Compare commits

...

63 Commits

Author SHA1 Message Date
liangshuo-1
9f2e049858 chore(release): v1.0.51 (#1388) 2026-06-10 22:51:08 +08:00
raistlin042
2c703f2fce feat: apps support multi dev modes (#1175)
* feat: add fullstack app-type and --message to apps +create (#1)

* feat: accept fullstack app-type and require --message for it

* feat: inject message into fullstack create request body

* refactor: align fullstack message injection with existing body-build style

* docs: document fullstack app-type and --message for apps +create

* docs: keep scene numbering consistent in lark-apps-create reference

* docs: add HTML/fullstack intent routing to lark-apps SKILL.md

* docs: cover fullstack in lark-apps skill description and clarify HTML flow step

* test: assert fullstack in allow-list error and reject wrong-cased fullstack

* feat: drop --message from apps +create (#4)

* feat: drop --message from apps +create

* docs: drop --message and document agent-generated name/description for apps +create

* feat: add apps local key-value file storage (#5)

* feat: add Miaoda app git credential support (#9)

* fix: remove APIError detail field dependency

* docs(apps): expand lark-apps skill for local-dev & cloud-chat workflows (#3)

Reframe lark-apps from an HTML-publish skill into a full Miaoda app dev
tool covering three paths: local fullstack dev, HTML hosting, and cloud
session dev. Builds on the fullstack create change already on this branch.

- SKILL.md: 3-path routing table; mental models (code via native git,
  develop/main branch model, DB via +db-* through Miaoda, env auto-pulled
  by `npm dev run`, auto-managed credentials); command index for the new
  verbs; ambiguous-input fallback (infer app type from need, ask local vs
  cloud instead of assuming; default HTML when no signal)
- add local-dev and cloud-dev playbooks
- create: keep HTML/fullstack + required --message; add local/cloud scene
  routing and --enable-multi-env-db
- list: usable by agents with --filter; app_id resolution order
  (user-provided / .spark/meta.json / +list --filter)

Co-authored-by: wangjiangwen-gif <286006750+wangjiangwen-gif@users.noreply.github.com>
Co-authored-by: raistlin042 <lvxinsheng@bytedance.com>

* feat(apps): add 4 db CLI commands (table-list / table-schema / sql / dev-init)

妙搭 data CLI 4 条命令,复用存量 OpenAPI URL + 1 个新增 dev-init:
- +db-table-list  → GET /apps/{id}/tables(游标分页,AppTable 含预估行数/占用空间)
- +db-table-schema → GET /apps/{id}/tables/{name}(默认结构化 schema;--format pretty 出建表 DDL)
- +db-sql         → POST /apps/{id}/sql_commands(?transactional=false DBA 模式)
- +db-dev-init    → POST /apps/{id}/db_dev_init(单库→online/dev,不可逆,high-risk-write)

要点:
- sql result 兼容两种 wire 形态(结构化 [{sql_type,data,record_count}] 与 legacy ["rows-json"])
- 多语句失败:server 返 code:0 + ERROR 哨兵,CLI 升级成 typed api_error(exit 非 0),
  detail 带 statement_index/completed/rolled_back,防止 agent 误判 ok:true 假成功
- pretty 渲染对齐 miaoda:列间两空格、CJK 双宽、size 友好格式(KB/MB/GB)
- 单测 + e2e dry-run 全覆盖;BOE 真机 e2e 验证通过(25 PASS)
- SKILL.md 注册 4 条命令 + 4 篇 reference

注:内含的 BOE 联调专用 env 覆盖(LARK_CLI_OPEN_API_BASE / LARK_CLI_X_TT_ENV,
internal/cmdutil + internal/envvars)未包含在本次提交,仅本地联调用。

Change-Id: I0fe4458086708a93941e2dee852fa6a10b53bd4a

* docs(lark-apps): db 能力补进 SKILL.md description 的 WHEN 段

按 skill 质量规范(description 三段式 WHAT+WHEN+NOT,加载前唯一可见信息),
原 WHEN 仅"连数据库调试"含糊覆盖 db。补成「查看或操作应用数据库(看表结构 /
跑 SQL / 初始化 dev 环境)」,让 +db-table-schema / +db-sql / +db-dev-init
类查询能精确触发,净增 ~12 字无膨胀。

Change-Id: Id52819fa7d6b8ed0c1f174bf5946d55da7b893d7

* Feat/apps env pull (#11)

* feat: add apps env-pull shortcut

* fix: support array env_vars response in apps env-pull

* fix(apps): improve env-pull merge and expiry output

* feat: add keyword/scope/app-type query to apps +list and unhide it (#8)

* feat: switch apps +create --app-type enum to lowercase html/full_stack

* feat: add keyword/scope/app-type query to apps +list and unhide it

* docs: document apps +list query params and lowercase app_type enum

* test: update apps cli_e2e dry-run tests for lowercase app_type and +list filters

* docs: trim redundant app_type case-sensitivity note in create skill

* docs: single-source apps +list usage contract to SKILL.md

* feat: add apps publish shortcuts (publish/status/history/error-log) (#12)

* feat: add apps publish shared guard and NodeStatus mapping

* test: cover json.Number path in injectStatusName

* feat: add apps +publish shortcut

Implements the `apps +publish` command with dry-run preview (upstream
PSM path shown) and an Execute gated by ensurePublishWired() per the
not-yet-deployed OpenAPI gateway constraint (publishAPIWired=false).

* refactor: make apps publish path placeholders var to satisfy go vet

Declare the four publishXxxPath constants as var instead of const so
go vet's printf analyzer skips them while they are empty placeholders.
Revert the Execute path-build in apps_publish.go from strings.Replace
back to fmt.Sprintf (now safe because the format string is a var).

* feat: add apps +publish-history shortcut

* feat: add apps +publish-status shortcut

* feat: add apps +publish-error-log shortcut

* feat: register apps publish shortcuts

Add AppsPublish, AppsPublishHistory, AppsPublishStatus, AppsPublishErrorLog
to Shortcuts() and update count test from 6 → 10.

* docs: add skill references for apps publish shortcuts

* docs: surface apps publish shortcuts in lark-apps SKILL.md

* docs: clarify publish instance id is not an approval instance

* docs: nudge agent to run apps +publish --dry-run for release requests

* feat: update apps publish shortcuts to v1.0.381 release protocol

Rename concept instance→release across all 4 publish shortcuts and their
tests: NodeStatus→ReleaseStatus enum, --instance-id→--release-id flag,
pipelineTaskID→releaseID response field, errorJobs→errorLogs, and
upstream HTTP path consts→RPC method name consts (PSM lark.apaas.devops
v1.0.381). Dry-run now shows psm+rpc_method instead of an HTTP path.

* docs: update apps publish skill docs to v1.0.381 release protocol

* fix: soften apps publish unavailable hint to user-facing language

* feat: update apps publish to v1.0.385 string status + --status filter

- Remove obsolete int-enum machinery (releaseStatusName/toInt/injectStatusName)
  and their encoding/json + fmt imports from apps_publish_common.go
- +publish Execute now returns status string alongside release_id
- +publish-history gains --status Enum flag (publishing/finished/failed);
  buildHistoryBody gains status param, table column status_name→status
- +publish-status Execute drops injectStatusName, pretty prints out["status"]
- +publish-error-log shapeErrorLog is string passthrough (no status_name)
- Unit tests updated: delete 3 obsolete common tests, update history/error-log

* docs: update apps publish docs to v1.0.385 string status + --status filter

* feat: wire apps publish shortcuts to final gateway paths (guard stays until deploy)

Replace RPC-name placeholders with real OpenAPI paths (publishCreate/Get/ErrorLog/ListPath consts). Switch DryRun to idiomatic HTTP form (POST/GET + real URL + body/params). Fix body/query placement: publish body has no app_id (path-only); history switches from POST body to GET query with snake page_token. Fix Execute response reads to snake_case fields (release_id, created_at, updated_at, error_logs). publishAPIWired stays false; 1-line flip activates live calls.

* docs: update apps publish docs to final gateway paths

Replace RPC/PSM dry-run example with real HTTP form (POST/GET /open-apis/spark/v1/apps/:app_id/releases[/:release_id[/error_logs]]).
Fix all response field names to snake_case (release_id, created_at, updated_at, error_log).
Note --status/--limit/--page-token as HTTP query params in publish-history.

* feat: enable apps publish gateway calls (remove not-deployed guard)

* docs: remove not-deployed transition notes from apps publish docs

* feat: use spark:app:publish scope for apps +publish

* feat(apps): add +init shortcut to initialize Miaoda app repo (#6)

* feat(apps): add command runner and credential redaction for +init

* fix(apps): make credential redaction scheme matching case-insensitive

* feat(apps): add +init shortcut declaration, validation, and dry-run

* feat(apps): implement +init orchestration (credential-init, clone, checkout, conditional push)

* fix(apps): redact full userinfo when repo URL contains literal @

* docs(apps): add +init skill reference

* fix(apps): declare explicit empty Scopes on +init shortcut

* fix(apps): consume repository_url from +git-credential-init in +init

* feat(apps): add +init template flag and absolute-path dir resolution

* refactor(apps): use shared charcheck for +init --dir validation

* feat(apps): add meta.json, steering, and empty-repo helpers for +init

* feat(apps): add +init npx scaffold orchestration (init/upgrade branches)

* feat(apps): wire +init scaffold, already-initialized short-circuit, npx dep check

* docs(apps): document +init npx scaffold, --template, --dir, already-initialized

* docs(apps): correct stale +git-credential-init unreleased note in +init ref

* fix(apps): reject all control chars in +init --dir

* feat(apps): add +init progress logging and optional --template resolver

* refactor(apps): inline constant in +init scaffold progress log

* docs(apps): document +init optional --template and stderr progress contract

* feat(apps): treat README-only repo as empty and commit with --no-verify in +init

* docs(apps): explain README-seed match and --no-verify rationale in +init

* docs(apps): document README-seed empty detection and commit --no-verify

* feat(apps): add session conversation lifecycle shortcuts (#13)

* feat(apps): add +session-create shortcut

* fix(apps): remove unused sessionPath helper, assert empty +session-create body

* feat(apps): add +session-list shortcut

* feat(apps): add +session-read shortcut

* feat(apps): add +session-stop shortcut

* feat(apps): add +chat shortcut

* feat(apps): register session lifecycle shortcuts

* docs(apps): add session conversation skill reference

* docs(apps): clarify fullstack session_id source and fallback

* style(apps): gofmt apps_session_create.go

* docs(apps): add conversation/session triggers to skill routing description

* docs(apps): add conversation flow guidance (when to reuse vs new session, per-step user prompts)

* docs(apps): slim session reference per skill quality standard (4047->1726 tok)

* docs(apps): tighten session additions in SKILL.md (4394->4145 tok)

* fix(apps): align +chat with v7.8 contract (async, no turn_id in response)

* fix(apps): update +chat path to .../sessions/{id}/chat (backend endpoint change)

* docs(apps): align SKILL.md session command shape with v7.8 contract

* style(apps): gofmt apps_db_table_schema_dryrun_test.go

Go 1.19+ gofmt 文档注释列表缩进新规则(普通缩进 → tab 对齐),
修复 fast-gate CI 的 gofmt 卡点。

Change-Id: Ic246a659e016d9d6216182199ef300ae6f00ef9d

* feat(apps): split +init commit, plainer wording, align skill branches (#14)

* refactor(apps): plainer +init progress/help wording, keep scaffold key

* refactor(apps): add porcelain change classifier for +init commit split

* feat(apps): split +init empty-repo commit into code + config, reword subjects

* refactor(apps): scaffold-kind constants and pathspec assertions for +init split

* docs(apps): use +init in Path A; align app-repo branch to sprint/default

* docs(apps): align local-dev playbook to sprint/default + origin remote

* docs(apps): document +init two-commit split and plainer init wording

* docs(apps): require asking clone dir before +init, no assumed path

* fix(apps): stage +init commits by exact paths to avoid gitignore error

* refactor(apps): lowercase miaoda in +init commit subjects

* test(apps): cover +init upgrade path with real git

* fix: harden app git credential handling (#16)

* fix: harden git credential refresh fallback (#18)

* fix(apps): validate env-pull key names before writing to .env.local (#17)

* fix(apps): validate env-pull key names before writing to .env.local

S2 (medium-low) from security review: env-pull wrote server-returned
env KEYs to .env.local without validation. A compromised or MITM'd
backend could inject arbitrary lines via keys containing newlines.

- Add envKeyPattern regex to validate keys match [A-Za-z_][A-Za-z0-9_]*
- extractEnvPullVars now returns skippedKeys for invalid key names
- Invalid keys are skipped (not hard-fail) so remaining valid keys
  are still pulled
- writeEnvPullPretty prints a warning listing skipped keys

* fix(skills): correct npm script syntax from 'npm dev run' to 'npm run dev'

* fix(skills): align env-pull guidance with implementation

🤖 Generated with [Aiden x Claude Code]

* test(apps): cover storage/git-credential error paths and fix tz-flaky env-pull tests (#19)

The coverage and unit-test CI jobs failed on two timezone-dependent
assertions in apps_env_pull_test.go: the code renders the database
expiry via time.Local() while the tests hard-coded a CST literal, so
they failed under CI's UTC. Compute the expected string from the same
timestamp with Local() instead, making the assertions timezone-agnostic.

Also add unit tests for the error branches codecov flagged as uncovered,
taking storage.go and git_credential.go to 100%:
- storage Read/Write/Delete/List filesystem-error paths
- +git-credential-remove ConfigWarning output (pretty and JSON)
- gitCredentialLocalError nil passthrough

* fix(apps): silence +init forbidigo, npx app sync -y --prefer-online (#20)

* fix(apps): add Subtype to env-pull error literals (#21)

typed_error_completeness lint requires all errs.XxxError literals to
set Problem.Subtype. Add the missing field to 11 error constructions:
- ValidationError (user input checks): SubtypeInvalidArgument
- ValidationError (API response parsing): SubtypeInvalidResponse
- InternalError (filesystem ops): SubtypeUnknown

* feat(apps): inject FORCE_DB_BRANCH=dev in env-pull output (#23)

* feat(apps): inject FORCE_DB_BRANCH=dev in env-pull output

Always write FORCE_DB_BRANCH="dev" into the resolved .env.local after
extracting upstream env_vars, so downstream tooling pinning the dev
database branch does not need a separate manual edit. Existing local
values are overwritten in place via the canonical merge path.

* docs(skills): document apps +env-pull in lark-apps skill

Add the env-pull entry to the lark-apps SKILL index and ship the
matching reference doc covering args, merge semantics, return shape,
error envelope subtypes, and dry-run behavior so AI agents can route
to it without reading the Go source.

* feat(apps): surface is_published and online_url in +list pretty view (#22)

* docs: refactor lark-apps skill per quality spec (#24)

Slim SKILL.md and references against the lark-cli skill quality spec
while preserving domain knowledge and safety guardrails.

- Compress SKILL.md (drop the MUST-read prelude, full command-index
  tables, and content already owned by lark-shared: auth, scope,
  exit-10, risk policy, _notice); add version field; zero CRITICAL
  markers.
- Defer flag enumeration in references to `--help`; convert
  narration-inducing prohibitions into positive defaults; de-duplicate
  the per-file error.hint relay into a single resident SKILL.md rule.
- Fix stale facts found against shortcuts/apps source: drop the
  non-existent +create --message and --enable-multi-env-db flags,
  +list --filter (now --keyword), +db-multi-env-init (now
  +db-dev-init), and the removed html-publish cwd hard-reject.
- Keep all safety guardrails: db-dev-init irreversibility/exit-10,
  db-sql non-transactional multi-statement, git-credential token
  handling, html-publish credential scan, access-scope confirmation.
- Restore intent lost during slimming: release_id is not an approval
  instance (do not route to lark-approval); resolve access-scope
  targets via contact/im; ask the user before publishing as a
  side-effect; distinguish developing an existing app locally
  (+init) from creating a new one (+create).

* test(apps): supplement shortcuts/apps unit-test coverage to 88% (#25)

* test(apps): cover db-table-list numeric/byte formatting helpers

* test(apps): cover db-sql cell/code/dml/error render helpers

* test(apps): cover env-pull newline/expiry/extract-vars helpers

* test(apps): cover db-sql render branches and env-pull expiry edge case

* test(apps): cover init empty-dir/meta/ls-files error branches

* test(apps): cover env-pull target/read/parent-dir error branches

* test(apps): cover stage-and-commit and commit-push error branches

* test(apps): cover access-scope target split and JSON validation

* test(apps): cover html-publish decode error and scaffold sync failure

* test(apps): cover apps-update body field combinations

* test(apps): cover access-scope body build branches

* feat(apps): pass --local to npx skills sync in +init (#26)

* feat(apps): pass --local to all npx miaoda-cli calls in +init

* feat(apps): pass --local only to npx skills sync in +init

* docs(apps): surface +publish and +init dir-choice in local-dev flow (#27)

* docs(apps): surface +publish as deploy action in skill routing

* docs(apps): add explicit deploy-after-local-edit section to local-dev

* docs(apps): promote +init dir-choice instruction to a domain rule

* docs(apps): make dev-method a signal-driven entry gate before routing (#28)

* docs(apps): restore three-path overview line in apps skill intro (#29)

* feat(apps): add executable Examples to shortcut --help and error hints (#30)

* test(apps): guard every shortcut has a help Example and no PII

* feat(apps): add help Examples to all 24 apps shortcuts

* feat(apps): add actionable hints to high-impact error paths

* test(apps): cover withAppsHint set-if-empty hint behavior

* feat(apps): use concrete enum value in access-scope-set Example

* docs(apps): clarify db-sql/db-table-list json default output behavior

两处仅补充注释,不改逻辑:
- +db-sql: data.results 在 json 默认路径原样透出全部行,CLI 不二次截断;
  server 对单条 SELECT 有 1000 行硬上限、超出直接返报错,非无界 token 黑洞。
- +db-table-list: json 默认透出含每表完整 columns[] 系产品设计(list 接口本就
  返回列定义,json 消费方一次拿全量、免逐表再调 +db-table-schema),pretty 仅摘计数。

Change-Id: I1a49de8defc4428bfe1e774e4fd7adb45e59e3af

* feat(apps): command-layer AI-friendliness governance (P0+P1) (#32)

* fix(apps): normalize --app-type case to align with server

* refactor(apps): migrate CallAPI to CallAPITyped for typed errors and retryable

* feat(apps): trim icon_url and created_at from +list default output

* feat(apps): add actionable hints to high-impact error paths

* feat(apps): add 2-3 help Examples to +chat and +access-scope-set

* docs(apps): add --jq filter tips to list/db commands

* docs(apps): sync +list reference with trimmed output fields

* test(apps): assert error hints and messages carry no secrets or PII

* fix(apps): prefix --jq tips with .data. so they run against the response envelope

* test(apps): expect --app-type uppercase normalization in create dry-run E2E (#33)

* fix(apps): scaffold via @latest miaoda-cli instead of @alpha (#34)

* feat(apps): rework lark-apps triggering, routing & confirm policy (#35)

* feat(apps): results-oriented triggering, pre-auth floors, terminal URL

Widen description WHEN to cover app-building openers (CRM/审批/HTML page)
with no Miaoda signal word, WHAT still anchored to 妙搭应用开发与托管.
Add a pre-authorization rule (auth words skip confirm) with two non-exempt
floors: destructive DDL (DROP/TRUNCATE/ALTER drop|modify column) dry-run,
and first public-URL publish (+publish/+html-publish) when no auth word.
Exempt html app_type from the local-vs-cloud dev-method gate, and scope
that gate to new-app creation only (existing-app ops route directly).
Require an accessible URL as the end-to-end terminal step.

* feat(apps): apply eval-fix behavior contracts across reference docs

init/local-dev: end-to-end default-directory escape hatch; end-to-end
new-build starts with +create. db-sql: additive DDL direct-exec when
authorized, destructive DDL stays dry-run. local-dev/publish-status:
return online_url via +list as the full_stack publish terminal step.
cloud-dev: generation != shareable URL, +publish handoff, background
until-poll snippet (sleep N && cmd intercepted; deprecate ScheduleWakeup),
multi-turn publish precondition. publish/publish-error-log: transient
failure (EAI_AGAIN/ETIMEDOUT/registry) discrimination, retry cap 2,
honest receipt. env-pull: first-launch fallback. local-dev/db-dev-init:
new full_stack ships dual DB, skip +db-dev-init.

* refactor(apps): apply review feedback — semantic criteria, drop overfit/unverified content

Per line-by-line review of the eval-fix changes:
- Entry routing reframed to objective/semantic criteria (new-vs-existing =
  'can an existing app be identified'; dev-method = who-writes-code
  preference), replacing keyword/example matching.
- db-sql DDL gate restated by effect (data-loss / reversibility), not a
  keyword list.
- Pre-authorization judged by expressed intent (not a word list); single
  non-exempt floor (destructive/irreversible DB dry-run); confirm policy in
  its own section, error.hint in 'failure handling'.
- init.md slimmed to command facts (directory choice owned by local-dev,
  no init<->local-dev cycle); local-dev defers new-vs-existing to the entry.
- Reverted unverified/redundant/runtime-coupled additions: cloud-dev
  session-read preview-URL claim + background-poll snippet + queued_count
  precondition; publish transient-retry/ScheduleWakeup; env-pull first-launch;
  db-dev-init positive restatement; SKILL terminal-URL mandate.
- Fixed dangling section references after the rename.

* fix(apps): scope pre-authorization to hands-off intent, not 'wants a result' (#36)

Follow-up to #35. The merged pre-authorization rule treated 'wanting the
final result' as authorization, so '先在本地跑起来让我看看' was read as
pre-authorized and the agent silently picked a clone directory without
asking. Re-state the criterion as the user's hands-off intent (explicit
waiver, or an end-to-end directive), judged uniformly across the flow
(directory/clone, publish) — not a per-decision carve-out. Merely wanting
a result or asking to review is not authorization.

* docs: clarify apps cloud dev publish state

* fix(apps): require commit+push before publish, clarify deploy flow (#38)

* fix(apps): require committing changes before publish in local-dev flow

* fix(apps): make commit+push mandatory before publish in agent rules

* fix(apps): scope selective-add caveat to incremental deploy, not new-app flow

* fix(apps): make pre-publish commit conditional on local changes

* fix(apps): tighten pre-publish commit wording in agent rules

* fix(apps): cloud-dev does not auto-deploy, add explicit publish step

* docs(apps): document +chat init vs incremental turn cost (#39)

First +chat on a not-initialized app runs full design+gen server-side
(~20-50 min); chat on an already-initialized app is incremental and
finishes in minutes. Surface this in the +chat Go comment as a pointer
and put the init-state check + matching polling cadence (5-10s vs
60-120s) in the lark-apps cloud-dev skill reference as the canonical
source. Cloud-side init check uses +session-read committed-version
info or +list is_published:true.

* docs(apps): document +chat init vs incremental turn cost (#40)

First +chat on a not-initialized app runs full design+gen server-side
(~20-50 min); chat on an already-initialized app is incremental and
finishes in minutes. Surface this in the +chat Go comment as a pointer
and put the init-state check + matching polling cadence (5-10s vs
60-120s) in the lark-apps cloud-dev skill reference as the canonical
source. Cloud-side init check uses +session-read committed-version
info or +list is_published:true.

* feat(apps): surface online_url/error_logs in +publish-status output (#41)

* refactor(apps): extract shared release error-log table helper

* fix(apps): keep error-log table byte-identical for null error_logs

* feat(apps): surface online_url/error_logs in +publish-status output

* docs(apps): read online_url/error_logs from +publish-status in publish flow

* docs(apps): align local/cloud dev publish flow with +publish-status fields

* refactor(apps): rename +db-dev-init→+db-env-create, trim db-table-list columns

- +db-env-create(原 +db-dev-init):新增 --env 参数(调用方传入,目前只支持 dev),
  --sync-data 改为 true/false 取值;服务端 URL 仍走 db_dev_init。
- +db-table-list:json 默认用白名单投影(dbTableListItem)只输出产品要求字段,
  每表 columns[] 折算成 column_count、不再透出完整列定义(与 +db-table-schema 重复且放大
  token);要完整列定义/索引/约束用 +db-table-schema。
- 同步对齐 db 相关 skill 文档(命令名、column_count、env-create 参数)。
- 单测 + cli_e2e dry-run 全绿。

Change-Id: I116ab11807679f8f06ed18221f705bab426d015c

* refactor(apps): rename +db-table-schema → +db-table-get

动词对齐 +db-table-list(list/get)。仅命令名 + 标识符 + 文档改名,行为/输出/URL 不变:
- AppsDBTableSchema→AppsDBTableGet,文件/测试/cli_e2e test 重命名
- buildDBTableSchemaParams→buildDBTableGetParams
- +db-sql / +db-table-list 里的交叉引用 hint、skill 文档同步

Change-Id: I36dfb8fd0d2613492a57dc7815bc58414c145480

* feat: auto-pull env vars after apps +init (#42)

* test: route apps +env-pull to its own fake-runner key

* feat(apps): add +env-pull envelope parsers for +init

* feat(apps): add pullEnv helper invoking sibling +env-pull

* feat(apps): +init auto-runs +env-pull after push (non-fatal)

* docs(apps): clarify db-sql --query @path is relative-only, use stdin for absolute paths

@path 受 lark-cli 全局文件安全策略约束,只接受 cwd 内相对路径;绝对路径 / cwd 不固定
场景改用 stdin(--query - < /abs/file.sql),无需先 cd。

Change-Id: Ib3453810cfc9303d72b4facf3493ad9688eeffd3

* docs(apps): refine db-sql --query path guidance wording

以 agent 视角重写:@ 仅接受工作目录内相对路径,绝对路径/越界路径被拒(CLI 文件访问统一约束);
工作目录外的文件经 stdin 传入。

Change-Id: Ic7db00934b3571368eb704451f4ce1776463806d

* feat(apps): make +db-sql high-risk-write (require --yes)

+db-sql 可含 DML/DDL,统一升级为 high-risk-write:框架对所有执行强制 --yes 确认关卡
(--dry-run 预览豁免),无 --yes 返 confirmation_required / exit 10。
- Risk: write → high-risk-write(去掉自定义门禁,直接用框架机制)
- skill 文档:命令骨架标注 --yes 要求;Agent 规则改为「执行需 --yes,只读可直接带、
  破坏性先 dry-run 确认再带」
- 单测所有执行调用补 --yes

Change-Id: I57e78832b35fa170a485774e6fb7289109d678c3

* docs(apps): clarify app_ (Miaoda) vs cli_ (Feishu) app id (#46)

* 优化云端开发skill,明确执行模型,参数解释 (#44)

Co-authored-by: fushengdong.1 <fushengdong.1@bytedance.com>

* refactor: rename apps publish commands to release and session-get (#45)

* refactor(apps): drop +publish-error-log, rename release path constants

* refactor(apps): rename +publish to +release-create

* refactor(apps): rename +publish-history to +release-list, unify pagination to --page-size

* refactor(apps): rename +publish-status to +release-get

Renames apps +publish-status → +release-get (AppsPublishStatus → AppsReleaseGet),
updates --release-id desc to reference +release-create, and fixes the Execute
error hint to point at +release-list instead of +publish-history.

* refactor(apps): rename +session-read to +session-get

* docs(apps): rename publish references to release, +session-read to +session-get

* refactor(apps): clean up residual publish/session-read references

Fix six leftover references missed in Tasks 1-6: +publish-history in
jq-tip test wantCmds map and common_test hint fixture (×3), +session-read
in apps_chat.go comment+output string (×2), apps_session_stop.go flag
desc (×1), apps_chat_test.go comment (×1), and +publish-status in
lark-apps-list.md agent rule prose (×1).

* docs(apps): clarify release-get link contract and session-get vs session-list

* docs(apps): generalize release-list page-size rule to N records

* feat(apps): rename +list --scope flag to --ownership (#47)

* feat(apps): rename +list --scope flag to --ownership

* test(apps): update +list cli_e2e dry-run for --ownership rename

* docs(apps): document +list --ownership flag

* feat(apps): align +release commands with new release API format (#48)

* feat(apps): align +release-create scope to spark:app:write

* feat(apps): raise +release-list --page-size documented max to 500

* feat(apps): show commit_id in +release-get pretty output

* docs(apps): update release reference docs for page-size 500 and commit_id

* test(apps): cover empty commit_id in +release-get pretty output

* docs: align lark apps cloud dev release flow

* feat(apps): redesign +db-sql → +db-execute (--sql/--file, default env dev)

按 db 子域命令最终设计重做执行入口:
- 命令 +db-sql → +db-execute(动词收尾,对齐 +db-table-list/-get)
- --query 拆为 --sql(内联/stdin)与 --file(.sql 文件路径),二选一互斥;
  --file 在 Validate 阶段读出归一化到 --sql
- 默认 --env online → dev(打生产库需显式 --env online)
- 文件/标识符/注册/测试/cli_e2e/skill 文档全部对齐重命名
- 新增测试:--sql/--file 互斥、--file 读取、默认 env=dev

不在本次范围:--transaction/--no-transaction(服务端 transactional 实为路径切换、
非真事务,需 dataloom 侧先支持真事务开关)、--max-rows/--timeout 等后续项。

Change-Id: I50c06faf83527471446e2a6651ccb51f6eedd6ff

* docs(apps): clearer --env online wording for +db-execute

把口语化的「打生产库需显式」改为「需要操作线上环境数据库时,显式指定 --env online」;
flag desc 同步去掉 hit production 措辞。

Change-Id: Iee82fccf17e08bddb4b760c3970a416746b10c4c

* docs(apps): drop 'ad-hoc' jargon from +db-execute description

中文文档/英文 description 去掉术语 ad-hoc;SELECT/DML/DDL 已表意,含义不丢。

Change-Id: Ie2cccc5fc3491fe5f57190a87b93ecd70405b156

* docs(apps): trim +db-execute when-to-use and --file path wording

- 何时用去掉「(查询 / 临时数据修复 / 应急 DDL)」枚举
- --file 路径说明去掉 .. /符号链接/统一约束 的技术化描述,改为「相对路径,
  否则用 --sql - < 文件路径」的产品化口吻

Change-Id: Ie70e57895c78650230b6942b03d90a2d95c937f2

* docs(apps): note --file rejects absolute/cwd-escaping paths

简短补回 --file 的路径约束(绝对路径 / 经 ..、符号链接越界会被拒),去掉冗余评注。

Change-Id: I549893c82cafbe97529e08dcbc3ee5496927da18

* fix(apps): replace t.Chdir with os.Chdir in db-execute test (Go 1.23 compat)

t.Chdir 是 Go 1.24 API,但 go.mod 为 go 1.23.0,CI(Go 1.23)报
"t.Chdir undefined"。改用 os.Chdir + t.Cleanup 还原,1.23 兼容。

Change-Id: I550611773e5088275be1c4344d4f8269610ce74a

* feat(apps): refine +init description and refresh env on re-init

* fix(apps): treat accessible-link requests as publish intent (#53)

* refactor(apps): +db-env-create --sync-data string-enum → Type:bool

原实现用 string + Enum["true","false"] + == "true" 模拟 bool,啰嗦且非惯用。
改为 Type:bool(rctx.Bool):传 --sync-data 即开启、省略为 false。
同步更新测试、cli_e2e dry-run、skill 文档。

Change-Id: I3068e0577fa20a7cbaf414ca9af3d197f6ae8049

* fix(apps): declare --app-type as strict lowercase enum (#55)

* docs(apps): front-load routing, dedupe, and trim lark-apps skill (#56)

* docs(apps): front-load intent-routing table and dedupe skill body

* docs(apps): dedupe publish guardrail and polling rules in cloud-dev

* docs(apps): trim env-pull implementation detail to behavior contract

* docs(apps): add +env-pull routing entry in SKILL.md

* docs(apps): fix create.md cross-ref to actual SKILL.md section name

* feat(apps): add error.hint to command failures and a consistency gate (#57)

* feat(apps): add appIDListHint const and wrap 4 pure app-id command failure paths

Adds shared `appIDListHint` recovery hint to common.go and wraps the
CallAPITyped failure branch of session-create, session-list, update, and
release-list to surface an actionable next-step hint on 4xx errors.
Includes httpmock unit tests in apps_hints_more_test.go (TDD: red→green).

* feat(apps): add sessionStopHint and createHint for session-stop and create commands

Adds per-command recovery hints with specific guidance: sessionStopHint
points at +session-list and +session-get; createHint explains valid
--app-type values and permission failure. Wraps the CallAPITyped failure
branch in both commands.

* feat(apps): add recovery hints for db-env-create, db-table-get, db-table-list

Adds dbEnvCreateHint, dbTableGetHint, and dbTableListHint with actionable
cross-command guidance (e.g. pointing at +db-table-list for env conflicts,
+db-env-create for missing dev env). Wraps only the CallAPITyped failure
branch; requireAppID validation errors are left untouched.

* refactor(apps): make session-stop hint runnable and align hint test names

* test(apps): guard withAppsHint upstream-wins contract and new hint leak safety

* test(apps): add help-skill command consistency gate

---------

Co-authored-by: linchao5102 <linchao.5102@bytedance.com>
Co-authored-by: Wang <wangjiangwen@bytedance.com>
Co-authored-by: wangjiangwen-gif <286006750+wangjiangwen-gif@users.noreply.github.com>
Co-authored-by: 陈兴炀 <chenxingyang.1019@bytedance.com>
Co-authored-by: aihao-git <aihao.0331@bytedance.com>
Co-authored-by: bali <bali@bytedance.com>
Co-authored-by: hunnnnngry <chenxi.xichen@bytedance.com>
Co-authored-by: shengdongyc <1135978761fsd@gmail.com>
Co-authored-by: fushengdong.1 <fushengdong.1@bytedance.com>
2026-06-10 21:45:45 +08:00
sammi-bytedance
501bf539af feat(im): complete audio/post rendering and add opt-in --download-resources (#1245)
Block 1 — field completion: audio renders <audio key="..." duration="Xs"/>
(falls back to [Voice: Xs]/[Voice]); post renders emotion -> :emoji_type:,
applies text.style (bold/italic/underline/lineThrough), passes through md;
sticker unchanged.

Block 2 — opt-in --download-resources (default off) on +chat-messages-list,
+messages-mget, +threads-messages-list: extract downloadable resource refs
during formatting (image/file/audio/video/media + post-embedded; sticker
excluded; merge_forward sub-items carry the top-level container message_id,
since the resources endpoint rejects sub-item ids with "234003 File not in
msg" and can only fetch a forwarded resource through the container; thread
replies get their own block), then download each distinct (message_id,
file_key) once into ./lark-im-resources/ with bounded concurrency (3), filling
back local_path/size_bytes; single-resource failures are isolated (error:true +
stderr warning). Path safety reuses normalizeDownloadOutputPath +
ResolveSavePath.

Batch download keys each file on disk by its unique file_key basename and only
appends an extension (from the Content-Disposition filename or MIME type) —
it does NOT substitute the server's Content-Disposition filename. Otherwise two
resources whose servers return the same filename (e.g. download.bin) would
resolve to the same ./lark-im-resources/ path and clobber each other
concurrently. The friendly "adopt the server filename" behavior is kept only
for an explicit +messages-resources-download with no --output.

Resource ref extraction guards against self-referential / cyclic merge_forward
prefetch maps (a real API sub-item list can include the container's own id or a
back-pointing merge_forward) via a visited set, so extraction terminates instead
of overflowing the stack. The container message_id is threaded through nested
merge_forwards as the download owner.

Also: document the feature (including the im:message:readonly scope requirement)
in skills/lark-im — SKILL.md is generated from skill-template/domains/im.md
(edit the source), plus the hand-written message-enrichment + 3 command
references.

Change-Id: I3a71d7d1b193130f551aaa2ec180ac1500d59ac4
Meego: https://meego.larkoffice.com/5e96d7bff4e7c525510f9156/story/detail/7331555925
2026-06-10 20:07:49 +08:00
Yuxuan Zhao
8e667db534 test(base): remove brittle primary field/view assertions (#1386) 2026-06-10 18:13:21 +08:00
evandance
e751a53f76 feat(markdown): emit typed error envelopes across the markdown domain (#1347)
Emit structured validation, API, network, file, and internal error envelopes for Markdown shortcuts so users and agents can recover from failed markdown workflows using stable type, subtype, param, and code fields.

Add Markdown domain errscontract and golangci guards to prevent legacy envelope and common helper regressions.
2026-06-10 17:42:18 +08:00
fangshuyu-768
e794fd5925 docs(skills): remove unsupported ⚠️ from callout emoji list (#1374) 2026-06-10 16:29:21 +08:00
zgz2048
077b5e7180 feat: configure initial base table schema (#1377)
* feat: configure initial base table schema

* fix: add base create table scopes
2026-06-10 15:47:33 +08:00
zhangjun-bytedance
0d20a02050 feat: replace words for transcript (#1372) 2026-06-10 14:41:44 +08:00
fangshuyu-768
7cc0b49603 docs(skills): warn about @file absolute path restriction in lark-doc skills (#1375) 2026-06-10 14:21:34 +08:00
evandance
6b48a39d55 feat(slides): emit typed error envelopes across the slides domain (#1349)
Emit structured validation, API, network, file, and internal error envelopes for Slides shortcuts so users and agents can recover from failed presentation workflows using stable type, subtype, param, and code fields.

Add Slides domain errscontract and golangci guards to prevent legacy envelope and common helper regressions.
2026-06-10 14:08:25 +08:00
evandance
b07be60068 feat(sheets): emit typed error envelopes across the sheets domain (#1348)
Emit structured validation, API, network, file, and internal error envelopes for Sheets shortcuts so users and agents can recover from failed spreadsheet workflows using stable type, subtype, param, and code fields.

Add Sheets domain errscontract and golangci guards to prevent legacy envelope and common helper regressions.
2026-06-10 11:51:42 +08:00
calendar-assistant
31bc87a2cc feat(vc): add recording event support (#1369) 2026-06-10 11:42:12 +08:00
liangshuo-1
7fdf55821b chore(release): v1.0.50 (#1359) 2026-06-09 22:43:44 +08:00
evandance
201e3e016f feat(doc): emit typed error envelopes across the doc domain (#1346)
Emit structured validation, API, network, file, and internal error envelopes for Doc shortcuts so users and agents can recover from failed document workflows using stable type, subtype, param, and code fields.

Add Doc domain errscontract and golangci guards to prevent legacy envelope and common helper regressions.
2026-06-09 20:43:20 +08:00
xiongyuanwen-byted
eed711bb11 feat(sheets): guard +csv-put --csv against a path passed without @ (#1337)
+csv-put --csv data.csv (a forgotten @) was silently written as one-cell content, because any string parses as valid CSV — unlike malformed JSON it never errored, so the filename landed in the sheet instead of the file's contents.

+csv-put's Validate now rejects a --csv value when it names a real file in the cwd subtree (guardCSVValueIsNotFilePath; fileIO.Stat, fail-open), hinting to use --csv @file or stdin (--csv -). Scoped to --csv only — no framework or other-flag change. Checking real existence (not name shape) lets inline content that merely ends in a filename pass through. Adds TestGuardCSVValueIsNotFilePath.
2026-06-09 19:48:28 +08:00
fangshuyu-768
4f4c0b59c9 docs(lark-doc): replace append with block_insert_after in skeleton workflow guidance (#1340)
`append` always inserts at document end (equiv. `block_insert_after --block-id -1`),
but skill docs previously recommended it for the "skeleton + chapter-by-chapter fill"
pattern, causing all content to pile up after the last heading.

Changes:
- Remove `append` from skeleton workflow guidance in `lark-doc-create-workflow.md`
  and `lark-doc-create.md`; recommend `block_insert_after` with explicit `--block-id`
- Fix `block_move_after` required params: remove `--content` (not supported),
  only `--block-id` and `--src-block-ids` are valid
- Add bash language tag to code block for proper highlighting
2026-06-09 18:11:56 +08:00
evandance
2b4c6349a1 feat(event): emit typed error envelopes across the event domain (#1289)
Replace every command-facing error path in the event domain — the
consume/schema command layer, the +subscribe shortcut, EventKey
definitions, and the consume orchestration — with typed errs.*
envelopes, so consumers get stable type, subtype, param, hint, and
missing_scopes metadata for classification and recovery instead of
free-form message text.

- Input validation (--jq, --param, --output-dir, --filter, --route,
  unknown EventKey, EventKey params) reports validation /
  invalid_argument with the offending flag in param and an actionable
  hint.
- Scope preflight reports authorization / missing_scope with the
  machine-readable missing_scopes list; console-subscription and
  single-bus preconditions report failed_precondition with recovery
  hints.
- The consume API boundary passes already-typed errors through and
  classifies transport, non-JSON HTTP, and unparsable responses; the
  vc note-detail retry now matches the not-found code on typed errors
  (it silently never fired against the legacy envelope shape).
- Previously-bare failures exited 1 with a plain-text "Error:" line
  and now exit with their category code (validation 2, auth 3,
  network 4, internal 5) alongside the typed stderr envelope.
- forbidigo and errscontract guards now cover the event paths so
  regressions fail lint; AGENTS.md and the lark-event skill document
  the typed contract for agent consumers.

Validation: make unit-test (race) green; event unit and e2e suites
assert category/subtype/param/hint and cause preservation against the
real binary; errscontract and golangci lint clean.
2026-06-09 17:12:55 +08:00
wangweiming-01
944cd55fc7 docs: add drive comment location guidance (#1258)
Change-Id: I7cfdfd5a456658cca89fc974ef7a85dc20c2c395
2026-06-09 17:00:56 +08:00
fangshuyu-768
7229baae40 fix: clarify --block-id supports comma-separated batch delete in help text (#1336) 2026-06-09 15:21:09 +08:00
fangshuyu-768
170565c57e fix: add @file/stdin support to drive +add-comment --content (#1343) 2026-06-09 15:20:25 +08:00
evandance
03ea6e78b8 feat(contact): emit typed error envelopes across the contact domain (#1287) 2026-06-09 12:07:35 +08:00
ViperCai
ed3fe9337f fix(slides): build create URL locally instead of drive metas call (#1329)
slides +create finished by calling /drive/v1/metas/batch_query just to
fetch the presentation URL. That call needs a drive scope the shortcut
never declares, so it 403'd for users who only authorized slides scopes
(both UserAccessToken re-auth and TenantAccessToken scope-not-opened),
producing a large share of the shortcut's failure telemetry — even though
the presentation itself was already created successfully.

slides creation never otherwise touches drive, so rather than gating a
drive-free operation behind a drive scope, build the URL locally from the
token via common.BuildResourceURL (the same brand-standard-host fallback
already used by drive +upload / wiki +node-create). The URL is now always
returned, no extra scope is required, and creation never blocks.

Tests are updated to match: drop the registerBatchQueryStub helper and its
call sites (the httpmock Verify cleanup was failing on the now-unconsumed
batch_query stubs), point url assertions at the brand-standard host, and
replace TestSlidesCreateURLFetchBestEffort with TestSlidesCreateURLBuiltLocally,
which asserts the url is produced with no drive call registered.
2026-06-09 11:30:14 +08:00
ZEden0
cc416a4de5 docs(lark-doc): document <folder-manager> resource block (#1168)
- lark-doc-xml.md §三「资源块」: add <folder-manager wiki-token="..."> entry
  with full sub-page schema (title / url / file-type+doc-id fallback /
  space-id / owner / owner-id / create-time / edit-time, ms timestamps,
  has-more="true" beyond 100 children)
- lark-doc-xml.md §四「复制」: append folder-manager to copy support list
  (per spec FE-1 TC-D acceptance)
- lark-doc-xml.md §八 完整示例: add folder-manager example
- lark-doc-fetch.md: add 子页面列表 section explaining fetch behavior,
  url-first / file-type+doc-id fallback, container-only on wiki.core
  failure or no permission

Spec ref: cli-docx-folder-manager FE-1

Change-Id: I746fbebcc3398c5ec0b144f2eb2a306e6d96fb74
2026-06-09 10:46:03 +08:00
JackZhao10086
00d45f8fa2 feat: adjust agent timeout hint output conditions (#1328) 2026-06-09 10:05:11 +08:00
liangshuo-1
0d847511d2 chore(release): v1.0.49 (#1331) 2026-06-08 21:38:23 +08:00
fangshuyu-768
8f5504c51c docs: improve lark-doc skill guidance (#1283) 2026-06-08 20:02:28 +08:00
fangshuyu-768
d0a896ce91 docs(skills): tighten drive and markdown guardrails (#1326) 2026-06-08 19:11:41 +08:00
fangshuyu-768
99ceb2279c feat(markdown): harden create upload failures (#1325)
* feat(markdown): harden create upload failures

* test(markdown): address AI review follow-ups
2026-06-08 18:17:35 +08:00
Emrys1105
ec2ffebf47 fix: keep bounded event consume runs alive after stdin EOF (#1285) 2026-06-08 18:09:21 +08:00
hugang-lark
ee5113f9d0 fix: optimize calendar,vc,minutes skill (#1269) 2026-06-08 17:36:05 +08:00
liangshuo-1
7cce7468d6 docs(approval): restructure skill with intent table and scope boundaries (#1307)
* docs(approval): restructure skill with intent table and scope boundaries

Rewrite the description for intent-based routing (situation framing
instead of method enumeration) and add the lark-task disambiguation.
Replace the bare method list with an intent-to-command table including
topic and add_sign_type enums, document the query-to-operate workflow
chain with a runnable example, and add an out-of-scope section routing
definition creation to the Feishu client/admin console.

Bump version to 1.1.0.

Change-Id: I33b7b13b7855d67f40954701a09b115e3c91176c

* docs(approval): strengthen description coverage of edge actions

Restore the "all processing operations" phrasing so edge actions like
remind route to this skill; weak-model routing evals regressed on the
narrower "query and process" wording (2 misses in 4 runs vs 0 after
the fix).

Change-Id: Ica1928dacf879b6c7a46dfda37e35b1be9391432

* docs(approval): drop misleading 已发起 from tasks query row

tasks query 查的是本人作为审批人的任务;已发起(本人发起的实例)应走
instances initiated,该路径已在下方表行列出。移除 tasks query 的「已发起」
标签与 topic=3 枚举,避免 agent 误用 tasks query topic=3 查已发起。
2026-06-08 17:32:10 +08:00
fangshuyu-768
281cdbd37c feat(drive): harden inspect shortcut failures (#1324) 2026-06-08 17:09:53 +08:00
ViperCai
add079ea1c docs(lark-slides): tighten routing/boundary and reconcile in-slide whiteboard (#1169)
Land the high-value, low-risk items from the skill-quality audit; SKILL.md only.

- description: drop the '接口通过 XML 协议通信' impl detail; append a 不负责
  out-of-scope clause so 'make a deck' / 'draw a diagram' stop mis-routing.
- replace the 权限速查 scope table with a ## 不在本 skill 范围 routing table
  (doc / whiteboard / drive / sheets / base).
- reconcile the whiteboard boundary with the in-slide <whiteboard> element
  (added on main, #1029): lark-whiteboard owns only standalone whiteboard
  objects in cloud docs; flow/architecture diagrams drawn inside a slide stay
  in this skill via <whiteboard>. Clarified in description and out-of-scope note.
- defer auth / permissions / global params to lark-shared as single source.
- move native-API resource hint into prose; reword schema reminder; move the
  'schema is source of truth' note next to 核心规则.

Deliberately not adopted: moving Design Ideas out of the body, relocating the
wiki-token section, dropping the native-API schema guardrail, and the bulk
lark-slides- reference rename.
2026-06-08 16:37:09 +08:00
evandance
076f4d579f feat(minutes,vc): emit typed error envelopes across both domains (#1234)
Failures from the minutes and video-conference commands now surface as
structured, typed errors carrying a stable category and subtype — spanning
input validation, missing permissions, network and file-I/O failures, and
remote API errors — so callers can branch on the error kind instead of
parsing free-form text. Batch commands report partial failures explicitly,
emitting per-item results with a non-zero exit instead of masking them.
2026-06-08 16:20:43 +08:00
SunPeiYang996
0c2fd08d5a feat:remove docs v1 api (#1291)
Change-Id: I29d0af3e5325261f94949d3ab3f65051fb6bd52b
2026-06-08 16:07:52 +08:00
liangshuo-1
9d845442ce feat: add skills command to read embedded skill content (#1318) 2026-06-08 13:58:45 +08:00
Max Huang
c07a14aa2b feat(lark-shared): document relative-path-only file arguments (#1319) 2026-06-08 13:19:03 +08:00
ethan-zhx
8b39f7243c feat: add iconpark lookup for lark slides (#1123) 2026-06-08 12:28:04 +08:00
liujinkun2025
e40ef66912 docs(lark-wiki): optimize skill guidance and routing boundaries (#1275)
- Add explicit NOT boundaries to the description and a dedicated
  "不在本 skill 范围" section: file upload -> lark-drive, content
  editing -> lark-doc / lark-sheets / lark-base.
- Move the Shortcuts table up, right after 快速决策, so command entry
  points are discoverable first; keep the member-add flow and
  target-semantics sections after it.
- Add an inline reminder under the delete-space guidance that a wiki
  URL / name is not a space_id and must be resolved via
  `wiki spaces get_node` first.
- Remove the duplicated permission (scope) table and the redundant
  schema note so auth/permission guidance stays centralized in
  lark-shared.
- Bump the skill version to 1.0.1.
- Keep skill-template/domains/wiki.md in sync with the SKILL.md
  introduction narrative.

Change-Id: If2b4341f350191ee0a65bf3a2cab9afa2b76d931
2026-06-08 11:10:59 +08:00
zhumiaoxin
e1bb9db552 feat(im): format feed group error handling (#1308) 2026-06-07 21:12:19 +08:00
zhangheng023
7c50b3d9e3 feat: fetch official skills index (#1301)
lark-cli update currently discovers official skills by parsing unstable human-oriented `skills add --list` output. This prefers the stable official JSON index for skills discovery, while preserving the existing CLI-list fallback and full-install fallback for resilience.

Changes:

- Add official skills index JSON parsing in `internal/skillscheck/sync.go`

- Prefer JSON index discovery before existing CLI list parsing in `internal/skillscheck/sync.go`

- Add reason-chain details when both discovery layers fall back to `fallbackFullInstall`

- Add bounded HTTPS fetch for `https://open.feishu.cn/.well-known/skills/index.json` in `internal/selfupdate/updater.go`

- Add unit tests for parser behavior, discovery fallback order, and fallback detail reasons in `internal/skillscheck/sync_test.go`

Co-authored-by: zhaoyukun.yk <zhaoyukun.yk@bytedance.com>
2026-06-06 18:29:04 +08:00
evandance
5788a6c384 feat(im): return typed error envelopes across the im domain (#1230) 2026-06-06 17:07:57 +08:00
zhumiaoxin
bd07859c90 feat(im): cli support feed group (#1102)
Add IM feed group support documentation for lark-cli, making the raw im feed.groups.* APIs discoverable and easier for agents to use correctly.
2026-06-06 14:25:31 +08:00
evandance
8c3cba17b2 feat(task): emit typed error envelopes across the task domain (#1231)
Task commands now return structured, typed errors instead of the legacy
exit-code envelope: every failure carries a stable category, subtype, and
recovery hint, so callers can branch on the error class instead of parsing
messages. Exit codes derive from the error category — input validation exits 2,
a permission denial exits 3, other API errors exit 1.

Batch operations (adding tasks to a tasklist, creating a tasklist with tasks)
now report partial failure honestly: the per-item successes and failures stay
on stdout and the command exits non-zero instead of masking failures as a
success.
2026-06-05 22:30:45 +08:00
evandance
6367aaa0f5 feat(okr,whiteboard): emit typed error envelopes across both domains (#1236)
The okr and whiteboard commands now report every failure as a typed error
envelope. Invalid flags, malformed input, output-file conflicts, and API or
transport failures alike carry a stable category, subtype, the offending flag
or Lark error code, and a meaningful exit code — so scripts and agents can
branch on the error shape instead of scraping message strings.
2026-06-05 20:00:04 +08:00
qinxiaoyun
37b17f3d37 feat(events): add whiteboard event domain with per-board subscription (#1265)
Wire the board.whiteboard.updated_v1 EventKey into the consume pipeline so that lark-cli event consume automatically calls the per-whiteboard subscribe / unsubscribe OAPIs instead of requiring callers to manage server-side subscriptions out-of-band.

Change-Id: I94323807e8dc649d3296f6922311d2acaf92284e
2026-06-05 17:09:17 +08:00
evandance
be5527ca4e feat(im): add feed shortcut create, list, and remove shortcuts (#1273)
Adds feed shortcut management to the im domain: pin chats to the user's feed sidebar, list pinned entries, and unpin them. Three new shortcuts wrap the im/v2/feed_shortcuts OpenAPI routes, which currently expose CHAT-type entries only and accept user identity only.
2026-06-05 16:42:48 +08:00
fangshuyu-768
a75420f72c docs: add markdown domain template (#1293) 2026-06-05 15:48:01 +08:00
evandance
f3949f04c4 feat(calendar): emit typed error envelopes across the calendar domain (#1232)
Calendar commands now return structured, typed error envelopes for every
failure mode — input validation, internal faults, and API responses —
instead of legacy generic errors. Callers and AI agents get consistent
exit codes and a machine-readable shape (type / subtype / code / hint),
and can tell bad input, an internal fault, and an API rejection apart.
Validation errors are attributed to the offending flag.

Server-supplied error details (e.g. why an event time was rejected) are
surfaced on the typed error's hint via a shared classifier improvement
that benefits every domain. Multi-step operations (create-with-attendees
rollback, multi-field update) preserve the real failure's classification
and report which steps completed.

The whole calendar domain is now lint-locked against reintroducing legacy
error constructors.
2026-06-05 13:06:50 +08:00
caojie0621
62364fc320 fix(drive): use docs secure label read scope (#1281) 2026-06-05 12:48:22 +08:00
fangshuyu-768
2f4e2c3019 docs: improve lark-markdown skill guidance (#1279) 2026-06-05 12:34:56 +08:00
evandance
3990151122 feat(base): emit typed error envelopes across the base domain (#1248) 2026-06-05 11:40:00 +08:00
MaxHuang22
fa929f02d6 feat: clear recommend.allow scope auto-approve overrides (#1272)
The recommend.allow list in scope_overrides.json special-cased a set of
calendar/contact/mail scopes into the auto-approve set on top of the
platform recommendations in scope_priorities.json. Remove all entries so
no scopes are special-cased anymore; auto-approve now reflects only the
platform recommend=true scopes (plus the recommend.deny removals).

Update registry tests to use a recommend=true scope (sheets:spreadsheet:read)
as the auto-approve sample and assert the override allow set is empty.

Change-Id: Ic555a2c664e2dbd742f79712253f2918dfabf7ce
2026-06-05 11:37:46 +08:00
sang-neo03
a4a4bd6ee0 feat: check shortcut example commands against the live CLI tree (#1244)
Validate the example commands embedded in shortcut definitions (the
"Example: lark-cli ..." lines in each shortcut's Tips, shown in --help)
against the real command tree built by cmd.Build. Implemented entirely as
test-only code in cmd/ (package cmd_test), so it ships in no binary and is
not importable by product code; the truth source is cmd.Build, the same
tree the binary uses, so the check cannot drift. It runs in the standard
unit-test CI job (go test ./cmd/...); a renamed command or unaccepted flag
in an example fails that job.
2026-06-05 10:59:55 +08:00
zhoujunteng-max
ac116e7ca3 feat(drive): add drive preview and cover shortcuts and document quota details (#1259)
* feat: support get quota detail

* feat: add drive preview and cover shortcuts

- add `drive +preview` and `drive +cover` shortcuts
- wrap `preview_result` output with stable preview item fields
- support cover download via `preview_download` with validated preset mappings
- update lark-drive skill references for preview and cover usage

* fix(drive): classify cover 404 as failed precondition

* fix(drive): show preview download step in dry-run

* docs(drive): clarify quota details user-only usage

* fix(drive): soften cover 404 guidance
2026-06-04 21:08:59 +08:00
evandance
5e6a3eb857 feat(mail): return typed error envelopes across the mail domain (#1250)
* feat(mail): return typed error envelopes across the mail domain

Replace every produced error path in shortcuts/mail with typed errs.* envelopes, so consumers get stable category, subtype, param/params, hint, retryable, and log_id metadata for classification and recovery instead of free-form message text.

- Locally constructed mail errors move from output.Err* / output.Errorf / final fmt.Errorf / common legacy helpers to errs.* builders, with structured params on multi-flag validation and failed-precondition states kept non-retryable.

- API-call failures move from runtime.CallAPI / DoAPIJSON legacy boundaries to runtime.CallAPITyped or runtime.ClassifyAPIResponse, and mail-specific enrichers read errs.ProblemOf so typed code, subtype, hint, and log_id metadata are preserved.

- Batch draft-send partial failures now use runtime.OutPartialFailure so successful and failed draft sends stay in stdout while the command exits through a typed multi-status signal.

- Add mail-domain typed helpers, mail API code metadata, and guard wiring to keep shortcuts/mail from reintroducing legacy envelopes or legacy API calls.

- Keep genuine intermediate fmt.Errorf wraps in parser/builder layers annotated with nolint comments; command-facing paths wrap them into typed validation, API, network, or internal errors.

* fix(mail): report aborted draft-send batches as a single failure result

When an account-level failure interrupts a batch send after some drafts
already went out, the command previously produced two machine-readable
failure results: the partial-failure ledger on stdout and a second error
envelope on stderr. Consumers could not tell which one to recover from.

The batch ledger is now the only failure result for that case: it gains
aborted and abort_error fields carrying the typed cause, so callers can
see which drafts were sent, which failed, why the batch stopped, and how
to recover — all from stdout. A --stop-on-error stop keeps these fields
unset because stopping early there is the caller's own choice.
2026-06-04 21:02:20 +08:00
liangshuo-1
493b3cce95 chore(release): v1.0.48 (#1270) 2026-06-04 20:49:54 +08:00
zhangheng023
abc0553f21 fix: use json skills list during update (#1251)
* fix: use json skills list during update

* fix: preserve versioned skill names
2026-06-04 19:19:26 +08:00
xukuncx
a82a486508 feat(mail): preserve mailbox context in +triage output for public mailboxes (#1238)
When triaging a public/shared mailbox, downstream AI consumers (e.g.
mail +message) need the mailbox_id to construct correct API paths.
Previously the triage output only included message_id, causing
/user_mailboxes/me/messages/{id} lookups that fail for public mailboxes.

- Add mailbox_id field to every normalized message in structured output
- Add mailbox_id to top-level JSON/data output envelope
- Add mailbox_id to table rows when mailbox is not "me"
- Update stderr next-step tip to include --mailbox for non-me mailboxes
- Update next-page hint to include --mailbox for non-me mailboxes
- Add unit tests covering list, search, and public mailbox paths
- Update triage skill docs to show mailbox_id in output examples
2026-06-04 18:27:13 +08:00
YH-1600
c000dc3a44 docs: refine lark-drive knowledge organize workflow (#1253)
Change-Id: I49b4f398d60c5bb073d6c8d61987bd16f1a29c4e
2026-06-04 15:31:46 +08:00
zhicong666-bytedance
256df8c0fb docs(vc-agent): require explicit leave request (#1260) 2026-06-04 14:33:57 +08:00
Huangwenbo-wb
7a0dbe057b docs(slides): add whiteboard element documentation and improve slide guidance (#1029)
* feat(slides): add whiteboard element support and reference documentation

- Add lark-slides-whiteboard.md covering SVG and Mermaid modes, routing
  rules, layout examples, known issues, and self-check checklist
- Register <whiteboard> in slides_xml_schema_definition.xml; remove it
  from the undefined element type list
- Update SKILL.md quick-reference table and按需再读 section to point to
  the new whiteboard reference
- Update xml-schema-quick-ref.md with <whiteboard> syntax examples
- Update slide create/get/replace references to include whiteboard as a
  valid <data> child element
- Tighten fallback_if_missing descriptions in planning-layer.md and
  asset-planning.md: replace "shapes" wording with neutral intent
  language and add "whiteboard diagrams" to the fallback tool lists

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(slides): refine whiteboard reference doc structure and content

- Restructure doc: common attributes and prerequisites moved to top
- Move design quality rules under SVG mode section
- Add z-order inline note to full-screen layout example
- Replace JS coordinate script with Python, broaden scope to decorative elements
- Delete redundant Mermaid examples (keep one complete whiteboard+flowchart)
- Add prerequisite link and references section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(slides): clarify chart vs whiteboard selection and fix doc gaps

- lark-slides-whiteboard: add chart vs whiteboard decision table at top;
  fix intro and SVG use-case list to remove bar/line (those belong to <chart>)
- SKILL.md: split whiteboard quick-ref row into chart row + whiteboard row;
  fix sidebar link label to match actual scope
- asset-planning: correct chart asset type — remove funnel/scatter (unsupported
  by <chart> XSD) and note they fall back to <whiteboard> SVG
- visual-planning: add one-line whiteboard preference hint to
  architecture-diagram and process-flow layout types
- validation-checklist: add Whiteboard Elements section noting slide.get
  does not return SVG/Mermaid content; content correctness requires manual
  visual sign-off

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(slides): add SVG decorative visibility principles

Add two design rules to SVG quality requirements: check background
luminance before writing SVG (dark bg requires higher contrast), and
use non-linear brightness jumps (e.g. 0.10→0.40→0.70→1.0) instead of
linear opacity stacking (0.04→0.08→0.12) which produces near-identical
layers on dark backgrounds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(slides): add custom icon use case to whiteboard SVG

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(slides): fix whiteboard SVG rendering rules

- content area is determined by child element bounding box union, not svg width/height/viewBox/xmlns
- viewBox only purpose: provide reference for percentage-based attribute values; omit when using absolute coords
- remove redundant attributes from all svg examples, use bare <svg> tags
- drop positive/negative coordinate guidance; rendering rule simplified to bounding-box auto-scale
2026-06-04 11:58:09 +08:00
suhui928
8ce38793a7 feat: add contact skill domain guidance (#1144)
* feat(lark-contact): route user_profiles batch_query in skill

- Add user_profiles batch_query row to the routing table.
- Add a worked example next to the search-user one, with `lark-cli
  schema` first (best practice: don't guess `--data` / `--params`).
- Trim description: drop the duplicated trigger clause, add
  personal_status / signature to the capability list so routing picks
  this skill up for those queries.
2026-06-03 22:32:27 +08:00
597 changed files with 90356 additions and 9200 deletions

View File

@@ -73,20 +73,20 @@ linters:
- forbidigo
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete.
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go|shortcuts/drive/)
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
text: errs-typed-only
linters:
- forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught.
- path-except: (shortcuts/drive/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
text: errs-no-bare-wrap
linters:
- forbidigo
# errs-no-legacy-helper is drive-only: the shared helpers it bans are
# still used by other domains until their later migration phase.
- path-except: (shortcuts/drive/)
# errs-no-legacy-helper enforced on domains whose shared validation/save
# helpers have migrated to typed final errors.
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|cmd/event/|events/|shortcuts/event/)
text: errs-no-legacy-helper
linters:
- forbidigo
@@ -115,17 +115,15 @@ linters:
msg: >-
[errs-typed-only] use errs.NewXxxError(...) builder
(see errs/types.go).
# ── legacy shared error helpers banned on drive ──
# These helpers internally produce legacy output.Err* shapes, so they
# are invisible to the errs-typed-only ban above. Drive has migrated its
# calls to typed errs.* (drive-local driveInputStatError / driveSaveError);
# this prevents reintroduction. Other domains still use the shared
# helpers (migrated globally in a later phase), so this is drive-scoped.
- pattern: (common\.FlagErrorf|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
# ── legacy shared error helpers banned on migrated domains ──
# These helpers emit legacy output.Err* / bare error shapes or drop
# typed metadata such as Param/Cause. Migrated domains must use typed
# common replacements or local typed helpers instead.
- pattern: (common\.FlagErrorf|common\.RejectDangerousChars|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
msg: >-
[errs-no-legacy-helper] these shared helpers emit legacy output.Err*
shapes. Use the typed errs.NewXxxError builders or the drive-local
driveInputStatError / driveSaveError helpers (shortcuts/drive/drive_errors.go).
[errs-no-legacy-helper] these shared helpers emit legacy or
metadata-poor error shapes. Use typed common replacements, typed
errs.NewXxxError builders, or domain-local typed helpers.
# ── bare error wraps banned on fully-typed paths ──
- pattern: (fmt\.Errorf|errors\.New)\b
msg: >-

View File

@@ -75,7 +75,31 @@ The one rule to internalize: **every error message you write will be parsed by a
### Structured errors in commands
`RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
Command-facing failures must be typed `errs.*` errors — never the legacy `output.Err*` helpers and never a final bare `fmt.Errorf`. AI agents parse the stderr envelope's `type` / `subtype` / `param` / `hint` fields to decide their next action; the full taxonomy lives in `errs/ERROR_CONTRACT.md`.
Picking a constructor:
| Failure | Constructor |
|---------|-------------|
| User flag/arg fails validation | `errs.NewValidationError(errs.SubtypeInvalidArgument, ...).WithParam("--flag")` |
| Valid request, wrong system state | `errs.NewValidationError(errs.SubtypeFailedPrecondition, ...).WithHint(...)` |
| Lark API returned `code != 0` | `runtime.CallAPITyped` (shortcuts) / `errclass.BuildAPIError` (raw responses) — never hand-build |
| Network / transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTransport, ...)` |
| Local file I/O failure | `errs.NewInternalError(errs.SubtypeFileIO, ...)` — validate the path first (`validate.SafeInputPath` / `SafeOutputPath`) and use `vfs.*` |
| Unclassified lower-layer error as final | `errs.NewInternalError(errs.SubtypeUnknown, ...).WithCause(err)` |
| Lower layer already returned a typed error | pass it through unchanged — re-wrapping downgrades its classification |
Signatures that are easy to guess wrong:
- `runtime.CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error)` — it performs the HTTP request itself and classifies `code != 0` into a typed error; just return the error it gives you.
- Typed pass-through check: `if _, ok := errs.ProblemOf(err); ok { return err }``ProblemOf` returns `(*errs.Problem, bool)`, not a nilable pointer.
- `.WithParam` exists only on `*errs.ValidationError`. `InternalError` / `NetworkError` have no param field — file or endpoint context goes in the message or `.WithHint(...)`.
`forbidigo` + `lint/errscontract` reject the legacy `output.Err*` helpers, bare final `fmt.Errorf` / `errors.New`, and legacy envelope literals on migrated paths. Beyond what lint catches, three authoring conventions apply:
- Preserve the underlying error with `.WithCause(err)` so `errors.Is` / `errors.Unwrap` keep working.
- `param` names only the user input that actually failed. Recovery guidance goes in `.WithHint(...)`; machine-readable recovery fields (`missing_scopes`, `log_id`) carry server/system ground truth only — never caller-side guesses.
- Error-path tests assert typed metadata via `errs.ProblemOf` (`category` / `subtype` / `param`) and cause preservation, not message substrings alone.
### stdout is data, stderr is everything else

View File

@@ -2,6 +2,103 @@
All notable changes to this project will be documented in this file.
## [v1.0.51] - 2026-06-10
### Features
- **apps**: Support multi dev modes (#1175)
- **im**: Complete audio/post rendering and add opt-in `--download-resources` (#1245)
- **base**: Configure initial base table schema (#1377)
- **vc**: Add recording event support (#1369)
- **minutes**: Replace words for transcript (#1372)
- **markdown**: Emit typed error envelopes across the markdown domain (#1347)
- **sheets**: Emit typed error envelopes across the sheets domain (#1348)
- **slides**: Emit typed error envelopes across the slides domain (#1349)
### Documentation
- **skills**: Warn about `@file` absolute path restriction in lark-doc skills (#1375)
- **skills**: Remove unsupported ⚠️ from callout emoji list (#1374)
## [v1.0.50] - 2026-06-09
### Features
- **doc**: Emit typed error envelopes across the doc domain (#1346)
- **event**: Emit typed error envelopes across the event domain (#1289)
- **contact**: Emit typed error envelopes across the contact domain (#1287)
- **sheets**: Guard `+csv-put --csv` against a path passed without `@` (#1337)
- **cli**: Adjust agent timeout hint output conditions (#1328)
### Bug Fixes
- **drive**: Add `@file`/stdin support to `+add-comment --content` (#1343)
- **slides**: Build create URL locally instead of drive metas call (#1329)
- **cli**: Clarify `--block-id` supports comma-separated batch delete in help text (#1336)
### Documentation
- **doc**: Replace append with `block_insert_after` in skeleton workflow guidance (#1340)
- **doc**: Document `<folder-manager>` resource block (#1168)
- **drive**: Add drive comment location guidance (#1258)
## [v1.0.49] - 2026-06-08
### Features
- **events**: Add whiteboard event domain with per-board subscription (#1265)
- **im**: Support feed group (#1102)
- **im**: Add feed shortcut create, list, and remove shortcuts (#1273)
- **im**: Format feed group error handling (#1308)
- **im**: Return typed error envelopes across the im domain (#1230)
- **base**: Emit typed error envelopes across the base domain (#1248)
- **calendar**: Emit typed error envelopes across the calendar domain (#1232)
- **task**: Emit typed error envelopes across the task domain (#1231)
- **okr,whiteboard**: Emit typed error envelopes across both domains (#1236)
- **minutes,vc**: Emit typed error envelopes across both domains (#1234)
- **markdown**: Harden create upload failures (#1325)
- **drive**: Harden inspect shortcut failures (#1324)
- **slides**: Add IconPark lookup for Lark slides (#1123)
- **doc**: Remove docs v1 API (#1291)
- **cli**: Add `skills` command to read embedded skill content (#1318)
- **cli**: Fetch official skills index (#1301)
- **shared**: Document relative-path-only file arguments (#1319)
- **scopes**: Clear `recommend.allow` scope auto-approve overrides (#1272)
- **shortcuts**: Check shortcut example commands against the live CLI tree (#1244)
### Bug Fixes
- **events**: Keep bounded event consume runs alive after stdin EOF (#1285)
- **drive**: Use docs secure label read scope (#1281)
### Documentation
- **approval**: Restructure skill with intent table and scope boundaries (#1307)
- **skills**: Tighten drive and markdown guardrails (#1326)
- **skills**: Optimize calendar, vc, and minutes skill guidance (#1269)
- **markdown**: Add markdown domain template (#1293)
- **markdown**: Improve lark-markdown skill guidance (#1279)
- **doc**: Improve lark-doc skill guidance (#1283)
- **wiki**: Optimize skill guidance and routing boundaries (#1275)
- **slides**: Tighten routing/boundary and reconcile in-slide whiteboard (#1169)
## [v1.0.48] - 2026-06-04
### Features
- **mail**: Preserve mailbox context in `+triage` output for public mailboxes (#1238)
- **contact**: Add contact skill domain guidance (#1144)
### Bug Fixes
- **skills**: Use JSON skills list during update (#1251)
### Documentation
- **drive**: Refine lark-drive knowledge organize workflow (#1253)
- **vc-agent**: Require explicit leave request (#1260)
- **slides**: Add whiteboard element documentation and improve slide guidance (#1029)
## [v1.0.47] - 2026-06-03
### Features
@@ -1009,6 +1106,10 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45

View File

@@ -41,7 +41,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
| 🔗 Apps | Develop, deploy HTML, web pages and applications |
| 🔗 Apps | Create Spark/Miaoda apps, publish HTML/static sites, run cloud generation, and manage access scope |
## Installation & Quick Start

View File

@@ -41,7 +41,7 @@
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| 🎯 OKR | 查询、创建、更新 OKR管理目标、关键结果、对齐、指标和进展记录 |
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
| 🔗 应用 | 创建妙搭Spark/Miaoda应用、发布 HTML/静态站点、云端生成迭代、管理可用范围 |
## 安装与快速开始

View File

@@ -296,10 +296,11 @@ func authLoginRun(opts *LoginOptions) error {
}
// Step 2: Show user code and verification URL.
// Both branches surface AgentTimeoutHint, but on different channels:
// JSON mode embeds it as a structured field (so an agent that captures
// stdout into a JSON parser sees it without stream-mixing surprises),
// text mode prints to stderr (alongside the URL prompt).
// JSON mode embeds AgentTimeoutHint as a structured field so agents that
// capture stdout into a JSON parser see it without stream-mixing surprises.
// Text mode prints the hint to stderr only when running under a non-TTY
// (i.e. piped / agent harness), since humans reading a terminal don't need
// the agent-oriented instructions.
if opts.JSON {
data := map[string]interface{}{
"event": "device_authorization",
@@ -317,7 +318,9 @@ func authLoginRun(opts *LoginOptions) error {
} else {
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
if f.IOStreams != nil && !f.IOStreams.IsTerminal {
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
}
// Step 3: Poll for token
@@ -404,10 +407,11 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
}
}
// Skip the stderr hint in JSON mode the --no-wait call that issued the
// device_code already returned the hint as a JSON field, and writing
// text to stderr would pollute consumers that combine streams via 2>&1.
if !opts.JSON {
// Skip the stderr hint in JSON mode (the --no-wait call that issued
// the device_code already surfaced it as a JSON field), and also skip it
// when running on an interactive terminal — the agent-oriented
// instructions only matter for piped / harness environments.
if !opts.JSON && f.IOStreams != nil && !f.IOStreams.IsTerminal {
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
log(msg.WaitingAuth)

View File

@@ -6,6 +6,7 @@ package cmd
import (
"context"
"io"
"io/fs"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
@@ -16,6 +17,7 @@ import (
"github.com/larksuite/cli/cmd/profile"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/cmd/skill"
cmdupdate "github.com/larksuite/cli/cmd/update"
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/build"
@@ -51,6 +53,18 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
}
}
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
// at build time. It is registered by the repo-root package main's init via
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
// breaking the single-file preview build (see skills_embed.go). nil in builds
// that embed no skills; the `skills` commands then return a typed internal error.
var embeddedSkillContent fs.FS
// SetEmbeddedSkillContent registers the embedded skill tree. Called from the
// repo-root package main's init; a wrapper main can call it before Execute to
// supply its own skill content.
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
// HideProfile sets the visibility policy for the root-level --profile flag.
// When hide is true the flag stays registered (so existing invocations still
// parse) but is omitted from help and shell completion. Typically called as
@@ -103,6 +117,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
if cfg.keychain != nil {
f.Keychain = cfg.keychain
}
f.SkillContent = embeddedSkillContent
rootCmd := &cobra.Command{
Use: "lark-cli",
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
@@ -140,6 +155,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
rootCmd.AddCommand(skill.NewCmdSkill(f))
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)

View File

@@ -0,0 +1,160 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import (
"sort"
"strings"
)
// universalFlags are accepted by every command (cobra auto-injects help; the
// root injects version). They are never reported as unknown.
var universalFlags = map[string]bool{"--help": true, "-h": true, "--version": true}
// catalog is the source-of-truth command catalog: command path -> accepted flag
// tokens. A path is the command words WITHOUT the "lark-cli" root prefix, e.g.
// "contact +search-user". The root command is the empty path "".
type catalog struct {
flagsByPath map[string]map[string]bool
group map[string]bool // paths that are parent groups (have subcommands)
sorted []string // cached sorted paths for suggestCommand; invalidated on addCommand
}
func newCatalog() *catalog {
return &catalog{
flagsByPath: map[string]map[string]bool{},
group: map[string]bool{},
}
}
// setGroup records whether path is a parent group (has subcommands). Leftover
// words after a group node are unknown subcommands; after a leaf they are
// positionals (e.g. "api GET /path").
func (c *catalog) setGroup(path string, isGroup bool) {
if isGroup {
c.group[path] = true
}
}
func (c *catalog) isGroup(path string) bool { return c.group[path] }
// addCommand registers a command path and the flags it accepts. Repeated calls
// for the same path union the flag sets. flags are full tokens ("--query", "-q").
func (c *catalog) addCommand(path string, flags []string) {
set := c.flagsByPath[path]
if set == nil {
set = map[string]bool{}
c.flagsByPath[path] = set
}
for _, f := range flags {
set[f] = true
}
c.sorted = nil // invalidate cached suggestion list
}
func (c *catalog) hasCommand(path string) bool {
_, ok := c.flagsByPath[path]
return ok
}
// hasFlag reports whether flag is accepted by command path (universal flags
// always pass).
func (c *catalog) hasFlag(path, flag string) bool {
if universalFlags[flag] {
return true
}
set := c.flagsByPath[path]
return set[flag]
}
// longestPrefix returns the longest known command path that is a prefix of
// words, plus how many words it consumed. This separates real subcommands from
// trailing positionals (e.g. "api GET /path" resolves to "api"). When words is
// empty it falls back to the root command. ok=false means not even the first
// word names a command.
func (c *catalog) longestPrefix(words []string) (path string, n int, ok bool) {
if len(words) == 0 {
if c.hasCommand("") {
return "", 0, true
}
return "", 0, false
}
for i := len(words); i >= 1; i-- {
cand := strings.Join(words[:i], " ")
if c.hasCommand(cand) {
return cand, i, true
}
}
return "", 0, false
}
// paths returns all known command paths, sorted.
func (c *catalog) paths() []string {
out := make([]string, 0, len(c.flagsByPath))
for p := range c.flagsByPath {
out = append(out, p)
}
sort.Strings(out)
return out
}
// suggestCommand returns the known command path closest to want (small edit
// distance), for error hints. Returns "" when nothing is reasonably close.
func (c *catalog) suggestCommand(want string) string {
if c.sorted == nil {
c.sorted = c.paths() // built once after the catalog is fully populated
}
return closest(want, c.sorted)
}
// suggestFlag returns the flag of path closest to flag, for error hints.
func (c *catalog) suggestFlag(path, flag string) string {
set := c.flagsByPath[path]
cands := make([]string, 0, len(set))
for f := range set {
cands = append(cands, f)
}
sort.Strings(cands)
return closest(flag, cands)
}
// closest returns the candidate with the smallest Levenshtein distance to want,
// but only if that distance is within a tolerance scaled to want's length
// (avoids absurd suggestions).
func closest(want string, cands []string) string {
best := ""
bestD := 1 << 30
for _, cand := range cands {
d := levenshtein(want, cand)
if d < bestD {
bestD, best = d, cand
}
}
tol := len(want)/2 + 1
if bestD > tol {
return ""
}
return best
}
func levenshtein(a, b string) int {
ra, rb := []rune(a), []rune(b)
prev := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
cur := make([]int, len(rb)+1)
cur[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
cur[j] = min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost)
}
prev = cur
}
return prev[len(rb)]
}

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import "strings"
// Finding kinds.
const (
unknownCommand = "unknown_command"
unknownFlag = "unknown_flag"
)
// finding is a single mismatch between an example command reference and the
// catalog.
type finding struct {
line int
raw string
kind string // unknownCommand | unknownFlag
path string // resolved command path (unknownFlag) or attempted path (unknownCommand)
flag string // offending flag (unknownFlag only)
suggest string // nearest known command/flag, "" if none close
}
// checkRefs validates refs against cat and returns all mismatches in order.
func checkRefs(cat *catalog, refs []ref) []finding {
var out []finding
for _, r := range refs {
path, n, ok := cat.longestPrefix(r.words)
if !ok {
attempted := strings.Join(r.words, " ")
out = append(out, finding{
line: r.line, raw: r.raw, kind: unknownCommand,
path: attempted, suggest: cat.suggestCommand(attempted),
})
continue
}
// Leftover words after a group node are an unknown subcommand (e.g. a
// mistyped method like "batch_modify_message"). After a leaf they are
// positionals (e.g. "api GET /path"), so only groups trigger this.
if n < len(r.words) && cat.isGroup(path) {
attempted := strings.Join(r.words, " ")
out = append(out, finding{
line: r.line, raw: r.raw, kind: unknownCommand,
path: attempted, suggest: cat.suggestCommand(attempted),
})
continue
}
for _, f := range r.flags {
if cat.hasFlag(path, f) {
continue
}
out = append(out, finding{
line: r.line, raw: r.raw, kind: unknownFlag,
path: path, flag: f, suggest: cat.suggestFlag(path, f),
})
}
}
return out
}

View File

@@ -0,0 +1,222 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import (
"regexp"
"strings"
)
// ref is one lark-cli command reference extracted from a shortcut example.
type ref struct {
line int // 1-based line number (the line where the command starts)
raw string // reconstructed command text, for error display
words []string // command words before the first flag (subcommand candidates)
flags []string // flag tokens used, e.g. "--query", "-q"
}
const cliToken = "lark-cli"
// subcommandStart guards against false positives from prose: a real command's
// first word is ASCII (a service name or a +shortcut). A token starting with
// CJK / punctuation is treated as narration, not a command.
var subcommandStart = regexp.MustCompile(`^[A-Za-z+]`)
// shellStops are standalone tokens that terminate a command (pipes, redirects,
// separators). Separators glued to a token (`get;`, `foo|`) are handled inline.
var shellStops = map[string]bool{
"|": true, "||": true, "&&": true, "&": true, ";": true,
">": true, ">>": true, "<": true, "2>": true, "2>&1": true,
}
// wordTrailPunct is sentence / CJK punctuation that can cling to a command word
// in prose ("auth login." / "auth login"); stripped so the word still resolves
// instead of being dropped as an unknown command or non-ASCII narration.
const wordTrailPunct = `.,;:!?"')]},。、;:!?)】」』`
// parseRefs extracts every lark-cli command reference from text (a shortcut's
// Tips line, which may embed an "Example: lark-cli ..." command). It is
// deliberately format-agnostic: it keys on the "lark-cli" token whether it sits
// in a ```bash fence, an inline `code` span, or bare prose. Backslash
// line-continuations are joined first so a multi-line invocation is parsed as
// one command; inline-code backticks and trailing # comments terminate it.
func parseRefs(content string) []ref {
var refs []ref
lines := strings.Split(content, "\n")
for i := 0; i < len(lines); i++ {
lineNo := i + 1
logical := lines[i]
// Shell line continuation: a trailing backslash joins the next physical
// line. Without this, flags on the continuation lines of a multi-line
// `lark-cli ... \` example are never seen by the checker.
for endsWithBackslash(logical) && i+1 < len(lines) {
logical = strings.TrimRight(logical, " \t")
logical = logical[:len(logical)-1] // drop the trailing backslash
i++
logical += " " + lines[i]
}
refs = append(refs, parseLine(logical, lineNo)...)
}
return refs
}
func endsWithBackslash(s string) bool {
return strings.HasSuffix(strings.TrimRight(s, " \t"), `\`)
}
func parseLine(line string, lineNo int) []ref {
var refs []ref
rest := line
for {
idx := strings.Index(rest, cliToken)
if idx < 0 {
break
}
after := rest[idx+len(cliToken):]
beforeOK := idx == 0 || isBoundary(rest[idx-1])
afterOK := after == "" || isBoundary(after[0])
if beforeOK && afterOK {
if words, flags, raw, ok := parseCmd(after); ok {
refs = append(refs, ref{line: lineNo, raw: cliToken + raw, words: words, flags: flags})
}
}
rest = after
}
return refs
}
// parseCmd tokenizes the text following "lark-cli" into leading command words
// (the subcommand path, up to the first flag) and flag tokens. It stops at a
// shell separator (standalone or glued), an inline-code backtick, a comment, or
// a placeholder/prose word. ok=false filters out non-commands.
func parseCmd(after string) (words, flags []string, raw string, ok bool) {
// An inline code span ends at the next backtick; a command never spans one.
if i := strings.IndexByte(after, '`'); i >= 0 {
after = after[:i]
}
// Drop $(...) command substitutions so flags belonging to the inner command
// (e.g. `--data "$(jq -n --arg x ...)"`) are not mistaken for lark-cli flags.
after = stripCmdSubst(after)
var kept []string
inFlags := false
for _, orig := range strings.Fields(after) {
tok := orig
if shellStops[tok] || strings.HasPrefix(tok, "#") {
break
}
// A shell separator glued to a token ends the command mid-token
// ("get;", "foo|next"): keep the part before it, handle it, then stop.
stop := false
if i := strings.IndexAny(tok, ";|"); i >= 0 {
tok, stop = tok[:i], true
}
switch {
case tok == "" || tok == "-":
// empty (after a glued separator) or a bare stdin marker — skip
case strings.HasPrefix(tok, "-"):
if f := normalizeFlag(tok); f != "" {
inFlags = true
flags = append(flags, f)
kept = append(kept, tok)
}
case inFlags:
// positional / flag value after the first flag — not a command word
kept = append(kept, tok)
default:
// Command-path word. ASCII placeholder markers (<x>, [x], {x|y},
// +<verb>, ...) end the command — checked on the RAW token so the
// trailing-punct stripping below cannot erase a "..." ellipsis
// ("base +..." must stay a placeholder, not become "+").
if strings.ContainsAny(tok, "<>[]{}|") || strings.Contains(tok, "...") {
stop = true
break
}
// Strip trailing sentence/CJK punctuation so "login." / "login"
// resolve to "login"; non-ASCII narration ends the command.
w := strings.TrimRight(tok, wordTrailPunct)
if w == "" || hasNonASCII(w) {
stop = true
break
}
words = append(words, w)
kept = append(kept, tok)
}
if stop {
break
}
}
if len(kept) > 0 {
raw = " " + strings.Join(kept, " ")
}
// Keep root-only refs ("lark-cli --help") and refs whose first word looks
// like a subcommand; drop prose ("lark-cli 就能搞定 ...").
if len(words) == 0 {
return words, flags, raw, len(flags) > 0
}
if !subcommandStart.MatchString(words[0]) {
return nil, nil, "", false
}
return words, flags, raw, true
}
// stripCmdSubst removes $(...) command substitutions (including nested ones)
// from s, leaving the surrounding text intact. Backtick substitutions are
// already handled upstream (a command never spans a backtick).
func stripCmdSubst(s string) string {
var b strings.Builder
depth := 0
for i := 0; i < len(s); i++ {
if depth == 0 && i+1 < len(s) && s[i] == '$' && s[i+1] == '(' {
depth = 1
i++ // skip '('
continue
}
if depth > 0 {
switch s[i] {
case '(':
depth++
case ')':
depth--
}
continue
}
b.WriteByte(s[i])
}
return b.String()
}
// isPlaceholderOrProse reports whether a command word is a doc placeholder
// (<resource>, [flags], {a|b}, +<verb>, ...) or narration (CJK / other
// non-ASCII), rather than a literal command token.
func isPlaceholderOrProse(w string) bool {
if hasNonASCII(w) {
return true
}
return strings.ContainsAny(w, "<>[]{}|") || strings.Contains(w, "...")
}
func hasNonASCII(s string) bool {
return strings.IndexFunc(s, func(r rune) bool { return r > 127 }) >= 0
}
// flagShape matches the leading flag token, stripping any trailing junk such as
// a "=value" suffix or punctuation that bled in from the surrounding markdown
// ("--help\"", "--help;", "--params={}"). The underscore is allowed because
// real flags use it ("--input_format", "--output_as"). Returns "" for non-flags.
var flagShape = regexp.MustCompile(`^--?[A-Za-z][A-Za-z0-9_-]*`)
// normalizeFlag extracts the canonical flag token from tok, or "" if tok is not
// a real flag (e.g. a shell-string fragment like "-草稿'").
func normalizeFlag(tok string) string {
return flagShape.FindString(tok)
}
func isBoundary(b byte) bool {
switch b {
case ' ', '\t', '`', '(', ')', '\'', '"', '*':
return true
}
return false
}

113
cmd/cmdexample_test.go Normal file
View File

@@ -0,0 +1,113 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// This file and its cmdexample_*_test.go siblings implement a test-only check:
// the example commands embedded in shortcut definitions (the "Example: lark-cli
// ..." lines in each shortcut's Tips, shown in --help) must match the real
// command tree. It lives entirely in _test.go files (package cmd_test) so it
// ships in no binary and is not importable by product code; the truth source is
// cmd.Build, the same tree the binary uses, so the check cannot drift.
//
// It runs in the standard unit-test CI job (go test ./cmd/...). A mismatch — an
// example using a renamed command or an unaccepted flag — fails that job.
package cmd_test
import (
"context"
"sort"
"strings"
"testing"
"github.com/larksuite/cli/cmd"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// TestShortcutExampleCommands checks the example commands embedded in every
// shortcut's Tips against the live command tree. A shortcut that defines no
// example is simply skipped.
//
// Because the examples and the command definitions live in the same Go code,
// this is a self-consistency check: any mismatch (an example using a renamed
// command or a flag the command doesn't accept) is a bug to fix at the source.
// It runs over all shortcuts — no baseline, no diff — since a wrong example is
// always a defect, never acceptable "pre-existing drift".
func TestShortcutExampleCommands(t *testing.T) {
// Reproducibility: use the embedded API metadata (not a developer's stale
// ~/.lark-cli remote cache, which can miss commands) and an empty config
// dir so local strict mode / plugins / policy cannot reshape the tree.
// t.Setenv auto-restores after the test, so other cmd tests are unaffected.
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cat := buildCmdExampleCatalog()
type located struct {
shortcut string
f finding
}
var findings []located
for _, sc := range shortcuts.AllShortcuts() {
var refs []ref
for _, tip := range sc.Tips {
refs = append(refs, parseRefs(tip)...)
}
label := strings.TrimSpace(sc.Service + " " + sc.Command)
for _, f := range checkRefs(cat, refs) {
findings = append(findings, located{shortcut: label, f: f})
}
}
if len(findings) == 0 {
return
}
sort.Slice(findings, func(i, j int) bool { return findings[i].shortcut < findings[j].shortcut })
for _, lf := range findings {
hint := ""
if lf.f.suggest != "" {
hint = " (did you mean " + lf.f.suggest + "?)"
}
if lf.f.kind == unknownFlag {
t.Errorf("shortcut %q example uses unknown flag %s on %q%s\n %s",
lf.shortcut, lf.f.flag, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
} else {
t.Errorf("shortcut %q example uses unknown command %q%s\n %s",
lf.shortcut, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
}
}
t.Fatalf("%d shortcut example command(s) don't match the real CLI — "+
"fix the Example in the shortcut definition.", len(findings))
}
// buildCmdExampleCatalog walks the live cobra command tree and records every
// command path (minus the "lark-cli" root prefix) with its accepted flags and
// whether it is a parent group. This is the same Build() the binary uses, so
// the catalog can never drift from the real commands.
func buildCmdExampleCatalog() *catalog {
root := cmd.Build(context.Background(), cmdutil.InvocationContext{})
cat := newCatalog()
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
path := strings.TrimSpace(strings.TrimPrefix(c.CommandPath(), "lark-cli"))
var flags []string
add := func(fl *pflag.Flag) {
flags = append(flags, "--"+fl.Name)
if fl.Shorthand != "" {
flags = append(flags, "-"+fl.Shorthand)
}
}
c.Flags().VisitAll(add)
c.InheritedFlags().VisitAll(add)
c.PersistentFlags().VisitAll(add) // root's own persistent flags (e.g. --profile)
cat.addCommand(path, flags)
cat.setGroup(path, c.HasSubCommands())
for _, sub := range c.Commands() {
walk(sub)
}
}
walk(root)
return cat
}

View File

@@ -0,0 +1,233 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import (
"strings"
"testing"
)
func testCatalog() *catalog {
c := newCatalog()
c.addCommand("", []string{"--profile"}) // root
c.setGroup("", true)
c.addCommand("contact", []string{"--profile"})
c.setGroup("contact", true)
c.addCommand("contact +search-user", []string{"--query", "--as", "--format", "-q"})
c.addCommand("api", []string{"--params", "--data", "--as"}) // leaf (no subcommands)
c.addCommand("mail", nil)
c.setGroup("mail", true)
c.addCommand("mail user_mailbox.messages", []string{"--profile"})
c.setGroup("mail user_mailbox.messages", true)
c.addCommand("mail user_mailbox.messages batch_modify", []string{"--params", "--data"})
return c
}
func TestCmdExampleCatalogHasCommandAndFlag(t *testing.T) {
c := testCatalog()
if !c.hasCommand("contact +search-user") {
t.Fatal("expected contact +search-user to exist")
}
if c.hasCommand("contact +nope") {
t.Fatal("did not expect contact +nope")
}
if !c.hasFlag("contact +search-user", "--query") {
t.Fatal("--query should be valid")
}
if c.hasFlag("contact +search-user", "--nope") {
t.Fatal("--nope should be invalid")
}
// universal flags pass on any command
for _, f := range []string{"--help", "-h", "--version"} {
if !c.hasFlag("contact +search-user", f) {
t.Fatalf("universal flag %s should pass", f)
}
}
}
func TestCmdExampleLongestPrefix(t *testing.T) {
c := testCatalog()
tests := []struct {
words []string
want string
wantN int
wantOK bool
}{
{[]string{"contact", "+search-user"}, "contact +search-user", 2, true},
{[]string{"api", "GET", "/open-apis/x"}, "api", 1, true}, // trailing positionals
{[]string{"nope"}, "", 0, false},
{nil, "", 0, true}, // empty -> root
}
for _, tt := range tests {
got, n, ok := c.longestPrefix(tt.words)
if got != tt.want || n != tt.wantN || ok != tt.wantOK {
t.Errorf("longestPrefix(%v) = (%q,%d,%v), want (%q,%d,%v)",
tt.words, got, n, ok, tt.want, tt.wantN, tt.wantOK)
}
}
}
func refWordsOf(refs []ref) [][]string {
var out [][]string
for _, r := range refs {
out = append(out, r.words)
}
return out
}
func TestCmdExampleParseRefsExtractsCommands(t *testing.T) {
content := strings.Join([]string{
"运行 `lark-cli contact +search-user --query 张三` 搜索", // inline code
"```bash",
"lark-cli api GET /open-apis/x --params '{}'", // bash block
"```",
"用 lark-cli mail user_mailbox.messages batch_modify 即可", // bare prose command
"npx foo | lark-cli api GET /y", // after a pipe
}, "\n")
refs := parseRefs(content)
if len(refs) != 4 {
t.Fatalf("expected 4 refs, got %d: %v", len(refs), refWordsOf(refs))
}
if got := refs[0]; strings.Join(got.words, " ") != "contact +search-user" ||
len(got.flags) != 1 || got.flags[0] != "--query" {
t.Errorf("ref0 = %+v", got)
}
if got := refs[1]; strings.Join(got.words, " ") != "api GET /open-apis/x" {
t.Errorf("ref1 words = %v", got.words)
}
}
func TestCmdExampleParseRefsFiltersPlaceholdersAndProse(t *testing.T) {
// A line whose first word is prose yields no command at all.
if refs := parseRefs("lark-cli 就能搞定这件事"); len(refs) != 0 {
t.Errorf("prose-first line should yield 0 refs, got %v", refWordsOf(refs))
}
// Syntax templates / trailing prose may leave a real leading word ("mail"),
// but no placeholder or CJK token may leak into the command words — that is
// what prevents false positives like an "<resource>" unknown-command report.
for _, line := range []string{
"lark-cli mail <resource> <method> [flags]",
"lark-cli apps +<verb> [flags]",
"lark-cli base +...",
"lark-cli mail 写信场景下的格式说明",
} {
for _, r := range parseRefs(line) {
for _, w := range r.words {
if isPlaceholderOrProse(w) {
t.Errorf("%q: placeholder/prose token %q leaked into words %v", line, w, r.words)
}
}
}
}
}
func TestCmdExampleParseRefsStripsTrailingJunk(t *testing.T) {
// frontmatter-style quoted value: the trailing quote must not bleed into the flag
refs := parseRefs(`cliHelp: "lark-cli contact --help"`)
if len(refs) != 1 {
t.Fatalf("expected 1 ref, got %d", len(refs))
}
if len(refs[0].flags) != 1 || refs[0].flags[0] != "--help" {
t.Errorf("expected flag --help, got %v", refs[0].flags)
}
// bare "-" (stdin marker) and "=value" suffix
refs = parseRefs("lark-cli api GET /x --params={} --data -")
if len(refs) != 1 {
t.Fatalf("expected 1 ref, got %d", len(refs))
}
flags := strings.Join(refs[0].flags, " ")
if flags != "--params --data" {
t.Errorf("expected '--params --data', got %q", flags)
}
}
func TestCmdExampleCheck(t *testing.T) {
c := testCatalog()
tests := []struct {
name string
r ref
wantKind string // "" = no finding
wantPath string
}{
{"valid shortcut", ref{words: []string{"contact", "+search-user"}, flags: []string{"--query"}}, "", ""},
{"valid leaf positional", ref{words: []string{"api", "GET", "/x"}}, "", ""},
{"unknown top command", ref{words: []string{"nope"}}, unknownCommand, "nope"},
{"group leftover = unknown subcommand",
ref{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}},
unknownCommand, "mail user_mailbox.messages batch_modify_message"},
{"unknown flag", ref{words: []string{"contact", "+search-user"}, flags: []string{"--nope"}}, unknownFlag, "contact +search-user"},
{"universal flag ok", ref{words: []string{"contact", "+search-user"}, flags: []string{"--help"}}, "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fs := checkRefs(c, []ref{tt.r})
if tt.wantKind == "" {
if len(fs) != 0 {
t.Fatalf("expected no finding, got %+v", fs)
}
return
}
if len(fs) != 1 {
t.Fatalf("expected 1 finding, got %d: %+v", len(fs), fs)
}
if fs[0].kind != tt.wantKind || fs[0].path != tt.wantPath {
t.Errorf("got kind=%s path=%q, want kind=%s path=%q", fs[0].kind, fs[0].path, tt.wantKind, tt.wantPath)
}
})
}
}
func TestCmdExampleCheckSuggestsNearest(t *testing.T) {
c := testCatalog()
fs := checkRefs(c, []ref{{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}}})
if len(fs) != 1 || fs[0].suggest != "mail user_mailbox.messages batch_modify" {
t.Fatalf("expected suggestion 'mail user_mailbox.messages batch_modify', got %+v", fs)
}
}
// TestCmdExampleParseRefsRobustness covers the parser edge cases hardened after
// review: backslash continuation, underscore flags, $(...) substitution, glued
// separators, trailing punctuation, and the "..." placeholder.
func TestCmdExampleParseRefsRobustness(t *testing.T) {
cases := []struct {
name, content, wantWords, wantFlags string
wantRefs int
}{
{"backslash continuation joins flags",
"lark-cli contact +search-user \\\n --query foo \\\n --as user",
"contact +search-user", "--query --as", 1},
{"underscore flag not truncated",
"lark-cli whiteboard +update --input_format mermaid",
"whiteboard +update", "--input_format", 1},
{"command-substitution flags ignored",
`lark-cli slides x create --data "$(jq -n --arg c '{}')" --as user`,
"slides x create", "--data --as", 1},
{"glued separator truncates",
"lark-cli auth login; echo done",
"auth login", "", 1},
{"trailing CJK punctuation stripped",
"用 lark-cli auth login。",
"auth login", "", 1},
{"ellipsis placeholder stays placeholder",
"lark-cli base +...",
"base", "", 1},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
refs := parseRefs(tt.content)
if len(refs) != tt.wantRefs {
t.Fatalf("refs=%d want %d: %v", len(refs), tt.wantRefs, refWordsOf(refs))
}
if tt.wantRefs == 0 {
return
}
if got := strings.Join(refs[0].words, " "); got != tt.wantWords {
t.Errorf("words=%q want %q", got, tt.wantWords)
}
if got := strings.Join(refs[0].flags, " "); got != tt.wantFlags {
t.Errorf("flags=%q want %q", got, tt.wantFlags)
}
})
}
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/event"
@@ -38,7 +39,8 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
logger, err := bus.SetupBusLogger(eventsDir)
if err != nil {
return err
return errs.NewInternalError(errs.SubtypeFileIO,
"set up bus logger: %s", err).WithCause(err)
}
tr := transport.New()
@@ -58,7 +60,14 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
}
}()
return b.Run(ctx)
if err := b.Run(ctx); err != nil {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeUnknown,
"event bus daemon exited: %s", err).WithCause(err)
}
return nil
},
}

45
cmd/event/bus_test.go Normal file
View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// The hidden `event _bus` daemon command must exit with a typed file_io error
// when its log directory cannot be created (the error is only visible in the
// forked process's captured stderr / bus.log).
func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
// Block the events/ root with a regular file so MkdirAll fails.
if err := os.WriteFile(filepath.Join(dir, "events"), []byte("x"), 0600); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
})
cmd := NewCmdBus(f)
cmd.SetArgs([]string{})
err := cmd.Execute()
if err == nil {
t.Fatal("expected logger setup error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryInternal, errs.SubtypeFileIO)
}
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/appmeta"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
@@ -64,8 +65,8 @@ Use 'event schema <EventKey>' for parameter details.`,
cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output")
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr")
cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)")
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop.")
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout').")
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop. Bounded runs ignore stdin EOF.")
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout'). Bounded runs ignore stdin EOF.")
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
@@ -101,11 +102,10 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
if o.jqExpr != "" {
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
return output.ErrWithHint(
output.ExitValidation, "validation",
err.Error(),
fmt.Sprintf("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey),
)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
WithParam("--jq").
WithCause(err).
WithHint("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey)
}
}
@@ -184,8 +184,9 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
errOut = io.Discard
}
// Non-TTY only: stdin EOF is shutdown for subprocess callers; in TTY Ctrl-D must not exit.
if !f.IOStreams.IsTerminal {
// Non-TTY unbounded consumers use stdin EOF as shutdown for subprocess callers.
// Bounded runs already have --max-events/--timeout as their lifecycle control.
if shouldWatchStdinEOF(f.IOStreams.IsTerminal, o.maxEvents, o.timeout) {
watchStdinEOF(os.Stdin, cancel, errOut)
}
@@ -260,12 +261,12 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
if len(missing) == 0 {
return nil
}
return output.ErrWithHint(
output.ExitAuth, "auth",
fmt.Sprintf("missing required scopes for EventKey %s (as %s): %s",
pf.eventKey, pf.identity, strings.Join(missing, ", ")),
scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand),
)
return errs.NewPermissionError(errs.SubtypeMissingScope,
"missing required scopes for EventKey %s (as %s): %s",
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
WithIdentity(string(pf.identity)).
WithMissingScopes(missing...).
WithHint("%s", scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand))
}
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
@@ -300,23 +301,27 @@ func preflightEventTypes(pf *preflightCtx) error {
if len(missing) == 0 {
return nil
}
return output.ErrWithHint(
output.ExitValidation, "validation",
fmt.Sprintf("EventKey %s requires event types not subscribed in console: %s",
pf.keyDef.Key, strings.Join(missing, ", ")),
fmt.Sprintf("subscribe these events and publish a new app version at: %s",
consoleEventSubscriptionURL(pf.brand, pf.appID)),
)
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"EventKey %s requires event types not subscribed in console: %s",
pf.keyDef.Key, strings.Join(missing, ", ")).
WithHint("subscribe these events and publish a new app version at: %s",
consoleEventSubscriptionURL(pf.brand, pf.appID))
}
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
func sanitizeOutputDir(dir string) (string, error) {
if strings.HasPrefix(dir, "~") {
return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s; use a relative path like ./output instead", errOutputDirTilde).
WithParam("--output-dir").
WithCause(errOutputDirTilde)
}
safe, err := validate.SafeOutputPath(dir)
if err != nil {
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s %q: %s", errOutputDirUnsafe, dir, err).
WithParam("--output-dir").
WithCause(errOutputDirUnsafe)
}
return safe, nil
}
@@ -328,18 +333,21 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
}
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
if err != nil {
return "", output.ErrAuth("resolve tenant access token: %s", err)
if _, ok := errs.ProblemOf(err); ok {
return "", err
}
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"resolve tenant access token: %s", err).WithCause(err)
}
if result == nil || result.Token == "" {
return "", output.ErrWithHint(
output.ExitAuth, "auth",
fmt.Sprintf("no tenant access token available for app %s", appID),
"Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.",
)
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"no tenant access token available for app %s", appID).
WithHint("Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.")
}
return result.Token, nil
}
// Sentinels for errors.Is checks; call sites wrap them as typed ValidationError causes.
var (
errInvalidParamFormat = errors.New("invalid --param format")
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
@@ -351,7 +359,10 @@ func parseParams(raw []string) (map[string]string, error) {
for _, kv := range raw {
k, v, ok := strings.Cut(kv, "=")
if !ok || k == "" {
return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s %q: expected key=value", errInvalidParamFormat, kv).
WithParam("--param").
WithCause(errInvalidParamFormat)
}
m[k] = v
}
@@ -370,3 +381,8 @@ func watchStdinEOF(r io.Reader, cancel context.CancelFunc, errOut io.Writer) {
cancel()
}()
}
// shouldWatchStdinEOF gates the stdin-EOF shutdown watcher: non-TTY unbounded runs only (<= 0 mirrors downstream's >0-is-bounded semantics, so negative bounds stay unbounded).
func shouldWatchStdinEOF(isTerminal bool, maxEvents int, timeout time.Duration) bool {
return !isTerminal && maxEvents <= 0 && timeout <= 0
}

View File

@@ -61,3 +61,70 @@ func TestWatchStdinEOF_DiagnosticMessage(t *testing.T) {
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
}
}
func TestShouldWatchStdinEOF(t *testing.T) {
tests := []struct {
name string
isTerminal bool
maxEvents int
timeout time.Duration
want bool
}{
{
name: "terminal",
isTerminal: true,
want: false,
},
{
name: "non terminal unbounded",
want: true,
},
{
name: "non terminal negative max events is unbounded",
maxEvents: -1,
want: true,
},
{
name: "non terminal negative timeout is unbounded",
timeout: -1 * time.Second,
want: true,
},
{
name: "non terminal max events bounded",
maxEvents: 1,
want: false,
},
{
name: "non terminal timeout bounded",
timeout: 10 * time.Minute,
want: false,
},
{
name: "non terminal both bounds positive",
maxEvents: 1,
timeout: 10 * time.Minute,
want: false,
},
{
name: "non terminal bounded max events with negative timeout",
maxEvents: 1,
timeout: -1 * time.Second,
want: false,
},
{
name: "non terminal bounded timeout with negative max events",
maxEvents: -1,
timeout: 10 * time.Minute,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldWatchStdinEOF(tt.isTerminal, tt.maxEvents, tt.timeout)
if got != tt.want {
t.Fatalf("shouldWatchStdinEOF() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -4,9 +4,14 @@
package event
import (
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/credential"
)
func TestParseParams(t *testing.T) {
@@ -73,6 +78,7 @@ func TestParseParams(t *testing.T) {
if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) {
t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho)
}
assertInvalidArgumentParam(t, err, "--param")
return
}
if err != nil {
@@ -90,6 +96,77 @@ func TestParseParams(t *testing.T) {
}
}
// emptyTokenResolver resolves to a result that carries no token.
type emptyTokenResolver struct{}
func (emptyTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return &credential.TokenResult{}, nil
}
// failingTokenResolver fails outright with an untyped error.
type failingTokenResolver struct{}
func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return nil, errors.New("backend unavailable")
}
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
}
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {
_, err := resolveTenantToken(context.Background(), factoryWithResolver(emptyTokenResolver{}), "cli_x")
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
}
var malformed *credential.MalformedTokenResultError
if !errors.As(err, &malformed) {
t.Error("empty-token failure should preserve the credential-layer cause")
}
}
func TestResolveTenantToken_ResolverFailure(t *testing.T) {
_, err := resolveTenantToken(context.Background(), factoryWithResolver(failingTokenResolver{}), "cli_x")
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
}
if errors.Unwrap(err) == nil {
t.Error("resolver failure should preserve its cause")
}
}
// assertInvalidArgumentParam verifies err is a typed validation error with
// subtype invalid_argument naming the given flag in its param field.
func assertInvalidArgumentParam(t *testing.T, err error, param string) {
t.Helper()
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != param {
t.Errorf("param = %q, want %q", ve.Param, param)
}
}
func TestSanitizeOutputDir(t *testing.T) {
cases := []struct {
name string
@@ -130,6 +207,7 @@ func TestSanitizeOutputDir(t *testing.T) {
if !errors.Is(err, tc.wantSentry) {
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
}
assertInvalidArgumentParam(t, err, "--output-dir")
return
}
if err != nil {

View File

@@ -8,10 +8,10 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/appmeta"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
)
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
@@ -89,19 +89,17 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") {
t.Errorf("error should name the missing event type, got: %v", err)
}
var exit *output.ExitError
if !errors.As(err, &exit) {
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if exit.Code != output.ExitValidation {
t.Errorf("ExitCode = %d, want ExitValidation (%d)", exit.Code, output.ExitValidation)
}
if exit.Detail == nil {
t.Fatal("expected Detail with hint")
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryValidation, errs.SubtypeFailedPrecondition)
}
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
if !strings.Contains(exit.Detail.Hint, wantURL) {
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, exit.Detail.Hint)
if !strings.Contains(p.Hint, wantURL) {
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, p.Hint)
}
}
@@ -145,17 +143,19 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
if !strings.Contains(err.Error(), "im:message.group_at_msg") {
t.Errorf("error should name missing scope, got: %v", err)
}
var exit *output.ExitError
if !errors.As(err, &exit) {
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
}
if exit.Code != output.ExitAuth {
t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
if permErr.Category != errs.CategoryAuthorization || permErr.Subtype != errs.SubtypeMissingScope {
t.Errorf("problem = %s/%s, want %s/%s", permErr.Category, permErr.Subtype,
errs.CategoryAuthorization, errs.SubtypeMissingScope)
}
if exit.Detail == nil {
t.Fatal("expected Detail with hint, got nil Detail")
wantMissing := []string{"im:message.group_at_msg"}
if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != wantMissing[0] {
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, wantMissing)
}
hint := exit.Detail.Hint
hint := permErr.Hint
wantSubstrings := []string{
"https://open.feishu.cn/app/cli_x/auth?q=",
"im:message.group_at_msg",

View File

@@ -6,8 +6,8 @@ package event
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
)
@@ -26,7 +26,11 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
As: r.accessIdentity,
})
if err != nil {
return nil, err
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport,
"api %s %s: %s", method, path, err).WithCause(err)
}
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
ct := resp.Header.Get("Content-Type")
@@ -36,11 +40,20 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
if len(body) > maxBodyEcho {
body = body[:maxBodyEcho] + "…(truncated)"
}
return nil, fmt.Errorf("api %s %s returned %d: %s", method, path, resp.StatusCode, body)
if resp.StatusCode >= 500 {
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer,
"api %s %s returned %d: %s", method, path, resp.StatusCode, body).WithRetryable()
}
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"api %s %s returned %d: %s", method, path, resp.StatusCode, body)
}
result, err := client.ParseJSONResponse(resp)
if err != nil {
return nil, err
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"api %s %s: %s", method, path, err).WithCause(err)
}
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
return json.RawMessage(resp.RawBody), apiErr

147
cmd/event/runtime_test.go Normal file
View File

@@ -0,0 +1,147 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
// staticTokenResolver always returns a fixed token without any HTTP calls.
type staticTokenResolver struct{}
func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return &credential.TokenResult{Token: "test-token"}, nil
}
// stubRoundTripper intercepts every outgoing request with a canned response.
type stubRoundTripper struct {
respond func(*http.Request) (*http.Response, error)
}
func (s stubRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return s.respond(r) }
func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
sdk := lark.NewClient("test-app", "test-secret",
lark.WithEnableTokenCache(false),
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHttpClient(&http.Client{Transport: rt}),
)
return &consumeRuntime{
client: &client.APIClient{
SDK: sdk,
ErrOut: io.Discard,
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
},
accessIdentity: core.AsBot,
}
}
func stubResponse(status int, contentType, body string) func(*http.Request) (*http.Response, error) {
return func(r *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: status,
Header: http.Header{"Content-Type": []string{contentType}},
Body: io.NopCloser(strings.NewReader(body)),
Request: r,
}, nil
}
}
func requireCallAPIProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != category || p.Subtype != subtype {
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, category, subtype)
}
}
func TestConsumeRuntimeCallAPI_NonJSONHTTPError(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusNotFound, "text/plain", "gone")})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
if !strings.Contains(err.Error(), "returned 404") {
t.Errorf("error should echo the HTTP status, got: %v", err)
}
}
func TestConsumeRuntimeCallAPI_NonJSONHTTPErrorTruncatesLongBody(t *testing.T) {
long := strings.Repeat("x", 300)
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusBadGateway, "text/html", long)})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
requireCallAPIProblem(t, err, errs.CategoryNetwork, errs.SubtypeNetworkServer)
p, _ := errs.ProblemOf(err)
if !p.Retryable {
t.Fatal("5xx non-JSON response should be marked retryable")
}
if !strings.Contains(err.Error(), "…(truncated)") {
t.Errorf("long body should be truncated in the message, got: %v", err)
}
}
func TestConsumeRuntimeCallAPI_UnparsableJSONBody(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json", "{not json")})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
}
func TestConsumeRuntimeCallAPI_TransportFailure(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
return nil, errors.New("connection refused")
}})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryNetwork {
t.Fatalf("category = %s, want %s", p.Category, errs.CategoryNetwork)
}
}
func TestConsumeRuntimeCallAPI_EnvelopeErrorIsTyped(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
`{"code":99991663,"msg":"app not found"}`)})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if _, ok := errs.ProblemOf(err); !ok {
t.Fatalf("envelope error should be typed via BuildAPIError, got %T: %v", err, err)
}
}
func TestConsumeRuntimeCallAPI_Success(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
`{"code":0,"data":{"ok":true}}`)})
raw, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(string(raw), `"code":0`) {
t.Errorf("raw body should pass through, got: %s", raw)
}
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
@@ -39,12 +40,14 @@ func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string,
if len(def.Schema.FieldOverrides) > 0 {
var parsed map[string]interface{}
if err := json.Unmarshal(base, &parsed); err != nil {
return nil, nil, err
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
"parse base schema for field overrides: %s", err).WithCause(err)
}
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
out, err := json.Marshal(parsed)
if err != nil {
return nil, nil, err
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
"serialize schema with field overrides: %s", err).WithCause(err)
}
return out, orphans, nil
}
@@ -73,7 +76,7 @@ func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
copy(buf, s.Raw)
return buf, nil
}
return nil, fmt.Errorf("schemaSpec has neither Type nor Raw")
return nil, errs.NewInternalError(errs.SubtypeUnknown, "schemaSpec has neither Type nor Raw")
}
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
@@ -165,7 +168,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
resolved, _, err := resolveSchemaJSON(def)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "resolve schema: %v", err)
return err
}
if resolved != nil {
fmt.Fprintf(out, "\nOutput Schema:\n")

View File

@@ -10,6 +10,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
@@ -129,3 +130,38 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
t.Errorf("overlay format = %v, want open_id", got)
}
}
func TestRenderSpec_EmptySpecIsTypedInternalError(t *testing.T) {
_, err := renderSpec(&eventlib.SchemaSpec{})
if err == nil {
t.Fatal("expected error for spec with neither Type nor Raw")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
}
}
func TestResolveSchemaJSON_InvalidBaseWithOverridesIsTypedInternalError(t *testing.T) {
def := &eventlib.KeyDefinition{
Key: "synthetic.invalid.base",
Schema: eventlib.SchemaDef{
Custom: &eventlib.SchemaSpec{Raw: json.RawMessage("{not json")},
FieldOverrides: map[string]schemas.FieldMeta{"x": {}},
},
}
_, _, err := resolveSchemaJSON(def)
if err == nil {
t.Fatal("expected error for unparsable base schema")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
}
}

View File

@@ -8,8 +8,8 @@ import (
"sort"
"strings"
"github.com/larksuite/cli/errs"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/suggest"
)
@@ -64,9 +64,6 @@ func unknownEventKeyErr(key string) error {
if guesses := suggestEventKeys(key); len(guesses) > 0 {
msg += " — did you mean " + formatSuggestions(guesses) + "?"
}
return output.ErrWithHint(
output.ExitValidation, "validation",
msg,
"Run 'lark-cli event list' to see available keys.",
)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
WithHint("Run 'lark-cli event list' to see available keys.")
}

View File

@@ -377,9 +377,9 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "api_error",
Type: "api",
Code: 230002,
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
Message: "Bot/User can NOT be out of the chat.",
},
})
}

183
cmd/skill/skill.go Normal file
View File

@@ -0,0 +1,183 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package skill implements the `lark-cli skills` command group, which serves
// binary-embedded skill content to AI agents. The package is "skill"; the
// user-facing verb is "skills".
package skill
import (
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/skillcontent"
"github.com/spf13/cobra"
)
func newReader(f *cmdutil.Factory) (*skillcontent.Reader, error) {
if f.SkillContent == nil {
return nil, errs.NewInternalError(errs.SubtypeFileIO,
"skill content not embedded in this build")
}
return skillcontent.New(f.SkillContent), nil
}
type readEnvelope struct {
Skill string `json:"skill"`
Path string `json:"path"`
Content string `json:"content"`
Guidance string `json:"guidance,omitempty"`
}
type listEnvelope struct {
OK bool `json:"ok"`
Skills []skillcontent.SkillInfo `json:"skills"`
Count int `json:"count"`
}
type listPathEnvelope struct {
OK bool `json:"ok"`
Path string `json:"path"`
Entries []skillcontent.DirEntry `json:"entries"`
Count int `json:"count"`
}
func NewCmdSkill(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "skills",
Short: "Read embedded skill content (list / read)",
Long: "Read agent-readable skill content (SKILL.md and reference files) embedded in " +
"the CLI binary at build time, so it stays in sync with the CLI version. " +
"Machine resources such as assets/ and scripts/ are not embedded.",
}
// Risk is set on each leaf (GetRisk does not walk parents); the group has none.
cmdutil.DisableAuthCheck(cmd)
cmd.AddCommand(newListCmd(f), newReadCmd(f))
return cmd
}
func newListCmd(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "list [name[/path]]",
Short: "List skills, or list one layer under a skill path (like ls)",
Example: ` lark-cli skills list # all skills: name, description, version
lark-cli skills list lark-doc # one layer under a skill (like ls)
lark-cli skills list lark-doc/references # one layer under a subdirectory`,
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 1 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"list takes at most 1 argument: [name[/path]]").
WithHint("run 'lark-cli skills list --help'")
}
r, err := newReader(f)
if err != nil {
return err
}
if len(args) == 0 {
skills, err := r.List()
if err != nil {
return err
}
output.PrintJson(f.IOStreams.Out, listEnvelope{OK: true, Skills: skills, Count: len(skills)})
return nil
}
entries, listed, err := r.ListPath(args[0])
if err != nil {
return err
}
output.PrintJson(f.IOStreams.Out, listPathEnvelope{OK: true, Path: listed, Entries: entries, Count: len(entries)})
return nil
},
}
// --json is a no-op (list is always JSON), accepted only to stay symmetric with read.
cmd.Flags().Bool("json", false, "no-op (list output is always JSON)")
cmdutil.SetRisk(cmd, "read")
cmdutil.DisableAuthCheck(cmd)
return cmd
}
func newReadCmd(f *cmdutil.Factory) *cobra.Command {
var asJSON bool
cmd := &cobra.Command{
Use: "read <name>[/<path>] [path]",
Short: "Print a skill's SKILL.md, or a file under the skill (raw markdown by default)",
Example: ` lark-cli skills read lark-doc # the skill's SKILL.md
lark-cli skills read lark-doc references/lark-doc-fetch.md # a file under the skill
lark-cli skills read lark-doc/references/lark-doc-fetch.md # same, slash form
lark-cli skills read lark-doc --json # JSON envelope`,
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
name, relpath, err := parseReadTarget(args)
if err != nil {
return err
}
r, err := newReader(f)
if err != nil {
return err
}
var content []byte
var pathOut string
if relpath == "" {
content, err = r.ReadSkill(name)
pathOut = "SKILL.md"
} else {
content, pathOut, err = r.ReadReference(name, relpath)
}
if err != nil {
return err
}
isMain := pathOut == "SKILL.md"
if asJSON {
env := readEnvelope{Skill: name, Path: pathOut, Content: string(content)}
if isMain {
env.Guidance = readGuidance(name)
}
output.PrintJson(f.IOStreams.Out, env)
return nil
}
// Raw stdout stays byte-identical to the file; guidance goes to stderr.
if _, err := f.IOStreams.Out.Write(content); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "failed to write output: %v", err)
}
if isMain {
fmt.Fprintln(f.IOStreams.ErrOut, readGuidance(name))
}
return nil
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "output as a JSON envelope instead of raw markdown")
cmdutil.SetRisk(cmd, "read")
cmdutil.DisableAuthCheck(cmd)
return cmd
}
// parseReadTarget maps 1-or-2 positional args to (name, relpath); a lone
// "<a>/<b>" splits on the first '/', and relpath "" reads the main SKILL.md.
func parseReadTarget(args []string) (name, relpath string, err error) {
switch len(args) {
case 1:
name, relpath = skillcontent.SplitArg(args[0])
return name, relpath, nil
case 2:
return args[0], args[1], nil
default:
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"read requires 1 or 2 arguments: <name>[/<path>] [path]").
WithHint("run 'lark-cli skills read --help'")
}
}
// readGuidance routes cross-skill "../lark-foo/..." references back through
// `skills read lark-foo/...`: the path guard rejects a literal "../", so the
// relative form must be rewritten.
func readGuidance(name string) string {
return fmt.Sprintf("> Tip: read this skill's own files (e.g. `references/...`) with "+
"`lark-cli skills read %s <relative-path>` to keep them in sync with this CLI version. "+
"A reference to another skill (`../lark-foo/...`) uses the same command with the "+
"leading `../` removed: `lark-cli skills read lark-foo/...`.", name)
}

306
cmd/skill/skill_test.go Normal file
View File

@@ -0,0 +1,306 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skill
import (
"encoding/json"
"io"
"io/fs"
"strings"
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/cmdutil"
)
// calFS is the default single-skill content tree for these tests. The embedded
// FS is now injected through the Factory (no package global), so tests pass it
// explicitly to run() — nothing is shared, so they are safe under -parallel.
func calFS() fstest.MapFS {
return fstest.MapFS{
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\nversion: 1.0.0\ndescription: \"Cal\"\nmetadata:\n cliHelp: \"lark-cli calendar --help\"\n---\nbody")},
"lark-calendar/references/agenda.md": {Data: []byte("# Agenda")},
}
}
// run executes the skills command tree against the given content FS (may be nil
// to exercise the not-embedded path) and returns stdout/stderr/err.
func run(t *testing.T, fsys fs.FS, args ...string) (stdout, stderr string, err error) {
t.Helper()
// Isolate CLI config state so tests never read/write the real config dir
// (repo convention).
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, out, errOut, _ := cmdutil.TestFactory(t, nil)
f.SkillContent = fsys
cmd := NewCmdSkill(f)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
err = cmd.Execute()
return out.String(), errOut.String(), err
}
func TestSkillList(t *testing.T) {
stdout, _, err := run(t, calFS(), "list")
if err != nil {
t.Fatalf("list error: %v", err)
}
var got struct {
OK bool `json:"ok"`
Skills []map[string]any `json:"skills"`
Count int `json:"count"`
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
}
// "ok" is an explicit success marker (the list envelope is a typed struct;
// no automatic _notice attaches).
if !got.OK {
t.Error("expected ok=true in list envelope")
}
if got.Count != 1 || len(got.Skills) != 1 {
t.Fatalf("count: got %d", got.Count)
}
if got.Skills[0]["name"] != "lark-calendar" {
t.Errorf("name: got %v", got.Skills[0]["name"])
}
// Top-level list carries version + metadata, not a references list.
if _, ok := got.Skills[0]["references"]; ok {
t.Error("top-level list must not include references")
}
if got.Skills[0]["version"] != "1.0.0" {
t.Errorf("version: got %v, want 1.0.0", got.Skills[0]["version"])
}
if _, ok := got.Skills[0]["metadata"]; !ok {
t.Error("expected metadata in list entry")
}
}
func TestSkillListJSONFlagAccepted(t *testing.T) {
// `list --json` must be accepted (no-op), not rejected as an unknown flag,
// so it stays symmetric with read --json.
stdout, _, err := run(t, calFS(), "list", "--json")
if err != nil {
t.Fatalf("list --json error: %v", err)
}
var got struct {
OK bool `json:"ok"`
Count int `json:"count"`
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
}
if !got.OK || got.Count != 1 {
t.Errorf("envelope: %+v", got)
}
}
func TestSkillListPath(t *testing.T) {
stdout, _, err := run(t, calFS(), "list", "lark-calendar")
if err != nil {
t.Fatalf("list <name> error: %v", err)
}
var got struct {
OK bool `json:"ok"`
Path string `json:"path"`
Entries []struct {
Path string `json:"path"`
IsDir bool `json:"is_dir"`
} `json:"entries"`
Count int `json:"count"`
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
}
if !got.OK || got.Path != "lark-calendar" {
t.Errorf("envelope: %+v", got)
}
// One layer under the skill root: SKILL.md (file) + references (dir).
if got.Count != 2 || len(got.Entries) != 2 {
t.Fatalf("entries: got %+v", got.Entries)
}
if got.Entries[0].Path != "lark-calendar/SKILL.md" || got.Entries[0].IsDir {
t.Errorf("entry[0]: got %+v", got.Entries[0])
}
if got.Entries[1].Path != "lark-calendar/references" || !got.Entries[1].IsDir {
t.Errorf("entry[1]: got %+v", got.Entries[1])
}
}
func TestSkillListPathUnknown(t *testing.T) {
_, _, err := run(t, calFS(), "list", "no-such-skill")
if err == nil || !strings.Contains(err.Error(), "unknown skill") {
t.Fatalf("expected 'unknown skill' error, got %v", err)
}
}
func TestSkillListPathTraversal(t *testing.T) {
stdout, _, err := run(t, calFS(), "list", "lark-calendar/../../etc")
if err == nil || !strings.Contains(err.Error(), "invalid path") {
t.Fatalf("expected 'invalid path' error, got %v", err)
}
if stdout != "" {
t.Errorf("stdout must be empty on rejection, got %q", stdout)
}
}
func TestSkillListTooManyArgs(t *testing.T) {
_, _, err := run(t, calFS(), "list", "a", "b")
if err == nil || !strings.Contains(err.Error(), "at most 1 argument") {
t.Fatalf("expected 'at most 1 argument' error, got %v", err)
}
}
// TestSkillListSkipsDirWithoutSKILLmd proves a top-level dir lacking SKILL.md is
// omitted from the catalog (no blank entry).
func TestSkillListSkipsDirWithoutSKILLmd(t *testing.T) {
fsys := fstest.MapFS{
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\ndescription: \"Cal\"\n---\nb")},
"not-a-skill/readme.txt": {Data: []byte("junk")}, // dir without SKILL.md
}
stdout, _, err := run(t, fsys, "list")
if err != nil {
t.Fatalf("list error: %v", err)
}
var got struct {
Skills []map[string]any `json:"skills"`
Count int `json:"count"`
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
}
if got.Count != 1 || got.Skills[0]["name"] != "lark-calendar" {
t.Fatalf("expected only lark-calendar, got %+v", got.Skills)
}
}
func TestSkillReadRaw(t *testing.T) {
stdout, stderr, err := run(t, calFS(), "read", "lark-calendar")
if err != nil {
t.Fatalf("read error: %v", err)
}
if !strings.HasPrefix(stdout, "---\nname: lark-calendar") {
t.Errorf("raw output: got %q", stdout)
}
// Raw stdout is byte-pure SKILL.md — the guidance tip must NOT be appended.
if strings.Contains(stdout, "Tip:") {
t.Errorf("raw stdout must not carry the guidance tip: got %q", stdout)
}
// Guidance goes to stderr: own files via `skills read <name> ...`, and
// cross-skill refs routed to `skills read <other-skill> ...` (version-
// consistent), not "read directly".
if !strings.Contains(stderr, "lark-cli skills read lark-calendar <relative-path>") {
t.Errorf("expected own-files guidance on stderr: got %q", stderr)
}
if !strings.Contains(stderr, "lark-cli skills read lark-foo/...") {
t.Errorf("expected cross-skill refs routed to skills read: got %q", stderr)
}
if strings.Contains(stderr, "instead of opening them directly") ||
strings.Contains(stderr, "read those directly") {
t.Errorf("guidance must not steer cross-skill refs to direct reads: got %q", stderr)
}
}
func TestSkillReadJSON(t *testing.T) {
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "--json")
if err != nil {
t.Fatalf("read --json error: %v", err)
}
var got struct {
Skill, Path, Content, Guidance string
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v", e)
}
if got.Skill != "lark-calendar" || got.Path != "SKILL.md" || got.Content == "" {
t.Errorf("envelope: %+v", got)
}
// Guidance is a separate field, not merged into content.
if got.Guidance == "" {
t.Error("expected guidance field for main SKILL.md")
}
if strings.Contains(got.Content, "Tip:") {
t.Error("guidance must not be merged into content")
}
}
func TestSkillReadFile(t *testing.T) {
// Both the 2-arg and slash forms read the same file, with no guidance tip.
for _, args := range [][]string{
{"read", "lark-calendar", "references/agenda.md"},
{"read", "lark-calendar/references/agenda.md"},
} {
stdout, stderr, err := run(t, calFS(), args...)
if err != nil {
t.Fatalf("read %v error: %v", args, err)
}
if stdout != "# Agenda" {
t.Errorf("read %v output: got %q", args, stdout)
}
// Reference reads carry no guidance on either stream.
if strings.Contains(stderr, "Tip:") {
t.Errorf("read %v must not emit guidance on stderr: got %q", args, stderr)
}
}
}
func TestSkillReadFileJSON(t *testing.T) {
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "references/agenda.md", "--json")
if err != nil {
t.Fatalf("read file --json error: %v", err)
}
var got struct {
Skill, Path, Content, Guidance string
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
}
if got.Skill != "lark-calendar" || got.Path != "references/agenda.md" || got.Content != "# Agenda" {
t.Errorf("envelope: %+v", got)
}
// Reference reads do not carry the guidance tip.
if got.Guidance != "" {
t.Errorf("reference read must not include guidance, got %q", got.Guidance)
}
}
func TestSkillReadUnknown(t *testing.T) {
_, _, err := run(t, calFS(), "read", "no-such")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "unknown skill") {
t.Errorf("err: %v", err)
}
}
func TestSkillReadMissingArg(t *testing.T) {
_, _, err := run(t, calFS(), "read")
if err == nil || !strings.Contains(err.Error(), "requires 1 or 2 arguments") {
t.Fatalf("expected arg error, got %v", err)
}
}
func TestSkillReadTraversal(t *testing.T) {
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "../../etc/passwd")
if err == nil {
t.Fatal("expected rejection")
}
if !strings.Contains(err.Error(), "invalid path") {
t.Errorf("err: %v", err)
}
if stdout != "" {
t.Errorf("stdout must be empty on rejection, got %q", stdout)
}
}
func TestSkillNilContentFS(t *testing.T) {
_, _, err := run(t, nil, "list")
if err == nil {
t.Fatal("expected error when SkillContent is nil")
}
if !strings.Contains(err.Error(), "not embedded") {
t.Errorf("err: %v", err)
}
}

View File

@@ -49,18 +49,29 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.NpmInstallOverride = npmFn
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
return func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(`{"skills":[{"name":"lark-calendar"},{"name":"lark-mail"}]}`)
return r
}
}
func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
return func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
switch strings.Join(args, " ") {
case "-y skills add https://open.feishu.cn --list":
r.Stdout.WriteString("Available Skills\n │ lark-calendar\n │ lark-mail\n")
case "-y skills ls -g --json":
r.Stdout.WriteString(`[{"name":"lark-calendar","path":"/tmp/lark-calendar","scope":"global","agents":["Codex"]},{"name":"custom-skill","path":"/tmp/custom-skill","scope":"global","agents":["Codex"]}]`)
case "-y skills ls -g":
r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n")
default:
@@ -476,6 +487,10 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
u.RestoreAvailableOverride = func() bool { return false }
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
t.Fatal("skills sync should not run when binary verification fails")
return nil
}
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
t.Fatal("skills sync should not run when binary verification fails")
return nil
@@ -808,6 +823,11 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("index unavailable")
return r
}
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found")
@@ -860,6 +880,11 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("index unavailable")
return r
}
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found")
@@ -1004,6 +1029,7 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsCommand()(args...)
@@ -1042,6 +1068,7 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{
Method: selfupdate.InstallManual,
@@ -1086,6 +1113,7 @@ func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{
Method: selfupdate.InstallNpm, NpmAvailable: true,
@@ -1145,6 +1173,10 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
},
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsIndexFetch()()
},
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsCommand()(args...)
@@ -1194,6 +1226,10 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsIndexFetch()()
},
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsCommand()(args...)

View File

@@ -5,9 +5,9 @@ package minutes
import (
"context"
"fmt"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
@@ -16,7 +16,8 @@ const cleanupTimeout = 5 * time.Second
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
if rt == nil {
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
body := map[string]string{"event_type": eventType}

View File

@@ -8,6 +8,7 @@ import (
"github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/events/minutes"
"github.com/larksuite/cli/events/vc"
"github.com/larksuite/cli/events/whiteboard"
"github.com/larksuite/cli/internal/event"
)
@@ -17,6 +18,7 @@ func init() {
im.Keys(),
minutes.Keys(),
vc.Keys(),
whiteboard.Keys(),
}
for _, keys := range all {
for _, k := range keys {

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
)
// isLarkCode must match the API code on typed errs.* errors — the consume
// runtime classifies OAPI failures via errclass.BuildAPIError, so the
// not-found retry in fillVCNoteGeneratedDetails depends on this reading
// Problem.Code rather than the legacy envelope shape.
func TestIsLarkCode_MatchesTypedAPIErrorCode(t *testing.T) {
typedNotFound := errs.NewAPIError(errs.SubtypeNotFound, "note not ready").
WithCode(vcNoteDetailNotFoundCode)
if !isLarkCode(typedNotFound, vcNoteDetailNotFoundCode) {
t.Fatal("typed API error carrying the not-found code must match (retry path)")
}
if isLarkCode(typedNotFound, 99999) {
t.Error("a different expected code must not match")
}
otherTyped := errs.NewAPIError(errs.SubtypeServerError, "boom").WithCode(500)
if isLarkCode(otherTyped, vcNoteDetailNotFoundCode) {
t.Error("typed error with another code must not match")
}
if isLarkCode(errors.New("plain failure"), vcNoteDetailNotFoundCode) {
t.Error("untyped error must not match")
}
}

View File

@@ -6,12 +6,11 @@ package vc
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
)
@@ -148,9 +147,8 @@ func fillVCNoteGeneratedDetails(ctx context.Context, rt event.APIClient, out *VC
}
func isLarkCode(err error, code int) bool {
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
return exitErr.Detail.Code == code
if p, ok := errs.ProblemOf(err); ok {
return p.Code == code
}
return false
}

View File

@@ -5,9 +5,9 @@ package vc
import (
"context"
"fmt"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
@@ -16,7 +16,8 @@ const cleanupTimeout = 5 * time.Second
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
if rt == nil {
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
body := map[string]string{"event_type": eventType}

View File

@@ -0,0 +1,84 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/larksuite/cli/internal/event"
)
// VCRecordingEndedOutput is the flattened shape for vc.recording.recording_ended_v1.
type VCRecordingEndedOutput struct {
Type string `json:"type" desc:"Event type; always vc.recording.recording_ended_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
EventTime string `json:"event_time,omitempty" desc:"Time when the recording ended and uploaded successfully, in RFC3339 / ISO 8601 with the current system timezone"`
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
}
type recordingEndedEnvelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event recordingEndedEvent `json:"event"`
}
type recordingEndedEvent struct {
UniqueKey string `json:"unique_key"`
Source string `json:"source"`
}
func processVCRecordingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
envelope, ok := parseRecordingEndedEnvelope(raw)
if !ok {
return raw.Payload, nil
}
if !isRecordingEndedBeanEvent(envelope) {
return nil, nil
}
out := &VCRecordingEndedOutput{
Type: recordingEndedEventType(envelope, raw),
EventID: envelope.Header.EventID,
EventTime: recordingEndedEventTime(envelope.Header.CreateTime),
UniqueKey: envelope.Event.UniqueKey,
Source: envelope.Event.Source,
}
return json.Marshal(out)
}
func parseRecordingEndedEnvelope(raw *event.RawEvent) (*recordingEndedEnvelope, bool) {
var envelope recordingEndedEnvelope
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return nil, false
}
return &envelope, true
}
func isRecordingEndedBeanEvent(envelope *recordingEndedEnvelope) bool {
return envelope != nil && envelope.Event.Source == "recording_bean"
}
func recordingEndedEventType(envelope *recordingEndedEnvelope, raw *event.RawEvent) string {
if envelope != nil && envelope.Header.EventType != "" {
return envelope.Header.EventType
}
return raw.EventType
}
func recordingEndedEventTime(raw string) string {
if raw == "" {
return ""
}
millis, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return ""
}
return time.UnixMilli(millis).Local().Format(time.RFC3339)
}

View File

@@ -0,0 +1,84 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/larksuite/cli/internal/event"
)
// VCRecordingStartedOutput is the flattened shape for vc.recording.recording_started_v1.
type VCRecordingStartedOutput struct {
Type string `json:"type" desc:"Event type; always vc.recording.recording_started_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
EventTime string `json:"event_time,omitempty" desc:"Recording start time in RFC3339 / ISO 8601 with the current system timezone"`
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
}
type recordingStartedEnvelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event recordingStartedEvent `json:"event"`
}
type recordingStartedEvent struct {
UniqueKey string `json:"unique_key"`
Source string `json:"source"`
}
func processVCRecordingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
envelope, ok := parseRecordingStartedEnvelope(raw)
if !ok {
return raw.Payload, nil
}
if !isRecordingStartedBeanEvent(envelope) {
return nil, nil
}
out := &VCRecordingStartedOutput{
Type: recordingStartedEventType(envelope, raw),
EventID: envelope.Header.EventID,
EventTime: recordingStartedEventTime(envelope.Header.CreateTime),
UniqueKey: envelope.Event.UniqueKey,
Source: envelope.Event.Source,
}
return json.Marshal(out)
}
func parseRecordingStartedEnvelope(raw *event.RawEvent) (*recordingStartedEnvelope, bool) {
var envelope recordingStartedEnvelope
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return nil, false
}
return &envelope, true
}
func isRecordingStartedBeanEvent(envelope *recordingStartedEnvelope) bool {
return envelope != nil && envelope.Event.Source == "recording_bean"
}
func recordingStartedEventType(envelope *recordingStartedEnvelope, raw *event.RawEvent) string {
if envelope != nil && envelope.Header.EventType != "" {
return envelope.Header.EventType
}
return raw.EventType
}
func recordingStartedEventTime(raw string) string {
if raw == "" {
return ""
}
millis, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return ""
}
return time.UnixMilli(millis).Local().Format(time.RFC3339)
}

468
events/vc/recording_test.go Normal file
View File

@@ -0,0 +1,468 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestVCKeys_RecordingEventsRegistered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
eventType string
}{
{eventTypeRecordingStarted},
{eventTypeRecordingTranscriptGenerated},
{eventTypeRecordingEnded},
} {
t.Run(tc.eventType, func(t *testing.T) {
def, ok := event.Lookup(tc.eventType)
if !ok {
t.Fatalf("%s should be registered via Keys()", tc.eventType)
}
if def.Schema.Custom == nil {
t.Error("Processed key must set Schema.Custom")
}
if def.Schema.Native != nil {
t.Error("Processed key must not set Schema.Native")
}
if def.Process == nil {
t.Error("Process must not be nil for processed key")
}
if def.PreConsume == nil {
t.Error("PreConsume must not be nil for processed key")
}
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:recording:read" {
t.Errorf("Scopes = %v", def.Scopes)
}
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v", def.AuthTypes)
}
if len(def.RequiredConsoleEvents) != 1 || def.RequiredConsoleEvents[0] != tc.eventType {
t.Errorf("RequiredConsoleEvents = %v", def.RequiredConsoleEvents)
}
if !strings.Contains(def.Description, "recording_bean") {
t.Errorf("Description should document recording_bean source, got %q", def.Description)
}
if !strings.Contains(def.Description, "connected to Feishu software") {
t.Errorf("Description should document Feishu software connection requirement, got %q", def.Description)
}
if strings.Contains(def.Description, "future") || strings.Contains(def.Description, "software_recording") {
t.Errorf("Description should not mention future sources, got %q", def.Description)
}
if tc.eventType == eventTypeRecordingEnded && (strings.Contains(def.Description, "object_type") || strings.Contains(def.Description, "object_id")) {
t.Errorf("ended Description should not document object metadata, got %q", def.Description)
}
wantSchemaType := reflect.TypeOf(VCRecordingStartedOutput{})
switch tc.eventType {
case eventTypeRecordingTranscriptGenerated:
wantSchemaType = reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{})
case eventTypeRecordingEnded:
wantSchemaType = reflect.TypeOf(VCRecordingEndedOutput{})
}
if def.Schema.Custom.Type != wantSchemaType {
t.Errorf("Custom schema Type = %v, want %v", def.Schema.Custom.Type, wantSchemaType)
}
})
}
}
func TestProcessVCRecordingStarted(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
out := runRecordingProcess[VCRecordingStartedOutput](t, eventTypeRecordingStarted, processVCRecordingStarted, `{
"schema": "2.0",
"header": {
"event_id": "ev_rec_start_001",
"event_type": "vc.recording.recording_started_v1",
"create_time": "1761782400000"
},
"event": {
"unique_key": "recording_001",
"source": "recording_bean"
}
}`)
if out.Type != eventTypeRecordingStarted {
t.Errorf("Type = %q", out.Type)
}
if out.EventID != "ev_rec_start_001" || out.EventTime != recordingTestEventTime(1761782400000) {
t.Errorf("EventID/EventTime = %q/%q", out.EventID, out.EventTime)
}
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
}
}
func TestProcessVCRecordingTranscriptGenerated(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
got := runRecordingProcessRaw(t, eventTypeRecordingTranscriptGenerated, processVCRecordingTranscriptGenerated, `{
"schema": "2.0",
"header": {
"event_id": "ev_rec_transcript_001",
"event_type": "vc.recording.recording_transcript_generated_v1",
"create_time": "1761782400100"
},
"event": {
"unique_key": "recording_001",
"source": "recording_bean",
"transcript_items": [
{
"speaker": {
"id": {
"open_id": "ou_0f8bf7acdf2ae69553ecbdbfbbd10a53",
"union_id": "on_bc03f16d781bff4178a5d11e48eb1867",
"user_id": null
},
"user_type": 100,
"user_role": 1,
"user_name": "Alice"
},
"text": "hello world",
"language": "en_us",
"start_time_ms": "1761782399000",
"end_time_ms": "1761782400000",
"sentence_id": "987654321"
},
{
"speaker": {
"user_name": "Bob"
},
"text": "second sentence",
"language": "en_us",
"start_time_ms": "1761782401000",
"end_time_ms": "1761782402000",
"sentence_id": "987654322"
}
]
}
}`)
if got == nil {
t.Fatal("Process output is nil")
}
var out VCRecordingTranscriptGeneratedOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
}
if out.Type != eventTypeRecordingTranscriptGenerated {
t.Errorf("Type = %q", out.Type)
}
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
}
if out.EventTime != recordingTestEventTime(1761782400100) {
t.Errorf("EventTime = %q", out.EventTime)
}
if len(out.TranscriptItems) != 2 {
t.Fatalf("TranscriptItems len = %d, want 2", len(out.TranscriptItems))
}
item := out.TranscriptItems[0]
if item.SpeakerName != "Alice" || item.Text != "hello world" {
t.Errorf("Transcript speaker/text = %q/%q", item.SpeakerName, item.Text)
}
if item.StartTime != recordingTestEventTime(1761782399000) || item.EndTime != recordingTestEventTime(1761782400000) {
t.Errorf("Transcript timing = %q/%q", item.StartTime, item.EndTime)
}
if item.SentenceID != "987654321" {
t.Errorf("SentenceID = %q, want 987654321", item.SentenceID)
}
if out.TranscriptItems[1].SpeakerName != "Bob" || out.TranscriptItems[1].SentenceID != "987654322" {
t.Errorf("second transcript item = %+v", out.TranscriptItems[1])
}
itemJSON, err := json.Marshal(item)
if err != nil {
t.Fatalf("marshal transcript item: %v", err)
}
var itemFields map[string]any
if err := json.Unmarshal(itemJSON, &itemFields); err != nil {
t.Fatalf("unmarshal transcript item JSON: %v", err)
}
wantItemFields := map[string]bool{
"speaker_name": true,
"text": true,
"start_time": true,
"end_time": true,
"sentence_id": true,
}
for gotField := range itemFields {
if !wantItemFields[gotField] {
t.Errorf("Transcript item should not contain field %q, got %s", gotField, string(itemJSON))
}
}
for wantField := range wantItemFields {
if _, ok := itemFields[wantField]; !ok {
t.Errorf("Transcript item missing field %q, got %s", wantField, string(itemJSON))
}
}
for _, unexpected := range []string{
`"seq_id"`,
`"speaker"`,
`"user_open_id"`,
`"user_type"`,
`"user_role"`,
`"language"`,
`"start_time_ms"`,
`"end_time_ms"`,
`"sequence_id"`,
`"transcript_item"`,
} {
if strings.Contains(string(got), unexpected) {
t.Errorf("Transcript output should not contain %s, got %s", unexpected, string(got))
}
}
if !strings.Contains(string(got), `"sentence_id":"987654321"`) {
t.Errorf("Transcript output should contain sentence_id, got %s", string(got))
}
}
func TestProcessVCRecordingEnded(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
out := runRecordingProcess[VCRecordingEndedOutput](t, eventTypeRecordingEnded, processVCRecordingEnded, `{
"schema": "2.0",
"header": {
"event_id": "ev_rec_end_001",
"event_type": "vc.recording.recording_ended_v1",
"create_time": "1761782400200"
},
"event": {
"unique_key": "recording_001",
"source": "recording_bean",
"object_type": "minutes",
"object_id": "minute_token_001"
}
}`)
if out.Type != eventTypeRecordingEnded {
t.Errorf("Type = %q", out.Type)
}
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
}
if out.EventTime != recordingTestEventTime(1761782400200) {
t.Errorf("EventTime = %q", out.EventTime)
}
}
func TestProcessVCRecordingEnded_DropsObjectMetadata(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
got := runRecordingProcessRaw(t, eventTypeRecordingEnded, processVCRecordingEnded, `{
"schema": "2.0",
"header": {
"event_id": "ev_rec_end_001",
"event_type": "vc.recording.recording_ended_v1",
"create_time": "1761782400200"
},
"event": {
"unique_key": "recording_001",
"source": "recording_bean",
"object_type": "minutes",
"object_id": "minute_token_001"
}
}`)
if strings.Contains(string(got), "object_type") || strings.Contains(string(got), "object_id") {
t.Fatalf("ended output should drop object metadata, got %s", string(got))
}
}
func TestProcessVCRecording_DropsTimestampField(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
got := runRecordingProcessRaw(t, eventTypeRecordingStarted, processVCRecordingStarted, `{
"schema": "2.0",
"header": {
"event_id": "ev_rec_start_001",
"event_type": "vc.recording.recording_started_v1",
"create_time": "1761782400000"
},
"event": {
"unique_key": "recording_001",
"source": "recording_bean"
}
}`)
if strings.Contains(string(got), `"timestamp"`) {
t.Fatalf("recording output should use event_time instead of timestamp, got %s", string(got))
}
if !strings.Contains(string(got), `"event_time":"`+recordingTestEventTime(1761782400000)+`"`) {
t.Fatalf("recording output should include ISO 8601 event_time, got %s", string(got))
}
}
func TestProcessVCRecording_NonRecordingBeanFiltered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
payload string
}{
{
name: "started",
eventType: eventTypeRecordingStarted,
process: processVCRecordingStarted,
payload: `{
"schema": "2.0",
"header": {"event_id": "ev_rec_start_001", "event_type": "vc.recording.recording_started_v1"},
"event": {"unique_key": "recording_001", "source": "software_recording"}
}`,
},
{
name: "transcript",
eventType: eventTypeRecordingTranscriptGenerated,
process: processVCRecordingTranscriptGenerated,
payload: `{
"schema": "2.0",
"header": {"event_id": "ev_rec_transcript_001", "event_type": "vc.recording.recording_transcript_generated_v1"},
"event": {"unique_key": "recording_001", "source": "software_recording", "transcript_items": []}
}`,
},
{
name: "ended",
eventType: eventTypeRecordingEnded,
process: processVCRecordingEnded,
payload: `{
"schema": "2.0",
"header": {"event_id": "ev_rec_end_001", "event_type": "vc.recording.recording_ended_v1"},
"event": {"unique_key": "recording_001", "source": "software_recording"}
}`,
},
} {
t.Run(tc.name, func(t *testing.T) {
got := runRecordingProcessRaw(t, tc.eventType, tc.process, tc.payload)
if got != nil {
t.Fatalf("non-recording_bean event should be filtered, got %s", string(got))
}
})
}
}
func TestProcessVCRecording_MalformedPayloadPassthrough(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
}{
{name: "started", eventType: eventTypeRecordingStarted, process: processVCRecordingStarted},
{name: "transcript", eventType: eventTypeRecordingTranscriptGenerated, process: processVCRecordingTranscriptGenerated},
{name: "ended", eventType: eventTypeRecordingEnded, process: processVCRecordingEnded},
} {
t.Run(tc.name, func(t *testing.T) {
raw := &event.RawEvent{
EventType: tc.eventType,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := tc.process(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
})
}
}
func TestVCRecording_PreConsumeSubscriptionLifecycle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
eventType string
}{
{eventTypeRecordingStarted},
{eventTypeRecordingTranscriptGenerated},
{eventTypeRecordingEnded},
} {
t.Run(tc.eventType, func(t *testing.T) {
def, ok := event.Lookup(tc.eventType)
if !ok {
t.Fatalf("%s should be registered via Keys()", tc.eventType)
}
type call struct {
method string
path string
body any
}
var calls []call
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
calls = append(calls, call{method: method, path: path, body: body})
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
},
}
cleanup, err := def.PreConsume(context.Background(), rt, nil)
if err != nil {
t.Fatalf("PreConsume error: %v", err)
}
if cleanup == nil {
t.Fatal("cleanup must not be nil")
}
if len(calls) != 1 {
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
}
if calls[0].method != "POST" || calls[0].path != pathRecordingSubscribe {
t.Fatalf("subscribe call = %+v", calls[0])
}
assertSubscriptionRequest(t, calls[0].body, tc.eventType)
cleanup()
if len(calls) != 2 {
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
}
if calls[1].method != "POST" || calls[1].path != pathRecordingUnsubscribe {
t.Fatalf("unsubscribe call = %+v", calls[1])
}
assertSubscriptionRequest(t, calls[1].body, tc.eventType)
})
}
}
func runRecordingProcess[T any](t *testing.T, eventType string, process event.ProcessFunc, payload string) T {
t.Helper()
got := runRecordingProcessRaw(t, eventType, process, payload)
if got == nil {
t.Fatal("Process output is nil")
}
var out T
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
}
return out
}
func runRecordingProcessRaw(t *testing.T, eventType string, process event.ProcessFunc, payload string) json.RawMessage {
t.Helper()
raw := &event.RawEvent{
EventType: eventType,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := process(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
return got
}
func recordingTestEventTime(millis int64) string {
return time.UnixMilli(millis).Local().Format(time.RFC3339)
}

View File

@@ -0,0 +1,163 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/larksuite/cli/internal/event"
)
// VCRecordingTranscriptItemOutput is one flattened transcript item for recording events.
type VCRecordingTranscriptItemOutput struct {
SpeakerName string `json:"speaker_name,omitempty" desc:"Speaker display name"`
Text string `json:"text,omitempty" desc:"Transcript text"`
StartTime string `json:"start_time,omitempty" desc:"Transcript item start time in RFC3339 / ISO 8601 with the current system timezone"`
EndTime string `json:"end_time,omitempty" desc:"Transcript item end time in RFC3339 / ISO 8601 with the current system timezone"`
SentenceID string `json:"sentence_id,omitempty" desc:"Transcript sentence ID"`
}
// VCRecordingTranscriptGeneratedOutput is the flattened shape for vc.recording.recording_transcript_generated_v1.
type VCRecordingTranscriptGeneratedOutput struct {
Type string `json:"type" desc:"Event type; always vc.recording.recording_transcript_generated_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
EventTime string `json:"event_time,omitempty" desc:"Time when this batch of transcript items was generated, in RFC3339 / ISO 8601 with the current system timezone"`
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
TranscriptItems []VCRecordingTranscriptItemOutput `json:"transcript_items,omitempty" desc:"Generated transcript items"`
}
type recordingTranscriptGeneratedEnvelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event recordingTranscriptGeneratedEvent `json:"event"`
}
type recordingTranscriptGeneratedEvent struct {
UniqueKey string `json:"unique_key"`
Source string `json:"source"`
TranscriptItems []recordingTranscriptGeneratedItemIn `json:"transcript_items"`
}
type recordingTranscriptGeneratedItemIn struct {
Speaker *recordingTranscriptGeneratedSpeakerIn `json:"speaker"`
Text string `json:"text"`
StartTimeMs recordingTranscriptGeneratedString `json:"start_time_ms"`
EndTimeMs recordingTranscriptGeneratedString `json:"end_time_ms"`
SentenceID string `json:"sentence_id"`
}
type recordingTranscriptGeneratedSpeakerIn struct {
UserName string `json:"user_name"`
}
type recordingTranscriptGeneratedString string
func processVCRecordingTranscriptGenerated(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
envelope, ok := parseRecordingTranscriptGeneratedEnvelope(raw)
if !ok {
return raw.Payload, nil
}
if !isRecordingTranscriptGeneratedBeanEvent(envelope) {
return nil, nil
}
out := &VCRecordingTranscriptGeneratedOutput{
Type: recordingTranscriptGeneratedEventType(envelope, raw),
EventID: envelope.Header.EventID,
EventTime: recordingTranscriptGeneratedEventTime(envelope.Header.CreateTime),
UniqueKey: envelope.Event.UniqueKey,
Source: envelope.Event.Source,
TranscriptItems: recordingTranscriptItems(envelope.Event.TranscriptItems),
}
return json.Marshal(out)
}
func parseRecordingTranscriptGeneratedEnvelope(raw *event.RawEvent) (*recordingTranscriptGeneratedEnvelope, bool) {
var envelope recordingTranscriptGeneratedEnvelope
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return nil, false
}
return &envelope, true
}
func isRecordingTranscriptGeneratedBeanEvent(envelope *recordingTranscriptGeneratedEnvelope) bool {
return envelope != nil && envelope.Event.Source == "recording_bean"
}
func recordingTranscriptGeneratedEventType(envelope *recordingTranscriptGeneratedEnvelope, raw *event.RawEvent) string {
if envelope != nil && envelope.Header.EventType != "" {
return envelope.Header.EventType
}
return raw.EventType
}
func recordingTranscriptGeneratedEventTime(raw string) string {
return recordingTranscriptGeneratedMillisToLocalRFC3339(raw)
}
func recordingTranscriptGeneratedMillisToLocalRFC3339(raw string) string {
if raw == "" {
return ""
}
millis, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return ""
}
return time.UnixMilli(millis).Local().Format(time.RFC3339)
}
func recordingTranscriptItems(items []recordingTranscriptGeneratedItemIn) []VCRecordingTranscriptItemOutput {
if len(items) == 0 {
return nil
}
out := make([]VCRecordingTranscriptItemOutput, 0, len(items))
for _, item := range items {
out = append(out, recordingTranscriptItem(item))
}
return out
}
func recordingTranscriptItem(item recordingTranscriptGeneratedItemIn) VCRecordingTranscriptItemOutput {
return VCRecordingTranscriptItemOutput{
SpeakerName: recordingSpeakerName(item.Speaker),
Text: item.Text,
StartTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.StartTimeMs.String()),
EndTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.EndTimeMs.String()),
SentenceID: item.SentenceID,
}
}
func recordingSpeakerName(speaker *recordingTranscriptGeneratedSpeakerIn) string {
if speaker == nil {
return ""
}
return speaker.UserName
}
func (s *recordingTranscriptGeneratedString) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
return nil
}
var str string
if err := json.Unmarshal(data, &str); err == nil {
*s = recordingTranscriptGeneratedString(str)
return nil
}
var num json.Number
if err := json.Unmarshal(data, &num); err != nil {
return err
}
*s = recordingTranscriptGeneratedString(num.String())
return nil
}
func (s recordingTranscriptGeneratedString) String() string {
return string(s)
}

View File

@@ -11,13 +11,18 @@ import (
)
const (
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
eventTypeNoteGenerated = "vc.note.generated_v1"
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
eventTypeNoteGenerated = "vc.note.generated_v1"
eventTypeRecordingStarted = "vc.recording.recording_started_v1"
eventTypeRecordingTranscriptGenerated = "vc.recording.recording_transcript_generated_v1"
eventTypeRecordingEnded = "vc.recording.recording_ended_v1"
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription"
pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription"
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription"
pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription"
pathRecordingSubscribe = "/open-apis/vc/v1/recordings/subscription"
pathRecordingUnsubscribe = "/open-apis/vc/v1/recordings/unsubscription"
pathNoteDetailFmt = "/open-apis/vc/v1/notes/%s"
)
@@ -57,5 +62,53 @@ func Keys() []event.KeyDefinition {
},
RequiredConsoleEvents: []string{eventTypeNoteGenerated},
},
{
Key: eventTypeRecordingStarted,
DisplayName: "Recording started",
Description: "Triggered when a recording_bean recording starts; only generated when connected to Feishu software.",
EventType: eventTypeRecordingStarted,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingStartedOutput{})},
},
Process: processVCRecordingStarted,
PreConsume: subscriptionPreConsume(eventTypeRecordingStarted, pathRecordingSubscribe, pathRecordingUnsubscribe),
Scopes: []string{"vc:recording:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeRecordingStarted},
},
{
Key: eventTypeRecordingTranscriptGenerated,
DisplayName: "Recording transcript generated",
Description: "Triggered when recording_bean transcript items are generated; only generated when connected to Feishu software.",
EventType: eventTypeRecordingTranscriptGenerated,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{})},
},
Process: processVCRecordingTranscriptGenerated,
PreConsume: subscriptionPreConsume(eventTypeRecordingTranscriptGenerated, pathRecordingSubscribe, pathRecordingUnsubscribe),
Scopes: []string{"vc:recording:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeRecordingTranscriptGenerated},
},
{
Key: eventTypeRecordingEnded,
DisplayName: "Recording ended",
Description: "Triggered when a recording_bean recording ends and uploads successfully; only generated when connected to Feishu software.",
EventType: eventTypeRecordingEnded,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingEndedOutput{})},
},
Process: processVCRecordingEnded,
PreConsume: subscriptionPreConsume(eventTypeRecordingEnded, pathRecordingSubscribe, pathRecordingUnsubscribe),
Scopes: []string{"vc:recording:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeRecordingEnded},
},
}
}

View File

@@ -0,0 +1,23 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
// BoardWhiteboardUpdatedV1Data is the flattened whiteboard updated source payload.
type BoardWhiteboardUpdatedV1Data struct {
// WhiteboardID is the id of the whiteboard whose content was updated.
WhiteboardID string `json:"whiteboard_id"`
// OperatorIDs lists the operators that produced this update batch.
OperatorIDs []OperatorID `json:"operator_ids"`
}
// OperatorID identifies an operator that produced the whiteboard update,
// expressed in the three Lark identity formats.
type OperatorID struct {
// OpenID is the operator's open_id within the current app.
OpenID string `json:"open_id"`
// UnionID is the operator's union_id across apps under the same ISV.
UnionID string `json:"union_id"`
// UserID is the operator's user_id within the tenant.
UserID string `json:"user_id"`
}

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"fmt"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/validate"
)
// cleanupTimeout bounds how long the unsubscribe call has to finish during
// PreConsume cleanup so a stuck OAPI cannot block process shutdown.
const cleanupTimeout = 5 * time.Second
// whiteboardSubscriptionPreConsume calls the whiteboard event subscribe OAPI
// and returns a cleanup that invokes the matching unsubscribe.
//
// board.whiteboard.updated_v1 is subscribed per-whiteboard (by whiteboard_id),
// so the path contains a :whiteboard_id placeholder that must be supplied via params.
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func(), error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
whiteboardID := params["whiteboard_id"]
if whiteboardID == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"param whiteboard_id is required for %s", eventType).
WithParam("--param").
WithHint("pass it as --param whiteboard_id=<id>; run `lark-cli event schema %s` for details", eventType)
}
encoded := validate.EncodePathSegment(whiteboardID)
subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded)
unsubscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/unsubscribe", encoded)
body := map[string]string{"event_type": eventType}
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
return nil, err
}
return func() {
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
defer cancel()
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
}, nil
}
}

View File

@@ -0,0 +1,212 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
// recordedCall captures a single APIClient invocation for assertion.
type recordedCall struct {
method string
path string
body interface{}
}
// fakeAPIClient is a minimal event.APIClient stub that records calls and
// can be configured to fail when the request path matches errOnPath.
type fakeAPIClient struct {
mu sync.Mutex
calls []recordedCall
errOnPath string
}
// CallAPI records the invocation and optionally returns a simulated error
// when the path contains the configured errOnPath substring.
func (f *fakeAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, recordedCall{method: method, path: path, body: body})
if f.errOnPath != "" && strings.Contains(path, f.errOnPath) {
return nil, errors.New("simulated subscribe failure")
}
return json.RawMessage(`{}`), nil
}
// TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID verifies that the
// PreConsume hook fails fast with an actionable error when whiteboard_id
// is absent from the params map.
func TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{})
if err == nil {
t.Fatalf("expected error when whiteboard_id missing")
}
if cleanup != nil {
t.Fatalf("expected nil cleanup on error")
}
if !strings.Contains(err.Error(), "whiteboard_id") {
t.Fatalf("error should mention whiteboard_id, got: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--param")
}
if ve.Hint == "" {
t.Error("missing whiteboard_id should carry a hint")
}
}
// TestWhiteboardSubscriptionPreConsume_NilRuntime verifies that PreConsume
// returns an error when the runtime APIClient dependency is missing.
func TestWhiteboardSubscriptionPreConsume_NilRuntime(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
_, err := pc(context.Background(), nil, map[string]string{"whiteboard_id": "wb1"})
if err == nil {
t.Fatalf("expected error when runtime client is nil")
}
if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryInternal {
t.Errorf("nil-runtime invariant should be a typed internal error, got %T: %v", err, err)
}
}
// TestWhiteboardSubscriptionPreConsume_SubscribeError verifies that a
// failed subscribe call surfaces the error and skips registering a cleanup,
// so no spurious unsubscribe is invoked.
func TestWhiteboardSubscriptionPreConsume_SubscribeError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
rt := &fakeAPIClient{errOnPath: "/subscribe"}
cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"})
if err == nil {
t.Fatalf("expected error from subscribe call")
}
if cleanup != nil {
t.Fatalf("expected nil cleanup when subscribe fails")
}
// only the failed subscribe call should have been made; no unsubscribe.
if len(rt.calls) != 1 {
t.Fatalf("expected exactly 1 call (subscribe), got %d", len(rt.calls))
}
}
// TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup verifies the full
// happy-path: subscribe is called once with the correct method/path/body,
// and the returned cleanup invokes the matching unsubscribe.
func TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
rt := &fakeAPIClient{}
cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cleanup == nil {
t.Fatalf("expected non-nil cleanup")
}
if len(rt.calls) != 1 {
t.Fatalf("expected 1 call after subscribe, got %d", len(rt.calls))
}
got := rt.calls[0]
if got.method != "POST" {
t.Errorf("subscribe method: got %q, want POST", got.method)
}
wantSubPath := "/open-apis/board/v1/whiteboards/wb1/subscribe"
if got.path != wantSubPath {
t.Errorf("subscribe path: got %q, want %q", got.path, wantSubPath)
}
body, _ := got.body.(map[string]string)
if body["event_type"] != eventTypeWhiteboardUpdated {
t.Errorf("subscribe body event_type: got %q, want %q", body["event_type"], eventTypeWhiteboardUpdated)
}
cleanup()
if len(rt.calls) != 2 {
t.Fatalf("expected 2 calls after cleanup, got %d", len(rt.calls))
}
got2 := rt.calls[1]
if got2.method != "POST" {
t.Errorf("unsubscribe method: got %q, want POST", got2.method)
}
wantUnsubPath := "/open-apis/board/v1/whiteboards/wb1/unsubscribe"
if got2.path != wantUnsubPath {
t.Errorf("unsubscribe path: got %q, want %q", got2.path, wantUnsubPath)
}
body2, _ := got2.body.(map[string]string)
if body2["event_type"] != eventTypeWhiteboardUpdated {
t.Errorf("unsubscribe body event_type: got %q, want %q", body2["event_type"], eventTypeWhiteboardUpdated)
}
}
// TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded verifies that
// whiteboard_id values containing reserved URL characters are properly
// path-segment encoded so they cannot escape into adjacent path segments.
func TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
rt := &fakeAPIClient{}
// 含特殊字符的 whiteboard_id 应被 path-segment 编码,避免越界到其他 path 段。
_, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb/1?evil"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rt.calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(rt.calls))
}
if strings.Contains(rt.calls[0].path, "wb/1?evil") {
t.Errorf("whiteboard_id was not encoded; path: %s", rt.calls[0].path)
}
}
// TestWhiteboardUpdatedV1HasPreConsume ensures the registered EventKey for
// board.whiteboard.updated_v1 wires the PreConsume hook and declares the
// required whiteboard_id parameter.
func TestWhiteboardUpdatedV1HasPreConsume(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
keys := Keys()
for _, k := range keys {
if k.Key == eventTypeWhiteboardUpdated {
if k.PreConsume == nil {
t.Fatalf("EventKey %s should have PreConsume hook", eventTypeWhiteboardUpdated)
}
if len(k.Params) == 0 {
t.Fatalf("EventKey %s should declare whiteboard_id param", eventTypeWhiteboardUpdated)
}
var found bool
for _, p := range k.Params {
if p.Name == "whiteboard_id" && p.Required {
found = true
}
}
if !found {
t.Fatalf("EventKey %s must declare required whiteboard_id param", eventTypeWhiteboardUpdated)
}
return
}
}
t.Fatalf("EventKey %s not registered", eventTypeWhiteboardUpdated)
}
// 确保 event.APIClient 接口与本测试 mock 一致。
var _ event.APIClient = (*fakeAPIClient)(nil)

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package whiteboard registers Board-domain EventKeys.
package whiteboard
import (
"reflect"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
)
// eventTypeWhiteboardUpdated is the OAPI event type for whiteboard content updates.
const eventTypeWhiteboardUpdated = "board.whiteboard.updated_v1"
// Keys returns all Board-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeWhiteboardUpdated,
DisplayName: "Whiteboard updated",
Description: "Pushed when the whiteboard content is updated.",
EventType: eventTypeWhiteboardUpdated,
Params: []event.ParamDef{
{
Name: "whiteboard_id",
Type: event.ParamString,
Required: true,
Description: "Whiteboard id to subscribe; subscription is per-whiteboard.",
},
},
Schema: event.SchemaDef{
Native: &event.SchemaSpec{Type: reflect.TypeOf(BoardWhiteboardUpdatedV1Data{})},
FieldOverrides: map[string]schemas.FieldMeta{
"/event/whiteboard_id": {Kind: "whiteboard_id", Description: "whiteboard id to subscribe"},
"/event/operator_ids/*/open_id": {Kind: "open_id"},
"/event/operator_ids/*/union_id": {Kind: "union_id"},
"/event/operator_ids/*/user_id": {Kind: "user_id"},
},
},
PreConsume: whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated),
Scopes: []string{"board:whiteboard:node:read"},
AuthTypes: []string{"user", "bot"},
RequiredConsoleEvents: []string{eventTypeWhiteboardUpdated},
},
}
}

View File

@@ -6,6 +6,7 @@ package cmdutil
import (
"context"
"io"
"io/fs"
"net/http"
"strings"
@@ -43,6 +44,8 @@ type Factory struct {
Credential *credential.CredentialProvider
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
}
// ResolveFileIO resolves a FileIO instance using the current execution context.

View File

@@ -92,6 +92,18 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
base.Troubleshooter = ts
}
}
// Upstream-provided field-level reasons (resp.error.details[].value). Lark
// returns these as free-text reason strings with no machine-readable field
// name (verified for code 190014:
// {"error":{"details":[{"value":"end_time should be later than start_time"}]}}),
// so they are lifted into Problem.Hint — the sanctioned free-text recovery
// prompt — rather than fabricated structured params. Lifted before the
// category switch so any classified arm inherits it; the CategoryAPI arm
// below prefers this server detail over the context-free APIHint default.
detailHint := liftErrorDetailValues(resp)
if detailHint != "" {
base.Hint = detailHint
}
switch meta.Category {
case errs.CategoryAuthorization:
@@ -129,7 +141,11 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
Action: action,
}
case errs.CategoryAPI:
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
// A server-supplied detail (lifted into base.Hint above) wins over the
// context-free APIHint default; only fall back to APIHint when absent.
if base.Hint == "" {
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
}
return &errs.APIError{Problem: base}
default:
// Fail closed: an unrecognized Category routes to InternalError
@@ -214,6 +230,10 @@ func stringFromAny(v any) string {
// per-subtype recovery hint before returning it, so the wire envelope
// emitted via BuildAPIError always carries a hint for known config subtypes.
func buildConfigError(p errs.Problem) *errs.ConfigError {
// Config categories have authoritative recovery guidance, so the curated
// ConfigHint deliberately overrides any server detail lifted into p.Hint
// (the opposite precedence from the CategoryAPI arm, where the lifted
// detail wins).
p.Hint = ConfigHint(p.Subtype)
return &errs.ConfigError{Problem: p}
}
@@ -244,6 +264,8 @@ func APIHint(subtype errs.Subtype) string {
return "operate on source and target within the same tenant and region/unit"
case errs.SubtypeCrossBrand:
return "operate on source and target within the same brand environment"
case errs.SubtypeQuotaExceeded:
return "reduce the request volume or free quota, then retry after the relevant quota resets"
}
return ""
}
@@ -256,6 +278,10 @@ func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContex
}
consoleURL := ConsoleURL(cc.Brand, cc.AppID, missing)
p.Message = CanonicalPermissionMessage(p.Subtype, cc.AppID, missing, p.Message)
// Permission categories have authoritative recovery guidance (scopes to
// grant, console URL), so the curated PermissionHint deliberately overrides
// any server detail lifted into p.Hint (the opposite precedence from the
// CategoryAPI arm, where the lifted detail wins).
p.Hint = PermissionHint(missing, identity, p.Subtype, consoleURL)
permErr := &errs.PermissionError{
Problem: p,
@@ -364,6 +390,32 @@ func PermissionHint(missing []string, identity string, subtype errs.Subtype, con
return "check the calling identity has the required scope"
}
// liftErrorDetailValues collects the non-empty resp.error.details[].value reason
// strings and joins them with "; ". Returns "" when the structure is absent or
// carries no non-empty value. The shape (verified for code 190014) is
// {"error":{"details":[{"value":"<reason>"}]}}.
func liftErrorDetailValues(resp map[string]any) string {
errBlock, ok := resp["error"].(map[string]any)
if !ok {
return ""
}
details, ok := errBlock["details"].([]any)
if !ok || len(details) == 0 {
return ""
}
var values []string
for _, d := range details {
m, ok := d.(map[string]any)
if !ok {
continue
}
if v, _ := m["value"].(string); v != "" {
values = append(values, v)
}
}
return strings.Join(values, "; ")
}
// extractMissingScopes walks resp["error"]["permission_violations"][].subject.
// Returns nil when the structure is absent.
func extractMissingScopes(resp map[string]any) []string {

View File

@@ -220,6 +220,111 @@ func TestBuildAPIError_TroubleshooterLiftedOnPermissionArm(t *testing.T) {
}
}
// TestBuildAPIError_DetailsLiftedToHintOnAPIArm pins that BuildAPIError lifts
// resp.error.details[].value into Problem.Hint when the response routes to the
// catch-all CategoryAPI arm. The real Lark shape (verified for code 190014) is
// {"error":{"details":[{"value":"end_time should be later than start_time"}]}}
// — only a human-readable reason string, no machine-readable field name. It is
// lifted into Hint (sanctioned free-text recovery prompt) rather than fabricated
// structured params.
func TestBuildAPIError_DetailsLiftedToHintOnAPIArm(t *testing.T) {
resp := map[string]any{
"code": 190014,
"msg": "invalid params",
"error": map[string]any{
"details": []any{
map[string]any{"value": "end_time should be later than start_time"},
},
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatal("ProblemOf returned !ok")
}
if !strings.Contains(p.Hint, "end_time should be later than start_time") {
t.Errorf("Hint = %q, want it to contain the server detail value", p.Hint)
}
}
// TestBuildAPIError_MultipleDetailsJoinedIntoHint pins that multiple non-empty
// detail values are joined with "; " into a single Hint, and empty values are
// skipped.
func TestBuildAPIError_MultipleDetailsJoinedIntoHint(t *testing.T) {
resp := map[string]any{
"code": 190014,
"msg": "invalid params",
"error": map[string]any{
"details": []any{
map[string]any{"value": "first reason"},
map[string]any{"value": ""},
map[string]any{"value": "second reason"},
},
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatal("ProblemOf returned !ok")
}
if p.Hint != "first reason; second reason" {
t.Errorf("Hint = %q, want %q", p.Hint, "first reason; second reason")
}
}
// TestBuildAPIError_DetailsSkipsNonMapEntries pins that malformed entries in
// the details array (not a JSON object) are skipped rather than panicking, and
// well-formed siblings still surface in the Hint.
func TestBuildAPIError_DetailsSkipsNonMapEntries(t *testing.T) {
resp := map[string]any{
"code": 190014,
"msg": "invalid params",
"error": map[string]any{
"details": []any{
"i am a bare string, not an object",
map[string]any{"value": "the real reason"},
42,
},
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatal("ProblemOf returned !ok")
}
if p.Hint != "the real reason" {
t.Errorf("Hint = %q, want %q", p.Hint, "the real reason")
}
}
// TestBuildAPIError_DetailsMalformedShapesNoHint pins that a missing error
// block, a non-array details field, and an empty details array all leave the
// Hint untouched (no lifted detail) instead of erroring.
func TestBuildAPIError_DetailsMalformedShapesNoHint(t *testing.T) {
cases := []struct {
name string
resp map[string]any
}{
{"no error block", map[string]any{"code": 190014, "msg": "invalid params"}},
{"details not array", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": "nope"}}},
{"empty details", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": []any{}}}},
{"detail values all empty", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": []any{map[string]any{"value": ""}}}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := errclass.BuildAPIError(tc.resp, errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatal("ProblemOf returned !ok")
}
// With no liftable detail, the Hint must not echo a server detail.
if strings.Contains(p.Hint, "nope") {
t.Errorf("Hint should not lift a non-array details field, got %q", p.Hint)
}
})
}
}
// TestBuildAPIError_TroubleshooterAbsent pins that Troubleshooter stays empty
// when the upstream response omits it — wire envelope must omit the field.
func TestBuildAPIError_TroubleshooterAbsent(t *testing.T) {

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// calendarCodeMeta holds calendar-service Lark code → CodeMeta mappings.
// Only codes whose meaning is verifiable from repo evidence are registered;
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var calendarCodeMeta = map[int]CodeMeta{
190014: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid params (carries a field-level detail lifted into Hint)
}
func init() { mergeCodeMeta(calendarCodeMeta, "calendar") }

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import (
"fmt"
"testing"
"github.com/larksuite/cli/errs"
)
// TestLookupCodeMeta_CalendarCodes pins each calendar-service code registered
// via the codemeta_calendar.go init() merge to its expected
// Category/Subtype/Retryable.
func TestLookupCodeMeta_CalendarCodes(t *testing.T) {
cases := []struct {
code int
wantCat errs.Category
wantSubtype errs.Subtype
wantRetry bool
}{
// 190014: calendar "invalid params" with a field-level detail
// (error.details[].value) lifted into Hint by BuildAPIError.
{190014, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
meta, ok := LookupCodeMeta(tc.code)
if !ok {
t.Fatalf("code %d not registered in codeMeta", tc.code)
}
if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry {
t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v",
tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry)
}
})
}
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// mailCodeMeta holds mail-service Lark code -> CodeMeta mappings.
// Only codes whose meaning is verifiable from repo evidence are registered;
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
var mailCodeMeta = map[int]CodeMeta{
1234013: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // mailbox not found or not active
1236007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // user daily send count exceeded
1236008: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // user daily external recipient count exceeded
1236009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tenant daily external recipient count exceeded
1236010: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // mail quota limit
1236013: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tenant storage limit exceeded
}
func init() { mergeCodeMeta(mailCodeMeta, "mail") }

View File

@@ -0,0 +1,18 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// minutesCodeMeta holds minutes-service Lark code → CodeMeta mappings.
// Only codes whose meaning is stable across minutes endpoints are registered;
// endpoint-specific codes fall back to CategoryAPI via BuildAPIError.
// Command-specific messages, hints, and subtypes are layered on top via
// per-command enrichment.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var minutesCodeMeta = map[int]CodeMeta{
2091005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller lacks edit/read permission for the minute
}
func init() { mergeCodeMeta(minutesCodeMeta, "minutes") }

View File

@@ -70,6 +70,12 @@ func TestLookupCodeMeta_TaskPermissionDenied_MergedViaInit(t *testing.T) {
}
}
func TestLookupCodeMeta_MinutesEndpointSpecificCode_NotGlobal(t *testing.T) {
if got, ok := LookupCodeMeta(2091001); ok {
t.Fatalf("LookupCodeMeta(2091001) = %+v, want unregistered; minutes endpoints use this code for different failures", got)
}
}
func TestLookupCodeMeta_RetryableAuthCode(t *testing.T) {
got, ok := LookupCodeMeta(20050)
if !ok {

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// vcCodeMeta holds vc-service Lark code → CodeMeta mappings.
// Only codes whose meaning is verifiable from repo evidence are registered;
// ambiguous codes (e.g. 124002 "recording still generating", which has no
// precise taxonomy fit) fall back to CategoryAPI via BuildAPIError and rely on
// per-command enrichment for a retry hint.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var vcCodeMeta = map[int]CodeMeta{
121004: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // meeting has no minute file
121005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller is not a participant / lacks view permission
}
func init() { mergeCodeMeta(vcCodeMeta, "vc") }

View File

@@ -14,6 +14,7 @@ import (
"sync/atomic"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/transport"
)
@@ -44,7 +45,9 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
keyDef, ok := event.Lookup(opts.EventKey)
if !ok {
return fmt.Errorf("unknown EventKey: %s\nRun 'lark-cli event list' to see available keys", opts.EventKey)
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown EventKey: %s", opts.EventKey).
WithHint("run `lark-cli event list` to see available keys")
}
if err := validateParams(keyDef, opts.Params); err != nil {
@@ -80,7 +83,8 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType})
if err != nil {
return fmt.Errorf("handshake failed: %w", err)
return errs.NewInternalError(errs.SubtypeUnknown,
"event bus handshake failed: %s", err).WithCause(err)
}
var cleanup func()
@@ -90,7 +94,11 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
}
cleanup, err = keyDef.PreConsume(ctx, opts.Runtime, opts.Params)
if err != nil {
return fmt.Errorf("pre-consume failed: %w", err)
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeUnknown,
"pre-consume failed: %s", err).WithCause(err)
}
}
@@ -130,7 +138,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
if !opts.Quiet {
fmt.Fprintln(errOut, listeningText(opts))
if !opts.IsTTY {
fmt.Fprintln(errOut, stopHintText())
fmt.Fprintln(errOut, stopHintText(opts))
}
}
@@ -152,8 +160,10 @@ func validateParams(def *event.KeyDefinition, params map[string]string) error {
for _, p := range def.Params {
if p.Required {
if _, ok := params[p.Name]; !ok {
return fmt.Errorf("required param %q missing for EventKey %s. Run 'lark-cli event schema %s' for details",
p.Name, def.Key, def.Key)
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"required param %q missing for EventKey %s", p.Name, def.Key).
WithParam("--param").
WithHint("pass it as --param %s=<value>; run `lark-cli event schema %s` for details", p.Name, def.Key)
}
}
}
@@ -169,11 +179,15 @@ func validateParams(def *event.KeyDefinition, params map[string]string) error {
continue
}
if len(validNames) == 0 {
return fmt.Errorf("unknown param %q: EventKey %s accepts no params. Run 'lark-cli event schema %s' for details",
k, def.Key, def.Key)
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown param %q: EventKey %s accepts no params", k, def.Key).
WithParam("--param").
WithHint("run `lark-cli event schema %s` for details", def.Key)
}
return fmt.Errorf("unknown param %q for EventKey %s. valid params: %s. Run 'lark-cli event schema %s' for details",
k, def.Key, strings.Join(validNames, ", "), def.Key)
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown param %q for EventKey %s. valid params: %s", k, def.Key, strings.Join(validNames, ", ")).
WithParam("--param").
WithHint("run `lark-cli event schema %s` for details", def.Key)
}
return nil
}
@@ -213,7 +227,11 @@ func exitReason(ctx context.Context, emitted int64, opts Options) string {
return "signal"
}
func stopHintText() string {
func stopHintText(opts Options) string {
if opts.MaxEvents > 0 || opts.Timeout > 0 {
return "[event] to stop gracefully: send SIGTERM (kill <pid>). " +
"Avoid kill -9 — it skips cleanup and may leak server-side subscriptions."
}
return "[event] to stop gracefully: send SIGTERM (kill <pid>) or close stdin. " +
"Avoid kill -9 — it skips cleanup and may leak server-side subscriptions."
}

View File

@@ -8,17 +8,21 @@ import (
"fmt"
"github.com/itchyny/gojq"
"github.com/larksuite/cli/errs"
)
// CompileJQ compiles once for hot-path reuse; exported so callers can preflight before side effects.
func CompileJQ(expr string) (*gojq.Code, error) {
query, err := gojq.Parse(expr)
if err != nil {
return nil, fmt.Errorf("invalid jq expression: %w", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid jq expression: %s", err).WithParam("--jq").WithCause(err)
}
code, err := gojq.Compile(query)
if err != nil {
return nil, fmt.Errorf("jq compile error: %w", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"jq compile error: %s", err).WithParam("--jq").WithCause(err)
}
return code, nil
}

View File

@@ -50,12 +50,32 @@ func TestListeningText_NonTTY_MaxEventsAndTimeout(t *testing.T) {
}
// AI-facing contract: must name "kill -9" + "cleanup" so agents parsing stderr are steered away from SIGKILL.
func TestStopHintText_Content(t *testing.T) {
got := stopHintText()
mustContain := []string{"SIGTERM", "kill -9", "cleanup"}
func TestStopHintText_Unbounded(t *testing.T) {
got := stopHintText(Options{})
mustContain := []string{"SIGTERM", "kill -9", "cleanup", "close stdin"}
for _, s := range mustContain {
if !bytes.Contains([]byte(got), []byte(s)) {
t.Errorf("stopHintText missing %q; got %q", s, got)
t.Errorf("stopHintText(unbounded) missing %q; got %q", s, got)
}
}
}
// AI-facing contract: must name "kill -9" + "cleanup" so agents parsing stderr are steered away from SIGKILL.
func TestStopHintText_Bounded(t *testing.T) {
cases := []Options{
{MaxEvents: 1},
{Timeout: 30 * time.Second},
}
for _, opts := range cases {
got := stopHintText(opts)
mustContain := []string{"SIGTERM", "kill -9", "cleanup"}
for _, s := range mustContain {
if !bytes.Contains([]byte(got), []byte(s)) {
t.Errorf("stopHintText(bounded) missing %q; got %q", s, got)
}
}
if bytes.Contains([]byte(got), []byte("close stdin")) {
t.Errorf("stopHintText(bounded) must not contain \"close stdin\"; got %q", got)
}
}
}

View File

@@ -5,10 +5,13 @@ package consume
import (
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"testing"
"github.com/larksuite/cli/errs"
)
func TestCompileJQReportsErrorEarly(t *testing.T) {
@@ -20,6 +23,16 @@ func TestCompileJQReportsErrorEarly(t *testing.T) {
if !strings.Contains(msg, "compile") && !strings.Contains(msg, "parse") && !strings.Contains(msg, "invalid") {
t.Errorf("error should mention compile/parse/invalid, got: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--jq" {
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--jq")
}
if errors.Unwrap(err) == nil {
t.Error("compile error should preserve its cause")
}
}
func TestCompileJQReturnsUsableCode(t *testing.T) {

View File

@@ -13,6 +13,7 @@ import (
"sync/atomic"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/vfs"
)
@@ -23,7 +24,8 @@ type Sink interface {
func newSink(opts Options) (Sink, error) {
if opts.OutputDir != "" {
if err := vfs.MkdirAll(opts.OutputDir, 0755); err != nil {
return nil, fmt.Errorf("create output dir: %w", err)
return nil, errs.NewInternalError(errs.SubtypeFileIO,
"create output dir: %s", err).WithCause(err)
}
// PID disambiguates filenames across processes sharing a Dir.
return &DirSink{Dir: opts.OutputDir, pid: os.Getpid()}, nil

View File

@@ -16,6 +16,7 @@ import (
"path/filepath"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/protocol"
@@ -51,10 +52,9 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
} else {
fmt.Fprintf(errOut, "[event] remote connection check: online_instance_cnt=%d\n", count)
if count > 0 {
return nil, fmt.Errorf("another event bus is already connected to this app "+
"(%d active connection(s) detected via API).\n"+
"Only one bus should run globally to avoid duplicate event delivery.\n"+
"Use 'lark-cli event status' to check, or 'lark-cli event stop' on the other machine first", count)
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
"another event bus is already connected to this app (%d active connection(s) detected via API); only one bus should run globally to avoid duplicate event delivery", count).
WithHint("use `lark-cli event status` to check, or `lark-cli event stop` on the other machine first")
}
}
} else {
@@ -65,8 +65,10 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
pid, forkErr := forkBus(tr, appID, profileName, domain)
if forkErr != nil && !errors.Is(forkErr, lockfile.ErrHeld) {
eventsRoot := filepath.Join(core.GetConfigDir(), "events")
return nil, fmt.Errorf("failed to start event bus daemon: %w\n"+
"Check: disk space, permissions on %s, and 'lark-cli doctor'", forkErr, eventsRoot)
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"failed to start event bus daemon: %s", forkErr).
WithCause(forkErr).
WithHint("check disk space, permissions on %s, and `lark-cli doctor`", eventsRoot)
}
if pid > 0 {
announceForkedBus(errOut, pid)
@@ -88,7 +90,9 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
fmt.Fprintln(errOut, "[event] event bus exited unexpectedly.")
fmt.Fprintln(errOut, "[event] please check app credentials (lark-cli config show) and retry.")
fmt.Fprintf(errOut, "[event] logs: %s\n", logPath)
return nil, fmt.Errorf("failed to connect to event bus within %v (app=%s)", dialTimeout, appID)
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"failed to connect to event bus within %v (app=%s)", dialTimeout, appID).
WithHint("check app credentials (`lark-cli config show`) and retry; bus logs: %s", logPath)
}
// probeAndDialBus distinguishes a healthy bus from a mid-shutdown listener via StatusQuery first.

View File

@@ -0,0 +1,99 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"context"
"encoding/json"
"errors"
"io"
"net"
"strconv"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
// failDialTransport refuses every dial so EnsureBus falls through to the
// remote-connection check without a local bus.
type failDialTransport struct{}
func (failDialTransport) Listen(string) (net.Listener, error) { return nil, errors.New("no listen") }
func (failDialTransport) Dial(string) (net.Conn, error) { return nil, errors.New("refused") }
func (failDialTransport) Address(string) string { return "guard-test-addr" }
func (failDialTransport) Cleanup(string) {}
// remoteBusyAPIClient reports active remote WebSocket connections.
type remoteBusyAPIClient struct{ count int }
func (c remoteBusyAPIClient) CallAPI(context.Context, string, string, interface{}) (json.RawMessage, error) {
return json.RawMessage(`{"code":0,"msg":"ok","data":{"online_instance_cnt":` +
strconv.Itoa(c.count) + `}}`), nil
}
func TestEnsureBus_RemoteBusAlreadyConnectedIsFailedPrecondition(t *testing.T) {
conn, err := EnsureBus(context.Background(), failDialTransport{},
"cli_guard_test", "", "", remoteBusyAPIClient{count: 2}, io.Discard)
if conn != nil {
t.Fatal("expected nil conn when a remote bus is already connected")
}
if err == nil {
t.Fatal("expected single-bus guard error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeFailedPrecondition)
}
if !strings.Contains(ve.Hint, "event stop") {
t.Errorf("hint should point at `event stop`, got: %q", ve.Hint)
}
}
func TestRun_UnknownEventKeyIsTypedValidation(t *testing.T) {
err := Run(context.Background(), failDialTransport{}, "cli_x", "", "", Options{
EventKey: "bogus.run.key",
ErrOut: io.Discard,
})
if err == nil {
t.Fatal("expected unknown EventKey error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(ve.Hint, "event list") {
t.Errorf("hint should point at `event list`, got: %q", ve.Hint)
}
}
func TestRun_InvalidJQFailsBeforeAnySideEffect(t *testing.T) {
event.RegisterKey(event.KeyDefinition{
Key: "consume.runtest.jq",
EventType: "consume.runtest.jq_v1",
Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{}`)}},
})
err := Run(context.Background(), failDialTransport{}, "cli_x", "", "", Options{
EventKey: "consume.runtest.jq",
JQExpr: "[invalid{{{",
ErrOut: io.Discard,
})
if err == nil {
t.Fatal("expected jq validation error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Param != "--jq" {
t.Errorf("param = %q, want %q", ve.Param, "--jq")
}
}

View File

@@ -0,0 +1,64 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
func requireParamValidationError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected validation error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--param")
}
if ve.Hint == "" {
t.Error("param validation error should hint at `lark-cli event schema`")
}
}
func TestValidateParams_RequiredMissing(t *testing.T) {
def := &event.KeyDefinition{
Key: "x.test",
Params: []event.ParamDef{{Name: "chat_id", Required: true}},
}
requireParamValidationError(t, validateParams(def, map[string]string{}))
}
func TestValidateParams_UnknownParam(t *testing.T) {
def := &event.KeyDefinition{
Key: "x.test",
Params: []event.ParamDef{{Name: "chat_id"}},
}
requireParamValidationError(t, validateParams(def, map[string]string{"nope": "1"}))
}
func TestValidateParams_UnknownParamNoParamsAccepted(t *testing.T) {
def := &event.KeyDefinition{Key: "x.test"}
requireParamValidationError(t, validateParams(def, map[string]string{"nope": "1"}))
}
func TestValidateParams_DefaultAppliedAndValidPasses(t *testing.T) {
def := &event.KeyDefinition{
Key: "x.test",
Params: []event.ParamDef{{Name: "mode", Required: true, Default: "all"}},
}
params := map[string]string{}
if err := validateParams(def, params); err != nil {
t.Fatalf("default should satisfy required param, got: %v", err)
}
if params["mode"] != "all" {
t.Errorf("default not applied, params=%v", params)
}
}

View File

@@ -231,14 +231,9 @@ func TestLoadAutoApproveSet(t *testing.T) {
t.Fatal("expected non-empty auto-approve set")
}
// From scope_overrides.json allow list
if !aaSet["calendar:calendar.event:create"] {
t.Error("expected calendar:calendar.event:create in auto-approve set (from allow list)")
}
// Verify allow list entries are present
// From scope_priorities.json recommend=="true"
if !aaSet["sheets:spreadsheet:read"] {
t.Error("expected sheets:spreadsheet:read in auto-approve set (from allow list)")
t.Error("expected sheets:spreadsheet:read in auto-approve set (recommend=true in priorities)")
}
t.Logf("Auto-approve set has %d scopes", len(aaSet))
@@ -257,16 +252,10 @@ func TestLoadPlatformAutoApproveSet(t *testing.T) {
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
allowSet := LoadOverrideAutoApproveAllow()
if len(allowSet) == 0 {
t.Fatal("expected non-empty override allow set")
}
// Known entries from scope_overrides.json
if !allowSet["calendar:calendar.event:create"] {
t.Error("expected calendar:calendar.event:create in allow set")
}
if !allowSet["mail:event"] {
t.Error("expected mail:event in allow set")
// recommend.allow in scope_overrides.json is intentionally empty:
// no scopes are special-cased into the auto-approve set anymore.
if len(allowSet) != 0 {
t.Errorf("expected empty override allow set, got %d entries", len(allowSet))
}
}
@@ -277,9 +266,9 @@ func TestLoadOverrideAutoApproveDeny(t *testing.T) {
}
func TestIsAutoApproveScope(t *testing.T) {
// Known auto-approve scope (in allow list)
if !IsAutoApproveScope("calendar:calendar.event:create") {
t.Error("expected calendar:calendar.event:create to be auto-approve")
// Known auto-approve scope (recommend=true in scope_priorities.json)
if !IsAutoApproveScope("sheets:spreadsheet:read") {
t.Error("expected sheets:spreadsheet:read to be auto-approve")
}
// Completely unknown scope
@@ -290,9 +279,8 @@ func TestIsAutoApproveScope(t *testing.T) {
func TestFilterAutoApproveScopes(t *testing.T) {
scopes := []string{
"calendar:calendar.event:create", // auto-approve (in allow list)
"zzz:unknown:scope", // not in auto-approve
"sheets:spreadsheet:read", // auto-approve (in allow list)
"sheets:spreadsheet:read", // auto-approve (recommend=true in priorities)
"zzz:unknown:scope", // not in auto-approve
}
result := FilterAutoApproveScopes(scopes)
@@ -300,10 +288,10 @@ func TestFilterAutoApproveScopes(t *testing.T) {
t.Fatal("expected at least 1 auto-approve scope in result")
}
// Check that calendar:calendar.event:create is included
// Check that sheets:spreadsheet:read is included
found := false
for _, s := range result {
if s == "calendar:calendar.event:create" {
if s == "sheets:spreadsheet:read" {
found = true
}
// Ensure unknown scopes are not included
@@ -312,7 +300,7 @@ func TestFilterAutoApproveScopes(t *testing.T) {
}
}
if !found {
t.Error("expected calendar:calendar.event:create in result")
t.Error("expected sheets:spreadsheet:read in result")
}
}

View File

@@ -12,25 +12,7 @@
"vc:meeting.meetingevent:read": 75
},
"recommend": {
"allow": [
"calendar:calendar.event:create",
"calendar:calendar.event:delete",
"calendar:calendar.event:read",
"calendar:calendar.event:update",
"calendar:calendar.free_busy:read",
"calendar:calendar:create",
"calendar:calendar:delete",
"calendar:calendar:read",
"calendar:calendar:update",
"contact:user.basic_profile:readonly",
"mail:event",
"mail:user_mailbox.mail_contact:read",
"mail:user_mailbox.mail_contact:write",
"mail:user_mailbox.message.address:read",
"mail:user_mailbox.message.body:read",
"mail:user_mailbox.message.subject:read",
"mail:user_mailbox.message:readonly"
],
"allow": [],
"deny": [
"im:chat",
"im:message.send_as_user"

View File

@@ -10,10 +10,13 @@ import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os/exec"
"strings"
"time"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/vfs"
)
@@ -37,9 +40,15 @@ const (
)
const (
npmInstallTimeout = 10 * time.Minute
skillsUpdateTimeout = 2 * time.Minute
verifyTimeout = 10 * time.Second
npmInstallTimeout = 10 * time.Minute
skillsUpdateTimeout = 2 * time.Minute
skillsIndexMaxBodySize = 1 << 20
verifyTimeout = 10 * time.Second
)
var (
skillsIndexFetchTimeout = 10 * time.Second
officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json"
)
// DetectResult holds installation detection results.
@@ -83,6 +92,7 @@ func (r *NpmResult) CombinedOutput() string {
type Updater struct {
DetectOverride func() DetectResult
NpmInstallOverride func(version string) *NpmResult
SkillsIndexFetchOverride func() *NpmResult
SkillsCommandOverride func(args ...string) *NpmResult
VerifyOverride func(expectedVersion string) error
RestoreAvailableOverride func() bool
@@ -153,6 +163,53 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
return r
}
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
if u.SkillsIndexFetchOverride != nil {
return u.SkillsIndexFetchOverride()
}
r := &NpmResult{}
ctx, cancel := context.WithTimeout(context.Background(), skillsIndexFetchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, officialSkillsIndexURL, nil)
if err != nil {
r.Err = err
return r
}
client := transport.NewHTTPClient(0)
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if req.URL.Scheme != "https" {
return fmt.Errorf("official skills index redirected to non-HTTPS URL: %s", req.URL.Redacted())
}
return nil
}
resp, err := client.Do(req)
if err != nil {
r.Err = err
return r
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
r.Err = fmt.Errorf("official skills index returned HTTP %d", resp.StatusCode)
return r
}
limited := io.LimitReader(resp.Body, skillsIndexMaxBodySize+1)
if _, err := io.Copy(&r.Stdout, limited); err != nil {
r.Err = err
return r
}
if r.Stdout.Len() > skillsIndexMaxBodySize {
r.Stdout.Reset()
r.Err = fmt.Errorf("official skills index exceeds %d bytes", skillsIndexMaxBodySize)
return r
}
return r
}
func (u *Updater) ListOfficialSkills() *NpmResult {
r := u.runSkillsListOfficial("https://open.feishu.cn")
if r.Err != nil {
@@ -165,6 +222,10 @@ func (u *Updater) ListGlobalSkills() *NpmResult {
return u.runSkillsListGlobal()
}
func (u *Updater) ListGlobalSkillsJSON() *NpmResult {
return u.runSkillsCommand("-y", "skills", "ls", "-g", "--json")
}
func (u *Updater) InstallSkill(nameList []string) *NpmResult {
r := u.runSkillsInstall("https://open.feishu.cn", nameList)
if r.Err != nil {

View File

@@ -4,12 +4,18 @@
package selfupdate
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/vfs"
)
@@ -188,6 +194,13 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
},
want: "-y skills ls -g",
},
{
name: "list global json",
run: func(u *Updater) *NpmResult {
return u.ListGlobalSkillsJSON()
},
want: "-y skills ls -g --json",
},
{
name: "install skill primary",
run: func(u *Updater) *NpmResult {
@@ -225,6 +238,113 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
}
}
func TestListOfficialSkillsIndexSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
}))
defer server.Close()
oldURL := officialSkillsIndexURL
officialSkillsIndexURL = server.URL
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
result := New().ListOfficialSkillsIndex()
if result.Err != nil {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want nil", result.Err)
}
if got := result.Stdout.String(); !strings.Contains(got, "lark-calendar") {
t.Fatalf("ListOfficialSkillsIndex() stdout = %q, want skill JSON", got)
}
}
func TestListOfficialSkillsIndexHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not found", http.StatusNotFound)
}))
defer server.Close()
oldURL := officialSkillsIndexURL
officialSkillsIndexURL = server.URL
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
result := New().ListOfficialSkillsIndex()
if result.Err == nil || !strings.Contains(result.Err.Error(), "HTTP 404") {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want HTTP 404", result.Err)
}
}
func TestListOfficialSkillsIndexBodyTooLarge(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, strings.Repeat("x", skillsIndexMaxBodySize+1))
}))
defer server.Close()
oldURL := officialSkillsIndexURL
officialSkillsIndexURL = server.URL
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
result := New().ListOfficialSkillsIndex()
if result.Err == nil || !strings.Contains(result.Err.Error(), "exceeds") {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want exceeds", result.Err)
}
if result.Stdout.Len() != 0 {
t.Fatalf("ListOfficialSkillsIndex() stdout len = %d, want 0", result.Stdout.Len())
}
}
func TestListOfficialSkillsIndexTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(200 * time.Millisecond)
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
}))
defer server.Close()
oldURL := officialSkillsIndexURL
oldTimeout := skillsIndexFetchTimeout
officialSkillsIndexURL = server.URL
skillsIndexFetchTimeout = 50 * time.Millisecond
t.Cleanup(func() {
officialSkillsIndexURL = oldURL
skillsIndexFetchTimeout = oldTimeout
})
result := New().ListOfficialSkillsIndex()
var netErr net.Error
if result.Err == nil || (!errors.Is(result.Err, context.DeadlineExceeded) && !(errors.As(result.Err, &netErr) && netErr.Timeout())) {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want timeout error", result.Err)
}
}
func TestListOfficialSkillsIndexRejectsNonHTTPSRedirect(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "http://example.com/skills.json", http.StatusFound)
}))
defer server.Close()
oldURL := officialSkillsIndexURL
officialSkillsIndexURL = server.URL
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
result := New().ListOfficialSkillsIndex()
if result.Err == nil || !strings.Contains(result.Err.Error(), "non-HTTPS") {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want non-HTTPS redirect", result.Err)
}
}
func TestListOfficialSkillsIndexUsesOverride(t *testing.T) {
result := (&Updater{SkillsIndexFetchOverride: func() *NpmResult {
r := &NpmResult{}
r.Stdout.WriteString(`{"skills":[{"name":"override-skill"}]}`)
return r
}}).ListOfficialSkillsIndex()
if result.Err != nil {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want nil", result.Err)
}
if !strings.Contains(result.Stdout.String(), "override-skill") {
t.Fatalf("ListOfficialSkillsIndex() stdout = %q, want override result", result.Stdout.String())
}
}
func TestListOfficialSkillsFallsBack(t *testing.T) {
called := []string{}
updater := &Updater{

View File

@@ -0,0 +1,209 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package skillcontent reads embedded skill content from an injected fs.FS
// rooted at the skill list (entries like "lark-calendar/SKILL.md").
package skillcontent
import (
"io/fs"
"path"
"sort"
"strings"
"github.com/larksuite/cli/errs"
"gopkg.in/yaml.v3"
)
type Reader struct {
fsys fs.FS
}
func New(fsys fs.FS) *Reader { return &Reader{fsys: fsys} }
type SkillInfo struct {
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// DirEntry.Path is skill-prefixed (e.g. "lark-doc/references/x.md") so it can be
// fed straight back into `read`.
type DirEntry struct {
Path string `json:"path"`
IsDir bool `json:"is_dir"`
}
func (r *Reader) List() ([]SkillInfo, error) {
entries, err := fs.ReadDir(r.fsys, ".")
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeFileIO, "failed to read embedded skills: %v", err)
}
out := make([]SkillInfo, 0, len(entries))
for _, e := range entries {
if !e.IsDir() {
continue
}
// Skip dirs that aren't real skills (no SKILL.md).
if info, ok := r.skillInfo(e.Name()); ok {
out = append(out, info)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
func (r *Reader) skillInfo(name string) (SkillInfo, bool) {
data, err := fs.ReadFile(r.fsys, name+"/SKILL.md")
if err != nil {
return SkillInfo{}, false
}
desc, version, metadata := parseFrontmatter(data)
return SkillInfo{Name: name, Description: desc, Version: version, Metadata: metadata}, true
}
// ListPath lists one directory layer (no recursion) under "<name>" or
// "<name>/<sub>", returning the entries and the cleaned path listed.
func (r *Reader) ListPath(arg string) ([]DirEntry, string, error) {
name, sub := SplitArg(arg)
if err := r.ensureSkill(name); err != nil {
return nil, "", err
}
dir := name
if sub != "" {
cleaned, err := cleanSubPath(sub)
if err != nil {
return nil, "", err
}
dir = name + "/" + cleaned
info, err := fs.Stat(r.fsys, dir)
if err != nil {
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"path %q not found in skill %q", sub, name).
WithHint("run 'lark-cli skills list " + name + "' to see files in this skill")
}
if !info.IsDir() {
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"path %q is a file, not a directory; use 'lark-cli skills read %s/%s' to read it", sub, name, cleaned)
}
}
entries, err := fs.ReadDir(r.fsys, dir)
if err != nil {
return nil, "", errs.NewInternalError(errs.SubtypeFileIO,
"failed to read embedded skill content: %v", err)
}
out := make([]DirEntry, 0, len(entries))
for _, e := range entries {
out = append(out, DirEntry{Path: dir + "/" + e.Name(), IsDir: e.IsDir()})
}
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out, dir, nil
}
// SplitArg splits "<name>/<rest>" at the first separator; an argument with no
// separator is a bare skill name (rest "").
func SplitArg(arg string) (name, rest string) {
name, rest, _ = strings.Cut(arg, "/")
return name, rest
}
// parseFrontmatter best-effort-extracts the frontmatter fields; missing or
// unparseable frontmatter yields ("", "", nil), never an error.
func parseFrontmatter(skillMD []byte) (description, version string, metadata map[string]any) {
lines := strings.Split(string(skillMD), "\n")
if strings.TrimRight(lines[0], "\r") != "---" {
return "", "", nil
}
block := make([]string, 0, len(lines))
closed := false
for _, ln := range lines[1:] {
if strings.TrimRight(ln, "\r") == "---" {
closed = true
break
}
block = append(block, ln)
}
if !closed {
return "", "", nil
}
var fm struct {
Description string `yaml:"description"`
Version string `yaml:"version"`
Metadata map[string]any `yaml:"metadata"`
}
if err := yaml.Unmarshal([]byte(strings.Join(block, "\n")), &fm); err != nil {
return "", "", nil
}
return fm.Description, fm.Version, fm.Metadata
}
func (r *Reader) ReadSkill(name string) ([]byte, error) {
if err := r.ensureSkill(name); err != nil {
return nil, err
}
data, err := fs.ReadFile(r.fsys, name+"/SKILL.md")
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeFileIO,
"failed to read embedded skill content: %v", err)
}
return data, nil
}
func (r *Reader) ensureSkill(name string) error {
if name == "" || strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
return unknownSkill(name)
}
info, err := fs.Stat(r.fsys, name)
if err != nil || !info.IsDir() {
return unknownSkill(name)
}
return nil
}
func unknownSkill(name string) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown skill %q", name).
WithHint("run 'lark-cli skills list' to see available skills")
}
// cleanSubPath returns the cleaned form of relpath, rejecting absolute paths and
// ".." escapes. relpath must be non-empty (callers handle the skill-root case).
func cleanSubPath(relpath string) (string, error) {
cleaned := path.Clean(relpath)
// path.Clean only treats '/' as a separator, so a Windows-style "..\" prefix
// survives; reject it explicitly alongside "../".
if relpath == "" || path.IsAbs(relpath) || cleaned == "." ||
cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, `..\`) {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid path %q: must be a relative path without '..'", relpath)
}
return cleaned, nil
}
// ReadReference returns the bytes of <name>/<relpath> and the cleaned path.
func (r *Reader) ReadReference(name, relpath string) ([]byte, string, error) {
if err := r.ensureSkill(name); err != nil {
return nil, "", err
}
cleaned, err := cleanSubPath(relpath)
if err != nil {
return nil, "", err
}
full := name + "/" + cleaned
info, err := fs.Stat(r.fsys, full)
if err != nil {
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"reference %q not found in skill %q", relpath, name).
WithHint("run 'lark-cli skills list " + name + "' to see files in this skill")
}
if info.IsDir() {
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"reference %q is a directory, not a file", relpath)
}
data, err := fs.ReadFile(r.fsys, full)
if err != nil {
return nil, "", errs.NewInternalError(errs.SubtypeFileIO,
"failed to read embedded skill content: %v", err)
}
return data, cleaned, nil
}

View File

@@ -0,0 +1,290 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillcontent
import (
"errors"
"strings"
"testing"
"testing/fstest"
"github.com/larksuite/cli/errs"
)
func testFS() fstest.MapFS {
return fstest.MapFS{
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\nversion: 1.0.0\ndescription: \"Calendar skill\"\nmetadata:\n requires:\n bins: [\"lark-cli\"]\n cliHelp: \"lark-cli calendar --help\"\n---\nbody\n")},
"lark-calendar/references/agenda.md": {Data: []byte("# Agenda")},
"lark-calendar/references/create.md": {Data: []byte("# Create")},
"lark-calendar/assets/tpl.html": {Data: []byte("<html></html>")},
"lark-im/SKILL.md": {Data: []byte("no frontmatter here\n")},
"lark-im/references/send.md": {Data: []byte("# Send")},
}
}
func TestList(t *testing.T) {
r := New(testFS())
skills, err := r.List()
if err != nil {
t.Fatalf("List() error: %v", err)
}
if len(skills) != 2 {
t.Fatalf("got %d skills, want 2", len(skills))
}
if skills[0].Name != "lark-calendar" || skills[1].Name != "lark-im" {
t.Fatalf("skills not sorted by name: %v", skills)
}
if skills[0].Description != "Calendar skill" {
t.Errorf("description: got %q, want %q", skills[0].Description, "Calendar skill")
}
// version is the frontmatter `version:` field, passed through for drift checks.
if skills[0].Version != "1.0.0" {
t.Errorf("version: got %q, want %q", skills[0].Version, "1.0.0")
}
// metadata is the frontmatter `metadata:` block, passed through verbatim.
if skills[0].Metadata == nil {
t.Fatal("expected metadata for lark-calendar")
}
if skills[0].Metadata["cliHelp"] != "lark-cli calendar --help" {
t.Errorf("metadata.cliHelp: got %v", skills[0].Metadata["cliHelp"])
}
// No frontmatter → empty description and nil metadata (omitted from JSON).
if skills[1].Description != "" {
t.Errorf("lark-im description: got %q, want empty", skills[1].Description)
}
if skills[1].Metadata != nil {
t.Errorf("lark-im metadata: got %v, want nil", skills[1].Metadata)
}
if skills[1].Version != "" {
t.Errorf("lark-im version: got %q, want empty", skills[1].Version)
}
}
func TestListPath(t *testing.T) {
r := New(testFS())
// Skill root: direct children only (one layer), each path skill-prefixed.
entries, listed, err := r.ListPath("lark-calendar")
if err != nil {
t.Fatalf("ListPath root error: %v", err)
}
if listed != "lark-calendar" {
t.Errorf("listed path: got %q", listed)
}
want := map[string]bool{ // path → isDir
"lark-calendar/SKILL.md": false,
"lark-calendar/references": true,
"lark-calendar/assets": true,
}
if len(entries) != len(want) {
t.Fatalf("root entries: got %v, want %d entries", entries, len(want))
}
for _, e := range entries {
isDir, ok := want[e.Path]
if !ok {
t.Errorf("unexpected entry %q", e.Path)
continue
}
if e.IsDir != isDir {
t.Errorf("%q is_dir: got %v, want %v", e.Path, e.IsDir, isDir)
}
}
// Entries are sorted by path.
if entries[0].Path != "lark-calendar/SKILL.md" {
t.Errorf("entries not sorted: %v", entries)
}
// Subdirectory: one layer under <name>/<subpath>.
subEntries, subListed, err := r.ListPath("lark-calendar/references")
if err != nil {
t.Fatalf("ListPath subdir error: %v", err)
}
if subListed != "lark-calendar/references" {
t.Errorf("listed subpath: got %q", subListed)
}
if len(subEntries) != 2 ||
subEntries[0].Path != "lark-calendar/references/agenda.md" ||
subEntries[1].Path != "lark-calendar/references/create.md" {
t.Errorf("subdir entries: got %v", subEntries)
}
// Unknown skill → typed validation error.
if _, _, err := r.ListPath("no-such-skill"); err == nil {
t.Error("expected error for unknown skill")
} else {
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Errorf("expected *errs.ValidationError, got %T", err)
}
}
// Path that points at a file (not a dir) → validation error.
if _, _, err := r.ListPath("lark-calendar/SKILL.md"); err == nil {
t.Error("expected error listing a file")
} else if !strings.Contains(err.Error(), "is a file") {
t.Errorf("message: got %q", err.Error())
}
// Nonexistent subpath → validation error.
if _, _, err := r.ListPath("lark-calendar/nope"); err == nil {
t.Error("expected not-found error")
} else if !strings.Contains(err.Error(), "not found") {
t.Errorf("message: got %q", err.Error())
}
// Traversal in the subpath is rejected, no listing leaked.
for _, bad := range []string{"lark-calendar/../lark-im", "lark-calendar/../../etc", `lark-calendar/..\x`} {
entries, _, err := r.ListPath(bad)
if err == nil {
t.Errorf("expected rejection for %q", bad)
}
if entries != nil {
t.Errorf("entries leaked for %q: %v", bad, entries)
}
}
}
func TestReadSkill(t *testing.T) {
r := New(testFS())
data, err := r.ReadSkill("lark-calendar")
if err != nil {
t.Fatalf("ReadSkill error: %v", err)
}
if !strings.HasPrefix(string(data), "---\nname: lark-calendar") {
t.Errorf("unexpected content: %q", string(data))
}
_, err = r.ReadSkill("no-such-skill")
if err == nil {
t.Fatal("expected error for unknown skill")
}
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if !strings.Contains(verr.Message, `unknown skill "no-such-skill"`) {
t.Errorf("message: got %q", verr.Message)
}
if _, err := r.ReadSkill("../etc"); err == nil {
t.Error("expected error for name with separator")
}
}
func TestReadReference(t *testing.T) {
r := New(testFS())
data, cleaned, err := r.ReadReference("lark-calendar", "references/agenda.md")
if err != nil {
t.Fatalf("ReadReference error: %v", err)
}
if string(data) != "# Agenda" {
t.Errorf("content: got %q", string(data))
}
if cleaned != "references/agenda.md" {
t.Errorf("cleaned path: got %q", cleaned)
}
if _, _, err := r.ReadReference("lark-calendar", "references/nope.md"); err == nil {
t.Error("expected not-found error")
} else if !strings.Contains(err.Error(), "not found") {
t.Errorf("message: got %q", err.Error())
}
if _, _, err := r.ReadReference("lark-calendar", "references"); err == nil {
t.Error("expected directory error")
} else if !strings.Contains(err.Error(), "is a directory") {
t.Errorf("message: got %q", err.Error())
}
for _, bad := range []string{"../../etc/passwd", "/etc/passwd", "..", "", "references/../../im/SKILL.md", `..\..\x`} {
data, _, err := r.ReadReference("lark-calendar", bad)
if err == nil {
t.Errorf("expected rejection for %q", bad)
}
if data != nil {
t.Errorf("content leaked for %q: %q", bad, string(data))
}
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Errorf("expected validation error for %q, got %T", bad, err)
}
}
}
func TestParseFrontmatter(t *testing.T) {
cases := []struct {
name string
input string
wantDesc string
wantVer string
wantHasMeta bool
}{
{
name: "description, version and metadata",
input: "---\ndescription: My skill\nversion: 2.1.0\nmetadata:\n cliHelp: \"x\"\n---\nbody\n",
wantDesc: "My skill",
wantVer: "2.1.0",
wantHasMeta: true,
},
{
name: "description only, no metadata",
input: "---\ndescription: Plain\n---\nbody\n",
wantDesc: "Plain",
},
{
name: "no frontmatter",
input: "no frontmatter here\n",
},
{
name: "unclosed frontmatter",
input: "---\ndescription: Never closed\n",
},
{
name: "malformed YAML inside frontmatter",
input: "---\n: bad: yaml: [\n---\nbody\n",
},
{
name: "CRLF line endings",
input: "---\r\ndescription: CRLF skill\r\nmetadata:\r\n cliHelp: \"y\"\r\n---\r\nbody\r\n",
wantDesc: "CRLF skill",
wantHasMeta: true,
},
{
name: "empty input",
input: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
desc, ver, meta := parseFrontmatter([]byte(tc.input))
if desc != tc.wantDesc {
t.Errorf("description = %q, want %q", desc, tc.wantDesc)
}
if ver != tc.wantVer {
t.Errorf("version = %q, want %q", ver, tc.wantVer)
}
if (meta != nil) != tc.wantHasMeta {
t.Errorf("metadata = %v, wantHasMeta %v", meta, tc.wantHasMeta)
}
})
}
}
func TestReadSkillMissingFile(t *testing.T) {
// Use a separate MapFS so testFS() (and TestList) are unaffected.
emptyFS := fstest.MapFS{
"lark-empty/references/x.md": {Data: []byte("# X")},
}
r := New(emptyFS)
_, err := r.ReadSkill("lark-empty")
if err == nil {
t.Fatal("expected error when SKILL.md is absent")
}
var ierr *errs.InternalError
if !errors.As(err, &ierr) {
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
}
}

View File

@@ -4,6 +4,7 @@
package skillscheck
import (
"encoding/json"
"fmt"
"regexp"
"sort"
@@ -57,6 +58,52 @@ func ParseSkillsList(text string) []string {
return nil
}
func ParseGlobalSkillsJSON(text string) []string {
type globalSkill struct {
Name string `json:"name"`
}
var skills []globalSkill
if err := json.Unmarshal([]byte(text), &skills); err != nil {
return nil
}
seen := map[string]bool{}
for _, skill := range skills {
candidate := strings.TrimSpace(skill.Name)
if candidate == "" || !skillNamePattern.MatchString(candidate) {
continue
}
seen[candidate] = true
}
return sortedKeys(seen)
}
func ParseOfficialSkillsIndexJSON(text string) ([]string, error) {
type officialSkill struct {
Name string `json:"name"`
}
type officialIndex struct {
Skills []officialSkill `json:"skills"`
}
var index officialIndex
if err := json.Unmarshal([]byte(text), &index); err != nil {
return nil, err
}
seen := map[string]bool{}
for _, skill := range index.Skills {
candidate := strings.TrimSpace(skill.Name)
if skillNamePattern.MatchString(candidate) {
seen[candidate] = true
}
}
return sortedKeys(seen), nil
}
// parseGlobalSkillsList parses the output of "npx -y skills ls -g"
func parseGlobalSkillsList(lines []string) []string {
seen := map[string]bool{}
@@ -77,8 +124,11 @@ func parseGlobalSkillsList(lines []string) []string {
continue
}
// Skip indented lines (Agents: ...)
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
if strings.HasPrefix(trimmed, "Agents:") {
continue
}
if isGlobalSkillsSectionHeader(trimmed) {
continue
}
@@ -91,21 +141,24 @@ func parseGlobalSkillsList(lines []string) []string {
candidate := parts[0]
// Validate and add
if candidate == "" || strings.Contains(candidate, " ") || strings.HasSuffix(candidate, ":") {
if candidate == "" || !skillNamePattern.MatchString(candidate) {
continue
}
if !skillNamePattern.MatchString(candidate) {
continue
}
if at := strings.Index(candidate, "@"); at > 0 {
candidate = candidate[:at]
}
seen[candidate] = true
}
return sortedKeys(seen)
}
func isGlobalSkillsSectionHeader(line string) bool {
switch line {
case "General", "Project", "Local":
return true
default:
return false
}
}
// parseOfficialSkillsList parses the output of "npx -y skills add ... --list"
func parseOfficialSkillsList(lines []string) []string {
seen := map[string]bool{}
@@ -131,8 +184,7 @@ func parseOfficialSkillsList(lines []string) []string {
if len(parts) > 0 {
candidate := parts[0]
// Check if it's a valid official skill name
if strings.HasPrefix(candidate, "lark-") && skillNamePattern.MatchString(candidate) {
if skillNamePattern.MatchString(candidate) {
seen[candidate] = true
}
}
@@ -194,7 +246,9 @@ func PlanSync(input SyncInput) SyncPlan {
}
type SkillsRunner interface {
ListOfficialSkillsIndex() *selfupdate.NpmResult
ListOfficialSkills() *selfupdate.NpmResult
ListGlobalSkillsJSON() *selfupdate.NpmResult
ListGlobalSkills() *selfupdate.NpmResult
InstallSkill(nameList []string) *selfupdate.NpmResult
InstallAllSkills() *selfupdate.NpmResult
@@ -228,21 +282,15 @@ func SyncSkills(opts SyncOptions) *SyncResult {
}
// --- Step 1: List official skills ---
officialResult := opts.Runner.ListOfficialSkills()
if officialResult == nil || officialResult.Err != nil {
return fallbackFullInstall(opts, resultDetail(officialResult), nil)
}
official := ParseSkillsList(officialResult.Stdout.String())
if len(official) == 0 && strings.TrimSpace(officialResult.Stdout.String()) != "" {
return fallbackFullInstall(opts, "official skills list parsed as empty despite non-empty stdout", nil)
official, reason, ok := listOfficialSkills(opts.Runner)
if !ok {
return fallbackFullInstall(opts, reason, nil)
}
// --- Step 2: List local (installed) skills ---
local := []string{}
localResult := opts.Runner.ListGlobalSkills()
if localResult != nil && localResult.Err == nil {
local = ParseSkillsList(localResult.Stdout.String())
local, ok := listLocalSkills(opts.Runner)
if !ok {
return fallbackFullInstall(opts, "local skills list failed or parsed as empty", official)
}
// --- Step 3: Read previous state ---
@@ -298,6 +346,58 @@ func SyncSkills(opts SyncOptions) *SyncResult {
return result
}
func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) {
reasons := []string{}
indexResult := runner.ListOfficialSkillsIndex()
if indexResult == nil || indexResult.Err != nil {
reasons = append(reasons, "official skills index failed: "+resultDetail(indexResult))
} else {
official, err := ParseOfficialSkillsIndexJSON(indexResult.Stdout.String())
if err != nil {
reasons = append(reasons, "official skills index JSON invalid: "+err.Error())
} else if len(official) > 0 {
return official, "", true
} else {
reasons = append(reasons, "official skills index contains no skills")
}
}
officialResult := runner.ListOfficialSkills()
if officialResult == nil || officialResult.Err != nil {
reasons = append(reasons, "official skills list failed: "+resultDetail(officialResult))
return nil, strings.Join(reasons, "; "), false
}
official := ParseSkillsList(officialResult.Stdout.String())
if len(official) > 0 {
return official, "", true
}
if strings.TrimSpace(officialResult.Stdout.String()) != "" {
reasons = append(reasons, "official skills list parsed as empty despite non-empty stdout")
} else {
reasons = append(reasons, "official skills list returned no skills")
}
return nil, strings.Join(reasons, "; "), false
}
func listLocalSkills(runner SkillsRunner) ([]string, bool) {
jsonResult := runner.ListGlobalSkillsJSON()
if jsonResult != nil && jsonResult.Err == nil {
if local := ParseGlobalSkillsJSON(jsonResult.Stdout.String()); len(local) > 0 {
return local, true
}
}
textResult := runner.ListGlobalSkills()
if textResult != nil && textResult.Err == nil {
if local := ParseSkillsList(textResult.Stdout.String()); len(local) > 0 {
return local, true
}
}
return nil, false
}
// fallbackFullInstall performs a full skills install (npx -y skills add <source> -g -y)
// when incremental sync is not possible. On success it writes a state file so that
// subsequent syncs can use incremental mode. When official is non-nil the state

View File

@@ -30,6 +30,19 @@ lark-cli-harness:dev@0.1.0
}
}
func TestParseOfficialSkillsListAcceptsNonLarkOfficialNames(t *testing.T) {
input := `Available Skills
│ lark-calendar
│ official-shared
│ bad/name
`
got := ParseSkillsList(input)
want := []string{"lark-calendar", "official-shared"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseSkillsList() (Available Skills) = %#v, want %#v", got, want)
}
}
func TestParseGlobalSkillsList(t *testing.T) {
input := `Global Skills
@@ -67,6 +80,86 @@ func TestParseGlobalSkillsListWithANSI(t *testing.T) {
}
}
func TestParseGlobalSkillsListWithIndentedGroupedRows(t *testing.T) {
input := `Global Skills
General
lark-apps ~/.agents/skills/lark-apps
lark-base ~/.agents/skills/lark-base
`
got := ParseSkillsList(input)
want := []string{"lark-apps", "lark-base"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseSkillsList() (indented Global Skills) = %#v, want %#v", got, want)
}
}
func TestParseGlobalSkillsJSON(t *testing.T) {
input := `[
{"name":"lark-calendar","path":"/Users/example/.agents/skills/lark-calendar","scope":"global","agents":["Codex"]},
{"name":"lark-mail@1.2.3","path":"/Users/example/.agents/skills/lark-mail","scope":"global","agents":["Codex"]},
{"name":"lark-calendar","path":"/Users/example/.agents/skills/lark-calendar","scope":"global","agents":["Codex"]},
{"name":" lark-base ","path":"/Users/example/.agents/skills/lark-base","scope":"global","agents":["Codex"]},
{"name":""},
{"name":" "},
{"name":"bad skill"}
]`
got := ParseGlobalSkillsJSON(input)
want := []string{"lark-base", "lark-calendar", "lark-mail@1.2.3"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseGlobalSkillsJSON() = %#v, want %#v", got, want)
}
}
func TestParseGlobalSkillsJSONInvalidOrUnsupported(t *testing.T) {
for _, input := range []string{
`not json`,
`{"name":"lark-calendar"}`,
`[]`,
} {
if got := ParseGlobalSkillsJSON(input); len(got) != 0 {
t.Fatalf("ParseGlobalSkillsJSON(%q) = %#v, want empty", input, got)
}
}
}
func TestParseOfficialSkillsIndexJSON(t *testing.T) {
input := `{
"skills": [
{"name":"lark-calendar","description":"Calendar","files":["SKILL.md"]},
{"name":"lark-mail","description":"Mail","files":["SKILL.md","references/lark-mail-search.md"]},
{"name":" lark-base ","description":"Base","files":[]},
{"name":"lark-calendar","description":"duplicate","files":["SKILL.md"]},
{"name":"custom-skill","description":"not official","files":["SKILL.md"]},
{"name":"bad skill","description":"invalid","files":["SKILL.md"]},
{"name":"","description":"empty","files":["SKILL.md"]}
]
}`
got, err := ParseOfficialSkillsIndexJSON(input)
if err != nil {
t.Fatalf("ParseOfficialSkillsIndexJSON() err = %v, want nil", err)
}
want := []string{"custom-skill", "lark-base", "lark-calendar", "lark-mail"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseOfficialSkillsIndexJSON() = %#v, want %#v", got, want)
}
}
func TestParseOfficialSkillsIndexJSONInvalidOrUnsupported(t *testing.T) {
for _, input := range []string{
`not json`,
`[{"name":"lark-calendar"}]`,
`{"name":"lark-calendar"}`,
`{"skills":[]}`,
`{"skills":[{"name":"bad skill"}]}`,
} {
got, err := ParseOfficialSkillsIndexJSON(input)
if err == nil && len(got) != 0 {
t.Fatalf("ParseOfficialSkillsIndexJSON(%q) = %#v, want empty", input, got)
}
}
}
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
got := PlanSync(SyncInput{
@@ -113,14 +206,22 @@ func TestPlanForceRestoresAllOfficial(t *testing.T) {
}
type fakeSkillsRunner struct {
officialOut string
globalOut string
officialErr error
globalErr error
installErr error
installAllErr error
installed [][]string
installedAll int
officialIndexOut string
officialOut string
globalJSONOut string
globalOut string
officialIndexErr error
officialErr error
globalJSONErr error
globalErr error
installErr error
installAllErr error
installed [][]string
installedAll int
listedIndex int
listedOfficial int
listedGlobalJSON int
listedGlobalText int
}
func officialSkillsOutput(names ...string) string {
@@ -134,6 +235,19 @@ func officialSkillsOutput(names ...string) string {
return b.String()
}
func officialSkillsIndexOutput(names ...string) string {
var b strings.Builder
b.WriteString(`{"skills":[`)
for i, name := range names {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, `{"name":%q,"description":"test skill","files":["SKILL.md"]}`, name)
}
b.WriteString(`]}`)
return b.String()
}
func globalSkillsOutput(names ...string) string {
var b strings.Builder
b.WriteString("Global Skills\n\n")
@@ -146,14 +260,45 @@ func globalSkillsOutput(names ...string) string {
return b.String()
}
func globalSkillsJSONOutput(names ...string) string {
var b strings.Builder
b.WriteString("[")
for i, name := range names {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, `{"name":%q,"path":"/Users/example/.agents/skills/%s","scope":"global","agents":["Codex"]}`, name, name)
}
b.WriteString("]")
return b.String()
}
func (f *fakeSkillsRunner) ListOfficialSkillsIndex() *selfupdate.NpmResult {
f.listedIndex++
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.officialIndexOut)
r.Err = f.officialIndexErr
return r
}
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
f.listedOfficial++
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.officialOut)
r.Err = f.officialErr
return r
}
func (f *fakeSkillsRunner) ListGlobalSkillsJSON() *selfupdate.NpmResult {
f.listedGlobalJSON++
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.globalJSONOut)
r.Err = f.globalJSONErr
return r
}
func (f *fakeSkillsRunner) ListGlobalSkills() *selfupdate.NpmResult {
f.listedGlobalText++
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.globalOut)
r.Err = f.globalErr
@@ -186,8 +331,10 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
}
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
globalOut: globalSkillsOutput("lark-calendar", "lark-custom"),
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
globalOut: globalSkillsOutput("lark-mail"),
}
result := SyncSkills(SyncOptions{
Version: "1.0.33",
@@ -199,6 +346,12 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-new"})
if runner.listedGlobalJSON != 1 {
t.Fatalf("listedGlobalJSON = %d, want 1", runner.listedGlobalJSON)
}
if runner.listedGlobalText != 0 {
t.Fatalf("listedGlobalText = %d, want 0 when JSON list succeeds", runner.listedGlobalText)
}
state, readable, err := ReadState()
if err != nil || !readable {
@@ -213,12 +366,119 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
}
}
func TestSyncSkills_OfficialIndexSuccessSkipsOfficialListCommand(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
officialOut: officialSkillsOutput("lark-should-not-be-used"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
globalOut: globalSkillsOutput("lark-mail"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail", "lark-new"})
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-mail", "lark-new"})
if runner.listedIndex != 1 {
t.Fatalf("listedIndex = %d, want 1", runner.listedIndex)
}
if runner.listedOfficial != 0 {
t.Fatalf("listedOfficial = %d, want 0 when index succeeds", runner.listedOfficial)
}
}
func TestSyncSkills_OfficialIndexFailureFallsBackToOfficialList(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
if runner.listedIndex != 1 || runner.listedOfficial != 1 {
t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial)
}
if runner.installedAll != 0 {
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
}
}
func TestSyncSkills_OfficialIndexEmptyFallsBackToOfficialList(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: `{"skills":[]}`,
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
if runner.listedIndex != 1 || runner.listedOfficial != 1 {
t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial)
}
}
func TestSyncSkills_OfficialDiscoveryFailuresFallBackToFullInstallWithReasons(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"),
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
}
if !strings.Contains(result.Detail, "official skills index failed") || !strings.Contains(result.Detail, "official skills list failed") {
t.Fatalf("SyncSkills() detail = %q, want both discovery failure reasons", result.Detail)
}
}
func TestSyncSkills_OfficialDiscoveryEmptyFallsBackToFullInstallWithReasons(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: `{"skills":[]}`,
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
}
if !strings.Contains(result.Detail, "official skills index contains no skills") || !strings.Contains(result.Detail, "official skills list returned no skills") {
t.Fatalf("SyncSkills() detail = %q, want both empty discovery reasons", result.Detail)
}
}
func TestSyncSkills_ListOfficialFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
officialIndexErr: fmt.Errorf("index unavailable"),
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -246,8 +506,9 @@ func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialErr: fmt.Errorf("list failed"),
installAllErr: fmt.Errorf("full install failed"),
officialIndexErr: fmt.Errorf("index unavailable"),
officialErr: fmt.Errorf("list failed"),
installAllErr: fmt.Errorf("full install failed"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -262,47 +523,76 @@ func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
}
}
func TestSyncSkills_GlobalListFailureDegradesToColdStart(t *testing.T) {
func TestSyncSkills_GlobalJSONFailureFallsBackToTextList(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalErr: fmt.Errorf("global list failed"),
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONErr: fmt.Errorf("json list failed"),
globalOut: globalSkillsOutput("lark-calendar"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
if result.Action != "synced" {
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
}
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.SkippedDeleted, []string{})
if runner.listedGlobalJSON != 1 || runner.listedGlobalText != 1 {
t.Fatalf("listed JSON/text = %d/%d, want 1/1", runner.listedGlobalJSON, runner.listedGlobalText)
}
if runner.installedAll != 0 {
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
}
}
func TestSyncSkills_ParseEmptyGlobalListWithNonEmptyStdoutDegradesToColdStart(t *testing.T) {
func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: "Some unrecognized output format\n",
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONErr: fmt.Errorf("json list failed with /Users/example/.agents/skills/lark-calendar agents Codex"),
globalErr: fmt.Errorf("text list failed with /Users/example/.agents/skills/lark-mail agents Codex"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
if result.Action != "synced" {
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
if len(runner.installed) != 0 {
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
}
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.SkippedDeleted, []string{})
if runner.installedAll != 0 {
t.Fatalf("installedAll = %d, want 0 (no fallback)", runner.installedAll)
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
}
if len(runner.installed) != 1 {
t.Fatalf("installed = %d calls, want 1 (incremental)", len(runner.installed))
if strings.Contains(result.Detail, "/Users/example") || strings.Contains(result.Detail, "agents") {
t.Fatalf("SyncSkills() detail leaks local command output: %q", result.Detail)
}
}
func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: `[]`,
globalOut: "Some unrecognized output format\n",
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
if len(runner.installed) != 0 {
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
}
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
}
}
@@ -318,9 +608,10 @@ func TestSyncSkills_EmptyToUpdateFallsBackToFullInstall(t *testing.T) {
}
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput(),
installAllErr: nil,
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput(),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -343,10 +634,12 @@ func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -374,10 +667,12 @@ func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: fmt.Errorf("full install boom"),
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: fmt.Errorf("full install boom"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -406,8 +701,9 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutFallsBack(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: "Some unrecognized output format\n",
installAllErr: nil,
officialIndexErr: fmt.Errorf("index unavailable"),
officialOut: "Some unrecognized output format\n",
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -423,8 +719,9 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutAndFullInstallFails(t *testing.T
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: "Some unrecognized output format\n",
installAllErr: fmt.Errorf("full install failed"),
officialIndexErr: fmt.Errorf("index unavailable"),
officialOut: "Some unrecognized output format\n",
installAllErr: fmt.Errorf("full install failed"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -447,8 +744,9 @@ func TestSyncSkills_FallbackWithUnknownOfficialWritesMinimalState(t *testing.T)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: "Some unrecognized output format\n",
installAllErr: nil,
officialIndexErr: fmt.Errorf("index unavailable"),
officialOut: "Some unrecognized output format\n",
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -472,10 +770,12 @@ func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -496,10 +796,12 @@ func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -519,8 +821,9 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
officialIndexErr: fmt.Errorf("index unavailable"),
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
}
result1 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -537,8 +840,10 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
}
runner2 := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
}
result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now})
if result2.Action != "synced" {

View File

@@ -15,7 +15,24 @@ import (
// legacy validation/save helpers are forbidden; callers must use the typed
// common replacements or construct an errs.* typed error directly.
var migratedCommonHelperPaths = []string{
"cmd/event/",
"events/",
"internal/event/consume/",
"shortcuts/base/",
"shortcuts/calendar/",
"shortcuts/contact/",
"shortcuts/doc/",
"shortcuts/drive/",
"shortcuts/event/",
"shortcuts/mail/",
"shortcuts/markdown/",
"shortcuts/minutes/",
"shortcuts/okr/",
"shortcuts/sheets/",
"shortcuts/slides/",
"shortcuts/task/",
"shortcuts/vc/",
"shortcuts/whiteboard/",
}
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"

View File

@@ -16,7 +16,25 @@ import (
// call sites must return a typed errs.* error instead. Future domains opt in by
// appending their path prefix here.
var migratedEnvelopePaths = []string{
"cmd/event/",
"events/",
"internal/event/consume/",
"shortcuts/base/",
"shortcuts/calendar/",
"shortcuts/contact/",
"shortcuts/doc/",
"shortcuts/drive/",
"shortcuts/event/",
"shortcuts/mail/",
"shortcuts/markdown/",
"shortcuts/minutes/",
"shortcuts/okr/",
"shortcuts/sheets/",
"shortcuts/slides/",
"shortcuts/task/",
"shortcuts/vc/",
"shortcuts/whiteboard/",
"shortcuts/im/",
}
// legacyOutputImportPath is the import path of the package that declares the

View File

@@ -18,7 +18,7 @@ import (
// forbidigo's errs-typed-only ban does not see them because they are method
// calls, not output.Err* identifiers — this AST rule covers that gap.
//
// Migrated code must call a typed API wrapper (e.g. drive's driveCallAPI) or use
// Migrated code must call the domain's typed API wrapper or use
// runtime.DoAPI + errclass.BuildAPIError directly, so failures classify into
// typed errs.* errors.
//
@@ -27,6 +27,11 @@ import (
// is not matched. runtime.DoAPI / runtime.RawAPI are intentionally not listed:
// they return the raw response for the caller to classify and do not emit a
// legacy envelope themselves.
//
// Files that do not import shortcuts/common are skipped: the legacy helpers
// are methods on common.RuntimeContext, so a same-named method on another
// receiver (for example the event domain's APIClient interface, whose
// implementation classifies into typed errs.* errors) is not a legacy call.
func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") {
return nil
@@ -36,6 +41,9 @@ func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
if err != nil {
return nil
}
if !importsPath(file, commonImportPath) {
return nil
}
var out []Violation
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
@@ -53,7 +61,7 @@ func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
File: path,
Line: fset.Position(call.Pos()).Line,
Message: "runtime." + name + " emits a legacy output.ExitError api_error envelope and downgrades typed network/auth boundary errors; it is forbidden on migrated paths",
Suggestion: "call the domain's typed API wrapper (e.g. driveCallAPI) or runtime.DoAPI + errclass.BuildAPIError " +
Suggestion: "call the domain's typed API wrapper (for example driveCallAPI or callTaskAPITyped) or runtime.DoAPI + errclass.BuildAPIError " +
"so failures classify into typed errs.* errors",
})
}
@@ -71,3 +79,16 @@ func matchLegacyRuntimeAPIMethod(name string) (string, bool) {
}
return "", false
}
// importsPath reports whether the file imports the given package path.
func importsPath(file *ast.File, importPath string) bool {
for _, imp := range file.Imports {
if imp.Path == nil {
continue
}
if strings.Trim(imp.Path.Value, "`\"") == importPath {
return true
}
}
return false
}

View File

@@ -618,6 +618,36 @@ func boom() error {
}
}
func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnMigratedShortcutPaths(t *testing.T) {
for _, path := range []string{
"shortcuts/markdown/markdown_fetch.go",
"shortcuts/okr/okr_image_upload.go",
"shortcuts/task/task_update.go",
"shortcuts/whiteboard/whiteboard_update.go",
} {
t.Run(path, func(t *testing.T) {
src := `package migrated
import "github.com/larksuite/cli/internal/output"
func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral(path, src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "ExitError") {
t.Errorf("message should name the legacy type: %s", v[0].Message)
}
})
}
}
func TestCheckNoLegacyEnvelopeLiteral_RejectsErrDetailLiteralOnDrivePath(t *testing.T) {
src := `package drive
@@ -662,7 +692,7 @@ func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/calendar/foo.go", src)
v := CheckNoLegacyEnvelopeLiteral("shortcuts/unmigrated/foo.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path should pass, got: %+v", v)
}
@@ -784,6 +814,8 @@ func boom() error {
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnDrivePath(t *testing.T) {
src := `package drive
import "github.com/larksuite/cli/shortcuts/common"
func boom(runtime *common.RuntimeContext) error {
_, err := runtime.CallAPI("POST", "/x", nil, nil)
return err
@@ -801,9 +833,33 @@ func boom(runtime *common.RuntimeContext) error {
}
}
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnTaskPath(t *testing.T) {
src := `package task
import "github.com/larksuite/cli/shortcuts/common"
func boom(runtime *common.RuntimeContext) error {
_, err := runtime.CallAPI("POST", "/x", nil, nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/task/task_update.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "CallAPI") {
t.Errorf("message should name the legacy method: %s", v[0].Message)
}
}
func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) {
src := `package drive
import "github.com/larksuite/cli/shortcuts/common"
func boom(runtime *common.RuntimeContext) error {
_, err := runtime.DoAPIJSONWithLogID("POST", "/x", nil, nil)
return err
@@ -851,14 +907,14 @@ func boom(runtime *common.RuntimeContext) error {
}
func TestCheckNoLegacyRuntimeAPICall_IgnoresNonMigratedPath(t *testing.T) {
src := `package im
src := `package contact
func boom(runtime *common.RuntimeContext) error {
_, err := runtime.CallAPI("POST", "/x", nil, nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/im/im_send.go", src)
v := CheckNoLegacyRuntimeAPICall("shortcuts/unmigrated/sample.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path must not fire, got: %+v", v)
}
@@ -894,32 +950,134 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
"ResolveOpenIDs",
"HandleApiResult",
}
for _, helper := range helpers {
t.Run(helper, func(t *testing.T) {
src := `package drive
paths := []string{
"shortcuts/doc/docs_fetch_v2.go",
"shortcuts/drive/drive_search.go",
"shortcuts/mail/mail_send.go",
"shortcuts/markdown/markdown_fetch.go",
"shortcuts/okr/okr_progress_create.go",
"shortcuts/sheets/helpers.go",
"shortcuts/slides/slides_create.go",
"shortcuts/task/task_update.go",
"shortcuts/whiteboard/whiteboard_query.go",
}
for _, path := range paths {
for _, helper := range helpers {
t.Run(path+"_"+helper, func(t *testing.T) {
src := `package migrated
import "github.com/larksuite/cli/shortcuts/common"
func boom() {
common.` + helper + `()
common.` + helper + `()
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for %s, got %d: %+v", helper, len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "common."+helper) {
t.Errorf("message should name helper %s: %s", helper, v[0].Message)
}
})
v := CheckNoLegacyCommonHelperCall(path, src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for %s on %s, got %d: %+v", helper, path, len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "common."+helper) {
t.Errorf("message should name helper %s: %s", helper, v[0].Message)
}
})
}
}
}
func TestCheckNoLegacyCommonHelperCall_RejectsDangerousCharsOnCalendarPath(t *testing.T) {
src := `package calendar
import "github.com/larksuite/cli/shortcuts/common"
func boom() {
common.RejectDangerousChars("--summary", "x")
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/calendar/calendar_create.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Suggestion, "common.RejectDangerousCharsTyped") {
t.Errorf("suggestion should name typed replacement, got: %s", v[0].Suggestion)
}
}
func TestCheckNoLegacyCommonHelperCall_CoversDocPathWithAliasAndFunctionValue(t *testing.T) {
src := `package migrated
import c "github.com/larksuite/cli/shortcuts/common"
func boom() {
f := c.FlagErrorf
_ = f
c.WrapInputStatError(nil)
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/doc/docs_fetch_v2.go", src)
if len(v) != 2 {
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on doc path, got %d: %+v", len(v), v)
}
}
func TestCheckNoLegacyCommonHelperCall_CoversSheetsPathWithAliasAndFunctionValue(t *testing.T) {
src := `package migrated
import c "github.com/larksuite/cli/shortcuts/common"
func boom() {
f := c.FlagErrorf
_ = f
c.WrapInputStatError(nil)
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/sheets/helpers.go", src)
if len(v) != 2 {
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on sheets path, got %d: %+v", len(v), v)
}
}
func TestCheckNoLegacyCommonHelperCall_CoversSlidesPathWithAliasAndFunctionValue(t *testing.T) {
src := `package migrated
import c "github.com/larksuite/cli/shortcuts/common"
func boom() {
f := c.FlagErrorf
_ = f
c.WrapInputStatError(nil)
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/slides/slides_create.go", src)
if len(v) != 2 {
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on slides path, got %d: %+v", len(v), v)
}
}
func TestCheckNoLegacyCommonHelperCall_CoversMarkdownPathWithAliasAndFunctionValue(t *testing.T) {
src := `package migrated
import c "github.com/larksuite/cli/shortcuts/common"
func boom() {
f := c.FlagErrorf
_ = f
c.WrapInputStatError(nil)
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/markdown/markdown_fetch.go", src)
if len(v) != 2 {
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on markdown path, got %d: %+v", len(v), v)
}
}
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
src := `package im
src := `package contact
import "github.com/larksuite/cli/shortcuts/common"
@@ -927,7 +1085,7 @@ func boom() {
common.FlagErrorf("legacy allowed until domain migrates")
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/im/im_send.go", src)
v := CheckNoLegacyCommonHelperCall("shortcuts/unmigrated/sample.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path must pass, got: %+v", v)
}
@@ -997,3 +1155,23 @@ func boom() error {
t.Fatalf("expected 1 violation for function-value reference, got %d: %+v", len(v), v)
}
}
func TestCheckNoLegacyRuntimeAPICall_SkipsNonCommonReceiver(t *testing.T) {
// The event domain's APIClient interface has a same-named CallAPI method
// whose implementation classifies into typed errs.* errors; without the
// shortcuts/common import the call cannot be the legacy RuntimeContext
// helper and must not fire.
src := `package vc
import "github.com/larksuite/cli/internal/event"
func boom(rt event.APIClient) error {
_, err := rt.CallAPI(nil, "POST", "/x", nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("events/vc/preconsume.go", src)
if len(v) != 0 {
t.Errorf("non-common CallAPI receiver must not fire, got: %+v", v)
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.47",
"version": "1.0.51",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -21,9 +21,12 @@ var AppsAccessScopeGet = common.Shortcut{
Command: "+access-scope-get",
Description: "Get Miaoda app access scope configuration",
Risk: "read",
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Tips: []string{
"Example: lark-cli apps +access-scope-get --app-id <app_id>",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
},
@@ -42,9 +45,9 @@ var AppsAccessScopeGet = common.Shortcut{
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
path := fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))
data, err := rctx.CallAPI("GET", path, nil, nil)
data, err := rctx.CallAPITyped("GET", path, nil, nil)
if err != nil {
return err
return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`")
}
// 原样透传 — 保留服务端字符串枚举 (All/Tenant/Range),不合并 users/departments/chats。
rctx.OutFormat(data, nil, func(w io.Writer) {

View File

@@ -27,9 +27,14 @@ var AppsAccessScopeSet = common.Shortcut{
Command: "+access-scope-set",
Description: "Set Miaoda app access scope (specific / public / tenant)",
Risk: "write",
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Tips: []string{
`Example: lark-cli apps +access-scope-set --app-id <app_id> --scope tenant`,
`Example: lark-cli apps +access-scope-set --app-id <app_id> --scope public --require-login`,
`Example: lark-cli apps +access-scope-set --app-id <app_id> --scope specific --targets '[{"type":"user","id":"<open_id>"}]'`,
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "scope", Desc: "scope: specific | public | tenant", Required: true, Enum: []string{"specific", "public", "tenant"}},
@@ -64,9 +69,9 @@ var AppsAccessScopeSet = common.Shortcut{
}
appID := strings.TrimSpace(rctx.Str("app-id"))
path := fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))
data, err := rctx.CallAPI("PUT", path, nil, body)
data, err := rctx.CallAPITyped("PUT", path, nil, body)
if err != nil {
return err
return withAppsHint(err, "verify --app-id is correct; for scope=specific, each --targets id must be a valid open_id/department_id/chat_id and --approver a valid open_id; review the current scope with `lark-cli apps +access-scope-get --app-id <app_id>`")
}
rctx.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "access-scope set: %s\n", rctx.Str("scope"))

View File

@@ -8,9 +8,62 @@ import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func testRuntimeAccessScope(t *testing.T, scope, targets, approver string, applyEnabled, requireLogin bool) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "access-scope-set"}
cmd.Flags().String("scope", scope, "")
cmd.Flags().String("targets", targets, "")
cmd.Flags().String("approver", approver, "")
cmd.Flags().Bool("apply-enabled", applyEnabled, "")
cmd.Flags().Bool("require-login", requireLogin, "")
return common.TestNewRuntimeContext(cmd, nil)
}
func TestBuildAccessScopeBody_Branches(t *testing.T) {
t.Run("invalid scope", func(t *testing.T) {
if _, err := buildAccessScopeBody(testRuntimeAccessScope(t, "bogus", "", "", false, false)); err == nil {
t.Error("unknown scope must error")
}
})
t.Run("specific with all target kinds and approver", func(t *testing.T) {
body, err := buildAccessScopeBody(testRuntimeAccessScope(t,
"specific",
`[{"type":"user","id":"u1"},{"type":"department","id":"d1"},{"type":"chat","id":"c1"}]`,
"ou_appr", true, false))
if err != nil {
t.Fatalf("err=%v", err)
}
if body["scope"] != "Range" {
t.Errorf("scope=%v want Range", body["scope"])
}
for _, k := range []string{"users", "departments", "chats", "apply_config"} {
if _, ok := body[k]; !ok {
t.Errorf("missing %q in body=%v", k, body)
}
}
})
t.Run("specific with invalid targets JSON", func(t *testing.T) {
if _, err := buildAccessScopeBody(testRuntimeAccessScope(t, "specific", "{bad", "", false, false)); err == nil {
t.Error("invalid targets JSON must error")
}
})
t.Run("public sets require_login", func(t *testing.T) {
body, err := buildAccessScopeBody(testRuntimeAccessScope(t, "public", "", "", false, true))
if err != nil {
t.Fatalf("err=%v", err)
}
if body["scope"] != "All" || body["require_login"] != true {
t.Errorf("public body=%v", body)
}
})
}
func TestAppsAccessScopeSet_Specific(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
@@ -201,3 +254,44 @@ func TestAppsAccessScopeSet_TrimsAppIDInPath(t *testing.T) {
t.Fatalf("execute err=%v", err)
}
}
func TestSplitAccessScopeTargets_Partitions(t *testing.T) {
users, departments, chats := splitAccessScopeTargets([]map[string]interface{}{
{"type": "user", "id": "u1"},
{"type": "department", "id": "d1"},
{"type": "chat", "id": "c1"},
{"type": "user", "id": " "}, // empty id skipped
{"type": "unknown", "id": "x"}, // unknown type skipped
})
if len(users) != 1 || users[0] != "u1" {
t.Errorf("users=%v want [u1]", users)
}
if len(departments) != 1 || departments[0] != "d1" {
t.Errorf("departments=%v want [d1]", departments)
}
if len(chats) != 1 || chats[0] != "c1" {
t.Errorf("chats=%v want [c1]", chats)
}
}
func TestValidateTargetsJSON_Cases(t *testing.T) {
cases := []struct {
name string
in string
wantErr bool
}{
{"invalid json", "{not json", true},
{"empty array", "[]", true},
{"bad type", `[{"type":"role","id":"r1"}]`, true},
{"empty id", `[{"type":"user","id":" "}]`, true},
{"valid", `[{"type":"user","id":"u1"}]`, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := validateTargetsJSON(c.in)
if (err != nil) != c.wantErr {
t.Errorf("validateTargetsJSON(%q) err=%v wantErr=%v", c.in, err, c.wantErr)
}
})
}
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
// TestAppsList_503IsRetryableTypedError pins the typed-error upgrade: a 5xx
// response from the apps list endpoint must surface as a typed errs.Problem with
// Retryable == true (via CallAPITyped → httpStatusError). The pre-migration
// CallAPI path produced a legacy *output.ExitError with no Retryable field, so
// this test fails until AppsList is migrated to CallAPITyped.
func TestAppsList_503IsRetryableTypedError(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps",
Status: 503,
// A gateway-style non-JSON body (text/html) forces the status-based
// classifier (httpStatusError) rather than the API-envelope path.
Headers: http.Header{"Content-Type": []string{"text/html"}},
RawBody: []byte("<html><body>503 Service Unavailable</body></html>"),
})
err := runAppsShortcut(t, AppsList,
[]string{"+list", "--as", "user"}, factory, stdout)
if err == nil {
t.Fatalf("expected an error on 503, got nil; stdout:\n%s", stdout.String())
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.Problem on 503, got %T: %v", err, err)
}
if !p.Retryable {
t.Fatalf("expected Retryable == true on 503, got Problem=%+v", p)
}
}
// TestAppsList_SuccessShapeUnchanged pins that the success path is
// output-shape-neutral after migration: a 200 envelope still yields a success
// stdout envelope carrying the app_id.
func TestAppsList_SuccessShapeUnchanged(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"app_id": "a", "name": "n"},
},
},
},
})
if err := runAppsShortcut(t, AppsList,
[]string{"+list", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"app_id": "a"`) {
t.Fatalf("stdout missing app_id: %s", got)
}
}

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsChat sends a user message to a session, starting/continuing a conversation.
// Async: the message is queued and the response carries no business payload (no
// turn_id, no next_poll_after_ms — the turn is not generated yet). Poll
// +session-get; it returns next_poll_after_ms, and once the turn runs its handle
// is in latest_turn.turn_id.
// Turn cost varies sharply by init state: the first +chat on a not-initialized
// app runs a one-time design + first-generation pass server-side (~20-50 min);
// chat on an already-initialized app is incremental and finishes in minutes.
// The init-state check and matching polling cadence live in the lark-apps
// skill reference (references/lark-apps-cloud-dev.md) — the canonical source.
var AppsChat = common.Shortcut{
Service: appsService,
Command: "+chat",
Description: "Send a message to a session to start/continue a conversation",
Risk: "write",
Tips: []string{
`Example: lark-cli apps +chat --app-id <app_id> --session-id <session_id> --message "做一个待办清单页面"`,
`Example: lark-cli apps +chat --app-id <app_id> --session-id <session_id> --message "把首页标题改为 我的待办"`,
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "session-id", Desc: "session ID", Required: true},
{Name: "message", Desc: "user message text", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
}
if strings.TrimSpace(rctx.Str("session-id")) == "" {
return output.ErrValidation("--session-id is required")
}
// Do not echo --message content in the error (spec §4 redaction).
if strings.TrimSpace(rctx.Str("message")) == "" {
return output.ErrValidation("--message is required")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST(chatPath(rctx.Str("app-id"), rctx.Str("session-id"))).
Desc("Send a message to a session").
Body(buildChatBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPITyped("POST", chatPath(rctx.Str("app-id"), rctx.Str("session-id")), nil, buildChatBody(rctx))
if err != nil {
return withAppsHint(err, "if the session_id is unknown or invalid, list this app's sessions with `lark-cli apps +session-list --app-id "+strings.TrimSpace(rctx.Str("app-id"))+"`")
}
rctx.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "message sent; poll +session-get for turn status\n")
})
return nil
},
}
func chatPath(appID, sessionID string) string {
return sessionPath(appID, sessionID) + "/chat"
}
func buildChatBody(rctx *common.RuntimeContext) map[string]interface{} {
return map[string]interface{}{
"message": strings.TrimSpace(rctx.Str("message")),
}
}

View File

@@ -0,0 +1,104 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAppsChat_Success(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x/chat",
Body: map[string]interface{}{
"code": 0,
// +chat is async and returns NO business payload (no turn_id, no
// next_poll_after_ms — the turn is not generated yet). turn_id and the
// poll interval are read later from +session-get.
"data": map[string]interface{}{},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsChat,
[]string{"+chat", "--app-id", "app_x", "--session-id", "conv_x", "--message", "把首页表头改成蓝色", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if sent["message"] != "把首页表头改成蓝色" {
t.Fatalf("body.message = %v", sent["message"])
}
if _, present := sent["attachment_ids"]; present {
t.Fatalf("attachment_ids must not be sent this iteration: %v", sent)
}
// +chat carries no next_poll_after_ms; the CLI must not fabricate one.
if got := stdout.String(); strings.Contains(got, "next_poll_after_ms") {
t.Fatalf("stdout must not reference next_poll_after_ms (chat returns none): %s", got)
}
}
func TestAppsChat_Pretty(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x/chat",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
})
if err := runAppsShortcut(t, AppsChat,
[]string{"+chat", "--app-id", "app_x", "--session-id", "conv_x", "--message", "hi", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "message sent") || !strings.Contains(got, "+session-get") {
t.Fatalf("pretty wrong: %q", got)
}
}
func TestAppsChat_RequiresMessage(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsChat,
[]string{"+chat", "--app-id", "app_x", "--session-id", "conv_x", "--message", "", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "message") {
t.Fatalf("expected --message required error, got %v", err)
}
}
// Security: a non-blank message that fails for another reason must never be echoed.
// Here we assert the blank-message error names the field only (no content leak path).
func TestAppsChat_ValidationDoesNotEchoMessage(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
// blank message triggers validation; the error must mention the flag, not any content.
err := runAppsShortcut(t, AppsChat,
[]string{"+chat", "--app-id", "", "--session-id", "conv_x", "--message", "secret-content-xyz", "--as", "user"}, factory, stdout)
if err == nil {
t.Fatalf("expected validation error")
}
if strings.Contains(err.Error(), "secret-content-xyz") {
t.Fatalf("validation error must not echo --message content: %v", err)
}
}
func TestAppsChat_DryRun(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsChat,
[]string{"+chat", "--app-id", "app_x", "--session-id", "conv_x", "--message", "hi", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/sessions/conv_x/chat") {
t.Fatalf("dry-run missing endpoint: %s", got)
}
if !strings.Contains(got, `"message": "hi"`) {
t.Fatalf("dry-run missing message body: %s", got)
}
}

View File

@@ -13,18 +13,24 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
const createHint = "verify --app-type is html or full_stack and --name is non-empty; if this is a permission error, confirm your account can create Miaoda apps"
// AppsCreate creates a new Miaoda app.
var AppsCreate = common.Shortcut{
Service: appsService,
Command: "+create",
Description: "Create a new Miaoda app",
Risk: "write",
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Tips: []string{
`Example: lark-cli apps +create --name "审批系统" --app-type full_stack`,
`Example: lark-cli apps +create --name "活动页" --app-type html --description "活动报名"`,
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "name", Desc: "app display name", Required: true},
{Name: "app-type", Desc: "app type (currently only: HTML)", Required: true},
{Name: "app-type", Desc: "app type", Required: true, Enum: []string{"html", "full_stack"}},
{Name: "description", Desc: "app description"},
{Name: "icon-url", Desc: "app icon URL (server uses default if omitted)"},
},
@@ -32,13 +38,6 @@ var AppsCreate = common.Shortcut{
if strings.TrimSpace(rctx.Str("name")) == "" {
return output.ErrValidation("--name is required")
}
appType := strings.TrimSpace(rctx.Str("app-type"))
if appType == "" {
return output.ErrValidation("--app-type is required")
}
if !validAppTypes[appType] {
return output.ErrValidation(fmt.Sprintf("--app-type %q is not supported (allowed: HTML)", appType))
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
@@ -48,9 +47,9 @@ var AppsCreate = common.Shortcut{
Body(buildAppsCreateBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPI("POST", apiBasePath+"/apps", nil, buildAppsCreateBody(rctx))
data, err := rctx.CallAPITyped("POST", apiBasePath+"/apps", nil, buildAppsCreateBody(rctx))
if err != nil {
return err
return withAppsHint(err, createHint)
}
rctx.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "created: %s\n", common.GetString(data, "app", "app_id"))
@@ -59,15 +58,13 @@ var AppsCreate = common.Shortcut{
},
}
// 应用类型枚举。当前只有 HTML未来会扩展SPA、NATIVE、...)。
var validAppTypes = map[string]bool{
"HTML": true,
}
func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
// --app-type is constrained to the lowercase enum (html / full_stack) by the
// flag's Enum, so send it through verbatim. Legacy uppercase compatibility is
// a server concern and is intentionally not surfaced by the CLI.
body := map[string]interface{}{
"name": strings.TrimSpace(rctx.Str("name")),
"app_type": strings.TrimSpace(rctx.Str("app-type")),
"app_type": rctx.Str("app-type"),
}
if desc := strings.TrimSpace(rctx.Str("description")); desc != "" {
body["description"] = desc

View File

@@ -22,6 +22,7 @@ import (
func newAppsExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "test-app-" + strings.ToLower(t.Name()),
@@ -68,7 +69,7 @@ func TestAppsCreate_Success(t *testing.T) {
reg.Register(stub)
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--description", "d", "--as", "user"},
[]string{"+create", "--name", "Demo", "--app-type", "html", "--description", "d", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
@@ -83,8 +84,8 @@ func TestAppsCreate_Success(t *testing.T) {
if sent["name"] != "Demo" {
t.Fatalf("body.name = %v", sent["name"])
}
if sent["app_type"] != "HTML" {
t.Fatalf("body.app_type = %v (want HTML)", sent["app_type"])
if sent["app_type"] != "html" {
t.Fatalf("body.app_type = %v (want html)", sent["app_type"])
}
if sent["description"] != "d" {
t.Fatalf("body.description = %v", sent["description"])
@@ -108,7 +109,7 @@ func TestAppsCreate_WithIconURL(t *testing.T) {
})
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--icon-url", "https://example.com/icon.svg", "--as", "user"},
[]string{"+create", "--name", "Demo", "--app-type", "html", "--icon-url", "https://example.com/icon.svg", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
@@ -133,7 +134,7 @@ func TestAppsCreate_PrettyOutputReadsNestedAppID(t *testing.T) {
})
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--format", "pretty", "--as", "user"},
[]string{"+create", "--name", "Demo", "--app-type", "html", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
@@ -144,7 +145,7 @@ func TestAppsCreate_PrettyOutputReadsNestedAppID(t *testing.T) {
func TestAppsCreate_RequiresName(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsCreate, []string{"+create", "--app-type", "HTML", "--as", "user"}, factory, stdout)
err := runAppsShortcut(t, AppsCreate, []string{"+create", "--app-type", "html", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "name") {
t.Fatalf("expected name required error, got %v", err)
}
@@ -159,20 +160,31 @@ func TestAppsCreate_RequiresAppType(t *testing.T) {
}
}
// TestAppsCreate_RejectsInvalidAppType pins that --app-type is a strict
// lowercase enum (html / full_stack). Unknown values and legacy uppercase are
// both rejected by the flag's Enum — the CLI does not normalize case; legacy
// uppercase compatibility is a server-side concern, not surfaced by the client.
func TestAppsCreate_RejectsInvalidAppType(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "spa", "--as", "user"},
factory, stdout)
if err == nil || !strings.Contains(err.Error(), "not supported") {
t.Fatalf("expected unsupported app-type error, got %v", err)
for _, appType := range []string{"spa", "HTML", "Full_Stack"} {
t.Run(appType, func(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", appType, "--as", "user"},
factory, stdout)
if err == nil || !strings.Contains(err.Error(), "invalid value") {
t.Fatalf("expected invalid-enum error for %q, got %v", appType, err)
}
if !strings.Contains(err.Error(), "full_stack") {
t.Fatalf("expected enum error to list allowed values, got %v", err)
}
})
}
}
func TestAppsCreate_DryRun(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--dry-run", "--as", "user"},
[]string{"+create", "--name", "Demo", "--app-type", "html", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
@@ -183,7 +195,55 @@ func TestAppsCreate_DryRun(t *testing.T) {
if !strings.Contains(got, `"name": "Demo"`) {
t.Fatalf("dry-run missing body: %s", got)
}
if !strings.Contains(got, `"app_type": "HTML"`) {
if !strings.Contains(got, `"app_type": "html"`) {
t.Fatalf("dry-run missing app_type: %s", got)
}
}
func TestAppsCreate_FullstackSuccess(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"app": map[string]interface{}{"app_id": "app_fs", "name": "Demo"},
},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "full_stack", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if sent["app_type"] != "full_stack" {
t.Fatalf("body.app_type = %v (want full_stack)", sent["app_type"])
}
if _, present := sent["message"]; present {
t.Fatalf("message should never be sent: %v", sent)
}
}
func TestAppsCreate_FullstackDryRun(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "full_stack", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"app_type": "full_stack"`) {
t.Fatalf("dry-run missing app_type full_stack: %s", got)
}
if strings.Contains(got, `"message"`) {
t.Fatalf("dry-run should not contain message: %s", got)
}
}

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
const dbEnvCreateHint = "verify --app-id is correct; if the app is already multi-env this is a conflict — inspect current tables with `lark-cli apps +db-table-list --app-id <app_id> --env dev`"
// AppsDBEnvCreate creates a DB environment for a Miaoda app拆分单库为 dev/online 多环境)。
//
// 调 POST /apps/{app_id}/db_dev_init。--env 指定要创建的环境,由调用方传入,目前只支持 dev。
// 不可逆:单库一旦拆成 dev/online 双库无法回退。Risk: high-risk-write 触发框架自动注入 --yes 确认关卡。
var AppsDBEnvCreate = common.Shortcut{
Service: appsService,
Command: "+db-env-create",
Description: "Create a DB environment (split single-env DB into dev/online, irreversible)",
Risk: "high-risk-write",
Tips: []string{
"Example: lark-cli apps +db-env-create --env dev --sync-data --app-id <app_id> --yes",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "env", Default: "dev", Enum: []string{"dev"}, Desc: "environment to create (only dev supported for now)"},
{Name: "sync-data", Type: "bool", Desc: "copy existing online data into the new environment (default off)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
_, err := requireAppID(rctx.Str("app-id"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(appDbEnvCreatePath(appID)).
Desc("Create Miaoda app DB environment").
Body(buildDBEnvCreateBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
data, err := rctx.CallAPITyped("POST", appDbEnvCreatePath(appID), nil, buildDBEnvCreateBody(rctx))
if err != nil {
return withAppsHint(err, dbEnvCreateHint)
}
rctx.OutFormat(data, nil, func(w io.Writer) {
renderEnvCreatePretty(w, data)
})
return nil
},
}
// buildDBEnvCreateBody 构造 db 环境创建 bodysync_databool
// --env 目前只支持 dev、服务端接口本身即创建 dev 环境,故不下发 env 字段(仅做 CLI 入参校验/前向兼容)。
func buildDBEnvCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
return map[string]interface{}{
"sync_data": rctx.Bool("sync-data"),
}
}
// renderEnvCreatePretty 输出 4 行pretty 模式):
//
// ✓ Multi-env initialized
// Environments: dev, online
// Data synced: yes
// Note: structure changes in dev now need to be released to online.
func renderEnvCreatePretty(w io.Writer, data map[string]interface{}) {
fmt.Fprintln(w, "✓ Multi-env initialized")
if envs, ok := data["environments"].([]interface{}); ok && len(envs) > 0 {
names := make([]string, 0, len(envs))
for _, e := range envs {
if s, ok := e.(string); ok {
names = append(names, s)
}
}
fmt.Fprintf(w, "Environments: %s\n", strings.Join(names, ", "))
}
synced := "no"
if ds, ok := data["data_synced"].(bool); ok && ds {
synced = "yes"
}
fmt.Fprintf(w, "Data synced: %s\n", synced)
fmt.Fprintln(w, "Note: structure changes in dev now need to be released to online.")
}

View File

@@ -0,0 +1,124 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAppsDBEnvCreate_WithYesPostsSyncData(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/db_dev_init", // URL 仍走 db_dev_initCLI 命令名 +db-env-create
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"status": "initialized",
"environments": []interface{}{"dev", "online"},
"data_synced": true,
},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsDBEnvCreate,
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--sync-data", "--yes", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if sent["sync_data"] != true {
t.Fatalf("body.sync_data = %v (want true)", sent["sync_data"])
}
if !strings.Contains(stdout.String(), "initialized") {
t.Fatalf("stdout should include status, got %s", stdout.String())
}
}
// 不传 --sync-data默认→ body.sync_data=false
func TestAppsDBEnvCreate_SyncDataFalseByDefault(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/db_dev_init",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "initialized"}},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsDBEnvCreate,
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--yes", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if sent["sync_data"] != false {
t.Fatalf("body.sync_data = %v (want false by default)", sent["sync_data"])
}
}
func TestAppsDBEnvCreate_PrettyEmitsAllFourLines(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/db_dev_init",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"status": "initialized",
"environments": []interface{}{"dev", "online"},
"data_synced": true,
},
},
})
if err := runAppsShortcut(t, AppsDBEnvCreate,
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--sync-data", "--yes", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
wantLines := []string{
"✓ Multi-env initialized",
"Environments: dev, online",
"Data synced: yes",
"Note: structure changes in dev now need to be released to online.",
}
for _, line := range wantLines {
if !strings.Contains(got, line) {
t.Errorf("pretty output missing line %q\ngot:\n%s", line, got)
}
}
}
func TestAppsDBEnvCreate_DryRunNoConfirm(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBEnvCreate,
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/db_dev_init") {
t.Fatalf("dry-run missing endpoint: %s", got)
}
}
// --env 只接受 dev传 online 应被 enum 校验拒绝。
func TestAppsDBEnvCreate_RejectsNonDevEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBEnvCreate,
[]string{"+db-env-create", "--app-id", "app_x", "--env", "online", "--yes", "--as", "user"},
factory, stdout)
if err == nil || !strings.Contains(err.Error(), "env") {
t.Fatalf("expected env enum rejection, got %v", err)
}
}

View File

@@ -0,0 +1,520 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"encoding/json"
"fmt"
"io"
"sort"
"strconv"
"strings"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsDBExecute executes SQL against a Miaoda app database.
//
// POST /apps/{app_id}/sql_commandsCLI 永远带 ?transactional=false 进入 DBA 模式
// (不默认包事务、支持 DDL、result 字符串内嵌结构化 JSON
//
// pretty 渲染 6 种形态:
// - 单 SELECT表格列间两空格、列对齐填充
// - 空 SELECT`(0 rows)`
// - 单 DML`✓ N row(s) <verb>`verb 跟 sql_typeINSERT→inserted/UPDATE→updated/DELETE→deleted
// - 单 DDL`✓ DDL executed`
// - 多语句全部成功:逐条 `Statement K: ✓ <summary>` + 末尾 `✓ N statements executed`
// - 多语句部分失败:`Statement K: ✗ <message> [<code>]` + 末尾「前序语句已落地」提示
//
// 失败语义server 多语句失败仍返 code:0把失败语句标成 ERROR 哨兵塞进 result。Execute 检测到哨兵
// 后升级成 typed api_errorexit 非 0、detail 带 statement_index / completed / rolled_back
// 避免 agent 误判 ok:true 假成功。CLI 永远 DBA 模式transactional=false失败前的语句已 auto-commit
// 落地,故 rolled_back=false真机 boe 实证)。
//
// JSON envelope成功路径CLI 把 server 返的 result 字符串解出来放进 `data.results` 数组。
//
// Risk: high-risk-write —— SQL 可含 DML/DDL框架对所有执行强制 --yes 确认关卡(--dry-run 预览豁免)。
//
// SQL 来源二选一:--sql内联文本或 - 读 stdin/ --file.sql 文件路径,受 CLI 相对路径约束)。
// --file 在 Validate 阶段读出内容、归一化到 --sql下游统一从 rctx.Str("sql") 取。
var AppsDBExecute = common.Shortcut{
Service: appsService,
Command: "+db-execute",
Description: "Execute SQL (SELECT / DML / DDL) against a Miaoda app database",
Risk: "high-risk-write",
Tips: []string{
`Example: lark-cli apps +db-execute --app-id <app_id> --sql "SELECT * FROM orders LIMIT 10" --yes`,
`Example: lark-cli apps +db-execute --app-id <app_id> --env dev --file ./migration.sql --yes`,
"Tip: filter fields with --jq, e.g. -q '.data.results[].sql_type'",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
Input: []string{common.Stdin}},
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
{Name: "env", Default: "dev", Enum: []string{"dev", "online"}, Desc: "target db environment (default dev; use --env online for the online environment)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
sql := strings.TrimSpace(rctx.Str("sql"))
file := strings.TrimSpace(rctx.Str("file"))
if sql != "" && file != "" {
return output.ErrValidation("--sql and --file are mutually exclusive")
}
if file != "" {
data, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
if err != nil {
return output.ErrValidation("--file: %v", err)
}
// 归一化:把文件内容写回 --sql下游DryRun/Execute统一从 sql 取。
rctx.Cmd.Flags().Set("sql", string(data))
sql = strings.TrimSpace(string(data))
}
if sql == "" {
return output.ErrValidation("one of --sql or --file is required (use --sql - to read stdin)")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(appSQLPath(appID)).
Desc("Execute SQL on Miaoda app database").
Params(buildDBSQLParams(rctx)).
Body(buildDBSQLBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
raw, err := rctx.CallAPITyped("POST", appSQLPath(appID),
buildDBSQLParams(rctx),
buildDBSQLBody(rctx))
if err != nil {
return withAppsHint(err, "verify table/column names with `lark-cli apps +db-table-get --app-id "+appID+" --table <table>`; for day-to-day debugging target the dev database with `--env dev`")
}
// server `result: string` 内嵌结构化数组 —— CLI 解出来放进 envelope 的 data.results
// 让 json/pretty 路径都基于同一份反序列化产物渲染。
stmts := parseSQLResult(common.GetString(raw, "result"))
// 注意data.results 在 json默认路径下原样透出全部行CLI 侧不再二次截断。
// 这不是无界 token 黑洞 —— server 对单条 SELECT 结果集有 1000 行硬上限,超出会直接
// 返报错(而非静默截断)。需要更大结果集时请在 SQL 里显式 LIMIT/分页,由调用方控制规模。
data := map[string]interface{}{"results": stmts}
// 多语句 / 单语句失败server 仍返 code:0把失败语句标成 ERROR 哨兵塞进 result。
// 升级成 typed api_errorexit 非 0别让 agent 误判 ok:true 假成功。
// pretty 模式仍把逐条 ✓/✗ 摘要打到 stdout人看再返回 errorenvelope→stderr
if errIdx, errStmt, failed := findErrorSentinel(stmts); failed {
if rctx.Format == "pretty" {
renderSQLPretty(rctx.IO().Out, stmts)
}
return sqlStatementError(stmts, errIdx, errStmt)
}
rctx.OutFormat(data, nil, func(w io.Writer) {
renderSQLPretty(w, stmts)
})
return nil
},
}
// findErrorSentinel 在 statements 里找 ERROR 哨兵server 失败时追加在失败语句位置)。
// 返回失败语句下标0-based、该 ERROR statement、是否命中。
func findErrorSentinel(stmts []map[string]interface{}) (int, map[string]interface{}, bool) {
for i, s := range stmts {
if common.GetString(s, "sql_type") == "ERROR" {
return i, s, true
}
}
return 0, nil, false
}
// sqlStatementError 把 ERROR 哨兵升级成 typed api_error。
//
// CLI 永远 DBA 模式transactional=false真机 boe 实证:失败语句之前的语句已逐条 auto-commit
// 落地,不存在外层事务回滚。因此 rolled_back=false、completed 列出已落地的前序语句hint 提示用户
// 别整批重跑(否则会重复写入)。
func sqlStatementError(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) error {
code, msg := parseErrorSentinel(common.GetString(errStmt, "data"))
stmtNo := errIdx + 1 // 1-based 给人看
fullMsg := fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts))
apiErr := output.ErrAPI(code, fullMsg, map[string]interface{}{
"statement_index": errIdx,
"completed": stmts[:errIdx],
"rolled_back": false,
})
if apiErr.Detail != nil {
if errIdx > 0 {
apiErr.Detail.Hint = fmt.Sprintf(
"statements 1-%d were already applied (DBA mode auto-commits each statement); fix statement %d and re-run only the remaining statements.",
errIdx, stmtNo)
} else {
apiErr.Detail.Hint = "no statements were applied; fix the SQL and re-run."
}
}
return apiErr
}
// parseErrorSentinel 解析 ERROR 哨兵的 data`{code,message}` JSON返回数值 code 与 message。
// code 兼容 int / "k_dl_1300002" / 数字字符串多形态(复用 codeString解析失败回退 0 / 原文。
func parseErrorSentinel(data string) (int, string) {
if data == "" {
return 0, "(unknown error)"
}
var e struct {
Code interface{} `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal([]byte(data), &e); err != nil {
return 0, data
}
code := 0
if cs := codeString(e.Code); cs != "" {
if n, convErr := strconv.Atoi(cs); convErr == nil {
code = n
}
}
if e.Message == "" {
return code, "(unknown error)"
}
return code, e.Message
}
// buildDBSQLParams 构造 sql 接口的 queryenv + 强制 transactional=falseDBA 模式)。
//
// CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。
func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
return map[string]interface{}{
"env": rctx.Str("env"),
"transactional": false,
}
}
// buildDBSQLBody 构造 sql 接口的 body仅 sql来源由 Validate 归一化到 --sql
func buildDBSQLBody(rctx *common.RuntimeContext) map[string]interface{} {
return map[string]interface{}{
"sql": rctx.Str("sql"),
}
}
// parseSQLResult 从 server result 字符串反序列化出 statements 数组,兼容两种 wire 形态:
//
// 1. 结构化形态:`[{"sql_type":"SELECT","data":"[...]","record_count":N}, ...]`
// —— 每条 statement 含 sql_type / data / record_count / affected_rows 元数据。
//
// 2. 字符串数组形态:`["[{...rows...}]", "", ...]`
// —— 每条 statement 一个字符串SELECT 是 rows JSON、DML/DDL 是空串;
// 无 sql_type 元数据CLI 端按内容形态推断SELECT vs OK
//
// 解析失败时返回单元素 fallback `{sql_type:"RAW", data:resultStr}`pretty 路径原样打。
func parseSQLResult(resultStr string) []map[string]interface{} {
if resultStr == "" {
return nil
}
// 形态 1结构化数组每元素是 object
var structured []map[string]interface{}
if err := json.Unmarshal([]byte(resultStr), &structured); err == nil && isStructuredResult(structured) {
return structured
}
// 形态 2字符串数组每元素是 rows JSON 或 ""
var legacy []string
if err := json.Unmarshal([]byte(resultStr), &legacy); err == nil {
out := make([]map[string]interface{}, 0, len(legacy))
for _, rowsJSON := range legacy {
out = append(out, normalizeLegacyStatement(rowsJSON))
}
return out
}
return []map[string]interface{}{{"sql_type": "RAW", "data": resultStr}}
}
// isStructuredResult 判断反序列化出来的 []map 是不是新形态:第一条元素含 sql_type 字段。
// 兼容场景:[]map 反序列化 legacy `[""]` 可能也能成(空 map用 sql_type 存在性区分。
func isStructuredResult(stmts []map[string]interface{}) bool {
if len(stmts) == 0 {
return false
}
_, ok := stmts[0]["sql_type"]
return ok
}
// normalizeLegacyStatement 把 legacy wire 一个字符串元素转成跟新形态一致的 map。
// 推断规则data 是非空 rows 数组 → sql_type=SELECT空串 / 空数组 → sql_type=OKDML/DDL 老 wire 不可分)。
func normalizeLegacyStatement(rowsJSON string) map[string]interface{} {
stmt := map[string]interface{}{
"sql_type": "OK",
"data": rowsJSON,
}
trimmed := strings.TrimSpace(rowsJSON)
if trimmed == "" || trimmed == "null" {
return stmt
}
var rows []interface{}
if err := json.Unmarshal([]byte(trimmed), &rows); err != nil {
// 非 JSON 数组(理论上 server 不会返这种),按原样保留 sql_type=OK
return stmt
}
// 是 JSON 数组 → 视作 SELECT含 record_count
stmt["sql_type"] = "SELECT"
stmt["record_count"] = float64(len(rows))
return stmt
}
// renderSQLPretty 按 statements 数量分单条 / 多条两种渲染路径。
func renderSQLPretty(w io.Writer, stmts []map[string]interface{}) {
if len(stmts) == 0 {
fmt.Fprintln(w, "(empty result)")
return
}
if len(stmts) == 1 {
renderSingleStatementPretty(w, stmts[0])
return
}
renderMultiStatementPretty(w, stmts)
}
// renderSingleStatementPretty 单条 statement pretty无 Statement header
func renderSingleStatementPretty(w io.Writer, s map[string]interface{}) {
sqlType := common.GetString(s, "sql_type")
switch {
case sqlType == "SELECT":
renderSelectRowsAsTable(w, common.GetString(s, "data"))
case sqlType == "ERROR":
// 单条就挂的极端场景:直接打 ERROR 行(跟多语句失败的最后一行格式一致)。
fmt.Fprintln(w, "✗ "+errorSummary(common.GetString(s, "data")))
case isDMLType(sqlType):
// 结构化 wire 下 INSERT / UPDATE / DELETE / MERGE✓ N row(s) <verb>
fmt.Fprintln(w, "✓ "+dmlSummary(sqlType, s["affected_rows"]))
case sqlType == "OK":
// legacy wire 下 DML / DDL 都映射成 OK老 wire 不带 sql_type 元数据,无法区分动词 / 行数)
fmt.Fprintln(w, "✓ ok")
default:
// 其余皆 DDL真机 boe 返细粒度动词 CREATE_TABLE / DROP_TABLE / ALTER_TABLE / TRUNCATE 等。
fmt.Fprintln(w, "✓ DDL executed")
}
}
// renderMultiStatementPretty 多条 statement pretty
// - 每条用 "Statement K: ✓ <summary>" / "Statement K: ✗ <error> [<code>]"
// - SELECT 用 "Statement K: SELECT (N row(s))" 头 + 紧跟表格
// - 末尾汇总:全部成功 "✓ N statements executed";遇 ERROR 哨兵打「前序语句已落地」提示
// DBA 模式不回滚),失败本身由 Execute 升级成 typed errorexit 非 0
func renderMultiStatementPretty(w io.Writer, stmts []map[string]interface{}) {
failedIdx := -1
successCount := 0
for i, s := range stmts {
sqlType := common.GetString(s, "sql_type")
idx := i + 1
switch {
case sqlType == "ERROR":
fmt.Fprintf(w, "Statement %d: ✗ %s\n", idx, errorSummary(common.GetString(s, "data")))
failedIdx = i
case sqlType == "SELECT":
rc := intOrZero(s["record_count"])
fmt.Fprintf(w, "Statement %d: SELECT (%d row%s)\n", idx, rc, plural(rc))
renderSelectRowsAsTable(w, common.GetString(s, "data"))
successCount++
case isDMLType(sqlType):
fmt.Fprintf(w, "Statement %d: ✓ %s\n", idx, dmlSummary(sqlType, s["affected_rows"]))
successCount++
case sqlType == "OK":
fmt.Fprintf(w, "Statement %d: ✓ ok\n", idx)
successCount++
default:
// DDL 族CREATE_TABLE / DROP_TABLE / ALTER_TABLE / TRUNCATE / CREATE_INDEX ...
fmt.Fprintf(w, "Statement %d: ✓ DDL executed\n", idx)
successCount++
}
if i < len(stmts)-1 {
fmt.Fprintln(w) // statements 间留空行
}
}
fmt.Fprintln(w)
if failedIdx >= 0 {
// CLI 永远 DBA 模式transactional=false失败语句之前的语句已 auto-commit 落地,
// 不存在整批回滚 —— 如实告诉用户,避免整批重跑导致重复写入。
if successCount > 0 {
fmt.Fprintf(w, "(statement %d failed; %d statement%s before it already applied — DBA mode auto-commits each)\n",
failedIdx+1, successCount, plural(int64(successCount)))
} else {
fmt.Fprintf(w, "(statement %d failed; no statements applied)\n", failedIdx+1)
}
} else {
fmt.Fprintf(w, "✓ %d statements executed\n", successCount)
}
}
// renderSelectRowsAsTable 把 SELECT 的 datarows JSON 数组字符串)解析并渲染成对齐表格。
// 空结果输出 "(0 rows)"。
func renderSelectRowsAsTable(w io.Writer, dataJSON string) {
if dataJSON == "" || dataJSON == "[]" {
fmt.Fprintln(w, "(0 rows)")
return
}
var rows []map[string]interface{}
if err := json.Unmarshal([]byte(dataJSON), &rows); err != nil {
// 数据不符合预期 schema —— 原样打 fallback。
fmt.Fprintln(w, dataJSON)
return
}
if len(rows) == 0 {
fmt.Fprintln(w, "(0 rows)")
return
}
headers := collectColumns(rows)
cells := make([][]string, 0, len(rows))
for _, row := range rows {
line := make([]string, 0, len(headers))
for _, h := range headers {
line = append(line, cellString(row[h]))
}
cells = append(cells, line)
}
renderAlignedTable(w, headers, cells)
}
// collectColumns 按首行字段顺序收集列名;首行 key 顺序由 encoding/json 反序列化决定map 无序),
// 排序后保证输出稳定。列顺序在示例里跟 SQL SELECT 顺序一致——但 Go encoding/json 反序列化丢列序,
// 这里按字典序保证可重现agent / 测试可稳定 assert。
func collectColumns(rows []map[string]interface{}) []string {
set := map[string]struct{}{}
for _, r := range rows {
for k := range r {
set[k] = struct{}{}
}
}
cols := make([]string, 0, len(set))
for k := range set {
cols = append(cols, k)
}
sort.Strings(cols)
return cols
}
// cellString 把任意 JSON value 转字符串显示null → 空串;非字符串/数字 → JSON 编码)。
func cellString(v interface{}) string {
switch x := v.(type) {
case nil:
return ""
case string:
return x
case bool:
if x {
return "true"
}
return "false"
case float64:
// 整数值不输出小数id=101 而不是 101.000000)。
if x == float64(int64(x)) {
return fmt.Sprintf("%d", int64(x))
}
return fmt.Sprintf("%g", x)
}
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}
// dmlSummary 把 sql_type + affected_rows 渲染成 "N row(s) <verb>" 字符串。
//
// 动词映射INSERT → inserted / UPDATE → updated / DELETE → deleted / MERGE → merged。
// 未知 sql_type 默认 "affected"。
func dmlSummary(sqlType string, affectedRows interface{}) string {
n := intOrZero(affectedRows)
verb := dmlVerb(sqlType)
return fmt.Sprintf("%d row%s %s", n, plural(n), verb)
}
// isDMLType 判断 sql_type 是否是行级 DML带 affected_rows 语义)。
// 真机 boe wireSELECT 走表格、INSERT/UPDATE/DELETE/MERGE 走行数摘要、其余CREATE_TABLE /
// DROP_TABLE / ALTER_TABLE / TRUNCATE / CREATE_INDEX ...)一律按 DDL 处理。
func isDMLType(sqlType string) bool {
switch strings.ToUpper(sqlType) {
case "INSERT", "UPDATE", "DELETE", "MERGE":
return true
}
return false
}
func dmlVerb(sqlType string) string {
switch strings.ToUpper(sqlType) {
case "INSERT":
return "inserted"
case "UPDATE":
return "updated"
case "DELETE":
return "deleted"
case "MERGE":
return "merged"
}
return "affected"
}
func plural(n int64) string {
if n == 1 {
return ""
}
return "s"
}
// errorSummary 从 ERROR 哨兵的 data 字段({code, message} JSON解析出 "message [code]" 形态。
// 解析失败时回退到原文。
func errorSummary(data string) string {
if data == "" {
return "(unknown error)"
}
var e struct {
Code interface{} `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal([]byte(data), &e); err != nil {
return data
}
codeStr := codeString(e.Code)
if codeStr != "" {
return fmt.Sprintf("%s [%s]", e.Message, codeStr)
}
return e.Message
}
// codeString 处理 code 字段在 wire 上可能是 int / "k_dl_1300015" / 数字字符串等多形态。
func codeString(c interface{}) string {
switch x := c.(type) {
case nil:
return ""
case string:
// "k_dl_1300015" → 抽 1300015纯数字保持原样。
if strings.HasPrefix(x, "k_dl_") {
return strings.TrimPrefix(x, "k_dl_")
}
return x
case float64:
return fmt.Sprintf("%d", int64(x))
}
return ""
}
// intOrZero 把 JSON number 转 int64nil / 类型不匹配返回 0。
func intOrZero(raw interface{}) int64 {
if n, ok := numericAsFloat(raw); ok {
return int64(n)
}
return 0
}

View File

@@ -0,0 +1,797 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestAppsDBExecute_SingleSELECTJSONEnvelopeWrapsResults(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
// DBA 模式 result结构化数组 JSON 字符串
"result": `[{"sql_type":"SELECT","data":"[{\"id\":101,\"total_cents\":2500}]","record_count":1}]`,
},
},
})
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select 1", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
// JSON envelope 应该把 result 字符串 parse 之后放进 data.results
var env struct {
Data struct {
Results []map[string]interface{} `json:"results"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode envelope: %v\n%s", err, stdout.String())
}
if len(env.Data.Results) != 1 {
t.Fatalf("data.results = %d items (want 1)", len(env.Data.Results))
}
if env.Data.Results[0]["sql_type"] != "SELECT" {
t.Fatalf("results[0].sql_type = %v", env.Data.Results[0]["sql_type"])
}
}
func TestAppsDBExecute_DryRunSendsTransactionalFalse(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select 1", "--env", "dev", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
t.Fatalf("decode: %v\n%s", err, stdout.String())
}
if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/sql_commands" {
t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL)
}
if env.API[0].Body["sql"] != "select 1" {
t.Fatalf("body.sql = %v", env.API[0].Body["sql"])
}
if env.API[0].Params["env"] != "dev" {
t.Fatalf("params.env = %v", env.API[0].Params["env"])
}
if env.API[0].Params["transactional"] != false {
t.Fatalf("params.transactional = %v (want false, CLI is DBA mode)", env.API[0].Params["transactional"])
}
if _, ok := env.API[0].Body["transactional"]; ok {
t.Fatalf("transactional should NOT be in body, got body=%v", env.API[0].Body)
}
}
func TestAppsDBExecute_RejectsEmptySQL(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", " ", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "--sql or --file") {
t.Fatalf("expected empty-sql error, got %v", err)
}
}
// --sql 与 --file 互斥
func TestAppsDBExecute_RejectsSQLAndFileTogether(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "SELECT 1", "--file", "x.sql", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("expected mutual-exclusion error, got %v", err)
}
}
// --file 读取相对路径 .sql 文件 → 内容进 body.sqldry-run 验证)
func TestAppsDBExecute_FileReadsSQLIntoBody(t *testing.T) {
dir := t.TempDir()
sqlPath := filepath.Join(dir, "m.sql")
if err := os.WriteFile(sqlPath, []byte("SELECT 42 AS answer;\n"), 0o600); err != nil {
t.Fatal(err)
}
// 切到临时目录使相对路径校验通过CLI 仅接受 cwd 内相对路径)。
// 用 os.Chdir + 还原而非 t.Chdir后者要 Go 1.24,本仓库 go.mod 为 1.23。
oldWD, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--app-id", "app_x", "--env", "dev", "--file", "m.sql", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
t.Fatalf("decode: %v\n%s", err, stdout.String())
}
if env.API[0].Body["sql"] != "SELECT 42 AS answer;\n" {
t.Fatalf("body.sql = %v, want file content", env.API[0].Body["sql"])
}
}
// ============================================================================
// legacy wire 形态测试 —— BOE server 实测返这种 ["rows-json-string", ...]
// 形态而非 spec 里的 [{sql_type, data, ...}]CLI 端必须兼容。
// 输入用 BOE 真实抓包数据test_scripts/boe_e2e/run.log
// ============================================================================
func TestAppsDBExecute_LegacyWireSingleSelect(t *testing.T) {
// BOE 实测SELECT 1 AS x → result: "[\"[{\\\"x\\\":1}]\"]"
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `["[{\"x\":1}]"]`,
},
},
})
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "SELECT 1 AS x", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "x") {
t.Errorf("missing header 'x':\n%s", got)
}
if !strings.Contains(got, "1") {
t.Errorf("missing value row '1':\n%s", got)
}
// 不应回退到 RAW
if strings.Contains(got, "RAW") || strings.Contains(got, "[\\\"") {
t.Errorf("should not fall back to RAW or raw-string passthrough:\n%s", got)
}
}
func TestAppsDBExecute_LegacyWireSingleSelectJSONEnvelope(t *testing.T) {
// 验证 JSON envelope 也把 legacy result 正确归一化进 data.results
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `["[{\"x\":1}]"]`,
},
},
})
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "SELECT 1 AS x", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var env struct {
Data struct {
Results []map[string]interface{} `json:"results"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode: %v\n%s", err, stdout.String())
}
if len(env.Data.Results) != 1 {
t.Fatalf("results length = %d, want 1; got: %v", len(env.Data.Results), env.Data.Results)
}
if env.Data.Results[0]["sql_type"] != "SELECT" {
t.Fatalf("results[0].sql_type = %v, want SELECT", env.Data.Results[0]["sql_type"])
}
if env.Data.Results[0]["record_count"] != float64(1) {
t.Fatalf("results[0].record_count = %v, want 1", env.Data.Results[0]["record_count"])
}
}
func TestAppsDBExecute_LegacyWireMultiSelect(t *testing.T) {
// BOE 实测SELECT 1; SELECT 2 → result: "[\"[{\\\"?column?\\\":1}]\",\"[{\\\"?column?\\\":2}]\"]"
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `["[{\"?column?\":1}]","[{\"?column?\":2}]"]`,
},
},
})
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "SELECT 1; SELECT 2;", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
// 多语句应有 Statement N: header
if !strings.Contains(got, "Statement 1: SELECT") || !strings.Contains(got, "Statement 2: SELECT") {
t.Errorf("missing Statement headers:\n%s", got)
}
// 末尾应有 ✓ N statements executed
if !strings.Contains(got, "✓ 2 statements executed") {
t.Errorf("missing summary line:\n%s", got)
}
}
func TestAppsDBExecute_LegacyWireDDLEmptyResult(t *testing.T) {
// BOE 实测CREATE TABLE → result: "" (空字符串,无 rows
// 老 wire 不区分 DDL/DML/无返回,统一标 "ok"
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": ``, // 空字符串
},
},
})
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "CREATE TABLE foo (id INT)", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
// result="" 触发 parseSQLResult 返 nil → renderSQLPretty 输出 "(empty result)"
if !strings.Contains(got, "(empty result)") {
t.Errorf("expected '(empty result)' for empty result string, got:\n%s", got)
}
}
func TestAppsDBExecute_LegacyWireMultiSelectWithRealTable(t *testing.T) {
// BOE 实测真实表抓包course 表第一行):复杂 JSON 含 CJK / timestamp / uuid 字段
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `["[{\"id\":\"abc-123\",\"title\":\"高效沟通\",\"capacity\":30}]"]`,
},
},
})
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "SELECT id,title,capacity FROM course LIMIT 1", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
// 验证 CJK / uuid / int 都能正确显示在表格里
for _, want := range []string{"id", "title", "capacity", "abc-123", "高效沟通", "30"} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in pretty output:\n%s", want, got)
}
}
}
// pretty 单 SELECT表格输出列间两空格无 Statement header。
func TestAppsDBExecute_PrettySingleSelectTable(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `[{"sql_type":"SELECT","data":"[{\"id\":101,\"total_cents\":2500},{\"id\":102,\"total_cents\":1800}]","record_count":2}]`,
},
},
})
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if strings.Contains(got, "Statement 1:") {
t.Errorf("single statement pretty should NOT have Statement header\noutput:\n%s", got)
}
// 列按字典序排序id / total_cents
if !strings.Contains(got, "id total_cents") {
t.Errorf("missing header row\noutput:\n%s", got)
}
if !strings.Contains(got, "101 2500") || !strings.Contains(got, "102 1800") {
t.Errorf("missing data rows\noutput:\n%s", got)
}
}
func TestAppsDBExecute_PrettyEmptySelect(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `[{"sql_type":"SELECT","data":"[]","record_count":0}]`,
},
},
})
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), "(0 rows)") {
t.Fatalf("empty SELECT should print (0 rows), got:\n%s", stdout.String())
}
}
func TestAppsDBExecute_PrettySingleDMLAndDDL(t *testing.T) {
cases := []struct {
name string
result string
wantStr string
}{
{"INSERT_1_row", `[{"sql_type":"INSERT","data":"","affected_rows":1}]`, "✓ 1 row inserted"},
{"UPDATE_5_rows", `[{"sql_type":"UPDATE","data":"","affected_rows":5}]`, "✓ 5 rows updated"},
{"DELETE_0_rows", `[{"sql_type":"DELETE","data":"","affected_rows":0}]`, "✓ 0 rows deleted"},
{"DDL", `[{"sql_type":"DDL","data":"","affected_rows":0}]`, "✓ DDL executed"},
// 真机 boe 实测DDL 的 sql_type 是细粒度动词CREATE_TABLE / DROP_TABLE / ALTER_TABLE...
// data 是 "[]"、无 affected_rows。必须识别为 DDL而不是落到 dmlSummary 渲染成 "0 rows affected"。
{"CREATE_TABLE", `[{"sql_type":"CREATE_TABLE","data":"[]"}]`, "✓ DDL executed"},
{"DROP_TABLE", `[{"sql_type":"DROP_TABLE","data":"[]"}]`, "✓ DDL executed"},
{"ALTER_TABLE", `[{"sql_type":"ALTER_TABLE","data":"[]"}]`, "✓ DDL executed"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"result": c.result}},
})
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), c.wantStr) {
t.Errorf("want %q\ngot:\n%s", c.wantStr, stdout.String())
}
})
}
}
func TestAppsDBExecute_PrettyMultiStatementsAllSuccess(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `[` +
`{"sql_type":"INSERT","data":"","affected_rows":1},` +
`{"sql_type":"UPDATE","data":"","affected_rows":1},` +
`{"sql_type":"SELECT","data":"[{\"id\":999}]","record_count":1}` +
`]`,
},
},
})
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, line := range []string{
"Statement 1: ✓ 1 row inserted",
"Statement 2: ✓ 1 row updated",
"Statement 3: SELECT (1 row)",
"✓ 3 statements executed",
} {
if !strings.Contains(got, line) {
t.Errorf("missing %q in pretty output\nfull:\n%s", line, got)
}
}
}
// TestAppsDBExecute_PrettyMultiStatementsDDL 钉住真机 boe 多语句 DDL 的 wire
// CREATE_TABLE / DROP_TABLEdata="[]"、无 affected_rows须渲染成 "✓ DDL executed"
// 不能落到 dmlSummary 变成 "0 rows affected"。
func TestAppsDBExecute_PrettyMultiStatementsDDL(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `[{"sql_type":"CREATE_TABLE","data":"[]"},{"sql_type":"DROP_TABLE","data":"[]"}]`,
},
},
})
if err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, line := range []string{
"Statement 1: ✓ DDL executed",
"Statement 2: ✓ DDL executed",
"✓ 2 statements executed",
} {
if !strings.Contains(got, line) {
t.Errorf("missing %q in pretty output\nfull:\n%s", line, got)
}
}
if strings.Contains(got, "rows affected") {
t.Errorf("DDL must not render as 'rows affected'\nfull:\n%s", got)
}
}
func TestAppsDBExecute_PrettyMultiStatementsPartialFailureWithErrorSentinel(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `[` +
`{"sql_type":"INSERT","data":"","affected_rows":1},` +
`{"sql_type":"ERROR","data":"{\"code\":1300015,\"message\":\"syntax error at or near 'SELEC'\"}"}` +
`]`,
},
},
})
// pretty 失败路径:逐条 ✓/✗ 摘要照打到 stdout人看同时返回 typed errorexit 非 0
err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"},
factory, stdout)
if err == nil {
t.Fatalf("pretty multi-statement failure must still return a typed error; stdout:\n%s", stdout.String())
}
got := stdout.String()
for _, line := range []string{
"Statement 1: ✓ 1 row inserted",
"Statement 2: ✗ syntax error at or near 'SELEC' [1300015]",
} {
if !strings.Contains(got, line) {
t.Errorf("missing %q in pretty output\nfull:\n%s", line, got)
}
}
// DBA 模式transactional=false前序语句已 auto-commit 落地绝不能误报「rolled back」。
if strings.Contains(got, "rolled back") {
t.Errorf("DBA mode must NOT claim rollback (prior statements persisted); got:\n%s", got)
}
if strings.Contains(got, "statements executed") {
t.Errorf("failed run should NOT print success summary; got:\n%s", got)
}
}
// TestAppsDBExecute_MultiStatementFailureReturnsTypedError 钉死「多语句失败 → typed api_error」
// json 默认不再打 ok:true 假成功,而是返回 *output.ExitErrortype=api_error、非零 exit
// detail 带 statement_index / completed / rolled_back。rolled_back=false 因 CLI 永远 DBA 模式
// (真机 boe 实证:失败前的语句已落地)。
func TestAppsDBExecute_MultiStatementFailureReturnsTypedError(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `[` +
`{"sql_type":"INSERT","data":"","affected_rows":1},` +
`{"sql_type":"ERROR","data":"{\"code\":\"k_dl_1300002\",\"message\":\"duplicate key value violates unique constraint\"}"}` +
`]`,
},
},
})
err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
factory, stdout)
if err == nil {
t.Fatalf("multi-statement failure must return a typed error; stdout:\n%s", stdout.String())
}
// json 失败路径不得打成功 envelope。
if strings.Contains(stdout.String(), `"ok": true`) {
t.Errorf("must not emit ok:true success envelope on failure; stdout:\n%s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("want *output.ExitError with detail, got %T: %v", err, err)
}
if exitErr.Detail.Type != "api_error" {
t.Errorf("error.type = %q, want api_error", exitErr.Detail.Type)
}
if exitErr.Detail.Code != 1300002 {
t.Errorf("error.code = %d, want 1300002", exitErr.Detail.Code)
}
if !strings.Contains(exitErr.Detail.Message, "(at statement 2 of 2)") {
t.Errorf("error.message missing statement locator: %q", exitErr.Detail.Message)
}
if output.ExitCodeOf(err) != output.ExitAPI {
t.Errorf("exit = %d, want %d (ExitAPI)", output.ExitCodeOf(err), output.ExitAPI)
}
detail, ok := exitErr.Detail.Detail.(map[string]interface{})
if !ok {
t.Fatalf("error.detail not a map: %T", exitErr.Detail.Detail)
}
if detail["statement_index"] != 1 {
t.Errorf("statement_index = %v, want 1", detail["statement_index"])
}
if detail["rolled_back"] != false {
t.Errorf("rolled_back = %v, want false (DBA mode persists prior statements)", detail["rolled_back"])
}
if completed, ok := detail["completed"].([]map[string]interface{}); !ok || len(completed) != 1 {
t.Errorf("completed = %v, want 1 persisted statement", detail["completed"])
}
}
// TestAppsDBExecute_SingleErrorReturnsTypedError 单条语句失败server 也返 code:0 + ERROR 哨兵)
// 同样升级成 typed errorstatement_index=0、completed 空、message 标注 (at statement 1 of 1)。
func TestAppsDBExecute_SingleErrorReturnsTypedError(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": `[{"sql_type":"ERROR","data":"{\"code\":\"k_dl_000002\",\"message\":\"syntax error at or near 'SELEC'\"}"}]`,
},
},
})
err := runAppsShortcut(t, AppsDBExecute,
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
factory, stdout)
if err == nil {
t.Fatalf("single ERROR sentinel must return a typed error; stdout:\n%s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("want *output.ExitError with detail, got %T: %v", err, err)
}
if !strings.Contains(exitErr.Detail.Message, "(at statement 1 of 1)") {
t.Errorf("error.message missing locator: %q", exitErr.Detail.Message)
}
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
if detail["statement_index"] != 0 {
t.Errorf("statement_index = %v, want 0", detail["statement_index"])
}
if completed, ok := detail["completed"].([]map[string]interface{}); !ok || len(completed) != 0 {
t.Errorf("completed = %v, want empty", detail["completed"])
}
}
func TestCellString_AllKinds(t *testing.T) {
cases := []struct {
name string
in interface{}
want string
}{
{"nil", nil, ""},
{"string", "hello", "hello"},
{"bool true", true, "true"},
{"bool false", false, "false"},
{"int float", float64(101), "101"},
{"fractional", float64(1.25), "1.25"},
{"object", map[string]interface{}{"a": float64(1)}, `{"a":1}`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := cellString(c.in); got != c.want {
t.Errorf("cellString(%v)=%q want %q", c.in, got, c.want)
}
})
}
}
func TestCodeString_Forms(t *testing.T) {
cases := []struct {
name string
in interface{}
want string
}{
{"nil", nil, ""},
{"k_dl prefix", "k_dl_1300015", "1300015"},
{"plain string", "1300015", "1300015"},
{"float64", float64(42), "42"},
{"unsupported", []int{1}, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := codeString(c.in); got != c.want {
t.Errorf("codeString(%v)=%q want %q", c.in, got, c.want)
}
})
}
}
func TestDmlVerb_AllVerbs(t *testing.T) {
cases := map[string]string{
"INSERT": "inserted",
"update": "updated",
"DELETE": "deleted",
"Merge": "merged",
"CREATE_TABLE": "affected",
}
for in, want := range cases {
if got := dmlVerb(in); got != want {
t.Errorf("dmlVerb(%q)=%q want %q", in, got, want)
}
}
}
func TestIntOrZero_Cases(t *testing.T) {
if got := intOrZero(float64(5)); got != 5 {
t.Errorf("intOrZero(5)=%d want 5", got)
}
if got := intOrZero("x"); got != 0 {
t.Errorf("intOrZero(non-numeric)=%d want 0", got)
}
if got := intOrZero(nil); got != 0 {
t.Errorf("intOrZero(nil)=%d want 0", got)
}
}
func TestErrorSummary_Cases(t *testing.T) {
cases := []struct {
name, in, want string
}{
{"empty", "", "(unknown error)"},
{"malformed json", "not json", "not json"},
{"with code", `{"code":"k_dl_1300015","message":"boom"}`, "boom [1300015]"},
{"no code", `{"message":"plain"}`, "plain"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := errorSummary(c.in); got != c.want {
t.Errorf("errorSummary(%q)=%q want %q", c.in, got, c.want)
}
})
}
}
func TestParseErrorSentinel_Cases(t *testing.T) {
cases := []struct {
name, in string
wantCode int
wantMsg string
}{
{"empty", "", 0, "(unknown error)"},
{"malformed", "xyz", 0, "xyz"},
{"code+msg", `{"code":"1300015","message":"boom"}`, 1300015, "boom"},
{"empty msg", `{"code":"1300015","message":""}`, 1300015, "(unknown error)"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
code, msg := parseErrorSentinel(c.in)
if code != c.wantCode || msg != c.wantMsg {
t.Errorf("parseErrorSentinel(%q)=%d,%q want %d,%q", c.in, code, msg, c.wantCode, c.wantMsg)
}
})
}
}
func TestIsStructuredResult_Cases(t *testing.T) {
if !isStructuredResult([]map[string]interface{}{{"sql_type": "SELECT"}}) {
t.Error("expected structured=true when sql_type present")
}
if isStructuredResult([]map[string]interface{}{{}}) {
t.Error("expected structured=false when sql_type absent")
}
if isStructuredResult(nil) {
t.Error("expected structured=false for empty")
}
}
func TestNormalizeLegacyStatement_Cases(t *testing.T) {
t.Run("empty -> OK", func(t *testing.T) {
got := normalizeLegacyStatement("")
if got["sql_type"] != "OK" {
t.Errorf("got sql_type=%v want OK", got["sql_type"])
}
})
t.Run("null -> OK", func(t *testing.T) {
got := normalizeLegacyStatement("null")
if got["sql_type"] != "OK" {
t.Errorf("got sql_type=%v want OK", got["sql_type"])
}
})
t.Run("rows -> SELECT", func(t *testing.T) {
got := normalizeLegacyStatement(`[{"id":1}]`)
if got["sql_type"] != "SELECT" {
t.Errorf("got sql_type=%v want SELECT", got["sql_type"])
}
if got["record_count"] != float64(1) {
t.Errorf("got record_count=%v want 1", got["record_count"])
}
})
t.Run("non-json kept as OK", func(t *testing.T) {
got := normalizeLegacyStatement(`notjson`)
if got["sql_type"] != "OK" {
t.Errorf("got sql_type=%v want OK", got["sql_type"])
}
})
}
func TestCellString_MarshalFallback(t *testing.T) {
// complex128 is not switch-handled and json.Marshal rejects it →
// falls back to fmt.Sprintf("%v", v), which is deterministic for complex.
if got := cellString(complex(1, 2)); got != "(1+2i)" {
t.Errorf("cellString(complex)=%q want (1+2i)", got)
}
}
func TestRenderSingleStatementPretty_Branches(t *testing.T) {
cases := []struct {
name string
stmt map[string]interface{}
substr string
}{
{"select empty", map[string]interface{}{"sql_type": "SELECT", "data": "[]"}, "(0 rows)"},
{"error", map[string]interface{}{"sql_type": "ERROR", "data": `{"message":"boom"}`}, "✗ boom"},
{"dml insert", map[string]interface{}{"sql_type": "INSERT", "affected_rows": float64(3)}, "✓ 3 rows inserted"},
{"legacy ok", map[string]interface{}{"sql_type": "OK"}, "✓ ok"},
{"ddl default", map[string]interface{}{"sql_type": "CREATE_TABLE"}, "✓ DDL executed"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var b strings.Builder
renderSingleStatementPretty(&b, c.stmt)
if !strings.Contains(b.String(), c.substr) {
t.Errorf("output %q does not contain %q", b.String(), c.substr)
}
})
}
}
func TestRenderSelectRowsAsTable_Branches(t *testing.T) {
cases := []struct {
name string
data string
substr string
}{
{"empty string", "", "(0 rows)"},
{"empty array", "[]", "(0 rows)"},
{"malformed fallback", "{bad", "{bad"},
{"rows", `[{"id":1}]`, "id"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var b strings.Builder
renderSelectRowsAsTable(&b, c.data)
if !strings.Contains(b.String(), c.substr) {
t.Errorf("output %q does not contain %q", b.String(), c.substr)
}
})
}
}

View File

@@ -0,0 +1,87 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const dbTableGetHint = "verify --app-id and --table are correct; list tables with `lark-cli apps +db-table-list --app-id <app_id>`; if targeting --env dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --env dev`"
// AppsDBTableGet gets one table's structure (动词对齐 +db-table-list)。
//
// GET /apps/{app_id}/tables/{table_name}。
//
// `--format` 同时驱动 CLI 渲染和 server 请求形态:
// - `--format json`(默认)/ table / ndjson / csvCLI 不传 format queryresponse 含结构化
// columns / indexes / constraints / statsenvelope 化输出。
// - `--format pretty`CLI 给 server 带 ?format=ddlresponse 含 ddl 字符串stdout 直接打
// ddl 内容(无 envelope / 无表格包装)。
var AppsDBTableGet = common.Shortcut{
Service: appsService,
Command: "+db-table-get",
Description: "Get a table's structure: columns, indexes and constraints",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +db-table-get --app-id <app_id> --table <table>",
"Tip: filter fields with --jq (json format), e.g. -q '.data.columns[].name'",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "table name", Required: true},
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if strings.TrimSpace(rctx.Str("table")) == "" {
return output.ErrValidation("--table is required")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(appTablePath(appID, strings.TrimSpace(rctx.Str("table")))).
Desc("Get Miaoda app db table schema").
Params(buildDBTableGetParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
path := appTablePath(appID, strings.TrimSpace(rctx.Str("table")))
data, err := rctx.CallAPITyped("GET", path, buildDBTableGetParams(rctx), nil)
if err != nil {
return withAppsHint(err, dbTableGetHint)
}
rctx.OutFormat(data, nil, func(w io.Writer) {
// pretty 模式stdout 直接打 ddl 文本(无 trailing newline由 server 返回的字符串决定)。
io.WriteString(w, common.GetString(data, "ddl"))
})
return nil
},
}
// buildDBTableGetParams 构造 schema 接口的 query。
//
// CLI 检测 rctx.Format == "pretty" 时给 server 带 format=ddl要求返 CREATE 语句文本;
// 其他 format含默认 json不传该参数让 server 返默认结构化字段。
func buildDBTableGetParams(rctx *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{"env": rctx.Str("env")}
if rctx.Format == "pretty" {
params["format"] = "ddl"
}
return params
}

View File

@@ -0,0 +1,131 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAppsDBTableGet_DefaultJSONReturnsStructuredFields(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/tables/orders",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"name": "orders",
"description": "订单表",
"columns": []interface{}{
map[string]interface{}{
"name": "id", "data_type": "int8",
"is_primary_key": true, "is_unique": true,
"is_allow_null": false, "default_value": "",
},
},
"indexes": []interface{}{
map[string]interface{}{"name": "orders_pkey", "type": "btree", "columns": []interface{}{"id"}, "definition": "..."},
},
"constraints": []interface{}{
map[string]interface{}{"type": "primary_key", "name": "orders_pkey", "columns": []interface{}{"id"}},
},
"estimated_row_count": 1200,
"size_bytes": 81920,
},
},
})
if err := runAppsShortcut(t, AppsDBTableGet,
[]string{"+db-table-get", "--app-id", "app_x", "--table", "orders", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"name": "orders"`) {
t.Fatalf("stdout missing schema name: %s", got)
}
}
// --format pretty 是触发 DDL 模式的唯一开关。
// 用 --format json + --dry-run 走 JSON envelope 路径方便 parse但 query 形态由代码内部
// 根据 rctx.Format 决定 —— 这里我们直接传 --format pretty + --dry-runpretty 模式下 dry-run
// 输出是 plain text 列表,用 substring 校验 format=ddl 出现在 URL query 中。
func TestAppsDBTableGet_PrettyFormatSendsFormatDDLQuery(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBTableGet,
[]string{"+db-table-get", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/tables/orders") {
t.Fatalf("missing URL in dry-run output:\n%s", got)
}
if !strings.Contains(got, "format=ddl") {
t.Fatalf("--format=pretty should trigger ?format=ddl, got:\n%s", got)
}
}
func TestAppsDBTableGet_NonPrettyFormatsOmitFormatQuery(t *testing.T) {
// 默认 json / table / ndjson / csv 都走 schema 路径 —— CLI 不传 format query。
for _, format := range []string{"json", "table", "ndjson", "csv"} {
t.Run(format, func(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
args := []string{"+db-table-get", "--app-id", "app_x", "--table", "orders", "--format", format, "--dry-run", "--as", "user"}
if err := runAppsShortcut(t, AppsDBTableGet, args, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
t.Fatalf("decode: %v", err)
}
if _, ok := env.API[0].Params["format"]; ok {
t.Fatalf("--format=%s should omit format query, got %v", format, env.API[0].Params)
}
})
}
}
func TestAppsDBTableGet_PrettyOutputIsDDLTextOnly(t *testing.T) {
// pretty 模式 stdout 直接打 ddl 字段文本,无 envelope / 表格包装。
factory, stdout, reg := newAppsExecuteFactory(t)
ddl := "CREATE TABLE orders (\n id bigint NOT NULL,\n PRIMARY KEY (id)\n);"
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/tables/orders",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"ddl": ddl},
},
})
if err := runAppsShortcut(t, AppsDBTableGet,
[]string{"+db-table-get", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "CREATE TABLE orders") {
t.Fatalf("pretty output should contain raw DDL, got:\n%s", got)
}
if strings.Contains(got, `"data":`) || strings.Contains(got, `"ddl":`) {
t.Fatalf("pretty output should not be JSON envelope, got:\n%s", got)
}
}
func TestAppsDBTableGet_RequiresTable(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBTableGet,
[]string{"+db-table-get", "--app-id", "app_x", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "table") {
t.Fatalf("expected table required error, got %v", err)
}
}

View File

@@ -0,0 +1,301 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
const dbTableListHint = "verify --app-id is correct; if targeting --env dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --env dev`"
// AppsDBTableList lists tables in a Miaoda app's database.
//
// GET /apps/{app_id}/tablescursor 分页response items[] 含 estimated_row_count /
// size_bytes optional 字段,默认返回,不必额外传 query。
//
// 输出裁剪server 给每张表回完整 columns[](与 +db-table-get 同源、内容一致。CLI 用白名单
// 投影dbTableListItem只组装产品要求字段、把 columns[] 折算成 column_count避免逐表重复列定义
// 放大 token、并与 +db-table-get 职责区分。完整列定义 / 索引 / 约束 / DDL 用 +db-table-get。
//
// pretty 渲染 5 列name / description / estimated_row_count / size / columns即 column_count
// 列间两空格、列对齐填充、空 description 用 "—" 占位、size 按 KB/MB/GB 友好格式化。
var AppsDBTableList = common.Shortcut{
Service: appsService,
Command: "+db-table-list",
Description: "List tables in a Miaoda app database (cursor pagination)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +db-table-list --app-id <app_id>",
"Tip: filter fields with --jq, e.g. -q '.data.items[].name'",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
_, err := requireAppID(rctx.Str("app-id"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(appTablesPath(appID)).
Desc("List Miaoda app db tables").
Params(buildDBTableListParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
data, err := rctx.CallAPITyped("GET", appTablesPath(appID), buildDBTableListParams(rctx), nil)
if err != nil {
return withAppsHint(err, dbTableListHint)
}
// 白名单投影:只把产品要求的字段组装进 dbTableListItem替换 server 原始 items[]。
// server 给每张表回完整 columns[](与 +db-table-get 同源、逐字节一致),在 list 里逐表
// 重复既放大 token 又与 schema 职责重叠。这里用白名单而非 delete 黑名单 —— server 后续新增
// 字段不会自动泄漏进 CLI 输出。需要完整列定义 / 索引 / 约束 / DDL 用 +db-table-get。
items := projectTableListItems(data["items"])
data["items"] = items
rctx.OutFormat(data, nil, func(w io.Writer) {
renderTableListPretty(w, items)
})
return nil
},
}
// dbTableListItem 是 +db-table-list 对外输出的「产品要求字段」白名单。
// 改字段在此处增删即可,无需在 Execute 里逐个 delete server 返回的多余字段。
type dbTableListItem struct {
Name string `json:"name"`
Description string `json:"description"`
EstimatedRowCount interface{} `json:"estimated_row_count,omitempty"`
SizeBytes interface{} `json:"size_bytes,omitempty"`
ColumnCount int `json:"column_count"`
}
// projectTableListItems 把 server 原始 items[]map投影成白名单 dbTableListItem 切片。
// column_count 由 server 返回的 columns[] 长度派生(随后 columns[] 不再透出)。
func projectTableListItems(raw interface{}) []dbTableListItem {
arr, _ := raw.([]interface{})
out := make([]dbTableListItem, 0, len(arr))
for _, it := range arr {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
out = append(out, dbTableListItem{
Name: common.GetString(m, "name"),
Description: common.GetString(m, "description"),
EstimatedRowCount: m["estimated_row_count"],
SizeBytes: m["size_bytes"],
ColumnCount: deriveColumnCount(m),
})
}
return out
}
func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{
"env": rctx.Str("env"),
"page_size": rctx.Int("page-size"),
}
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
params["page_token"] = token
}
return params
}
// renderTableListPretty 5 列输出,列间两空格、列对齐填充。
//
// 列名name / description / estimated_row_count / size / columns。
// 空 description 用 "—" 占位size 由 size_bytes 经 humanBytes 友好格式化;
// columns 列取白名单投影的 column_count。
func renderTableListPretty(w io.Writer, items []dbTableListItem) {
headers := []string{"name", "description", "estimated_row_count", "size", "columns"}
rows := make([][]string, 0, len(items))
for _, item := range items {
desc := item.Description
if desc == "" {
desc = "—"
}
rows = append(rows, []string{
item.Name,
desc,
intString(item.EstimatedRowCount),
humanBytes(item.SizeBytes),
fmt.Sprintf("%d", item.ColumnCount),
})
}
renderAlignedTable(w, headers, rows)
}
// renderAlignedTable 输出列对齐表格:列间两空格、列宽按每列最长 cell 填充、
// 不画 `|` 和 `-` 分隔线、不依赖 TTY 着色。
func renderAlignedTable(w io.Writer, headers []string, rows [][]string) {
if len(headers) == 0 {
return
}
widths := make([]int, len(headers))
for i, h := range headers {
widths[i] = displayWidth(h)
}
for _, row := range rows {
for i, cell := range row {
if i >= len(widths) {
break
}
if dw := displayWidth(cell); dw > widths[i] {
widths[i] = dw
}
}
}
writeRow := func(cells []string) {
for i, cell := range cells {
if i >= len(widths) {
continue
}
if i > 0 {
io.WriteString(w, " ")
}
io.WriteString(w, cell)
if i < len(widths)-1 {
pad := widths[i] - displayWidth(cell)
if pad > 0 {
io.WriteString(w, strings.Repeat(" ", pad))
}
}
}
io.WriteString(w, "\n")
}
writeRow(headers)
for _, r := range rows {
writeRow(r)
}
}
// displayWidth 估算字符串在 monospace 终端下的显示宽度。
// ASCII 占 1 列CJK / 全角字符占 2 列;其他多字节字符按 rune 数算(保守)。
func displayWidth(s string) int {
w := 0
for _, r := range s {
switch {
case r < 0x80:
w++
case isWide(r):
w += 2
default:
w++
}
}
return w
}
func isWide(r rune) bool {
switch {
case r >= 0x1100 && r <= 0x115F: // Hangul Jamo
case r >= 0x2E80 && r <= 0x303E: // CJK Radicals / Kangxi
case r >= 0x3041 && r <= 0x33FF: // Hiragana / Katakana / Bopomofo / CJK Symbols
case r >= 0x3400 && r <= 0x4DBF: // CJK Extension A
case r >= 0x4E00 && r <= 0x9FFF: // CJK Unified Ideographs
case r >= 0xA000 && r <= 0xA4CF: // Yi
case r >= 0xAC00 && r <= 0xD7A3: // Hangul Syllables
case r >= 0xF900 && r <= 0xFAFF: // CJK Compatibility Ideographs
case r >= 0xFE30 && r <= 0xFE4F: // CJK Compatibility Forms
case r >= 0xFF00 && r <= 0xFF60: // Fullwidth Forms
case r >= 0xFFE0 && r <= 0xFFE6: // Fullwidth Signs
case r >= 0x20000 && r <= 0x2FFFD: // CJK Extension B-F
case r >= 0x30000 && r <= 0x3FFFD: // CJK Extension G
default:
return false
}
return true
}
// humanBytes 把 size_bytes 数值转 KB / MB / GB 友好字符串。
// 1 KiB = 1024 B与 PG / 操作系统约定一致。
func humanBytes(raw interface{}) string {
n, ok := numericAsFloat(raw)
if !ok {
return "—"
}
const unit = 1024.0
switch {
case n < unit:
return fmt.Sprintf("%d B", int64(n))
case n < unit*unit:
return fmt.Sprintf("%.0f KB", n/unit)
case n < unit*unit*unit:
return formatFloat(n/(unit*unit)) + " MB"
default:
return formatFloat(n/(unit*unit*unit)) + " GB"
}
}
// formatFloat 一位小数整数值省略小数24 KB 而不是 24.0 KB1.5 MB 而不是 1 MB
func formatFloat(f float64) string {
if f == float64(int64(f)) {
return fmt.Sprintf("%d", int64(f))
}
return fmt.Sprintf("%.1f", f)
}
// intString 把 JSON 反序列化进来的 number 转为整数字符串显示estimated_row_count
func intString(raw interface{}) string {
if n, ok := numericAsFloat(raw); ok {
return fmt.Sprintf("%d", int64(n))
}
return "—"
}
func numericAsFloat(raw interface{}) (float64, bool) {
switch v := raw.(type) {
case float64:
return v, true
case float32:
return float64(v), true
case int:
return float64(v), true
case int32:
return float64(v), true
case int64:
return float64(v), true
case uint:
return float64(v), true
case uint32:
return float64(v), true
case uint64:
return float64(v), true
case json.Number:
f, err := v.Float64()
if err != nil {
return 0, false
}
return f, true
case nil:
return 0, false
}
return 0, false
}
// deriveColumnCount 从 items[i].columns 数组长度派生 column_count。
func deriveColumnCount(m map[string]interface{}) int {
cols, ok := m["columns"].([]interface{})
if !ok {
return 0
}
return len(cols)
}

View File

@@ -0,0 +1,309 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
// TestAppsDBTableList_BusinessErrorSurfacedAsTypedEnvelope 验证 server 业务错误
// code != 0如单环境 app 查 env=dev 返 "Invalid DB Branch")被 CLI 透出成
// typed error —— 用 BOE 实测的错误码 / 文案做输入。
//
// 迁移到 runtime.CallAPITyped 后,非零 code 的业务错误由 errclass.BuildAPIError
// 归类为 typed errs.* errorwire type 为 "api" 类别,不再是 legacy 的
// *output.ExitError / "api_error"),但仍保留 code 与 message。与 drive/okr 等
// 已迁移域一致:用 errs.ProblemOf 读 typed envelope断言不弱化。
func TestAppsDBTableList_BusinessErrorSurfacedAsTypedEnvelope(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/tables",
Body: map[string]interface{}{
"code": 500002511,
"msg": "k_dl_1600000Invalid DB Branchdev",
},
})
err := runAppsShortcut(t, AppsDBTableList,
[]string{"+db-table-list", "--app-id", "app_x", "--env", "dev", "--as", "user"},
factory, stdout)
if err == nil {
t.Fatalf("expected business error to surface, got nil; stdout=%s", stdout.String())
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.Problem, got %T: %v", err, err)
}
if p.Category != errs.CategoryAPI {
t.Fatalf("error.type = %q, want %q", p.Category, errs.CategoryAPI)
}
if p.Code != 500002511 {
t.Fatalf("error.code = %d, want 500002511", p.Code)
}
if !strings.Contains(p.Message, "Invalid DB Branch") {
t.Fatalf("error.message missing 'Invalid DB Branch': %q", p.Message)
}
}
func TestAppsDBTableList_SuccessReturnsItemsWithStats(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/tables",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{
map[string]interface{}{
"name": "orders",
"description": "订单表",
"columns": []interface{}{map[string]interface{}{"name": "id"}, map[string]interface{}{"name": "user_id"}},
"estimated_row_count": 1200,
"size_bytes": 81920,
},
},
},
},
})
if err := runAppsShortcut(t, AppsDBTableList,
[]string{"+db-table-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"name": "orders"`) {
t.Fatalf("stdout missing table name: %s", got)
}
if !strings.Contains(got, `"estimated_row_count": 1200`) {
t.Fatalf("stdout missing estimated_row_count: %s", got)
}
// CLI 裁剪json 默认不透出每表 columns[],折算成 column_countmock 给了 2 列)。
if !strings.Contains(got, `"column_count": 2`) {
t.Fatalf("stdout missing column_count (should replace columns[]): %s", got)
}
if strings.Contains(got, `"columns"`) {
t.Fatalf("stdout should NOT contain raw columns[] (stripped to column_count): %s", got)
}
}
// pretty 5 列 + 列名 (size / columns不是 size_bytes / column_count) + size 友好格式KB +
// 空 description 用 "—" 占位。
func TestAppsDBTableList_PrettyRendersFiveColumnsHumanReadable(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/tables",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"name": "orders",
"description": "Order entries",
"columns": []interface{}{map[string]interface{}{"name": "id"}, map[string]interface{}{"name": "user_id"}},
"estimated_row_count": 1200,
"size_bytes": 81920, // 80 KB
},
map[string]interface{}{
"name": "customers",
"description": "",
"columns": []interface{}{map[string]interface{}{"name": "id"}},
"estimated_row_count": 350,
"size_bytes": 24576, // 24 KB
},
},
},
},
})
if err := runAppsShortcut(t, AppsDBTableList,
[]string{"+db-table-list", "--app-id", "app_x", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
// Header 行 5 列命名。
wantHeader := "name description estimated_row_count size columns"
// rows
wantOrders := "orders Order entries 1200 80 KB 2"
wantCustomers := "customers — 350 24 KB 1"
for _, want := range []string{wantHeader, wantOrders, wantCustomers} {
if !strings.Contains(got, want) {
t.Errorf("missing line %q\nactual output:\n%s", want, got)
}
}
// 禁止出现旧列名 / 原始字节。
for _, banned := range []string{"size_bytes", "column_count", "81920", "24576"} {
if strings.Contains(got, banned) {
t.Errorf("pretty output contains %q (must be human-formatted)\noutput:\n%s", banned, got)
}
}
}
func TestAppsDBTableList_RequiresAppID(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBTableList,
[]string{"+db-table-list", "--app-id", " ", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "app-id") {
t.Fatalf("expected app-id required error, got %v", err)
}
}
func TestAppsDBTableList_DryRunSendsPaginationAndEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBTableList,
[]string{"+db-table-list", "--app-id", "app_x", "--env", "dev",
"--page-size", "50", "--page-token", "cursor-abc",
"--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
if env.API[0].Method != "GET" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/tables" {
t.Fatalf("dry-run method/url = %s %s", env.API[0].Method, env.API[0].URL)
}
if env.API[0].Params["env"] != "dev" {
t.Fatalf("dry-run params.env = %v (want dev)", env.API[0].Params["env"])
}
if pz, _ := env.API[0].Params["page_size"].(float64); int(pz) != 50 {
t.Fatalf("dry-run params.page_size = %v (want 50)", env.API[0].Params["page_size"])
}
if env.API[0].Params["page_token"] != "cursor-abc" {
t.Fatalf("dry-run params.page_token = %v (want cursor-abc)", env.API[0].Params["page_token"])
}
}
func TestAppsDBTableList_DoesNotSendIncludeStatsQuery(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBTableList,
[]string{"+db-table-list", "--app-id", "app_x", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
t.Fatalf("decode: %v", err)
}
if _, ok := env.API[0].Params["include_stats"]; ok {
t.Fatalf("CLI should not send include_stats query, but got params=%v", env.API[0].Params)
}
}
func TestAppsDBTableList_RejectsBadEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBTableList,
[]string{"+db-table-list", "--app-id", "app_x", "--env", "prod", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "env") {
t.Fatalf("expected env enum rejection, got %v", err)
}
}
func TestNumericAsFloat_AllTypes(t *testing.T) {
cases := []struct {
name string
in interface{}
want float64
ok bool
}{
{"float64", float64(3.5), 3.5, true},
{"float32", float32(2), 2, true},
{"int", int(7), 7, true},
{"int32", int32(8), 8, true},
{"int64", int64(9), 9, true},
{"uint", uint(10), 10, true},
{"uint32", uint32(11), 11, true},
{"uint64", uint64(12), 12, true},
{"json.Number valid", json.Number("13.5"), 13.5, true},
{"json.Number invalid", json.Number("abc"), 0, false},
{"nil", nil, 0, false},
{"unsupported string", "x", 0, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, ok := numericAsFloat(c.in)
if ok != c.ok || got != c.want {
t.Fatalf("numericAsFloat(%v) = %v,%v want %v,%v", c.in, got, ok, c.want, c.ok)
}
})
}
}
func TestFormatFloat_IntegerVsFractional(t *testing.T) {
cases := []struct {
in float64
want string
}{
{24, "24"},
{1.5, "1.5"},
{2.04, "2.0"},
{0, "0"},
}
for _, c := range cases {
if got := formatFloat(c.in); got != c.want {
t.Errorf("formatFloat(%v)=%q want %q", c.in, got, c.want)
}
}
}
func TestHumanBytes_UnitBoundaries(t *testing.T) {
cases := []struct {
name string
in interface{}
want string
}{
{"non-numeric", "x", "—"},
{"bytes", float64(512), "512 B"},
{"kb", float64(2048), "2 KB"},
{"mb fractional", float64(1572864), "1.5 MB"},
{"gb integer", float64(2 * 1024 * 1024 * 1024), "2 GB"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := humanBytes(c.in); got != c.want {
t.Errorf("humanBytes(%v)=%q want %q", c.in, got, c.want)
}
})
}
}
func TestIntString_Cases(t *testing.T) {
if got := intString(float64(42)); got != "42" {
t.Errorf("intString(42)=%q want 42", got)
}
if got := intString("x"); got != "—" {
t.Errorf("intString(non-numeric)=%q want —", got)
}
}
func TestDeriveColumnCount_Cases(t *testing.T) {
if got := deriveColumnCount(map[string]interface{}{"columns": []interface{}{1, 2, 3}}); got != 3 {
t.Errorf("deriveColumnCount=%d want 3", got)
}
if got := deriveColumnCount(map[string]interface{}{}); got != 0 {
t.Errorf("deriveColumnCount(missing)=%d want 0", got)
}
if got := deriveColumnCount(map[string]interface{}{"columns": "notarray"}); got != 0 {
t.Errorf("deriveColumnCount(wrongtype)=%d want 0", got)
}
}

View File

@@ -0,0 +1,380 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// envKeyPattern matches valid environment variable names: [A-Za-z_][A-Za-z0-9_]*
var envKeyPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
type envPullDatabaseInfo struct {
Detected bool
ExpiresAtRaw string
ExpiresAtText string
}
// AppsEnvPull pulls startup env vars for an app into the local .env.local file.
var AppsEnvPull = common.Shortcut{
Service: appsService,
Command: "+env-pull",
Description: "Pull app startup env vars into the local project .env.local",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +env-pull --app-id <app_id>",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID"},
{Name: "project-path", Desc: "local project root path (defaults to current directory)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: "--app-id is required"}, Param: "app-id"}
}
_, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
if err != nil {
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("--project-path: %v", err)}, Param: "project-path", Cause: err}
}
if err := checkEnvPullTarget(envFile); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
projectPath, envFile, _ := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))).
Desc("Pull app startup env vars into the local .env.local file").
Set("project_path", projectPath).
Set("env_file", envFile)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
_, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
if err != nil {
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("--project-path: %v", err)}, Param: "project-path", Cause: err}
}
if err := checkEnvPullTarget(envFile); err != nil {
return err
}
if err := rctx.EnsureScopes([]string{"spark:app:read"}); err != nil {
return err
}
path := fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))
data, err := rctx.CallAPITyped("POST", path, nil, nil)
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`")
}
envVars, databaseInfo, skippedKeys, err := extractEnvPullVars(data)
if err != nil {
return err
}
if envVars == nil {
envVars = map[string]string{}
}
envVars["FORCE_DB_BRANCH"] = "dev"
original, err := readEnvPullFile(envFile)
if err != nil {
return err
}
merged, updated, created := mergeEnvPullFileContent(original, envVars)
if err := ensureEnvPullParentDir(envFile); err != nil {
return err
}
if err := validate.AtomicWrite(envFile, []byte(merged), 0o600); err != nil {
return &errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown, Message: fmt.Sprintf("cannot write %s: %v", envFile, err)}, Cause: err}
}
result := buildEnvPullSuccessData(appID, envFile, databaseInfo)
rctx.OutFormat(result, nil, func(w io.Writer) {
writeEnvPullPretty(w, appID, envFile, databaseInfo, skippedKeys)
})
_ = updated
_ = created
return nil
},
}
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.
if err != nil {
return "", "", fmt.Errorf("cannot determine working directory: %w", err)
}
projectPath = cwd
}
if err := validate.RejectControlChars(projectPath, "--project-path"); err != nil {
return "", "", err
}
projectPath = filepath.Clean(projectPath)
return projectPath, filepath.Join(projectPath, ".env.local"), nil
}
func checkEnvPullTarget(envFile string) error {
info, err := os.Lstat(envFile) //nolint:forbidigo // shortcuts cannot import internal/vfs; direct lstat is needed to reject symlinks before write.
if err != nil {
if os.IsNotExist(err) {
return nil
}
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("cannot inspect %s: %v", envFile, err)}, Param: "project-path", Cause: err}
}
if info.Mode()&os.ModeSymlink != 0 {
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("target %s must be a regular file, not a symlink", envFile)}, Param: "project-path"}
}
if !info.Mode().IsRegular() {
return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("target %s must be a regular file", envFile)}, Param: "project-path"}
}
return nil
}
func extractEnvPullVars(data map[string]interface{}) (map[string]string, envPullDatabaseInfo, []string, error) {
raw := data["env_vars"]
if raw == nil {
if nested, ok := data["data"].(map[string]interface{}); ok {
raw = nested["env_vars"]
}
}
if raw == nil {
return nil, envPullDatabaseInfo{}, nil, &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidResponse, Message: "response field env_vars must be an object or array of key/value entries"}}
}
var skippedKeys []string
switch typed := raw.(type) {
case map[string]interface{}:
out := make(map[string]string, len(typed))
for key, value := range typed {
if !envKeyPattern.MatchString(key) {
skippedKeys = append(skippedKeys, key)
continue
}
s, ok := value.(string)
if !ok {
continue
}
out[key] = s
}
return out, envPullDatabaseInfo{Detected: hasEnvPullDatabase(out)}, skippedKeys, nil
case []interface{}:
out := make(map[string]string, len(typed))
info := envPullDatabaseInfo{}
for _, item := range typed {
entry, ok := item.(map[string]interface{})
if !ok {
continue
}
key, ok := entry["key"].(string)
if !ok || strings.TrimSpace(key) == "" {
continue
}
if !envKeyPattern.MatchString(key) {
skippedKeys = append(skippedKeys, key)
continue
}
value, ok := entry["value"].(string)
if !ok {
continue
}
out[key] = value
if key == "SUDA_DATABASE_URL" {
info.Detected = true
info.ExpiresAtRaw, info.ExpiresAtText = extractEnvPullDatabaseExpiry(entry["extras"])
}
}
return out, info, skippedKeys, nil
default:
return nil, envPullDatabaseInfo{}, nil, &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidResponse, Message: "response field env_vars must be an object or array of key/value entries"}}
}
}
func readEnvPullFile(envFile string) (string, error) {
data, err := os.ReadFile(envFile) //nolint:forbidigo // shortcuts cannot import internal/vfs; validated local file read for a single env file.
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", &errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown, Message: fmt.Sprintf("cannot read %s: %v", envFile, err)}, Cause: err}
}
return string(data), nil
}
func ensureEnvPullParentDir(envFile string) error {
dir := filepath.Dir(envFile)
if err := os.MkdirAll(dir, 0o755); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; local mkdir for target env parent dir.
return &errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown, Message: fmt.Sprintf("cannot create %s: %v", dir, err)}, Cause: err}
}
return nil
}
func mergeEnvPullFileContent(original string, envVars map[string]string) (string, []string, []string) {
if len(envVars) == 0 {
if original == "" {
return "", nil, nil
}
return ensureTrailingNewline(original), nil, nil
}
normalized := strings.ReplaceAll(original, "\r\n", "\n")
lines := []string{}
if normalized != "" {
lines = strings.Split(normalized, "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
}
used := make(map[string]bool, len(envVars))
updated := make([]string, 0, len(envVars))
for i, line := range lines {
key, ok := parseEnvPullAssignmentLine(line)
if !ok {
continue
}
value, exists := envVars[key]
if !exists {
continue
}
lines[i] = formatEnvPullAssignment(key, value)
updated = append(updated, key)
used[key] = true
}
created := make([]string, 0, len(envVars))
pending := make([]string, 0, len(envVars))
for key := range envVars {
if used[key] {
continue
}
pending = append(pending, key)
}
sort.Strings(pending)
for _, key := range pending {
lines = append(lines, formatEnvPullAssignment(key, envVars[key]))
created = append(created, key)
}
sort.Strings(updated)
content := strings.Join(lines, "\n")
if content != "" {
content += "\n"
}
return content, updated, created
}
func parseEnvPullAssignmentLine(line string) (string, bool) {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
return "", false
}
if strings.HasPrefix(trimmed, "export ") || strings.HasPrefix(trimmed, "export\t") {
remainder := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(trimmed, "export "), "export\t"))
if remainder == "" || strings.HasPrefix(remainder, "=") {
return "", false
}
trimmed = remainder
}
idx := strings.Index(trimmed, "=")
if idx <= 0 {
return "", false
}
key := strings.TrimSpace(trimmed[:idx])
if key == "" || strings.ContainsAny(key, " \t") {
return "", false
}
return key, true
}
func formatEnvPullAssignment(key, value string) string {
return fmt.Sprintf("%s=%s", key, strconv.Quote(value))
}
func buildEnvPullSuccessData(appID, envFile string, databaseInfo envPullDatabaseInfo) map[string]interface{} {
result := map[string]interface{}{
"app_id": appID,
"env_file": envFile,
}
if databaseInfo.ExpiresAtRaw != "" {
result["database_url_expires_at"] = databaseInfo.ExpiresAtRaw
}
return result
}
func hasEnvPullDatabase(envVars map[string]string) bool {
_, ok := envVars["SUDA_DATABASE_URL"]
return ok
}
func extractEnvPullDatabaseExpiry(rawExtras interface{}) (string, string) {
extras, ok := rawExtras.([]interface{})
if !ok {
return "", ""
}
for _, raw := range extras {
entry, ok := raw.(map[string]interface{})
if !ok {
continue
}
key, _ := entry["key"].(string)
if key != "expiresAt" {
continue
}
switch value := entry["value"].(type) {
case string:
rawValue := strings.TrimSpace(value)
ts, err := strconv.ParseInt(rawValue, 10, 64)
if err != nil {
return "", ""
}
return rawValue, time.Unix(ts, 0).Local().Format("2006-01-02 15:04:05 MST")
case float64:
ts := int64(value)
rawValue := strconv.FormatInt(ts, 10)
return rawValue, time.Unix(ts, 0).Local().Format("2006-01-02 15:04:05 MST")
}
}
return "", ""
}
func writeEnvPullPretty(w io.Writer, appID, envFile string, databaseInfo envPullDatabaseInfo, skippedKeys []string) {
fmt.Fprintf(w, "✓ App detected: %s\n", appID)
if databaseInfo.Detected {
fmt.Fprintln(w, "✓ Development database detected")
}
fmt.Fprintf(w, "✓ Local environment written to %s\n", envFile)
if databaseInfo.ExpiresAtText != "" {
fmt.Fprintln(w)
fmt.Fprintf(w, "DATABASE_URL is valid until %s.\n", databaseInfo.ExpiresAtText)
}
if len(skippedKeys) > 0 {
fmt.Fprintln(w)
fmt.Fprintf(w, "⚠ Skipped %d invalid key(s): %s (key names must match [A-Za-z_][A-Za-z0-9_]*)\n", len(skippedKeys), strings.Join(skippedKeys, ", "))
}
fmt.Fprintf(w, "Run `lark-cli apps +env-pull --app-id <app_id>` again to refresh it.\n")
}
func ensureTrailingNewline(s string) string {
if s == "" || strings.HasSuffix(s, "\n") {
return s
}
return s + "\n"
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"regexp"
"strings"
"testing"
)
func TestAppsShortcutsHaveExamples(t *testing.T) {
realAppID := regexp.MustCompile(`app_[a-z0-9]{6,}`)
email := regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`)
phone := regexp.MustCompile(`\b1[3-9]\d{9}\b`)
for _, s := range Shortcuts() {
hasExample := false
for _, tip := range s.Tips {
if strings.HasPrefix(tip, "Example: lark-cli apps +") {
hasExample = true
}
if realAppID.MatchString(tip) {
t.Errorf("%s tip leaks real-looking app id (use <app_id>): %q", s.Command, tip)
}
if email.MatchString(tip) || phone.MatchString(tip) {
t.Errorf("%s tip leaks PII: %q", s.Command, tip)
}
}
if !hasExample {
t.Errorf("%s has no \"Example: lark-cli apps +...\" tip", s.Command)
}
}
}
func TestHighFreqCommandsHaveMultipleExamples(t *testing.T) {
want := map[string]int{"+chat": 2, "+access-scope-set": 2}
for _, s := range Shortcuts() {
min, ok := want[s.Command]
if !ok {
continue
}
n := 0
for _, tip := range s.Tips {
if strings.HasPrefix(tip, "Example: lark-cli apps +") {
n++
}
}
if n < min {
t.Errorf("%s has %d Example tips, want >= %d", s.Command, n, min)
}
}
}

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"regexp"
"testing"
)
// TestAppsErrorHintsCarryNoSecretsOrPII guards the actionable error hints added
// for the apps command-governance task. Those hints are inline string literals
// spread across several files (apps_env_pull.go, apps_access_scope_set.go,
// apps_access_scope_get.go, apps_init.go git-push path, and the
// gitCredentialIssueHint const in git_credential.go). They are stable English
// strings, so we assert the verbatim copies here: a real app_id, an email, or a
// phone number must never appear in a hint. Placeholders like <app_id> are
// expected and must NOT trip the real-app-id regex.
func TestAppsErrorHintsCarryNoSecretsOrPII(t *testing.T) {
// These are copied verbatim from the source. If a hint changes, copy the new
// text here so this leak guard keeps tracking the real production string.
hints := []string{
// apps_env_pull.go:86 and apps_access_scope_get.go:50 (identical literals)
"verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`",
// apps_access_scope_set.go:74
"verify --app-id is correct; for scope=specific, each --targets id must be a valid open_id/department_id/chat_id and --approver a valid open_id; review the current scope with `lark-cli apps +access-scope-get --app-id <app_id>`",
// apps_init.go:483 (git push rejection)
"the push was rejected — the git output is in the message above; if it is a non-fast-forward (remote has new commits), sync the remote and retry; if it is an auth failure, make sure `lark-cli apps +git-credential-init` has succeeded",
// git_credential.go gitCredentialIssueHint const (referenced directly so a
// rename or text change breaks the build instead of silently drifting)
gitCredentialIssueHint,
// command-governance hints added for this task (referenced by const, no drift)
appIDListHint,
sessionStopHint,
createHint,
dbEnvCreateHint,
dbTableGetHint,
dbTableListHint,
}
realAppID := regexp.MustCompile(`app_[a-z0-9]{6,}`)
email := regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`)
phone := regexp.MustCompile(`\b1[3-9]\d{9}\b`)
// An obvious secret: a PAT-like token or a "secret=..." / "token=..." pair.
secret := regexp.MustCompile(`(?i)(pat-[a-z0-9]+|secret\s*[=:]\s*\S|token\s*[=:]\s*\S)`)
for _, h := range hints {
if realAppID.MatchString(h) {
t.Errorf("hint leaks a real-looking app id (use <app_id>): %q", h)
}
if email.MatchString(h) {
t.Errorf("hint leaks an email address: %q", h)
}
if phone.MatchString(h) {
t.Errorf("hint leaks a phone number: %q", h)
}
if secret.MatchString(h) {
t.Errorf("hint leaks an obvious secret/token: %q", h)
}
}
// Sanity: the placeholder <app_id> must NOT match the real-app-id regex,
// otherwise the guard above would be a false positive on legitimate hints.
if realAppID.MatchString("<app_id>") {
t.Fatal("realAppID regex incorrectly matches the <app_id> placeholder")
}
}

View File

@@ -0,0 +1,129 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func assertHintContains(t *testing.T, sc common.Shortcut, args []string, stub *httpmock.Stub, want string) {
t.Helper()
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(stub)
err := runAppsShortcut(t, sc, args, factory, stdout)
if err == nil {
t.Fatalf("expected failure, got nil; stdout=%s", stdout.String())
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
}
if !strings.Contains(p.Hint, want) {
t.Fatalf("hint %q does not contain %q", p.Hint, want)
}
}
func TestAppsSessionCreate_4xxFailureCarriesListHint(t *testing.T) {
assertHintContains(t, AppsSessionCreate,
[]string{"+session-create", "--app-id", "app_x", "--as", "user"},
&httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/sessions",
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "app not found"}},
"apps +list")
}
func TestAppsSessionList_4xxFailureCarriesListHint(t *testing.T) {
assertHintContains(t, AppsSessionList,
[]string{"+session-list", "--app-id", "app_x", "--as", "user"},
&httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/sessions",
Status: http.StatusForbidden, Body: map[string]interface{}{"msg": "permission denied"}},
"apps +list")
}
func TestAppsUpdate_4xxFailureCarriesListHint(t *testing.T) {
assertHintContains(t, AppsUpdate,
[]string{"+update", "--app-id", "app_x", "--name", "n", "--as", "user"},
&httpmock.Stub{Method: "PATCH", URL: "/open-apis/spark/v1/apps/app_x",
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "app not found"}},
"apps +list")
}
func TestAppsReleaseList_4xxFailureCarriesListHint(t *testing.T) {
assertHintContains(t, AppsReleaseList,
[]string{"+release-list", "--app-id", "app_x", "--as", "user"},
&httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases",
Status: http.StatusForbidden, Body: map[string]interface{}{"msg": "permission denied"}},
"apps +list")
}
func TestAppsSessionStop_4xxFailureCarriesSessionHint(t *testing.T) {
assertHintContains(t, AppsSessionStop,
[]string{"+session-stop", "--app-id", "app_x", "--session-id", "s1", "--turn-id", "t1", "--as", "user"},
&httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/sessions/s1/stop",
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "session not found"}},
"+session-list")
}
func TestAppsCreate_4xxFailureCarriesTypeHint(t *testing.T) {
assertHintContains(t, AppsCreate,
[]string{"+create", "--name", "n", "--app-type", "html", "--as", "user"},
&httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps",
Status: http.StatusForbidden, Body: map[string]interface{}{"msg": "permission denied"}},
"full_stack")
}
func TestAppsDBEnvCreate_4xxFailureCarriesHint(t *testing.T) {
assertHintContains(t, AppsDBEnvCreate,
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--yes", "--as", "user"},
&httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/db_dev_init",
Status: http.StatusConflict, Body: map[string]interface{}{"msg": "already multi-env"}},
"+db-table-list")
}
func TestAppsDBTableGet_4xxFailureCarriesHint(t *testing.T) {
assertHintContains(t, AppsDBTableGet,
[]string{"+db-table-get", "--app-id", "app_x", "--table", "users", "--as", "user"},
&httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/tables/users",
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "table not found"}},
"+db-table-list")
}
func TestAppsDBTableList_4xxFailureCarriesHint(t *testing.T) {
assertHintContains(t, AppsDBTableList,
[]string{"+db-table-list", "--app-id", "app_x", "--env", "dev", "--as", "user"},
&httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/tables",
Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "dev env not found"}},
"+db-env-create")
}
// withAppsHint must only fill an EMPTY hint; an upstream-provided hint wins.
func TestWithAppsHint_DoesNotOverrideUpstreamHint(t *testing.T) {
upstream := &errs.Problem{Message: "boom", Hint: "upstream specific hint"}
got := withAppsHint(upstream, appIDListHint)
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("expected typed problem, got %T", got)
}
if p.Hint != "upstream specific hint" {
t.Fatalf("upstream hint was overridden: %q", p.Hint)
}
}
// withAppsHint fills the hint when empty and leaves Message untouched.
func TestWithAppsHint_FillsEmptyHintKeepsMessage(t *testing.T) {
p0 := &errs.Problem{Message: "boom"}
got := withAppsHint(p0, appIDListHint)
p, _ := errs.ProblemOf(got)
if p.Hint != appIDListHint {
t.Fatalf("hint not filled: %q", p.Hint)
}
if p.Message != "boom" {
t.Fatalf("message mutated: %q", p.Message)
}
}

View File

@@ -0,0 +1,91 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
// TestAppsEnvPull_4xxFailureCarriesListHint verifies that a 4xx failure from the
// env_vars endpoint surfaces an actionable hint pointing at `lark-cli apps +list`.
func TestAppsEnvPull_4xxFailureCarriesListHint(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
Status: http.StatusForbidden,
Body: map[string]interface{}{"msg": "permission denied"},
})
err := runAppsShortcut(t, AppsEnvPull,
[]string{"+env-pull", "--app-id", "app_x", "--project-path", t.TempDir(), "--as", "user"},
factory, stdout)
if err == nil {
t.Fatalf("expected failure, got nil; stdout=%s", stdout.String())
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
}
if !strings.Contains(p.Hint, "apps +list") {
t.Fatalf("hint missing `apps +list`: %q", p.Hint)
}
}
// TestAppsAccessScopeGet_4xxFailureCarriesListHint verifies the access-scope-get
// 4xx failure points at `lark-cli apps +list`.
func TestAppsAccessScopeGet_4xxFailureCarriesListHint(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
Status: http.StatusNotFound,
Body: map[string]interface{}{"msg": "app not found"},
})
err := runAppsShortcut(t, AppsAccessScopeGet,
[]string{"+access-scope-get", "--app-id", "app_x", "--as", "user"},
factory, stdout)
if err == nil {
t.Fatalf("expected failure, got nil; stdout=%s", stdout.String())
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
}
if !strings.Contains(p.Hint, "apps +list") {
t.Fatalf("hint missing `apps +list`: %q", p.Hint)
}
}
// TestAppsAccessScopeSet_4xxFailureCarriesScopeGetHint verifies the
// access-scope-set 4xx failure points at `+access-scope-get`.
func TestAppsAccessScopeSet_4xxFailureCarriesScopeGetHint(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
Status: http.StatusBadRequest,
Body: map[string]interface{}{"msg": "invalid target id"},
})
err := runAppsShortcut(t, AppsAccessScopeSet,
[]string{"+access-scope-set", "--app-id", "app_x", "--scope", "tenant", "--as", "user"},
factory, stdout)
if err == nil {
t.Fatalf("expected failure, got nil; stdout=%s", stdout.String())
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
}
if !strings.Contains(p.Hint, "+access-scope-get") {
t.Fatalf("hint missing `+access-scope-get`: %q", p.Hint)
}
}

View File

@@ -21,9 +21,13 @@ var AppsHTMLPublish = common.Shortcut{
Command: "+html-publish",
Description: "Publish HTML to a Miaoda app (single multipart POST returns the access URL)",
Risk: "write",
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Tips: []string{
"Example: lark-cli apps +html-publish --app-id <app_id> --path ./dist",
"Example: lark-cli apps +html-publish --app-id <app_id> --path ./site --dry-run",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
{Name: "path", Desc: "path to HTML file or directory", Required: true},

674
shortcuts/apps/apps_init.go Normal file
View File

@@ -0,0 +1,674 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"unicode"
"github.com/larksuite/cli/internal/charcheck"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// defaultInitBranch is the fixed remote branch +init checks out after clone.
const defaultInitBranch = "sprint/default"
// Fixed init commit subjects. Constants — never interpolate user input. The
// empty-repo (`app init`) path splits the scaffolded tree into two commits;
// the non-empty (`app sync`) path stays a single commit.
const (
commitMsgAppCode = "chore: initialize app project code"
commitMsgAppConfig = "chore: initialize miaoda app config"
commitMsgUpgrade = "chore: initialize miaoda app repository"
)
// scaffold kinds returned by runScaffold and consumed by commitAndPushIfDirty.
const (
scaffoldKindInit = "init"
scaffoldKindUpgrade = "upgrade"
)
const (
miaodaCLIPkg = "@lark-apaas/miaoda-cli@latest"
defaultTemplate = "nestjs-react-fullstack"
metaRelPath = ".spark/meta.json"
steeringRelPath = ".agent/skills/steering"
seedReadme = "README.md"
)
// initRunner is the commandRunner used by +init. Package-level so unit tests
// can swap in a fakeCommandRunner. Production uses execCommandRunner.
var initRunner commandRunner = execCommandRunner{}
// AppsInit initializes a Miaoda app's code and local development environment.
var AppsInit = common.Shortcut{
Service: appsService,
Command: "+init",
Description: "Initialize a Miaoda app's code and local development environment",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +init --app-id <app_id> --dir <dir>",
"Example: lark-cli apps +init --app-id <app_id> --dir <dir> --dry-run",
},
// +init makes no direct lark API calls (it shells out to the
// +git-credential-init subprocess, which enforces its own scopes), so it
// declares no scopes of its own. Explicit []string{} (not nil) per the
// convention enforced by TestAllShortcutsScopesNotNil.
Scopes: []string{},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
// NOTE: --app-id is intentionally NOT Required:true. The framework maps
// Required:true to cobra's MarkFlagRequired, whose error is plain-text
// exit-1 (root.go handleRootError case 4), bypassing the structured
// envelope. The spec and the E2E assert exit-2 + a structured
// {"ok":false,"error":{...}} envelope for missing --app-id, so the empty
// check lives in Validate (output.ErrValidation -> ExitValidation=2).
{Name: "app-id", Desc: "Miaoda app ID"},
{Name: "dir", Desc: "clone target directory; absolute or relative path (default ./<app-id>)"},
{Name: "template", Desc: "code-init template for an empty repo; optional — if omitted, derived from the app's tech stack"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
template := resolveTemplate(rctx, appID)
dry := common.NewDryRunAPI().
Desc("Initialize Miaoda app code (credential-init, clone, checkout, npx code-init, optional commit/push)").
Set("credential_init", fmt.Sprintf("apps +git-credential-init --app-id %s --format json", appID)).
Set("checkout", "git checkout "+defaultInitBranch).
Set("scaffold", fmt.Sprintf("empty repo: npx -y --prefer-online %s app init --template %s --app-id %s; non-empty: npx -y --prefer-online %s app sync + .spark/meta.json app_id patch + conditional skills sync --local", miaodaCLIPkg, template, appID, miaodaCLIPkg)).
Set("commit_push", "conditional: git add -A + commit + push origin "+defaultInitBranch+" when the working tree has changes").
Set("template", template).
Set("env_pull", fmt.Sprintf("apps +env-pull --app-id %s --project-path <clone_path> --format json (after successful init)", appID))
dir, err := resolveTargetPath(rctx, appID)
if err != nil {
dry.Set("dir_error", err.Error())
dir = defaultCloneDir(appID)
} else if isAlreadyInitialized(dir) {
dry.Set("already_initialized", true)
} else if e := ensureEmptyDir(dir); e != nil {
dry.Set("dir_error", e.Error())
}
dry.Set("clone", fmt.Sprintf("git clone -- <repository_url-from-credential-init> %s", dir))
dry.Set("clone_path", dir)
return dry
},
Execute: appsInitExecute,
}
// defaultCloneDir returns the default clone target (./<app-id>) for an app ID.
func defaultCloneDir(appID string) string {
return filepath.Join(".", appID)
}
// resolveTemplate returns the scaffold template for an empty-repo `app init`.
// An explicit --template wins. When omitted, it should be derived from the
// app's tech stack.
// TODO(apps-init): look up the app by appID via the apps API (e.g. `apps +list`
// or a get-app endpoint), read its tech stack, and map tech-stack -> template
// through a (future) enum. Until that lands, fall back to defaultTemplate.
func resolveTemplate(rctx *common.RuntimeContext, appID string) string {
if t := strings.TrimSpace(rctx.Str("template")); t != "" {
return t
}
// TODO(apps-init): derive from app tech stack (apps API + enum mapping).
return defaultTemplate
}
// initLogf writes a one-line progress message to stderr. stdout stays reserved
// for the structured JSON envelope, so progress never pollutes it. Callers must
// never pass a raw repository_url (it may embed a token) — pass step names,
// clone_path, branch, or scaffold kind, and route any URL through
// redactURLCredentials first.
func initLogf(rctx *common.RuntimeContext, format string, args ...interface{}) {
fmt.Fprintf(rctx.IO().ErrOut, "→ "+format+"\n", args...)
}
// resolveTargetPath computes the absolute clone target from --dir (or the
// ./<app-id> default). Unlike the prior SafeInputPath approach it does NOT
// confine to cwd — the clone destination is user-chosen (the skill prompts for
// it). It rejects empty input and control characters; symlink/no-clobber
// guarding happens in ensureEmptyDir.
func resolveTargetPath(rctx *common.RuntimeContext, appID string) (string, error) {
raw := strings.TrimSpace(rctx.Str("dir"))
if raw == "" {
raw = defaultCloneDir(appID)
}
// Reject ALL control characters (incl. tab/newline — a newline in an echoed
// path is a log-injection vector); charcheck additionally rejects dangerous
// Unicode (bidi overrides, zero-width) that IsControl does not.
if strings.IndexFunc(raw, unicode.IsControl) >= 0 {
return "", output.ErrValidation("--dir must not contain control characters")
}
if err := charcheck.RejectControlChars(raw, "--dir"); err != nil {
return "", output.ErrValidation("%v", err)
}
abs, err := filepath.Abs(raw) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); raw is control-char-validated above, and FileIO.ResolvePath cannot resolve a clone target (it rejects absolute paths).
if err != nil {
return "", output.ErrValidation("--dir cannot be resolved: %v", err)
}
return abs, nil
}
// ensureEmptyDir refuses to clone into an existing non-empty dir, a symlink, or
// a non-directory. A non-existent path is fine (git clone creates it). Uses
// Lstat so a symlinked target is rejected rather than followed.
func ensureEmptyDir(dir string) error {
info, err := os.Lstat(dir) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); dir is the validated clone target, and lstat is required to reject a symlink (FileIO has no Lstat; its Stat follows symlinks).
if os.IsNotExist(err) {
return nil
}
if err != nil {
return output.ErrValidation("--dir cannot be read: %v", err)
}
if info.Mode()&os.ModeSymlink != 0 {
return output.ErrValidation("--dir must not be a symlink: %q", dir)
}
if !info.IsDir() {
return output.ErrValidation("--dir exists and is not a directory: %q", dir)
}
entries, err := os.ReadDir(dir) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); dir is the validated clone target, and FileIO has no ReadDir.
if err != nil {
return output.ErrValidation("--dir cannot be read: %v", err)
}
if len(entries) > 0 {
return output.ErrValidation("target directory %q already exists and is not empty", dir)
}
return nil
}
// isAlreadyInitialized reports whether dir is an already-initialized Miaoda app
// repo, detected by the presence of <dir>/.spark/meta.json (regardless of its
// app_id value). Used to short-circuit +init into a friendly no-op.
func isAlreadyInitialized(dir string) bool {
info, err := os.Stat(filepath.Join(dir, metaRelPath)) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Stat rejects absolute paths.
return err == nil && !info.IsDir()
}
// ensureMetaAppID patches <dir>/.spark/meta.json to include app_id when the file
// exists but lacks (or has an empty) app_id. Other fields are preserved. When
// the file does not exist, this is a no-op (we never create it).
func ensureMetaAppID(dir, appID string) error {
path := filepath.Join(dir, metaRelPath)
b, err := os.ReadFile(path) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Open rejects absolute paths.
if os.IsNotExist(err) {
return nil
}
if err != nil {
return output.Errorf(output.ExitAPI, "meta_write", "read %s failed: %v", metaRelPath, err)
}
var m map[string]interface{}
if err := json.Unmarshal(b, &m); err != nil {
return output.Errorf(output.ExitAPI, "meta_write", "parse %s failed: %v", metaRelPath, err)
}
if cur, _ := m["app_id"].(string); strings.TrimSpace(cur) != "" {
return nil
}
if m == nil {
m = map[string]interface{}{}
}
m["app_id"] = appID
out, err := json.MarshalIndent(m, "", " ")
if err != nil {
return output.Errorf(output.ExitAPI, "meta_write", "marshal %s failed: %v", metaRelPath, err)
}
if err := os.WriteFile(path, append(out, '\n'), 0o644); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Save rejects absolute paths.
return output.Errorf(output.ExitAPI, "meta_write", "write %s failed: %v", metaRelPath, err)
}
return nil
}
// hasSteeringSkills reports whether <dir>/.agent/skills/steering exists as a dir.
func hasSteeringSkills(dir string) bool {
info, err := os.Stat(filepath.Join(dir, steeringRelPath)) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Stat rejects absolute paths.
return err == nil && info.IsDir()
}
// isEmptyRepo reports whether the checked-out branch has no tracked files
// other than the backend's default seed README.md. `git ls-files` listing
// nothing — or only README.md — counts as empty (→ scaffold via `app init`).
func isEmptyRepo(ctx context.Context, dir string) (bool, error) {
stdout, stderr, err := initRunner.Run(ctx, dir, "git", "ls-files")
if err != nil {
return false, output.Errorf(output.ExitAPI, "git_ls_files", "git ls-files failed: %s", gitErr(stderr, err))
}
for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") {
f := strings.TrimSpace(line)
// Match the seed exactly (case- and path-sensitive): only a root-level
// "README.md" is the backend's default seed. A docs/README.md or readme.md
// is treated as real content (→ non-empty), which is the safe direction
// (skip scaffolding rather than risk overwriting). Extend this allow-list
// here if the backend's seed set grows.
if f == "" || f == seedReadme {
continue
}
return false, nil // a non-README tracked file → non-empty repo
}
return true, nil
}
// runScaffold runs the npx scaffolding step inside the cloned repo (cwd=dir).
// Empty repo -> `app init`; non-empty -> `app sync` + meta app_id patch +
// conditional `skills sync`. Returns "init" or "upgrade".
func runScaffold(ctx context.Context, dir, appID, template string) (string, error) {
empty, err := isEmptyRepo(ctx, dir)
if err != nil {
return "", err
}
if empty {
// isEmptyRepo treats a repo with no tracked files — or only the backend's
// seed README.md — as empty. If other seed files (e.g. .gitignore) can
// appear, extend isEmptyRepo's allow-list accordingly.
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", template, "--app-id", appID); err != nil {
return "", output.Errorf(output.ExitAPI, "npx_app_init", "npx app init failed: %s", gitErr(stderr, err))
}
return scaffoldKindInit, nil
}
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "sync"); err != nil {
return "", output.Errorf(output.ExitAPI, "npx_app_sync", "npx app sync failed: %s", gitErr(stderr, err))
}
if err := ensureMetaAppID(dir, appID); err != nil {
return "", err
}
if !hasSteeringSkills(dir) {
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
return "", output.Errorf(output.ExitAPI, "npx_skills_sync", "npx skills sync failed: %s", gitErr(stderr, err))
}
}
return scaffoldKindUpgrade, nil
}
// parseRepoURLFromEnvelope extracts data.repository_url from a lark-cli JSON
// envelope ({"ok":true,"data":{"repository_url":"..."}}). The field name
// matches the contract emitted by `apps +git-credential-init`.
func parseRepoURLFromEnvelope(stdout string) (string, error) {
var env struct {
OK bool `json:"ok"`
Data struct {
RepositoryURL string `json:"repository_url"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
return "", output.Errorf(output.ExitInternal, "credential_init", "could not parse +git-credential-init output as JSON: %v", err)
}
if !env.OK {
return "", output.Errorf(output.ExitInternal, "credential_init", "+git-credential-init reported failure")
}
if strings.TrimSpace(env.Data.RepositoryURL) == "" {
return "", output.Errorf(output.ExitInternal, "credential_init", "+git-credential-init returned no repository_url")
}
return env.Data.RepositoryURL, nil
}
// parseEnvFileFromEnvelope extracts data.env_file from a `+env-pull` success
// envelope ({"ok":true,"data":{"env_file":"..."}}) on stdout.
func parseEnvFileFromEnvelope(stdout string) (string, error) {
var env struct {
OK bool `json:"ok"`
Data struct {
EnvFile string `json:"env_file"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
return "", output.Errorf(output.ExitInternal, "env_pull", "could not parse +env-pull output as JSON: %v", err)
}
if !env.OK {
return "", output.Errorf(output.ExitInternal, "env_pull", "+env-pull reported failure")
}
if strings.TrimSpace(env.Data.EnvFile) == "" {
return "", output.Errorf(output.ExitInternal, "env_pull", "+env-pull returned no env_file")
}
return env.Data.EnvFile, nil
}
// parseEnvPullErrorEnvelope extracts a single-line reason from a `+env-pull`
// error envelope ({"ok":false,"error":{"type":...,"message":...}}) on stderr.
// Returns "" when stderr is not a parseable error envelope (caller falls back).
func parseEnvPullErrorEnvelope(stderr string) string {
var env struct {
Error struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(stderr)), &env); err != nil {
return ""
}
msg := strings.TrimSpace(env.Error.Message)
if msg == "" {
return ""
}
if t := strings.TrimSpace(env.Error.Type); t != "" {
return t + ": " + msg
}
return msg
}
// validateRepoURLScheme rejects any repository_url that is not http(s):// to
// block git's dangerous transports (ext::, file://, ssh://) and option injection.
func validateRepoURLScheme(repoURL string) error {
if strings.HasPrefix(repoURL, "http://") || strings.HasPrefix(repoURL, "https://") {
return nil
}
return output.Errorf(output.ExitValidation, "validation",
"repository_url from +git-credential-init must be http(s); refusing %q", redactURLCredentials(repoURL))
}
func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
dir, err := resolveTargetPath(rctx, appID)
if err != nil {
return err
}
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
// initialized Miaoda app repo -> skip clone/scaffold/commit, but still refresh
// the local env so a re-run picks up the latest startup env vars.
if isAlreadyInitialized(dir) {
initLogf(rctx, "Already initialized at %s — refreshing local environment", dir)
out := map[string]interface{}{
"app_id": appID,
"clone_path": dir,
"scaffold": "already_initialized",
"committed": false,
"pushed": false,
}
initLogf(rctx, "Pulling local environment variables...")
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
envPulled := envPullErr == ""
out["env_pulled"] = envPulled
if envPulled {
initLogf(rctx, "Local environment written to %s", envFile)
out["env_file"] = envFile
out["message"] = "Repository already initialized. Local env refreshed — you can start developing."
} else {
initLogf(rctx, "Could not pull local env vars: %s", envPullErr)
out["env_pull_error"] = envPullErr
out["message"] = fmt.Sprintf("Repository already initialized. Could not pull local env vars automatically — run `lark-cli apps +env-pull --app-id %s` to retry.", appID)
}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ Already initialized at %s\n", dir)
if envPulled {
fmt.Fprintf(w, "✓ Local environment written to %s\n", envFile)
} else {
fmt.Fprintf(w, "⚠ Could not pull local env vars: %s\n", envPullErr)
fmt.Fprintf(w, " run `lark-cli apps +env-pull --app-id %s` to retry\n", appID)
}
fmt.Fprintln(w, "仓库已初始化完成,可以开始开发了。")
})
return nil
}
if _, err := exec.LookPath("git"); err != nil {
return output.ErrWithHint(output.ExitInternal, "dependency",
"git executable not found on PATH", "install git and ensure it is on your PATH")
}
if _, err := exec.LookPath("npx"); err != nil {
return output.ErrWithHint(output.ExitInternal, "dependency",
"npx executable not found on PATH", "install Node.js (which provides npx) and ensure it is on your PATH")
}
if err := ensureEmptyDir(dir); err != nil {
return err
}
initLogf(rctx, "Issuing repository credentials for %s...", appID)
repoURL, err := issueCredentials(ctx, rctx, appID)
if err != nil {
return err
}
if err := validateRepoURLScheme(repoURL); err != nil {
return err
}
initLogf(rctx, "Cloning into %s...", dir)
if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", repoURL, dir); err != nil {
return output.Errorf(output.ExitAPI, "git_clone", "git clone failed: %s", gitErr(stderr, err))
}
initLogf(rctx, "Checking out %s...", defaultInitBranch)
if _, stderr, err := initRunner.Run(ctx, dir, "git", "checkout", defaultInitBranch); err != nil {
return output.Errorf(output.ExitAPI, "git_checkout", "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err))
}
initLogf(rctx, "Initializing app code (running miaoda-cli)...")
scaffold, err := runScaffold(ctx, dir, appID, resolveTemplate(rctx, appID))
if err != nil {
return err
}
committed, pushed, err := commitAndPushIfDirty(ctx, dir, scaffold)
if err != nil {
return err
}
if pushed {
initLogf(rctx, "Committed and pushed to %s", defaultInitBranch)
} else {
initLogf(rctx, "Working tree clean — skipped commit/push")
}
initLogf(rctx, "Pulling local environment variables...")
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
envPulled := envPullErr == ""
if envPulled {
initLogf(rctx, "Local environment written to %s", envFile)
} else {
initLogf(rctx, "Could not pull local env vars: %s", envPullErr)
}
out := map[string]interface{}{
"app_id": appID,
"repository_url": redactURLCredentials(repoURL),
"branch": defaultInitBranch,
"clone_path": dir,
"scaffold": scaffold,
"committed": committed,
"pushed": pushed,
"env_pulled": envPulled,
"message": "Repository initialized. You can start developing.",
}
if envPulled {
out["env_file"] = envFile
} else {
out["env_pull_error"] = envPullErr
out["message"] = fmt.Sprintf("Repository initialized. Could not pull local env vars automatically — run `lark-cli apps +env-pull --app-id %s` to retry.", appID)
}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ Repository initialized at %s\n", dir)
fmt.Fprintf(w, " branch: %s\n scaffold: %s\n", defaultInitBranch, scaffold)
if envPulled {
fmt.Fprintf(w, "✓ Local environment written to %s\n", envFile)
} else {
fmt.Fprintf(w, "⚠ Could not pull local env vars: %s\n", envPullErr)
fmt.Fprintf(w, " run `lark-cli apps +env-pull --app-id %s` to retry\n", appID)
}
fmt.Fprintln(w, "仓库已初始化完成,可以开始开发了。")
})
return nil
}
// pullEnv runs `<self> apps +env-pull --app-id <appID> --project-path <dir>
// --format json`, forwarding --as when set. Returns (envFile, "") on success or
// ("", reason) on failure. Non-fatal by contract: the caller logs a warning and
// continues. The success envelope is read from stdout, the error envelope from
// stderr (lark-cli writes structured errors to stderr; see cmd/root.go
// handleRootError). The reason is always redacted.
func pullEnv(ctx context.Context, rctx *common.RuntimeContext, appID, dir string) (envFile, reason string) {
self, err := os.Executable()
if err != nil {
return "", redactURLCredentials(fmt.Sprintf("cannot locate lark-cli executable: %v", err))
}
args := []string{"apps", "+env-pull", "--app-id", appID, "--project-path", dir, "--format", "json"}
if as := strings.TrimSpace(rctx.Str("as")); as != "" {
args = append(args, "--as", as)
}
stdout, stderr, runErr := initRunner.Run(ctx, "", self, args...)
if runErr != nil {
r := parseEnvPullErrorEnvelope(stderr)
if r == "" {
r = gitErr(stderr, runErr)
}
return "", redactURLCredentials(r)
}
envFile, perr := parseEnvFileFromEnvelope(stdout)
if perr != nil {
return "", redactURLCredentials(perr.Error())
}
return envFile, ""
}
// issueCredentials runs `<self> apps +git-credential-init --app-id <id> --format json`
// and returns the repo_url it reports. Forwards --as when set.
func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (string, error) {
self, err := os.Executable()
if err != nil {
return "", output.Errorf(output.ExitInternal, "internal", "cannot locate lark-cli executable: %v", err)
}
args := []string{"apps", "+git-credential-init", "--app-id", appID, "--format", "json"}
if as := strings.TrimSpace(rctx.Str("as")); as != "" {
args = append(args, "--as", as)
}
stdout, stderr, err := initRunner.Run(ctx, "", self, args...)
if err != nil {
return "", output.ErrWithHint(output.ExitAPI, "credential_init",
fmt.Sprintf("apps +git-credential-init failed: %s", gitErr(stderr, err)),
"ensure apps +git-credential-init is available and you are logged in")
}
return parseRepoURLFromEnvelope(stdout)
}
// commitAndPushIfDirty commits and pushes only when the working tree has
// changes; a clean tree is a no-op (returns false,false). For the empty-repo
// init path (scaffoldKind == "init") it splits the scaffolded tree into two
// commits — app project code, then Miaoda config (.spark/.agent) — skipping
// either commit when that group has no changes (no empty commits). Other paths
// commit once. Push is a single `git push origin <branch>` for all commits.
func commitAndPushIfDirty(ctx context.Context, dir, scaffoldKind string) (committed, pushed bool, err error) {
status, stderr, runErr := initRunner.Run(ctx, dir, "git", "status", "--porcelain")
if runErr != nil {
return false, false, output.Errorf(output.ExitAPI, "git_status", "git status failed: %s", gitErr(stderr, runErr))
}
if strings.TrimSpace(status) == "" {
return false, false, nil
}
if scaffoldKind == scaffoldKindInit {
// Stage each group by its exact porcelain paths (never gitignored files),
// so neither `git add` errors on an ignored path like .agent.
appPaths, configPaths := classifyPorcelain(status)
if len(appPaths) > 0 {
if e := stageAndCommit(ctx, dir, commitMsgAppCode, appPaths...); e != nil {
return committed, false, e
}
committed = true
}
if len(configPaths) > 0 {
if e := stageAndCommit(ctx, dir, commitMsgAppConfig, configPaths...); e != nil {
return committed, false, e
}
committed = true
}
} else {
if e := stageAndCommit(ctx, dir, commitMsgUpgrade, "."); e != nil {
return false, false, e
}
committed = true
}
if !committed {
return false, false, nil
}
if _, se, e := initRunner.Run(ctx, dir, "git", "push", "origin", defaultInitBranch); e != nil {
return true, false, withAppsHint(
output.Errorf(output.ExitAPI, "git_push", "git push failed: %s", gitErr(se, e)),
"the push was rejected — the git output is in the message above; if it is a non-fast-forward (remote has new commits), sync the remote and retry; if it is an auth failure, make sure `lark-cli apps +git-credential-init` has succeeded")
}
return true, true, nil
}
// stageAndCommit stages the given pathspecs (`git add -A -- <pathspecs>`) and
// makes one `git commit --no-verify -m message`. --no-verify skips the scaffold
// repo's local pre-commit / commit-msg hooks (local only; the later push is not
// --no-verify). Callers gate this on classifyPorcelain so the group is non-empty
// and the commit never hits "nothing to commit".
func stageAndCommit(ctx context.Context, dir, message string, pathspecs ...string) error {
addArgs := append([]string{"add", "-A", "--"}, pathspecs...)
if _, se, e := initRunner.Run(ctx, dir, "git", addArgs...); e != nil {
return output.Errorf(output.ExitAPI, "git_add", "git add failed: %s", gitErr(se, e))
}
if _, se, e := initRunner.Run(ctx, dir, "git", "commit", "--no-verify", "-m", message); e != nil {
return output.Errorf(output.ExitAPI, "git_commit", "git commit failed: %s", gitErr(se, e))
}
return nil
}
// classifyPorcelain parses `git status --porcelain` output and partitions the
// changed paths into the "app code" group (anything outside .spark/ and .agent/)
// and the "Miaoda config" group (.spark/ and .agent/). It returns the exact
// porcelain paths so callers can stage them verbatim: porcelain never lists
// gitignored files, so `git add -- <these paths>` never trips git's ignored-path
// error. (Naming an ignored dir explicitly — or combining a "." pathspec with
// :(exclude) magic — DOES error when a scaffold template gitignores e.g. .agent,
// which is why we stage exact paths instead of pathspecs.)
func classifyPorcelain(status string) (appPaths, configPaths []string) {
for _, line := range strings.Split(status, "\n") {
p := porcelainPath(line)
if p == "" {
continue
}
if isConfigPath(p) {
configPaths = append(configPaths, p)
} else {
appPaths = append(appPaths, p)
}
}
return appPaths, configPaths
}
// porcelainPath extracts the path from a `git status --porcelain` v1 line.
// Format is "XY <path>" (2 status chars + space); rename/copy lines are
// "XY <orig> -> <dest>" (dest is what matters). Quoted paths are unquoted.
func porcelainPath(line string) string {
if len(line) < 4 {
return ""
}
p := line[3:]
if i := strings.Index(p, " -> "); i >= 0 {
p = p[i+len(" -> "):]
}
p = strings.TrimSpace(p)
p = strings.Trim(p, `"`)
return p
}
// isConfigPath reports whether p is the Miaoda app-config group: the .spark or
// .agent directory itself, or anything under them. ".sparkrc" is NOT config.
func isConfigPath(p string) bool {
return p == ".spark" || p == ".agent" ||
strings.HasPrefix(p, ".spark/") || strings.HasPrefix(p, ".agent/")
}
// gitErr builds a redacted, single-line error detail from stderr (falling back
// to the exec error). Always redacts embedded credentials.
func gitErr(stderr string, err error) string {
s := strings.TrimSpace(stderr)
if s == "" && err != nil {
s = err.Error()
}
return redactURLCredentials(s)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"strings"
"testing"
)
func TestListyCommandsHaveJqTip(t *testing.T) {
wantCmds := map[string]bool{
"+list": true, "+db-table-list": true, "+db-table-schema": true,
"+db-sql": true, "+release-list": true, "+session-list": true,
}
for _, s := range Shortcuts() {
if !wantCmds[s.Command] {
continue
}
has := false
for _, tip := range s.Tips {
if strings.Contains(tip, "--jq") || strings.Contains(tip, "-q '") {
has = true
}
}
if !has {
t.Errorf("%s should have a --jq filter tip", s.Command)
}
}
}

View File

@@ -12,23 +12,30 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// AppsList lists Miaoda apps owned by the calling user (cursor pagination).
// AppsList lists Miaoda apps visible to the calling user (cursor pagination).
//
// Hidden from --help / tab completion (Hidden: true) so agents do not discover it
// as a way to enumerate / search applications. Direct invocation still works for
// humans who know the command. When agents need an existing app_id, they should
// ask the user to provide either the Miaoda app URL (extract app_id from the
// path segment after /app/) or the app_id string directly; see lark-apps SKILL.md.
// Supports name fuzzy match (--keyword), ownership-dimension filter
// (--ownership: all / mine / shared), and app-type filter (--app-type). See
// lark-apps SKILL.md for when an agent should use this to resolve an app_id
// from a user-supplied name (only when the user named an app and a downstream
// op needs its app_id — never unconditional enumeration).
var AppsList = common.Shortcut{
Service: appsService,
Command: "+list",
Description: "List Miaoda apps owned by the calling user (cursor pagination)",
Description: "List Miaoda apps visible to the calling user (cursor pagination)",
Risk: "read",
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Hidden: true,
Tips: []string{
"Example: lark-cli apps +list",
"Example: lark-cli apps +list --keyword <keyword>",
"Tip: filter fields with --jq, e.g. -q '.data.items[].app_id'",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "keyword", Desc: "fuzzy match on app name"},
{Name: "ownership", Desc: "ownership filter: all (created by me + shared with me) | mine | shared", Enum: []string{"all", "mine", "shared"}},
{Name: "app-type", Desc: "app type filter (html or full_stack)", Enum: []string{"html", "full_stack"}},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
},
@@ -39,18 +46,42 @@ var AppsList = common.Shortcut{
Params(buildAppsListParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPI("GET", apiBasePath+"/apps", buildAppsListParams(rctx), nil)
data, err := rctx.CallAPITyped("GET", apiBasePath+"/apps", buildAppsListParams(rctx), nil)
if err != nil {
return err
}
items, _ := data["items"].([]interface{})
// Project away icon_url (an image URL agents can't render) and created_at
// (redundant with updated_at) from every item BEFORE OutFormat, so json /
// table / pretty are all lean. Every other field (description, etc.) is kept.
rawItems, _ := data["items"].([]interface{})
items := make([]interface{}, 0, len(rawItems))
for _, item := range rawItems {
m, ok := item.(map[string]interface{})
if !ok {
items = append(items, item)
continue
}
out := make(map[string]interface{}, len(m))
for k, v := range m {
if k == "icon_url" || k == "created_at" {
continue
}
out[k] = v
}
items = append(items, out)
}
data["items"] = items
rctx.OutFormat(data, nil, func(w io.Writer) {
// Table view (--format table) intentionally shows only the columns
// most useful for visual scanning: app_id (to copy-paste downstream),
// name (to match what the user sees in the UI), and updated_at (to
// pick the most recent variant). description / icon_url / created_at
// stay in the underlying JSON (--format json) but would make the
// table too wide for a terminal.
// Curated pretty view (--format pretty) shows the columns most useful
// for visual scanning: app_id (to copy-paste downstream), name (to match
// what the user sees in the UI), is_published / online_url (publish state
// and post-publish access link — the actionable fields after a deploy),
// and updated_at (to pick the most recent variant). online_url can be long
// but is the key value once published; the renderer clamps column width.
// Unpublished apps carry no online_url, so that cell renders empty.
// description stays in the underlying data (--format json / table) but
// would make the curated view too wide. icon_url / created_at are trimmed
// from the data entirely above (not useful to an agent).
rows := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
m, ok := item.(map[string]interface{})
@@ -58,9 +89,11 @@ var AppsList = common.Shortcut{
continue
}
rows = append(rows, map[string]interface{}{
"app_id": m["app_id"],
"name": m["name"],
"updated_at": m["updated_at"],
"app_id": m["app_id"],
"name": m["name"],
"is_published": m["is_published"],
"online_url": m["online_url"],
"updated_at": m["updated_at"],
})
}
output.PrintTable(w, rows)
@@ -76,5 +109,14 @@ func buildAppsListParams(rctx *common.RuntimeContext) map[string]interface{} {
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
params["page_token"] = token
}
if kw := strings.TrimSpace(rctx.Str("keyword")); kw != "" {
params["keyword"] = kw
}
if ownership := strings.TrimSpace(rctx.Str("ownership")); ownership != "" {
params["ownership"] = ownership
}
if at := strings.TrimSpace(rctx.Str("app-type")); at != "" {
params["app_type"] = at
}
return params
}

View File

@@ -63,6 +63,56 @@ func TestAppsList_WithPageToken(t *testing.T) {
}
}
func TestAppsList_WithKeywordOwnershipAppType(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps?app_type=html&keyword=%E9%97%AE%E5%8D%B7&ownership=mine&page_size=20",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false},
},
})
if err := runAppsShortcut(t, AppsList,
[]string{"+list", "--keyword", "问卷", "--ownership", "mine", "--app-type", "html", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
}
func TestAppsList_InvalidOwnership(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsList,
[]string{"+list", "--ownership", "bogus", "--as", "user"}, factory, stdout)
if err == nil {
t.Fatalf("expected enum validation error for --ownership bogus")
}
}
func TestAppsList_InvalidAppType(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsList,
[]string{"+list", "--app-type", "HTML", "--as", "user"}, factory, stdout)
if err == nil {
t.Fatalf("expected enum validation error for --app-type HTML (hard cut to lowercase)")
}
}
func TestAppsList_DryRunWithFilters(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsList,
[]string{"+list", "--keyword", "q", "--ownership", "all", "--app-type", "full_stack", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
for _, want := range []string{"keyword", "ownership", "app_type", "full_stack"} {
if !strings.Contains(got, want) {
t.Fatalf("dry-run missing %q: %s", want, got)
}
}
}
func TestAppsList_DryRun(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsList,
@@ -78,3 +128,86 @@ func TestAppsList_DryRun(t *testing.T) {
t.Fatalf("dry-run missing page_size param: %s", got)
}
}
func TestAppsList_TrimsIconURLAndCreatedAt(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps?page_size=20",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"app_id": "app_x",
"name": "Trim Me",
"is_published": true,
"online_url": "https://example.com/spark/faas/app_x",
"updated_at": "2026-05-28T10:05:16Z",
"created_at": "2026-05-01T08:00:00Z",
"icon_url": "https://example.com/icon.png",
"description": "An app to test trimming",
},
},
"page_token": "next_cursor",
"has_more": true,
},
},
})
if err := runAppsShortcut(t, AppsList, []string{"+list", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, drop := range []string{"icon_url", "created_at"} {
if strings.Contains(got, drop) {
t.Fatalf("default output should not contain %q:\n%s", drop, got)
}
}
for _, keep := range []string{"app_id", "name", "is_published", "online_url", "updated_at", "description"} {
if !strings.Contains(got, keep) {
t.Fatalf("default output missing %q:\n%s", keep, got)
}
}
}
func TestAppsList_PrettyShowsPublishFields(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps?page_size=20",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"app_id": "app_pub",
"name": "Published App",
"is_published": true,
"online_url": "https://example.com/spark/faas/app_pub",
"updated_at": "2026-05-28T10:05:16Z",
},
map[string]interface{}{
"app_id": "app_draft",
"name": "Draft App",
"is_published": false,
"updated_at": "2026-05-31T12:31:27Z",
},
},
"has_more": false,
},
},
})
if err := runAppsShortcut(t, AppsList,
[]string{"+list", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, want := range []string{"is_published", "online_url", "https://example.com/spark/faas/app_pub", "true", "false"} {
if !strings.Contains(got, want) {
t.Fatalf("pretty output missing %q:\n%s", want, got)
}
}
}

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"io"
"github.com/larksuite/cli/internal/output"
)
// Gateway paths for the spark app.release OpenAPI methods.
// Prefix reuses apiBasePath = "/open-apis/spark/v1" (same package).
// Each path contains %s placeholders; use fmt.Sprintf to build the final URL.
const (
releaseCreatePath = apiBasePath + "/apps/%s/releases"
releaseGetPath = apiBasePath + "/apps/%s/releases/%s"
releaseListPath = apiBasePath + "/apps/%s/releases"
)
// writeReleaseErrorLogTable renders a release's error_logs (a slice of
// {step, error_log} maps from the gateway) as a two-column step/error_log
// table via output.PrintTable. Used by +release-get to render a failed
// release's error_logs. A nil/non-slice or
// empty value yields an empty table (PrintTable prints "(no data)").
func writeReleaseErrorLogTable(w io.Writer, raw interface{}) {
logs, _ := raw.([]interface{})
rows := make([]map[string]interface{}, 0, len(logs))
for _, l := range logs {
m, ok := l.(map[string]interface{})
if !ok {
continue
}
rows = append(rows, map[string]interface{}{
"step": m["step"],
"error_log": m["error_log"],
})
}
output.PrintTable(w, rows)
}

Some files were not shown because too many files have changed in this diff Show More