Compare commits

..

11 Commits

Author SHA1 Message Date
zhangli
f0eaed3354 fix(plugin): create temp dir in project path to avoid cross-filesystem EXDEV on Rename
pluginInstallLocal used os.MkdirTemp("") which creates the temp
directory on the system temp partition. On Windows (and some
Linux/macOS setups), the temp partition is on a different filesystem
from the project directory, causing os.Rename to fail with EXDEV.

Use projectPath as the temp dir parent so it is always on the same
filesystem as node_modules.
2026-06-30 18:05:11 +08:00
zhangli
2424d05b01 fix(plugin): harden plugin commands against path traversal, DoS, and agent misuse
Security fixes from PR #1596 security audit:
- Skip symlink/hardlink entries during tgz extraction (Zip Slip)
- Limit tgz entry and download size to 10 MB (OOM/DoS)
- Limit error response body read to 4 KB
- Validate MIAODA_APP_TYPE as numeric to prevent path manipulation
- Add validatePluginKey + secureModulePath to block --name path
  traversal (../../.ssh etc.) for install/uninstall

Usability fix:
- Add explicit 'local command, no --app-id' notice in plugin
  reference docs to prevent agent from incorrectly passing
  --app-id to plugin commands (which read package.json locally)
2026-06-30 17:35:13 +08:00
anngo-nk
cf9e8d512d fix(plugin): fix nolint directive format and nilerr placement in plugin_common.go (#1668)
- Change nolint comment separator from -- to // to satisfy nolintlint
- Move nilerr nolint directive to return statement to suppress nilerr correctly
- Fix forbidigo nolint format for intermediate fmt.Errorf in pluginExtractTGZ
2026-06-30 14:37:58 +08:00
lvxinsheng
9d7f1e4e6b style: gofmt apps_plugin list/uninstall/install_test files
Fix fast-gate Check formatting failure: align struct literal fields in
apps_plugin_list.go and apps_plugin_uninstall.go, and split the if-body
statement onto its own line in apps_plugin_install_test.go.
2026-06-30 14:17:33 +08:00
anngo-nk
be7c05cc97 fix(plugin): resolve CI lint, deadcode, and unit-test failures (#1667)
- Add Scopes: []string{} to plugin-install, plugin-list, plugin-uninstall
  shortcuts to satisfy TestAllShortcutsScopesNotNil
- Remove unused pluginCheckInstalled function (deadcode)
- Fix nilerr: add //nolint:nilerr for intentional best-effort nil returns
- Fix forbidigo: replace bare fmt.Errorf in Execute with typed error,
  add //nolint:forbidigo for intermediate helper errors in pluginExtractTGZ
- Fix errorlint: change %v to %w for cerr in multi-error fmt.Errorf
- Remove all unused //nolint:forbidigo directives from test files
2026-06-30 14:04:38 +08:00
zhangli
9a85ffb4d2 style: gofmt apps plugin files (#1664) 2026-06-30 12:06:33 +08:00
anngo-nk
ff65e614e7 fix(plugin): rename files to apps_ prefix and handle Close() errors (#1655)
- Rename plugin_install/list/uninstall .go files to apps_plugin_ prefix
  for consistency with other files in the package
- Handle f.Close() errors in pluginExtractTGZ to avoid silent data loss
2026-06-30 11:07:14 +08:00
lvxinsheng
9f2fe50f4a feat(apps): add release polling interval time and release time costs 2026-06-29 20:03:51 +08:00
anngo-nk
7d1164dcb4 feat(plugin): add plugin package management commands (#1609)
* feat: add plugin package and instance management commands for apps domain

Add 8 new shortcut commands under `lark-cli apps`:

Plugin package management (aligned with fullstack-cli):
- +plugin-install: download tgz, extract to node_modules, update package.json
- +plugin-uninstall: remove from node_modules and package.json actionPlugins
- +plugin-list: list declared plugins with installation status

Plugin instance CRUD (aligned with feida-ai):
- +plugin-instance-create: validate + write capability JSON with formValue validation
- +plugin-instance-update: merge mutable fields, re-validate formValue
- +plugin-instance-delete: idempotent file removal
- +plugin-instance-get: read capability JSON
- +plugin-instance-list: scan capabilities directory

Shared infrastructure (plugin_common.go):
- 4-level capabilities dir resolution (flag → env → .env.local MIAODA_APP_TYPE → detection)
- formValue validation ported from feida-ai (5 rules: forbidden Handlebars, paramsSchema
  type constraints, input ref existence, unconsumed params, array double-wrap auto-fix)
- tgz extraction with path traversal protection
- package.json actionPlugins management
- Install version check with mismatch warnings

* fix: close install gaps aligned with fullstack-cli

- latest version: re-check installed version after API resolves, skip
  download when already up to date
- actionPlugins sync: ensure package.json record is updated even when
  install is skipped (already_installed path)
- peerDependencies: warn about missing peer deps after extraction
  instead of silently ignoring them

* feat: add +plugin-instance-types command and auto-generate on create/update

Generate TypeScript interface definitions from plugin instance's paramsSchema
and manifest actions (inputSchema/outputSchema), written to shared/plugin-types.ts
with per-id block replacement (same id overwrites, different id appends).

Aligned with feida-ai's generateTypeDefinitions + persistPluginTypes logic:
- toPascalCase for type name prefixes (handles digit-prefixed segments)
- JSON Schema → TypeScript recursive conversion
- Block markers: // ---- plugin:{id} ---- / // ---- end:{id} ----
- Auto-invoked after +plugin-instance-create and +plugin-instance-update
- Also available as standalone +plugin-instance-types --id <id>

* fix: hide +plugin-instance-types from agent (auto-invoked by create/update)

* feat: add plugin skill files for agent workflow guidance

- lark-apps-plugin.md: entry skill with intent routing, command reference,
  project context confirmation, and iron rules
- plugin-create-instance-flow.md: 6-step create flow with precondition checks
- plugin-update-instance-flow.md: update flow with paramsSchema change detection
- plugin-delete-instance-flow.md: delete flow with code reference scanning
- plugin-get-instance-flow.md: query routing for list/get/manifest reads
- plugin-instance-schema.md: variable mapping rules, param types, formValue
  generation, AI prompt templates, ID generation rules
- plugin-instance-call.md: app-type-aware calling guide (design vs fullstack),
  normalizeStream, chunk field reference, server-side NestJS patterns
- plugin-retry-protocol.md: validation failure retry protocol (max 3)
- SKILL.md: add plugin intent route with trigger keywords

* feat: add --local flag to +plugin-install for local tgz installation

Supports installing plugin packages from local .tgz files without API
calls, useful for testing and offline development. Reads plugin key and
version from the extracted package.json inside the tgz.

Also moved Scopes to ConditionalScopes so --local path skips auth.

* fix: improve error messages for plugin install and check

- pluginCheckInstalled: distinguish "directory not exist" (not installed)
  vs "directory exists but manifest.json missing" (not built correctly),
  with specific hints for each case
- pluginResolveVersion: detect non-JSON API response (typically HTML 404
  from unregistered endpoint) and give clear "API not available" message
  instead of misleading "check plugin key spelling"
- Hide --local flag from help (dev/test only, not for agents)

* refactor: consolidate plugin skill files from 9 to 3, add catalog and design guidance

- Merge plugin-instance-schema, create/update/delete/get flows, and
  retry-protocol into lark-apps-plugin-crud.md (Schema + CRUD + retry)
- Merge plugin-catalog into lark-apps-plugin.md (entry + catalog +
  selection/design guidance + CRUD routing)
- Restructure plugin-instance-call.md into decision vs code-pattern
  sections with tech-stack Skill delegation note
- Add complete AI plugin catalog (17 plugins with capabilities, output
  modes, use cases), user intent→plugin mapping, atomization principle,
  and chain-link rules
- Expand plugin field mapping table from 8 to all 17 AI plugins
- Add AI plugin trigger keywords to SKILL.md description for host agent
  skill matching
- Rename files to lark-apps-plugin-* prefix for consistency

* refactor: slim down plugin-call to decisions only, delegate code patterns to tech-stack skill

Remove all code pattern content (capabilityClient imports, normalizeStream,
NestJS injection, streaming examples, chunk field table) from
lark-apps-plugin-call.md. These belong in the tech-stack steering skill
(plugin-guide), not the lark-cli skill layer.

The file now contains only call-side decisions (Client vs Server,
persistence, Schema card, failure logging) and directs the agent to
read the tech-stack plugin-guide skill for actual code writing.

* fix: use absolute project-path for tech-stack skill location in plugin-call

Replace relative .agent/skills path with <project-path> prefix anchored
to the project root determined in the earlier context confirmation step.
Add fallback path and minimal call rules when skill file doesn't exist.

* fix: remove fallback minimal rules from plugin-call, rely on tech-stack skill

* fix: require reading project plugin-guide skill before writing call code

* fix: improve plugin error hints for AI agent friendliness

- Version mismatch warning now includes the exact +plugin-install
  command to update
- Batch install (+plugin-install without --name) now re-installs
  when declared version differs from installed version
- Remove --local flag from user-facing error hints (internal-only)

* docs: add plugin package ≠ npm package distinction to skill docs

Add a comparison table and iron law #6 to prevent agents from confusing
+plugin-install with npm install, which was a recurring failure in
multi-model evaluation.

* fix: block plugin uninstall when instances still reference the package

Add pluginCheckDependentInstances to scan capabilities/ for instances
that reference the plugin being uninstalled. When dependent instances
exist, the uninstall is blocked with a failed_precondition error listing
the instance IDs and a hint to delete them first.

* fix: update plugin API paths to match new OpenAPI gateway routes

- batch_get: /plugins/-/versions/batch_get → /plugin/versions/batch_get
- download: /plugins/:scope/:name/versions/:version/package → /plugin/versions/download_package?plugin_key=&version=

* fix: update plugin install to match final OpenAPI gateway protocol

- batch_query: URL /plugin/versions/batch_query, request uses plugin_keys
  array + latest_only boolean, response uses flat data.items list with
  plugin_key/plugin_version fields
- download: changed from GET+query to POST+JSON body {plugin_key, plugin_version},
  response is binary tgz stream (supportFileDownload)
- scope: spark:plugin:readonly → spark:app:read

* fix: align dry-run output with new batch_query + download_package request format

* fix: match actual API response field names (key/version instead of plugin_key/plugin_version)

* docs: strengthen plugin reference reading rules from advisory to mandatory

Change lark-apps-plugin.md from implicit to explicit required reading
for any plugin work. Replace soft '按需读' with bold '必读' for all three
plugin reference files. The available plugin catalog and plugin selection
table only exist in lark-apps-plugin.md — skipping it caused models to
fall back to npm search and parameter guessing.

* fix: remove call example annotation from types, add skill reference instead

* refactor: streamline plugin skill files

* refactor: 插件 PE 下沉到仓库,lark-cli 侧精简为命令参考

- 删除旧的 3 个插件 reference(plugin.md / plugin-crud.md / plugin-call.md),
  其中的 Schema 规则、CRUD 流程、插件目录、Prompt 模板等内容已下沉到
  应用仓库 .agents/skills/plugin-guide/SKILL.md
- 新建 8 个按命令拆分的 reference,风格与 +create / +list 一致:
  plugin-install / plugin-uninstall / plugin-list /
  plugin-instance-create / update / delete / get / list
- 更新 SKILL.md:description 泛化触发词(不再列举 17 个具体能力),
  意图路由引导先读仓库 Skill 再看 CLI 命令参考

* fix(plugin):simplify skill docs and resolve plugin version from actionPlugins

Remove redundant skill documentation (pre-check table, validation error
examples, JSON return samples, fullstack-cli references) that duplicate
CLI error hints.  Make --plugin version optional and resolve from
package.json actionPlugins.  Drop unused createdBy field.

* fix: 去掉 reference 中的具体插件名和参数示例,强制 agent 读仓库 Skill

- 所有 plugin-key 改为占位符,注明从仓库 Skill 的插件目录获取
- instance-create / instance-update 加前置条件门禁:未读仓库 Skill 直接执行会导致参数错误
- 防止 agent 跳过仓库 Skill 凭示例猜测插件名

* fix(plugin): resolve real paths in dry-run output for instance commands

Replace <capabilities_dir> placeholders with resolved paths so models
can see actual file locations before execution. Add version_source,
types_output, and scan_dir fields to describe implicit behaviors.

* refactor(plugin): hide instance commands, delegate to repo Skill

Hide +plugin-instance-create/update/delete/get/list from CLI help.
Remove instance reference files from lark-apps skill. Route instance
CRUD and call code generation to project repo plugin-guide skill.

Go instance code preserved, just hidden.

* refactor: 删除 plugin-instance 5 个 CLI 命令,改由仓库 Skill 引导 agent 直接操作文件

- 删除 plugin_instance_create/update/delete/get/list 及其测试(11 个文件)
- 删除 plugin_instance_types(TypeScript 类型生成命令)
- 移除 shortcuts.go 中的 6 个注册项
- 清理 plugin_common.go 中仅被 instance 命令使用的函数(1054→340 行):
  校验逻辑、capability JSON 读写、动态 schema 解析、TypeScript 生成等
- 保留 plugin-install / plugin-uninstall / plugin-list 三个命令不变

插件实例的 CRUD 操作改由仓库 Skill 引导 agent 直接读写 capabilities/*.json,
验证规则写在 Skill 中由 agent 自校验。

* refactor(plugin): remove --project-path flag and split --name into --name + --version

- Remove --project-path from plugin-install/list/uninstall (use cwd like npm)
- Split --name key@version into separate --name and --version flags
- Remove pluginParseInstallTarget (no longer needed)
- Improve DryRun desc and error hints for --version usage
- Update skill docs to reflect new flag structure
- Tests use chdirTest helper instead of --project-path

* feat(plugin): add Examples to --help for plugin-install/list/uninstall

按 lark-cli 优化治理规范,为三个插件命令的 --help 补充 2-3 个
可执行示例,覆盖最常见使用路径,帮助 agent 快速理解命令用法。

* fix(plugin): address PR #1609 review findings

- Fix hint referencing non-existent +plugin-instance-delete command,
  point to repo plugin-guide Skill instead
- Remove undeclared --capabilities-dir flag, simplify pluginResolveCapDir
  to env-only resolution, fix ambiguous hint to suggest env vars
- Reclassify download errors from file_io to network/api with proper
  hints and retryable marking
- Slim SKILL.md routing row, move judgment rules to plugin-install reference
- Rename --local flag to --file to align with CLI conventions

* fix(skill): restore plugin routing row with judgment rules, fix markdown formatting

Revert SKILL.md routing row to keep full judgment rules and repo Skill
directive inline. Fix bold marker spacing and restore missing table column.
Revert reference to original content without duplicated rules.

* fix(plugin): revert SKILL.md to pre-review version, fix shortcut count test

Restore SKILL.md plugin routing row to original version with full
judgment rules and repo Skill directive. Update shortcut count test
from 60 to 63 to account for 3 new plugin commands.

* fix(plugin):fix lark-apps skill docs which is about plugin

* fix(plugin):correct plugin skill md

* fix(plugin):correct plugin md

* fix(plugin):correct plugin and local dev skills md

* fix(plugin):correct apps plugin skills md

* fix(lark-apps): move repo skill reading hint to post-init phase

将「仓库 Skill 优先」从 SKILL.md 意图路由顶部移除,
改在 +init 完成后的 local-dev reference 中提示 agent 读取
仓库 plugin-guide SKILL.md,解决应用未初始化时 repo skill
不存在导致 agent 无法获取插件知识的时序问题。

* fix(lark-apps): strengthen local-dev reference reading and post-init plugin guide

- SKILL.md 路由表:local-dev.md 从"按需读取"提升为"执行前必读"
- local-dev.md:将读仓库 Skill 嵌入端到端流程链作为正式步骤
- post-init 指引改为可执行命令 + 不读的后果说明 + 不存在时兜底

---------

Co-authored-by: zhangli <zhangli.268@bytedance.com>
2026-06-29 11:22:01 +08:00
wangwei
2362437de9 fix: improve env-pull dev database hint (#1614) 2026-06-26 17:57:48 +08:00
陈兴炀
8a5c1dc547 test(apps): align db dry-run e2e with --environment rename and dev default
db dry-run tests still used the removed --env flag and asserted the old
online default, breaking the Run dry-run E2E tests CI step after the
--environment hard rename and dev-default change. Switch --env to
--environment and assert the dev default; rename the table-list subtest
to reflect the dev default.
2026-06-26 17:18:27 +08:00
17 changed files with 200 additions and 81 deletions

View File

@@ -83,7 +83,7 @@ var AppsEnvPull = common.Shortcut{
data, err := rctx.CallAPITyped("POST", envPullVarsPath(appID), nil, envPullVarsBody())
if err != nil {
return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`")
return withAppsHint(err, envPullAPIErrorHint(err, appID))
}
envVars, databaseInfo, skippedKeys, err := extractEnvPullVars(data)
@@ -126,6 +126,27 @@ func envPullVarsBody() map[string]interface{} {
}
}
func envPullAPIErrorHint(err error, appID string) string {
if isEnvPullDevDBNotInitializedError(err) {
appID = strings.TrimSpace(appID)
if appID == "" {
appID = "<app_id>"
}
return fmt.Sprintf("dev database is not initialized; preview creation with `lark-cli apps +db-env-create --app-id %s --environment dev --dry-run`, then run `lark-cli apps +db-env-create --app-id %s --environment dev --sync-data --yes` after confirming the irreversible split", appID, appID)
}
return appIDListHint
}
func isEnvPullDevDBNotInitializedError(err error) bool {
p, ok := errs.ProblemOf(err)
if !ok {
return false
}
message := strings.ToLower(p.Message)
return strings.Contains(message, "multi-environment database is not initialized") ||
(strings.Contains(message, "invalid db branch") && strings.Contains(message, "dev"))
}
func resolveEnvPullTarget(projectPath string) (string, string, error) {
if strings.TrimSpace(projectPath) == "" {
cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded.

View File

@@ -592,6 +592,38 @@ func TestAppsEnvPull_NonObjectJSONDoesNotCarryAppIDHint(t *testing.T) {
}
}
func TestAppsEnvPull_DevDBNotInitializedHintPointsToDBEnvCreate(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
Body: map[string]interface{}{
"code": -1,
"msg": "Multi-environment database is not initialized for this app. Invalid DB Branchdev",
},
OnMatch: func(req *http.Request) {
assertEnvPullBody(t, req)
},
})
err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--app-id", "app_x", "--project-path", t.TempDir(), "--as", "user"},
factory, stdout,
)
p := requireAppsAPIProblem(t, err)
if p.Code != -1 {
t.Fatalf("code = %d, want -1", p.Code)
}
for _, want := range []string{"+db-env-create", "--app-id app_x", "--environment dev", "--dry-run", "--yes"} {
if !strings.Contains(p.Hint, want) {
t.Fatalf("hint missing %q: %q", want, p.Hint)
}
}
if strings.Contains(p.Hint, "apps +list") {
t.Fatalf("hint should not point to app-id/list recovery for missing dev database: %q", p.Hint)
}
}
func TestAppsEnvPull_ExecuteUsesArrayEnvVars(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
projectDir := t.TempDir()

View File

@@ -31,6 +31,7 @@ var AppsPluginInstall = common.Shortcut{
Description: "Install a plugin package (download, extract, update package.json)",
Risk: "write",
ConditionalScopes: []string{"spark:app:read"},
Scopes: []string{},
AuthTypes: []string{"user"},
Tips: []string{
"Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate",
@@ -67,6 +68,11 @@ var AppsPluginInstall = common.Shortcut{
if err != nil {
return err
}
if key := strings.TrimSpace(rctx.Str("name")); key != "" {
if err := validatePluginKey(key); err != nil {
return err
}
}
return pluginCheckProjectDir(projectPath)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -133,7 +139,10 @@ func pluginInstallOne(ctx context.Context, rctx *common.RuntimeContext, projectP
}
// Extract to node_modules
destDir := filepath.Join(projectPath, "node_modules", key)
destDir, err := secureModulePath(projectPath, key)
if err != nil {
return err
}
if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; clean before extract.
return appsFileIOError(err, "cannot clean %s", destDir)
}
@@ -195,7 +204,7 @@ func pluginInstallAll(ctx context.Context, rctx *common.RuntimeContext, projectP
continue
}
if err := pluginInstallOne(ctx, rctx, projectPath, key, version); err != nil {
return fmt.Errorf("install %s: %w", key, err)
return errs.NewInternalError(errs.SubtypeUnknown, "install %s failed", key).WithCause(err)
}
installed++
}
@@ -217,7 +226,7 @@ func pluginInstallLocal(rctx *common.RuntimeContext, projectPath, tgzPath string
}
// Extract to a temp dir first to read package.json
tmpDir, err := os.MkdirTemp("", "plugin-local-*") //nolint:forbidigo
tmpDir, err := os.MkdirTemp(projectPath, ".plugin-tmp-*") //nolint:forbidigo // same FS as node_modules to avoid EXDEV on Rename
if err != nil {
return appsFileIOError(err, "cannot create temp dir")
}
@@ -246,7 +255,10 @@ func pluginInstallLocal(rctx *common.RuntimeContext, projectPath, tgzPath string
}
// Move to node_modules
destDir := filepath.Join(projectPath, "node_modules", key)
destDir, err := secureModulePath(projectPath, key)
if err != nil {
return err
}
if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo
return appsFileIOError(err, "cannot clean %s", destDir)
}
@@ -354,6 +366,9 @@ func pluginFindVersionInItems(data map[string]interface{}, key, version string)
// pluginDownloadPackage downloads a plugin .tgz via the download_package API.
// The endpoint is POST with JSON body {plugin_key, plugin_version}.
const pluginDownloadMaxBytes = 10 * 1024 * 1024
func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key, version string) ([]byte, error) {
apiPath := apiBasePath + "/plugin/versions/download_package"
body, _ := json.Marshal(map[string]string{
@@ -379,7 +394,7 @@ func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key
WithRetryable()
}
if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(resp.Body)
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
hint := "check plugin key and version spelling"
if resp.StatusCode == 403 {
hint = "download token may have expired; retry the install to get a fresh token"
@@ -389,5 +404,16 @@ func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key
return nil, errs.NewAPIError(errs.SubtypeUnknown, "download failed for %s@%s: HTTP %d: %s", key, version, resp.StatusCode, string(respBody)).
WithHint(hint)
}
return io.ReadAll(resp.Body)
data, err := io.ReadAll(io.LimitReader(resp.Body, pluginDownloadMaxBytes+1))
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed for %s@%s: %v", key, version, err).
WithHint("check network connectivity and retry").
WithRetryable().
WithCause(err)
}
if len(data) > pluginDownloadMaxBytes {
return nil, errs.NewAPIError(errs.SubtypeUnknown, "plugin package %s@%s exceeds %d MB size limit", key, version, pluginDownloadMaxBytes/(1024*1024)).
WithHint("contact plugin maintainer to reduce package size")
}
return data, nil
}

View File

@@ -63,7 +63,7 @@ func TestPluginInstall_SinglePlugin(t *testing.T) {
// Verify file extracted
manifestPath := filepath.Join(dir, "node_modules", "@test/my-plugin", "manifest.json")
if _, err := os.Stat(manifestPath); err != nil { //nolint:forbidigo
if _, err := os.Stat(manifestPath); err != nil {
t.Fatalf("manifest.json not extracted: %v", err)
}
@@ -92,8 +92,8 @@ func TestPluginInstall_AlreadyInstalled(t *testing.T) {
})
// Create an existing installed plugin with package.json containing version
pkgDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
os.MkdirAll(pkgDir, 0o755) //nolint:forbidigo
os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) //nolint:forbidigo
os.MkdirAll(pkgDir, 0o755)
os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644)
chdirTest(t, dir)
factory, stdout, _ := newAppsExecuteFactory(t)
@@ -126,7 +126,7 @@ func TestPluginExtractTGZ(t *testing.T) {
t.Fatalf("extract error: %v", err)
}
data, err := os.ReadFile(filepath.Join(destDir, "manifest.json")) //nolint:forbidigo
data, err := os.ReadFile(filepath.Join(destDir, "manifest.json"))
if err != nil {
t.Fatalf("manifest.json not extracted: %v", err)
}
@@ -153,7 +153,7 @@ func TestPluginExtractTGZ_PathTraversal(t *testing.T) {
if err := pluginExtractTGZ(&buf, destDir); err != nil {
t.Fatalf("extract should not error, but skip bad entries: %v", err)
}
if _, err := os.Stat(filepath.Join(destDir, "..", "..", "etc", "passwd")); err == nil { //nolint:forbidigo
if _, err := os.Stat(filepath.Join(destDir, "..", "..", "etc", "passwd")); err == nil {
t.Error("path traversal should have been blocked")
}
}

View File

@@ -17,8 +17,9 @@ import (
var AppsPluginList = common.Shortcut{
Service: appsService,
Command: "+plugin-list",
Description: "List declared plugin packages and their installation status",
Description: "List locally installed plugin packages and their installation status",
Risk: "read",
Scopes: []string{},
Tips: []string{
"Example: lark-cli apps +plugin-list",
"Example: lark-cli apps +plugin-list --format pretty",

View File

@@ -40,8 +40,8 @@ func TestPluginList_Installed(t *testing.T) {
},
})
manifestDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
os.MkdirAll(manifestDir, 0o755) //nolint:forbidigo
os.WriteFile(filepath.Join(manifestDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) //nolint:forbidigo
os.MkdirAll(manifestDir, 0o755)
os.WriteFile(filepath.Join(manifestDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644)
chdirTest(t, dir)
factory, stdout, _ := newAppsExecuteFactory(t)
@@ -99,14 +99,14 @@ func TestPluginList_DeclaredNotInstalled(t *testing.T) {
func chdirTest(t *testing.T, dir string) {
t.Helper()
prev, err := os.Getwd() //nolint:forbidigo
prev, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil { //nolint:forbidigo
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chdir(prev) }) //nolint:forbidigo,errcheck
t.Cleanup(func() { os.Chdir(prev) }) //nolint:errcheck
}
func writeTestPkgJSON(t *testing.T, dir string, pkg map[string]interface{}) {
@@ -115,7 +115,7 @@ func writeTestPkgJSON(t *testing.T, dir string, pkg map[string]interface{}) {
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "package.json"), data, 0o644); err != nil { //nolint:forbidigo
if err := os.WriteFile(filepath.Join(dir, "package.json"), data, 0o644); err != nil {
t.Fatal(err)
}
}

View File

@@ -8,7 +8,6 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/larksuite/cli/shortcuts/common"
@@ -21,6 +20,7 @@ var AppsPluginUninstall = common.Shortcut{
Command: "+plugin-uninstall",
Description: "Uninstall a plugin package (remove from node_modules and package.json)",
Risk: "write",
Scopes: []string{},
Tips: []string{
"Example: lark-cli apps +plugin-uninstall --name @official-plugins/ai-text-generate",
},
@@ -37,8 +37,10 @@ var AppsPluginUninstall = common.Shortcut{
Set("update_file", "package.json actionPlugins")
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("name")) == "" {
if key := strings.TrimSpace(rctx.Str("name")); key == "" {
return appsValidationParamError("--name", "--name is required")
} else if err := validatePluginKey(key); err != nil {
return err
}
projectPath, err := pluginResolveProjectPath("")
if err != nil {
@@ -58,7 +60,10 @@ var AppsPluginUninstall = common.Shortcut{
return err
}
pkgDir := filepath.Join(projectPath, "node_modules", key)
pkgDir, err := secureModulePath(projectPath, key)
if err != nil {
return err
}
if err := os.RemoveAll(pkgDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; remove plugin directory.
return appsFileIOError(err, "cannot remove %s", pkgDir)
}

View File

@@ -20,8 +20,8 @@ func TestPluginUninstall_Basic(t *testing.T) {
},
})
pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo
os.MkdirAll(pluginDir, 0o755)
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644)
chdirTest(t, dir)
factory, stdout, _ := newAppsExecuteFactory(t)
@@ -34,7 +34,7 @@ func TestPluginUninstall_Basic(t *testing.T) {
}
// Verify node_modules removed
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) { //nolint:forbidigo
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) {
t.Error("node_modules plugin dir should be removed")
}
@@ -77,12 +77,12 @@ func TestPluginUninstall_BlockedByDependentInstance(t *testing.T) {
})
// Install plugin
pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo
os.MkdirAll(pluginDir, 0o755)
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644)
// Create a capability that references this plugin
capDir := filepath.Join(dir, "server", "capabilities")
os.MkdirAll(capDir, 0o755) //nolint:forbidigo
os.MkdirAll(capDir, 0o755)
writeTestCapJSON(t, capDir, "my-instance.json", map[string]interface{}{
"id": "my-instance",
"pluginKey": "@test/my-plugin",
@@ -100,7 +100,7 @@ func TestPluginUninstall_BlockedByDependentInstance(t *testing.T) {
}
// Verify plugin directory still exists (blocked)
if _, err := os.Stat(pluginDir); err != nil { //nolint:forbidigo
if _, err := os.Stat(pluginDir); err != nil {
t.Errorf("plugin directory should still exist after blocked uninstall: %v", err)
}
@@ -125,12 +125,12 @@ func TestPluginUninstall_WithUnrelatedInstances(t *testing.T) {
},
})
pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo
os.MkdirAll(pluginDir, 0o755)
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644)
// Create a capability that references a DIFFERENT plugin — should not block
capDir := filepath.Join(dir, "server", "capabilities")
os.MkdirAll(capDir, 0o755) //nolint:forbidigo
os.MkdirAll(capDir, 0o755)
writeTestCapJSON(t, capDir, "other-instance.json", map[string]interface{}{
"id": "other-instance",
"pluginKey": "@test/other-plugin",
@@ -148,7 +148,7 @@ func TestPluginUninstall_WithUnrelatedInstances(t *testing.T) {
}
// Verify plugin was removed
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) { //nolint:forbidigo
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) {
t.Error("plugin directory should be removed")
}
}

View File

@@ -11,6 +11,7 @@ import (
"io"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
@@ -50,13 +51,48 @@ func pluginCheckProjectDir(projectPath string) error {
return nil
}
// validatePluginKey validates a plugin key for use in filesystem paths.
// Rejects empty, ".", "..", absolute paths, path traversal, and control characters.
func validatePluginKey(key string) error {
if key == "" || key == "." || key == ".." {
return appsValidationError("invalid plugin key: must not be empty, \".\", or \"..\"")
}
if filepath.IsAbs(key) {
return appsValidationError("invalid plugin key: must not be an absolute path: %q", key)
}
if strings.Contains(key, "..") {
return appsValidationError("invalid plugin key: must not contain path traversal: %q", key)
}
for _, r := range key {
if r < 32 || r == 127 {
return appsValidationError("invalid plugin key: contains control character (code %d)", r)
}
}
return nil
}
// secureModulePath validates the plugin key and joins it with
// projectPath/node_modules, asserting the result stays within node_modules.
func secureModulePath(projectPath, key string) (string, error) {
if err := validatePluginKey(key); err != nil {
return "", err
}
nodeModules := filepath.Join(projectPath, "node_modules")
resolved := filepath.Clean(filepath.Join(nodeModules, key))
expectedPrefix := filepath.Clean(nodeModules) + string(filepath.Separator)
if !strings.HasPrefix(resolved+string(filepath.Separator), expectedPrefix) {
return "", appsValidationError("plugin key %q resolves outside node_modules", key)
}
return resolved, nil
}
// pluginResolveCapDir resolves the capabilities directory using a 3-level fallback:
// 1. MIAODA_CAPABILITIES_DIR env var
// 2. MIAODA_APP_TYPE env var (2→server/capabilities, 6→shared/capabilities)
// 2.5 Read .env.local for MIAODA_APP_TYPE
// 3. Detect by checking which directories exist under projectPath
func pluginResolveCapDir(projectPath string) (string, error) {
if dir := os.Getenv("MIAODA_CAPABILITIES_DIR"); dir != "" { //nolint:forbidigo // env-based config lookup is intentional.
if dir := os.Getenv("MIAODA_CAPABILITIES_DIR"); dir != "" {
if filepath.IsAbs(dir) {
return dir, nil
}
@@ -64,10 +100,16 @@ func pluginResolveCapDir(projectPath string) (string, error) {
}
// 2. MIAODA_APP_TYPE: only appType=6 (Modern) uses shared/; everything else uses server/
appType := os.Getenv("MIAODA_APP_TYPE") //nolint:forbidigo // env-based config lookup is intentional.
appType := os.Getenv("MIAODA_APP_TYPE")
if appType == "" {
appType = pluginReadEnvLocalValue(projectPath, "MIAODA_APP_TYPE")
}
if appType != "" {
if _, err := strconv.Atoi(appType); err != nil {
return "", appsValidationError("MIAODA_APP_TYPE must be a number, got %q", appType).
WithHint("set MIAODA_APP_TYPE to a valid numeric value in .env.local")
}
}
if appType == "6" {
return filepath.Join(projectPath, "shared", "capabilities"), nil
}
@@ -157,13 +199,11 @@ func pluginListCapabilities(capDir string) ([]map[string]interface{}, error) {
func pluginCheckDependentInstances(projectPath, pluginKey string) error {
capDir, err := pluginResolveCapDir(projectPath)
if err != nil {
// No capabilities directory → no instances can exist → no conflict.
return nil
return nil //nolint:nilerr // best-effort: no capabilities dir means no conflict
}
caps, err := pluginListCapabilities(capDir)
if err != nil {
// Cannot scan → best-effort, don't block.
return nil
return nil //nolint:nilerr // best-effort: scan failure should not block uninstall
}
var deps []string
for _, cap := range caps {
@@ -181,26 +221,6 @@ func pluginCheckDependentInstances(projectPath, pluginKey string) error {
).WithHint("delete these instances first (see <project-path>/.agents/skills/plugin-guide/SKILL.md for instance removal steps), clean up calling code and types, then retry uninstall")
}
// pluginCheckInstalled verifies that the plugin package is installed in node_modules
// with a valid manifest.json.
func pluginCheckInstalled(projectPath, pluginKey string) error {
pluginDir := filepath.Join(projectPath, "node_modules", pluginKey)
manifestPath := filepath.Join(pluginDir, "manifest.json")
if _, err := os.Stat(manifestPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; local stat for plugin check.
if os.IsNotExist(err) {
if pluginDirExists(pluginDir) {
return appsFailedPreconditionError(
"plugin %q exists in node_modules but manifest.json is missing; the package may not have been built correctly", pluginKey,
).WithHint("run 'lark-cli apps +plugin-install --name %s' to reinstall from registry", pluginKey)
}
return appsFailedPreconditionError("plugin %q is not installed", pluginKey).
WithHint("run 'lark-cli apps +plugin-install --name %s' to install", pluginKey)
}
return appsFileIOError(err, "cannot check plugin installation for %s", pluginKey)
}
return nil
}
// ── package.json helpers ──
// pluginReadPackageJSON reads and parses the project's package.json.
@@ -324,13 +344,15 @@ func pluginInstalledVersion(projectPath, pluginKey string) string {
// ── tgz extraction ──
const pluginExtractMaxBytes = 10 * 1024 * 1024
// pluginExtractTGZ extracts a gzipped tar archive into destDir, stripping the
// first path component (npm convention: tarballs contain a "package/" prefix).
// Path traversal entries are silently skipped.
func pluginExtractTGZ(r io.Reader, destDir string) error {
gz, err := gzip.NewReader(r)
if err != nil {
return fmt.Errorf("gzip: %w", err)
return fmt.Errorf("gzip: %w", err) //nolint:forbidigo // intermediate helper error; callers wrap as typed
}
defer gz.Close()
@@ -342,7 +364,7 @@ func pluginExtractTGZ(r io.Reader, destDir string) error {
break
}
if err != nil {
return fmt.Errorf("tar: %w", err)
return fmt.Errorf("tar: %w", err) //nolint:forbidigo // intermediate helper error; callers wrap as typed
}
name := pluginStripFirstComponent(hdr.Name)
@@ -360,6 +382,8 @@ func pluginExtractTGZ(r io.Reader, destDir string) error {
}
switch hdr.Typeflag {
case tar.TypeSymlink, tar.TypeLink:
continue
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; tgz extraction.
return err
@@ -372,11 +396,15 @@ func pluginExtractTGZ(r io.Reader, destDir string) error {
if err != nil {
return err
}
if _, err := io.Copy(f, tr); err != nil { //nolint:gosec // bounded by tar entry size
f.Close()
if _, err := io.Copy(f, io.LimitReader(tr, pluginExtractMaxBytes)); err != nil {
if cerr := f.Close(); cerr != nil {
return fmt.Errorf("copy tar entry: %w; close file: %w", err, cerr) //nolint:forbidigo // intermediate helper error; callers wrap as typed
}
return err
}
if err := f.Close(); err != nil {
return err
}
f.Close()
}
}
return nil

View File

@@ -19,7 +19,7 @@ func TestPluginResolveProjectPath_DefaultToCwd(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cwd, _ := os.Getwd() //nolint:forbidigo
cwd, _ := os.Getwd()
if got != cwd {
t.Errorf("got %q, want cwd %q", got, cwd)
}
@@ -39,7 +39,7 @@ func TestPluginResolveProjectPath_ExplicitPath(t *testing.T) {
func TestPluginCheckProjectDir_OK(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644); err != nil { //nolint:forbidigo
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
if err := pluginCheckProjectDir(dir); err != nil {
@@ -99,7 +99,7 @@ func TestPluginResolveCapDir_AppTypeEnvShared(t *testing.T) {
func TestPluginResolveCapDir_EnvLocal(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, ".env.local"), []byte("MIAODA_APP_TYPE=2\n"), 0o644); err != nil { //nolint:forbidigo
if err := os.WriteFile(filepath.Join(dir, ".env.local"), []byte("MIAODA_APP_TYPE=2\n"), 0o644); err != nil {
t.Fatal(err)
}
got, err := pluginResolveCapDir(dir)
@@ -113,7 +113,7 @@ func TestPluginResolveCapDir_EnvLocal(t *testing.T) {
func TestPluginResolveCapDir_DetectServer(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { //nolint:forbidigo
if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil {
t.Fatal(err)
}
got, err := pluginResolveCapDir(dir)
@@ -127,7 +127,7 @@ func TestPluginResolveCapDir_DetectServer(t *testing.T) {
func TestPluginResolveCapDir_DetectShared(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { //nolint:forbidigo
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil {
t.Fatal(err)
}
got, err := pluginResolveCapDir(dir)
@@ -141,10 +141,10 @@ func TestPluginResolveCapDir_DetectShared(t *testing.T) {
func TestPluginResolveCapDir_Ambiguous(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { //nolint:forbidigo
if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { //nolint:forbidigo
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil {
t.Fatal(err)
}
_, err := pluginResolveCapDir(dir)
@@ -210,7 +210,7 @@ func TestPluginListCapabilities_WithFiles(t *testing.T) {
writeTestCapJSON(t, dir, "cap1.json", map[string]interface{}{"id": "cap1", "name": "Cap One"})
writeTestCapJSON(t, dir, "cap2.json", map[string]interface{}{"id": "cap2", "name": "Cap Two"})
// non-JSON file should be skipped
if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("ignore"), 0o644); err != nil { //nolint:forbidigo
if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("ignore"), 0o644); err != nil {
t.Fatal(err)
}
@@ -226,7 +226,7 @@ func TestPluginListCapabilities_WithFiles(t *testing.T) {
func TestPluginListCapabilities_SkipsMalformed(t *testing.T) {
dir := t.TempDir()
writeTestCapJSON(t, dir, "good.json", map[string]interface{}{"id": "good"})
if err := os.WriteFile(filepath.Join(dir, "bad.json"), []byte("not json"), 0o644); err != nil { //nolint:forbidigo
if err := os.WriteFile(filepath.Join(dir, "bad.json"), []byte("not json"), 0o644); err != nil {
t.Fatal(err)
}
@@ -247,7 +247,7 @@ func writeTestCapJSON(t *testing.T, dir, filename string, data map[string]interf
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, filename), b, 0o644); err != nil { //nolint:forbidigo
if err := os.WriteFile(filepath.Join(dir, filename), b, 0o644); err != nil {
t.Fatal(err)
}
}

View File

@@ -1,5 +1,7 @@
# apps +plugin-install
> **本地命令**:读当前目录的 `package.json`,在项目根目录下运行(和 npm 一样)。**不接受 `--app-id`**——它不是远端 API 命令。
安装插件包到项目。运行时命令事实以 `lark-cli apps +plugin-install --help` 为准。
## 何时用

View File

@@ -1,5 +1,7 @@
# apps +plugin-list
> **本地命令**:读当前目录的 `package.json`,在项目根目录下运行(和 npm 一样)。**不接受 `--app-id`**——它不是远端 API 命令。
列出已声明的插件包及安装状态。运行时命令事实以 `lark-cli apps +plugin-list --help` 为准。
## 何时用

View File

@@ -1,5 +1,7 @@
# apps +plugin-uninstall
> **本地命令**:读当前目录的 `package.json`,在项目根目录下运行(和 npm 一样)。**不接受 `--app-id`**——它不是远端 API 命令。
卸载插件包。运行时命令事实以 `lark-cli apps +plugin-uninstall --help` 为准。
## 何时用

View File

@@ -22,7 +22,7 @@ lark-cli apps +release-create --app-id app_xxx --branch sprint/default --dry-run
## 输出契约
- 成功读取 `data.release_id``data.status``release_id` 是后续 `+release-get` 的入参。
- `status=publishing` 表示发布仍在进行;继续用 `+release-get` 轮询。
- `status=publishing` 表示发布仍在进行;继续用 `+release-get` 轮询,轮询间隔应该为 20s。应用发布平均耗时大约 2min整体超时时间大约 5min
- `+release-create` 返回 release 只代表发布已发起。只有 `+release-get` 对同一个 `release_id` 返回 `finished` 后,才能说本轮最新版本已部署。
## Agent 规则

View File

@@ -24,7 +24,7 @@ func TestAppsDBEnvCreateDryRun(t *testing.T) {
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+db-env-create", "--app-id", "app_x", "--env", "dev", "--dry-run"},
Args: []string{"apps", "+db-env-create", "--app-id", "app_x", "--environment", "dev", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
@@ -40,7 +40,7 @@ func TestAppsDBEnvCreateDryRun(t *testing.T) {
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+db-env-create", "--app-id", "app_x", "--env", "dev", "--sync-data", "--dry-run"},
Args: []string{"apps", "+db-env-create", "--app-id", "app_x", "--environment", "dev", "--sync-data", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)

View File

@@ -46,7 +46,7 @@ func TestAppsDBExecuteDryRun(t *testing.T) {
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+db-execute", "--app-id", "app_x", "--sql", "SELECT 1", "--env", "online", "--dry-run"},
Args: []string{"apps", "+db-execute", "--app-id", "app_x", "--sql", "SELECT 1", "--environment", "online", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)

View File

@@ -19,7 +19,7 @@ import (
func TestAppsDBTableListDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("DefaultsToOnlineAndPageSize20", func(t *testing.T) {
t.Run("DefaultsToDevAndPageSize20", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
@@ -32,7 +32,7 @@ func TestAppsDBTableListDryRun(t *testing.T) {
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/tables", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "online", gjson.Get(result.Stdout, "api.0.params.env").String())
assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.params.env").String())
assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.params.page_token").Exists(),
"empty page_token must be omitted")
@@ -46,7 +46,7 @@ func TestAppsDBTableListDryRun(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+db-table-list",
"--app-id", "app_x", "--env", "dev",
"--app-id", "app_x", "--environment", "dev",
"--page-size", "50", "--page-token", "cursor-abc",
"--dry-run"},
DefaultAs: "user",