Compare commits

..

42 Commits

Author SHA1 Message Date
zhangli
412dcba4b8 style: gofmt plugin files 2026-06-30 11:50:13 +08:00
anguohui
e5f66ce22e fix(lark-apps): strengthen local-dev reference reading and post-init plugin guide
- SKILL.md 路由表:local-dev.md 从"按需读取"提升为"执行前必读"
- local-dev.md:将读仓库 Skill 嵌入端到端流程链作为正式步骤
- post-init 指引改为可执行命令 + 不读的后果说明 + 不存在时兜底
2026-06-27 16:23:36 +08:00
anguohui
1d313a56b1 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 无法获取插件知识的时序问题。
2026-06-27 16:23:36 +08:00
zhangli
1864b7fae9 fix(plugin):correct apps plugin skills md 2026-06-26 21:31:56 +08:00
zhangli
22ae7ab04d fix(plugin):correct plugin and local dev skills md 2026-06-26 20:45:02 +08:00
zhangli
110107458a fix(plugin):correct plugin md 2026-06-26 19:26:24 +08:00
zhangli
e28a00c2fe fix(plugin):correct plugin skill md 2026-06-26 18:53:56 +08:00
zhangli
2f50e39203 fix(plugin):fix lark-apps skill docs which is about plugin 2026-06-26 18:31:40 +08:00
anguohui
b5d3e9896e 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.
2026-06-26 18:10:28 +08:00
anguohui
a552aed3bc 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.
2026-06-26 17:27:28 +08:00
anguohui
70aec2726b 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
2026-06-26 17:14:00 +08:00
anguohui
52894d095b merge: resolve conflicts with feat/apps-spark-capibilities
Keep both plugin commands and openapi-key commands in shortcuts registry;
merge SKILL.md descriptions to include both plugin and observability features.
2026-06-26 16:38:35 +08:00
anguohui
7810a01eba feat(plugin): add Examples to --help for plugin-install/list/uninstall
按 lark-cli 优化治理规范,为三个插件命令的 --help 补充 2-3 个
可执行示例,覆盖最常见使用路径,帮助 agent 快速理解命令用法。
2026-06-26 16:27:50 +08:00
anguohui
b33fe32718 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
2026-06-26 15:43:07 +08:00
anguohui
490006ee7b 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 自校验。
2026-06-25 22:02:30 +08:00
zhangli
4e2abab504 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.
2026-06-25 21:42:50 +08:00
zhangli
0ff2957c6e 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.
2026-06-25 16:21:46 +08:00
anguohui
41aefd63f0 fix: 去掉 reference 中的具体插件名和参数示例,强制 agent 读仓库 Skill
- 所有 plugin-key 改为占位符,注明从仓库 Skill 的插件目录获取
- instance-create / instance-update 加前置条件门禁:未读仓库 Skill 直接执行会导致参数错误
- 防止 agent 跳过仓库 Skill 凭示例猜测插件名
2026-06-25 12:07:02 +08:00
zhangli
09984fa92a 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.
2026-06-25 11:59:52 +08:00
anguohui
de5de57ced 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 命令参考
2026-06-25 11:43:31 +08:00
anguohui
911f584ab0 refactor: streamline plugin skill files 2026-06-24 23:57:47 +08:00
anguohui
08340bf3aa fix: remove call example annotation from types, add skill reference instead 2026-06-24 19:21:46 +08:00
zhangli
a99dc33195 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.
2026-06-24 18:35:52 +08:00
anguohui
bb891e0c50 fix: match actual API response field names (key/version instead of plugin_key/plugin_version) 2026-06-24 15:18:35 +08:00
anguohui
d5f65d1aa4 fix: align dry-run output with new batch_query + download_package request format 2026-06-24 12:21:24 +08:00
anguohui
5365cb97ab 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
2026-06-24 11:13:05 +08:00
anguohui
8037bd8037 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=
2026-06-24 11:13:05 +08:00
zhangli
dbc1c93b71 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.
2026-06-23 22:03:29 +08:00
zhangli
2beb110523 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.
2026-06-23 20:53:45 +08:00
anguohui
112183f447 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)
2026-06-23 20:38:26 +08:00
anguohui
3b9ee1af67 fix: require reading project plugin-guide skill before writing call code 2026-06-23 17:26:48 +08:00
anguohui
a5386f6053 fix: remove fallback minimal rules from plugin-call, rely on tech-stack skill 2026-06-23 12:03:49 +08:00
anguohui
d6c37232e6 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.
2026-06-23 12:02:12 +08:00
anguohui
999ac4e7d6 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.
2026-06-23 11:46:13 +08:00
anguohui
a91f2cdd85 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
2026-06-22 22:36:31 +08:00
anguohui
d80636d7da 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)
2026-06-22 20:12:01 +08:00
anguohui
0a999171be 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.
2026-06-22 11:48:28 +08:00
anguohui
1d9f102b36 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
2026-06-18 17:22:04 +08:00
anguohui
d7820f7c1f fix: hide +plugin-instance-types from agent (auto-invoked by create/update) 2026-06-18 17:19:22 +08:00
anguohui
b8f45c96d7 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>
2026-06-18 17:18:18 +08:00
anguohui
9dc032ca73 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
2026-06-18 16:18:20 +08:00
anguohui
e9f2da086f 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
2026-06-18 16:10:47 +08:00
17 changed files with 81 additions and 200 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, envPullAPIErrorHint(err, appID))
return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`")
}
envVars, databaseInfo, skippedKeys, err := extractEnvPullVars(data)
@@ -126,27 +126,6 @@ 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,38 +592,6 @@ 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

@@ -11,7 +11,6 @@ import (
"io"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
@@ -51,48 +50,13 @@ 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 != "" {
if dir := os.Getenv("MIAODA_CAPABILITIES_DIR"); dir != "" { //nolint:forbidigo // env-based config lookup is intentional.
if filepath.IsAbs(dir) {
return dir, nil
}
@@ -100,16 +64,10 @@ 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")
appType := os.Getenv("MIAODA_APP_TYPE") //nolint:forbidigo // env-based config lookup is intentional.
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
}
@@ -199,11 +157,13 @@ func pluginListCapabilities(capDir string) ([]map[string]interface{}, error) {
func pluginCheckDependentInstances(projectPath, pluginKey string) error {
capDir, err := pluginResolveCapDir(projectPath)
if err != nil {
return nil //nolint:nilerr // best-effort: no capabilities dir means no conflict
// No capabilities directory → no instances can exist → no conflict.
return nil
}
caps, err := pluginListCapabilities(capDir)
if err != nil {
return nil //nolint:nilerr // best-effort: scan failure should not block uninstall
// Cannot scan → best-effort, don't block.
return nil
}
var deps []string
for _, cap := range caps {
@@ -221,6 +181,26 @@ 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.
@@ -344,15 +324,13 @@ 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) //nolint:forbidigo // intermediate helper error; callers wrap as typed
return fmt.Errorf("gzip: %w", err)
}
defer gz.Close()
@@ -364,7 +342,7 @@ func pluginExtractTGZ(r io.Reader, destDir string) error {
break
}
if err != nil {
return fmt.Errorf("tar: %w", err) //nolint:forbidigo // intermediate helper error; callers wrap as typed
return fmt.Errorf("tar: %w", err)
}
name := pluginStripFirstComponent(hdr.Name)
@@ -382,8 +360,6 @@ 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
@@ -396,15 +372,11 @@ func pluginExtractTGZ(r io.Reader, destDir string) error {
if err != nil {
return err
}
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 {
if _, err := io.Copy(f, tr); err != nil { //nolint:gosec // bounded by tar entry size
f.Close()
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()
cwd, _ := os.Getwd() //nolint:forbidigo
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 {
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644); err != nil { //nolint:forbidigo
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 {
if err := os.WriteFile(filepath.Join(dir, ".env.local"), []byte("MIAODA_APP_TYPE=2\n"), 0o644); err != nil { //nolint:forbidigo
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 {
if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { //nolint:forbidigo
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 {
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { //nolint:forbidigo
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 {
if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { //nolint:forbidigo
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil {
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { //nolint:forbidigo
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 {
if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("ignore"), 0o644); err != nil { //nolint:forbidigo
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 {
if err := os.WriteFile(filepath.Join(dir, "bad.json"), []byte("not json"), 0o644); err != nil { //nolint:forbidigo
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 {
if err := os.WriteFile(filepath.Join(dir, filename), b, 0o644); err != nil { //nolint:forbidigo
t.Fatal(err)
}
}

View File

@@ -31,7 +31,6 @@ 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",
@@ -68,11 +67,6 @@ 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 {
@@ -139,10 +133,7 @@ func pluginInstallOne(ctx context.Context, rctx *common.RuntimeContext, projectP
}
// Extract to node_modules
destDir, err := secureModulePath(projectPath, key)
if err != nil {
return err
}
destDir := filepath.Join(projectPath, "node_modules", key)
if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; clean before extract.
return appsFileIOError(err, "cannot clean %s", destDir)
}
@@ -204,7 +195,7 @@ func pluginInstallAll(ctx context.Context, rctx *common.RuntimeContext, projectP
continue
}
if err := pluginInstallOne(ctx, rctx, projectPath, key, version); err != nil {
return errs.NewInternalError(errs.SubtypeUnknown, "install %s failed", key).WithCause(err)
return fmt.Errorf("install %s: %w", key, err)
}
installed++
}
@@ -226,7 +217,7 @@ func pluginInstallLocal(rctx *common.RuntimeContext, projectPath, tgzPath string
}
// Extract to a temp dir first to read package.json
tmpDir, err := os.MkdirTemp(projectPath, ".plugin-tmp-*") //nolint:forbidigo // same FS as node_modules to avoid EXDEV on Rename
tmpDir, err := os.MkdirTemp("", "plugin-local-*") //nolint:forbidigo
if err != nil {
return appsFileIOError(err, "cannot create temp dir")
}
@@ -255,10 +246,7 @@ func pluginInstallLocal(rctx *common.RuntimeContext, projectPath, tgzPath string
}
// Move to node_modules
destDir, err := secureModulePath(projectPath, key)
if err != nil {
return err
}
destDir := filepath.Join(projectPath, "node_modules", key)
if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo
return appsFileIOError(err, "cannot clean %s", destDir)
}
@@ -366,9 +354,6 @@ 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{
@@ -394,7 +379,7 @@ func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key
WithRetryable()
}
if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
respBody, _ := io.ReadAll(resp.Body)
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"
@@ -404,16 +389,5 @@ 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)
}
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
return io.ReadAll(resp.Body)
}

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 {
if _, err := os.Stat(manifestPath); err != nil { //nolint:forbidigo
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)
os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644)
os.MkdirAll(pkgDir, 0o755) //nolint:forbidigo
os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) //nolint:forbidigo
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"))
data, err := os.ReadFile(filepath.Join(destDir, "manifest.json")) //nolint:forbidigo
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 {
if _, err := os.Stat(filepath.Join(destDir, "..", "..", "etc", "passwd")); err == nil { //nolint:forbidigo
t.Error("path traversal should have been blocked")
}
}

View File

@@ -17,9 +17,8 @@ import (
var AppsPluginList = common.Shortcut{
Service: appsService,
Command: "+plugin-list",
Description: "List locally installed plugin packages and their installation status",
Description: "List declared 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)
os.WriteFile(filepath.Join(manifestDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644)
os.MkdirAll(manifestDir, 0o755) //nolint:forbidigo
os.WriteFile(filepath.Join(manifestDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) //nolint:forbidigo
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()
prev, err := os.Getwd() //nolint:forbidigo
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
if err := os.Chdir(dir); err != nil { //nolint:forbidigo
t.Fatal(err)
}
t.Cleanup(func() { os.Chdir(prev) }) //nolint:errcheck
t.Cleanup(func() { os.Chdir(prev) }) //nolint:forbidigo,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 {
if err := os.WriteFile(filepath.Join(dir, "package.json"), data, 0o644); err != nil { //nolint:forbidigo
t.Fatal(err)
}
}

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/larksuite/cli/shortcuts/common"
@@ -20,7 +21,6 @@ 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,10 +37,8 @@ var AppsPluginUninstall = common.Shortcut{
Set("update_file", "package.json actionPlugins")
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if key := strings.TrimSpace(rctx.Str("name")); key == "" {
if strings.TrimSpace(rctx.Str("name")) == "" {
return appsValidationParamError("--name", "--name is required")
} else if err := validatePluginKey(key); err != nil {
return err
}
projectPath, err := pluginResolveProjectPath("")
if err != nil {
@@ -60,10 +58,7 @@ var AppsPluginUninstall = common.Shortcut{
return err
}
pkgDir, err := secureModulePath(projectPath, key)
if err != nil {
return err
}
pkgDir := filepath.Join(projectPath, "node_modules", key)
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)
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644)
os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo
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) {
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) { //nolint:forbidigo
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)
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644)
os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo
// Create a capability that references this plugin
capDir := filepath.Join(dir, "server", "capabilities")
os.MkdirAll(capDir, 0o755)
os.MkdirAll(capDir, 0o755) //nolint:forbidigo
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 {
if _, err := os.Stat(pluginDir); err != nil { //nolint:forbidigo
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)
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644)
os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo
// Create a capability that references a DIFFERENT plugin — should not block
capDir := filepath.Join(dir, "server", "capabilities")
os.MkdirAll(capDir, 0o755)
os.MkdirAll(capDir, 0o755) //nolint:forbidigo
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) {
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) { //nolint:forbidigo
t.Error("plugin directory should be removed")
}
}

View File

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

View File

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

View File

@@ -1,7 +1,5 @@
# 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` 轮询,轮询间隔应该为 20s。应用发布平均耗时大约 2min整体超时时间大约 5min
- `status=publishing` 表示发布仍在进行;继续用 `+release-get` 轮询。
- `+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", "--environment", "dev", "--dry-run"},
Args: []string{"apps", "+db-env-create", "--app-id", "app_x", "--env", "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", "--environment", "dev", "--sync-data", "--dry-run"},
Args: []string{"apps", "+db-env-create", "--app-id", "app_x", "--env", "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", "--environment", "online", "--dry-run"},
Args: []string{"apps", "+db-execute", "--app-id", "app_x", "--sql", "SELECT 1", "--env", "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("DefaultsToDevAndPageSize20", func(t *testing.T) {
t.Run("DefaultsToOnlineAndPageSize20", 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, "dev", gjson.Get(result.Stdout, "api.0.params.env").String())
assert.Equal(t, "online", 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", "--environment", "dev",
"--app-id", "app_x", "--env", "dev",
"--page-size", "50", "--page-token", "cursor-abc",
"--dry-run"},
DefaultAs: "user",