Compare commits

..

95 Commits

Author SHA1 Message Date
wenzhuozhen
020151f01b docs(spec): add sheet-history-revert spec (history list / revert / revert-status)
3 new lark-sheets shortcuts via existing ToolsCall framework:
- +history-list, +history-revert, +history-revert-status
- async revert: sheet/data RecoverHistory → RecoverMsg(MQ) → agg consumer
  assigns memberId by scene (lark-cli=11 / doubao=10)
- scene threaded via ctx baggage; no thrift changes (codegen all-no)
- spans larksuite/cli, ee/sheet-skill-spec, ee/sheet-facade-agg, sheet/data

Force-added under docs/ (repo-wide gitignored) so ccm-harness drift
detection has the git-tracked SSOT.
2026-06-26 10:53:04 +08:00
wenzhuozhen
3a85ef389d chore: inject ccm-harness skill routing v2 to CLAUDE.md/AGENTS.md
Auto-injected by ccm-harness inject-routing-banner.sh.
- Claude Code 读 CLAUDE.md, Codex 读 AGENTS.md, 内容一致
- 如已建 AGENTS.md symlink, 只 inject CLAUDE.md 即可

Source template: $CCM_HARNESS_SRC/templates/claude-md-routing.md
2026-06-23 20:58:20 +08:00
zhengzhijiej-tech
68f867d6a5 Merge pull request #1519 from larksuite/feat/sheets-wiki-url
feat(sheets): resolve wiki URLs to the backing spreadsheet for --url
2026-06-23 11:06:36 +08:00
zhengzhijie
78f7fba89e fix(sheets): match --url path segment via url.Parse, not substring
parseSpreadsheetRef classified /wiki/ with strings.Index over the whole URL, so a /sheets/ link whose query or fragment merely contained /wiki/ (e.g. .../sheets/sht?from=/wiki/x) was hijacked into a get_node call. Now parse the URL and match /sheets/, /spreadsheets/, /wiki/ only as a path prefix, mirroring slides parsePresentationRef which already fixed this class. Drop the substring helpers. Also align wiki resolution with slides: CallAPITyped (typed error + log_id) and classify an incomplete get_node response as InternalError instead of a --url validation error. Add regression tests for query/fragment /wiki/ and incomplete node.
2026-06-22 19:13:38 +08:00
zhengzhijie
06241666a0 docs(sheets): note --url accepts wiki URLs (synced from spec) 2026-06-22 19:13:07 +08:00
zhengzhijie
a35cc26131 feat(sheets): resolve wiki URLs to the backing spreadsheet for --url
Sheets shortcuts only accepted /sheets/ and /spreadsheets/ URLs via --url.
A /wiki/<node_token> URL was rejected with "must be a spreadsheet URL"
because the wiki node_token is not a spreadsheet token: resolving it to the
backing spreadsheet needs a wiki get_node call, which Validate/DryRun (kept
network-free) must not make.

Mirror the existing slides/doc/drive two-stage pattern:

- parseSpreadsheetRef classifies --url / --spreadsheet-token network-free
  into a sheet token or an (unresolved) wiki node_token.
- resolveSpreadsheetTokenExec (Execute only) resolves a /wiki/ node_token
  via wiki get_node, verifies obj_type=sheet, and returns the obj_token.
  The wiki:node:read scope is enforced on this path only, so non-wiki
  invocations are unaffected.
- resolveSpreadsheetToken stays network-free for Validate/DryRun, passing
  the node_token through unchanged.

All 47 Execute paths (including +batch-update and +workbook-export) switch
to the Exec resolver; Validate/DryRun keep the network-free one. No tool
schema change: the CLI feeds the resolved spreadsheet token as excel_id, so
this is a pure CLI-layer change.

Tested: unit (parse classification + wiki get_node e2e via httpmock) and
live end-to-end against a real wiki spreadsheet (read: +workbook-info,
+cells-get, +csv-get; write: +sheet-create, +sheet-rename, +csv-put).
2026-06-22 19:13:07 +08:00
xiongyuanwen-byted
b6da950be3 feat(sheets): styles 接受 halign/valign 等对齐字段别名
把模型常幻觉的 horizontal_align / halign / vertical_align / valign 映射到
规范字段 horizontal_alignment / vertical_alignment,覆盖 --styles 与 typed
--cells;与规范字段冲突时报错而非静默择一。同步 lark-sheets skill 文档补
对齐字段说明 + --print-schema --flag-name styles 提示。
2026-06-22 18:28:05 +08:00
xiongyuanwen-byted
aa545083b6 docs(lark-sheets): sync from spec — set+H 告诫通则化(移入 stdin 段) 2026-06-22 18:28:05 +08:00
xiongyuanwen-byted
5c7100ee4c fix(sheets): migrate +table-put to typed error contract
The merge from main brought in #1449 (retire legacy error envelopes),
which removed output.ExitError / output.ErrDetail and forbids
constructing them. Port tablePutPartial off the legacy envelope:

- no sheets written -> typed errs.APIError (plain failure)
- some sheets written -> ok:false result via runtime.OutPartialFailure
  carrying written_sheets, returning the partial-failure exit signal

Also fix two drifts the same merge introduced:
- regenerate flag_defs_gen.go to match the committed flag-defs.json
- update the --max-chars flag test to assert visible (no longer hidden)
2026-06-22 12:29:03 +08:00
xiongyuanwen-byted
3ef3a9d1d3 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-06-22 10:14:25 +08:00
xiongyuanwen-byted
bdad336caf docs(lark-sheets): sync from spec — set+H 改单引号 / 速查表补臆造命令名 / workbook-import 引导 2026-06-20 14:11:02 +08:00
xiongyuanwen-byted
39a7d4bfb4 feat(sheets): 写操作报错增强 + --token 别名
- 复合 JSON shape 校验失败时报错附 --print-schema 提示,agent 可直接拿到精确结构(pro26 头号:+cells-set --cells 反复猜 shape)
- JSON 解析失败且该 flag 支持 stdin 时提示改用 stdin(公式/引号/逗号内联到 shell 被转义弄坏 JSON)
- --token 作为 --spreadsheet-token 的解析期别名:复用 sheets 已有 PostMount 钩子 + pflag normalize,仅 sheets 包,common 零改动
2026-06-20 14:11:02 +08:00
liangshuo-1
bba13cfe0f chore: release v1.0.56 (#1518) 2026-06-18 18:53:21 +08:00
liujiashu-shiro
815cdb8f1c feat(im/convert): support content_v2 blocks in post message conversion (#1411)
Support content_v2 post message conversion in IM shortcuts so newer post payloads render with the expected markdown, mention, and image formats while preserving fallback compatibility with legacy content.
2026-06-18 17:53:22 +08:00
liangshuo-1
4f3ae0c71a fix: pin fetch_meta.py output to utf-8 encoding (#1516) 2026-06-18 17:18:45 +08:00
91-enjoy
96d70143c5 feat: support message recieve event card format (#1480)
Previously, im.message.receive events with message_type: interactive surfaced the raw JSON
payload as content, requiring callers to manually parse the card schema. This PR introduces a
user_dsl renderer (ConvertInteractiveEventContent) that converts interactive card content into
structured human-readable text — consistent with how text, post, image, and other message
types are already handled.

The output format is <card title="..." subtitle="...">...</card>, with each card element type
serialised to a readable representation (markdown body, button links, table rows, chart summaries,
etc.).
2026-06-18 17:18:01 +08:00
syh-cpdsss
83db15907f Improve OKR shortcuts (#1487)
* feat(okr): add +batch-create, +reorder, +weight shortcuts

Add three new OKR shortcuts for managing objectives and key results:

- +batch-create: Bulk create objectives with key results, with automatic
  rollback on failure
- +reorder: Adjust position of objectives or key results within a cycle/objective
- +weight: Adjust weights of objectives or key results with automatic
  normalization using fixed-point arithmetic to avoid float precision issues

Key implementation details:
- API paths use underscore separators (/objectives_position, /objectives_weight)
- Weight normalization uses json.Number for precise JSON serialization
- Items are sorted by position before API calls to match backend requirements
- Full unit test coverage and dry-run/live E2E tests
- Skill documentation with usage examples and parameter descriptions

Change-Id: I92b658e0cc42ffa8cbdaec2ec628a079bcfc38f5

* fix: skill simplify & minor fix

Change-Id: I3f27a01cdae2122f26e48ee2acb7f334f2bab7d2

* fix: CR issue

Change-Id: Id9fab84e06f0d67e9f79c1fb9946b6b633200592

* fix: CR issue 2

Change-Id: I6a5e57dd4b10dc79f8681ec614354fbba82abc04

* fix: error handle of +weight shortcut

Change-Id: I6e2a39269e62e3b504e681110843b2ccc315a527
2026-06-18 16:25:23 +08:00
xiongyuanwen-byted
4b404fc0ee docs(lark-sheets): sync from spec — --max-chars 放出为可见 flag + 落盘优先指引
源同步自 sheet-skill-spec:--max-chars 放出(默认 500000,可调小避免大输出被 Bash/终端转存为文件、改 has_more 分页);read-data 增「大数据优先落盘」指引。
2026-06-18 15:58:20 +08:00
xiongyuanwen-byted
fc6e1e25de docs(lark-sheets): sync from spec — +csv-put 含逗号公式正例 + 收敛警示标签
源同步自 sheet-skill-spec:write-cells 补含逗号公式 RFC 4180 转义正例与结构化写入优先指引;全 reference 收敛「高频致命错误」类标签。
2026-06-18 13:07:30 +08:00
xiongyuanwen-byted
14d3107bf2 feat(sheets): +cells-get/+csv-get --max-chars 默认值 200000 → 500000
放宽默认防爆上限。flag_defs_gen.go 由 go generate 重生;flag_defs_test.go
的 expected default 同步;flag-schemas.json schema_version 2 → 3 是上游
spec-tables 架构调整带来的元数据 bump,与本业务改动无关、go:embed 不解析
该字段、无功能影响。

Synced from sheet-skill-spec@93f7a78.
2026-06-17 21:24:54 +08:00
hanshaoshuai
1f2164c7c2 fix: trim semantic review input for broad changes 2026-06-17 20:15:04 +08:00
raistlin042
76f5419a0d feat: add +session-messages-list for session turn reply messages (#1402)
* feat(apps): add +message-get to fetch session turn reply messages

* docs(apps): add +message-get skill reference

* fix(apps): drop Required flags on +message-get so missing ids return structured exit-2 envelope

* docs(apps): route turn reply-message queries to +message-get in SKILL.md

* docs(apps): guide cloud-dev to read live turn progress via +message-get

* docs(apps): note +message-get reads a still-running turn incrementally

* docs(apps): route live-turn reply queries to +message-get in SKILL.md

* refactor(apps): rename +message-get to +session-messages-list with page_token paging

* refactor(apps): use typed errs validation in +session-messages-list

* docs(apps): clarify +session-messages-list paging stops on has_more, not token
2026-06-17 20:12:22 +08:00
evandance
c5b5aece33 refactor: retire legacy error envelopes and enforce typed contract (#1449)
* refactor: retire legacy error envelopes and enforce typed contract

Consolidate all command error reporting onto the typed errs.* contract, remove
the legacy error surface that predated it, and tighten the lint guards so the
contract holds across the whole repository going forward.

Every failure now reaches stderr as one envelope shape: a category, an
optional subtype, a human- and agent-readable message, and a recovery hint,
with invalid parameters listed under `params`. The legacy ExitError envelope,
its constructors, and the boundary bridge that promoted untyped config and
authorization errors are deleted, leaving a single path from error to wire.
Predicate commands keep their silent-exit behavior through a dedicated signal
that carries only an exit code.

Infrastructure paths that still emitted ad-hoc envelopes — flag parsing,
unknown commands and subcommands, plugin and policy guards, confirmation
prompts, and auth/config failures — now classify into the same taxonomy.
Business, API, auth, and config exit codes are preserved; the one behavioral
change is that Cobra usage failures (missing required flag, unknown command,
bad arguments) now emit the typed validation envelope and exit 2, matching the
explicit flag and subcommand guards, instead of Cobra's plain-text exit 1.

Enforcement is repo-wide rather than per-path:
- The errscontract guards run by default everywhere instead of through a
  migration allowlist, so legacy envelopes cannot be reintroduced anywhere.
- errorlint runs across the whole repository: every error wrap must use %w and
  every comparison must use errors.Is/errors.As, so interior wraps stay legal
  but can no longer break the chain the typed boundary relies on.
- The errs-no-bare-wrap guard is keyed by structural prefix instead of an
  explicit per-domain allowlist, so new shortcut domains are covered without
  editing a list. It runs where forbidigo is enabled (the shortcut domains and
  the auth/config/service command groups); repo-wide chain integrity for the
  remaining command paths is carried by errorlint above.

* test: align cli_e2e success assertions to the ok envelope

The api and service success path now emits the {"ok":true} envelope, so the
cli_e2e workflow assertions that still expected the old {"code":0} shape via
AssertStdoutStatus(t, 0) fail once they run with live credentials. Switch those
workflow assertions to AssertStdoutStatus(t, true); the fake-payload helper test
in core_test.go keeps its code-shape assertion.
2026-06-17 19:42:38 +08:00
fangshuyu-768
d687a76c79 feat: soften lark doc style guidance (#1463) 2026-06-17 19:16:02 +08:00
guokexin.02
4a4c3344c8 fix: align api success envelopes (#1489) 2026-06-17 17:41:48 +08:00
hanshaoshuai
c61acb5264 feat: add ci quality gate 2026-06-17 16:29:33 +08:00
zgz2048
7eeb111a2d fix: reject out-of-range base pagination flags (#1495) 2026-06-17 15:41:59 +08:00
zhengzhijiej-tech
e795f4f068 Merge pull request #1482 from larksuite/zzj/mention-doc-link
feat(sheets): document link requirement for @document mentions
2026-06-17 14:12:41 +08:00
xiongyuanwen-byted
2e4033a1a0 fix(shortcuts): clarify single-stdin constraint in flag help and error hint
Input flags advertised '(supports @file, - for stdin)' per flag, leading
AI agents to write '--a - <x --b - <y' where the second '<' silently
clobbers the first and the first flag reads the wrong payload. A process
has a single stdin, so at most one flag per call can use '-'.

- Reword the generated help hint to '- reads stdin (one flag per call;
  use @file for others)'.
- Add an actionable .WithHint to the stdin-conflict validation error
  pointing callers to @file for the extra flags.
- Assert the new hint in TestResolveInputFlags_DuplicateStdin.
2026-06-17 11:35:37 +08:00
liangshuo-1
714da970d0 chore(release): v1.0.55 (#1490) 2026-06-16 22:26:40 +08:00
xiongyuanwen-byted
fc44564b01 refactor(sheets): migrate legacy error helpers to typed errs in sheets domain
golangci-lint forbidigo (errs-no-legacy-helper / errs-no-bare-wrap) flagged
the table I/O, workbook, and dataframe shortcuts that landed on this branch:
93 common.FlagErrorf and 48 fmt.Errorf calls.

- Replace every common.FlagErrorf with common.ValidationErrorf (typed
  *errs.ValidationError, same signature) across workbook / table_io /
  dataframe / object_crud.
- writeDataframeOut's two final --dataframe-out write failures become typed
  errs.NewInternalError(SubtypeFileIO, ...).WithCause(err).
- applyWorkbookCreateVisualOps now passes the typed callTool error through
  unchanged (re-wrapping would downgrade classification) and attaches the
  failing op as a recovery hint only when none is set.
- The remaining fmt.Errorf are genuine intermediate errors that the command
  layer re-wraps into typed validation errors (buildTypedCell / Arrow
  decode-encode) or surfaces as a partial_success message string
  (writeTypedSheets via tablePutPartial); each carries a //nolint:forbidigo
  with that reason, per the lint guidance.

No behavior change: error messages and partial-success shapes are preserved;
gofmt, go vet, golangci-lint (0 issues) and sheets tests all pass.
2026-06-16 20:47:54 +08:00
xiongyuanwen-byted
7742a47072 fix(sheets): collapse duplicate validateCreateInput from bad merge resolution
A prior merge kept both branches' independently-added validateCreateInput
fields on objectCRUDSpec with conflicting signatures (pivot's
func(rt, input) and cond-format's func(input)), plus both call sites in
objectCreateInput, which failed to compile (validateCreateInput redeclared).

Collapse to the single richer func(rt flagView, input) signature and one
call site. cond-format's validateCondFormatAttrs (func(input), still shared
with validateUpdateInput) is wrapped in a closure that ignores rt. Both
behaviors are preserved: pivot --target-position/--range mutex and
cond-format attrs-shape-vs-rule_type validation.
2026-06-16 20:10:47 +08:00
xiongyuanwen-byted
3668b904ca Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-06-16 20:01:50 +08:00
xiongyuanwen-byted
1c68d31d12 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop
# Conflicts:
#	shortcuts/drive/drive_export.go
#	shortcuts/drive/drive_import.go
2026-06-16 19:52:50 +08:00
sang-neo03
ed7fdd1a27 feat: optimize event subscription precheck, links, and consumer guard (#1447)
* feat: add SubscriptionType and SingleConsumer to EventKey definition

* feat: fetch subscribed callbacks from application/get

* feat: build addons scan-to-enable deep link for event precheck

* feat: route callback precheck to application/get and emit scan links

* feat: add reject fields to hello_ack protocol message

* feat: add exclusive registration to event bus hub

* feat: reject duplicate consumer for SingleConsumer EventKey at bus handshake

* feat: surface bus consumer rejection as failed_precondition error

* fix: encode empty addons sides as [] not null per launcher contract

* fix: report missing callbacks when console has none subscribed

* feat: bound exclusive consumer cleanup wait with configurable timeout

* refactor: drain exclusive-wait timer and document websocket-only callbacks

* fix: use camelCase clientID param in event scan-to-enable link

* test: cover null/omitted callbacks and assert typed error category

* fix: keep auth login remediation for user-identity missing scopes

* refactor: simplify SubscriptionType normalization to match validateAuth style
2026-06-16 19:41:52 +08:00
xiongyuanwen-byted
4c51cd36fb docs(sheets): fix csv-get current_region guidance to cross-check row_count
current_region is a blank-row/column-bounded block, not the true sheet extent:
an internal blank row truncates it, so it can miss rows past the gap. The
read-data reference previously called it the "真实数据边界" and told agents to
prefer it over row_count — which drove the "read only to current_region's last
row, miss the tail" failure.

- current_region: warn it can be both smaller (internal blank rows truncate)
  and larger (trailing summary/signature rows) than the real data range.
- csv-get output contract: clarify its row_count/col_count is the returned size
  (= actual_range), not the physical sheet size; has_more only reflects the
  current range, not whether the whole sheet was read.
- "确定数据范围的正确流程": add a step to cross-check against +workbook-info's
  physical row_count and probe past current_region's last row for data beyond an
  internal blank row.
2026-06-16 18:48:00 +08:00
xiongyuanwen-byted
bbeae3636c fix(sheets): default +table-get to full used range, not A1 current region
+table-get without --range anchored its current_region probe at A1, so an
internal blank row or column silently truncated everything past it — agents
then treated the partial data as complete (the pro016 / pro025 incident).

- Probe the used range over the full physical grid (row_count × column_count
  from the workbook structure) so it spans internal blank rows/columns; fall
  back to the legacy A1 anchor when dimensions are unknown.
- Emit the actually-read `range` on every sheet so callers can detect
  truncation (get_cell_ranges has no has_more flag).
- Fix the same A1-anchor bug in append mode's last-data-row probe, which could
  otherwise overwrite data past an internal blank row.
- Add unit + dry-run/live E2E coverage; refresh synced skill docs.
2026-06-16 18:48:00 +08:00
wangweiming-01
4464ba7660 fix: validate drive import folder target (#1485)
Change-Id: I43755c3966b0daa06b708d2b3d03294f439547fa
2026-06-16 18:14:08 +08:00
zhicong666-bytedance
bb03c8ac4d feat(vc): support agent meeting event workflows (#1483)
* feat: support vc agent active meetings

* docs: clarify vc agent active meeting flow

* fix: align active meeting shortcut scope

* docs: clarify active meeting id fields

* fix: reject meeting numbers for vc events

* docs: clarify vc agent active meeting flow

* docs: refine vc agent meeting flow guidance

* docs: address vc agent skill review feedback

* docs: clarify vc meeting product wording

* docs: align vc agent skill with quality guidelines

* docs: trim vc agent skill token budget

* Revert "docs: trim vc agent skill token budget"

This reverts commit 8560bb9c19.
2026-06-16 18:08:07 +08:00
zhengzhijiej-tech
a9d88c5666 Merge pull request #1486 from larksuite/fix/cond-format-attrs-shape-validation
fix(sheets): reject cond-format attrs whose shape mismatches rule_type
2026-06-16 17:49:32 +08:00
zhengzhijie
4801675fd6 test(sheets): guard condFormatAttrsRequired against flag-schemas drift
Add TestCondFormatAttrsRequired_MatchesSchemaOneOf, comparing the
hand-maintained condFormatAttrsRequired table against the embedded
flag-schemas.json attrs oneOf (multiset of required-key sets, for both
create and update). The cross-field validator only holds if its
per-rule_type required keys mirror the schema branches, and the two
share no compile-time link — this pins them together so a future schema
sync that adds/drops a required key can't silently desync the table.
2026-06-16 17:45:11 +08:00
zhengzhijie
dd04b3705f fix(sheets): reject cond-format attrs whose shape mismatches rule_type
A conditional-format rule created with --rule-type colorScale but
cellIs-shaped attrs ({compare_type,value}, no color) was accepted by
the CLI and written through to the server, producing a color-less
color-scale segment. That dirty data crashes the frontend on snapshot
deserialization, so the spreadsheet can no longer be opened (5005).

The per-entry schema check can't catch this: properties.attrs.items is
a oneOf over all nine attr shapes and passes as soon as any branch
matches, blind to the sibling rule_type — {compare_type,value} matches
the cellIs branch even when rule_type says colorScale. The tool side
maps attrs blindly by rule_type and only validates dataBar count and
iconSet ordering, so the gap reaches the data layer.

Add a cross-field validator (validateCondFormatAttrs) wired into both
create and update via the new objectCRUDSpec.validateCreateInput hook
(twin of validateUpdateInput). It enforces, per rule_type, the keys
every attrs entry must carry — mirroring the tool's converter contract
— and treats an empty required string (notably color) as missing.
Rule types that take no attrs (duplicateValues / uniqueValues /
containsBlanks / notContainsBlanks) and updates that omit rule_type are
left to the server.
2026-06-16 17:23:58 +08:00
yballul-bytedance
3feb70b32a feat(drive): 支持导出 Base 结构快照 (#1481)
1. 为 drive +export 增加 --only-schema 参数,并透传 only_schema 到导出任务请求。
2. 限制该参数仅用于 bitable 导出 .base,并补充单测与 dry-run E2E 覆盖。

Change-Id: I736cebf5841cc1c6acaa8a3ab16be51ba4cb355d
2026-06-16 16:36:31 +08:00
ZEden0
64b1b3f3ed feat(docs): support lang for fetch v2 (#1459) 2026-06-16 16:25:36 +08:00
ZEden0
a0e83c7e59 feat(docs): add docx cover resource commands (#1468)
Spec source: active@bd186a6373948acc76d8b0872334b1a53ad40f5645b1a4e129937d7a51f5596c
2026-06-16 15:25:37 +08:00
zhengzhijie
439f184ba5 feat(sheets): document link requirement for @document mentions in cells flag schema
@document mentions (mention_type != 0) must pass link (doc URL) to render a
clickable card; @user mentions (mention_type=0) don't need it. Synced from the
upstream tools-schema.
2026-06-16 14:58:36 +08:00
xiongyuanwen-byted
825071fd7a docs(lark-sheets): point read-data to +sheet-info for hidden row/col identification
skip-hidden defaults to false (lossless reads), but the read primitives don't mark which rows/cols are hidden. Cross-reference +sheet-info --include hidden_rows,hidden_cols + row_indices/col_indices so agents can identify hidden ranges when they need to filter or interpret hidden data.

Synced from sheet-skill-spec.
2026-06-16 14:25:19 +08:00
xiongyuanwen-byted
72999cd303 feat(sheets): add --styles to +table-put for one-step typed write with styling
+table-put now accepts --styles (same shape as +workbook-create's --styles):
cell_styles merge into the set_cell_range matrix, while cell_merges /
row_sizes / col_sizes apply as their own tool calls after the write. The
styles payload is name-matched against the written sheets and validated up
front, so a malformed or mismatched style fails before any write lands.

Also points +sheet-create users to +table-put (auto-creates missing sheets)
when they need data/styles, via a runtime Tip and the lark-sheets skill
references. Flag is sourced from the upstream Base table and regenerated
through sheet-skill-spec (flag-defs.json / flag-schemas.json / gen file).

Adds unit tests (dry-run styles, name-mismatch reject, execute) and a
dry-run E2E (tests/cli_e2e/sheets/sheets_table_put_dryrun_test.go).
2026-06-16 12:56:59 +08:00
xiongyuanwen-byted
f9c73e217d docs(lark-sheets): clarify cell-image vs float-image routing and fix reference self-references
Synced from sheet-skill-spec.

- Add a binding-based decision (does the image belong to a record and move with its row?) to route +cells-set-image vs +float-image-create across the SKILL entry, float-image and write-cells references.
- Add routing rows to the SKILL command cheat-sheet and warn against defaulting to float-image out of familiarity.
- Replace mislabeled 本 skill / 子 skill / 跨 skill wording in references with 本文 / reference names, matching the existing convention.
2026-06-16 10:55:23 +08:00
liangshuo-1
297b2a222e chore(release): v1.0.54 (#1476) 2026-06-15 21:58:07 +08:00
Zhang-986
80a5f30f4d fix(event): clarify remote bus blocker recovery (#1454) 2026-06-15 20:27:59 +08:00
xzcong0820
cf35d1e499 feat(mail): auto-attach default signature on send/reply/forward (#1415)
* feat(mail): auto-attach default signature on send/reply/forward

- Add exported PlainTextFromHTML wrapper in draft/htmltext.go
- Add DefaultSendID/DefaultReplyID in signature/provider.go
- Add noSignatureFlag, autoResolveSignatureID, validateNoSignatureConflict,
  injectPlainTextSignature in signature_compose.go; remove validateSignatureWithPlainText
- mail_send, mail_draft_create: add --no-signature flag, auto-resolve default
  signature when no --signature-id given, inject plain-text sig in plain-text branch
- mail_reply, mail_reply_all, mail_forward: same flag/validate changes + timing fix
  (resolveSignature moved to after senderEmail is finalized)
- Update 5 reference docs: add --no-signature row, update --plain-text and
  --signature-id descriptions

---------

Co-authored-by: xzcong0820 <278082089+xzcong0820@users.noreply.github.com>
2026-06-15 20:04:05 +08:00
fangshuyu-768
fd16cf106b clarify lark-doc create title guidance (#1474) 2026-06-15 19:38:56 +08:00
1uckypeach
53076733ec docs(skills): add rename prompt for import without --name (#1461)
When --name is omitted, remind user that the title defaults to the source
filename and may duplicate content headings, causing visual redundancy.
Ask whether to rename before executing the import.
2026-06-15 19:30:51 +08:00
陈家名
a3bee13ca9 fix(vfs): reject blank local paths (#1460) 2026-06-15 19:14:31 +08:00
xiongyuanwen-byted
5f3c1c8e6a docs(lark-sheets): remove financial modeling standards reference
Drop the lark-sheets-financial-modeling-standards.md reference doc and all
pointers to it from SKILL.md, core-operations, and visual-standards. Bump
skill version to 3.0.0.
2026-06-15 18:46:34 +08:00
fangshuyu-768
6217bd2c29 fix docs fetch and update ergonomics (#1466) 2026-06-15 17:47:34 +08:00
search_zhuhao
72c294712c feat: 【larksuite/cli】【drive 搜索支持 original_creator_ids】 M-7074213537 (#1046)
sa: none

fg: none

cfg: none

doc: none

test: ppe
Change-Id: I88bedd02a5daa3307b05c9b6f94748e1544d279a
2026-06-15 14:18:45 +08:00
sammi-bytedance
37f4f899b2 docs(lark-im): document @mention format per message type (text/post/card) (#1419)
Split the send/reply @Mention sections by message type:
- text: <at user_id="ou_xxx">name</at> (inner name optional), @all
- post: inline form in text/md elements, or a dedicated {"tag":"at"} node
- interactive card: card-native <at id=>, <at ids=>, <at email=>
2026-06-15 14:08:50 +08:00
zhengzhijiej-tech
ead8aa854f Merge pull request #1439 from larksuite/fix/sheet-mention-type-enum
fix(sheets): add mention_type enum to set_cell_range cells schema
2026-06-15 11:50:35 +08:00
xiongyuanwen-byted
833b7cde33 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop
# Conflicts:
#	shortcuts/sheets/lark_sheet_workbook.go
#	shortcuts/sheets/lark_sheet_workbook_test.go
2026-06-15 11:26:15 +08:00
xiongyuanwen-byted
57d71607e1 feat(sheets): add --dataframe Arrow IPC input for +table-put/+table-get/+workbook-create
Introduce a binary-typed twin of --sheets: --dataframe accepts an Arrow IPC
(Feather v2) payload that pandas' df.to_feather() writes, deriving dtypes and
per-column number formats from the Arrow schema. The two producers are mutually
exclusive and funnel through a shared resolver so +table-put and
+workbook-create stay in lockstep; +table-get gains --dataframe-out for
single-sheet reads. Also auto-grow a sub-sheet's row/column count before
writing so blocks past the backend's default 200x20 bounds no longer fail with
range-exceeds-sheet-bounds.
2026-06-14 22:40:39 +08:00
AlbertSun
c0730b46bf feat: simplify proxy plugin warning and gate on tty (#1448) 2026-06-13 20:32:16 +08:00
hhang
751092c8ef fix(vfs): reject Windows absolute paths cross-platform (#1401)
* fix(vfs): reject Windows absolute paths cross-platform

* test(vfs): cover input Windows absolute paths
2026-06-13 18:56:13 +08:00
liangshuo-1
deb0bd9dd6 refactor: converge command pipelines onto a typed metadata model + catalog (#1191) 2026-06-13 18:02:50 +08:00
raistlin042
0fbfe68726 docs: drop Miaoda brand word from apps command help text (#1399) 2026-06-13 14:00:30 +08:00
xiongyuanwen-byted
d2c326a78c feat(sheets): implement pandas-split --sheets protocol for +table-put/+table-get/+workbook-create
Synced from sheet-skill-spec canonical (cli:table_put schema +
references). +table-put/+workbook-create accept the new shape via a
tableSheetIn -> tableSheetSpec normalize step (dtype string -> internal
type/format mapping). +table-get emits the same shape so the writer's
df_to_sheet and the reader's sheet_to_df round-trip cleanly.

isoDateToSerial now accepts the full ISO datetime form
(2024-01-15T00:00:00.000, including timezone suffixes) emitted by
df.to_json(date_format="iso"), not just yyyy-mm-dd. End-to-end verified
by the spec repo's contracts/python_helper_roundtrip script against a
real Lark spreadsheet on pandas 2.2 and 3.0.
2026-06-12 17:32:08 +08:00
zhengzhijie
422797305a fix(sheets): add mention_type enum to set_cell_range cells schema
Constrain rich_text mention_type to the proto MENTION_FILE_TYPE set so a
file @mention with an out-of-enum value (e.g. 6 = cloud shared folder) is
rejected by the schema validator before it reaches the server and fails
pb serialization ("mentionFileInfo.fileType: enum value expected").

- data/flag-schemas.json: mention_type gains enum + per-value description
- lark_sheet_write_cells_test.go: cover reject (6) + allow (0 / 2 / 22)
2026-06-12 16:53:40 +08:00
xiongyuanwen-byted
3fa28c10fa Merge remote-tracking branch 'origin/feat/lark-sheets-develop' into feat/lark-sheets-develop 2026-06-12 12:03:00 +08:00
xiongyuanwen-byted
27d185c91c feat(sheets): rework +workbook-create flags and --styles
- --values builds a type-less typed payload, writing through --sheets' batched set_cell_range path (raw passthrough preserves auto-detect; large tables batch; big ints via json.Number)
- drop --headers (subsumed by --values first row) and --header-style (typed header no longer auto-bold; use --styles instead)
- styles: deep-merge overlapping cell_styles/border_styles fields (was wholesale-replace which dropped fields); add manual border_styles validation (style/weight enums + sides) since --styles is on parseJSONFlagSkip and bypasses the schema validator
- regenerate flag-defs/flag-schemas/skills mirror from sheet-skill-spec (--styles flag + full per-side border schema)
2026-06-12 12:02:32 +08:00
zhengzhijiej-tech
83926943ae Merge pull request #1397 from larksuite/fix-chart-aggregate-counta-zzj
feat(sheets): add counta to chart aggregateType enum
2026-06-11 19:11:36 +08:00
zhengzhijie
752bfcbbb9 feat(sheets): make --target-position and --range mutually exclusive on +pivot-create
Both flags map to the same wire field (properties.range), so passing
non-default values for both is ambiguous. Mirror the
--target-sheet-id / --target-sheet-name mutex pattern: --target-position
takes priority over --range, and supplying both with non-default values
is rejected up front with a typed FlagErrorf. --target-position=A1 is
the documented default and is treated as "not set".

Add a symmetric validateCreateInput hook on objectCRUDSpec (alongside
the existing validateUpdateInput), wire it into objectCreateInput, and
inject the pivot-specific check on pivotSpec.
2026-06-11 16:45:28 +08:00
zhengzhijie
80d9f6b59b feat(sheets): add counta to chart aggregateType enum
Add `counta` (count non-empty cells, incl. text) to manage_chart_object
dim2.series[].aggregateType in the chart flag schema. `count` only counts
numeric cells, so counting occurrences of a text/category column renders an
empty chart; `counta` enables category frequency counts. Synced from the
sheet-skill-spec canonical schema.
2026-06-11 14:32:03 +08:00
xiongyuanwen-byted
080ef44cdb Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-06-09 19:52:08 +08:00
xiongyuanwen-byted
f046fb6282 fix(sheets): regenerate flag defs and fix asasalint in table io 2026-06-09 17:48:58 +08:00
xiongyuanwen-byted
ca9eddb142 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-06-09 17:29:26 +08:00
zhengzhijiej-tech
1caeb2d377 Merge pull request #1351 from larksuite/fix/chart-dim-insert-example
docs(sheets): chart / filter / workbook reference corrections
2026-06-09 16:47:31 +08:00
zhengzhijie
a66bef66af docs(sheets): label +sheet-create --index as 0-based
The base flag description for +sheet-create's --index omitted the
coordinate base, while its siblings +sheet-move ("Target position
(0-based)") and +sheet-copy already state 0-based. Align the description
so the index base is unambiguous. Synced from the spec source
(flag-defs.json + workbook reference).
2026-06-09 16:25:02 +08:00
zhengzhijie
421805d35c docs(sheets): chart coordinate base / quoting + filter condition enums
Sync three reference-doc corrections from the spec source:

1. chart: label position.row as 0-based (first row = row:0), distinct
   from the 1-based row numbers used by A1 ranges and +dim-insert
   --position, removing the row-base ambiguity.

2. chart: convert the three runnable examples whose JSON contains a
   quoted sheet prefix ('Sheet1'!A1) from inline single-quoted
   --properties '{...}' to a stdin heredoc (--properties - <<'JSON').
   Inside an inline single-quoted string bash strips the inner quotes
   around the sheet name (and splits names with spaces into words),
   corrupting the JSON; a quoted heredoc delimiter performs no shell
   substitution and preserves it. Adds a short note on the pitfall.

3. filter / filter-view: add the full conditions[].type x compare_type
   enum table (text / number / multiValue / color and their respective
   compare_type values and values shape), and call out the
   equals/notEquals (with s) vs equal/notEqual (no s) gotcha. The docs
   previously only showed two values via examples.
2026-06-09 16:25:02 +08:00
zhengzhijie
8d5bb73c70 docs(sheets): fix invalid +dim-insert example in chart reference
The chart reference's placement example used non-existent flags
--dimension/--start/--end for +dim-insert. The real signature is
--position (required) + --count (required); copying the example
fails Validate with "--position is required". Replace it with
+dim-insert --position V --count 6 (insert 6 columns before V,
i.e. after U), aligning with the sheet-structure reference.
2026-06-09 15:34:05 +08:00
xiongyuanwen-byted
97b9ffb466 docs(sheets): align +csv-put help with formula support
Sync the formula-support wording from sheet-skill-spec (flag-defs, skill
references) and update the hand-authored cobra Description and comment for
+csv-put. +csv-put evaluates a leading-= cell as a formula via
set_range_from_csv; descriptions only, no behavior change.
2026-06-08 20:38:10 +08:00
zhengzhijiej-tech
336f147ca6 Merge pull request #1296 from larksuite/feat/sheet-eval-guidance-fixes
docs(sheets): strengthen lark-sheets references for common editing pitfalls
2026-06-08 19:13:29 +08:00
zhengzhijie
0a47f35c7d docs(sheets): align write-cells reference with the generated output
Bring the hand-applied write-cells example in line with the spec-generated
reference so the CLI mirror is byte-identical to the canonical source.
2026-06-08 19:07:44 +08:00
Chenweifeng-bd
72ac526e23 docs: add lark sheets financial modeling guidance 2026-06-08 17:05:11 +08:00
zhengzhijie
023a8786f0 docs(sheets): reword guidance to avoid eval-specific phrasing
Replace scoring-framework wording in the examples with plain functional
consequences (e.g. "not delivered", "goes stale when the source changes",
"breaks the original visual format"), so the references stay agent-facing.
2026-06-08 15:44:35 +08:00
zhengzhijie
3ecd75b53d docs(sheets): keep original column widths; align chart axis with requested metric
- range-operations: only widen new / overflowing columns; never recompute or
  shrink the widths of existing columns (any blanket resize, even by 1px,
  breaks the original visual format)
- chart: when the user asks for a share / percentage, the value axis should be
  a percentage (pie, or stack.percentage on bar/column) rather than raw counts
2026-06-08 14:38:00 +08:00
xiongyuanwen-byted
5bf71428a4 refactor(sheets): reuse the drive export core in +workbook-export
Replace +workbook-export's parallel export-task implementation with the shared drive ExportParams/RunExport core (pinned to type=sheet). Drops ~90 lines of duplicated poll/download code; +workbook-export now inherits drive's ctx cancellation, resume-on-timeout, filename sanitize/overwrite, and the full set of export status labels. The output contract aligns with drive's (adds ready/downloaded/doc_type; saved_path preserved). Also normalize an empty drive --output-dir to "." so drive +export behavior is unchanged, and fix the sheets export e2e to call +workbook-export instead of a nonexistent +export.
2026-06-08 12:58:11 +08:00
xiongyuanwen-byted
e819e819fe feat(sheets): add +workbook-import wrapping the drive import core
Import a local xlsx/xls/csv as a new spreadsheet by delegating to the shared drive import flow with the target type pinned to sheet. Refactor drive +import to expose ImportParams / ValidateImport / PlanImportDryRun / RunImport (behavior unchanged, existing drive tests still cover it); sheets reuses them. Regenerate flag_defs_gen.go and sync the spec mirror.
2026-06-08 11:00:46 +08:00
xiongyuanwen-byted
2017e9dab8 docs(sheets): sync SKILL.md (drop "Feishu sheets only" caveat)
Mirror the upstream sheet-skill-spec change removing the "applies to Feishu sheets only" tail from the 14 sheet reference descriptions.
2026-06-07 22:45:53 +08:00
xiongyuanwen-byted
74a02e6f2d docs(sheets): sync SKILL.md (drop "not for local Excel" caveat)
Mirror the upstream sheet-skill-spec change removing the "not applicable to local Excel files" tail from the sheets skill and reference descriptions.
2026-06-07 22:39:58 +08:00
xiongyuanwen-byted
02f4f73227 docs(sheets): surface typed-write path at the write-decision point
Quick-ref table (SKILL.md, the first decision point) had no +table-put and
gated typed writes on "DataFrame", so a model holding a Counter/list/dict
would fall back to +csv-put and silently lose number/date fidelity.

- split csv-put row to plain-text values (no numeric/date semantics)
- add +table-put row for typed writes into an existing sheet
- add +workbook-create --sheets row for create + typed write in one shot
- add judgment note: number/amount/date/percent/count -> +table-put
  (or +workbook-create --sheets when the workbook does not exist yet);
  plain text -> +csv-put
- reframe write-cells scenario row to lead with numeric semantics
- point new-table writes at +workbook-create --sheets (one shot) instead
  of the create-empty-then-table-put two-step

Synced from sheet-skill-spec canonical (generate:cli + sync:cli).
2026-06-07 00:30:13 +08:00
xiongyuanwen-byted
a2625d036d feat(sheets): implement table-put/table-get and sync skill specs
- Add lark_sheet_table_io.go with +table-put / +table-get and tests
- Refactor read-data; extend workbook; register new shortcuts
- Sync generated flag defs/schemas (go:embed) from sheet-skill-spec
- Sync skill references (write-cells numeric-column guidance, plus
  read-data / workbook / chart updates)
2026-06-05 20:03:33 +08:00
zhengzhijie
d005694e0f docs(sheets): strengthen lark-sheets references for common editing pitfalls
Add targeted guidance to six lark-sheets references to reduce frequent
mistakes when editing spreadsheets through the CLI:

- write-cells: sanity-check units / dimension conversion / quantity factors
  before formula writes (formulas can run clean yet be off by a factor);
  keep derived output off original data columns to avoid clobbering source
- core-operations: prefer live formulas for derived values even when "live
  update" is not explicitly requested; scope rewrite/transform precisely so
  rows/columns that should stay unchanged are kept 1:1; treat header-stated
  format rules as checklist items; confirm the artifact file actually exists
  before finishing; write back bare values from local scripts
- visual-standards: apply border/header formatting on explicit request and
  identify the real header row; keep font size consistent with the source
- range-operations: keep total column width within A4 for printing
- read-data: dedup/compare long numbers via raw values, not csv formatted
  display (scientific notation collapses distinct numbers and causes false
  duplicates)
- chart: format date/number axes via source-cell number_format; place charts
  outside the data area so they do not cover existing data
2026-06-05 19:20:25 +08:00
zhengzhijiej-tech
3149c77134 Merge pull request #1264 from zhengzhijiej-tech/feat/sheet-gridline
feat(sheets): add gridline show/hide shortcuts
2026-06-04 19:12:41 +08:00
zhengzhijie
6e067f2180 feat(sheets): add +sheet-show-gridline / +sheet-hide-gridline shortcuts 2026-06-04 17:00:07 +08:00
577 changed files with 53540 additions and 12037 deletions

View File

@@ -10,8 +10,6 @@ on:
permissions:
contents: read
actions: read
checks: write
pull-requests: write
jobs:
# ── Layer 1: Fast Gate ─────────────────────────────────────────────
@@ -80,10 +78,47 @@ jobs:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Resolve changed-from baseline
env:
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
- name: Run golangci-lint
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev="$QUALITY_GATE_CHANGED_FROM"
- name: Run errs/ lint guards (lintcheck)
run: go run -C lint . ..
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
deterministic-gate:
needs: fast-gate
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Resolve changed-from baseline
env:
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
- name: Run CLI deterministic gate
run: make quality-gate
- name: Upload quality gate facts
if: ${{ always() && github.event_name == 'pull_request' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: quality-gate-facts-${{ github.event.pull_request.base.sha }}-${{ github.event.pull_request.head.sha }}
path: .tmp/quality-gate/facts.json
if-no-files-found: error
retention-days: 7
coverage:
needs: fast-gate
@@ -103,6 +138,7 @@ jobs:
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
- name: Upload coverage to Codecov
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
with:
files: coverage.txt
@@ -184,7 +220,7 @@ jobs:
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
e2e-dry-run:
needs: [unit-test, lint]
needs: [unit-test, lint, deterministic-gate]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
@@ -205,9 +241,12 @@ jobs:
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
e2e-live:
needs: [unit-test, lint]
needs: [unit-test, lint, deterministic-gate]
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
permissions:
contents: read
checks: write
env:
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
@@ -254,6 +293,9 @@ jobs:
# ── Layer 4: Security & Compliance (parallel with L2-L3) ──────────
security:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
@@ -291,7 +333,7 @@ jobs:
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
needs: [fast-gate, unit-test, lint, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
@@ -303,6 +345,7 @@ jobs:
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deterministic-gate | ${{ needs.deterministic-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L3 | e2e-dry-run | ${{ needs.e2e-dry-run.result }} |" >> $GITHUB_STEP_SUMMARY
@@ -318,6 +361,7 @@ jobs:
"${{ needs.fast-gate.result }}" \
"${{ needs.unit-test.result }}" \
"${{ needs.lint.result }}" \
"${{ needs.deterministic-gate.result }}" \
"${{ needs.coverage.result }}" \
"${{ needs.deadcode.result }}" \
"${{ needs.e2e-dry-run.result }}" \

560
.github/workflows/semantic-review.yml vendored Normal file
View File

@@ -0,0 +1,560 @@
name: Semantic Review
on:
workflow_run:
workflows: ["CI"]
types: [completed]
permissions:
actions: read
contents: read
jobs:
pr-quality-summary:
if: github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
issues: write
pull-requests: write
steps:
- name: Verify workflow run and pull request for summary
id: pr
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
if (typeof run.head_sha !== "string" || run.head_sha.length !== 40) throw new Error("invalid head sha");
const runPRs = Array.isArray(run.pull_requests) ? run.pull_requests : [];
if (runPRs.length > 1) {
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
}
let prNumber = Number(runPRs[0]?.number || 0);
let eventBaseSha = runPRs[0]?.base?.sha || "";
const eventHeadSha = runPRs[0]?.head?.sha || "";
const targetHeadSha = eventHeadSha || run.head_sha;
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
per_page: 100,
});
const factsArtifacts = artifactData.artifacts.filter((artifact) => factsArtifactPattern.test(artifact.name));
let factsArtifactName = "";
let artifactBaseSha = "";
let artifactError = "";
if (factsArtifacts.length !== 1) {
artifactError = `expected exactly one base-bound quality gate facts artifact, got ${factsArtifacts.length}`;
} else {
factsArtifactName = factsArtifacts[0].name;
const [, parsedBaseSha, artifactHeadSha] = factsArtifactName.match(factsArtifactPattern);
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
artifactError = "facts artifact head sha does not match verified PR head sha";
factsArtifactName = "";
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
factsArtifactName = "";
} else {
artifactBaseSha = parsedBaseSha;
}
}
if (!prNumber) {
const { data: associatedPRs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: targetHeadSha,
});
const candidatePRs = associatedPRs.filter((candidate) =>
candidate.state === "open" &&
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
);
if (candidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
}
if (candidatePRs.length === 1) {
prNumber = candidatePRs[0].number;
}
}
if (!prNumber) {
const candidatePRs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
if (candidatePRs.length !== 1) {
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
}
prNumber = candidatePRs[0].number;
}
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
if (pr.head.sha !== targetHeadSha) {
core.notice("PR quality summary skipped: workflow_run is stale for this PR head");
core.setOutput("stale", "true");
return;
}
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
core.notice("PR quality summary skipped: workflow_run is stale for this PR base");
core.setOutput("stale", "true");
return;
}
if (artifactError) {
core.warning(`quality gate facts artifact binding is unavailable: ${artifactError}`);
}
core.setOutput("pr_number", String(prNumber));
core.setOutput("head_sha", targetHeadSha);
core.setOutput("base_sha", baseSha);
core.setOutput("run_id", String(run.id));
core.setOutput("facts_artifact_name", factsArtifactName);
core.setOutput("artifact_error", artifactError);
core.setOutput("stale", "false");
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
id: checkout
if: ${{ steps.pr.outputs.stale != 'true' }}
with:
ref: ${{ steps.pr.outputs.base_sha }}
persist-credentials: false
- name: Verify summary facts artifact metadata
id: artifact
if: ${{ steps.pr.outputs.stale != 'true' && steps.pr.outputs.facts_artifact_name != '' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
const factsArtifactName = "${{ steps.pr.outputs.facts_artifact_name }}";
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
per_page: 100,
});
const artifacts = data.artifacts.filter(a => a.name === factsArtifactName);
if (artifacts.length !== 1) throw new Error(`expected exactly one quality-gate-facts artifact, got ${artifacts.length}`);
const artifact = artifacts[0];
if (artifact.expired) throw new Error("quality-gate-facts artifact expired");
if (artifact.size_in_bytes <= 0 || artifact.size_in_bytes > 5 * 1024 * 1024) {
throw new Error(`invalid artifact size: ${artifact.size_in_bytes}`);
}
if (!artifact.digest) throw new Error("facts artifact digest is missing from GitHub API response");
core.setOutput("artifact_id", String(artifact.id));
core.setOutput("artifact_digest", artifact.digest);
- name: Download facts artifact zip
if: ${{ steps.pr.outputs.stale != 'true' && steps.artifact.outputs.artifact_id != '' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
id: download
with:
script: |
const fs = require("fs");
const path = require("path");
const artifactId = Number("${{ steps.artifact.outputs.artifact_id }}");
if (!Number.isInteger(artifactId) || artifactId <= 0) throw new Error("invalid artifact id");
const { data } = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifactId,
archive_format: "zip",
});
const zipPath = path.join(process.env.RUNNER_TEMP, "quality-gate-facts.zip");
fs.writeFileSync(zipPath, Buffer.from(data));
core.setOutput("zip_path", zipPath);
- name: Verify and extract summary facts artifact
if: ${{ steps.pr.outputs.stale != 'true' && steps.download.outputs.zip_path != '' }}
env:
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
SEMANTIC_REVIEW_DECISION_OUT: decision.json
SEMANTIC_REVIEW_MARKDOWN_OUT: semantic-review.md
run: node scripts/semantic-review-verify-artifact.js '${{ steps.download.outputs.zip_path }}' facts.json '${{ steps.artifact.outputs.artifact_digest }}'
- name: Publish PR quality summary
if: ${{ always() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome == 'success' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
CI_QUALITY_SUMMARY_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
CI_QUALITY_SUMMARY_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
CI_QUALITY_SUMMARY_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
CI_QUALITY_SUMMARY_RUN_ID: ${{ steps.pr.outputs.run_id }}
CI_QUALITY_SUMMARY_ARTIFACT_ERROR: ${{ steps.pr.outputs.artifact_error }}
with:
script: |
const { publish } = require("./scripts/ci-quality-summary-publish.js");
await publish({ github, context, core });
semantic-review:
needs: pr-quality-summary
if: always() && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
permissions:
actions: read
checks: write
contents: read
issues: write
pull-requests: write
steps:
- name: Verify workflow run and pull request
id: pr
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
if (typeof run.head_sha !== "string" || run.head_sha.length !== 40) throw new Error("invalid head sha");
const runPRs = Array.isArray(run.pull_requests) ? run.pull_requests : [];
if (runPRs.length > 1) {
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
}
let prNumber = Number(runPRs[0]?.number || 0);
let eventBaseSha = runPRs[0]?.base?.sha || "";
const eventHeadSha = runPRs[0]?.head?.sha || "";
const targetHeadSha = eventHeadSha || run.head_sha;
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
per_page: 100,
});
const factsArtifacts = artifactData.artifacts.filter((artifact) => factsArtifactPattern.test(artifact.name));
let factsArtifactName = "";
let artifactBaseSha = "";
let artifactError = "";
if (factsArtifacts.length !== 1) {
artifactError = `expected exactly one base-bound quality gate facts artifact, got ${factsArtifacts.length}`;
} else {
factsArtifactName = factsArtifacts[0].name;
const [, parsedBaseSha, artifactHeadSha] = factsArtifactName.match(factsArtifactPattern);
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
artifactError = "facts artifact head sha does not match verified PR head sha";
factsArtifactName = "";
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
factsArtifactName = "";
} else {
artifactBaseSha = parsedBaseSha;
}
}
if (!prNumber) {
const { data: associatedPRs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: targetHeadSha,
});
const candidatePRs = associatedPRs.filter((candidate) =>
candidate.state === "open" &&
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
);
if (candidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
}
if (candidatePRs.length === 1) {
prNumber = candidatePRs[0].number;
}
}
if (!prNumber) {
const candidatePRs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
if (candidatePRs.length !== 1) {
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
}
prNumber = candidatePRs[0].number;
}
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
if (pr.head.sha !== targetHeadSha) {
core.notice("semantic review skipped: workflow_run is stale for this PR head");
core.setOutput("stale", "true");
return;
}
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
core.notice("semantic review skipped: workflow_run is stale for this PR base");
core.setOutput("stale", "true");
return;
}
if (artifactError) {
core.warning(`semantic review facts artifact binding is unavailable: ${artifactError}`);
}
core.setOutput("pr_number", String(prNumber));
core.setOutput("head_sha", targetHeadSha);
core.setOutput("base_sha", baseSha);
core.setOutput("head_owner", pr.head.repo.owner.login);
core.setOutput("head_repo", pr.head.repo.name);
core.setOutput("head_repo_id", String(pr.head.repo.id));
core.setOutput("head_is_base_repo", pr.head.repo.id === context.payload.repository.id ? "true" : "false");
core.setOutput("run_id", String(run.id));
core.setOutput("facts_artifact_name", factsArtifactName);
core.setOutput("artifact_error", artifactError);
core.setOutput("stale", "false");
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
id: checkout
if: ${{ steps.pr.outputs.stale != 'true' }}
with:
ref: ${{ steps.pr.outputs.base_sha }}
persist-credentials: false
- name: Publish pre-checkout semantic review failure
if: ${{ failure() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome != 'success' && steps.pr.outputs.head_sha != '' && steps.pr.outputs.pr_number != '' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
SEMANTIC_REVIEW_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
SEMANTIC_REVIEW_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
SEMANTIC_REVIEW_RUN_ID: ${{ steps.pr.outputs.run_id }}
with:
script: |
const runtimeBlockMode = process.env.SEMANTIC_REVIEW_BLOCK === "true";
const pr = Number(process.env.SEMANTIC_REVIEW_PR_NUMBER || 0);
const headSha = process.env.SEMANTIC_REVIEW_HEAD_SHA || "";
const baseSha = process.env.SEMANTIC_REVIEW_BASE_SHA || "";
if (!Number.isInteger(pr) || pr <= 0 || !/^[a-f0-9]{40}$/i.test(headSha) || !/^[a-f0-9]{40}$/i.test(baseSha)) {
throw new Error("missing verified semantic review target");
}
const { data: pull } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr,
});
if (pull.head.sha !== headSha) {
core.notice("semantic review skipped infrastructure failure check: PR head changed");
return;
}
if (pull.base.sha !== baseSha) {
core.notice("semantic review skipped infrastructure failure check: PR base changed");
return;
}
if (pull.base.repo.id !== context.payload.repository.id) {
throw new Error("PR base repo mismatch before infrastructure failure check");
}
await github.rest.checks.create({
owner: context.repo.owner,
repo: context.repo.repo,
name: runtimeBlockMode ? "semantic-review/result" : "semantic-review/observe",
head_sha: headSha,
status: "completed",
conclusion: runtimeBlockMode ? "failure" : "neutral",
output: {
title: "Semantic review infrastructure failure",
summary: "Semantic review could not checkout the verified base commit. Inspect the workflow logs before relying on semantic review output.",
},
});
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
if: ${{ steps.pr.outputs.stale != 'true' }}
with:
go-version-file: go.mod
- name: Verify semantic facts artifact metadata
id: artifact
if: ${{ steps.pr.outputs.stale != 'true' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
const factsArtifactName = "${{ steps.pr.outputs.facts_artifact_name }}";
if (!/^quality-gate-facts-[a-f0-9]{40}-[a-f0-9]{40}$/i.test(factsArtifactName)) {
throw new Error("missing verified facts artifact binding");
}
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
per_page: 100,
});
const artifacts = data.artifacts.filter(a => a.name === factsArtifactName);
if (artifacts.length !== 1) throw new Error(`expected exactly one quality-gate-facts artifact, got ${artifacts.length}`);
const artifact = artifacts[0];
if (artifact.expired) throw new Error("quality-gate-facts artifact expired");
if (artifact.size_in_bytes <= 0 || artifact.size_in_bytes > 5 * 1024 * 1024) {
throw new Error(`invalid artifact size: ${artifact.size_in_bytes}`);
}
if (!artifact.digest) throw new Error("facts artifact digest is missing from GitHub API response");
core.setOutput("artifact_id", String(artifact.id));
core.setOutput("artifact_digest", artifact.digest);
- name: Download facts artifact zip
if: ${{ steps.pr.outputs.stale != 'true' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
id: download
with:
script: |
const fs = require("fs");
const path = require("path");
const artifactId = Number("${{ steps.artifact.outputs.artifact_id }}");
if (!Number.isInteger(artifactId) || artifactId <= 0) throw new Error("invalid artifact id");
const { data } = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifactId,
archive_format: "zip",
});
const zipPath = path.join(process.env.RUNNER_TEMP, "quality-gate-facts.zip");
fs.writeFileSync(zipPath, Buffer.from(data));
core.setOutput("zip_path", zipPath);
- name: Verify and extract semantic facts artifact
if: ${{ steps.pr.outputs.stale != 'true' }}
env:
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
SEMANTIC_REVIEW_DECISION_OUT: decision.json
SEMANTIC_REVIEW_MARKDOWN_OUT: semantic-review.md
run: node scripts/semantic-review-verify-artifact.js '${{ steps.download.outputs.zip_path }}' facts.json '${{ steps.artifact.outputs.artifact_digest }}'
- name: Download PR semantic waiver config
id: waiver_config
if: ${{ steps.pr.outputs.stale != 'true' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
SEMANTIC_REVIEW_HEAD_OWNER: ${{ steps.pr.outputs.head_owner }}
SEMANTIC_REVIEW_HEAD_REPO: ${{ steps.pr.outputs.head_repo }}
SEMANTIC_REVIEW_HEAD_IS_BASE_REPO: ${{ steps.pr.outputs.head_is_base_repo }}
with:
script: |
const fs = require("fs");
const path = require("path");
const headSha = process.env.SEMANTIC_REVIEW_HEAD_SHA || "";
if (!/^[a-f0-9]{40}$/i.test(headSha)) {
throw new Error("missing verified semantic review target");
}
const headOwner = process.env.SEMANTIC_REVIEW_HEAD_OWNER || "";
const headRepo = process.env.SEMANTIC_REVIEW_HEAD_REPO || "";
if (!headOwner || !headRepo) {
throw new Error("missing verified semantic review head repository");
}
const waiverPath = "internal/qualitygate/config/semantic/waivers.txt";
const outPath = path.join(process.env.RUNNER_TEMP, "semantic-review-waivers.txt");
const headIsBaseRepo = process.env.SEMANTIC_REVIEW_HEAD_IS_BASE_REPO === "true";
if (!headIsBaseRepo) {
core.notice("fork PR semantic waiver config is ignored");
core.setOutput("path", "");
return;
}
let content = "";
try {
const { data } = await github.rest.repos.getContent({
owner: headOwner,
repo: headRepo,
path: waiverPath,
ref: headSha,
});
if (Array.isArray(data) || data.type !== "file" || data.encoding !== "base64") {
throw new Error(`${waiverPath} is not a base64 file at PR head`);
}
if (data.size > 256 * 1024) {
throw new Error(`${waiverPath} is too large: ${data.size} bytes`);
}
content = Buffer.from(data.content, "base64").toString("utf8");
} catch (err) {
if (err.status !== 404) {
throw err;
}
}
fs.writeFileSync(outPath, content);
core.setOutput("path", outPath);
- name: Run semantic review
id: semantic
if: ${{ steps.pr.outputs.stale != 'true' }}
env:
ARK_API_KEY: ${{ secrets.ARK_API_KEY }}
ARK_BASE_URL: ${{ vars.ARK_BASE_URL }}
ARK_MODEL: ${{ vars.ARK_MODEL }}
ARK_TIMEOUT_SECONDS: ${{ vars.ARK_TIMEOUT_SECONDS }}
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
run: |
args=(
--repo .
--facts facts.json
--decision-out decision.json
--markdown-out semantic-review.md
)
if [ -n "${{ steps.waiver_config.outputs.path }}" ]; then
args+=(--waivers-file '${{ steps.waiver_config.outputs.path }}')
fi
if [ "$SEMANTIC_REVIEW_BLOCK" = "true" ]; then
args+=(--block)
fi
go run ./internal/qualitygate/cmd/semantic-review "${args[@]}"
- name: Publish semantic review
if: ${{ always() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome == 'success' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
SEMANTIC_REVIEW_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
SEMANTIC_REVIEW_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
SEMANTIC_REVIEW_RUN_ID: ${{ steps.pr.outputs.run_id }}
with:
script: |
const { publish } = require("./scripts/semantic-review-publish.js");
await publish({ github, context, core });

View File

@@ -29,11 +29,11 @@ linters:
- unused # checks for unused constants, variables, functions and types
- depguard # blocks forbidden package imports
- forbidigo # forbids specific function calls
- errorlint # enforces error wrapping (%w) and errors.Is/As over == and type asserts
# To enable later after fixing existing issues:
# - errcheck # checks for unchecked errors
# - errname # checks that error types are named XxxError
# - errorlint # checks error wrapping best practices
# - gosec # security-oriented linter
# - misspell # finds commonly misspelled English words
# - staticcheck # comprehensive static analysis
@@ -49,9 +49,16 @@ linters:
- gocritic
- depguard
- forbidigo
# Paths that run forbidigo. Add an entry when a path joins one of
# the rules below.
- errorlint # tests legitimately do identity (==) and concrete type-assert checks
# forbidigo runs repo-wide (minus the boundaries below) so errs-no-bare-wrap
# has no gap. The framework bans (os/vfs, raw HTTP, fmt.Print, filepath,
# log) stay scoped to shortcuts/ + internal/ + config/auth/service via the
# next rule; elsewhere only errs-no-bare-wrap fires.
- path-except: (shortcuts/|internal/|cmd/|events/)
linters:
- forbidigo
- path-except: (shortcuts/|internal/|cmd/auth/|cmd/config/|cmd/service/)
text: (vfs|IOStreams|ctx\.Out|shortcuts-no-raw-http|filepath functions|os\.Exit|structured error return)
linters:
- forbidigo
- path: internal/vfs/
@@ -65,31 +72,26 @@ linters:
- path: shortcuts/.*/internal/gen/
linters:
- forbidigo
# internal/qualitygate/cmd contains standalone CI tools. Their main
# entrypoints legitimately own process exit codes and stdio, matching the
# old tools/ layout before these packages moved under internal/.
- path: internal/qualitygate/cmd/[^/]+/main\.go$
linters:
- forbidigo
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
# for the client / credential layer.
- path-except: shortcuts/
text: shortcuts-no-raw-http
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/apps/|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/wiki/|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/apps/|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/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
# errs-no-bare-wrap enforced across every command/wire boundary by
# structural prefix, so any future business domain or command is covered
# without editing an allowlist. Genuine intermediate wraps inside these
# paths use //nolint:forbidigo with a reason.
- path-except: (cmd/|shortcuts/|events/)
text: errs-no-bare-wrap
linters:
- forbidigo
# errs-no-legacy-helper enforced on domains whose shared validation/save
# helpers have migrated to typed final errors.
- path-except: (shortcuts/apps/|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/wiki/|cmd/event/|events/|shortcuts/event/)
text: errs-no-legacy-helper
linters:
- forbidigo
settings:
depguard:
@@ -108,22 +110,6 @@ linters:
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
forbidigo:
forbid:
# ── legacy output.Err* helpers banned on migrated paths ──
# output.ErrBare is intentionally not listed — it is the predicate-
# command silent-exit signal, outside the typed envelope contract.
- pattern: output\.(ErrValidation|ErrAuth|ErrNetwork|ErrAPI|ErrWithHint|Errorf)\b
msg: >-
[errs-typed-only] use errs.NewXxxError(...) builder
(see errs/types.go).
# ── 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 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

@@ -141,3 +141,74 @@ CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInput
| Modify shortcut flags/params | Required | If behavior changes |
| Shortcut bug fix | Required | If regression risk |
| Internal refactor (no shortcut impact) | Not needed | Not needed |
## CCM Harness Skill Routing (v2)
When the user's request matches one of these patterns, invoke the corresponding `/ccm-harness:*` skill via the Skill tool. Skills include multi-step workflows, gates, and quality checks that produce more reliable results than ad-hoc answers. When in doubt, invoke the skill — a false positive is cheaper than a false negative.
### 主链路spec → idl → dev → release
| 用户表达 | Skill |
|---------|-------|
| "新需求 / 这是个新功能 / 开始一个 req / 写 spec / 起 spec / PRD 来了 / 把 PRD 转 spec / 改 spec / 微调 spec / spec 局部更新 / spec 加一个字段" | `/ccm-harness:draft-spec <req-id> [<arg2>] [--force]`Phase 0 路由器自动决定 init / generate / update capability|
| "review spec / 看 spec / 评审 spec / spec 评审" | `/ccm-harness:spec-review <req-id>` |
| "生成 thrift / 起 idl / 把 spec 转 thrift" | `/ccm-harness:draft-idl` |
| "推 thrift / 落 contract / Frozen Spec / codegen / 生成框架代码" | `/ccm-harness:codegen-idl <req-id>` |
| "实现 spec / 写后端 / 后端开发 / 实现这个功能" | `/ccm-harness:backend-dev <req-id>` |
| "前端怎么改 / 写前端 / 前端开发 / 前端编码" | `/ccm-harness:frontend-coding <req-id>` |
| "部署 BOE / 上 PPE / 部署到 feature 环境" | `/ccm-harness:deploy <req-id>` |
| "提 release / 上线 / 发布 / 走 PRE-GRAY-ONLINE" | `/ccm-harness:release <req-id>` |
| "触发打包 / build / 起 SCM 编译" | `/ccm-harness:build <repo or psm>` |
### 守护与诊断
| 用户表达 | Skill |
|---------|-------|
| "工作流到哪一步了 / 下一步咋走 / 我迷路了" | `/ccm-harness:doctor` |
| "调试 / debug / 这个 bug 怎么排" | `/ccm-harness:debug` |
| "查 CI 失败 / CI 跑挂了 / pipeline 红了" | `/ccm-harness:check-ci-failure <mr>` |
| "检查实现是否符合 spec / 蓝图对比" | `/ccm-harness:check-impl-gap` |
| "spec 跟代码飘了吗 / drift 检查" | `/ccm-harness:spec-review <req-id> --mode drift` |
| "Hub 知识跟代码一致吗" | `/ccm-harness:check-knowledge-consistency` |
| "代码 review / 看 MR / cr 一下" | `/ccm-harness:code-review <mr>` |
| "设计 review / 看技术方案" | `/ccm-harness:design-review <doc>` |
### 工程辅助
| 用户表达 | Skill |
|---------|-------|
| "通知 reviewer / 发 review 卡片" | `/ccm-harness:notify-reviewer <mr>` |
| "自动修 MR / 按 review 改" | `/ccm-harness:autofix-mr <mr>` |
| "生成测试用例 / 起 case / 筛选可执行 case / E2E 用例 / Playwright 脚本 / 自动化验收" | `/ccm-harness:test`(路由到 `ccm-e2e-check -> exec-e2e` |
| "查 idl / 看 thrift 定义" | `/ccm-harness:lookup-idl <psm>` |
| "看仓库最近改了啥 / 仓库脉搏" | `/ccm-harness:pulse` |
### 元能力Skill / Prompt 研发)
| 用户表达 | Skill |
|---------|-------|
| "起一个新 skill / 设计 skill" | `/ccm-harness:meta-draft-skill` |
| "做评测集 / 给 skill 出评测数据" | `/ccm-harness:meta-build-evalset` |
| "跑评测 / Fornax 实验" | `/ccm-harness:meta-run-eval` |
| "优化 skill / skill 反馈优化" | `/ccm-harness:meta-optimize-skill` |
### 环境与配置
| 用户表达 | Skill |
|---------|-------|
| "升级 ccm-harness / 更新插件" | `/ccm-harness:upgrade` |
| "看遥测 / 最近的反馈" | `/ccm-harness:show-telemetry` |
| "清遥测 / 重置 telemetry" | `/ccm-harness:clear-telemetry` |
| "上报问题 / 提 issue" | `/ccm-harness:report-issue` |
| "看本地教训 / project learnings / 我们踩过啥" | `/ccm-harness:learn` |
| "反馈 / 评分这次 skill" | `/ccm-harness:feedback` |
### 使用提示
- **入口**:新需求**必从** `/ccm-harness:draft-spec <req-id>` 开始Phase 0 路由器自动决定建目录 / 带 PRD 一气呵成)。
- **不跳步**spec → idl → dev → release 是流水线,不是菜单——按顺序推进,反向走需要 `/ccm-harness:draft-spec <req-id> "<change-desc>"` 局部修(路由进 update capability
- **横切**:任何阶段发现 spec 要局部改 → `/ccm-harness:draft-spec <req-id> "<change-desc>"`;想检测漂移 → `/ccm-harness:spec-review <req-id> --mode drift`CI 红 → `/ccm-harness:check-ci-failure`;迷路 → `/ccm-harness:doctor`
完整流程文档:`docs/user-guide/workflow.md`

View File

@@ -2,6 +2,69 @@
All notable changes to this project will be documented in this file.
## [v1.0.56] - 2026-06-18
### Features
- **apps**: Add `+session-messages-list` for session turn reply messages (#1402)
### Bug Fixes
- **api**: Align API success envelopes (#1489)
- **base**: Reject out-of-range pagination flags (#1495)
### Refactor
- Retire legacy error envelopes and enforce typed contract (#1449)
### Documentation
- **skills**: Soften lark-doc style guidance (#1463)
### Build
- Add CI quality gate with semantic review
## [v1.0.55] - 2026-06-16
### Features
- **vc**: Support agent meeting event workflows (#1483)
- **drive**: Support exporting Base structure snapshots (#1481)
- **doc**: Add docx cover resource commands (#1468)
- **doc**: Support `lang` for docx fetch v2 (#1459)
- **event**: Optimize subscription precheck, links, and consumer guard (#1447)
### Bug Fixes
- **drive**: Validate drive import folder target (#1485)
## [v1.0.54] - 2026-06-15
### Features
- **mail**: Auto-attach default signature on send/reply/forward (#1415)
- **drive**: Support `original_creator_ids` filter in search (#1046)
- **cli**: Simplify proxy plugin warning and gate it on TTY (#1448)
### Bug Fixes
- **doc**: Fix docs fetch and update ergonomics (#1466)
- **vfs**: Reject blank local paths (#1460)
- **vfs**: Reject Windows absolute paths cross-platform (#1401)
- **event**: Clarify remote bus blocker recovery (#1454)
### Refactor
- Converge command pipelines onto a typed metadata model + catalog (#1191)
### Documentation
- **im**: Document `@mention` format per message type (text/post/card) (#1419)
- **doc**: Clarify lark-doc create title guidance (#1474)
- **skills**: Add rename prompt for import without `--name` (#1461)
- **apps**: Drop Miaoda brand word from apps command help text (#1399)
## [v1.0.53] - 2026-06-12
### Features
@@ -1149,6 +1212,9 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51

69
CLAUDE.md Normal file
View File

@@ -0,0 +1,69 @@
## CCM Harness Skill Routing (v2)
When the user's request matches one of these patterns, invoke the corresponding `/ccm-harness:*` skill via the Skill tool. Skills include multi-step workflows, gates, and quality checks that produce more reliable results than ad-hoc answers. When in doubt, invoke the skill — a false positive is cheaper than a false negative.
### 主链路spec → idl → dev → release
| 用户表达 | Skill |
|---------|-------|
| "新需求 / 这是个新功能 / 开始一个 req / 写 spec / 起 spec / PRD 来了 / 把 PRD 转 spec / 改 spec / 微调 spec / spec 局部更新 / spec 加一个字段" | `/ccm-harness:draft-spec <req-id> [<arg2>] [--force]`Phase 0 路由器自动决定 init / generate / update capability|
| "review spec / 看 spec / 评审 spec / spec 评审" | `/ccm-harness:spec-review <req-id>` |
| "生成 thrift / 起 idl / 把 spec 转 thrift" | `/ccm-harness:draft-idl` |
| "推 thrift / 落 contract / Frozen Spec / codegen / 生成框架代码" | `/ccm-harness:codegen-idl <req-id>` |
| "实现 spec / 写后端 / 后端开发 / 实现这个功能" | `/ccm-harness:backend-dev <req-id>` |
| "前端怎么改 / 写前端 / 前端开发 / 前端编码" | `/ccm-harness:frontend-coding <req-id>` |
| "部署 BOE / 上 PPE / 部署到 feature 环境" | `/ccm-harness:deploy <req-id>` |
| "提 release / 上线 / 发布 / 走 PRE-GRAY-ONLINE" | `/ccm-harness:release <req-id>` |
| "触发打包 / build / 起 SCM 编译" | `/ccm-harness:build <repo or psm>` |
### 守护与诊断
| 用户表达 | Skill |
|---------|-------|
| "工作流到哪一步了 / 下一步咋走 / 我迷路了" | `/ccm-harness:doctor` |
| "调试 / debug / 这个 bug 怎么排" | `/ccm-harness:debug` |
| "查 CI 失败 / CI 跑挂了 / pipeline 红了" | `/ccm-harness:check-ci-failure <mr>` |
| "检查实现是否符合 spec / 蓝图对比" | `/ccm-harness:check-impl-gap` |
| "spec 跟代码飘了吗 / drift 检查" | `/ccm-harness:spec-review <req-id> --mode drift` |
| "Hub 知识跟代码一致吗" | `/ccm-harness:check-knowledge-consistency` |
| "代码 review / 看 MR / cr 一下" | `/ccm-harness:code-review <mr>` |
| "设计 review / 看技术方案" | `/ccm-harness:design-review <doc>` |
### 工程辅助
| 用户表达 | Skill |
|---------|-------|
| "通知 reviewer / 发 review 卡片" | `/ccm-harness:notify-reviewer <mr>` |
| "自动修 MR / 按 review 改" | `/ccm-harness:autofix-mr <mr>` |
| "生成测试用例 / 起 case / 筛选可执行 case / E2E 用例 / Playwright 脚本 / 自动化验收" | `/ccm-harness:test`(路由到 `ccm-e2e-check -> exec-e2e` |
| "查 idl / 看 thrift 定义" | `/ccm-harness:lookup-idl <psm>` |
| "看仓库最近改了啥 / 仓库脉搏" | `/ccm-harness:pulse` |
### 元能力Skill / Prompt 研发)
| 用户表达 | Skill |
|---------|-------|
| "起一个新 skill / 设计 skill" | `/ccm-harness:meta-draft-skill` |
| "做评测集 / 给 skill 出评测数据" | `/ccm-harness:meta-build-evalset` |
| "跑评测 / Fornax 实验" | `/ccm-harness:meta-run-eval` |
| "优化 skill / skill 反馈优化" | `/ccm-harness:meta-optimize-skill` |
### 环境与配置
| 用户表达 | Skill |
|---------|-------|
| "升级 ccm-harness / 更新插件" | `/ccm-harness:upgrade` |
| "看遥测 / 最近的反馈" | `/ccm-harness:show-telemetry` |
| "清遥测 / 重置 telemetry" | `/ccm-harness:clear-telemetry` |
| "上报问题 / 提 issue" | `/ccm-harness:report-issue` |
| "看本地教训 / project learnings / 我们踩过啥" | `/ccm-harness:learn` |
| "反馈 / 评分这次 skill" | `/ccm-harness:feedback` |
### 使用提示
- **入口**:新需求**必从** `/ccm-harness:draft-spec <req-id>` 开始Phase 0 路由器自动决定建目录 / 带 PRD 一气呵成)。
- **不跳步**spec → idl → dev → release 是流水线,不是菜单——按顺序推进,反向走需要 `/ccm-harness:draft-spec <req-id> "<change-desc>"` 局部修(路由进 update capability
- **横切**:任何阶段发现 spec 要局部改 → `/ccm-harness:draft-spec <req-id> "<change-desc>"`;想检测漂移 → `/ccm-harness:spec-review <req-id> --mode drift`CI 红 → `/ccm-harness:check-ci-failure`;迷路 → `/ccm-harness:doctor`
完整流程文档:`docs/user-guide/workflow.md`

View File

@@ -5,6 +5,13 @@ BINARY := lark-cli
MODULE := github.com/larksuite/cli
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
DATE := $(shell date +%Y-%m-%d)
NODE ?= node
QUALITY_GATE_CHANGED_FROM ?= $(shell bash scripts/resolve-changed-from.sh)
QUALITY_GATE_CHANGED_FROM_RESOLVED = $(if $(strip $(QUALITY_GATE_CHANGED_FROM)),$(QUALITY_GATE_CHANGED_FROM),$(shell bash scripts/resolve-changed-from.sh))
QUALITY_GATE_DIR ?= .tmp/quality-gate
QUALITY_GATE_MANIFEST_OUT ?= $(QUALITY_GATE_DIR)/command-manifest.json
QUALITY_GATE_COMMAND_INDEX_OUT ?= $(QUALITY_GATE_DIR)/command-index.json
QUALITY_GATE_FACTS_OUT ?= $(QUALITY_GATE_DIR)/facts.json
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
PREFIX ?= /usr/local
@@ -15,7 +22,7 @@ PREFIX ?= /usr/local
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks
all: test
@@ -39,6 +46,12 @@ fmt-check:
exit 1; \
fi
script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
@@ -53,7 +66,30 @@ examples-build:
integration-test: build
go test -v -count=1 ./tests/...
test: vet fmt-check unit-test examples-build integration-test
test: vet fmt-check script-test unit-test examples-build integration-test
quality-gate: build
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT))
LARKSUITE_CLI_REMOTE_META=off \
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
go run ./internal/qualitygate/cmd/manifest-export \
--manifest-out $(QUALITY_GATE_MANIFEST_OUT) \
--command-index-out $(QUALITY_GATE_COMMAND_INDEX_OUT)
LARKSUITE_CLI_APP_ID=dry-run \
LARKSUITE_CLI_APP_SECRET=dry-run \
LARKSUITE_CLI_BRAND=feishu \
LARKSUITE_CLI_CONFIG_DIR=$${TMPDIR:-/tmp}/quality-gate-cli-config \
LARKSUITE_CLI_REMOTE_META=off \
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
go run ./internal/qualitygate/cmd/quality-gate check \
--repo . \
--cli-bin ./$(BINARY) \
--changed-from $(QUALITY_GATE_CHANGED_FROM_RESOLVED) \
--manifest $(QUALITY_GATE_MANIFEST_OUT) \
--command-index $(QUALITY_GATE_COMMAND_INDEX_OUT) \
--facts-out $(QUALITY_GATE_FACTS_OUT)
install: build
install -d $(PREFIX)/bin

View File

@@ -10,6 +10,7 @@ import (
"regexp"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -123,7 +124,13 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
// stdin conflict: --params and --data cannot both read from stdin, regardless of --file.
if opts.Params == "-" && opts.Data == "-" {
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--params and --data cannot both read from stdin (-)").
WithHint("pass at most one flag as '-'; give the other inline JSON or @file").
WithParams(
errs.InvalidParam{Name: "--params", Reason: "reads from stdin (-)"},
errs.InvalidParam{Name: "--data", Reason: "reads from stdin (-)"},
)
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
@@ -153,7 +160,10 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
return client.RawApiRequest{}, nil, err
}
if _, ok := dataFields.(map[string]any); !ok {
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--data must be a JSON object when used with --file").
WithHint(`with --file, --data carries multipart form fields, e.g. --data '{"image_type":"message"}'`).
WithParam("--data")
}
}
@@ -196,7 +206,13 @@ func apiRun(opts *APIOptions) error {
}
if opts.PageAll && opts.Output != "" {
return output.ErrValidation("--output and --page-all are mutually exclusive")
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--output and --page-all are mutually exclusive").
WithHint("drop --page-all to save a binary response, or drop --output to paginate JSON").
WithParams(
errs.InvalidParam{Name: "--output", Reason: "conflicts with --page-all"},
errs.InvalidParam{Name: "--page-all", Reason: "conflicts with --output"},
)
}
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
return err
@@ -233,7 +249,7 @@ func apiRun(opts *APIOptions) error {
}
if opts.PageAll {
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
}
@@ -243,7 +259,7 @@ func apiRun(opts *APIOptions) error {
// pass on *output.ExitError values. Typed *errs.* errors that flow
// through here keep their canonical message / hint from BuildAPIError;
// MarkRaw is a no-op on those (it only flips a flag on *ExitError).
return output.MarkRaw(err)
return errs.MarkRaw(err)
}
err = client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
@@ -263,7 +279,7 @@ func apiRun(opts *APIOptions) error {
// MarkRaw: see comment above on the DoAPI path. Skips legacy
// *ExitError enrichment; typed errors flow through unchanged.
if err != nil {
return output.MarkRaw(err)
return errs.MarkRaw(err)
}
return nil
}
@@ -272,46 +288,76 @@ func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.Cl
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, ac.CheckResponse); err != nil {
return output.MarkRaw(err)
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return errs.MarkRaw(err)
}
return nil
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return errs.MarkRaw(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
})
}
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) {
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
scanResult := output.ScanForSafety(commandPath, items, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
pf.FormatPage(items)
return nil
}, pagOpts)
if err != nil {
return output.MarkRaw(err)
return errs.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return output.MarkRaw(apiErr)
return errs.MarkRaw(apiErr)
}
if !hasItems {
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
output.FormatValue(out, result, output.FormatJSON)
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
return nil
default:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return output.MarkRaw(err)
return errs.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return output.MarkRaw(apiErr)
return errs.MarkRaw(apiErr)
}
output.FormatValue(out, result, format)
return nil
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
}

View File

@@ -4,6 +4,8 @@
package api
import (
"context"
"encoding/json"
"errors"
"os"
"sort"
@@ -11,6 +13,7 @@ import (
"testing"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -66,6 +69,24 @@ func TestApiCmd_DryRun(t *testing.T) {
}
}
// Regression: --params null parses to a nil map; writing page_size onto it must
// not panic. Symmetric to the typed-flag overlay path in cmd/service — both
// write into the map ParseJSONMap returns.
func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("--params null with --page-size should not error, got: %v", err)
}
if out := stdout.String(); !strings.Contains(out, "page_size") {
t.Errorf("expected page_size applied over null --params, got:\n%s", out)
}
}
func TestApiCmd_BotMode(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -83,8 +104,19 @@ func TestApiCmd_BotMode(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "success") {
t.Error("expected 'success' in output")
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
if got["ok"] != true || got["identity"] != "bot" {
t.Fatalf("unexpected envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if !ok || data["result"] != "success" {
t.Fatalf("data = %#v, want result=success", got["data"])
}
}
@@ -310,8 +342,16 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
t.Error("expected 'falling back to json' in stderr")
}
// Should output JSON result to stdout
if !strings.Contains(stdout.String(), "u123") {
t.Error("expected user_id in JSON output")
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if got["ok"] != true || got["identity"] != "bot" || !ok || data["user_id"] != "u123" {
t.Fatalf("unexpected fallback envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("fallback success envelope leaked outer code: %s", stdout.String())
}
}
@@ -324,7 +364,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
reg.Register(&httpmock.Stub{
URL: "/open-apis/im/v1/chats/oc_xxx/announcement",
Body: map[string]interface{}{
"code": 230001, "msg": "no permission",
"code": 230027, "msg": "user not authorized",
},
})
@@ -336,12 +376,20 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
t.Fatal("expected an error for non-zero code")
}
// Should still output the response body so user can see the error details
if !strings.Contains(stdout.String(), "230001") {
if !strings.Contains(stdout.String(), "230027") {
t.Errorf("expected error response in stdout, got: %s", stdout.String())
}
if !strings.Contains(stdout.String(), "no permission") {
if !strings.Contains(stdout.String(), "user not authorized") {
t.Errorf("expected error message in stdout, got: %s", stdout.String())
}
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected PermissionError, got %T: %v", err, err)
}
}
func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
@@ -377,6 +425,274 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
}
}
func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-err", AppSecret: "test-secret-pageall-stream-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
"has_more": true,
"page_token": "next",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code on later page")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier successful page to remain streamed, got: %s", out)
}
if strings.Contains(out, "230027") || strings.Contains(out, "user not authorized") {
t.Fatalf("streaming stdout should not contain raw error JSON, got: %s", out)
}
if strings.Contains(out, "\n \"code\"") {
t.Fatalf("streaming stdout should not contain indented JSON error dump, got: %s", out)
}
}
func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-json", AppSecret: "test-secret-pageall-json", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if got["ok"] != true || got["identity"] != "bot" || !ok {
t.Fatalf("unexpected envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
}
items, ok := data["items"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("data.items = %#v, want one item", data["items"])
}
}
type apiContentSafetyProvider struct {
called bool
path string
data interface{}
match string
}
func (p *apiContentSafetyProvider) Name() string { return "api-test" }
func (p *apiContentSafetyProvider) Scan(_ context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
p.called = true
p.path = req.Path
p.data = req.Data
if p.match != "" {
b, _ := json.Marshal(req.Data)
if !strings.Contains(string(b), p.match) {
return nil, nil
}
}
return &extcs.Alert{Provider: "api-test", MatchedRules: []string{"pagination"}}, nil
}
func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &apiContentSafetyProvider{}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-safety", AppSecret: "test-secret-pageall-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !provider.called {
t.Fatal("expected content safety provider to scan paginated output")
}
if provider.path != "api" {
t.Fatalf("scan path = %q, want api", provider.path)
}
data, ok := provider.data.(map[string]interface{})
if !ok {
t.Fatalf("scanned data type = %T, want map", provider.data)
}
if _, hasCode := data["code"]; hasCode {
t.Fatalf("scanned data should be business data only, got %#v", data)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
alert, ok := got["_content_safety_alert"].(map[string]interface{})
if !ok || alert["provider"] != "api-test" {
t.Fatalf("missing content safety alert in envelope: %#v", got)
}
}
func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &apiContentSafetyProvider{}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-safety", AppSecret: "test-secret-pageall-stream-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !provider.called {
t.Fatal("expected content safety provider to scan streamed paginated output")
}
if provider.path != "api" {
t.Fatalf("scan path = %q, want api", provider.path)
}
items, ok := provider.data.([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("scanned data = %#v, want one streamed item", provider.data)
}
if !strings.Contains(stderr.String(), "warning: content safety alert from api-test") {
t.Fatalf("expected content safety warning on stderr, got: %s", stderr.String())
}
if !strings.Contains(stdout.String(), `"id":"1"`) {
t.Fatalf("expected streamed ndjson output, got: %s", stdout.String())
}
}
func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
provider := &apiContentSafetyProvider{match: "blocked"}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-block", AppSecret: "test-secret-pageall-stream-block", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
"has_more": true,
"page_token": "next",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "blocked-page"}},
"has_more": false,
},
},
})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := root.Execute()
if err == nil {
t.Fatal("expected content safety block error")
}
var safetyErr *errs.ContentSafetyError
if !errors.As(err, &safetyErr) {
t.Fatalf("expected ContentSafetyError, got %T: %v", err, err)
}
if safetyErr.Category != errs.CategoryPolicy || safetyErr.Subtype != errs.SubtypeContentSafety {
t.Fatalf("problem = %s/%s, want %s/%s", safetyErr.Category, safetyErr.Subtype, errs.CategoryPolicy, errs.SubtypeContentSafety)
}
if len(safetyErr.Rules) != 1 || safetyErr.Rules[0] != "pagination" {
t.Fatalf("rules = %v, want [pagination]", safetyErr.Rules)
}
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier safe page to remain streamed, got: %s", out)
}
if strings.Contains(out, "blocked-page") {
t.Fatalf("blocked page was written before safety block: %s", out)
}
}
func requireProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype, code int) {
t.Helper()
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != category || p.Subtype != subtype || p.Code != code {
t.Fatalf("problem = %s/%s/%d, want %s/%s/%d", p.Category, p.Subtype, p.Code, category, subtype, code)
}
}
func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
for _, tt := range []struct {
name string

View File

@@ -33,12 +33,9 @@ func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
if got := output.ExitCodeOf(err); got != 1 {
t.Errorf("exit code = %d, want 1 (predicate 'missing' signal)", got)
}
var bare *output.ExitError
var bare *output.BareError
if !errors.As(err, &bare) {
t.Fatalf("expected *output.ExitError (ErrBare), got %T: %v", err, err)
}
if bare.Detail != nil {
t.Errorf("ErrBare must carry no Detail (no envelope), got %+v", bare.Detail)
t.Fatalf("expected *output.BareError (ErrBare), got %T: %v", err, err)
}
if stderr.Len() != 0 {

View File

@@ -9,6 +9,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -59,7 +60,7 @@ func authListRun(opts *ListOptions) error {
// keep the same contract here. We still want the hint to be
// workspace-aware, so we pull the message+hint out of
// NotConfiguredError() instead of hard-coding it.
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) {
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
if cfgErr.Hint != "" {

View File

@@ -92,16 +92,11 @@ func buildDomainMeta(name, lang string) domainMeta {
Description: desc,
}
}
// Fallback: read from from_meta spec (legacy)
meta := registry.LoadFromMeta(name)
// Fallback: read from the typed service spec (legacy)
dm := domainMeta{Name: name}
if meta != nil {
if t, ok := meta["title"].(string); ok {
dm.Title = t
}
if d, ok := meta["description"].(string); ok {
dm.Description = d
}
if svc, ok := registry.ServiceTyped(name); ok {
dm.Title = svc.Title
dm.Description = svc.Description
}
return dm
}

View File

@@ -878,7 +878,7 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
// contract that when --json is set and pollDeviceToken returns OK=false,
// stdout carries the structured authorization_failed event and stderr is
// NOT polluted with a typed envelope. The returned error is a bare
// ExitError with ExitAuth so the dispatcher only propagates the exit code
// BareError with ExitAuth so the dispatcher only propagates the exit code
// without emitting a second envelope on top of the JSON event.
func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
keyring.MockInit()
@@ -945,16 +945,13 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
t.Errorf("stderr should not contain JSON envelope fields, got: %s", stderrStr)
}
// Returned error must be the bare *output.ExitError signal (no envelope).
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
// Returned error must be the bare *output.BareError signal (no envelope).
var bareErr *output.BareError
if !errors.As(err, &bareErr) {
t.Fatalf("expected *output.BareError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitAuth {
t.Fatalf("ExitError.Code = %d, want %d", exitErr.Code, output.ExitAuth)
}
if exitErr.Detail != nil {
t.Errorf("ExitError.Detail should be nil for bare signal, got: %+v", exitErr.Detail)
if bareErr.Code != output.ExitAuth {
t.Fatalf("BareError.Code = %d, want %d", bareErr.Code, output.ExitAuth)
}
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/larksuite/cli/cmd/skill"
cmdupdate "github.com/larksuite/cli/cmd/update"
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -33,9 +34,13 @@ import (
type BuildOption func(*buildConfig)
type buildConfig struct {
streams *cmdutil.IOStreams
keychain keychain.KeychainAccess
globals GlobalOptions
streams *cmdutil.IOStreams
keychain keychain.KeychainAccess
globals GlobalOptions
skipPlugins bool
skipStrictMode bool
skipService bool
serviceCatalog *apicatalog.Catalog
}
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
@@ -75,6 +80,41 @@ func HideProfile(hide bool) BuildOption {
}
}
// WithoutPlugins builds only repository-owned commands. It is intended for
// inspection tools that need a deterministic command tree.
func WithoutPlugins() BuildOption {
return func(c *buildConfig) {
c.skipPlugins = true
}
}
// WithoutStrictMode builds the complete repository-owned command tree without
// applying user/profile strict-mode pruning. It is intended for offline
// inspection tools, not production execution.
func WithoutStrictMode() BuildOption {
return func(c *buildConfig) {
c.skipStrictMode = true
}
}
// WithoutServiceCommands builds only hand-authored commands. It is intended for
// repository quality gates that should not depend on the remote OpenAPI
// metadata command surface.
func WithoutServiceCommands() BuildOption {
return func(c *buildConfig) {
c.skipService = true
}
}
// WithServiceCatalog builds generated service commands from a specific metadata
// catalog. It is intended for offline inspection tools that need deterministic
// embedded metadata while production execution keeps using the runtime catalog.
func WithServiceCatalog(catalog apicatalog.Catalog) BuildOption {
return func(c *buildConfig) {
c.serviceCatalog = &catalog
}
}
// Build constructs the full command tree. It also installs registered
// plugins and emits the Startup lifecycle event during assembly --
// so Plugin.On(Startup) handlers run even if the returned command is
@@ -156,15 +196,26 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
rootCmd.AddCommand(skill.NewCmdSkill(f))
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
if !cfg.skipService {
if cfg.serviceCatalog != nil {
service.RegisterServiceCommandsFromCatalog(ctx, rootCmd, f, *cfg.serviceCatalog)
} else {
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
}
}
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
installUnknownSubcommandGuard(rootCmd)
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
pruneForStrictMode(rootCmd, mode)
}
if cfg.skipPlugins {
recordInventory(nil)
return f, rootCmd, nil
}
installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut)
if installErr != nil {
installPluginInstallErrorGuard(rootCmd, installErr)

46
cmd/build_test.go Normal file
View File

@@ -0,0 +1,46 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
func TestBuildWithoutPluginsStillBuildsBuiltinCommands(t *testing.T) {
root := Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
if root == nil {
t.Fatal("Build returned nil root")
}
if findCommand(root, "api") == nil {
t.Fatal("builtin api command missing")
}
if findCommand(root, "docs +fetch") == nil {
t.Fatal("builtin docs +fetch shortcut missing")
}
}
func findCommand(root *cobra.Command, path string) *cobra.Command {
parts := strings.Fields(path)
cmd := root
for _, part := range parts {
var next *cobra.Command
for _, child := range cmd.Commands() {
if child.Name() == part {
next = child
break
}
}
if next == nil {
return nil
}
cmd = next
}
return cmd
}

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"reflect"
"testing"
"github.com/spf13/cobra"
)
// TestCommandCatalogPath pins that the auth-hint path reconstruction inverts the
// service command tree for any depth — flat dotted resources AND genuinely
// nested resources — so it round-trips through apicatalog.Resolve instead of
// assuming a fixed root->service->resource->method shape.
func TestCommandCatalogPath(t *testing.T) {
chain := func(names ...string) *cobra.Command {
var parent, leaf *cobra.Command
for _, n := range names {
c := &cobra.Command{Use: n}
if parent != nil {
parent.AddCommand(c)
}
parent = c
leaf = c
}
return leaf
}
tests := []struct {
name string
leaf *cobra.Command
want []string
}{
{"flat dotted resource", chain("lark-cli", "im", "chat.members", "create"), []string{"im", "chat.members", "create"}},
{"nested resources", chain("lark-cli", "im", "spaces", "items", "get"), []string{"im", "spaces", "items", "get"}},
{"service level", chain("lark-cli", "im"), []string{"im"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := commandCatalogPath(tt.leaf); !reflect.DeepEqual(got, tt.want) {
t.Errorf("commandCatalogPath = %v, want %v", got, tt.want)
}
})
}
// The root command (no parent) has no catalog path.
if got := commandCatalogPath(&cobra.Command{Use: "lark-cli"}); len(got) != 0 {
t.Errorf("root path = %v, want empty", got)
}
}

View File

@@ -4,8 +4,7 @@
package completion
import (
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
@@ -32,7 +31,9 @@ func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command {
case "powershell":
return root.GenPowerShellCompletionWithDesc(out)
default:
return fmt.Errorf("unsupported shell: %s", args[0])
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unsupported shell: %s", args[0]).
WithHint("supported shells: bash, zsh, fish, powershell")
}
},
}

View File

@@ -212,10 +212,7 @@ func finalizeSource(opts *BindOptions) (string, error) {
if opts.IsTUI && !opts.langExplicit {
lang, err := promptLangSelection()
if err != nil {
if err == huh.ErrUserAborted {
return "", output.ErrBare(1)
}
return "", output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
return "", langSelectionError(err)
}
opts.Lang = string(lang)
opts.UILang = lang

View File

@@ -20,35 +20,29 @@ import (
"github.com/larksuite/cli/internal/output"
)
// assertExitError checks the full structured error in one assertion. It
// accepts both *output.ExitError (used by output.ErrWithHint) and the
// typed errors (ValidationError, ConfigError) — they normalize to the same
// wantDetail fields. The wantDetail.Type is matched against the typed error's
// Category string ("validation", "config", etc.).
func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) {
// wantErrDetail is the normalized comparison shape for a typed error's wire
// fields: Type is the error's Category string ("validation", "config", ...),
// alongside Message and Hint.
type wantErrDetail struct {
Type string
Message string
Hint string
}
// assertExitError checks the full structured error in one assertion against a
// typed error (ValidationError or ConfigError), normalizing its Category /
// Message / Hint to wantDetail.
func assertExitError(t *testing.T, err error, wantCode int, wantDetail wantErrDetail) {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
if exitErr.Code != wantCode {
t.Errorf("exit code = %d, want %d", exitErr.Code, wantCode)
}
if exitErr.Detail == nil {
t.Fatal("expected non-nil error detail")
}
if !reflect.DeepEqual(*exitErr.Detail, wantDetail) {
t.Errorf("error detail mismatch:\n got: %+v\n want: %+v", *exitErr.Detail, wantDetail)
}
return
}
var ve *errs.ValidationError
if errors.As(err, &ve) {
if got := output.ExitCodeOf(err); got != wantCode {
t.Errorf("exit code = %d, want %d", got, wantCode)
}
gotDetail := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
gotDetail := wantErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
if !reflect.DeepEqual(gotDetail, wantDetail) {
t.Errorf("validation error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
}
@@ -59,13 +53,13 @@ func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.Er
if got := output.ExitCodeOf(err); got != wantCode {
t.Errorf("exit code = %d, want %d", got, wantCode)
}
gotDetail := output.ErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint}
gotDetail := wantErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint}
if !reflect.DeepEqual(gotDetail, wantDetail) {
t.Errorf("config error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
}
return
}
t.Fatalf("error type = %T, want *output.ExitError or *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
t.Fatalf("error type = %T, want *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
}
// assertEnvelope decodes stdout and checks it matches want exactly — every key
@@ -179,15 +173,21 @@ func TestConfigBindRun_InvalidLang(t *testing.T) {
if err == nil {
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
}
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
if valErr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(exitErr.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
if valErr.Param != "--lang" {
t.Errorf("param = %q, want %q", valErr.Param, "--lang")
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", err.Error())
}
})
}
@@ -365,7 +365,7 @@ func TestConfigBindRun_InvalidSource(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "invalid"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `invalid --source "invalid"; valid values: openclaw, hermes, lark-channel`,
})
@@ -382,7 +382,7 @@ func TestConfigBindRun_MissingSourceNonTTY(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
// TestFactory has IsTerminal=false by default
err := configBindRun(&BindOptions{Factory: f, Source: ""})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: "cannot determine Agent source: no --source flag and no Agent environment detected",
Hint: "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context",
@@ -421,7 +421,7 @@ func TestConfigBindRun_SourceEnvMismatch_OpenClawFlagInHermesEnv(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--source "openclaw" does not match detected Agent environment (hermes)`,
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
@@ -437,7 +437,7 @@ func TestConfigBindRun_SourceEnvMismatch_HermesFlagInOpenClawEnv(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--source "hermes" does not match detected Agent environment (openclaw)`,
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
@@ -566,7 +566,7 @@ func TestConfigBindRun_HermesMissingEnvFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
envPath := filepath.Join(hermesHome, ".env")
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "failed to read Hermes config: open " + envPath + ": no such file or directory",
Hint: "verify Hermes is installed and configured at " + envPath,
@@ -584,7 +584,7 @@ func TestConfigBindRun_OpenClawMissingFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
configPath := filepath.Join(openclawHome, ".openclaw", "openclaw.json")
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
Hint: "verify OpenClaw is installed and configured",
@@ -731,7 +731,7 @@ func TestConfigBindRun_SourceEnvMismatch_LarkChannelFlagInOpenClawEnv(t *testing
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--source "lark-channel" does not match detected Agent environment (openclaw)`,
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
@@ -750,7 +750,7 @@ func TestConfigBindRun_LarkChannelMissingFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
configPath := filepath.Join(fakeHome, ".lark-channel", "config.json")
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
Hint: "verify lark-channel-bridge is installed and configured",
@@ -770,7 +770,7 @@ func TestConfigBindRun_LarkChannelEmptyAppID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "accounts.app.id missing in " + configPath,
Hint: "run lark-channel-bridge's setup to populate the app credential",
@@ -789,7 +789,7 @@ func TestConfigBindRun_LarkChannelEmptySecret(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "accounts.app.secret is empty in " + configPath,
Hint: "run lark-channel-bridge's setup to populate the app credential",
@@ -835,17 +835,19 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
t.Fatal("expected error for unbound workspace")
}
// Should be a structured ConfigError suggesting config bind, not config init.
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
t.Fatalf("error type = %T, want *errs.ConfigError", err)
}
// Config errors share ExitAuth (3); the workspace is detected but no
// binding exists yet, which is a config error.
if cfgErr.Code != output.ExitAuth {
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
if got := output.ExitCodeOf(err); got != output.ExitAuth {
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth)
}
if cfgErr.Type != "openclaw" {
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
// The workspace name stays out of the wire subtype; it only appears in
// the message.
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
}
if !strings.Contains(cfgErr.Message, "openclaw context detected") {
t.Errorf("message missing 'openclaw context detected': %q", cfgErr.Message)
@@ -1187,7 +1189,7 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
// iterates a map — ordering is non-deterministic. DeepEqual inline against
// each accepted variant so every ErrDetail field (Type, Code, Message,
// Hint, ConsoleURL, Detail, and any future addition) is still compared.
base := output.ErrDetail{
base := wantErrDetail{
Type: "validation",
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
}
@@ -1203,7 +1205,7 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; err = %v", err, err)
}
got := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
got := wantErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
if !reflect.DeepEqual(got, wantWorkFirst) && !reflect.DeepEqual(got, wantPersonalFirst) {
t.Errorf("error detail did not match any accepted variant:\n got: %+v\n want: %+v OR %+v",
got, wantWorkFirst, wantPersonalFirst)
@@ -1230,7 +1232,7 @@ func TestConfigBindRun_OpenClawMultiAccount_WrongAppID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", AppID: "nonexistent"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_only_one",
@@ -1250,7 +1252,7 @@ func TestConfigBindRun_InvalidIdentity(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes", Identity: "invalid"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `invalid --identity "invalid"; valid values: bot-only, user-default`,
})
@@ -1536,7 +1538,7 @@ func TestConfigBindRun_HermesMissingAppID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
envPath := filepath.Join(hermesHome, ".env")
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "FEISHU_APP_ID not found in " + envPath,
Hint: "run 'hermes setup' to configure Feishu credentials",
@@ -1556,7 +1558,7 @@ func TestConfigBindRun_HermesMissingAppSecret(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
envPath := filepath.Join(hermesHome, ".env")
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "FEISHU_APP_SECRET not found in " + envPath,
Hint: "run 'hermes setup' to configure Feishu credentials",
@@ -1582,7 +1584,7 @@ func TestConfigBindRun_OpenClawMissingFeishu(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "openclaw.json missing channels.feishu section",
Hint: "configure Feishu in OpenClaw first",
@@ -1610,7 +1612,7 @@ func TestConfigBindRun_OpenClawEmptyAppSecret(t *testing.T) {
openclawPath := filepath.Join(openclawDir, "openclaw.json")
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "appSecret is empty for app cli_no_secret in " + openclawPath,
Hint: "configure channels.feishu.appSecret in openclaw.json",
@@ -1672,7 +1674,7 @@ func TestConfigBindRun_OpenClawDisabledAccount(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "no Feishu app configured in openclaw.json",
Hint: "configure channels.feishu.appId in openclaw.json",

View File

@@ -51,7 +51,7 @@ func assertCandidate(t *testing.T, got *Candidate, want Candidate) {
func TestSelectCandidate_ZeroCandidates_OpenClaw(t *testing.T) {
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "no Feishu app configured in openclaw.json",
Hint: "configure channels.feishu.appId in openclaw.json",
@@ -64,7 +64,7 @@ func TestSelectCandidate_ZeroCandidates_GenericSource(t *testing.T) {
// even before it has a bespoke error message.
b := &fakeBinder{name: "hermes", path: "/tmp/.env"}
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "hermes: no app configured",
})
@@ -100,7 +100,7 @@ func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) {
{AppID: "cli_home", Label: "home"},
}
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
@@ -117,7 +117,7 @@ func TestSelectCandidate_MultiCandidate_NoFlag_NonTUI(t *testing.T) {
{AppID: "cli_home", Label: "home"},
}
_, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
@@ -152,7 +152,7 @@ func TestSelectCandidate_SingleCandidate_WrongFlag(t *testing.T) {
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
candidates := []Candidate{{AppID: "cli_only"}}
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_only",

View File

@@ -12,6 +12,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -92,16 +93,16 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
t.Fatal("expected error")
}
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
t.Fatalf("error type = %T, want *errs.ConfigError", err)
}
// Config errors share ExitAuth (3), not ExitValidation.
if cfgErr.Code != output.ExitAuth {
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
if got := output.ExitCodeOf(err); got != output.ExitAuth {
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth)
}
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
if cfgErr.Subtype != errs.SubtypeNotConfigured || cfgErr.Message != "not configured" {
t.Fatalf("detail = %+v, want not_configured/not configured", cfgErr)
}
}
@@ -233,15 +234,21 @@ func TestConfigInitCmd_InvalidLang(t *testing.T) {
if err == nil {
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
}
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
if valErr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(exitErr.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
if valErr.Param != "--lang" {
t.Errorf("param = %q, want %q", valErr.Param, "--lang")
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", err.Error())
}
})
}
@@ -385,8 +392,38 @@ func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T
if err == nil {
t.Fatal("expected conflict error")
}
if !strings.Contains(err.Error(), "conflicts with existing appId") {
t.Fatalf("error = %v, want conflict with existing appId", err)
// A name/appId conflict is user input — a typed validation error naming the
// offending flag, not a system storage failure.
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("error type = %T, want *errs.ValidationError; err=%v", err, err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
if verr.Param != "--name" {
t.Errorf("param = %q, want --name", verr.Param)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", output.ExitCodeOf(err), output.ExitValidation)
}
if !strings.Contains(verr.Message, "conflicts with existing appId") {
t.Errorf("message = %q, want conflict description", verr.Message)
}
}
// TestWrapSaveConfigError_PassesTypedValidationThrough pins that a user-input
// validation error (e.g. the --name conflict) is not reclassified as an
// internal storage failure on its way up through the save call sites.
func TestWrapSaveConfigError_PassesTypedValidationThrough(t *testing.T) {
conflict := errs.NewValidationError(errs.SubtypeInvalidArgument, "name conflict").WithParam("--name")
var verr *errs.ValidationError
if !errors.As(wrapSaveConfigError(conflict), &verr) {
t.Fatalf("typed validation must pass through unchanged, got %T", wrapSaveConfigError(conflict))
}
var ierr *errs.InternalError
if !errors.As(wrapSaveConfigError(errors.New("disk full")), &ierr) || ierr.Subtype != errs.SubtypeStorage {
t.Fatalf("untyped failure must become internal/storage")
}
}

View File

@@ -6,13 +6,11 @@ package config
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
@@ -127,12 +125,9 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
if ws.IsLocal() {
return nil
}
return &core.ConfigError{
Code: 2,
Type: ws.Display(),
Message: fmt.Sprintf("config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()),
Hint: "see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.",
}
return errs.NewConfigError(errs.SubtypeNotConfigured,
"config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()).
WithHint("see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.")
}
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
@@ -183,6 +178,20 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)))
}
// wrapSaveConfigError passes an already-typed error (e.g. the --name conflict
// validation error from saveAsProfile) through unchanged, and classifies any
// other failure as an internal storage error. Without the passthrough a user
// input error would surface to agents as a system storage failure.
func wrapSaveConfigError(err error) error {
if err == nil {
return nil
}
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
// saveAsProfile appends or updates a named profile in the config.
// If a profile with the same name exists, it updates it; otherwise appends.
// When updating, cleans up old keychain secrets if AppId changed.
@@ -207,7 +216,9 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang)
} else {
if findAppIndexByAppID(multi, profileName) >= 0 {
return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"profile name %q conflicts with existing appId", profileName).
WithParam("--name")
}
// Append new profile
multi.Apps = append(multi.Apps, core.AppConfig{
@@ -249,8 +260,8 @@ func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
// wrapUpdateExistingProfileErr classifies the error returned by
// updateExistingProfileWithoutSecret. Typed errors (e.g. *errs.ValidationError
// for blank-input) pass through unchanged so their exit code semantics
// survive; legacy *output.ExitError also passes through; everything else
// (filesystem, keychain, etc.) is wrapped as InternalError.
// survive; everything else (filesystem, keychain, etc.) is wrapped as
// InternalError.
func wrapUpdateExistingProfileErr(err error) error {
if err == nil {
return nil
@@ -258,10 +269,6 @@ func wrapUpdateExistingProfileErr(err error) error {
if errs.IsTyped(err) {
return err
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "failed to save config: %v", err).WithCause(err)
}
@@ -336,7 +343,7 @@ func configInitRun(opts *ConfigInitOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return wrapSaveConfigError(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
@@ -353,10 +360,7 @@ func configInitRun(opts *ConfigInitOptions) error {
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
lang, err := promptLangSelection()
if err != nil {
if err == huh.ErrUserAborted {
return output.ErrBare(1)
}
return output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
return langSelectionError(err)
}
opts.Lang = string(lang)
opts.UILang = lang
@@ -379,7 +383,7 @@ func configInitRun(opts *ConfigInitOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return wrapSaveConfigError(err)
}
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
@@ -409,7 +413,7 @@ func configInitRun(opts *ConfigInitOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return wrapSaveConfigError(err)
}
} else if result.Mode == "existing" && result.AppID != "" {
// Existing app with unchanged secret — update app ID and brand only
@@ -514,7 +518,7 @@ func configInitRun(opts *ConfigInitOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return wrapSaveConfigError(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)

View File

@@ -8,7 +8,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/errs"
)
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
@@ -26,12 +26,15 @@ func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) {
if err == nil {
t.Fatal("expected refusal in OpenClaw context, got nil")
}
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
t.Fatalf("error type = %T, want *errs.ConfigError", err)
}
if cfgErr.Type != "openclaw" {
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
}
if !strings.Contains(cfgErr.Message, "openclaw") {
t.Errorf("message must name the openclaw workspace; got %q", cfgErr.Message)
}
if !strings.Contains(cfgErr.Hint, "config bind --help") {
t.Errorf("hint must point to config bind --help; got %q", cfgErr.Hint)
@@ -48,12 +51,15 @@ func TestGuardAgentWorkspace_HermesRefuses(t *testing.T) {
if err == nil {
t.Fatal("expected refusal in Hermes context, got nil")
}
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
t.Fatalf("error type = %T, want *errs.ConfigError", err)
}
if cfgErr.Type != "hermes" {
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
}
if !strings.Contains(cfgErr.Message, "hermes") {
t.Errorf("message must name the hermes workspace; got %q", cfgErr.Message)
}
}

View File

@@ -4,10 +4,14 @@
package config
import (
"errors"
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
)
type initMsg struct {
@@ -97,3 +101,12 @@ func promptLangSelection() (i18n.Lang, error) {
}
return lang, nil
}
// langSelectionError maps a promptLangSelection failure to its exit surface:
// user abort exits bare with code 1; any other failure is internal.
func langSelectionError(err error) error {
if errors.Is(err, huh.ErrUserAborted) {
return output.ErrBare(1)
}
return errs.NewInternalError(errs.SubtypeUnknown, "language selection failed: %v", err).WithCause(err)
}

View File

@@ -65,8 +65,8 @@ func TestUpdateExistingProfileWithoutSecret_AppIdMismatch_EmitsValidationError(t
// wrapUpdateExistingProfileErr is the caller-side classifier for the error
// returned by updateExistingProfileWithoutSecret. It must preserve typed-error
// exit semantics (regression: typed ValidationError was being downgraded to
// InternalError by the legacy *output.ExitError-only passthrough).
// exit semantics: a typed ValidationError must keep ExitValidation rather than
// being downgraded to InternalError.
func TestWrapUpdateExistingProfileErr_NilPassesThrough(t *testing.T) {
if got := wrapUpdateExistingProfileErr(nil); got != nil {
@@ -90,18 +90,6 @@ func TestWrapUpdateExistingProfileErr_TypedValidationErrorPreserved(t *testing.T
}
}
func TestWrapUpdateExistingProfileErr_LegacyExitErrorPreserved(t *testing.T) {
in := &output.ExitError{Code: 7, Err: errors.New("legacy")}
got := wrapUpdateExistingProfileErr(in)
var exitErr *output.ExitError
if !errors.As(got, &exitErr) {
t.Fatalf("expected *output.ExitError to pass through, got %T: %v", got, got)
}
if exitErr.Code != 7 {
t.Errorf("Code = %d, want 7", exitErr.Code)
}
}
func TestWrapUpdateExistingProfileErr_UntypedErrorBecomesInternal(t *testing.T) {
in := fmt.Errorf("disk full")
got := wrapUpdateExistingProfileErr(in)

View File

@@ -14,6 +14,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -94,7 +95,7 @@ func doctorRun(opts *DoctorOptions) error {
// underlying problem is still visible.
msg, hint := err.Error(), ""
if errors.Is(err, os.ErrNotExist) {
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) {
msg, hint = cfgErr.Message, cfgErr.Hint
}
@@ -108,7 +109,7 @@ func doctorRun(opts *DoctorOptions) error {
cfg, err := f.Config()
if err != nil {
hint := ""
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) {
hint = cfgErr.Hint
}

View File

@@ -11,10 +11,10 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/apicatalog"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
@@ -48,32 +48,6 @@ func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
authErr.Hint += "\n" + scopeHint
}
// enrichMissingScopeError appends a "current command requires scope(s): X"
// hint to a legacy *output.ExitError when the underlying error carries the
// need_user_authorization marker AND the current command declares scopes
// locally.
//
// Deprecated: enrichment for the legacy envelope; the typed path is
// applyNeedAuthorizationHint above.
func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr == nil || exitErr.Detail == nil {
return
}
if !internalauth.IsNeedUserAuthorizationError(exitErr) {
return
}
scopes := resolveDeclaredScopesForCurrentCommand(f)
if len(scopes) == 0 {
return
}
scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", "))
if exitErr.Detail.Hint == "" {
exitErr.Detail.Hint = scopeHint
return
}
exitErr.Detail.Hint += "\n" + scopeHint
}
// resolveDeclaredScopesForCurrentCommand returns the scopes declared by the
// current command for the resolved identity, checking shortcuts first and then
// service methods from local registry metadata.
@@ -118,38 +92,37 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
}
// resolveDeclaredServiceMethodScopes returns the scopes declared by a
// service/resource/method command from the embedded from_meta registry.
// service/resource/method command. It reconstructs the catalog path from the
// command ancestry and resolves it through the same navigation Module the
// command tree is built from (apicatalog), so it stays correct for nested
// resources instead of hard-coding a root->service->resource->method depth.
// Non-method commands (services, resources, shortcuts) resolve to a non-method
// target and yield no scopes.
func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []string {
// Service-method scope lookup only applies to commands mounted as
// root -> service -> resource -> method. Non-resource/method commands
// intentionally return no scopes here so auth-hint enrichment does not
// change runtime semantics for other command shapes.
if cmd == nil || cmd.Parent() == nil || cmd.Parent().Parent() == nil || cmd.Parent().Parent().Parent() == nil {
if cmd == nil || strings.HasPrefix(cmd.Name(), "+") {
return nil
}
if strings.HasPrefix(cmd.Name(), "+") {
path := commandCatalogPath(cmd)
if len(path) == 0 {
return nil
}
target, err := registry.RuntimeCatalog().Resolve(path)
if err != nil || target.Kind != apicatalog.TargetMethod {
return nil
}
return registry.DeclaredScopesForMethod(target.Method.Method, identity)
}
service := cmd.Parent().Parent().Name()
resource := cmd.Parent().Name()
method := cmd.Name()
spec := registry.LoadFromMeta(service)
if spec == nil {
return nil
// commandCatalogPath reconstructs the catalog path [service, resource..., method]
// from a command's ancestry, excluding the root command. It is the inverse of
// the service command tree's construction, so any depth (flat or nested)
// round-trips through apicatalog.Resolve.
func commandCatalogPath(cmd *cobra.Command) []string {
var path []string
for c := cmd; c != nil && c.Parent() != nil; c = c.Parent() {
path = append([]string{c.Name()}, path...)
}
resources, _ := spec["resources"].(map[string]interface{})
resMap, _ := resources[resource].(map[string]interface{})
if resMap == nil {
return nil
}
methods, _ := resMap["methods"].(map[string]interface{})
methodMap, _ := methods[method].(map[string]interface{})
if methodMap == nil {
return nil
}
return registry.DeclaredScopesForMethod(methodMap, identity)
return path
}
// shortcutSupportsIdentity reports whether a shortcut supports the requested

View File

@@ -8,7 +8,7 @@ import (
"regexp"
)
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen when adding brands in consoleScopeGrantURL.
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen the host alternation when adding brands.
var authURLPattern = regexp.MustCompile(`https?://open\.(?:feishu\.cn|larksuite\.com)/app/[^/\s"']+/auth\?q=[^\s"'<>]+`)
// describeAppMetaErr reduces a FetchCurrentPublished error to a one-line stderr summary.

View File

@@ -4,21 +4,117 @@
package event
import (
"bytes"
"compress/gzip"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
)
// consoleScopeGrantURL builds the developer-console "apply & grant scopes" deep link; scopes are comma-joined without URL encoding.
func consoleScopeGrantURL(brand core.LarkBrand, appID string, scopes []string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s/app/%s/auth?q=%s&op_from=openapi&token_type=tenant",
host, appID, strings.Join(scopes, ","))
// Landing-page contract for the scan-to-enable deep link, verified against the
// open platform: {open-host}/page/launcher?clientID=<appID>&addons=<encoded>.
// Note the param is camelCase "clientID" (not snake_case), and the value is the
// consuming app's own ID. Centralized so it can be corrected in one place.
const (
addonsLandingPath = "/page/launcher"
addonsClientIDParam = "clientID"
)
// ManifestAddons mirrors the 5 public manifest sections the launcher page accepts.
// Encoded form: JSON -> gzip -> base64url(no padding).
type ManifestAddons struct {
Scopes *AddonsScopes `json:"scopes,omitempty"`
Events *AddonsEvents `json:"events,omitempty"`
Callbacks *AddonsCallbacks `json:"callbacks,omitempty"`
}
// consoleEventSubscriptionURL points at the app's event subscription console page.
func consoleEventSubscriptionURL(brand core.LarkBrand, appID string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s/app/%s/event", host, appID)
type AddonsScopes struct {
Tenant []string `json:"tenant"`
User []string `json:"user"`
}
type AddonsEvents struct {
Items AddonsEventItems `json:"items"`
}
type AddonsEventItems struct {
Tenant []string `json:"tenant"`
User []string `json:"user"`
}
type AddonsCallbacks struct {
Items []string `json:"items"`
}
// encodeAddons: JSON -> gzip -> base64url(no padding). Matches the front-end decode chain.
func encodeAddons(a ManifestAddons) (string, error) {
raw, err := json.Marshal(a)
if err != nil {
return "", err
}
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
if _, err := gw.Write(raw); err != nil {
return "", err
}
if err := gw.Close(); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf.Bytes()), nil
}
// consoleAddonsURL builds the scan-to-enable deep link carrying incremental scopes/events/callbacks.
func consoleAddonsURL(brand core.LarkBrand, appID string, a ManifestAddons) (string, error) {
encoded, err := encodeAddons(a)
if err != nil {
return "", err
}
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded), nil
}
// consoleLandingURL is the bare landing page (no addons) — fallback when encoding fails.
func consoleLandingURL(brand core.LarkBrand, appID string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID)
}
// addonsHintURL returns the scan URL, degrading to the bare landing page on encode error.
func addonsHintURL(brand core.LarkBrand, appID string, a ManifestAddons) string {
url, err := consoleAddonsURL(brand, appID, a)
if err != nil {
return consoleLandingURL(brand, appID)
}
return url
}
// missingScopeAddons routes missing scopes into the identity-appropriate section.
// The unused side is an empty (non-nil) slice so JSON encodes [] not null —
// the addons spec treats a missing tenant/user as an empty array.
func missingScopeAddons(identity core.Identity, missing []string) ManifestAddons {
s := &AddonsScopes{Tenant: []string{}, User: []string{}}
if identity.IsBot() {
s.Tenant = missing
} else {
s.User = missing
}
return ManifestAddons{Scopes: s}
}
// missingSubscriptionAddons routes missing events/callbacks into the right section.
// Like missingScopeAddons, unused event sides stay [] (not null) per the addons spec.
func missingSubscriptionAddons(subType eventlib.SubscriptionType, identity core.Identity, missing []string) ManifestAddons {
if subType == eventlib.SubTypeCallback {
return ManifestAddons{Callbacks: &AddonsCallbacks{Items: missing}}
}
ev := &AddonsEvents{Items: AddonsEventItems{Tenant: []string{}, User: []string{}}}
if identity.IsBot() {
ev.Items.Tenant = missing
} else {
ev.Items.User = missing
}
return ManifestAddons{Events: ev}
}

View File

@@ -4,33 +4,109 @@
package event
import (
"bytes"
"compress/gzip"
"encoding/base64"
"encoding/json"
"io"
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
)
func TestConsoleScopeGrantURL_Feishu(t *testing.T) {
got := consoleScopeGrantURL(core.BrandFeishu, "cli_XXXXXXXXXXXXXXXX", []string{
"im:message:readonly",
"im:message.group_at_msg",
})
want := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=im:message:readonly,im:message.group_at_msg&op_from=openapi&token_type=tenant"
if got != want {
t.Errorf("url\n got: %s\nwant: %s", got, want)
func decodeAddons(t *testing.T, encoded string) ManifestAddons {
t.Helper()
gz, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
t.Fatalf("base64url decode: %v", err)
}
zr, err := gzip.NewReader(bytes.NewReader(gz))
if err != nil {
t.Fatalf("gzip reader: %v", err)
}
raw, err := io.ReadAll(zr)
if err != nil {
t.Fatalf("gunzip: %v", err)
}
var a ManifestAddons
if err := json.Unmarshal(raw, &a); err != nil {
t.Fatalf("json: %v", err)
}
return a
}
func TestEncodeAddons_RoundTrip(t *testing.T) {
in := ManifestAddons{Scopes: &AddonsScopes{Tenant: []string{"im:message"}}}
encoded, err := encodeAddons(in)
if err != nil {
t.Fatalf("encode: %v", err)
}
for _, r := range encoded {
if !(r == '-' || r == '_' || (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) {
t.Fatalf("encoded contains non-base64url char %q in %q", r, encoded)
}
}
out := decodeAddons(t, encoded)
if out.Scopes == nil || len(out.Scopes.Tenant) != 1 || out.Scopes.Tenant[0] != "im:message" {
t.Errorf("roundtrip mismatch: %+v", out)
}
}
func TestConsoleScopeGrantURL_LarkBrand(t *testing.T) {
got := consoleScopeGrantURL(core.BrandLark, "cli_x", []string{"im:message"})
want := "https://open.larksuite.com/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant"
if got != want {
t.Errorf("url\n got: %s\nwant: %s", got, want)
func TestConsoleAddonsURL_FormatAndBrandHost(t *testing.T) {
url, err := consoleAddonsURL(core.BrandFeishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}})
if err != nil {
t.Fatalf("url: %v", err)
}
host := core.ResolveEndpoints(core.BrandFeishu).Open
prefix := host + "/page/launcher?clientID=cli_x&addons="
if !strings.HasPrefix(url, prefix) {
t.Errorf("url = %q, want prefix %q", url, prefix)
}
out := decodeAddons(t, strings.TrimPrefix(url, prefix))
if out.Callbacks == nil || len(out.Callbacks.Items) != 1 || out.Callbacks.Items[0] != "card.action.trigger" {
t.Errorf("decoded callbacks mismatch: %+v", out)
}
}
func TestConsoleScopeGrantURL_EmptyBrandDefaultsFeishu(t *testing.T) {
got := consoleScopeGrantURL("", "cli_x", []string{"im:message"})
if got != "https://open.feishu.cn/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant" {
t.Errorf("unexpected url: %s", got)
func TestMissingScopeAddons_ByIdentity(t *testing.T) {
bot := missingScopeAddons(core.AsBot, []string{"im:message"})
if bot.Scopes == nil || len(bot.Scopes.Tenant) != 1 || len(bot.Scopes.User) != 0 {
t.Errorf("bot scopes = %+v, want tenant-only", bot.Scopes)
}
user := missingScopeAddons(core.AsUser, []string{"im:message"})
if user.Scopes == nil || len(user.Scopes.User) != 1 || len(user.Scopes.Tenant) != 0 {
t.Errorf("user scopes = %+v, want user-only", user.Scopes)
}
}
func TestMissingSubscriptionAddons_EventVsCallback(t *testing.T) {
ev := missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"})
if ev.Events == nil || len(ev.Events.Items.Tenant) != 1 {
t.Errorf("event addons = %+v, want events.items.tenant", ev.Events)
}
cb := missingSubscriptionAddons(eventlib.SubTypeCallback, core.AsBot, []string{"card.action.trigger"})
if cb.Callbacks == nil || len(cb.Callbacks.Items) != 1 || cb.Events != nil {
t.Errorf("callback addons = %+v, want callbacks.items only", cb)
}
}
func TestMissingAddons_EncodeEmptyArraysNotNull(t *testing.T) {
// Unused identity sides must encode as [] (not null) so the launcher page's
// shape validation treats them as "缺省 -> 空数组" per the addons spec.
cases := []ManifestAddons{
missingScopeAddons(core.AsBot, []string{"im:message"}),
missingScopeAddons(core.AsUser, []string{"im:message"}),
missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"}),
}
for i, a := range cases {
raw, err := json.Marshal(a)
if err != nil {
t.Fatalf("case %d marshal: %v", i, err)
}
if bytes.Contains(raw, []byte("null")) {
t.Errorf("case %d encodes a null array, want []: %s", i, raw)
}
}
}

View File

@@ -146,14 +146,28 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
fmt.Fprintln(preflightErrOut, "[event] skipped console precheck: app has no published version")
}
// Callback subscriptions live in application/get, not app_versions; fetch the
// callback 底账 only for callback-type EventKeys. Weak dependency: on error,
// leave subscribedCallbacks nil so the callback precheck skips.
var subscribedCallbacks []string
if keyDef.SubscriptionType == eventlib.SubTypeCallback {
cbs, cbErr := appmeta.FetchSubscribedCallbacks(cmd.Context(), botRuntime, cfg.AppID)
if cbErr != nil {
fmt.Fprintf(preflightErrOut, "[event] skipped console precheck: %s\n", describeAppMetaErr(cbErr))
} else {
subscribedCallbacks = cbs
}
}
pf := &preflightCtx{
factory: f,
appID: cfg.AppID,
brand: cfg.Brand,
eventKey: eventKey,
identity: identity,
keyDef: keyDef,
appVer: appVer,
factory: f,
appID: cfg.AppID,
brand: cfg.Brand,
eventKey: eventKey,
identity: identity,
keyDef: keyDef,
appVer: appVer,
subscribedCallbacks: subscribedCallbacks,
}
if err := preflightEventTypes(pf); err != nil {
return err
@@ -229,6 +243,9 @@ type preflightCtx struct {
identity core.Identity
keyDef *eventlib.KeyDefinition
appVer *appmeta.AppVersion
// subscribedCallbacks is the application/get 底账 for callback-type EventKeys;
// nil means "not fetched / unavailable" → callback precheck skips (weak dependency).
subscribedCallbacks []string
}
// preflightScopes compares required scopes against session-available scopes (user: UAT stored; bot: appVer.TenantScopes).
@@ -266,46 +283,66 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
WithIdentity(string(pf.identity)).
WithMissingScopes(missing...).
WithHint("%s", scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand))
WithHint("%s", scopeRemediationHint(pf.brand, pf.appID, pf.identity, missing))
}
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
func scopeRemediationHint(identity core.Identity, missing []string, appID string, brand core.LarkBrand) string {
// Bot: the scan-to-enable link adds the scopes to the app manifest, after which
// the tenant token carries them. User: the scan link only updates the app
// manifest — the user's own token still lacks the scopes until it is
// re-authorized — so direct the user to re-login instead.
func scopeRemediationHint(brand core.LarkBrand, appID string, identity core.Identity, missing []string) string {
if identity.IsBot() {
return fmt.Sprintf(
"grant these scopes and publish a new app version at: %s",
consoleScopeGrantURL(brand, appID, missing),
)
return fmt.Sprintf("grant these scopes by scanning: %s",
addonsHintURL(brand, appID, missingScopeAddons(identity, missing)))
}
return fmt.Sprintf(
"run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.",
strings.Join(missing, " "),
)
strings.Join(missing, " "))
}
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed in the app's current published version.
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed
// in the app's console 底账 — published app_versions for event subscriptions,
// application/get subscribed_callbacks for callback subscriptions.
func preflightEventTypes(pf *preflightCtx) error {
if pf.appVer == nil || len(pf.keyDef.RequiredConsoleEvents) == 0 {
if len(pf.keyDef.RequiredConsoleEvents) == 0 {
return nil
}
subscribed := make(map[string]bool, len(pf.appVer.EventTypes))
for _, t := range pf.appVer.EventTypes {
subscribed[t] = true
var subscribed []string
noun := "event types"
if pf.keyDef.SubscriptionType == eventlib.SubTypeCallback {
if pf.subscribedCallbacks == nil {
return nil
}
subscribed = pf.subscribedCallbacks
noun = "callbacks"
} else {
if pf.appVer == nil {
return nil
}
subscribed = pf.appVer.EventTypes
}
have := make(map[string]bool, len(subscribed))
for _, t := range subscribed {
have[t] = true
}
var missing []string
for _, t := range pf.keyDef.RequiredConsoleEvents {
if !subscribed[t] {
if !have[t] {
missing = append(missing, t)
}
}
if len(missing) == 0 {
return nil
}
url := addonsHintURL(pf.brand, pf.appID, missingSubscriptionAddons(pf.keyDef.SubscriptionType, pf.identity, missing))
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))
"EventKey %s requires %s not subscribed in console: %s",
pf.keyDef.Key, noun, strings.Join(missing, ", ")).
WithHint("subscribe these %s by scanning: %s", noun, url)
}
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
@@ -349,9 +386,9 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
// 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")
errOutputDirUnsafe = errors.New("unsafe --output-dir")
errInvalidParamFormat = errors.New("invalid --param format") //nolint:forbidigo // sentinel, typed at call sites
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion") //nolint:forbidigo // sentinel, typed at call sites
errOutputDirUnsafe = errors.New("unsafe --output-dir") //nolint:forbidigo // sentinel, typed at call sites
)
func parseParams(raw []string) (map[string]string, error) {

View File

@@ -270,15 +270,15 @@ func TestExitForOrphan(t *testing.T) {
if err == nil {
t.Fatal("flag on + orphan → expected error, got nil")
}
var exit *output.ExitError
var exit *output.BareError
if !errorAs(err, &exit) || exit.Code != output.ExitValidation {
t.Errorf("exit code = %v, want ExitValidation", err)
}
}
func errorAs(err error, target interface{}) bool {
if e, ok := err.(*output.ExitError); ok {
if t, ok := target.(**output.ExitError); ok {
if e, ok := err.(*output.BareError); ok {
if t, ok := target.(**output.BareError); ok {
*t = e
return true
}

View File

@@ -97,9 +97,9 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
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"
wantURL := "https://open.feishu.cn/page/launcher?clientID=cli_XXXXXXXXXXXXXXXX&addons="
if !strings.Contains(p.Hint, wantURL) {
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, p.Hint)
t.Errorf("hint missing scan link %q\ngot: %s", wantURL, p.Hint)
}
}
@@ -157,9 +157,8 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
}
hint := permErr.Hint
wantSubstrings := []string{
"https://open.feishu.cn/app/cli_x/auth?q=",
"im:message.group_at_msg",
"token_type=tenant",
"grant these scopes by scanning: ",
"https://open.feishu.cn/page/launcher?clientID=cli_x&addons=",
}
for _, want := range wantSubstrings {
if !strings.Contains(hint, want) {
@@ -174,3 +173,109 @@ func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
t.Fatalf("no required scopes means nothing to verify, got: %v", err)
}
}
func TestPreflightEventTypes_CallbackMissing(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: []string{"profile.view.get"},
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
err := preflightEventTypes(pf)
if err == nil {
t.Fatal("expected error for missing callback")
}
if !strings.Contains(err.Error(), "callbacks not subscribed") {
t.Errorf("error = %q, want mention of 'callbacks not subscribed'", err.Error())
}
if !strings.Contains(err.Error(), "card.action.trigger") {
t.Errorf("error should name the missing callback, got: %q", err.Error())
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("problem = %v, want validation/failed_precondition", p)
}
}
func TestPreflightEventTypes_CallbackSkippedWhenNil(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: nil, // fetch 失败/拿不到 -> 弱依赖跳过
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
if err := preflightEventTypes(pf); err != nil {
t.Errorf("expected skip (nil), got %v", err)
}
}
func TestPreflightEventTypes_CallbackEmptyReportsMissing(t *testing.T) {
// fetched but zero callbacks subscribed (non-nil empty) is a definitive
// console state: a required callback IS missing and must be reported,
// not skipped as a weak dependency.
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: []string{}, // fetched, none subscribed
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
err := preflightEventTypes(pf)
if err == nil {
t.Fatal("expected error for missing callback when none are subscribed")
}
if !strings.Contains(err.Error(), "card.action.trigger") {
t.Errorf("error should name the missing callback, got: %q", err.Error())
}
}
func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: []string{"card.action.trigger", "profile.view.get"},
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
if err := preflightEventTypes(pf); err != nil {
t.Errorf("all callbacks subscribed, unexpected error: %v", err)
}
}
func TestScopeRemediationHint_ByIdentity(t *testing.T) {
// bot: scan-to-enable link (adds scopes to app manifest)
bot := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsBot, []string{"im:message"})
if !strings.Contains(bot, "/page/launcher?clientID=cli_x&addons=") {
t.Errorf("bot hint should give the scan link, got: %s", bot)
}
// user: re-login (scan link cannot grant scopes to the user's own token)
user := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsUser, []string{"im:message"})
if !strings.Contains(user, "auth login --scope") {
t.Errorf("user hint should direct to auth login, got: %s", user)
}
if strings.Contains(user, "/page/launcher") {
t.Errorf("user hint must NOT use the scan link, got: %s", user)
}
}

View File

@@ -19,12 +19,12 @@ func TestExitForOrphan_Orphan(t *testing.T) {
if err == nil {
t.Fatal("expected error when failOnOrphan=true and orphan present")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
var bareErr *output.BareError
if !errors.As(err, &bareErr) {
t.Fatalf("expected *output.BareError, got %T", err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("Code = %d, want %d", exitErr.Code, output.ExitValidation)
if bareErr.Code != output.ExitValidation {
t.Errorf("Code = %d, want %d", bareErr.Code, output.ExitValidation)
}
}

View File

@@ -5,10 +5,10 @@ package cmd
import (
"errors"
"slices"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
@@ -40,31 +40,65 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
c.Flags().Bool("dry-run", false, "")
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if exitErr.Detail.Type != "unknown_flag" {
t.Errorf("type = %q, want unknown_flag", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
if !strings.Contains(exitErr.Detail.Hint, "--range") {
t.Errorf("hint should suggest --range, got %q", exitErr.Detail.Hint)
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
detail, _ := exitErr.Detail.Detail.(map[string]any)
valid, _ := detail["valid_flags"].([]string)
if !slices.Contains(valid, "find") || !slices.Contains(valid, "range") {
t.Errorf("valid_flags should list find & range, got %v", valid)
// The offending flag is carried structurally on Params (replaces the
// legacy detail map) and named in the message.
if len(verr.Params) != 1 || verr.Params[0].Name != "--rang" {
t.Errorf("Params = %v, want one entry named --rang", verr.Params)
}
if len(verr.Params) == 1 && verr.Params[0].Reason == "" {
t.Error("Params[0].Reason must explain the rejection")
}
if !strings.Contains(verr.Message, "--rang") {
t.Errorf("message should name the offending flag, got %q", verr.Message)
}
// The ranked candidate rides on the param as a machine-readable suggestion
// so an agent can retry without parsing prose.
if len(verr.Params) == 1 {
found := false
for _, s := range verr.Params[0].Suggestions {
if s == "--range" {
found = true
}
}
if !found {
t.Errorf("Params[0].Suggestions should include --range, got %v", verr.Params[0].Suggestions)
}
}
// The same candidate is also carried in the human-facing hint.
if !strings.Contains(verr.Hint, "--range") {
t.Errorf("hint should suggest --range, got %q", verr.Hint)
}
}
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
c := &cobra.Command{Use: "demo"}
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if exitErr.Detail.Type != "flag_error" {
t.Errorf("type = %q, want flag_error (non-unknown-flag errors stay generic)", exitErr.Detail.Type)
// Non-unknown-flag errors stay generic: invalid_argument subtype, no
// structured param, generic --help hint (no "did you mean" suggestion).
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument (non-unknown-flag errors stay generic)", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if verr.Param != "" || len(verr.Params) != 0 {
t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params)
}
if strings.Contains(verr.Hint, "did you mean") {
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
}
}

View File

@@ -9,10 +9,12 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -102,7 +104,7 @@ func findLeaf(t *testing.T, parent *cobra.Command, names ...string) *cobra.Comma
}
// Happy path: a valid policy.yml denies one specific command. The denied
// command's RunE returns a typed ExitError envelope; allowed commands are
// command's RunE returns a typed error envelope; allowed commands are
// untouched.
func TestApplyUserPolicyPruning_appliesValidPolicy(t *testing.T) {
cfgDir := tmpHome(t)
@@ -127,13 +129,27 @@ max_risk: write
if err == nil {
t.Fatalf("+delete-doc RunE should return an error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "command_denied" {
t.Fatalf("expected command_denied ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok || detail["reason_code"] != "command_denylisted" {
t.Errorf("reason_code = %v, want command_denylisted", detail["reason_code"])
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// The denial taxonomy (reason_code, layer, rule) is preserved on the
// wrapped *platform.CommandDeniedError cause and folded into the hint.
var cd *platform.CommandDeniedError
if !errors.As(err, &cd) {
t.Fatalf("error chain should expose *platform.CommandDeniedError")
}
if cd.ReasonCode != "command_denylisted" {
t.Errorf("CommandDeniedError.ReasonCode = %q, want command_denylisted", cd.ReasonCode)
}
if !strings.Contains(verr.Hint, "command_denylisted") {
t.Errorf("hint should surface reason_code command_denylisted, got %q", verr.Hint)
}
// im/+send must be denied (domain not in Allow).

View File

@@ -8,9 +8,9 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
internalplatform "github.com/larksuite/cli/internal/platform"
)
@@ -34,16 +34,8 @@ import (
// lands directly on their RunE, which now carries the guard.
//
// makeErr is called for every guarded dispatch; it must return a fresh
// *output.ExitError each time (the envelope writer mutates a few fields
// as it serialises).
// Deprecated: installFatalGuard accepts a *output.ExitError-producing lambda,
// which is part of the legacy error surface that predates the typed error
// contract introduced by errs/. New code MUST NOT add new callers — the
// platform-extension fatal-guard plumbing will switch to typed errs.* errors
// when the platform-extension framework migrates. This wrapper is retained
// only for the existing in-tree call sites; it will be removed once they
// have moved to the typed surface.
func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError) {
// typed error each time.
func installFatalGuard(rootCmd *cobra.Command, makeErr func() error) {
// Two cobra subcommands are injected lazily at Execute() time and
// would otherwise slip past walkGuard. We pre-register both so
// walkGuard catches them.
@@ -80,120 +72,65 @@ func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError)
}
// installPluginInstallErrorGuard surfaces a FailClosed plugin install
// failure as a structured plugin_install envelope before any command
// runs.
// Deprecated: installPluginInstallErrorGuard produces a legacy
// *output.ExitError via its internal makeErr lambda. New code MUST NOT add
// such producers — plugin install failures should surface as a typed
// *errs.XxxError once the platform-extension framework migrates. This
// helper is retained only while existing call sites are migrated; it will
// be removed once they have moved to the typed surface.
// failure as a typed validation error (failed_precondition) before any
// command runs.
func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) {
makeErr := func() *output.ExitError {
makeErr := func() error {
var pi *internalplatform.PluginInstallError
if errors.As(installErr, &pi) {
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "plugin_install",
Message: pi.Error(),
Detail: map[string]any{
"plugin": pi.PluginName,
"reason_code": pi.ReasonCode,
"reason": pi.Reason,
},
},
Err: installErr,
}
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "plugin_install",
Message: installErr.Error(),
Detail: map[string]any{
"reason_code": internalplatform.ReasonInstallFailed,
},
},
Err: installErr,
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", pi.Error()).
WithHint("plugin %q failed to install (reason_code %s); fix or remove the plugin before running commands", pi.PluginName, pi.ReasonCode).
WithCause(installErr)
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", installErr.Error()).
WithHint("a plugin failed to install (reason_code %s); fix or remove the plugin before running commands", internalplatform.ReasonInstallFailed).
WithCause(installErr)
}
installFatalGuard(rootCmd, makeErr)
}
// installPluginConflictGuard surfaces a Plugin.Restrict() configuration
// error (single plugin invalid Rule or multiple plugins each contributing
// Restrict). The design separates the envelope type:
// Restrict). The hint separates the two failure modes by reason code:
//
// - "plugin_install" with reason_code "invalid_rule" - single bad rule
// - "plugin_conflict" with reason_code "multiple_restrict_plugins" - multi
// - "invalid_rule" - single bad rule
// - "multiple_restrict_plugins" - multiple Restrict plugins conflict
//
// Either way the CLI must NOT silently continue with a broken policy.
// Deprecated: installPluginConflictGuard produces a legacy *output.ExitError
// via its internal makeErr lambda. New code MUST NOT add such producers —
// plugin conflict failures should surface as a typed *errs.XxxError once the
// platform-extension framework migrates. This helper is retained only while
// existing call sites are migrated; it will be removed once they have moved
// to the typed surface.
func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
makeErr := func() *output.ExitError {
envelopeType := "plugin_install"
makeErr := func() error {
reasonCode := internalplatform.ReasonInvalidRule
if errors.Is(err, cmdpolicy.ErrMultipleRestricts) {
envelopeType = "plugin_conflict"
reasonCode = internalplatform.ReasonMultipleRestricts
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: envelopeType,
Message: err.Error(),
Detail: map[string]any{
"reason_code": reasonCode,
},
},
Err: err,
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
WithHint("plugin policy configuration is broken (reason_code %s); fix the plugin's Restrict rule or remove the conflicting plugin", reasonCode).
WithCause(err)
}
installFatalGuard(rootCmd, makeErr)
}
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
// failure as a plugin_lifecycle envelope. The reason_code splits
// returned-error vs panic so consumers (audit / on-call) can tell the
// two failure modes apart.
// Deprecated: installPluginLifecycleErrorGuard produces a legacy
// *output.ExitError via its internal makeErr lambda. New code MUST NOT add
// such producers — plugin lifecycle failures should surface as a typed
// *errs.XxxError once the platform-extension framework migrates. This
// helper is retained only while existing call sites are migrated; it will
// be removed once they have moved to the typed surface.
// failure as a typed validation error (failed_precondition). The hint's
// reason code splits returned-error vs panic so consumers (audit /
// on-call) can tell the two failure modes apart.
func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
makeErr := func() *output.ExitError {
makeErr := func() error {
reasonCode := "lifecycle_failed"
detail := map[string]any{
"reason_code": reasonCode,
}
hookName := ""
var le *hook.LifecycleError
if errors.As(err, &le) {
if le.Panic {
reasonCode = "lifecycle_panic"
}
detail = map[string]any{
"reason_code": reasonCode,
"hook_name": le.HookName,
"event": "startup",
}
hookName = le.HookName
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "plugin_lifecycle",
Message: err.Error(),
Detail: detail,
},
Err: err,
typed := errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
WithCause(err)
if hookName != "" {
return typed.WithHint("plugin startup hook %q failed (reason_code %s); fix or remove the plugin before running commands", hookName, reasonCode)
}
return typed.WithHint("a plugin startup hook failed (reason_code %s); fix or remove the plugin before running commands", reasonCode)
}
installFatalGuard(rootCmd, makeErr)
}
@@ -219,14 +156,7 @@ func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
//
// This way the very first non-nil step in cobra's chain is always our
// guard, regardless of which leaf the user invoked.
// Deprecated: walkGuard accepts a *output.ExitError-producing lambda, part
// of the legacy error surface that predates the typed error contract
// introduced by errs/. New code MUST NOT add new callers — the platform-
// extension guard plumbing will switch to typed errs.* errors when the
// platform-extension framework migrates. This wrapper is retained only for
// the existing in-tree call sites; it will be removed once they have moved
// to the typed surface.
func walkGuard(cmd *cobra.Command, makeErr func() *output.ExitError) {
func walkGuard(cmd *cobra.Command, makeErr func() error) {
if cmd == nil {
return
}

View File

@@ -6,12 +6,14 @@ package cmd
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
@@ -32,7 +34,7 @@ func (failClosedAbortingPlugin) Install(platform.Registrar) error {
}
// When a FailClosed plugin fails to install, buildInternal must
// install a PersistentPreRunE that returns a structured *output.ExitError.
// install a PersistentPreRunE that returns a typed *errs.ValidationError.
// The user must NEVER see a silent partial-install state.
//
// This pins the build.go fix for codex's NEW ISSUE about
@@ -93,26 +95,31 @@ func TestBuildInternal_failClosedAbortsCLI(t *testing.T) {
checkGuardError(t, leaf.RunE(leaf, nil))
}
// checkGuardError asserts that err is the structured plugin_install
// ExitError the guard produces.
// checkGuardError asserts that err is the typed validation error the
// install guard produces: a failed_precondition *errs.ValidationError
// (exit 2) whose message + hint preserve the plugin name and the
// install_failed reason code (the recovery info that lived in the legacy
// detail map).
func checkGuardError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatalf("PersistentPreRunE must surface the install error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "plugin_install" {
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
detail := exitErr.Detail.Detail.(map[string]any)
if detail["plugin"] != "policy" {
t.Errorf("detail.plugin = %v, want policy", detail["plugin"])
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if detail["reason_code"] != internalplatform.ReasonInstallFailed {
t.Errorf("detail.reason_code = %v, want install_failed", detail["reason_code"])
if !strings.Contains(verr.Hint, "policy") {
t.Errorf("hint should name the failing plugin %q, got %q", "policy", verr.Hint)
}
if !strings.Contains(verr.Hint, internalplatform.ReasonInstallFailed) {
t.Errorf("hint should surface reason_code %q, got %q", internalplatform.ReasonInstallFailed, verr.Hint)
}
}

View File

@@ -8,11 +8,13 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -156,19 +158,23 @@ func TestPluginPipeline_wrapAbortReachesEnvelope(t *testing.T) {
}
err = leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "hook" {
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
detail := exitErr.Detail.Detail.(map[string]any)
if detail["reason_code"] != "aborted" {
t.Errorf("detail.reason_code = %v, want aborted", detail["reason_code"])
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if detail["hook_name"] != "policy-plugin.policy" {
t.Errorf("detail.hook_name = %v, want policy-plugin.policy", detail["hook_name"])
// The namespaced hook name and the abort semantics are preserved in the
// message so a caller can identify which plugin hook rejected the call.
if !strings.Contains(verr.Message, "policy-plugin.policy") {
t.Errorf("message should name the aborting hook policy-plugin.policy, got %q", verr.Message)
}
if !strings.Contains(verr.Message, "aborted") {
t.Errorf("message should describe the abort, got %q", verr.Message)
}
// errors.As must still reach the original AbortError so consumers
@@ -409,15 +415,20 @@ func TestPluginConflictGuard_MultipleRestrictAbortsCLI(t *testing.T) {
t.Fatalf("no runnable leaf in command tree")
}
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "plugin_conflict" {
t.Errorf("envelope type = %q, want plugin_conflict", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "multiple_restrict_plugins" {
t.Errorf("reason_code = %v, want multiple_restrict_plugins", rc)
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// reason_code multiple_restrict_plugins is folded into the hint so the
// operator can distinguish a multi-Restrict conflict from a bad rule.
if !strings.Contains(verr.Hint, "multiple_restrict_plugins") {
t.Errorf("hint should surface reason_code multiple_restrict_plugins, got %q", verr.Hint)
}
}
@@ -447,15 +458,20 @@ func TestPluginConflictGuard_InvalidRuleAbortsCLI(t *testing.T) {
t.Fatalf("no runnable leaf in command tree")
}
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "plugin_install" {
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "invalid_rule" {
t.Errorf("reason_code = %v, want invalid_rule", rc)
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// reason_code invalid_rule is folded into the hint, distinct from the
// multiple_restrict_plugins conflict path.
if !strings.Contains(verr.Hint, "invalid_rule") {
t.Errorf("hint should surface reason_code invalid_rule, got %q", verr.Hint)
}
}
@@ -484,19 +500,24 @@ func TestPluginLifecycleGuard_StartupErrorAbortsCLI(t *testing.T) {
leaf := findRunnableLeaf(root)
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "plugin_lifecycle" {
t.Errorf("envelope type = %q, want plugin_lifecycle", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
d := exitErr.Detail.Detail.(map[string]any)
if d["reason_code"] != "lifecycle_failed" {
t.Errorf("reason_code = %v, want lifecycle_failed", d["reason_code"])
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if d["hook_name"] != "lc.start" {
t.Errorf("hook_name = %v, want lc.start", d["hook_name"])
// reason_code lifecycle_failed (vs lifecycle_panic) and the failing
// hook name are folded into the hint so audit / on-call can tell the
// failure mode and which hook failed.
if !strings.Contains(verr.Hint, "lifecycle_failed") {
t.Errorf("hint should surface reason_code lifecycle_failed, got %q", verr.Hint)
}
if !strings.Contains(verr.Hint, "lc.start") {
t.Errorf("hint should name the failing hook lc.start, got %q", verr.Hint)
}
}
@@ -520,12 +541,20 @@ func TestPluginLifecycleGuard_StartupPanicAbortsCLI(t *testing.T) {
}
leaf := findRunnableLeaf(root)
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "lifecycle_panic" {
t.Errorf("reason_code = %v, want lifecycle_panic", rc)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// A panicking startup hook is distinguished from a returned error by
// reason_code lifecycle_panic in the hint.
if !strings.Contains(verr.Hint, "lifecycle_panic") {
t.Errorf("hint should surface reason_code lifecycle_panic, got %q", verr.Hint)
}
}
@@ -579,19 +608,24 @@ func TestWrapperPanic_BecomesHookPanicEnvelope(t *testing.T) {
}()
err = leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "hook" {
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
d := exitErr.Detail.Detail.(map[string]any)
if d["reason_code"] != "panic" {
t.Errorf("reason_code = %v, want panic", d["reason_code"])
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if d["hook_name"] != "p.boom" {
t.Errorf("hook_name = %v, want p.boom (namespaced)", d["hook_name"])
// The recovered panic surfaces as a structured error naming the
// namespaced hook (p.boom) and describing the panic, so the process
// never crashes and the caller can attribute the failure.
if !strings.Contains(verr.Message, "p.boom") {
t.Errorf("message should name the namespaced hook p.boom, got %q", verr.Message)
}
if !strings.Contains(verr.Message, "panic") {
t.Errorf("message should describe the panic, got %q", verr.Message)
}
}
@@ -653,19 +687,24 @@ func TestWrapperFactoryPanic_BecomesHookPanicEnvelope(t *testing.T) {
}()
err = leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "hook" {
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
d := exitErr.Detail.Detail.(map[string]any)
if d["reason_code"] != "panic" {
t.Errorf("reason_code = %v, want panic", d["reason_code"])
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if d["hook_name"] != "fac.bad-factory" {
t.Errorf("hook_name = %v, want fac.bad-factory (namespaced)", d["hook_name"])
// A panic in the wrapper FACTORY (not just the inner handler) is
// recovered into the same structured panic error, naming the
// namespaced hook fac.bad-factory.
if !strings.Contains(verr.Message, "fac.bad-factory") {
t.Errorf("message should name the namespaced hook fac.bad-factory, got %q", verr.Message)
}
if !strings.Contains(verr.Message, "panic") {
t.Errorf("message should describe the panic, got %q", verr.Message)
}
}

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/i18n"
@@ -53,7 +54,9 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, brand, lang string, useAfter bool) error {
if err := core.ValidateProfileName(name); err != nil {
return output.ErrValidation("%v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).
WithCause(err).
WithParam("--name")
}
langPref, err := cmdutil.ParseLangFlag(lang)
@@ -64,46 +67,57 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
// Read secret from stdin
if !appSecretStdin {
return output.ErrValidation("app secret must be provided via stdin: use --app-secret-stdin and pipe the secret")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret must be provided via stdin").
WithHint("use --app-secret-stdin and pipe the secret").
WithParam("--app-secret-stdin")
}
scanner := bufio.NewScanner(f.IOStreams.In)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return output.ErrValidation("failed to read secret from stdin: %v", err)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "failed to read secret from stdin: %v", err).
WithCause(err).
WithParam("--app-secret-stdin")
}
return output.ErrValidation("stdin is empty, expected app secret")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "stdin is empty, expected app secret").
WithHint("pipe the app secret to stdin").
WithParam("--app-secret-stdin")
}
appSecret := strings.TrimSpace(scanner.Text())
if appSecret == "" {
return output.ErrValidation("app secret read from stdin is empty")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret read from stdin is empty").
WithHint("pipe a non-empty app secret to stdin").
WithParam("--app-secret-stdin")
}
// Load or create config
multi, err := core.LoadMultiAppConfig()
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return output.Errorf(output.ExitInternal, "internal", "failed to load config: %v", err)
return errs.NewInternalError(errs.SubtypeFileIO, "failed to load config: %v", err).WithCause(err)
}
multi = &core.MultiAppConfig{}
}
// Check name uniqueness
if multi.FindApp(name) != nil {
return output.ErrValidation("profile %q already exists", name)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "profile %q already exists", name).
WithHint("choose a different name, or remove the existing profile first").
WithParam("--name")
}
// Check app-id uniqueness — keychain stores secrets by appId, so
// multiple profiles sharing the same appId would collide on credentials.
for _, a := range multi.Apps {
if a.AppId == appID {
return output.ErrValidation("app-id %q is already used by profile %q; each profile must have a unique app-id", appID, a.ProfileName())
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "app-id %q is already used by profile %q; each profile must have a unique app-id", appID, a.ProfileName()).
WithParam("--app-id")
}
}
// Store secret securely
secret, err := core.ForStorage(appID, core.PlainSecret(appSecret), f.Keychain)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
return errs.NewInternalError(errs.SubtypeStorage, "%v", err).WithCause(err)
}
parsedBrand := core.ParseBrand(brand)
@@ -134,7 +148,7 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
}
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile %q added (%s, %s)", name, appID, parsedBrand))

View File

@@ -9,6 +9,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -45,7 +46,7 @@ func profileListRun(f *cmdutil.Factory) error {
output.PrintJson(f.IOStreams.Out, []profileListItem{})
return nil
}
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "failed to load config: %v", err).WithCause(err)
}
if multi == nil || len(multi.Apps) == 0 {
output.PrintJson(f.IOStreams.Out, []profileListItem{})

View File

@@ -11,6 +11,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
@@ -50,6 +51,16 @@ func TestProfileAddRun_InvalidExistingConfigReturnsError(t *testing.T) {
if !strings.Contains(err.Error(), "failed to load config") {
t.Fatalf("error = %v, want failed to load config", err)
}
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("error type = %T, want *errs.InternalError; err=%v", err, err)
}
if internalErr.Subtype != errs.SubtypeFileIO {
t.Fatalf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeFileIO)
}
if code := output.ExitCodeOf(err); code != output.ExitInternal {
t.Fatalf("exit code = %d, want %d (ExitInternal)", code, output.ExitInternal)
}
}
// TestProfileAddRun_Lang covers the unified --lang contract on profile add:
@@ -95,9 +106,9 @@ func TestProfileAddRun_Lang(t *testing.T) {
if err == nil {
t.Fatal("expected validation error for --lang ZH, got nil")
}
exitErr, ok := err.(*output.ExitError)
if !ok || exitErr.Code != output.ExitValidation {
t.Fatalf("expected ExitValidation, got %T: %v", err, err)
var valErr *errs.ValidationError
if !errors.As(err, &valErr) || output.ExitCodeOf(err) != output.ExitValidation {
t.Fatalf("expected typed validation error with ExitValidation, got %T: %v", err, err)
}
})
}
@@ -406,17 +417,226 @@ func TestProfileUseRun_SaveFailureReturnsStructuredError(t *testing.T) {
func assertInternalExitError(t *testing.T, err error, wantMsg string) {
t.Helper()
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError; err=%v", err, err)
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("error type = %T, want *errs.InternalError; err=%v", err, err)
}
if exitErr.Code != output.ExitInternal {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
if internalErr.Subtype != errs.SubtypeStorage {
t.Fatalf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeStorage)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "internal" {
t.Fatalf("detail = %#v, want internal detail", exitErr.Detail)
if internalErr.Cause == nil {
t.Fatalf("cause = nil, want wrapped underlying error")
}
if !strings.Contains(exitErr.Detail.Message, wantMsg) {
t.Fatalf("message = %q, want contains %q", exitErr.Detail.Message, wantMsg)
if !strings.Contains(internalErr.Message, wantMsg) {
t.Fatalf("message = %q, want contains %q", internalErr.Message, wantMsg)
}
if code := output.ExitCodeOf(err); code != output.ExitInternal {
t.Fatalf("exit code = %d, want %d (ExitInternal)", code, output.ExitInternal)
}
}
// assertValidationError asserts err is a typed *errs.ValidationError with the
// given subtype, message fragment, and exit code 2.
func assertValidationError(t *testing.T, err error, wantSubtype errs.Subtype, wantMsg string) *errs.ValidationError {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("error type = %T, want *errs.ValidationError; err=%v", err, err)
}
if valErr.Subtype != wantSubtype {
t.Fatalf("subtype = %q, want %q", valErr.Subtype, wantSubtype)
}
if !strings.Contains(valErr.Message, wantMsg) {
t.Fatalf("message = %q, want contains %q", valErr.Message, wantMsg)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
return valErr
}
func saveTwoProfiles(t *testing.T) {
t.Helper()
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}
func TestProfileAddRun_ValidationErrors(t *testing.T) {
t.Run("invalid profile name", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
err := profileAddRun(f, "bad name!", "app-x", true, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "")
if valErr.Param != "--name" {
t.Fatalf("param = %q, want %q", valErr.Param, "--name")
}
if valErr.Cause == nil {
t.Fatal("cause = nil, want wrapped validation error")
}
})
t.Run("missing app-secret-stdin flag", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileAddRun(f, "p", "app-x", false, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "app secret must be provided via stdin")
if valErr.Param != "--app-secret-stdin" {
t.Fatalf("param = %q, want %q", valErr.Param, "--app-secret-stdin")
}
if valErr.Hint == "" {
t.Fatal("hint is empty, want actionable hint")
}
})
t.Run("empty stdin", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("")
err := profileAddRun(f, "p", "app-x", true, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "stdin is empty")
if valErr.Param != "--app-secret-stdin" {
t.Fatalf("param = %q, want %q", valErr.Param, "--app-secret-stdin")
}
})
t.Run("blank secret on stdin", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader(" \n")
err := profileAddRun(f, "p", "app-x", true, "feishu", "", false)
assertValidationError(t, err, errs.SubtypeInvalidArgument, "app secret read from stdin is empty")
})
t.Run("duplicate profile name", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
err := profileAddRun(f, "default", "app-new", true, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, `profile "default" already exists`)
if valErr.Param != "--name" {
t.Fatalf("param = %q, want %q", valErr.Param, "--name")
}
})
t.Run("duplicate app-id", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
err := profileAddRun(f, "fresh", "app-default", true, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "already used by profile")
if valErr.Param != "--app-id" {
t.Fatalf("param = %q, want %q", valErr.Param, "--app-id")
}
})
}
func TestProfileUseRun_ValidationErrors(t *testing.T) {
t.Run("no previous profile for toggle", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileUseRun(f, "-")
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "no previous profile to switch back to")
if valErr.Hint == "" {
t.Fatal("hint is empty, want actionable hint")
}
})
t.Run("profile not found", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileUseRun(f, "ghost")
assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`)
})
}
func TestProfileRenameRun_ValidationErrors(t *testing.T) {
t.Run("invalid new name", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRenameRun(f, "default", "bad name!")
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "")
if valErr.Cause == nil {
t.Fatal("cause = nil, want wrapped validation error")
}
})
t.Run("old profile not found", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRenameRun(f, "ghost", "fresh")
assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`)
})
t.Run("new name already exists", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRenameRun(f, "default", "target")
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, `profile "target" already exists`)
if valErr.Hint == "" {
t.Fatal("hint is empty, want actionable hint")
}
})
}
func TestProfileRemoveRun_ValidationErrors(t *testing.T) {
t.Run("profile not found", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRemoveRun(f, "ghost")
assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`)
})
t.Run("cannot remove the only profile", func(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
CurrentApp: "solo",
Apps: []core.AppConfig{
{Name: "solo", AppId: "app-solo", AppSecret: core.PlainSecret("secret-solo"), Brand: core.BrandFeishu},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRemoveRun(f, "solo")
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "cannot remove the only profile")
if valErr.Hint == "" {
t.Fatal("hint is empty, want actionable hint")
}
})
}
func TestProfileListRun_InvalidConfigReturnsValidationError(t *testing.T) {
dir := setupProfileConfigDir(t)
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid json"), 0600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileListRun(f)
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "failed to load config")
if valErr.Cause == nil {
t.Fatal("cause = nil, want wrapped load error")
}
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -40,11 +41,12 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
idx := multi.FindAppIndex(name)
if idx < 0 {
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
}
if len(multi.Apps) == 1 {
return output.ErrValidation("cannot remove the only profile")
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "cannot remove the only profile").
WithHint("add another profile first: lark-cli profile add")
}
app := &multi.Apps[idx]
@@ -65,7 +67,7 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
}
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
// Best-effort credential cleanup after config commit

View File

@@ -9,6 +9,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/output"
@@ -30,7 +31,7 @@ func NewCmdProfileRename(f *cmdutil.Factory) *cobra.Command {
func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
if err := core.ValidateProfileName(newName); err != nil {
return output.ErrValidation("%v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err)
}
multi, err := core.LoadOrNotConfigured()
@@ -40,7 +41,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
idx := multi.FindAppIndex(oldName)
if idx < 0 {
return output.ErrValidation("profile %q not found, available profiles: %s", oldName, strings.Join(multi.ProfileNames(), ", "))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", oldName, strings.Join(multi.ProfileNames(), ", "))
}
// Check new name uniqueness across other profiles, allowing renames to this
@@ -50,7 +51,8 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
continue
}
if multi.Apps[i].Name == newName || multi.Apps[i].AppId == newName {
return output.ErrValidation("profile %q already exists", newName)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "profile %q already exists", newName).
WithHint("choose a different name")
}
}
@@ -66,7 +68,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
}
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile renamed: %q -> %q", oldProfileName, newName))

View File

@@ -9,6 +9,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/output"
@@ -40,14 +41,15 @@ func profileUseRun(f *cmdutil.Factory, name string) error {
// Handle "-" for toggle-back
if name == "-" {
if multi.PreviousApp == "" {
return output.ErrValidation("no previous profile to switch back to")
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "no previous profile to switch back to").
WithHint("switch to a profile by name first: lark-cli profile use <name>")
}
name = multi.PreviousApp
}
app := multi.FindApp(name)
if app == nil {
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
}
targetName := app.ProfileName()
@@ -66,7 +68,7 @@ func profileUseRun(f *cmdutil.Factory, name string) error {
multi.CurrentApp = targetName
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Switched to profile %q (%s, %s)", targetName, app.AppId, app.Brand))

View File

@@ -9,10 +9,10 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// pruneForStrictMode removes commands incompatible with the active strict mode.
@@ -65,10 +65,10 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
// pick auth's instead of our denial. A leaf-level no-op makes
// cobra stop here and proceed to the wrapped RunE.
//
// strict-mode keeps its short Message + independent Hint and
// composes the shared detail.* / wrapped-CommandDeniedError shape
// by hand; BuildDenialError would override Message with the
// CommandDeniedError.Error() long form.
// strict-mode keeps its short Message + independent Hint and wraps
// the CommandDeniedError as the Cause by hand; BuildDenialError
// would override Message with the CommandDeniedError.Error() long
// form.
stubMessage := fmt.Sprintf(
"strict mode is %q, only %s-identity commands are available",
mode, mode.ForcedIdentity())
@@ -105,20 +105,9 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
},
RunE: func(c *cobra.Command, _ []string) error {
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
// Legacy *output.ExitError producer: this literal predates the
// typed error contract introduced by errs/. New denial sites MUST
// NOT construct *output.ExitError directly — they should return a
// typed *errs.XxxError once the cmdpolicy framework migrates.
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "command_denied",
Message: stubMessage,
Hint: stubHint,
Detail: cmdpolicy.DenialDetailMap(cd),
},
Err: cd,
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", stubMessage).
WithHint("denied by %s policy (reason_code %s); %s", cd.Layer, cd.ReasonCode, stubHint).
WithCause(cd)
},
}
}

View File

@@ -8,6 +8,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -247,9 +248,12 @@ func TestStrictModeStub_BypassesArgsValidator(t *testing.T) {
}
}
// Pins the strict-mode envelope shape: structured detail.* / wrapped
// CommandDeniedError for external agents, AND the historical short
// Message + independent Hint for existing consumers.
// Pins the strict-mode typed envelope: a failed_precondition
// *errs.ValidationError (exit 2) carrying the short historical Message,
// a Hint that still surfaces the policy layer + reason code (the
// safety-critical recovery info that lived in the legacy detail map),
// and the wrapped *platform.CommandDeniedError so external agents can
// still inspect the structured denial taxonomy via errors.As.
func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
root := newTestTree()
pruneForStrictMode(root, core.StrictModeBot)
@@ -262,30 +266,33 @@ func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
t.Fatalf("strict-mode stub RunE should return error")
}
var ee *output.ExitError
if !errors.As(err, &ee) {
t.Fatalf("err is not *output.ExitError: %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("err is not *errs.ValidationError: %T", err)
}
if ee.Detail == nil {
t.Fatalf("ExitError.Detail is nil; envelope writer cannot emit JSON")
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if ee.Detail.Type != "command_denied" {
t.Errorf("Detail.Type = %q, want command_denied", ee.Detail.Type)
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
dm, ok := ee.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("Detail.Detail = %T, want map[string]any", ee.Detail.Detail)
// Short historical Message is preserved verbatim.
if verr.Message != `strict mode is "bot", only bot-identity commands are available` {
t.Errorf("Message = %q, want short historical form", verr.Message)
}
if got, _ := dm["layer"].(string); got != cmdpolicy.LayerStrictMode {
t.Errorf("Detail.Detail[layer] = %q, want %q", got, cmdpolicy.LayerStrictMode)
// The denial layer + reason code remain user-readable in the hint, and
// the historical switch-policy guidance is still appended.
if !strings.Contains(verr.Hint, cmdpolicy.LayerStrictMode) {
t.Errorf("Hint = %q, want substring %q (policy layer)", verr.Hint, cmdpolicy.LayerStrictMode)
}
if got, _ := dm["reason_code"].(string); got != "identity_not_supported" {
t.Errorf("Detail.Detail[reason_code] = %q, want identity_not_supported", got)
if !strings.Contains(verr.Hint, "identity_not_supported") {
t.Errorf("Hint = %q, want substring identity_not_supported (reason code)", verr.Hint)
}
if got, _ := dm["policy_source"].(string); got != "strict-mode" {
t.Errorf("Detail.Detail[policy_source] = %q, want strict-mode", got)
if !strings.Contains(verr.Hint, "if the user explicitly wants to switch policy") {
t.Errorf("Hint = %q, want historical switch-policy guidance", verr.Hint)
}
// The structured denial taxonomy survives on the wrapped cause.
var cd *platform.CommandDeniedError
if !errors.As(err, &cd) {
t.Fatalf("err does not unwrap to *platform.CommandDeniedError")
@@ -296,15 +303,12 @@ func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
if cd.ReasonCode != "identity_not_supported" {
t.Errorf("CommandDeniedError.ReasonCode = %q, want identity_not_supported", cd.ReasonCode)
}
if cd.PolicySource != "strict-mode" {
t.Errorf("CommandDeniedError.PolicySource = %q, want strict-mode", cd.PolicySource)
}
if !strings.Contains(cd.Reason, `strict mode is "bot"`) {
t.Errorf("CommandDeniedError.Reason = %q, want substring 'strict mode is \"bot\"'", cd.Reason)
}
if ee.Detail.Message != `strict mode is "bot", only bot-identity commands are available` {
t.Errorf("Detail.Message = %q, want short historical form", ee.Detail.Message)
}
if !strings.HasPrefix(ee.Detail.Hint, "if the user explicitly wants to switch policy") {
t.Errorf("Detail.Hint = %q, want historical hint", ee.Detail.Hint)
}
}
// strictModeStubFrom must write the denial annotations so the hook

View File

@@ -13,17 +13,12 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/errcompat"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/internal/update"
@@ -36,7 +31,7 @@ const rootLong = `lark-cli — Lark/Feishu CLI tool.
USAGE:
lark-cli <command> [subcommand] [method] [options]
lark-cli api <method> <path> [--params <json>] [--data <json>]
lark-cli schema <service.resource.method> [--format pretty]
lark-cli schema <service.resource.method>
EXAMPLES:
# View upcoming events
@@ -217,56 +212,37 @@ func configureFlagCompletions(args []string) {
// and returns the process exit code.
//
// Dispatch order:
// 1. Legacy shapes (*core.ConfigError, *internalauth.NeedAuthorizationError)
// are promoted via errcompat to their typed errs/ counterparts, with the
// original preserved in the Cause chain.
// 2. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError,
// *errs.SecurityPolicyError, *errs.AuthenticationError): render via the
// typed envelope writer, which lifts extension fields (missing_scopes,
// console_url, challenge_url, ...) to the top level. Routed by
// errs.CategoryOf via ExitCodeOf.
// 3. Legacy *output.ExitError: asExitError adapts it to the legacy
// envelope, written via WriteErrorEnvelope.
// 4. Cobra errors (required flags, unknown commands, etc.): plain text.
// 1. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError,
// *errs.SecurityPolicyError, *errs.AuthenticationError, *errs.ConfigError):
// render via the typed envelope writer, which lifts extension fields
// (missing_scopes, console_url, challenge_url, ...) to the top level.
// Routed by errs.CategoryOf via ExitCodeOf. Auth and config errors are
// constructed typed at their origin (internal/auth, internal/core), so the
// dispatcher no longer promotes any legacy shape here.
// 2. PartialFailure / BareError signals: the result envelope is already on
// stdout; honor the exit code and write nothing to stderr.
// 3. Residual cobra usage errors (missing required flag, unknown command,
// argument validation): typed as an invalid_argument envelope (exit 2),
// matching the explicit flag/subcommand guards. Flag parse errors are
// already typed upstream by the root FlagErrorFunc.
func handleRootError(f *cmdutil.Factory, err error) int {
errOut := f.IOStreams.ErrOut
// Promote legacy error shapes into typed errs/ before envelope marshal.
// NeedAuthorizationError check is first because it is the more specific
// shape; *core.ConfigError check follows. errors.As preserves the original
// in the Cause chain, so external errors.As(&core.ConfigError{}) consumers
// (cmd/auth/list.go, cmd/doctor/doctor.go, ...) still match.
//
// Outer-typed short-circuit: if err is already a typed *errs.* error,
// skip PromoteXxxError so the producer's Subtype / Hint / extension
// fields are not overwritten by a coarser promoted shape derived from a
// legacy error buried in its Cause chain. Promotion is only for legacy
// untyped entry points.
if !isOuterTypedError(err) {
var needAuthErr *internalauth.NeedAuthorizationError
if errors.As(err, &needAuthErr) {
err = errcompat.PromoteAuthError(needAuthErr)
} else {
var cfgErr *core.ConfigError
if errors.As(err, &cfgErr) {
err = errcompat.PromoteConfigError(cfgErr)
}
}
}
// When the typed error is a need_user_authorization signal, fold in the
// current command's declared scopes as a Hint so the user/AI sees the
// concrete scope(s) to re-auth with. The hint is computed on the fly from
// local shortcut/service metadata — it never depends on server state.
applyNeedAuthorizationHint(f, err)
if !errs.IsRaw(err) {
applyNeedAuthorizationHint(f, err)
}
// Staged dispatch: capture the typed exit code BEFORE attempting the
// envelope write. WriteTypedErrorEnvelope is best-effort on the wire
// (partial-write still returns true) so the exit code we read here is
// preserved even if stderr is torn — torn stderr must not downgrade
// typed exits 3/4/6/10 to the legacy "Error:" path with exit 1.
// typed exits 3/4/6/10 to the plain "Error:" path with exit 1.
// WriteTypedErrorEnvelope still returns false when err carries no
// Problem; in that case we fall through to the legacy bridge below.
// Problem; in that case we fall through to the signal / plain-text paths.
typedExit := output.ExitCodeOf(err)
if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) {
return typedExit
@@ -279,58 +255,63 @@ func handleRootError(f *cmdutil.Factory, err error) int {
return pfErr.Code
}
if exitErr := asExitError(err); exitErr != nil {
if !exitErr.Raw {
// Raw errors (e.g. from `api` command via output.MarkRaw)
// preserve the original API error detail; skip enrichment
// which would clear it.
enrichMissingScopeError(f, exitErr)
enrichPermissionError(f, exitErr)
// Silent-exit signal (e.g. `auth check` predicate, or `update --json`):
// stdout already carries the result; honor the requested exit code and
// write nothing to stderr.
var bareErr *output.BareError
if errors.As(err, &bareErr) {
return bareErr.Code
}
// Errors reaching here are untyped: every RunE returns a typed errs.* error
// and flag-parse errors are typed by the root FlagErrorFunc. The remainder
// is either a cobra usage mistake (missing required flag, unknown command,
// wrong arg count), which cobra surfaces as a plain error identified by its
// stable text — the same external contract unknownFlagName relies on — or an
// untyped error that leaked past the typed boundary. Classify the former as
// invalid_argument (exit 2, like the explicit guards); treat the latter as an
// internal fault (exit 5) rather than blaming the user's input. The message
// is preserved either way, and the typed envelope still carries any pending
// deprecation notice.
var fallback error
if isCobraUsageError(err) {
fallback = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error())
} else {
fallback = errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err)
}
output.WriteTypedErrorEnvelope(errOut, fallback, string(f.ResolvedIdentity))
return output.ExitCodeOf(fallback)
}
// cobraUsageErrorMarkers are the stable error-text fragments cobra / pflag
// (pinned at v1.10.2) emit for usage mistakes — missing required flag, unknown
// command / flag, wrong argument count. Cobra surfaces these as plain errors,
// not a typed value we can match on, so the dispatcher recognizes them by text;
// this is the same external contract unknownFlagName already depends on. A
// residual error matching none of these has leaked the typed boundary and is
// treated as an internal fault, not a user error.
var cobraUsageErrorMarkers = []string{
"unknown command ",
"unknown flag: ",
"unknown shorthand",
"required flag(s) ",
"flag needs an argument",
"bad flag syntax:",
"no such flag ",
"invalid argument ",
"arg(s), ", // accepts / requires N arg(s), received / only received M
}
// isCobraUsageError reports whether err is a cobra / pflag usage mistake,
// identified by the stable error text of the pinned cobra version.
func isCobraUsageError(err error) bool {
msg := err.Error()
for _, m := range cobraUsageErrorMarkers {
if strings.Contains(msg, m) {
return true
}
output.WriteErrorEnvelope(errOut, exitErr, string(f.ResolvedIdentity))
return exitErr.Code
}
// A backward-compat alias records its deprecation notice in PreRunE, which
// runs before cobra's required-flag validation — but a missing required flag
// fails before RunE and lands here, where the bare "Error:" line would drop
// the notice. When a deprecation is pending, route through the structured
// envelope so the migration hint still reaches the caller; all other errors
// keep the existing plain output.
if deprecation.GetPending() != nil {
output.WriteErrorEnvelope(errOut, &output.ExitError{
Code: 1,
Detail: &output.ErrDetail{Type: "validation", Message: err.Error()},
}, string(f.ResolvedIdentity))
return 1
}
fmt.Fprintln(errOut, "Error:", err)
return 1
}
// isOuterTypedError returns true if err is a typed *errs.* error AT THE
// TOP OF THE CHAIN (not buried inside Unwrap). Used by handleRootError
// to gate PromoteXxxError so a producer's outer typed envelope is never
// overwritten by a coarser shape derived from its legacy Cause.
func isOuterTypedError(err error) bool {
_, ok := err.(errs.TypedError)
return ok
}
// asExitError converts known structured error types to *output.ExitError.
// Returns nil for unrecognized errors (e.g. cobra flag errors).
//
// Deprecated: legacy *output.ExitError bridge.
func asExitError(err error) *output.ExitError {
var cfgErr *core.ConfigError
if errors.As(err, &cfgErr) {
return output.ErrWithHint(cfgErr.Code, cfgErr.Type, cfgErr.Message, cfgErr.Hint)
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return exitErr
}
return nil
return false
}
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
@@ -361,13 +342,10 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
}
}
// Deprecated: unknownSubcommandRunE produces a legacy *output.ExitError that
// predates the typed error contract introduced by errs/. New code MUST NOT
// add producers of this shape — unknown-subcommand signals should move to
// a typed *errs.ValidationError (or a dedicated typed error) carrying the
// agent-protocol metadata as typed extension fields. This helper is retained
// only while existing dispatch sites are migrated; it will be removed once
// they have moved to the typed surface.
// unknownSubcommandRunE replaces cobra's silent help fallback on group commands
// with a typed *errs.ValidationError: a flag that belongs to a missing
// subcommand, a misplaced subcommand-only flag, or an unknown subcommand name
// each fail structured (exit 2) instead of degrading to help + exit 0.
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
// A bare group (e.g. `sheets`), or one carrying only group-valid flags
@@ -383,28 +361,13 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
return cmd.Help()
}
if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 {
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: fmt.Sprintf("unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()),
Hint: fmt.Sprintf("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
Detail: map[string]any{
// Keep the same detail keys as flagDidYouMean's unknown_flag
// so a consumer keyed on Type can read a stable shape. The
// subcommand isn't resolved here, so suggestions/valid_flags
// have no meaningful universe to draw from — emit empty
// rather than the group's own (misleading) flags. unknown is
// the back-compat singular field; unknown_flags carries the
// full list when more than one flag was supplied.
"unknown": strings.Join(unknown, ", "),
"unknown_flags": unknown,
"command_path": cmd.CommandPath(),
"suggestions": []string{},
"valid_flags": []string{},
},
},
verr := errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()).
WithHint("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath())
for _, flag := range unknown {
verr.WithParams(errs.InvalidParam{Name: flag, Reason: "unknown flag before a subcommand"})
}
return verr
}
// The remaining flags are all defined somewhere in the tree. Those valid
// on the group itself or inherited (e.g. the global --profile) do not
@@ -416,19 +379,13 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
if len(misplaced) == 0 {
return cmd.Help()
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "missing_subcommand",
Message: fmt.Sprintf("missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")),
Hint: fmt.Sprintf("run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
Detail: map[string]any{
"command_path": cmd.CommandPath(),
"flags": misplaced,
"suggestions": []string{},
},
},
verr := errs.NewValidationError(errs.SubtypeInvalidArgument,
"missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")).
WithHint("run `%s --help` to list subcommands and their flags", cmd.CommandPath())
for _, flag := range misplaced {
verr.WithParams(errs.InvalidParam{Name: flag, Reason: "flag belongs to a subcommand, not the group"})
}
return verr
}
unknown := args[0]
available, deprecated := availableSubcommandNames(cmd)
@@ -442,27 +399,12 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)",
strings.Join(suggestions, ", "), cmd.CommandPath())
}
detail := map[string]any{
"unknown": unknown,
"command_path": cmd.CommandPath(),
"suggestions": suggestions,
"available": available,
}
// Only services with backward-compat aliases (currently sheets) carry a
// deprecated bucket; omit the key elsewhere so every other service's
// envelope is unchanged.
if len(deprecated) > 0 {
detail["deprecated"] = deprecated
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_subcommand",
Message: msg,
Hint: hint,
Detail: detail,
},
}
// Record the offending subcommand and its ranked candidates as a param with
// machine-readable Suggestions so an agent can retry without parsing the
// hint; the hint carries the same candidates as prose.
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
WithParams(errs.InvalidParam{Name: unknown, Reason: "unknown subcommand", Suggestions: suggestions}).
WithHint("%s", hint)
}
// flagTokensInArgs returns the flag-like tokens (-x, --foo, --foo=bar) in
@@ -588,47 +530,34 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin
}
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
// converts cobra's flag-parse errors into the structured ErrorEnvelope: an
// unknown flag gets a focused "did you mean" hint plus the full valid-flag list
// in detail (so agents recover even when the typo is semantic, e.g. --query vs
// --find, where edit distance alone finds nothing). Other flag errors stay
// structured but generic.
// converts cobra's flag-parse errors into a typed validation envelope: an
// unknown flag gets a focused "did you mean" hint (so agents recover even when
// the typo is semantic, e.g. --query vs --find, where edit distance alone finds
// nothing) and the offending flag in `params`. Other flag errors stay typed
// but generic.
func flagDidYouMean(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagName(ferr)
if !isUnknown {
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "flag_error",
Message: ferr.Error(),
Hint: fmt.Sprintf("run `%s --help` for valid flags", c.CommandPath()),
},
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
WithHint("run `%s --help` for valid flags", c.CommandPath())
}
valid := visibleFlagNames(c)
suggestions := suggest.Closest(name, valid, 3)
for i := range suggestions {
suggestions[i] = "--" + suggestions[i]
}
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
if len(suggestions) > 0 {
for i := range suggestions {
suggestions[i] = "--" + suggestions[i]
}
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
strings.Join(suggestions, ", "), c.CommandPath())
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: fmt.Sprintf("unknown flag %q for %q", "--"+name, c.CommandPath()),
Hint: hint,
Detail: map[string]any{
"unknown": "--" + name,
"command_path": c.CommandPath(),
"suggestions": suggestions,
"valid_flags": valid,
},
},
}
// The ranked candidates ride on the param as machine-readable Suggestions so
// an agent can retry without parsing the hint; the hint carries the same
// candidates as prose. The full valid-flag list stays recoverable via --help.
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %q for %q", "--"+name, c.CommandPath()).
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
WithHint("%s", hint)
}
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
@@ -698,56 +627,3 @@ func installTipsHelpFunc(root *cobra.Command) {
}
})
}
// enrichPermissionError rewrites the legacy *output.ExitError envelope so its
// Message + Hint match the per-subtype canonical text produced by the typed
// dispatcher path (errclass.CanonicalPermissionMessage / errclass.PermissionHint).
// This guarantees a caller observing the wire envelope cannot tell whether
// the error reached the dispatcher via the legacy *ExitError bridge or via
// the typed *errs.PermissionError fast path.
//
// Deprecated: legacy *output.ExitError enrichment; typed PermissionError
// values produced by errclass.BuildAPIError already carry MissingScopes +
// ConsoleURL directly.
func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr.Detail == nil {
return
}
// Only the legacy permission-class envelope types route here. "app_status"
// covers 99991662 (app_disabled) / 99991673 (app_unavailable); "permission"
// covers the four scope-class codes (99991672 / 99991676 / 99991679 / 230027).
if exitErr.Detail.Type != "permission" && exitErr.Detail.Type != "app_status" {
return
}
larkCode := exitErr.Detail.Code
meta, ok := errclass.LookupCodeMeta(larkCode)
if !ok || meta.Category != errs.CategoryAuthorization {
return
}
// Extract required scopes from API error detail (shared helper). May be
// empty for app-status codes — canonical message + hint still apply.
missing := registry.ExtractRequiredScopes(exitErr.Detail.Detail)
cfg, err := f.Config()
if err != nil {
return
}
// Reuse the same console URL builder as the typed path so both wire
// envelopes carry identical console_url values for the same input.
consoleURL := errclass.ConsoleURL(string(cfg.Brand), cfg.AppID, missing)
// Clear raw API detail — useful info is now in message/hint/console_url.
exitErr.Detail.Detail = nil
identity := string(f.ResolvedIdentity)
if identity == "" {
identity = "user"
}
exitErr.Detail.Message = errclass.CanonicalPermissionMessage(meta.Subtype, cfg.AppID, missing, exitErr.Detail.Message)
exitErr.Detail.Hint = errclass.PermissionHint(missing, identity, meta.Subtype, consoleURL)
exitErr.Detail.ConsoleURL = consoleURL
}

View File

@@ -8,7 +8,6 @@ import (
"context"
"encoding/json"
"os"
"reflect"
"strings"
"testing"
@@ -27,12 +26,12 @@ import (
"github.com/spf13/cobra"
)
// Canonical strict-mode envelope strings shared across fixtures
// (reflect.DeepEqual pins them; keep in sync with strictModeStubFrom).
// Canonical strict-mode envelope messages shared across fixtures. The
// switch-policy hint text is asserted by substring in
// assertStrictModeDenialEnvelope.
const (
strictModeBotMessage = `strict mode is "bot", only bot-identity commands are available`
strictModeUserMessage = `strict mode is "user", only user-identity commands are available`
strictModeHint = "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)"
)
// buildIntegrationRootCmd creates a root command with api, service, and shortcut
@@ -63,37 +62,46 @@ func executeRootIntegration(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Com
return 0
}
// parseEnvelope parses stderr bytes into an ErrorEnvelope.
func parseEnvelope(t *testing.T, stderr *bytes.Buffer) output.ErrorEnvelope {
// typedErrorEnvelope mirrors the typed wire shape produced by
// WriteTypedErrorEnvelope: the inner error marshals an errs.Problem
// directly, so "type" is the category, "subtype" is top-level, and there
// is no nested "detail" object. Recovery info (policy source, reason
// code, suggestions) is folded into "hint".
type typedErrorEnvelope struct {
OK bool `json:"ok"`
Identity string `json:"identity,omitempty"`
Error struct {
Type string `json:"type"`
Subtype string `json:"subtype"`
Message string `json:"message"`
Hint string `json:"hint"`
Param string `json:"param,omitempty"`
} `json:"error"`
}
// parseTypedEnvelope decodes stderr as the typed envelope and fails if the
// legacy nested "detail" object is present (the migration removed it).
func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
t.Helper()
if stderr.Len() == 0 {
t.Fatal("expected non-empty stderr, got empty")
}
var env output.ErrorEnvelope
var raw map[string]any
if err := json.Unmarshal(stderr.Bytes(), &raw); err != nil {
t.Fatalf("failed to parse stderr as JSON: %v\nstderr: %s", err, stderr.String())
}
if errObj, ok := raw["error"].(map[string]any); ok {
if _, hasDetail := errObj["detail"]; hasDetail {
t.Errorf("typed envelope must not carry a nested 'detail' object, got: %s", stderr.String())
}
}
var env typedErrorEnvelope
if err := json.Unmarshal(stderr.Bytes(), &env); err != nil {
t.Fatalf("failed to parse stderr as ErrorEnvelope: %v\nstderr: %s", err, stderr.String())
t.Fatalf("failed to parse stderr as typed envelope: %v\nstderr: %s", err, stderr.String())
}
return env
}
// assertEnvelope verifies exit code, stdout is empty, and stderr matches the
// expected ErrorEnvelope exactly via reflect.DeepEqual.
func assertEnvelope(t *testing.T, code int, wantCode int, stdout *bytes.Buffer, stderr *bytes.Buffer, want output.ErrorEnvelope) {
t.Helper()
if code != wantCode {
t.Errorf("exit code: got %d, want %d", code, wantCode)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
got := parseEnvelope(t, stderr)
if !reflect.DeepEqual(got, want) {
gotJSON, _ := json.MarshalIndent(got, "", " ")
wantJSON, _ := json.MarshalIndent(want, "", " ")
t.Errorf("stderr envelope mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, wantJSON)
}
}
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
t.Helper()
rootCmd := &cobra.Command{Use: "lark-cli"}
@@ -205,23 +213,71 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelop
// auth login is user-only, so it gets pruned in strict-mode-bot and the
// stub error fires (not login.go's inline check, which is shadowed by
// pruning).
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "command_denied",
Message: strictModeBotMessage,
Hint: strictModeHint,
Detail: map[string]any{
"path": "auth/login",
"layer": "strict_mode",
"policy_source": "strict-mode",
"rule_name": "",
"reason_code": "identity_not_supported",
"reason": strictModeBotMessage,
},
},
})
// pruning). The typed envelope is a failed_precondition validation
// error (exit 2); the strict-mode layer + reason code are folded into
// the hint.
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertStrictModeDenialEnvelope(t, env, strictModeBotMessage)
}
// assertStrictModeDenialEnvelope pins the shared strict-mode denial shape:
// a validation/failed_precondition envelope whose message is the short
// historical strict-mode line and whose hint still names the strict_mode
// layer + identity_not_supported reason code (the safety-critical recovery
// info), plus the historical switch-policy guidance.
func assertStrictModeDenialEnvelope(t *testing.T, env typedErrorEnvelope, wantMessage string) {
t.Helper()
if env.OK {
t.Errorf("envelope ok = true, want false")
}
if env.Error.Type != "validation" {
t.Errorf("error.type = %q, want validation", env.Error.Type)
}
if env.Error.Subtype != "failed_precondition" {
t.Errorf("error.subtype = %q, want failed_precondition", env.Error.Subtype)
}
if env.Error.Message != wantMessage {
t.Errorf("error.message = %q, want %q", env.Error.Message, wantMessage)
}
if !strings.Contains(env.Error.Hint, "strict_mode") {
t.Errorf("error.hint = %q, want substring strict_mode (policy layer)", env.Error.Hint)
}
if !strings.Contains(env.Error.Hint, "identity_not_supported") {
t.Errorf("error.hint = %q, want substring identity_not_supported (reason code)", env.Error.Hint)
}
if !strings.Contains(env.Error.Hint, "config strict-mode --help") {
t.Errorf("error.hint = %q, want historical switch-policy guidance", env.Error.Hint)
}
}
// assertCheckStrictModeEnvelope pins the typed envelope produced by
// cmdutil.Factory.CheckStrictMode (the identity-guard path for explicit
// --as on shortcuts / service methods / api): a *errs.ValidationError with
// subtype invalid_argument, the canonical strict-mode message, and the
// switch-policy hint.
func assertCheckStrictModeEnvelope(t *testing.T, env typedErrorEnvelope, wantMessage string) {
t.Helper()
if env.OK {
t.Errorf("envelope ok = true, want false")
}
if env.Error.Type != "validation" {
t.Errorf("error.type = %q, want validation", env.Error.Type)
}
if env.Error.Subtype != "invalid_argument" {
t.Errorf("error.subtype = %q, want invalid_argument", env.Error.Subtype)
}
if env.Error.Message != wantMessage {
t.Errorf("error.message = %q, want %q", env.Error.Message, wantMessage)
}
if !strings.Contains(env.Error.Hint, "config strict-mode --help") {
t.Errorf("error.hint = %q, want switch-policy guidance", env.Error.Hint)
}
}
func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnvelope(t *testing.T) {
@@ -232,22 +288,14 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnve
"im", "+messages-search", "--chat-id", "oc_xxx", "--query", "hello",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "command_denied",
Message: strictModeBotMessage,
Hint: strictModeHint,
Detail: map[string]any{
"path": "im/+messages-search",
"layer": "strict_mode",
"policy_source": "strict-mode",
"rule_name": "",
"reason_code": "identity_not_supported",
"reason": strictModeBotMessage,
},
},
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertStrictModeDenialEnvelope(t, env, strictModeBotMessage)
}
func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t *testing.T) {
@@ -277,15 +325,14 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
"im", "+chat-create", "--name", "probe", "--as", "bot", "--dry-run",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "validation",
Message: `strict mode is "user", only user-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertCheckStrictModeEnvelope(t, env, strictModeUserMessage)
}
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
@@ -296,15 +343,14 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "user",
Error: &output.ErrDetail{
Type: "validation",
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertCheckStrictModeEnvelope(t, env, strictModeBotMessage)
}
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
@@ -315,22 +361,14 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "command_denied",
Message: strictModeUserMessage,
Hint: strictModeHint,
Detail: map[string]any{
"path": "im/images/create",
"layer": "strict_mode",
"policy_source": "strict-mode",
"rule_name": "",
"reason_code": "identity_not_supported",
"reason": strictModeUserMessage,
},
},
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertStrictModeDenialEnvelope(t, env, strictModeUserMessage)
}
func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelope(t *testing.T) {
@@ -341,15 +379,14 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop
"api", "--as", "user", "GET", "/open-apis/im/v1/chats/oc_test", "--dry-run",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "user",
Error: &output.ErrDetail{
Type: "validation",
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertCheckStrictModeEnvelope(t, env, strictModeBotMessage)
}
// --- shortcut command ---
@@ -372,16 +409,43 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
"im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
})
// shortcut: typed error via DoAPIJSON path
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "api",
Code: 230002,
Message: "Bot/User can NOT be out of the chat.",
},
})
// shortcut: typed errs.APIError via the CallAPITyped → BuildAPIError path.
if code != output.ExitAPI {
t.Errorf("exit code = %d, want %d (ExitAPI)", code, output.ExitAPI)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
if stderr.Len() == 0 {
t.Fatal("expected non-empty stderr, got empty")
}
var raw struct {
OK bool `json:"ok"`
Identity string `json:"identity"`
Error struct {
Type string `json:"type"`
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(stderr.Bytes(), &raw); err != nil {
t.Fatalf("failed to parse typed envelope: %v\nstderr: %s", err, stderr.String())
}
if raw.OK {
t.Errorf("envelope ok = true, want false")
}
if raw.Identity != "bot" {
t.Errorf("identity = %q, want bot", raw.Identity)
}
if raw.Error.Type != "api" {
t.Errorf("error.type = %q, want api", raw.Error.Type)
}
if raw.Error.Code != 230002 {
t.Errorf("error.code = %d, want 230002", raw.Error.Code)
}
if raw.Error.Message != "Bot/User can NOT be out of the chat." {
t.Errorf("error.message = %q, want %q", raw.Error.Message, "Bot/User can NOT be out of the chat.")
}
}
// TestSetupNotices_ColdStart_NoNotice verifies that missing state

View File

@@ -137,9 +137,6 @@ func TestIsCompletionCommand(t *testing.T) {
}
}
// TestPromoteConfigError_* lives with the implementation in
// internal/errcompat/promote_test.go.
// TestHandleRootError_SecurityPolicyCanonicalEnvelope verifies that
// *errs.SecurityPolicyError flows through the canonical typed envelope
// (output.WriteTypedErrorEnvelope) — type=policy, numeric code, subtype,
@@ -269,12 +266,11 @@ func (f *failingWriter) Write(p []byte) (int, error) {
return len(p), nil
}
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins issue #4: a
// backward-compat alias that fails on a cobra-level required flag (which
// short-circuits before RunE) still routes through the structured envelope,
// because OnInvoke records the deprecation in PreRunE and the legacy fallback
// switches to WriteErrorEnvelope when a deprecation is pending — so the
// migration notice is no longer dropped on the plain "Error:" line.
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins that a
// backward-compat alias failing on a cobra-level required flag (which
// short-circuits before RunE) routes through the structured envelope, so the
// deprecation notice OnInvoke records in PreRunE is carried on the wire instead
// of being dropped on a plain "Error:" line.
func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
@@ -286,9 +282,9 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
deprecation.SetPending(&deprecation.Notice{
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
})
// The bare error shape cobra's ValidateRequiredFlags produces: neither typed
// nor an *output.ExitError, so it reaches the legacy fallback.
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
// The bare error shape cobra's ValidateRequiredFlags produces: not a typed
// errs.* error, so it reaches the deprecation fallback.
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
@@ -297,12 +293,96 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
if !strings.Contains(out, `"message"`) || !strings.Contains(out, "values") {
t.Errorf("expected a JSON error envelope carrying the failure message; got:\n%s", out)
}
// The envelope is typed validation, so the exit code must derive from that
// category (2) — the wire type and the exit code must not disagree.
if exit != int(output.ExitValidation) {
t.Errorf("exit = %d, want %d (validation envelope → category-derived exit)", exit, int(output.ExitValidation))
}
}
// TestHandleRootError_NoDeprecationKeepsPlainError pins the other half: with no
// deprecation pending, the legacy fallback stays a plain "Error:" line, so the
// fix does not reshape every unrecognized cobra error.
func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) {
// TestHandleRootError_AuthConfigWireGolden is the wire-consistency regression
// baseline for auth/config errors: it pins the typed envelope and exit code the
// dispatcher produces for the two source-of-truth shapes, which are constructed
// typed at their origin in internal/auth and internal/core.
func TestHandleRootError_AuthConfigWireGolden(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Run("token missing exits 3 with token_missing authentication envelope", func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, internalauth.NewNeedUserAuthorizationError("u_golden"))
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (ExitAuth)", exit, int(output.ExitAuth))
}
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "authentication" {
t.Errorf("error.type = %v, want %q", got, "authentication")
}
if got := errObj["subtype"]; got != "token_missing" {
t.Errorf("error.subtype = %v, want %q", got, "token_missing")
}
if got, _ := errObj["message"].(string); !strings.Contains(got, "need_user_authorization") {
t.Errorf("error.message = %q, must keep the need_user_authorization marker", got)
}
if got, _ := errObj["message"].(string); !strings.Contains(got, "u_golden") {
t.Errorf("error.message = %q, must carry the user open id", got)
}
if got, _ := errObj["hint"].(string); !strings.Contains(got, "auth login") {
t.Errorf("error.hint = %q, must point at auth login", got)
}
if got := errObj["user_open_id"]; got != "u_golden" {
t.Errorf("error.user_open_id = %v, want %q", got, "u_golden")
}
})
t.Run("not configured exits 3 with not_configured config envelope", func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, core.NotConfiguredError())
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (config shares ExitAuth)", exit, int(output.ExitAuth))
}
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "config" {
t.Errorf("error.type = %v, want %q", got, "config")
}
if got := errObj["subtype"]; got != "not_configured" {
t.Errorf("error.subtype = %v, want %q", got, "not_configured")
}
if got, _ := errObj["message"].(string); !strings.Contains(got, "not configured") {
t.Errorf("error.message = %q, want the not-configured message", got)
}
if got, _ := errObj["hint"].(string); !strings.Contains(got, "config init") {
t.Errorf("error.hint = %q, must point at config init", got)
}
})
}
// decodeErrorEnvelope unmarshals a typed error envelope and returns its
// top-level "error" object, failing the test if the shape is unexpected.
func decodeErrorEnvelope(t *testing.T, raw []byte) map[string]any {
t.Helper()
var env map[string]any
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatalf("envelope is not valid JSON: %v\n%s", err, raw)
}
errObj, ok := env["error"].(map[string]any)
if !ok {
t.Fatalf("envelope missing top-level error object: %s", raw)
}
return errObj
}
// TestHandleRootError_NoDeprecationTypesUsageError pins that a residual cobra
// usage error (missing required flag) is typed as invalid_argument with exit 2
// even with no deprecation pending — never cobra's plain "Error:" line.
func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
@@ -311,9 +391,45 @@ func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) {
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
if !strings.HasPrefix(errOut.String(), "Error:") {
t.Errorf("no deprecation pending: want a plain 'Error:' line, got:\n%s", errOut.String())
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
t.Fatalf("want a structured envelope, got a plain Error: line:\n%s", out)
}
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "validation" {
t.Errorf("error.type = %v, want %q", got, "validation")
}
if got, _ := errObj["message"].(string); !strings.Contains(got, "values") {
t.Errorf("error.message = %q, must carry the failing flag name", got)
}
if exit != int(output.ExitValidation) {
t.Errorf("exit = %d, want %d (validation envelope → category-derived exit)", exit, int(output.ExitValidation))
}
}
// TestHandleRootError_LeakedUntypedErrorBecomesInternal pins that an untyped
// error that does NOT match a cobra usage shape (i.e. one that leaked past the
// typed boundary from a helper) is classified as an internal fault (exit 5),
// not blamed on the user's input as a validation error.
func TestHandleRootError_LeakedUntypedErrorBecomesInternal(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, fmt.Errorf("upstream helper exploded: %w", io.ErrUnexpectedEOF))
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "internal" {
t.Errorf("error.type = %v, want %q (leaked untyped error must not be mislabeled validation)", got, "internal")
}
if exit != int(output.ExitInternal) {
t.Errorf("exit = %d, want %d (internal envelope → category-derived exit)", exit, int(output.ExitInternal))
}
}
@@ -337,12 +453,32 @@ func TestHandleRootError_PartialWritePreservesExitCode(t *testing.T) {
}
}
// TestHandleRootError_TypedOuterShortCircuitsPromote pins that when a typed
// *errs.AuthenticationError carries a legacy *NeedAuthorizationError in its
// Cause chain, the dispatcher does NOT run PromoteAuthError — doing so
// would replace the producer's TokenExpired subtype + custom hint with the
// promoted shape's TokenMissing.
func TestHandleRootError_TypedOuterShortCircuitsPromote(t *testing.T) {
// TestHandleRootError_BareErrorExitCodeNoStderr pins the silent-exit
// contract: a *output.BareError is honored for its exit code while stderr stays
// empty (stdout already carries the result, so the dispatcher must not layer a
// second envelope on top).
func TestHandleRootError_BareErrorExitCodeNoStderr(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, output.ErrBare(output.ExitAuth))
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (BareError code propagated)", exit, int(output.ExitAuth))
}
if errOut.Len() != 0 {
t.Errorf("stderr must stay empty for a bare predicate signal, got:\n%s", errOut.String())
}
}
// TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved pins that a typed
// *errs.AuthenticationError carrying a legacy *NeedAuthorizationError in its
// Cause chain renders the producer's TokenExpired subtype + custom hint
// verbatim — the legacy sentinel in the Cause chain never coarsens the wire
// shape.
func TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -494,136 +630,3 @@ func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) {
t.Errorf("expected appended hint %q, got %q", want, authErr.Hint)
}
}
// TestEnrichPermissionError_CanonicalConvergence pins that the legacy
// *output.ExitError dispatch path produces the same canonical Message + Hint
// + ConsoleURL as the typed *errs.PermissionError dispatch path. Both paths
// share errclass.CanonicalPermissionMessage / errclass.PermissionHint /
// errclass.ConsoleURL — so a wire consumer cannot tell which path produced
// the envelope.
func TestEnrichPermissionError_CanonicalConvergence(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cases := []struct {
name string
larkCode int
legacyErrType string
wantMsgSubstrs []string
wantHintSubstrs []string
wantConsoleURL bool
wantNoAuthLogin bool // hint must not suggest `auth login`
}{
{
name: "99991672 app_scope_not_applied",
larkCode: 99991672,
legacyErrType: "permission",
wantMsgSubstrs: []string{"access denied", "app cli_test", "drive:drive:read"},
wantHintSubstrs: []string{"developer console", "open.feishu.cn"},
wantConsoleURL: true,
wantNoAuthLogin: true,
},
{
name: "99991679 missing_scope",
larkCode: 99991679,
legacyErrType: "permission",
wantMsgSubstrs: []string{"unauthorized", "user authorization"},
wantHintSubstrs: []string{"lark-cli auth login"},
},
{
name: "99991673 app_unavailable",
larkCode: 99991673,
legacyErrType: "app_status",
wantMsgSubstrs: []string{"unauthorized app", "app cli_test", "not properly installed"},
wantHintSubstrs: []string{"tenant admin", "install status"},
},
{
name: "99991662 app_disabled",
larkCode: 99991662,
legacyErrType: "app_status",
wantMsgSubstrs: []string{"app cli_test", "not in use", "currently disabled"},
wantHintSubstrs: []string{"tenant admin", "re-enable"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_test", AppSecret: "s", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
// Mimic the wire shape ErrAPI produces: legacy *ExitError with
// Detail.Type populated by ClassifyLarkError, Detail.Detail
// carrying the permission_violations block so ExtractRequiredScopes
// can recover the missing scope.
scopeForDetail := "drive:drive:read"
exitErr := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: tc.legacyErrType,
Code: tc.larkCode,
Message: "upstream raw message — must be replaced",
Detail: map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": scopeForDetail},
},
},
},
}
enrichPermissionError(f, exitErr)
for _, sub := range tc.wantMsgSubstrs {
if !strings.Contains(exitErr.Detail.Message, sub) {
t.Errorf("Message %q missing substring %q", exitErr.Detail.Message, sub)
}
}
if exitErr.Detail.Message == "upstream raw message — must be replaced" {
t.Errorf("Message must be rewritten to canonical text; got upstream verbatim")
}
for _, sub := range tc.wantHintSubstrs {
if !strings.Contains(exitErr.Detail.Hint, sub) {
t.Errorf("Hint %q missing substring %q", exitErr.Detail.Hint, sub)
}
}
if tc.wantNoAuthLogin && strings.Contains(exitErr.Detail.Hint, "auth login") {
t.Errorf("Hint must not suggest `auth login` for this subtype; got %q", exitErr.Detail.Hint)
}
if tc.wantConsoleURL && exitErr.Detail.ConsoleURL == "" {
t.Error("ConsoleURL should be populated when missing scopes are present")
}
})
}
}
// TestEnrichPermissionError_SkipsUnrelatedTypes pins that an ExitError whose
// Detail.Type is neither "permission" nor "app_status" is left untouched —
// no Message rewrite, no Hint rewrite, no ConsoleURL injection.
func TestEnrichPermissionError_SkipsUnrelatedTypes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_test", AppSecret: "s", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
for _, ty := range []string{"api_error", "validation", "rate_limit", "auth"} {
exitErr := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: ty,
Code: 99991400,
Message: "untouched",
Hint: "original hint",
},
}
enrichPermissionError(f, exitErr)
if exitErr.Detail.Message != "untouched" {
t.Errorf("type=%q: Message was rewritten unexpectedly: %q", ty, exitErr.Detail.Message)
}
if exitErr.Detail.Hint != "original hint" {
t.Errorf("type=%q: Hint was rewritten unexpectedly: %q", ty, exitErr.Detail.Hint)
}
if exitErr.Detail.ConsoleURL != "" {
t.Errorf("type=%q: ConsoleURL should not be injected; got %q", ty, exitErr.Detail.ConsoleURL)
}
}
}

View File

@@ -5,17 +5,17 @@ package schema
import (
"context"
"fmt"
"errors"
"io"
"sort"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/schema"
"github.com/larksuite/cli/internal/util"
"github.com/spf13/cobra"
)
@@ -24,336 +24,10 @@ type SchemaOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
// Positional args
Path string // first positional, when only one is given
ExtraArgs []string // 2nd+ positional args (space-separated form)
// Flags
Format string
}
func printServices(w io.Writer) {
services := registry.ListFromMetaProjects()
fmt.Fprintf(w, "%sAvailable services:%s\n\n", output.Bold, output.Reset)
for _, s := range services {
spec := registry.LoadFromMeta(s)
title := registry.GetStrFromMap(spec, "title")
if title == "" {
title = registry.GetStrFromMap(spec, "description")
}
fmt.Fprintf(w, " %s%s%s %s%s%s\n", output.Cyan, s, output.Reset, output.Dim, title, output.Reset)
}
fmt.Fprintf(w, "\n%sUsage: lark-cli schema <service>.<resource>.<method>%s\n", output.Dim, output.Reset)
}
func printResourceList(w io.Writer, spec map[string]interface{}, mode core.StrictMode) {
name := registry.GetStrFromMap(spec, "name")
version := registry.GetStrFromMap(spec, "version")
title := registry.GetStrFromMap(spec, "title")
if title == "" {
title = registry.GetStrFromMap(spec, "description")
}
servicePath := registry.GetStrFromMap(spec, "servicePath")
fmt.Fprintf(w, "%s%s%s (%s) — %s\n\n", output.Bold, name, output.Reset, version, title)
fmt.Fprintf(w, "%sBase path: %s%s\n\n", output.Dim, servicePath, output.Reset)
resources, _ := spec["resources"].(map[string]interface{})
for _, resName := range sortedKeys(resources) {
resMap, _ := resources[resName].(map[string]interface{})
methods, _ := resMap["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
if len(methods) == 0 {
continue
}
fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset)
for _, methodName := range sortedKeys(methods) {
m, _ := methods[methodName].(map[string]interface{})
httpMethod := registry.GetStrFromMap(m, "httpMethod")
desc := registry.GetStrFromMap(m, "description")
danger := ""
if d, _ := m["danger"].(bool); d {
danger = fmt.Sprintf(" %s[danger]%s", output.Red, output.Reset)
}
fmt.Fprintf(w, " %-7s %s%s%s %s%s%s%s\n", httpMethod, output.Bold, methodName, output.Reset, output.Dim, desc, output.Reset, danger)
}
fmt.Fprintln(w)
}
fmt.Fprintf(w, "%sUsage: lark-cli schema %s.<resource>.<method>%s\n", output.Dim, name, output.Reset)
}
// hasFileFields returns true if any requestBody field has type "file".
func hasFileFields(method map[string]interface{}) (bool, []string) {
names := cmdutil.DetectFileFields(method)
return len(names) > 0, names
}
func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, methodName string, method map[string]interface{}) {
servicePath := registry.GetStrFromMap(spec, "servicePath")
specName := registry.GetStrFromMap(spec, "name")
methodPath := registry.GetStrFromMap(method, "path")
fullPath := servicePath + "/" + methodPath
httpMethod := registry.GetStrFromMap(method, "httpMethod")
desc := registry.GetStrFromMap(method, "description")
isFileUpload, fileFieldNames := hasFileFields(method)
fmt.Fprintf(w, "%s%s.%s.%s%s\n\n", output.Bold, specName, resName, methodName, output.Reset)
httpColor := output.Yellow
if httpMethod == "GET" {
httpColor = output.Green
} else if httpMethod == "DELETE" {
httpColor = output.Red
}
fmt.Fprintf(w, " %s%s%s %s\n", httpColor, httpMethod, output.Reset, fullPath)
if desc != "" {
fmt.Fprintf(w, " %s\n", desc)
}
fmt.Fprintln(w)
// Parameters
params, _ := method["parameters"].(map[string]interface{})
if len(params) > 0 {
fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset)
fmt.Fprintf(w, " %s--params%s <json> %soptional%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
for _, paramName := range sortedParamKeys(params) {
p, _ := params[paramName].(map[string]interface{})
pType := registry.GetStrFromMap(p, "type")
if pType == "" {
pType = "string"
}
location := registry.GetStrFromMap(p, "location")
required, _ := p["required"].(bool)
reqStr := fmt.Sprintf("%soptional%s", output.Dim, output.Reset)
if required {
reqStr = fmt.Sprintf("%srequired%s", output.Red, output.Reset)
}
locColor := output.Dim
if location == "path" {
locColor = output.Yellow
}
// Options (enum values)
optStr := formatOptions(p)
fmt.Fprintf(w, " - %s%s%s (%s, %s%s%s, %s)%s\n", output.Cyan, paramName, output.Reset, pType, locColor, location, output.Reset, reqStr, optStr)
if pdesc := registry.GetStrFromMap(p, "description"); pdesc != "" {
pdesc = util.TruncateStrWithEllipsis(pdesc, 100)
fmt.Fprintf(w, " %s%s%s\n", output.Dim, pdesc, output.Reset)
}
if ex := registry.GetStrFromMap(p, "example"); ex != "" {
fmt.Fprintf(w, " %se.g. %s%s\n", output.Dim, ex, output.Reset)
}
if rangeStr := formatRange(p); rangeStr != "" {
fmt.Fprintf(w, " %srange: %s%s\n", output.Dim, rangeStr, output.Reset)
}
}
fmt.Fprintln(w)
}
// --data for write methods
if httpMethod == "POST" || httpMethod == "PUT" || httpMethod == "PATCH" || httpMethod == "DELETE" {
if len(params) == 0 {
fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset)
}
fileUploadTag := ""
if isFileUpload {
fileUploadTag = fmt.Sprintf(" %s[file upload]%s", output.Yellow, output.Reset)
}
fmt.Fprintf(w, " %s--data%s <json> %soptional%s%s\n", output.Cyan, output.Reset, output.Dim, output.Reset, fileUploadTag)
requestBody, _ := method["requestBody"].(map[string]interface{})
if len(requestBody) > 0 {
printNestedFields(w, requestBody, " ", "")
}
if isFileUpload {
if len(fileFieldNames) == 1 {
fmt.Fprintf(w, "\n %s--file%s <[field=]path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
fmt.Fprintf(w, " Upload file as multipart/form-data. Default field: %q\n", fileFieldNames[0])
} else {
fmt.Fprintf(w, "\n %s--file%s <field=path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
fmt.Fprintf(w, " Upload file as multipart/form-data. Fields: %s\n", strings.Join(fileFieldNames, ", "))
}
}
fmt.Fprintln(w)
}
// Response
responseBody, _ := method["responseBody"].(map[string]interface{})
if len(responseBody) > 0 {
fmt.Fprintf(w, "%sResponse:%s\n\n", output.Bold, output.Reset)
printNestedFields(w, responseBody, " ", "")
fmt.Fprintln(w)
}
// Identity
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
var identities []string
for _, t := range tokens {
if s, ok := t.(string); ok {
switch s {
case "user":
identities = append(identities, "user")
case "tenant":
identities = append(identities, "bot")
}
}
}
if len(identities) > 0 {
fmt.Fprintf(w, "%sIdentity:%s %s\n", output.Bold, output.Reset, strings.Join(identities, ", "))
}
}
// Scopes (all)
if scopes, ok := method["scopes"].([]interface{}); ok && len(scopes) > 0 {
var scopeStrs []string
for _, s := range scopes {
if str, ok := s.(string); ok {
scopeStrs = append(scopeStrs, str)
}
}
fmt.Fprintf(w, "%sScopes:%s %s\n", output.Bold, output.Reset, strings.Join(scopeStrs, ", "))
}
// CLI example
if isFileUpload && len(fileFieldNames) == 1 {
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <path>\n", output.Bold, output.Reset, specName, resName, methodName)
} else if isFileUpload {
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <field=path>\n", output.Bold, output.Reset, specName, resName, methodName)
} else {
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s\n", output.Bold, output.Reset, specName, resName, methodName)
}
// Docs
if docUrl := registry.GetStrFromMap(method, "docUrl"); docUrl != "" {
fmt.Fprintf(w, "%sDocs:%s %s\n", output.Bold, output.Reset, docUrl)
}
}
func printNestedFields(w io.Writer, fields map[string]interface{}, indent, prefix string) {
for _, fieldName := range sortedFieldKeys(fields) {
f, _ := fields[fieldName].(map[string]interface{})
fullName := fieldName
if prefix != "" {
fullName = prefix + "." + fieldName
}
fType := registry.GetStrFromMap(f, "type")
required, _ := f["required"].(bool)
reqStr := fmt.Sprintf("%soptional%s", output.Dim, output.Reset)
if required {
reqStr = fmt.Sprintf("%srequired%s", output.Red, output.Reset)
}
optStr := formatOptions(f)
fmt.Fprintf(w, "%s- %s%s%s (%s, %s)%s\n", indent, output.Cyan, fullName, output.Reset, fType, reqStr, optStr)
desc := registry.GetStrFromMap(f, "description")
if desc != "" {
desc = util.TruncateStrWithEllipsis(desc, 100)
fmt.Fprintf(w, "%s %s%s%s\n", indent, output.Dim, desc, output.Reset)
}
if ex := registry.GetStrFromMap(f, "example"); ex != "" {
fmt.Fprintf(w, "%s %se.g. %s%s\n", indent, output.Dim, ex, output.Reset)
}
if rangeStr := formatRange(f); rangeStr != "" {
fmt.Fprintf(w, "%s %srange: %s%s\n", indent, output.Dim, rangeStr, output.Reset)
}
if props, ok := f["properties"].(map[string]interface{}); ok && len(props) > 0 {
printNestedFields(w, props, indent+" ", fullName)
}
}
}
// formatOptions returns " — val1 | val2 | ..." if field has options, else "".
func formatOptions(f map[string]interface{}) string {
opts, ok := f["options"].([]interface{})
if !ok || len(opts) == 0 {
return ""
}
var vals []string
for _, o := range opts {
if om, ok := o.(map[string]interface{}); ok {
if v := registry.GetStrFromMap(om, "value"); v != "" {
vals = append(vals, v)
}
}
}
if len(vals) == 0 {
return ""
}
return fmt.Sprintf(" %s— %s%s", output.Dim, strings.Join(vals, " | "), output.Reset)
}
// formatRange returns "min..max" if field has min/max, else "".
func formatRange(f map[string]interface{}) string {
minVal := registry.GetStrFromMap(f, "min")
maxVal := registry.GetStrFromMap(f, "max")
if minVal == "" && maxVal == "" {
return ""
}
if minVal != "" && maxVal != "" {
return minVal + ".." + maxVal
}
if minVal != "" {
return ">=" + minVal
}
return "<=" + maxVal
}
// sortedKeys returns map keys in alphabetical order.
func sortedKeys(m map[string]interface{}) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// sortedParamKeys returns parameter keys sorted: required first, then alphabetical.
func sortedParamKeys(params map[string]interface{}) []string {
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
pi, _ := params[keys[i]].(map[string]interface{})
pj, _ := params[keys[j]].(map[string]interface{})
ri, _ := pi["required"].(bool)
rj, _ := pj["required"].(bool)
if ri != rj {
return ri
}
return keys[i] < keys[j]
})
return keys
}
// sortedFieldKeys returns field keys sorted: required first, then alphabetical.
func sortedFieldKeys(fields map[string]interface{}) []string {
keys := make([]string, 0, len(fields))
for k := range fields {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
fi, _ := fields[keys[i]].(map[string]interface{})
fj, _ := fields[keys[j]].(map[string]interface{})
ri, _ := fi["required"].(bool)
rj, _ := fj["required"].(bool)
if ri != rj {
return ri
}
return keys[i] < keys[j]
})
return keys
}
func findResourceByPath(resources map[string]interface{}, parts []string) (map[string]interface{}, string, []string) {
for i := len(parts); i >= 1; i-- {
candidateName := strings.Join(parts[:i], ".")
if res, ok := resources[candidateName]; ok {
if resMap, ok := res.(map[string]interface{}); ok {
return resMap, candidateName, parts[i:]
}
}
}
return nil, "", nil
// Args are the positional path segments, in either the dotted single-arg
// form ("im.messages.reply") or the space-separated form ("im messages
// reply"); apicatalog.ParsePath normalizes both.
Args []string
}
// NewCmdSchema creates the schema command. If runF is non-nil it is called instead of schemaRun (test hook).
@@ -365,12 +39,7 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
Short: "View API method parameters, types, and scopes",
Args: cobra.MaximumNArgs(8),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
opts.Path = args[0]
}
if len(args) > 1 {
opts.ExtraArgs = args[1:]
}
opts.Args = append([]string(nil), args...)
opts.Ctx = cmd.Context()
if runF != nil {
return runF(opts)
@@ -380,433 +49,89 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
}
cmdutil.DisableAuthCheck(cmd)
// Tolerated for agent compatibility; ignored — schema only emits the JSON
// envelope, and its output is identity-independent (strict-mode filtering
// comes from ResolveStrictMode, never from --as).
cmd.Flags().String("format", "json", "")
cmd.Flags().Bool("json", true, "")
cmd.Flags().String("as", "", "")
_ = cmd.Flags().MarkHidden("format")
_ = cmd.Flags().MarkHidden("json")
_ = cmd.Flags().MarkHidden("as")
cmd.ValidArgsFunction = completeSchemaPath(f)
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
})
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
return cmd
}
// completeSchemaPath provides tab-completion for the schema path argument.
// It handles both legacy dotted resource names (e.g. app.table.fields) and the
// newer space-separated form (e.g. `schema im messages reply`).
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
// It uses the embedded source so completion candidates match what `schema`
// execution can resolve (both overlay-free).
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
mode := f.ResolveStrictMode(cmd.Context())
// Case 1: legacy "single dotted arg" path — no previous args yet
if len(args) == 0 {
parts := strings.Split(toComplete, ".")
if len(parts) <= 1 {
var completions []string
for _, s := range registry.ListFromMetaProjects() {
if strings.HasPrefix(s, toComplete) {
completions = append(completions, s+".")
}
}
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
}
serviceName := parts[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
spec = filterSpecByStrictMode(spec, mode)
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
afterService := strings.Join(parts[1:], ".")
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
allTrailingDot := len(completions) > 0
for _, c := range completions {
if !strings.HasSuffix(c, ".") {
allTrailingDot = false
break
}
}
directive := cobra.ShellCompDirectiveNoFileComp
if allTrailingDot {
directive |= cobra.ShellCompDirectiveNoSpace
}
return completions, directive
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
directive := cobra.ShellCompDirectiveNoFileComp
if noSpace {
directive |= cobra.ShellCompDirectiveNoSpace
}
// Case 2: space-form, args already has segments
// Walk down service -> resource(s) -> method based on existing args
serviceName := args[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
spec = filterSpecByStrictMode(spec, mode)
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// args[1:] are resource path segments (possibly partial); current
// toComplete is the next segment under cursor.
consumed := args[1:]
resource, _, remaining := findResourceByPath(resources, consumed)
if resource == nil {
// Suggest top-level resource names that match toComplete
var completions []string
for resName := range resources {
if strings.HasPrefix(resName, toComplete) {
completions = append(completions, resName)
}
}
sort.Strings(completions)
return completions, cobra.ShellCompDirectiveNoFileComp
}
if len(remaining) > 0 {
// Already typed past the resource — suggest methods
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
var completions []string
for mName := range methods {
if strings.HasPrefix(mName, toComplete) {
completions = append(completions, mName)
}
}
sort.Strings(completions)
return completions, cobra.ShellCompDirectiveNoFileComp
}
// Resource matched exactly, suggest methods
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
var completions []string
for mName := range methods {
if strings.HasPrefix(mName, toComplete) {
completions = append(completions, mName)
}
}
sort.Strings(completions)
return completions, cobra.ShellCompDirectiveNoFileComp
return completions, directive
}
}
func completeSchemaPathForSpec(serviceName string, resources map[string]interface{}, afterService string) []string {
var completions []string
for resName, resVal := range resources {
if strings.HasPrefix(resName, afterService) {
completions = append(completions, serviceName+"."+resName+".")
continue
}
if !strings.HasPrefix(afterService, resName+".") {
continue
}
methodPrefix := afterService[len(resName)+1:]
resMap, _ := resVal.(map[string]interface{})
if resMap == nil {
continue
}
methods, _ := resMap["methods"].(map[string]interface{})
for methodName := range methods {
if strings.HasPrefix(methodName, methodPrefix) {
completions = append(completions, serviceName+"."+resName+"."+methodName)
}
}
}
sort.Strings(completions)
return completions
}
func schemaRun(opts *SchemaOptions) error {
out := opts.Factory.IOStreams.Out
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
// args may have arrived as a single string (legacy single-arg path) or
// split into multiple — normalize to a single args slice.
var rawArgs []string
if opts.Path != "" {
rawArgs = []string{opts.Path}
}
if len(opts.ExtraArgs) > 0 {
if opts.Path != "" {
rawArgs = append([]string{opts.Path}, opts.ExtraArgs...)
} else {
rawArgs = append([]string(nil), opts.ExtraArgs...)
}
}
parts := schema.ParsePath(rawArgs)
if opts.Format == "pretty" {
return runPrettyMode(out, parts, mode)
}
return runJSONMode(out, parts, mode)
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
}
// runJSONMode dispatches list/single envelope output based on parts.
// JSON mode uses embedded data only (bypasses remote overlay) so envelope
// output is deterministic across machines.
func runJSONMode(out io.Writer, parts []string, mode core.StrictMode) error {
filter := strictModeFilter(mode)
switch len(parts) {
case 0:
envs := schema.AssembleAll(filter)
output.PrintJson(out, envs)
return nil
case 1:
spec := registry.EmbeddedSpec(parts[0])
if spec == nil {
return errUnknownEmbeddedService(parts[0])
// runSchema resolves the path through the embedded catalog and renders the
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
// output shape — a single resolved method renders as one envelope object,
// anything broader as an array — and maps resolve failures to hints.
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
catalog := registry.EmbeddedCatalog()
target, err := catalog.Resolve(parts)
if err != nil {
return resolveError(err)
}
refs := catalog.MethodRefs(target, registry.FilterForStrictMode(mode))
if target.Kind == apicatalog.TargetMethod {
if len(refs) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"Method %s not available in current identity mode", target.Method.SchemaPath()).
WithHint("strict mode hides methods the active account identity cannot call; it is shown for an identity (user or bot) that has the required access token")
}
envs := schema.AssembleService(parts[0], spec, filter)
output.PrintJson(out, envs)
return nil
default:
return runJSONForPath(out, parts, filter)
}
}
// runJSONForPath handles len(parts) >= 2: try resource match first, fallback
// to single-method match. Uses embedded data only.
func runJSONForPath(out io.Writer, parts []string, filter schema.MethodFilter) error {
serviceName := parts[0]
spec := registry.EmbeddedSpec(serviceName)
if spec == nil {
return errUnknownEmbeddedService(serviceName)
}
resources, _ := spec["resources"].(map[string]interface{})
resource, resName, remaining := findResourceByPath(resources, parts[1:])
if resource == nil {
var names []string
for k := range resources {
names = append(names, k)
}
sort.Strings(names)
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
}
if len(remaining) == 0 {
// Resource-scoped envelope array
envs := assembleResource(serviceName, resName, resource, filter)
output.PrintJson(out, envs)
output.PrintJson(out, schema.EnvelopeOf(refs[0]))
return nil
}
methodName := remaining[0]
methods, _ := resource["methods"].(map[string]interface{})
method, ok := methods[methodName].(map[string]interface{})
if !ok {
var names []string
for k := range methods {
names = append(names, k)
}
sort.Strings(names)
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
}
if len(remaining) > 1 {
// Method exists but caller appended extra segments — reject so they
// don't silently get this method's schema when they typo'd the path.
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown path: %s.%s.%s",
serviceName, resName, strings.Join(remaining, ".")),
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
methodName, strings.Join(remaining[1:], ".")))
}
if filter != nil && !filter(method) {
// Method exists in spec but filtered out by strict mode
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Method %s.%s.%s not available in current identity mode", serviceName, resName, methodName),
"Use --as user / --as bot to switch")
}
env := schema.AssembleEnvelope(serviceName, []string{resName}, methodName, method)
output.PrintJson(out, env)
output.PrintJson(out, schema.Envelopes(refs))
return nil
}
func assembleResource(serviceName, resName string, resource map[string]interface{}, filter schema.MethodFilter) []schema.Envelope {
methods, _ := resource["methods"].(map[string]interface{})
resourcePath := []string{resName}
var envs []schema.Envelope
for methodName, raw := range methods {
method, ok := raw.(map[string]interface{})
if !ok {
continue
}
if filter != nil && !filter(method) {
continue
}
envs = append(envs, schema.AssembleEnvelope(serviceName, resourcePath, methodName, method))
// resolveError maps a catalog *ResolveError to a typed *errs.ValidationError
// (CategoryValidation drives the exit code; Hint promotes to the envelope),
// preserving the historical message + hint text.
func resolveError(err error) error {
var re *apicatalog.ResolveError
if !errors.As(err, &re) {
return err
}
sort.Slice(envs, func(i, j int) bool { return envs[i].Name < envs[j].Name })
return envs
}
// runPrettyMode preserves the existing legacy pretty rendering verbatim.
// All printServices/printResourceList/printMethodDetail calls stay unchanged.
func runPrettyMode(out io.Writer, parts []string, mode core.StrictMode) error {
if len(parts) == 0 {
printServices(out)
return nil
}
serviceName := parts[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return errUnknownService(serviceName)
}
if len(parts) == 1 {
printResourceList(out, spec, mode)
return nil
}
resources, _ := spec["resources"].(map[string]interface{})
resource, resName, remaining := findResourceByPath(resources, parts[1:])
if resource == nil {
var names []string
for k := range resources {
names = append(names, k)
}
sort.Strings(names)
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
}
if len(remaining) == 0 {
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
for _, mName := range sortedKeys(methods) {
m, _ := methods[mName].(map[string]interface{})
httpMethod := registry.GetStrFromMap(m, "httpMethod")
desc := registry.GetStrFromMap(m, "description")
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
}
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
return nil
}
methodName := remaining[0]
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
method, ok := methods[methodName].(map[string]interface{})
if !ok {
var names []string
for k := range methods {
names = append(names, k)
}
sort.Strings(names)
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
}
if len(remaining) > 1 {
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown path: %s.%s.%s",
serviceName, resName, strings.Join(remaining, ".")),
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
methodName, strings.Join(remaining[1:], ".")))
}
printMethodDetail(out, spec, resName, methodName, method)
return nil
}
// strictModeFilter adapts core.StrictMode into a schema.MethodFilter, or returns
// nil if strict mode is not active.
func strictModeFilter(mode core.StrictMode) schema.MethodFilter {
if !mode.IsActive() {
return nil
}
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
return func(method map[string]interface{}) bool {
tokens, _ := method["accessTokens"].([]interface{})
if tokens == nil {
return true // permissive when meta_data lacks accessTokens
}
for _, t := range tokens {
if s, _ := t.(string); s == token {
return true
}
}
return false
}
}
func errUnknownService(name string) error {
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown service: %s", name),
fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", ")))
}
// errUnknownEmbeddedService is the JSON-mode variant: it lists only embedded
// services (no overlay) because JSON mode itself bypasses overlay; suggesting
// overlay-only services would mislead callers when those services subsequently
// fail to resolve in envelope output.
func errUnknownEmbeddedService(name string) error {
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown service: %s", name),
fmt.Sprintf("Available: %s", strings.Join(registry.EmbeddedServiceNames(), ", ")))
}
// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods
// filtered by strict mode. Returns the original spec when strict mode is off.
func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} {
if !mode.IsActive() {
return spec
}
result := make(map[string]interface{}, len(spec))
for k, v := range spec {
result[k] = v
}
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return result
}
filteredRes := make(map[string]interface{}, len(resources))
for resName, resVal := range resources {
resMap, ok := resVal.(map[string]interface{})
if !ok {
continue
}
methods, _ := resMap["methods"].(map[string]interface{})
filtered := filterMethodsByStrictMode(methods, mode)
if len(filtered) == 0 {
continue
}
resCopy := make(map[string]interface{}, len(resMap))
for k, v := range resMap {
resCopy[k] = v
}
resCopy["methods"] = filtered
filteredRes[resName] = resCopy
}
result["resources"] = filteredRes
return result
}
// filterMethodsByStrictMode removes methods incompatible with the active strict mode.
// Returns the original map unmodified when strict mode is off.
func filterMethodsByStrictMode(methods map[string]interface{}, mode core.StrictMode) map[string]interface{} {
if !mode.IsActive() || methods == nil {
return methods
}
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
filtered := make(map[string]interface{}, len(methods))
for name, val := range methods {
m, ok := val.(map[string]interface{})
if !ok {
continue
}
tokens, _ := m["accessTokens"].([]interface{})
if tokens == nil {
filtered[name] = val
continue
}
for _, t := range tokens {
if ts, ok := t.(string); ok && ts == token {
filtered[name] = val
break
}
}
}
return filtered
switch re.Kind {
case apicatalog.ErrService:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown service: %s", re.Subject).
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
case apicatalog.ErrResource:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown resource: %s", re.Subject).
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
case apicatalog.ErrMethod:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown method: %s", re.Subject).
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
case apicatalog.ErrPath:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown path: %s", re.Subject).
WithHint("Method %q exists but the trailing segments %q do not resolve", re.Method, re.Trailing)
}
return err
}

View File

@@ -4,11 +4,12 @@
package schema
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
@@ -21,29 +22,46 @@ func TestSchemaCmd_FlagParsing(t *testing.T) {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"calendar.events.list", "--format", "pretty"})
cmd.SetArgs([]string{"calendar.events.list"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Path != "calendar.events.list" {
t.Errorf("expected path calendar.events.list, got %s", gotOpts.Path)
}
if gotOpts.Format != "pretty" {
t.Errorf("expected Format=pretty, got %s", gotOpts.Format)
if len(gotOpts.Args) != 1 || gotOpts.Args[0] != "calendar.events.list" {
t.Errorf("expected args [calendar.events.list], got %v", gotOpts.Args)
}
}
func TestSchemaCmd_NoArgs_Pretty(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"--format", "pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
func TestSchemaCmd_OutputFlagsAcceptedForCompat(t *testing.T) {
// Agents are habituated to --format/--json/--as from api/service commands.
// schema must accept them without erroring and always emit the JSON envelope —
// its output is structured JSON and identity-independent, so the values have
// no effect.
argSets := [][]string{
{"--format", "json"},
{"--format", "pretty"},
{"--format", "table"}, // no table rendering for a nested schema -> JSON
{"--format", "csv"},
{"--json"},
{"--json", "--format", "ndjson"},
{"--as", "user"},
{"--as", "bot"},
{"--as", "user", "--json"},
}
if !strings.Contains(stdout.String(), "Available services") {
t.Error("expected service list in pretty mode")
for _, extra := range argSets {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs(append([]string{"im.images.create"}, extra...))
if err := cmd.Execute(); err != nil {
t.Fatalf("args %v should be accepted, got error: %v", extra, err)
}
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("args %v: output is not a JSON envelope: %v\n%s", extra, err, stdout.String())
}
if env["name"] != "im images create" {
t.Errorf("args %v: expected the im images create envelope, got name=%v", extra, env["name"])
}
}
}
@@ -51,7 +69,7 @@ func TestSchemaCmd_NoArgs_JSON_IsArray(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{}) // default --format json
cmd.SetArgs([]string{})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -76,7 +94,7 @@ func TestSchemaCmd_JSONIsEnvelope(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"im.images.create", "--format", "json"})
cmd.SetArgs([]string{"im.images.create"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -179,23 +197,6 @@ func TestSchemaCmd_NoYesForReadRisk(t *testing.T) {
}
}
func TestSchemaCmd_PrettyUnchanged_KeyTextPresent(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"im.images.create", "--format", "pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// Existing pretty rendering surfaces these markers — they must still appear
for _, want := range []string{"Parameters:", "Response:", "Identity:", "Scopes:", "CLI:"} {
if !strings.Contains(out, want) {
t.Errorf("pretty output missing marker %q", want)
}
}
}
func TestSchemaCmd_UnknownService(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -210,170 +211,47 @@ func TestSchemaCmd_UnknownService(t *testing.T) {
if !strings.Contains(err.Error(), "Unknown service") {
t.Errorf("expected 'Unknown service' error, got: %v", err)
}
}
func TestPrintMethodDetail_FileUpload(t *testing.T) {
spec := map[string]interface{}{
"name": "im",
"servicePath": "/open-apis/im/v1",
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
method := map[string]interface{}{
"path": "images",
"httpMethod": "POST",
"description": "Upload an image",
"requestBody": map[string]interface{}{
"image_type": map[string]interface{}{
"type": "string",
"required": true,
},
"image": map[string]interface{}{
"type": "file",
"required": true,
},
},
"accessTokens": []interface{}{"user", "tenant"},
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
var buf bytes.Buffer
printMethodDetail(&buf, spec, "images", "create", method)
out := buf.String()
if !strings.Contains(out, "file upload") {
t.Errorf("expected 'file upload' marker in output, got:\n%s", out)
}
if !strings.Contains(out, "--file") {
t.Errorf("expected '--file' in output, got:\n%s", out)
}
if !strings.Contains(out, `"image"`) {
t.Errorf("expected default field name 'image' in output, got:\n%s", out)
}
if !strings.Contains(out, "--file <path>") {
t.Errorf("expected CLI example with --file <path>, got:\n%s", out)
if !strings.Contains(ve.Hint, "Available:") {
t.Errorf("expected hint listing available services, got: %q", ve.Hint)
}
}
func TestPrintMethodDetail_NoFileUpload(t *testing.T) {
spec := map[string]interface{}{
"name": "calendar",
"servicePath": "/open-apis/calendar/v4",
}
method := map[string]interface{}{
"path": "events",
"httpMethod": "POST",
"description": "Create an event",
"requestBody": map[string]interface{}{
"summary": map[string]interface{}{
"type": "string",
"required": true,
},
},
}
// TestSchemaCmd_UnknownMethod_TypedValidation pins the typed envelope for the
// JSON-mode unknown-method path: *errs.ValidationError with
// subtype invalid_argument and a hint listing the available methods.
func TestSchemaCmd_UnknownMethod_TypedValidation(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var buf bytes.Buffer
printMethodDetail(&buf, spec, "events", "create", method)
out := buf.String()
if strings.Contains(out, "file upload") {
t.Errorf("did not expect 'file upload' marker for non-file method, got:\n%s", out)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"calendar.events.nonexistent_method"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for unknown method")
}
if strings.Contains(out, "--file") {
t.Errorf("did not expect '--file' for non-file method, got:\n%s", out)
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 = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(err.Error(), "Unknown method") {
t.Errorf("expected 'Unknown method' error, got: %v", err)
}
if !strings.Contains(ve.Hint, "Available:") {
t.Errorf("expected hint listing available methods, got: %q", ve.Hint)
}
}
func TestHasFileFields(t *testing.T) {
tests := []struct {
name string
method map[string]interface{}
wantBool bool
wantFields []string
}{
{
name: "has file field",
method: map[string]interface{}{
"requestBody": map[string]interface{}{
"image": map[string]interface{}{"type": "file"},
"name": map[string]interface{}{"type": "string"},
},
},
wantBool: true,
wantFields: []string{"image"},
},
{
name: "no file field",
method: map[string]interface{}{
"requestBody": map[string]interface{}{
"name": map[string]interface{}{"type": "string"},
},
},
wantBool: false,
wantFields: nil,
},
{
name: "no requestBody",
method: map[string]interface{}{},
wantBool: false,
wantFields: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, names := hasFileFields(tt.method)
if got != tt.wantBool {
t.Errorf("hasFileFields() = %v, want %v", got, tt.wantBool)
}
if tt.wantFields == nil && names != nil {
t.Errorf("expected nil names, got %v", names)
}
if tt.wantFields != nil && len(names) != len(tt.wantFields) {
t.Errorf("expected %d field names, got %d", len(tt.wantFields), len(names))
}
})
}
}
func TestCompleteSchemaPathForSpec(t *testing.T) {
resources := map[string]interface{}{
"records": map[string]interface{}{
"methods": map[string]interface{}{
"create": map[string]interface{}{},
"list": map[string]interface{}{},
},
},
"record_permissions": map[string]interface{}{
"methods": map[string]interface{}{
"get": map[string]interface{}{},
},
},
}
got := completeSchemaPathForSpec("base", resources, "records.cr")
if len(got) != 1 || got[0] != "base.records.create" {
t.Fatalf("completions = %v, want [base.records.create]", got)
}
got = completeSchemaPathForSpec("base", resources, "record")
if len(got) != 2 || got[0] != "base.record_permissions." || got[1] != "base.records." {
t.Fatalf("resource completions = %v", got)
}
}
func TestFilterSpecByStrictMode_RemovesIncompatibleMethodsFromCompletionSource(t *testing.T) {
spec := map[string]interface{}{
"resources": map[string]interface{}{
"records": map[string]interface{}{
"methods": map[string]interface{}{
"list": map[string]interface{}{"accessTokens": []interface{}{"tenant"}},
"create": map[string]interface{}{"accessTokens": []interface{}{"user"}},
},
},
},
}
filtered := filterSpecByStrictMode(spec, core.StrictModeBot)
resources, _ := filtered["resources"].(map[string]interface{})
got := completeSchemaPathForSpec("base", resources, "records.")
if len(got) != 1 || got[0] != "base.records.list" {
t.Fatalf("filtered completions = %v, want [base.records.list]", got)
}
}
// Completion candidate generation (dotted + space forms, strict-mode filtering,
// dotted-resource handling) now lives in internal/apicatalog and is covered by
// apicatalog's TestComplete. cmd/schema only adapts catalog.Complete to cobra.

80
cmd/service/affordance.go Normal file
View File

@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/meta"
)
// methodLong composes a method command's long help in one place: the
// description, the affordance guidance block (when the method has one), the
// pointer to the full schema, and the params-only addendum (params whose flag
// name is taken — paramFlagBinder.paramsOnlyHelp, "" when none). Affordance
// sits near the top so an agent sees when-to-use and few-shot examples before
// the flag list.
func methodLong(description, affordance, schemaPath, paramsOnly string) string {
var b strings.Builder
b.WriteString(description)
if affordance != "" {
b.WriteString("\n\n")
b.WriteString(affordance)
}
fmt.Fprintf(&b, "\n\nView parameter definitions before calling:\n lark-cli schema %s", schemaPath)
b.WriteString(paramsOnly)
return b.String()
}
// renderAffordance renders a method's affordance as a help block — when to use,
// prerequisites, and (most importantly for agents) few-shot Examples — or "" when
// the method carries no affordance. It reads the single typed model
// (meta.Method.ParsedAffordance) so the help and the envelope agree on shape.
func renderAffordance(m meta.Method) string {
a, ok := m.ParsedAffordance()
if !ok {
return ""
}
var b strings.Builder
bullets := func(title string, items []string) {
var nonEmpty []string
for _, it := range items {
if strings.TrimSpace(it) != "" {
nonEmpty = append(nonEmpty, it)
}
}
if len(nonEmpty) == 0 {
return
}
fmt.Fprintf(&b, "%s:\n", title)
for _, it := range nonEmpty {
fmt.Fprintf(&b, " • %s\n", it)
}
}
bullets("When to use", a.UseWhen)
bullets("Avoid when", a.DoNotUseWhen)
bullets("Prerequisites", a.Prerequisites)
if len(a.Examples) > 0 {
var lines []string
for _, ex := range a.Examples {
if ex.Command == "" {
continue
}
if ex.Description != "" {
lines = append(lines, fmt.Sprintf(" • %s\n %s", ex.Description, ex.Command))
} else {
lines = append(lines, fmt.Sprintf(" • %s", ex.Command))
}
}
if len(lines) > 0 {
fmt.Fprintf(&b, "Examples:\n%s\n", strings.Join(lines, "\n"))
}
}
bullets("Related", a.Related)
return strings.TrimRight(b.String(), "\n")
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
)
func TestRenderAffordance(t *testing.T) {
raw := json.RawMessage(`{
"use_when": ["发送文本消息"],
"do_not_use_when": ["群已解散"],
"prerequisites": ["已获取 chat_id"],
"examples": [
{"description":"发一条文本","command":"lark-cli im messages create --params '{...}'"},
{"command":"lark-cli im messages list"},
{"description":"no command, skipped","command":""}
],
"related": ["im.messages.list"]
}`)
out := renderAffordance(meta.Method{Affordance: raw})
for _, want := range []string{
"When to use:", "发送文本消息",
"Avoid when:", "群已解散",
"Prerequisites:", "已获取 chat_id",
"Examples:", "发一条文本", "lark-cli im messages create --params '{...}'",
"lark-cli im messages list", // example with no description -> bare command line
"Related:", "im.messages.list",
} {
if !strings.Contains(out, want) {
t.Errorf("renderAffordance missing %q in:\n%s", want, out)
}
}
if strings.Contains(out, "no command, skipped") {
t.Errorf("example with empty command should be skipped:\n%s", out)
}
// Absent or empty affordance renders nothing (so methods without an overlay
// add nothing to their help).
if renderAffordance(meta.Method{}) != "" || renderAffordance(meta.Method{Affordance: json.RawMessage(`{}`)}) != "" {
t.Error("empty affordance should render nothing")
}
}
func TestServiceMethod_AffordanceInLong(t *testing.T) {
withAff := map[string]interface{}{
"path": "messages", "httpMethod": "POST", "description": "发送消息",
"affordance": map[string]interface{}{
"examples": []interface{}{
map[string]interface{}{"description": "发文本", "command": "lark-cli im messages create ..."},
},
},
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withAff), "create", "messages", nil)
if !strings.Contains(cmd.Long, "Examples:") || !strings.Contains(cmd.Long, "lark-cli im messages create ...") {
t.Errorf("affordance examples not in command Long:\n%s", cmd.Long)
}
// A method with no affordance adds no guidance block.
plain := map[string]interface{}{"path": "x", "httpMethod": "GET", "description": "d"}
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(plain), "list", "x", nil)
if strings.Contains(cmd2.Long, "Examples:") {
t.Errorf("no-affordance method should have no Examples in Long:\n%s", cmd2.Long)
}
}

211
cmd/service/flaggroups.go Normal file
View File

@@ -0,0 +1,211 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// Flag annotations the grouped service-method help renderer reads.
const (
flagGroupAnnotation = "lark_flag_group" // display group key
flagSubAnnotation = "lark_flag_sub" // "required" | "optional" within API Parameters
flagNoteAnnotation = "lark_flag_note" // extra lines shown indented under a flag
groupParams = "params" // typed path/query flags
groupBody = "body" // --data, --file
groupRaw = "raw" // --params
groupExecution = "execution" // --as/--dry-run/--page-*/--yes
groupOutput = "output" // --output/--format/--jq
subRequired = "required"
subOptional = "optional"
)
// serviceFlagGroupOrder is the display order + titles of the flag groups. API
// Parameters carries only typed path/query flags; raw --params, request body and
// execution/output controls each get their own group so an agent can tell the
// distinct input kinds apart.
var serviceFlagGroupOrder = []struct{ key, title string }{
{groupParams, "API Parameters"},
{groupBody, "Request Body"},
{groupRaw, "Raw Parameter Input"},
{groupExecution, "Execution"},
{groupOutput, "Output"},
}
// applyGroupedUsage installs the grouped usage renderer on a service method
// cmd: local flags via the grouped renderer instead of cobra's flat Flags:
// list; global (inherited) flags and the Risk/Tips sections appended by the
// root help func are unaffected. Rendered by hand rather than via
// cmd.SetUsageTemplate: cobra lazy-links text/template on the first
// SetUsageTemplate call, whose executor reaches reflect.Value.MethodByName —
// that disables the linker's method-level dead-code elimination and costs
// ~19 MB of binary size.
func applyGroupedUsage(cmd *cobra.Command) {
cmd.SetUsageFunc(func(c *cobra.Command) error {
w := c.OutOrStderr()
fmt.Fprintf(w, "Usage:\n %s\n", c.UseLine())
if c.HasAvailableLocalFlags() {
fmt.Fprintf(w, "\n%s\n", renderServiceFlagGroups(c))
}
if c.HasAvailableInheritedFlags() {
fmt.Fprintf(w, "\nGlobal Flags:\n%s\n", strings.TrimRight(c.InheritedFlags().FlagUsages(), " \t\n"))
}
return nil
})
}
func annotate(f *pflag.Flag, key string, vals []string) {
if f.Annotations == nil {
f.Annotations = map[string][]string{}
}
f.Annotations[key] = vals
}
// tagFlagGroup records a flag's display group (no-op if the flag is absent).
func tagFlagGroup(fs *pflag.FlagSet, name, group string) {
if f := fs.Lookup(name); f != nil {
annotate(f, flagGroupAnnotation, []string{group})
}
}
func annotationOf(f *pflag.Flag, key string) []string {
if f.Annotations != nil {
return f.Annotations[key]
}
return nil
}
func flagGroupOf(f *pflag.Flag) string {
if v := annotationOf(f, flagGroupAnnotation); len(v) > 0 {
return v[0]
}
return ""
}
func flagSubOf(f *pflag.Flag) string {
if v := annotationOf(f, flagSubAnnotation); len(v) > 0 {
return v[0]
}
return ""
}
// renderServiceFlagGroups renders the command's local flags into ordered,
// titled groups; the API Parameters group is further split into Required /
// Optional. It is the body of the usage func applyGroupedUsage installs.
func renderServiceFlagGroups(cmd *cobra.Command) string {
var b strings.Builder
seen := map[*pflag.Flag]bool{}
for _, g := range serviceFlagGroupOrder {
flags := groupFlags(cmd, g.key, seen)
if len(flags) == 0 {
continue
}
fmt.Fprintf(&b, "%s:\n", g.title)
if g.key == groupParams {
writeSection(&b, " Required:", subFlags(flags, subRequired))
writeSection(&b, " Optional:", subFlags(flags, subOptional))
} else {
writeSection(&b, "", flags)
}
fmt.Fprintln(&b)
}
// Anything untagged (e.g. -h/--help) goes last under "Other".
var other []*pflag.Flag
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
if f.Hidden || seen[f] {
return
}
other = append(other, f)
})
if len(other) > 0 {
fmt.Fprintln(&b, "Other:")
writeSection(&b, "", other)
}
return strings.TrimRight(b.String(), "\n")
}
// groupFlags returns the visible local flags tagged with group key, marking them
// seen so the trailing "Other" bucket only catches genuinely untagged flags.
func groupFlags(cmd *cobra.Command, key string, seen map[*pflag.Flag]bool) []*pflag.Flag {
var flags []*pflag.Flag
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
if f.Hidden || flagGroupOf(f) != key {
return
}
flags = append(flags, f)
seen[f] = true
})
return flags
}
func subFlags(flags []*pflag.Flag, sub string) []*pflag.Flag {
var out []*pflag.Flag
for _, f := range flags {
s := flagSubOf(f)
// Untagged subgroup defaults to Optional so nothing is dropped.
if s == sub || (s == "" && sub == subOptional) {
out = append(out, f)
}
}
return out
}
// writeSection prints an optional (sub)header and the flags, aligned in a
// column, each flag row followed by its note lines indented under the usage.
func writeSection(b *strings.Builder, header string, flags []*pflag.Flag) {
if len(flags) == 0 {
return
}
if header != "" {
fmt.Fprintf(b, "%s\n", header)
}
specs := make([]string, len(flags))
maxSpec := 0
for i, f := range flags {
specs[i] = flagSpec(f)
if len(specs[i]) > maxSpec {
maxSpec = len(specs[i])
}
}
for i, f := range flags {
_, usage := pflag.UnquoteUsage(f)
if showsDefault(f) {
usage += fmt.Sprintf(" (default %s)", f.DefValue)
}
fmt.Fprintf(b, "%-*s %s\n", maxSpec, specs[i], strings.TrimSpace(usage))
for _, note := range annotationOf(f, flagNoteAnnotation) {
fmt.Fprintf(b, "%*s%s\n", maxSpec+3+4, "", note)
}
}
}
// flagSpec is pflag's " --name type" / " -x, --name type" left column.
func flagSpec(f *pflag.Flag) string {
typeName, _ := pflag.UnquoteUsage(f)
spec := " --" + f.Name
if f.Shorthand != "" && f.ShorthandDeprecated == "" {
spec = " -" + f.Shorthand + ", --" + f.Name
}
if typeName != "" {
spec += " " + typeName
}
return spec
}
// showsDefault mirrors pflag's "non-zero default" rule for the flag types these
// commands use, so the grouped rendering shows the same "(default x)" hints as
// cobra's flat list.
func showsDefault(f *pflag.Flag) bool {
switch f.DefValue {
case "", "0", "false", "[]":
return false
}
return true
}

View File

@@ -0,0 +1,115 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
)
func TestServiceFlagGroups_AgentContract(t *testing.T) {
method := map[string]interface{}{
"path": "chats/:chat_id/members",
"httpMethod": "POST",
"parameters": map[string]interface{}{
"chat_id": map[string]interface{}{"type": "string", "location": "path", "required": true},
"member_id_type": map[string]interface{}{
"type": "string", "location": "query",
"options": []interface{}{
map[string]interface{}{"value": "open_id", "description": "以 open_id 标识用户"},
map[string]interface{}{"value": "user_id", "description": "以 user_id 标识用户"},
},
},
},
// Documented body field -> --data belongs under Request Body.
"requestBody": map[string]interface{}{
"id_list": map[string]interface{}{"type": "list", "required": true},
},
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "create", "chat.members", nil)
out := renderServiceFlagGroups(cmd)
idx := func(s string) int { return strings.Index(out, s) }
// Section order: API Parameters → Request Body → Raw Parameter Input → Execution → Output.
iParams, iBody, iRaw, iExec, iOut := idx("API Parameters:"), idx("Request Body:"), idx("Raw Parameter Input:"), idx("Execution:"), idx("Output:")
for name, i := range map[string]int{"API Parameters": iParams, "Request Body": iBody, "Raw Parameter Input": iRaw, "Execution": iExec, "Output": iOut} {
if i < 0 {
t.Fatalf("missing section %q in:\n%s", name, out)
}
}
if !(iParams < iBody && iBody < iRaw && iRaw < iExec && iExec < iOut) {
t.Errorf("section order wrong:\n%s", out)
}
// Required/Optional subsections under API Parameters.
if i := idx(" Required:"); i < iParams || i > iBody {
t.Errorf("Required subsection misplaced:\n%s", out)
}
if i := idx(" Optional:"); i < iParams || i > iBody {
t.Errorf("Optional subsection misplaced:\n%s", out)
}
// Typed flags are API Parameters; required path flag under Required, enum
// flag under Optional with an inline "enum: ..." (not multi-line meanings).
if i := idx("--chat-id"); i < iParams || i > iBody {
t.Errorf("--chat-id not under API Parameters:\n%s", out)
}
if !strings.Contains(out, "chat_id, required") {
t.Errorf("typed flag help format wrong:\n%s", out)
}
if !strings.Contains(out, "enum: open_id=以 open_id 标识用户|user_id=以 user_id 标识用户") {
t.Errorf("expected compact enum value=meaning inline:\n%s", out)
}
// --data is Request Body; --params is Raw Parameter Input (NOT API Parameters)
// and carries the precedence rule.
if i := idx("--data"); i < iBody || i > iRaw {
t.Errorf("--data not under Request Body:\n%s", out)
}
if i := idx("--params"); i < iRaw || i > iExec {
t.Errorf("--params not under Raw Parameter Input:\n%s", out)
}
if !strings.Contains(out, "typed flags override matching keys in --params") {
t.Errorf("missing --params precedence rule:\n%s", out)
}
// Control flags land in Execution/Output.
if i := idx("--dry-run"); i < iExec || i > iOut {
t.Errorf("--dry-run not under Execution:\n%s", out)
}
if idx("--format") < iOut {
t.Errorf("--format not under Output:\n%s", out)
}
// The usage template is wired to the grouped renderer (no flat Flags: list).
if u := cmd.UsageString(); !strings.Contains(u, "API Parameters:") || strings.Contains(u, "\nFlags:\n") {
t.Errorf("usage template not grouped:\n%s", u)
}
}
// TestServiceFlagGroups_UndocumentedBodyIsRaw: a POST with no documented body
// fields still offers --data (escape hatch) but must NOT imply a declared body —
// it goes under Raw Parameter Input, not "Request Body".
func TestServiceFlagGroups_UndocumentedBodyIsRaw(t *testing.T) {
method := map[string]interface{}{"path": "things/do", "httpMethod": "POST"} // POST, no requestBody, no params
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "do", "things", nil)
out := renderServiceFlagGroups(cmd)
if strings.Contains(out, "Request Body:") {
t.Errorf("undocumented body must not render a Request Body section:\n%s", out)
}
iRaw, iData := strings.Index(out, "Raw Parameter Input:"), strings.Index(out, "--data")
if iRaw < 0 || iData < iRaw {
t.Errorf("--data not under Raw Parameter Input:\n%s", out)
}
if !strings.Contains(out, "no documented fields") {
t.Errorf("--data should be labeled a raw escape hatch:\n%s", out)
}
}

166
cmd/service/paramflags.go Normal file
View File

@@ -0,0 +1,166 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
type boundParamFlag struct {
field meta.Field
read func() interface{}
}
// paramsOnlyField is a path/query parameter that got no typed flag because its
// kebab name is already taken by another flag (a standard flag like --format, or
// a root persistent flag). It stays reachable via --params; the binder keeps it,
// with the flag that claimed the name, so --help can show the exact --params form
// and steer the reader off the wrong flag.
type paramsOnlyField struct {
field meta.Field
claimed *pflag.Flag
}
// paramFlagBinder owns one service method's generated typed param flags: it
// registers them (kind, help, enum completion, reserved-name skip) and applies
// the --params overlay, where a changed typed flag overrides its key in the
// --params JSON. Holding the field<->flag binding here keeps the request builder
// from re-deriving which flags map to which param keys.
type paramFlagBinder struct {
bound []boundParamFlag
paramsOnly []paramsOnlyField
}
// newParamFlagBinder registers one typed kebab flag per path/query parameter on
// cmd and returns a binder for the --params overlay. A name already taken by
// another flag is skipped — pflag panics on a local duplicate and a generated
// flag would silently shadow a persistent one — and recorded as paramsOnly so
// the parameter stays reachable (and discoverable) via --params. The taken set
// is derived, not hand-listed: local flags (the standard set, registered before
// this runs) via cmd, the lazily-added --help materialized here, and the root's
// persistent flags via reserved (nil for direct callers that have no root).
func newParamFlagBinder(cmd *cobra.Command, params []meta.Field, reserved *pflag.FlagSet) *paramFlagBinder {
cmd.InitDefaultHelpFlag() // materialize --help/-h so the local guard below sees it
b := &paramFlagBinder{}
for _, f := range params {
name := f.FlagName()
if claimed := flagClaiming(cmd, reserved, name); claimed != nil {
b.paramsOnly = append(b.paramsOnly, paramsOnlyField{field: f, claimed: claimed})
continue
}
read := registerTypedFlag(cmd.Flags(), name, f.CanonicalType(), paramFlagUsage(f))
if values := enumStrings(f.EnumValues()); len(values) > 0 {
cmdutil.RegisterFlagCompletion(cmd, name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return values, cobra.ShellCompDirectiveNoFileComp
})
}
// Group as an API parameter and mark required/optional for the
// Required/Optional subsections of the grouped --help renderer.
if fl := cmd.Flags().Lookup(name); fl != nil {
annotate(fl, flagGroupAnnotation, []string{groupParams})
sub := subOptional
if f.Required {
sub = subRequired
}
annotate(fl, flagSubAnnotation, []string{sub})
}
b.bound = append(b.bound, boundParamFlag{field: f, read: read})
}
return b
}
// flagClaiming returns the flag already occupying name (so a typed param flag
// would collide), or nil when the name is free. It checks the command's own
// flags (the standard set + the materialized --help) and the root's persistent
// flags — so the reserved set is whatever is actually registered, never a
// hand-kept list that drifts when a global flag is added.
func flagClaiming(cmd *cobra.Command, reserved *pflag.FlagSet, name string) *pflag.Flag {
if fl := cmd.Flags().Lookup(name); fl != nil {
return fl
}
if reserved != nil {
return reserved.Lookup(name)
}
return nil
}
// paramsOnlyHelp renders the --help addendum for parameters that have no typed
// flag, or "" when there are none. Per field: a copy-pasteable --params form,
// the same fieldFacts a typed flag would show on its usage line, and what the
// colliding flag actually does — so neither a human nor an agent sets the
// wrong one (e.g. --format, which is the output format, not the API parameter).
func (b *paramFlagBinder) paramsOnlyHelp() string {
if len(b.paramsOnly) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("\nParameters set via --params (no typed flag; the name is taken by another flag):\n")
for _, p := range b.paramsOnly {
name := p.field.Name
fmt.Fprintf(&sb, " %s: --params '{%q: %s}'\n", name, name, paramExample(p.field))
for _, fact := range fieldFacts(p.field) {
fmt.Fprintf(&sb, " %s\n", fact)
}
if p.claimed != nil {
fmt.Fprintf(&sb, " do not use --%s (%s)\n", p.claimed.Name, p.claimed.Usage)
}
}
return sb.String()
}
// hasTypedFlag reports whether the binder registered a typed flag for the
// param named name. False for params-only fields — a flag with the same kebab
// name may exist (that's the collision), but it is not this param's input.
// Nil-safe for direct buildServiceRequest callers that have no binder.
func (b *paramFlagBinder) hasTypedFlag(name string) bool {
if b == nil {
return false
}
for _, pf := range b.bound {
if pf.field.Name == name {
return true
}
}
return false
}
// overlay lets an explicit typed flag override the same key in --params
// (--params is the base). Only changed flags apply, so the --params-only path is
// unchanged. A nil binder or cmd is a no-op.
func (b *paramFlagBinder) overlay(cmd *cobra.Command, params map[string]interface{}) {
if b == nil || cmd == nil {
return
}
for _, pf := range b.bound {
if cmd.Flags().Changed(pf.field.FlagName()) {
params[pf.field.Name] = pf.read()
}
}
}
// registerTypedFlag registers one flag of the given canonical JSON-Schema kind
// and returns a reader for its parsed value; the kind→pflag-type switch lives
// only here.
func registerTypedFlag(fs *pflag.FlagSet, name, kind, usage string) func() interface{} {
switch kind {
case "integer":
return flagReader(fs.Int(name, 0, usage))
case "boolean":
return flagReader(fs.Bool(name, false, usage))
case "array":
return flagReader(fs.StringArray(name, nil, usage))
default:
return flagReader(fs.String(name, "", usage))
}
}
func flagReader[T any](p *T) func() interface{} {
return func() interface{} { return *p }
}

View File

@@ -0,0 +1,626 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// imChatMembersCreate: POST chats/{chat_id}/members with one path param and one
// optional enum query param — the canonical case from the screenshot feedback.
func imChatMembersCreate() meta.Method {
return meta.FromMap(map[string]interface{}{
"path": "chats/{chat_id}/members",
"httpMethod": "POST",
"parameters": map[string]interface{}{
"chat_id": map[string]interface{}{
"type": "string", "location": "path", "required": true,
},
"member_id_type": map[string]interface{}{
"type": "string", "location": "query", "required": false,
"options": []interface{}{
map[string]interface{}{"value": "open_id"},
map[string]interface{}{"value": "user_id"},
},
},
},
})
}
func TestServiceMethod_TypedFlagRegistered(t *testing.T) {
f := &cmdutil.Factory{}
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
if cmd.Flags().Lookup("chat-id") == nil {
t.Error("expected generated --chat-id flag for path param chat_id")
}
if cmd.Flags().Lookup("member-id-type") == nil {
t.Error("expected generated --member-id-type flag for query param member_id_type")
}
}
// A query param literally named "format" kebab-collides with the global
// --format flag. Generation must skip it (never re-register, never panic) and
// leave the standard --format flag intact.
func TestServiceMethod_TypedFlagReservedCollisionSkipped(t *testing.T) {
method := map[string]interface{}{
"path": "messages",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"format": map[string]interface{}{"type": "string", "location": "query"},
},
}
var cmd *cobra.Command
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("flag generation panicked on reserved-name collision: %v", r)
}
}()
cmd = NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), meta.FromMap(method), "list", "messages", nil)
}()
fl := cmd.Flags().Lookup("format")
if fl == nil || fl.DefValue != "json" {
t.Fatalf("standard --format flag must be preserved, got %+v", fl)
}
}
func TestServiceMethod_TypedFlag_DrivesPathParam(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--data", `{"id_list":["ou_x"]}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
t.Errorf("expected URL with chat_id substituted from --chat-id, got:\n%s", stdout.String())
}
}
func TestServiceMethod_TypedFlag_DrivesQueryParam(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--member-id-type", "open_id", "--data", `{}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "member_id_type") || !strings.Contains(out, "open_id") {
t.Errorf("expected query param member_id_type=open_id from flag, got:\n%s", out)
}
}
func TestServiceMethod_TypedFlag_AgreesWithParams(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--params", `{"chat_id":"oc_abc123"}`, "--data", `{}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("same value via flag and --params should be accepted, got: %v", err)
}
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
t.Errorf("expected URL with chat_id, got:\n%s", stdout.String())
}
}
// --params is the base; an explicit typed flag overrides the same key.
func TestServiceMethod_TypedFlag_OverridesParams(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--chat-id", "oc_flag", "--params", `{"chat_id":"oc_params"}`, "--data", `{}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "chats/oc_flag/members") {
t.Errorf("expected --chat-id to override --params chat_id, got:\n%s", out)
}
if strings.Contains(out, "oc_params") {
t.Errorf("--params value should have been overridden by the flag, got:\n%s", out)
}
}
// Override works for a non-string (integer) param too, exercising the int
// register/read path end to end.
func TestServiceMethod_TypedFlag_IntegerOverridesParams(t *testing.T) {
method := map[string]interface{}{
"path": "messages",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"page_size": map[string]interface{}{"type": "integer", "location": "query"},
},
}
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "messages", nil)
cmd.SetArgs([]string{"--page-size", "100", "--params", `{"page_size":5}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "page_size") || !strings.Contains(out, "100") {
t.Errorf("expected --page-size 100 to override --params page_size=5, got:\n%s", out)
}
}
// Regression: with no typed flags passed, behavior is byte-identical to today.
func TestServiceMethod_TypedFlag_OnlyParamsStillWorks(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--params", `{"chat_id":"oc_abc123"}`, "--data", `{}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
t.Errorf("expected URL with chat_id from --params, got:\n%s", stdout.String())
}
}
// Regression: --params null is valid JSON that unmarshals to a nil map. A typed
// flag overlaying onto it must not panic (assignment to a nil map) — null is
// treated as "no base params", with the flag value applied on top.
func TestServiceMethod_TypedFlag_OverridesNullParams(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--params", "null", "--data", `{}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("--params null with a typed flag should not error, got: %v", err)
}
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
t.Errorf("expected chat_id from --chat-id over null --params, got:\n%s", stdout.String())
}
}
// Startup smoke test: registering every embedded method must not panic on a
// generated-flag name collision (pflag panics on duplicate registration, which
// would crash the whole CLI at startup), and a known path param must surface as
// a typed flag end to end.
func TestRegisterServiceCommands_GeneratesFlagsNoPanic(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
f := &cmdutil.Factory{}
defer func() {
if r := recover(); r != nil {
t.Fatalf("registering all service commands panicked: %v", r)
}
}()
RegisterServiceCommands(root, f)
create, _, err := root.Find([]string{"im", "chat.members", "create"})
if err != nil {
t.Fatalf("im chat.members create not registered: %v", err)
}
if create.Flags().Lookup("chat-id") == nil {
t.Error("expected generated --chat-id flag on im chat.members create")
}
}
// Locks the boolean and array branches of bindParamFlag end to end (string and
// integer are covered above): a bool flag yields true and a repeatable array
// flag yields all its elements in the request.
func TestServiceMethod_TypedFlag_BoolAndArrayKinds(t *testing.T) {
method := map[string]interface{}{
"path": "items",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"with_deleted": map[string]interface{}{"type": "boolean", "location": "query"},
"ids": map[string]interface{}{"type": "list", "location": "query"},
},
}
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "items", nil)
cmd.SetArgs([]string{"--with-deleted", "--ids", "a", "--ids", "b", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{"with_deleted", "true", "ids", "\"a\"", "\"b\""} {
if !strings.Contains(out, want) {
t.Errorf("expected dry-run output to contain %q, got:\n%s", want, out)
}
}
}
// Override (--params base, typed flag wins) is covered for string and integer
// above; this locks the same semantics for the boolean and array kinds.
func TestServiceMethod_TypedFlag_BoolAndArrayOverrideParams(t *testing.T) {
method := map[string]interface{}{
"path": "items",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"with_deleted": map[string]interface{}{"type": "boolean", "location": "query"},
"ids": map[string]interface{}{"type": "list", "location": "query"},
},
}
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "items", nil)
cmd.SetArgs([]string{
"--params", `{"with_deleted":false,"ids":["from_params"]}`,
"--with-deleted", "--ids", "a", "--ids", "b",
"--dry-run",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{"with_deleted", "true", "\"a\"", "\"b\""} {
if !strings.Contains(out, want) {
t.Errorf("expected flag to override --params (want %q), got:\n%s", want, out)
}
}
if strings.Contains(out, "from_params") {
t.Errorf("--params array value should have been overridden by --ids, got:\n%s", out)
}
}
// A param whose kebab name collides with a global flag (here "format" vs the
// global --format) gets no typed flag, but the collision is no longer silent:
// non-colliding params still get flags, the global --format is untouched, and
// --help shows the exact --params form and steers the reader off --format.
func TestServiceMethod_ParamsOnly_HelpSteersToParams(t *testing.T) {
method := map[string]interface{}{
"path": "things/{thing_id}",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"thing_id": map[string]interface{}{"type": "string", "location": "path", "required": true},
"format": map[string]interface{}{"type": "string", "location": "query", "min": "1", "max": "64", "description": "返回的消息体格式。", "options": []interface{}{
map[string]interface{}{"value": "full"},
map[string]interface{}{"value": "metadata"},
}},
},
}
cmd := NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), meta.FromMap(method), "get", "things", nil)
if cmd.Flags().Lookup("thing-id") == nil {
t.Error("non-colliding param should still get a typed --thing-id flag")
}
if fl := cmd.Flags().Lookup("format"); fl == nil || fl.DefValue != "json" {
t.Fatalf("global --format must be preserved (not shadowed), got %+v", fl)
}
for _, want := range []string{`--params '{"format"`, "返回的消息体格式", "full", "metadata", "min: 1, max: 64", "do not use --format"} {
if !strings.Contains(cmd.Long, want) {
t.Errorf("help should contain %q so the reader uses --params, not --format; got:\n%s", want, cmd.Long)
}
}
}
// The collision guard derives reserved names from the actual flag sets — local
// flags plus the root's persistent flags passed in — so a future persistent
// flag is covered with no hand-maintained list. Here a param named "profile"
// (a root persistent flag) is skipped while a normal param is bound.
func TestParamFlagBinder_PersistentFlagReserved(t *testing.T) {
cmd := &cobra.Command{Use: "x"}
reserved := pflag.NewFlagSet("root", pflag.ContinueOnError)
reserved.String("profile", "", "use a specific profile")
m := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"profile": map[string]interface{}{"type": "string", "location": "query"},
"id": map[string]interface{}{"type": "string", "location": "path"},
}})
b := newParamFlagBinder(cmd, m.Params(), reserved)
if cmd.Flags().Lookup("id") == nil {
t.Error("non-colliding param should get a typed flag")
}
if cmd.Flags().Lookup("profile") != nil {
t.Error("param colliding with a reserved persistent flag must not be registered")
}
found := false
for _, p := range b.paramsOnly {
if p.field.Name == "profile" {
found = true
}
}
if !found {
t.Error("colliding param should be recorded for the --params help note")
}
}
// boolIntQueryMethod is the fixture for the zero-value semantics tests: one
// boolean and one integer query param, where false and 0 are meaningful values.
func boolIntQueryMethod(required bool) meta.Method {
return meta.FromMap(map[string]interface{}{
"path": "items",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"with_deleted": map[string]interface{}{"type": "boolean", "location": "query", "required": required},
"page_size": map[string]interface{}{"type": "integer", "location": "query"},
},
})
}
// Presence is intent: a typed flag is only overlaid when explicitly Changed,
// so --flag=false / --flag 0 are real values and must be sent — not silently
// dropped as "empty", which would let the API default win over an explicit
// user choice.
func TestServiceMethod_TypedFlag_ExplicitFalseAndZeroAreSent(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(false), "list", "items", nil)
cmd.SetArgs([]string{"--with-deleted=false", "--page-size", "0", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{`"with_deleted": false`, `"page_size": 0`} {
if !strings.Contains(out, want) {
t.Errorf("explicit zero value must be sent (want %s), got:\n%s", want, out)
}
}
}
// An explicitly provided false satisfies a required query parameter — the
// pre-flight must not report "missing" for a value the user just set.
func TestServiceMethod_TypedFlag_ExplicitFalseSatisfiesRequired(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(true), "list", "items", nil)
cmd.SetArgs([]string{"--with-deleted=false", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("required param explicitly set to false must pass pre-flight, got: %v", err)
}
if !strings.Contains(stdout.String(), `"with_deleted": false`) {
t.Errorf("explicit false must be sent, got:\n%s", stdout.String())
}
}
// The same presence-is-intent rule applies to the --params JSON base: a key
// deliberately written as false/0 is sent. (Zero values used to be silently
// dropped; this locks the corrected semantics as the contract.)
func TestServiceMethod_Params_JSONZeroValuesAreSent(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(false), "list", "items", nil)
cmd.SetArgs([]string{"--params", `{"with_deleted":false,"page_size":0}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{`"with_deleted": false`, `"page_size": 0`} {
if !strings.Contains(out, want) {
t.Errorf("--params zero value must be sent (want %s), got:\n%s", want, out)
}
}
}
// "" stays unusable: a required parameter fed an empty-string placeholder is
// still caught by the friendly pre-flight error, not sent as an empty value.
func TestServiceMethod_Params_EmptyStringStillMissing(t *testing.T) {
method := meta.FromMap(map[string]interface{}{
"path": "items",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"user_id_type": map[string]interface{}{"type": "string", "location": "query", "required": true},
},
})
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "items", nil)
cmd.SetArgs([]string{"--params", `{"user_id_type":""}`, "--dry-run"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "missing required query parameter") {
t.Fatalf("empty string for a required param should still pre-flight error, got: %v", err)
}
}
// A declared optional query param fed "" is dropped (unusable value), not sent
// as an empty query value — the declared-param loop owns the decision and the
// undeclared passthrough must not resurrect it. Undeclared keys stay the
// verbatim raw escape hatch.
func TestServiceMethod_Params_EmptyOptionalDroppedUndeclaredKept(t *testing.T) {
method := meta.FromMap(map[string]interface{}{
"path": "items",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"user_id_type": map[string]interface{}{"type": "string", "location": "query"},
},
})
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "items", nil)
cmd.SetArgs([]string{"--params", `{"user_id_type":"","custom_key":"v1"}`, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if strings.Contains(out, "user_id_type") {
t.Errorf("declared optional param with empty value must be dropped, got:\n%s", out)
}
if !strings.Contains(out, `"custom_key": "v1"`) {
t.Errorf("undeclared key must pass through verbatim, got:\n%s", out)
}
}
// min/max from the metadata surface on the typed flag's help line, in the same
// vocabulary as the envelope's minimum/maximum.
func TestParamFlagUsage_Bounds(t *testing.T) {
cases := []struct{ name, min, max, want string }{
{"both", "1", "100", "min: 1, max: 100"},
{"min only", "1", "", "min: 1"},
{"max only", "", "64", "max: 64"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"page_size": map[string]interface{}{"type": "integer", "location": "query", "min": tc.min, "max": tc.max},
}}).Params()
if usage := paramFlagUsage(fields[0]); !strings.Contains(usage, tc.want) {
t.Errorf("usage = %q, want contains %q", usage, tc.want)
}
})
}
t.Run("no bounds, no clause", func(t *testing.T) {
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"page_token": map[string]interface{}{"type": "string", "location": "query"},
}}).Params()
if usage := paramFlagUsage(fields[0]); strings.Contains(usage, "min:") || strings.Contains(usage, "max:") {
t.Errorf("usage without bounds should not mention min/max, got %q", usage)
}
})
}
// The sanitized field description rides the help line — a bare name like
// user_mailbox_id carries no meaning. The cut is at note separators (;), NOT
// at sentence ends (。): the later sentence often holds the key affordance.
func TestParamFlagUsage_Description(t *testing.T) {
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"user_mailbox_id": map[string]interface{}{
"type": "string", "location": "path", "required": true,
"description": `用户邮箱地址。当使用用户身份访问时,可以输入"me"代表当前调用接口用户;后续补充说明不该出现`,
},
}}).Params()
usage := paramFlagUsage(fields[0])
if !strings.Contains(usage, `可以输入"me"代表当前调用接口用户`) {
t.Errorf("description must keep full sentences up to the note separator, got %q", usage)
}
if strings.Contains(usage, "补充说明") {
t.Errorf("text after the note separator must be cut, got %q", usage)
}
t.Run("long description truncated", func(t *testing.T) {
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"x": map[string]interface{}{
"type": "string", "location": "query",
"description": strings.Repeat("长", 80),
},
}}).Params()
usage := paramFlagUsage(fields[0])
if !strings.Contains(usage, "...") {
t.Errorf("long description should be truncated with ellipsis, got %q", usage)
}
if strings.Contains(usage, strings.Repeat("长", 61)) {
t.Errorf("description should not exceed the cap, got %q", usage)
}
})
t.Run("trailing sentence punctuation trimmed", func(t *testing.T) {
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"x": map[string]interface{}{
"type": "string", "location": "query", "description": "返回格式。",
},
}}).Params()
if usage := paramFlagUsage(fields[0]); strings.Contains(usage, "。.") {
t.Errorf("clause join must not double the punctuation, got %q", usage)
}
})
}
// Pins the convergence contract: the params-only addendum renders the SAME
// fieldFacts list the typed flag's usage line joins inline — a fact added to
// fieldFacts reaches both surfaces, and neither can drift over what a param's
// help says (the addendum once rendered values-only enums and silently lacked
// the API default).
func TestParamHelp_BothSurfacesRenderFieldFacts(t *testing.T) {
f := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
"mode": map[string]interface{}{
"type": "string", "location": "query",
"description": "模式选择。",
"default": "fast",
"min": "1", "max": "8",
"options": []interface{}{
map[string]interface{}{"value": "fast", "description": "快速"},
map[string]interface{}{"value": "full"},
},
},
}}).Params()[0]
facts := fieldFacts(f)
if len(facts) != 4 { // description, enum, bounds, API default
t.Fatalf("fieldFacts = %v, want 4 facts", facts)
}
usage := paramFlagUsage(f)
help := (&paramFlagBinder{paramsOnly: []paramsOnlyField{{field: f}}}).paramsOnlyHelp()
for _, fact := range facts {
if !strings.Contains(usage, fact) {
t.Errorf("usage line missing fact %q: %q", fact, usage)
}
if !strings.Contains(help, fact) {
t.Errorf("params-only addendum missing fact %q:\n%s", fact, help)
}
}
}
// Bounds reach the registered flag's help end to end.
func TestServiceMethod_TypedFlag_HelpShowsBounds(t *testing.T) {
method := meta.FromMap(map[string]interface{}{
"path": "items",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"page_size": map[string]interface{}{"type": "integer", "location": "query", "min": "1", "max": "100", "default": "20"},
},
})
cmd := NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), method, "list", "items", nil)
fl := cmd.Flags().Lookup("page-size")
if fl == nil {
t.Fatal("expected generated --page-size flag")
}
if !strings.Contains(fl.Usage, "min: 1, max: 100") {
t.Errorf("flag usage should carry bounds, got %q", fl.Usage)
}
}
// The missing-required hint must name both recovery paths — the typed flag and
// the --params fallback — so a reader who only knows one input style can
// proceed without a round-trip through schema.
func TestServiceMethod_MissingRequired_HintNamesFlagAndParams(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--data", `{"id_list":["ou_x"]}`, "--dry-run"})
err := cmd.Execute()
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
for _, want := range []string{"--chat-id", `--params '{"chat_id": "<value>"}'`, "lark-cli schema im.chat.members.create"} {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint %q should contain %q", ve.Hint, want)
}
}
}
// A params-only required field (kebab name claimed by the standard --format
// flag) has no typed flag to offer: the hint must give only the --params form,
// never steer the reader to the colliding flag.
func TestServiceMethod_MissingRequired_ParamsOnlyHintSkipsFlag(t *testing.T) {
method := meta.FromMap(map[string]interface{}{
"path": "messages",
"httpMethod": "GET",
"parameters": map[string]interface{}{
"format": map[string]interface{}{"type": "string", "location": "query", "required": true},
},
})
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "messages", nil)
cmd.SetArgs([]string{"--dry-run"})
err := cmd.Execute()
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if !strings.Contains(ve.Hint, `--params '{"format": "<value>"}'`) {
t.Errorf("hint %q should carry the --params form", ve.Hint)
}
if strings.Contains(ve.Hint, "set --format") {
t.Errorf("hint %q must not steer to the colliding --format flag", ve.Hint)
}
}

162
cmd/service/paramhelp.go Normal file
View File

@@ -0,0 +1,162 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Help rendering for generated param flags. fieldFacts is the single list of
// agent-relevant facts a param exposes; every help surface (the typed flag's
// usage line, the params-only --params addendum) renders that one list, so the
// surfaces cannot drift over which facts exist. Values come from the
// meta.Field accessors, so nothing here depends on internal/schema.
package service
import (
"fmt"
"regexp"
"strconv"
"strings"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/util"
)
// fieldFacts returns a param field's facts in display order, each as a compact
// one-line clause: the sanitized description, the allowed enum values (with
// meanings), the min/max constraint, and the API default. This is the ONE
// place that decides what a param's help says — add a fact here (e.g. a future
// deprecation marker) and every surface shows it. Unabridged prose and
// per-option detail stay in `lark-cli schema`.
func fieldFacts(f meta.Field) []string {
var facts []string
if d := sanitizeFieldDesc(f.Description); d != "" {
facts = append(facts, d)
}
if opts := f.EnumOptions(); len(opts) > 0 {
facts = append(facts, "enum: "+formatEnumInline(opts))
}
if b := formatBoundsInline(f); b != "" {
facts = append(facts, b)
}
if s := literalStr(f.CoercedDefault()); s != "" {
facts = append(facts, "API default: "+s)
}
return facts
}
// paramFlagUsage renders the typed param flag's help line:
//
// <param_name>, required|optional[. <fact>]...
//
// It leads with the canonical underscore param name (the key this flag
// overrides in --params) and required/optional, then joins the field's facts
// inline.
func paramFlagUsage(f meta.Field) string {
req := "optional"
if f.Required {
req = "required"
}
parts := append([]string{fmt.Sprintf("%s, %s", f.Name, req)}, fieldFacts(f)...)
return strings.Join(parts, ". ") + "."
}
// paramExample picks a concrete sample for a params-only field's --help snippet:
// its first allowed enum value, else its example, else a placeholder.
func paramExample(f meta.Field) string {
if vals := enumStrings(f.EnumValues()); len(vals) > 0 {
return fmt.Sprintf("%q", vals[0])
}
if s := literalStr(f.CoercedExample()); s != "" {
return fmt.Sprintf("%q", s)
}
return `"<value>"`
}
var markdownLinkRe = regexp.MustCompile(`\[([^\]]*)\]\([^)]*\)`)
// inlineClause compresses metadata prose into one help clause: markdown links
// keep their text, the clause cuts at the first rune in stops, whitespace
// collapses, trailing punctuation goes — sentence enders (the clause join adds
// its own) and connectors a cut can strand, like a colon introducing a list the
// newline cut dropped — and the result caps at max runes. The two policies
// below differ only in where they cut and how much they keep.
func inlineClause(s, stops string, max int) string {
if s == "" {
return ""
}
s = markdownLinkRe.ReplaceAllString(s, "$1")
// Backquotes must go: pflag's UnquoteUsage treats a backquoted word in a
// flag's usage string as the flag's metavar, so a description like wiki
// space_id's "可替换为`my_library`" would render the flag as
// "--space-id my_library" instead of "--space-id string".
s = strings.ReplaceAll(s, "`", "")
if i := strings.IndexAny(s, stops); i >= 0 {
s = s[:i]
}
s = strings.Join(strings.Fields(s), " ")
s = strings.TrimRight(s, "。.:,、")
return util.TruncateStrWithEllipsis(s, max)
}
// sanitizeOptionDesc is the enum-option policy: many values share one line, so
// keep only the first clause (cut at 。 too) and stay ultra-compact.
func sanitizeOptionDesc(s string) string { return inlineClause(s, "。;;\n\r", 40) }
// sanitizeFieldDesc is the field-description policy: one line per field, so
// keep full sentences and cut only at note separators (meta_data appends
// bullet notes after ;/) — the later sentence often carries the key
// affordance, e.g. user_mailbox_id's `可以输入"me"`.
func sanitizeFieldDesc(s string) string { return inlineClause(s, ";\n\r", 60) }
// formatEnumInline renders allowed values for the help line: "v=meaning" when
// the value carries a (sanitized, truncated) description — so opaque numeric
// enums like succeed_type read as "0=…|1=…|2=…" — else just "v". Full meanings
// live in the envelope's enumDescriptions / `lark-cli schema`.
func formatEnumInline(opts []meta.EnumOption) string {
items := make([]string, len(opts))
for i, o := range opts {
if d := sanitizeOptionDesc(o.Description); d != "" {
items[i] = fmt.Sprintf("%v=%s", o.Value, d)
} else {
items[i] = fmt.Sprintf("%v", o.Value)
}
}
return strings.Join(items, "|")
}
// formatBoundsInline renders the field's min/max constraint ("min: 1, max:
// 100", or the single declared side), or "" when the field declares neither.
// The vocabulary matches the envelope's minimum/maximum, so help and `lark-cli
// schema` state the same constraint.
func formatBoundsInline(f meta.Field) string {
min, max := f.MinBound(), f.MaxBound()
switch {
case min != nil && max != nil:
return fmt.Sprintf("min: %s, max: %s", formatBound(*min), formatBound(*max))
case min != nil:
return "min: " + formatBound(*min)
case max != nil:
return "max: " + formatBound(*max)
}
return ""
}
// formatBound renders a bound without a float artifact (100 not 100.000000).
func formatBound(v float64) string {
return strconv.FormatFloat(v, 'f', -1, 64)
}
// literalStr renders a coerced literal (default/example) for flag help,
// returning "" for a nil or empty value so the caller can omit the clause.
func literalStr(v interface{}) string {
if v == nil {
return ""
}
return fmt.Sprintf("%v", v)
}
func enumStrings(enum []interface{}) []string {
out := make([]string, 0, len(enum))
for _, e := range enum {
out = append(out, fmt.Sprintf("%v", e))
}
return out
}

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"strings"
"testing"
)
func TestSanitizeOptionDesc(t *testing.T) {
cases := map[string]string{
"": "",
"以 open_id 标识用户": "以 open_id 标识用户",
"中文。English second clause": "中文", // first clause only (。)
"headtail": "head", // first clause ()
"line one\nline two": "line one", // first clause (newline)
" spaced out ": "spaced out", // whitespace collapsed
"see [飞书后台](https://x/admin) 详情": "see 飞书后台 详情", // markdown link -> text, url dropped
}
for in, want := range cases {
if got := sanitizeOptionDesc(in); got != want {
t.Errorf("sanitizeOptionDesc(%q) = %q, want %q", in, got, want)
}
}
// Truncation: a long single clause is cut to 40 runes with an ellipsis,
// rune-safe (no split mid-character).
long := strings.Repeat("文", 60)
got := sanitizeOptionDesc(long)
if r := []rune(got); len(r) != 40 || !strings.HasSuffix(got, "...") {
t.Errorf("truncation = %q (%d runes), want 40 runes ending in ...", got, len(r))
}
}
func TestSanitizeFieldDesc_TrimsDanglingPunctuation(t *testing.T) {
// A clause cut can strand a connector (e.g. a colon introducing a list the
// newline cut drops, as in im.reactions.list's message_id); the help line
// joiner then renders "…获取方式:." — so dangling punctuation must go too.
cases := map[string]string{
"待查询的消息ID。ID 获取方式:\n- 调用接口获取": "待查询的消息ID。ID 获取方式",
"see the list below:\nitem": "see the list below",
"逗号结尾,\n下一行": "逗号结尾",
}
for in, want := range cases {
if got := sanitizeFieldDesc(in); got != want {
t.Errorf("sanitizeFieldDesc(%q) = %q, want %q", in, got, want)
}
}
}
func TestSanitizeFieldDesc_StripsBackquotes(t *testing.T) {
// pflag's UnquoteUsage takes a backquoted word in a flag's usage string as
// the flag's metavar: wiki space_id's description rendered the flag as
// "--space-id my_library" instead of "--space-id string".
in := "[知识空间id](https://x/wiki),如果查询我的文档库可替换为`my_library`"
want := "知识空间id如果查询我的文档库可替换为my_library"
if got := sanitizeFieldDesc(in); got != want {
t.Errorf("sanitizeFieldDesc(%q) = %q, want %q", in, got, want)
}
}

View File

@@ -10,18 +10,21 @@ import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// RegisterServiceCommands registers all service commands from from_meta specs.
@@ -30,85 +33,79 @@ func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
}
func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
for _, project := range registry.ListFromMetaProjects() {
spec := registry.LoadFromMeta(project)
if spec == nil {
RegisterServiceCommandsFromCatalog(ctx, parent, f, registry.RuntimeCatalog())
}
func RegisterServiceCommandsFromCatalog(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory, catalog apicatalog.Catalog) {
// Drive the service list from the same navigation catalog the method walk
// uses, so registration is catalog-sourced end to end. Kept as a per-service
// loop rather than a flat WalkMethods(nil) drive precisely so a service with
// no methods still gets its bare command (WalkMethods yields one ref per
// method, so empty services would vanish).
for _, svc := range catalog.Services() {
if svc.Name == "" || svc.ServicePath == "" {
continue
}
specName := registry.GetStrFromMap(spec, "name")
servicePath := registry.GetStrFromMap(spec, "servicePath")
if specName == "" || servicePath == "" {
continue
}
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
continue
}
registerServiceWithContext(ctx, parent, spec, resources, f)
registerServiceWithContext(ctx, parent, svc, f)
}
}
func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
registerServiceWithContext(context.Background(), parent, spec, resources, f)
func registerService(parent *cobra.Command, svc meta.Service, f *cmdutil.Factory) {
registerServiceWithContext(context.Background(), parent, svc, f)
}
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
specName := registry.GetStrFromMap(spec, "name")
specDesc := registry.GetServiceDescription(specName, "en")
if specDesc == "" {
specDesc = registry.GetStrFromMap(spec, "description")
}
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, svc meta.Service, f *cmdutil.Factory) {
svcCmd := ensureChildCommand(parent, svc.Name, serviceShort(svc))
// Find existing service command or create one
var svc *cobra.Command
// Build the service's subtree from the catalog's method walk
// (apicatalog.ServiceMethods recurses nested resources), so the command tree
// is sourced from the same navigation Module as schema/scope rather than a
// hand-rolled resource/method walk. Each ref's ResourcePath becomes the
// resource-command chain — one level for a flat dotted resource like
// "chat.members", deeper for genuinely nested resources. A service with no
// methods keeps its bare command (svcCmd is created above regardless).
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
resCmd := svcCmd
for _, seg := range ref.ResourcePath {
resCmd = ensureChildCommand(resCmd, seg, seg+" operations")
}
resCmd.AddCommand(buildMethodCommand(ctx, f, newMethodCommandSpec(ref), nil, parent.PersistentFlags()))
}
}
// serviceShort is the service command's help summary: the localized description
// from the registry, falling back to the metadata's own description.
func serviceShort(svc meta.Service) string {
if d := registry.GetServiceDescription(svc.Name, "en"); d != "" {
return d
}
return svc.Description
}
// ensureChildCommand returns the child of parent named name, creating it (with
// short) when absent — so re-registration merges into an existing command tree
// instead of duplicating a level.
func ensureChildCommand(parent *cobra.Command, name, short string) *cobra.Command {
for _, c := range parent.Commands() {
if c.Name() == specName {
svc = c
break
if c.Name() == name {
cmdmeta.SetSource(c, cmdmeta.SourceService, true)
return c
}
}
if svc == nil {
svc = &cobra.Command{
Use: specName,
Short: specDesc,
}
parent.AddCommand(svc)
}
for resName, resource := range resources {
resMap, _ := resource.(map[string]interface{})
if resMap == nil {
continue
}
registerResourceWithContext(ctx, svc, spec, resName, resMap, f)
}
}
func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
res := &cobra.Command{
Use: name,
Short: name + " operations",
}
parent.AddCommand(res)
methods, _ := resource["methods"].(map[string]interface{})
for methodName, method := range methods {
methodMap, _ := method.(map[string]interface{})
if methodMap == nil {
continue
}
registerMethodWithContext(ctx, res, spec, methodMap, methodName, name, f)
}
cmd := &cobra.Command{Use: name, Short: short}
cmdmeta.SetSource(cmd, cmdmeta.SourceService, true)
parent.AddCommand(cmd)
return cmd
}
// ServiceMethodOptions holds all inputs for a dynamically registered service method command.
type ServiceMethodOptions struct {
Factory *cmdutil.Factory
Cmd *cobra.Command
Ctx context.Context
Spec map[string]interface{}
Method map[string]interface{}
SchemaPath string
Factory *cmdutil.Factory
Cmd *cobra.Command
Ctx context.Context
ServicePath string
Method meta.Method
SchemaPath string
// Flags
Params string
@@ -123,41 +120,113 @@ type ServiceMethodOptions struct {
DryRun bool
File string // --file flag value
FileFields []string // auto-detected file field names from metadata
// binder owns the generated typed param flags — registration and the
// --params overlay — replacing the raw paramFlags side-channel.
binder *paramFlagBinder
}
// detectFileFields delegates to the shared cmdutil.DetectFileFields helper.
func detectFileFields(method map[string]interface{}) []string {
return cmdutil.DetectFileFields(method)
}
func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
parent.AddCommand(NewCmdServiceMethodWithContext(ctx, f, spec, method, name, resName, nil))
// detectFileFields returns the request-body file-upload field names.
func detectFileFields(m meta.Method) []string {
files := m.Files()
if len(files) == 0 {
return nil
}
names := make([]string, len(files))
for i, f := range files {
names[i] = f.Name
}
return names
}
// NewCmdServiceMethod creates a command for a dynamically registered service method.
func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
return NewCmdServiceMethodWithContext(context.Background(), f, spec, method, name, resName, runF)
func NewCmdServiceMethod(f *cmdutil.Factory, svc meta.Service, m meta.Method, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
return NewCmdServiceMethodWithContext(context.Background(), f, svc, m, name, resName, runF)
}
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
desc := registry.GetStrFromMap(method, "description")
httpMethod := registry.GetStrFromMap(method, "httpMethod")
risk := registry.GetStrFromMap(method, "risk")
specName := registry.GetStrFromMap(spec, "name")
schemaPath := fmt.Sprintf("%s.%s.%s", specName, resName, name)
// NewCmdServiceMethodWithContext builds the command for one service method from
// its (service, resource, method) coordinates, deriving the methodCommandSpec
// via an apicatalog.MethodRef so direct callers and the catalog-driven
// registration assemble the command identically.
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, svc meta.Service, m meta.Method, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
m.Name = name
ref := apicatalog.MethodRef{Service: svc, ResourcePath: []string{resName}, Method: m}
// No root in scope here; persistent-flag collisions don't apply to a
// standalone command, and local/standard-flag collisions are still caught.
return buildMethodCommand(ctx, f, newMethodCommandSpec(ref), runF, nil)
}
// methodCommandSpec is the static description of one generated service method
// command, read off an apicatalog.MethodRef — the single place command
// construction gets the method's facts (schema path, HTTP base path, risk,
// identities, params, file fields, request-body support), so the cobra command
// is assembled from a typed spec rather than recomputing paths/flags inline.
type methodCommandSpec struct {
method meta.Method
schemaPath string // "service.resource.method", for the --help hint
servicePath string // service HTTP base path
risk string // RiskRead | RiskWrite | RiskHighRiskWrite
restricts bool // method declares accessTokens (identity-restricted)
identities []string // permitted --as values; empty when unrestricted
params []meta.Field // path/query params -> typed flags
fileFields []string // request-body file-upload field names
// acceptsBody is whether the HTTP method allows a request body at all (so
// --data is offered as a raw escape hatch). declaresBody is whether the
// metadata documents body fields (data or file). They differ for e.g. a POST
// with no documented requestBody: --data still works, but help must not imply
// the API declares a body.
acceptsBody bool
declaresBody bool
affordance string // rendered hand-authored usage guidance (when-to-use, examples); "" if none
}
func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
m := ref.Method
return methodCommandSpec{
method: m,
schemaPath: ref.SchemaPath(),
servicePath: ref.Service.ServicePath,
risk: m.Risk,
restricts: m.RestrictsIdentity(),
identities: m.Identities(),
params: m.Params(),
fileFields: detectFileFields(m),
acceptsBody: methodTakesBody(m.HTTPMethod),
declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0,
affordance: renderAffordance(m),
}
}
// methodTakesBody reports whether the HTTP method allows a request body, i.e.
// whether --data applies (as a raw escape hatch even when no body is declared).
func methodTakesBody(httpMethod string) bool {
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
return true
}
return false
}
// buildMethodCommand assembles the cobra command for a service method from its
// static spec: the standard flags, the conditional --data/--file/--yes flags,
// the generated typed param flags (via paramFlagBinder), and the risk/identity
// policy annotations.
func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodCommandSpec, runF func(*ServiceMethodOptions) error, reserved *pflag.FlagSet) *cobra.Command {
m := spec.method
opts := &ServiceMethodOptions{
Factory: f,
Spec: spec,
Method: method,
SchemaPath: schemaPath,
Factory: f,
ServicePath: spec.servicePath,
Method: m,
SchemaPath: spec.schemaPath,
FileFields: spec.fileFields,
}
var asStr string
cmd := &cobra.Command{
Use: name,
Short: desc,
Long: fmt.Sprintf("%s\n\nView parameter definitions before calling:\n lark-cli schema %s", desc, schemaPath),
Use: m.Name,
Short: m.Description,
// Long is assembled below, once the binder knows which params got no
// typed flag.
RunE: func(cmd *cobra.Command, args []string) error {
opts.Cmd = cmd
opts.Ctx = cmd.Context()
@@ -168,11 +237,17 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
return serviceMethodRun(opts)
},
}
cmdmeta.SetSource(cmd, cmdmeta.SourceService, true)
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin, @file for file input)")
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
cmd.Flags().StringVar(&opts.Params, "params", "", "Raw URL/query params JSON. Supports - and @file.")
if spec.acceptsBody {
dataUsage := "JSON request body. Supports - and @file."
if !spec.declaresBody {
// POST/etc. with no documented body fields: --data is a raw escape
// hatch, not a declared body — say so rather than imply structure.
dataUsage = "Raw JSON request body (no documented fields; see schema). Supports - and @file."
}
cmd.Flags().StringVar(&opts.Data, "data", "", dataUsage)
}
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
@@ -183,27 +258,61 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
if risk == "high-risk-write" {
if spec.risk == cmdutil.RiskHighRiskWrite {
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
}
// Conditionally register --file for methods with file-type fields.
fileFields := detectFileFields(method)
opts.FileFields = fileFields
if len(fileFields) > 0 {
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)")
}
// --file only for body methods that actually declare file-type fields.
if len(spec.fileFields) > 0 && spec.acceptsBody {
cmd.Flags().StringVar(&opts.File, "file", "", "File upload [field=]path. Supports - and stdin.")
}
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips"))
cmdutil.SetRisk(cmd, risk)
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens))
// Registered last so the collision guard sees the standard flags above.
opts.binder = newParamFlagBinder(cmd, spec.params, reserved)
// Single composition point for Long: description, affordance, schema
// pointer, and the binder's params-only addendum (params whose flag name is
// taken, reachable via --params only).
cmd.Long = methodLong(m.Description, spec.affordance, spec.schemaPath, opts.binder.paramsOnlyHelp())
// Group flags for the grouped --help renderer (typed param flags are grouped
// as API Parameters by the binder). tagFlagGroup is a no-op for flags not
// registered above (e.g. --data/--file/--yes only exist for some methods).
// --data sits under Request Body only when the metadata documents body
// fields; otherwise it's a raw escape hatch, grouped with --params so help
// doesn't imply a declared body the API doesn't have.
if fl := cmd.Flags().Lookup("data"); fl != nil {
if spec.declaresBody {
annotate(fl, flagGroupAnnotation, []string{groupBody})
} else {
annotate(fl, flagGroupAnnotation, []string{groupRaw})
}
}
tagFlagGroup(cmd.Flags(), "file", groupBody)
if fl := cmd.Flags().Lookup("params"); fl != nil {
annotate(fl, flagGroupAnnotation, []string{groupRaw})
// State the precedence rule where the agent reads it: --params is the
// base, typed flags override. Only meaningful when typed flags exist.
if len(spec.params) > 0 {
annotate(fl, flagNoteAnnotation, []string{
"Typed API parameter flags above are preferred.",
"If both are set, typed flags override matching keys in --params.",
})
}
}
for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} {
tagFlagGroup(cmd.Flags(), name, groupExecution)
}
for _, name := range []string{"output", "format", "jq"} {
tagFlagGroup(cmd.Flags(), name, groupOutput)
}
applyGroupedUsage(cmd)
cmdutil.SetTips(cmd, m.Tips)
cmdutil.SetRisk(cmd, spec.risk)
if spec.restricts {
cmdutil.SetSupportedIdentities(cmd, spec.identities)
}
return cmd
@@ -218,8 +327,8 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
}
// Check if this API method supports the resolved identity.
if tokens, ok := opts.Method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
if err := f.CheckIdentity(opts.As, cmdutil.AccessTokensToIdentities(tokens)); err != nil {
if opts.Method.RestrictsIdentity() {
if err := f.CheckIdentity(opts.As, opts.Method.Identities()); err != nil {
return err
}
}
@@ -235,12 +344,10 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
if err != nil {
return err
}
// Identity info is now included in the JSON envelope; skip stderr printing.
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
// Identity is not printed to stderr here: it is part of the JSON envelope.
scopes, _ := opts.Method["scopes"].([]interface{})
if !opts.As.IsBot() {
if err := checkServiceScopes(opts.Ctx, f.Credential, opts.As, config, opts.Method, scopes); err != nil {
if err := checkServiceScopes(opts.Ctx, f.Credential, opts.As, config, opts.Method); err != nil {
return err
}
}
@@ -257,7 +364,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
return serviceDryRun(f, request, config, opts.Format)
}
if registry.GetStrFromMap(opts.Method, "risk") == "high-risk-write" {
if opts.Method.Risk == cmdutil.RiskHighRiskWrite {
if yes, _ := opts.Cmd.Flags().GetBool("yes"); !yes {
return cmdutil.RequireConfirmation(opts.SchemaPath)
}
@@ -280,7 +387,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
checkErr := ac.CheckResponse
if opts.PageAll {
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}, checkErr)
}
@@ -302,7 +409,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
}
// checkServiceScopes pre-checks user scopes before making the API call.
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error {
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method meta.Method) error {
if ctx.Err() != nil {
return ctx.Err()
}
@@ -311,23 +418,15 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
return nil //nolint:nilerr // skip scope check when token resolution fails or has no scopes
}
requiredScopes, hasRequired := method["requiredScopes"].([]interface{})
if hasRequired && len(requiredScopes) > 0 {
if len(method.RequiredScopes) > 0 {
// Strict: ALL requiredScopes must be present
required := make([]string, 0, len(requiredScopes))
for _, s := range requiredScopes {
if str, ok := s.(string); ok {
required = append(required, str)
}
}
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
if missing := auth.MissingScopes(result.Scopes, method.RequiredScopes); len(missing) > 0 {
return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), missing)
}
return nil
}
if len(scopes) == 0 {
if len(method.Scopes) == 0 {
return nil
}
@@ -336,12 +435,12 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
for _, s := range strings.Fields(result.Scopes) {
grantedSet[s] = true
}
for _, s := range scopes {
if str, ok := s.(string); ok && grantedSet[str] {
for _, s := range method.Scopes {
if grantedSet[s] {
return nil
}
}
recommended := registry.SelectRecommendedScope(scopes, "user")
recommended := registry.SelectRecommendedScopeFromStrings(method.Scopes, "user")
return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), []string{recommended})
}
@@ -362,14 +461,44 @@ func newPreflightMissingScopeError(brand, appID, identity string, missing []stri
WithIdentity(identity)
}
// unusableParamValue reports whether a provided path/query parameter value
// cannot form a usable request value: nil or an empty string. A key's presence
// in params is the intent signal — a typed flag is overlaid only when
// explicitly Changed, and a --params JSON key is deliberately written — so
// false and 0 are real values and must not be conflated with "unset"
// (reflect.IsZero would drop an explicit --with-deleted=false or --foo 0).
// Only nil/"" stay treated as missing: that keeps the friendly pre-flight
// error when a required param is fed an empty placeholder, and never emits a
// declared param as an empty path segment or query value. Undeclared keys are
// not judged by this rule — they pass through verbatim as the raw escape hatch.
func unusableParamValue(v interface{}) bool {
if v == nil {
return true
}
s, ok := v.(string)
return ok && s == ""
}
// missingParamHint is the recovery hint for a missing required parameter. It
// names both input paths — the typed flag when the binder registered one, and
// the --params fallback — plus the schema pointer. A params-only field gets
// only the --params form: a flag with its kebab name exists but belongs to
// something else (e.g. the output --format), and the hint must not steer
// there. Asking the binder, not cmd.Flags(), is what tells those apart.
func missingParamHint(opts *ServiceMethodOptions, f meta.Field) string {
paramsForm := fmt.Sprintf("--params '{%q: \"<value>\"}'", f.Name)
if opts.binder.hasTypedFlag(f.Name) {
return fmt.Sprintf("set --%s <value> (or %s); see: lark-cli schema %s", f.FlagName(), paramsForm, opts.SchemaPath)
}
return fmt.Sprintf("set %s; see: lark-cli schema %s", paramsForm, opts.SchemaPath)
}
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
// When dryRun is true and a file is provided, file reading is skipped and
// FileUploadMeta is returned instead so the caller can render dry-run output.
func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
spec := opts.Spec
method := opts.Method
schemaPath := opts.SchemaPath
httpMethod := registry.GetStrFromMap(method, "httpMethod")
httpMethod := method.HTTPMethod
// stdin is an io.Reader consumed at most once. Only one of --params/--data
// may use "-" (stdin); the conflict check below prevents silent data loss.
@@ -387,53 +516,55 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
if err != nil {
return client.RawApiRequest{}, nil, err
}
opts.binder.overlay(opts.Cmd, params)
url := registry.GetStrFromMap(spec, "servicePath") + "/" + registry.GetStrFromMap(method, "path")
url := opts.ServicePath + "/" + method.Path
parameters, _ := method["parameters"].(map[string]interface{})
for name, param := range parameters {
p, _ := param.(map[string]interface{})
if registry.GetStrFromMap(p, "location") != "path" {
specs := method.Params()
for _, s := range specs {
if s.Location != "path" {
continue
}
val, ok := params[name]
if !ok || util.IsEmptyValue(val) {
val, ok := params[s.Name]
if !ok || unusableParamValue(val) {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"missing required path parameter: %s", name).
WithHint("lark-cli schema %s", schemaPath).
WithParam(name)
"missing required path parameter: %s", s.Name).
WithHint("%s", missingParamHint(opts, s)).
WithParam(s.Name)
}
valStr := fmt.Sprintf("%v", val)
if err := validate.ResourceName(valStr, name); err != nil {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(name).WithCause(err)
if err := validate.ResourceName(valStr, s.Name); err != nil {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(s.Name).WithCause(err)
}
url = strings.Replace(url, "{"+name+"}", validate.EncodePathSegment(valStr), 1)
delete(params, name)
url = strings.Replace(url, "{"+s.Name+"}", validate.EncodePathSegment(valStr), 1)
delete(params, s.Name)
}
queryParams := map[string]interface{}{}
for name, param := range parameters {
p, _ := param.(map[string]interface{})
if registry.GetStrFromMap(p, "location") != "query" {
for _, s := range specs {
if s.Location != "query" {
continue
}
value, exists := params[name]
required, _ := p["required"].(bool)
isPaginationParam := opts.PageAll && (name == "page_token" || name == "page_size")
if required && !isPaginationParam && (!exists || util.IsEmptyValue(value)) {
value, exists := params[s.Name]
isPaginationParam := opts.PageAll && (s.Name == "page_token" || s.Name == "page_size")
if s.Required && !isPaginationParam && (!exists || unusableParamValue(value)) {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"missing required query parameter: %s", name).
WithHint("lark-cli schema %s", schemaPath).
WithParam(name)
"missing required query parameter: %s", s.Name).
WithHint("%s", missingParamHint(opts, s)).
WithParam(s.Name)
}
if exists && !util.IsEmptyValue(value) {
queryParams[name] = value
if exists && !unusableParamValue(value) {
queryParams[s.Name] = value
}
// This loop owns declared query params: consume the key so the
// passthrough below can't resurrect a value the gate dropped (an
// unusable "" would otherwise be sent as an empty query value).
delete(params, s.Name)
}
// Whatever remains is undeclared — the raw escape hatch for params the
// metadata doesn't (yet) describe; passed through verbatim, no filtering.
for name, value := range params {
if _, ok := queryParams[name]; !ok {
queryParams[name] = value
}
queryParams[name] = value
}
request := client.RawApiRequest{
@@ -496,20 +627,45 @@ func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *cor
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
return client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, checkErr)
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return err
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return apiErr
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
})
}
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) {
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
scanResult := output.ScanForSafety(commandPath, items, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
pf.FormatPage(items)
return nil
}, pagOpts)
if err != nil {
return err
@@ -519,7 +675,12 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
}
if !hasItems {
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
output.FormatValue(out, result, output.FormatJSON)
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
return nil
default:
@@ -528,9 +689,14 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
return err
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return apiErr
}
output.FormatValue(out, result, format)
return nil
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
}

View File

@@ -8,13 +8,14 @@ import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
)
// highRiskDeleteMethod mirrors a simple DELETE API with a required path
// parameter and risk metadata. The returned map is what service registration
// reads; the test exercises --yes registration and the gate behavior.
func highRiskDeleteMethod() map[string]interface{} {
return map[string]interface{}{
// parameter and risk metadata. The test exercises --yes registration and the
// gate behavior.
func highRiskDeleteMethod() meta.Method {
return meta.FromMap(map[string]interface{}{
"path": "files/{file_token}",
"httpMethod": "DELETE",
"risk": "high-risk-write",
@@ -23,11 +24,11 @@ func highRiskDeleteMethod() map[string]interface{} {
"type": "string", "location": "path", "required": true,
},
},
}
})
}
func writeMethodNoRisk() map[string]interface{} {
return map[string]interface{}{
func writeMethodNoRisk() meta.Method {
return meta.FromMap(map[string]interface{}{
"path": "files/{file_token}",
"httpMethod": "DELETE",
"parameters": map[string]interface{}{
@@ -35,7 +36,7 @@ func writeMethodNoRisk() map[string]interface{} {
"type": "string", "location": "path", "required": true,
},
},
}
})
}
func TestServiceMethod_YesFlagRegisteredForHighRisk(t *testing.T) {

View File

@@ -4,13 +4,19 @@
package service
import (
"context"
"encoding/json"
"errors"
"os"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/meta"
"github.com/spf13/cobra"
)
@@ -20,14 +26,14 @@ var testConfig = &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
func driveSpec() map[string]interface{} {
return map[string]interface{}{
func driveSpec() meta.Service {
return meta.ServiceFromMap(map[string]interface{}{
"name": "drive",
"servicePath": "/open-apis/drive/v1",
}
})
}
func driveMethod(httpMethod string, params map[string]interface{}) map[string]interface{} {
func driveMethod(httpMethod string, params map[string]interface{}) meta.Method {
m := map[string]interface{}{
"path": "files/{file_token}/copy",
"httpMethod": httpMethod,
@@ -41,7 +47,7 @@ func driveMethod(httpMethod string, params map[string]interface{}) map[string]in
},
}
}
return m
return meta.FromMap(m)
}
// ── registerService ──
@@ -49,23 +55,23 @@ func driveMethod(httpMethod string, params map[string]interface{}) map[string]in
func TestRegisterService(t *testing.T) {
parent := &cobra.Command{Use: "root"}
f := &cmdutil.Factory{}
spec := map[string]interface{}{
base := meta.ServiceFromMap(map[string]interface{}{
"name": "base",
"description": "Base API",
"servicePath": "/open-apis/base/v3",
}
resources := map[string]interface{}{
"tables": map[string]interface{}{
"methods": map[string]interface{}{
"list": map[string]interface{}{
"description": "List tables",
"httpMethod": "GET",
"resources": map[string]interface{}{
"tables": map[string]interface{}{
"methods": map[string]interface{}{
"list": map[string]interface{}{
"description": "List tables",
"httpMethod": "GET",
},
},
},
},
}
})
registerService(parent, spec, resources, f)
registerService(parent, base, f)
// service command exists
svc, _, err := parent.Find([]string{"base"})
@@ -90,18 +96,18 @@ func TestRegisterService_MergesExistingCommand(t *testing.T) {
parent.AddCommand(existing)
f := &cmdutil.Factory{}
spec := map[string]interface{}{
svc := meta.ServiceFromMap(map[string]interface{}{
"name": "base", "description": "Base API", "servicePath": "/open-apis/base/v3",
}
resources := map[string]interface{}{
"tables": map[string]interface{}{
"methods": map[string]interface{}{
"list": map[string]interface{}{"description": "List", "httpMethod": "GET"},
"resources": map[string]interface{}{
"tables": map[string]interface{}{
"methods": map[string]interface{}{
"list": map[string]interface{}{"description": "List", "httpMethod": "GET"},
},
},
},
}
})
registerService(parent, spec, resources, f)
registerService(parent, svc, f)
// Should reuse existing, not duplicate
count := 0
@@ -143,7 +149,7 @@ func TestNewCmdServiceMethod_StrictModeHidesAsFlag(t *testing.T) {
func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) {
f := &cmdutil.Factory{}
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files", nil)
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files", nil)
if cmd.Flags().Lookup("data") != nil {
t.Error("GET method should not have --data flag")
@@ -159,7 +165,7 @@ func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) {
func TestNewCmdServiceMethod_POSTHasDataFlag(t *testing.T) {
f := &cmdutil.Factory{}
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "POST"}, "create", "files", nil)
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "POST"}), "create", "files", nil)
if cmd.Flags().Lookup("data") == nil {
t.Error("POST method should have --data flag")
@@ -171,7 +177,7 @@ func TestNewCmdServiceMethod_RunFCallback(t *testing.T) {
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil
@@ -268,15 +274,15 @@ func TestServiceMethod_MissingPathParam(t *testing.T) {
}
func TestServiceMethod_MissingRequiredQueryParam(t *testing.T) {
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{
})
method := meta.FromMap(map[string]interface{}{
"path": "items", "httpMethod": "GET",
"parameters": map[string]interface{}{
"q": map[string]interface{}{"location": "query", "required": true},
},
}
})
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--params", `{}`, "--dry-run"})
@@ -291,15 +297,15 @@ func TestServiceMethod_MissingRequiredQueryParam(t *testing.T) {
}
func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) {
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{
})
method := meta.FromMap(map[string]interface{}{
"path": "items", "httpMethod": "GET",
"parameters": map[string]interface{}{
"page_size": map[string]interface{}{"location": "query", "required": true},
},
}
})
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--params", `{}`, "--page-all", "--dry-run"})
@@ -315,10 +321,10 @@ func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) {
func TestServiceMethod_InvalidParamsJSON(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--params", "{bad", "--dry-run"})
@@ -333,10 +339,10 @@ func TestServiceMethod_InvalidParamsJSON(t *testing.T) {
func TestServiceMethod_InvalidDataJSON(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}}
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil)
cmd.SetArgs([]string{"--data", "{bad", "--dry-run"})
@@ -351,10 +357,10 @@ func TestServiceMethod_InvalidDataJSON(t *testing.T) {
func TestServiceMethod_ParamsAndDataBothStdinConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}}
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil)
cmd.SetArgs([]string{"--params", "-", "--data", "-", "--dry-run"})
@@ -369,10 +375,10 @@ func TestServiceMethod_ParamsAndDataBothStdinConflict(t *testing.T) {
func TestServiceMethod_OutputAndPageAllConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--page-all", "--output", "file.bin", "--as", "bot"})
@@ -398,16 +404,27 @@ func TestServiceMethod_BotMode_Success(t *testing.T) {
},
})
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "success") {
t.Errorf("expected 'success' in output, got:\n%s", stdout.String())
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
if got["ok"] != true || got["identity"] != "bot" {
t.Fatalf("unexpected envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if !ok || data["result"] != "success" {
t.Fatalf("data = %#v, want result=success", got["data"])
}
}
@@ -427,16 +444,320 @@ func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
},
})
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), `"id"`) {
t.Errorf("expected items in output, got:\n%s", stdout.String())
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if got["ok"] != true || got["identity"] != "bot" || !ok {
t.Fatalf("unexpected envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
}
items, ok := data["items"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("data.items = %#v, want one item", data["items"])
}
}
type serviceContentSafetyProvider struct {
called bool
path string
data interface{}
match string
}
func (p *serviceContentSafetyProvider) Name() string { return "service-test" }
func (p *serviceContentSafetyProvider) Scan(_ context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
p.called = true
p.path = req.Path
p.data = req.Data
if p.match != "" {
b, _ := json.Marshal(req.Data)
if !strings.Contains(string(b), p.match) {
return nil, nil
}
}
return &extcs.Alert{Provider: "service-test", MatchedRules: []string{"pagination"}}, nil
}
func TestServiceMethod_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &serviceContentSafetyProvider{}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-safety", AppSecret: "test-secret-service-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdServiceMethod(f, spec, method, "list", "items", nil))
root.SetArgs([]string{"list", "--as", "bot", "--page-all"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !provider.called {
t.Fatal("expected content safety provider to scan paginated output")
}
if provider.path != "list" {
t.Fatalf("scan path = %q, want list", provider.path)
}
data, ok := provider.data.(map[string]interface{})
if !ok {
t.Fatalf("scanned data type = %T, want map", provider.data)
}
if _, hasCode := data["code"]; hasCode {
t.Fatalf("scanned data should be business data only, got %#v", data)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
alert, ok := got["_content_safety_alert"].(map[string]interface{})
if !ok || alert["provider"] != "service-test" {
t.Fatalf("missing content safety alert in envelope: %#v", got)
}
}
func TestServiceMethod_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &serviceContentSafetyProvider{}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-stream-safety", AppSecret: "test-secret-service-stream-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdServiceMethod(f, spec, method, "list", "items", nil))
root.SetArgs([]string{"list", "--as", "bot", "--page-all", "--format", "ndjson"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !provider.called {
t.Fatal("expected content safety provider to scan streamed paginated output")
}
if provider.path != "list" {
t.Fatalf("scan path = %q, want list", provider.path)
}
items, ok := provider.data.([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("scanned data = %#v, want one streamed item", provider.data)
}
if !strings.Contains(stderr.String(), "warning: content safety alert from service-test") {
t.Fatalf("expected content safety warning on stderr, got: %s", stderr.String())
}
if !strings.Contains(stdout.String(), `"id":"1"`) {
t.Fatalf("expected streamed ndjson output, got: %s", stdout.String())
}
}
func TestServiceMethod_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
provider := &serviceContentSafetyProvider{match: "blocked"}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-stream-block", AppSecret: "test-secret-service-stream-block", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
"has_more": true,
"page_token": "next",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "blocked-page"}},
"has_more": false,
},
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdServiceMethod(f, spec, method, "list", "items", nil))
root.SetArgs([]string{"list", "--as", "bot", "--page-all", "--format", "ndjson"})
err := root.Execute()
if err == nil {
t.Fatal("expected content safety block error")
}
var safetyErr *errs.ContentSafetyError
if !errors.As(err, &safetyErr) {
t.Fatalf("expected ContentSafetyError, got %T: %v", err, err)
}
if safetyErr.Category != errs.CategoryPolicy || safetyErr.Subtype != errs.SubtypeContentSafety {
t.Fatalf("problem = %s/%s, want %s/%s", safetyErr.Category, safetyErr.Subtype, errs.CategoryPolicy, errs.SubtypeContentSafety)
}
if len(safetyErr.Rules) != 1 || safetyErr.Rules[0] != "pagination" {
t.Fatalf("rules = %v, want [pagination]", safetyErr.Rules)
}
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier safe page to remain streamed, got: %s", out)
}
if strings.Contains(out, "blocked-page") {
t.Fatalf("blocked page was written before safety block: %s", out)
}
}
func TestServiceMethod_BusinessErrorReturnsTypedErrorWithoutSuccessEnvelope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-err", AppSecret: "test-secret-service-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected PermissionError, got %T: %v", err, err)
}
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
}
}
func TestServiceMethod_PageAll_DefaultBusinessErrorOutputsRawResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-pageall-err", AppSecret: "test-secret-service-pageall-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
if !strings.Contains(stdout.String(), "230027") || !strings.Contains(stdout.String(), "user not authorized") {
t.Fatalf("expected raw error response on stdout, got: %s", stdout.String())
}
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
}
}
func TestServiceMethod_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-pageall-stream-err", AppSecret: "test-secret-service-pageall-stream-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
"has_more": true,
"page_token": "next",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 230027,
"msg": "user not authorized",
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier successful page to remain streamed, got: %s", out)
}
if strings.Contains(out, "230027") || strings.Contains(out, "user not authorized") {
t.Fatalf("streaming stdout should not contain raw error JSON, got: %s", out)
}
if strings.Contains(out, "\n \"code\"") {
t.Fatalf("streaming stdout should not contain indented JSON error dump, got: %s", out)
}
}
@@ -450,8 +771,8 @@ func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
})
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--format", "unknown"})
@@ -470,7 +791,7 @@ func TestNewCmdServiceMethod_JqFlag(t *testing.T) {
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil
@@ -492,7 +813,7 @@ func TestNewCmdServiceMethod_JqShortForm(t *testing.T) {
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil
@@ -508,10 +829,10 @@ func TestNewCmdServiceMethod_JqShortForm(t *testing.T) {
func TestServiceMethod_JqAndOutputConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--jq", ".data", "--output", "file.bin", "--as", "bot"})
@@ -542,8 +863,8 @@ func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) {
},
})
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--jq", ".data.items[].name"})
@@ -561,10 +882,10 @@ func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) {
func TestServiceMethod_JqAndFormatConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--jq", ".data", "--format", "ndjson", "--as", "bot"})
@@ -579,10 +900,10 @@ func TestServiceMethod_JqAndFormatConflict(t *testing.T) {
func TestServiceMethod_JqInvalidExpression(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
spec := meta.ServiceFromMap(map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--jq", "invalid[", "--as", "bot"})
@@ -611,8 +932,8 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
},
})
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--jq", ".data.items[].id"})
@@ -628,10 +949,55 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
}
}
func TestServiceMethod_PageAll_WithJqBusinessErrorOutputsRawResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-spjq-err", AppSecret: "test-secret-spjq-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--jq", ".data.items[].id"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected PermissionError, got %T: %v", err, err)
}
if !strings.Contains(stdout.String(), "230027") || !strings.Contains(stdout.String(), "user not authorized") {
t.Fatalf("expected raw error response on stdout, got: %s", stdout.String())
}
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
}
}
func requireProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype, code int) {
t.Helper()
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != category || p.Subtype != subtype || p.Code != code {
t.Fatalf("problem = %s/%s/%d, want %s/%s/%d", p.Category, p.Subtype, p.Code, category, subtype, code)
}
}
// ── file upload ──
func imImageMethod() map[string]interface{} {
return map[string]interface{}{
func imImageMethod() meta.Method {
return meta.FromMap(map[string]interface{}{
"path": "images",
"httpMethod": "POST",
"requestBody": map[string]interface{}{
@@ -645,14 +1011,14 @@ func imImageMethod() map[string]interface{} {
},
},
"accessTokens": []interface{}{"user", "tenant"},
}
})
}
func imSpec() map[string]interface{} {
return map[string]interface{}{
func imSpec() meta.Service {
return meta.ServiceFromMap(map[string]interface{}{
"name": "im",
"servicePath": "/open-apis/im/v1",
}
})
}
func TestServiceMethod_FileFlagRegistered(t *testing.T) {
@@ -684,7 +1050,7 @@ func TestServiceMethod_FileFlagNotRegisteredForGET(t *testing.T) {
},
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), getMethod, "get", "images", nil)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(getMethod), "get", "images", nil)
flag := cmd.Flags().Lookup("file")
if flag != nil {
t.Fatal("expected --file flag NOT to be registered for GET method")
@@ -752,7 +1118,7 @@ func TestDetectFileFields(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := detectFileFields(tt.method)
got := detectFileFields(meta.FromMap(tt.method))
if len(got) != len(tt.want) {
t.Errorf("detectFileFields() = %v, want %v", got, tt.want)
return
@@ -771,7 +1137,7 @@ func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil

View File

@@ -11,6 +11,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
@@ -126,29 +127,20 @@ func TestUnknownSubcommandRunE_FlagBeforeSubcommandIsStructured(t *testing.T) {
t.Errorf("error = %q, want it to mention an unknown flag", err.Error())
}
// The detail must stay schema-compatible with flagDidYouMean's unknown_flag
// (same Type → same keys), so a consumer keyed on Type reads a stable shape.
exitErr, ok := err.(*output.ExitError)
if !ok || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError with Detail, got %T", err)
// Typed surface: a validation error (exit 2) whose Params carries the
// offending flag so an agent can recover the token without parsing prose.
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if exitErr.Detail.Type != "unknown_flag" {
t.Errorf("detail.Type = %q, want unknown_flag", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("expected detail to be map[string]any, got %T", exitErr.Detail.Detail)
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("exit code = %d, want %d", output.ExitCodeOf(err), output.ExitValidation)
}
if detail["unknown"] != "--badflag" {
t.Errorf("detail.unknown = %v, want --badflag", detail["unknown"])
}
if got, _ := detail["unknown_flags"].([]string); len(got) != 1 || got[0] != "--badflag" {
t.Errorf("detail.unknown_flags = %v, want [--badflag]", detail["unknown_flags"])
}
for _, key := range []string{"suggestions", "valid_flags"} {
if _, present := detail[key]; !present {
t.Errorf("detail.%s missing; must be present (empty) to match the unknown_flag schema", key)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--badflag" {
t.Errorf("params = %v, want one entry named --badflag", verr.Params)
}
}
@@ -172,25 +164,21 @@ func TestUnknownSubcommandRunE_ValidFlagWithoutSubcommandIsStructured(t *testing
if err == nil {
t.Fatal("expected a structured missing_subcommand error, got nil (help fallthrough)")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("exit code = %d, want %d", output.ExitCodeOf(err), output.ExitValidation)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "missing_subcommand" {
t.Fatalf("detail.Type = %v, want missing_subcommand", exitErr.Detail)
if !strings.Contains(verr.Message, "missing subcommand") {
t.Errorf("message = %q, want it to mention a missing subcommand", verr.Message)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
if len(verr.Params) != 1 || verr.Params[0].Name != "--query" {
t.Errorf("params = %v, want one entry named --query", verr.Params)
}
if flags, _ := detail["flags"].([]string); len(flags) != 1 || flags[0] != "--query" {
t.Errorf("detail.flags = %v, want [--query]", detail["flags"])
}
if detail["command_path"] != "lark-cli drive" {
t.Errorf("detail.command_path = %v, want lark-cli drive", detail["command_path"])
if !strings.Contains(verr.Message, "lark-cli drive") {
t.Errorf("message = %q, want it to name the group path", verr.Message)
}
}
@@ -241,45 +229,23 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
t.Fatal("expected error for unknown subcommand")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("expected exit code %d, got %d", output.ExitValidation, exitErr.Code)
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("expected exit code %d, got %d", output.ExitValidation, output.ExitCodeOf(err))
}
if exitErr.Detail == nil {
t.Fatal("expected ExitError to carry Detail")
if !strings.Contains(verr.Message, `"+bogus"`) {
t.Errorf("message should echo the unknown token, got %q", verr.Message)
}
if exitErr.Detail.Type != "unknown_subcommand" {
t.Errorf("expected Detail.Type=unknown_subcommand, got %q", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
if !strings.Contains(verr.Message, "lark-cli drive") {
t.Errorf("message should name the group path, got %q", verr.Message)
}
// "+bogus" has no close neighbor among drive's subcommands, so the hint falls
// back to pointing at --help; the full machine-readable list lives in
// detail.available below (which also excludes hidden commands).
if !strings.Contains(exitErr.Detail.Hint, "--help") {
t.Errorf("hint should guide to --help when there is no suggestion, got %q", exitErr.Detail.Hint)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("expected Detail.Detail to be map[string]any, got %T", exitErr.Detail.Detail)
}
if detail["unknown"] != "+bogus" {
t.Errorf("detail.unknown should be +bogus, got %v", detail["unknown"])
}
if detail["command_path"] != "lark-cli drive" {
t.Errorf("detail.command_path should be %q, got %v", "lark-cli drive", detail["command_path"])
}
available, ok := detail["available"].([]string)
if !ok {
t.Fatalf("detail.available should be []string, got %T", detail["available"])
}
if len(available) != 3 {
t.Errorf("expected 3 available entries (hidden excluded), got %d: %v", len(available), available)
// back to pointing at --help (suggestions, when present, are folded into hint).
if !strings.Contains(verr.Hint, "--help") {
t.Errorf("hint should guide to --help when there is no suggestion, got %q", verr.Hint)
}
}
@@ -288,13 +254,12 @@ func TestUnknownSubcommandRunE_NestedResourceGroup(t *testing.T) {
installUnknownSubcommandGuard(root)
err := files.RunE(files, []string{"bogus"})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError on nested group, got %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError on nested group, got %T", err)
}
if exitErr.Detail.Detail.(map[string]any)["command_path"] != "lark-cli drive files" {
t.Errorf("command_path should reflect the nested resource, got %v",
exitErr.Detail.Detail.(map[string]any)["command_path"])
if !strings.Contains(verr.Message, "lark-cli drive files") {
t.Errorf("message should reflect the nested resource path, got %q", verr.Message)
}
}
@@ -337,10 +302,10 @@ func TestAvailableSubcommandNames_SplitsDeprecatedGroup(t *testing.T) {
}
}
// unknownSubcommandRunE must split current vs deprecated subcommands into
// separate detail buckets, while suggestions still rank across both so a
// mistyped legacy alias resolves.
func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) {
// unknownSubcommandRunE ranks suggestions across both current and deprecated
// subcommands so a mistyped legacy alias resolves; the closest match is folded
// into the hint.
func TestUnknownSubcommandRunE_SuggestsAcrossDeprecatedBucket(t *testing.T) {
svc := &cobra.Command{Use: "sheets"}
svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
svc.AddCommand(
@@ -349,31 +314,26 @@ func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) {
)
err := unknownSubcommandRunE(svc, []string{"+reat"})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
// "+reat" is closest to the deprecated +read: the candidate must surface
// both as a machine-readable param suggestion (for agent retry) and in the
// hint, proving ranking spans the deprecated bucket.
if len(verr.Params) != 1 || verr.Params[0].Name != "+reat" {
t.Fatalf("params = %v, want one entry named +reat (the offending subcommand)", verr.Params)
}
if available, _ := detail["available"].([]string); len(available) != 1 || available[0] != "+cells-get" {
t.Errorf("available = %v, want [+cells-get]", available)
}
deprecated, ok := detail["deprecated"].([]string)
if !ok || len(deprecated) != 1 || deprecated[0] != "+read" {
t.Errorf("deprecated = %v, want [+read]", deprecated)
}
// suggestions rank across both buckets: "+reat" is closest to +read.
suggestions, _ := detail["suggestions"].([]string)
found := false
for _, s := range suggestions {
foundSuggestion := false
for _, s := range verr.Params[0].Suggestions {
if s == "+read" {
found = true
foundSuggestion = true
}
}
if !found {
t.Errorf("suggestions %v should include +read (typo target)", suggestions)
if !foundSuggestion {
t.Errorf("Params[0].Suggestions should include +read, got %v", verr.Params[0].Suggestions)
}
if !strings.Contains(verr.Hint, "+read") {
t.Errorf("hint %q should suggest +read (typo target across deprecated bucket)", verr.Hint)
}
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
@@ -132,12 +133,14 @@ func updateRun(opts *UpdateOptions) error {
// 1. Fetch latest version
latest, err := fetchLatest()
if err != nil {
return reportError(opts, io, output.ExitNetwork, "network", "failed to check latest version: %s", err)
return reportError(opts, io, "network",
errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to check latest version: %s", err).WithCause(err))
}
// 2. Validate version format
if update.ParseVersion(latest) == nil {
return reportError(opts, io, output.ExitInternal, "update_error", "invalid version from registry: %s", latest)
return reportError(opts, io, "update_error",
errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid version from registry: %s", latest))
}
// 3. Compare versions
@@ -166,15 +169,18 @@ func updateRun(opts *UpdateOptions) error {
// --- Output helpers ---
func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, exitCode int, errType, format string, args ...interface{}) error {
msg := fmt.Sprintf(format, args...)
// reportError emits the failure on the requested surface: JSON mode prints the
// {ok:false, error:{type, message}} envelope to stdout and signals the typed
// error's exit code bare; human mode returns the typed error for the
// dispatcher to render.
func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, errType string, typedErr errs.TypedError) error {
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "error": map[string]interface{}{"type": errType, "message": msg},
"ok": false, "error": map[string]interface{}{"type": errType, "message": typedErr.ProblemDetail().Message},
})
return output.ErrBare(exitCode)
return output.ErrBare(output.ExitCodeOf(typedErr))
}
return output.Errorf(exitCode, errType, "%s", msg)
return typedErr
}
func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, canAutoUpdate bool) error {
@@ -228,7 +234,8 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
restore, err := updater.PrepareSelfReplace()
if err != nil {
return reportError(opts, io, output.ExitAPI, "update_error", "failed to prepare update: %s", err)
return reportError(opts, io, "update_error",
errs.NewAPIError(errs.SubtypeUnknown, "failed to prepare update: %s", err).WithCause(err))
}
if !opts.JSON {

View File

@@ -14,6 +14,7 @@ import (
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
@@ -334,13 +335,88 @@ func TestUpdateFetchError_Human(t *testing.T) {
if err == nil {
t.Fatal("expected non-nil error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
var netErr *errs.NetworkError
if !errors.As(err, &netErr) {
t.Fatalf("expected *errs.NetworkError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitNetwork {
t.Errorf("expected ExitNetwork (%d), got %d", output.ExitNetwork, exitErr.Code)
if netErr.Subtype != errs.SubtypeNetworkTransport {
t.Errorf("subtype = %q, want %q", netErr.Subtype, errs.SubtypeNetworkTransport)
}
if got := output.ExitCodeOf(err); got != output.ExitNetwork {
t.Errorf("expected ExitNetwork (%d), got %d", output.ExitNetwork, got)
}
}
// TestUpdateInvalidVersion_Human verifies a malformed registry version surfaces
// as a typed internal error in human mode, keeping the legacy exit code 5.
func TestUpdateInvalidVersion_Human(t *testing.T) {
f, _, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "not-a-version", nil }
defer func() { fetchLatest = origFetch }()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
err := cmd.Execute()
if err == nil {
t.Fatal("expected non-nil error, got nil")
}
var intErr *errs.InternalError
if !errors.As(err, &intErr) {
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
}
if intErr.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("expected ExitInternal (%d), got %d", output.ExitInternal, got)
}
}
// TestReportError pins reportError's two surfaces after the typed migration:
// human mode returns the typed error unchanged; JSON mode prints the legacy
// {ok:false, error:{type, message}} envelope and exits bare with the typed
// error's exit code (parity with the legacy explicit exit-code argument).
func TestReportError(t *testing.T) {
t.Run("human mode returns the typed error", func(t *testing.T) {
f, _, _ := newTestFactory(t)
typed := errs.NewAPIError(errs.SubtypeUnknown, "failed to prepare update: disk full")
err := reportError(&UpdateOptions{JSON: false}, f.IOStreams, "update_error", typed)
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected *errs.APIError, got %T: %v", err, err)
}
if apiErr != typed {
t.Errorf("reportError must return the typed error unchanged")
}
if got := output.ExitCodeOf(err); got != output.ExitAPI {
t.Errorf("exit code = %d, want %d (ExitAPI, legacy parity)", got, output.ExitAPI)
}
})
t.Run("json mode prints envelope and exits bare with typed code", func(t *testing.T) {
f, stdout, _ := newTestFactory(t)
typed := errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to check latest version: timeout")
err := reportError(&UpdateOptions{JSON: true}, f.IOStreams, "network", typed)
var bareErr *output.BareError
if !errors.As(err, &bareErr) {
t.Fatalf("expected bare *output.BareError, got %T: %v", err, err)
}
if bareErr.Code != output.ExitNetwork {
t.Errorf("bare exit code = %d, want %d", bareErr.Code, output.ExitNetwork)
}
out := stdout.String()
if !strings.Contains(out, `"type": "network"`) && !strings.Contains(out, `"type":"network"`) {
t.Errorf("JSON envelope missing type, got: %s", out)
}
if !strings.Contains(out, "failed to check latest version: timeout") {
t.Errorf("JSON envelope missing message, got: %s", out)
}
})
}
func TestUpdateInvalidVersion_JSON(t *testing.T) {
@@ -503,12 +579,12 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
if err == nil {
t.Fatal("expected verification failure")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
var bareErr *output.BareError
if !errors.As(err, &bareErr) {
t.Fatalf("expected *output.BareError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI (%d), got %d", output.ExitAPI, exitErr.Code)
if bareErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI (%d), got %d", output.ExitAPI, bareErr.Code)
}
out := stdout.String()

View File

@@ -0,0 +1,305 @@
---
req_id: sheet-history-revert
mode: from-prd
tracks: [be]
created_at: 2026-06-23T13:09:08Z
---
> ⚠️ **请勿直接编辑此文档**
> 修改请通过 `/ccm-harness:draft-spec sheet-history-revert "<变更描述>"`(路由器会进入 update capability
> 手改不会被 check-spec-drift 放过,且会破坏 Frozen 快照关联性
> 本文档由 /ccm-harness:draft-spec 生成于 2026-06-23T13:09:08Zreq-idsheet-history-revert
---
# Sheet 历史版本查询与回滚
<!-- BEGIN generated overview — scripts/render_spec_overview.py 自动生成,勿手改;改 spec 请走 /ccm-harness:draft-spec <req-id> "<变更>"update -->
## 方案概览 / TL;DR
> 本段由机器从下方子任务字段**确定性生成**,供快速把握方案;**权威细节仍以各子任务 `yaml` 块为准**。
- **需求** `sheet-history-revert` 模式 `from-prd` 端 BE
- **规模** 5 BE **涉及 PSM** `tooling.lark_cli`, `tooling.sheet_skill_spec`, `sheet.facade.agg`, `bear.server.sheet_data` **Thrift 影响**×5
| 子任务 | 一句话 | 关键信息 |
|---|---|---|
| BE-1 | lark-cli `+history-list` 历史记录列表 shortcut | `tooling.lark_cli` · thrift:无 · `history_list[read]callTool tools/invoke_read内部对接 /space/api/v3/sheet/histories` |
| BE-2 | lark-cli `+history-revert``+history-revert-status` 回滚流 shortcut | `tooling.lark_cli` · thrift:无 · `history_revert[write] / history_revert_status[read]callTool tools/invoke_write\|read内部对接 /space/api/v2/sheet/recover、/api/v2/sheet/recover/status` |
| BE-3 | sheet-skill-spec 上游事实源skill 正文 + shortcut/flag 定义) | `tooling.sheet_skill_spec` · thrift:无 · `A` |
| BE-4 | sheet-facade-agg 在现有 ToolsCall 上新增 3 个历史/回滚工具 | `sheet.facade.agg` · thrift:无 · `history_list[read] / history_revert[write] / history_revert_status[read]` |
| BE-5 | sheet/data 透传 scene 并在 RecoverMsg 上新增 Scene 字段 | `bear.server.sheet_data` · thrift:无 · `RecoverHistory / QueryRecoverStatus复用MQ RecoverMsg 加 Scene 字段` |
> 完整字段见各子任务 `yaml` 块。
<!-- END generated overview -->
## 概述与范围
`lark-cli` 的 lark-sheets 能力补齐**电子表格历史版本查询与回滚**,新增 3 个 shortcut让 AI / 用户可以列出某张表的历史版本、回滚到指定版本、并查询回滚的异步状态。三个 shortcut 封装的是飞书电子表格已上线的 space 接口见下表的「前端接口参考」本需求不新建产品能力只是把它们包装成稳定、AI 友好的命令面。
| 功能 | shortcut | 前端接口参考(搜索 lark/idl | 实现差异点 |
|---|---|---|---|
| 查历史记录列表 | `+history-list` | `/space/api/v3/sheet/histories` | ① 仅返回 `minor_histories` 列表;② `minor_histories``id` 字段重命名为 `history_version_id`;③ 每条仅保留 `history_version_id` / `create_time`(序列化成 AI 优化的可读格式)/ `action` / `all_block_revision` 四个字段 |
| 历史记录回滚 | `+history-revert` | `/space/api/v2/sheet/recover` | 传入 `+history-list` 拿到的 `history_version_id`,回滚到指定版本 |
| 查询回滚状态 | `+history-revert-status` | `/api/v2/sheet/recover/status` | 查询 `+history-revert` 发起的异步回滚的当前状态 |
**范围内**
- `larksuite/cli` 仓新增 3 个 sheets shortcut`+history-list` 的响应裁剪 / 字段重命名 / 时间格式转换逻辑)。
- `ee/sheet-skill-spec` 上游事实源补 skill 正文与 shortcut/flag 定义,经其工作流 `sync:cli` 同步到 `larksuite/cli``skills/lark-sheets/``shortcuts/sheets/data/`
- `ee/sheet-facade-agg` **复用现有 `ToolsCall` 接口**,在其上新增 3 个工具(`history_list` / `history_revert` / `history_revert_status`);并在**回滚消息消费侧**(消费 `sheet/data` 产出的 RecoverMsg按 scene 给 `memberId` 赋值doubao=10lark-cli=11后构造 recover cs。
- `sheet/data``bear.server.sheet_data`)把 scene 从入口透传到 `RecoverHistory`,并在产出的 `RecoverMsg`MQ 消息)上**新增 `Scene` 字段**,供 agg 消费时区分场景。
**范围外**
- 历史版本的底层存储 / 快照 / 过期清理逻辑、`RecoverHistory` 的回滚业务语义(`bear.server.sheet_data` 已有,本需求仅透传 scene + 加 MQ 字段,不改回滚逻辑本身)。
- 历史版本 diff、可视化、权限模型变更等产品侧扩展。
- `doubao-office` 消费方的同步(`sheet-skill-spec` 另有 `sync:doubao`,本需求不涉及)。
## 服务拓扑与 PSM 变更判定表
**调用链路(目标形态)**
```
用户 / AI ──> lark-cli (+history-list / +history-revert / +history-revert-status)
│ callTool → POST /open-apis/sheet_ai/v2/.../tools/invoke_read|writescene 随入口确定)
sheet.facade.agg ToolsCall复用现有接口新增 3 个工具:
history_list[read] / history_revert[write] / history_revert_status[read]
├─ history_list ──> 拉历史列表(裁剪 minor_histories / 4 字段 / AI 时间格式)
├─ history_revert ───scene(ctx 透传)──> bear.server.sheet_data: RecoverHistory
│ │ service.RecoverHistory → SendRecoverMsg
│ ▼
│ MQ: RecoverMsg新增 Scene 字段)
│ ▼
│ sheet.facade.agg RecoverMsg 消费者 ── 读 Scene → memberId(doubao=10/lark-cli=11)
│ → 构造 recover cs → 调既有 recover 下游
└─ history_revert_status ──> bear.server.sheet_data: QueryRecoverStatus(transactionID)
```
`lark-cli` 现有 sheets shortcut 统一通过 `callTool``shortcuts/sheets/sheet_ai_api.go`)走 One-OpenAPI 的 `tools/invoke_read|write` 入口(`ToolKindRead` / `ToolKindWrite`),由 `sheet.facade.agg``OpenAPIToolCallRead/Write``biz/handler/lark_cli.go`,底层复用 `aiService.ToolsCall`)按 `tool_name` 分发。本需求的 3 个 shortcut 即按此路径新增 3 个工具,而非直连 space 接口或新增独立 OpenAPI 路由。
**回滚为异步两段式**`history_revert` 工具调 `sheet/data``RecoverHistory``biz/history/service/recover.go`),后者通过 `SendRecoverMsg``infra/mq/producer/recover.go``RecoverMsg`)投递回滚消息并返回 `transactionID`agg 侧的 RecoverMsg 消费者真正构造 recover cs并在此时按 scene 给 `memberId` 赋值。`history_revert_status` 工具走 `sheet/data``QueryRecoverStatus(transactionID)` 查询异步结果。scene 从 ToolsCall 入口经 **ctx 透传**(沿用既有 `utils.WithSceneDoubao(ctx)` 范式)到 `RecoverHistory`,再写入 `RecoverMsg.Scene` 字段,使 agg 消费时可区分 doubao / lark-cli。
**PSM 变更判定表**
| PSM | 需要代码变更? | 变更内容 | 不变更原因 |
|---|---|---|---|
| `tooling.lark_cli``larksuite/cli`,无服务 PSM | 是 | 新增 3 个 shortcut + `+history-list` 响应 transform | — |
| `tooling.sheet_skill_spec``ee/sheet-skill-spec`,无服务 PSM | 是 | 新增 lark-sheets skill 正文 + 3 个 shortcut/flag 定义,生成后同步到 cli | — |
| `sheet.facade.agg``ee/sheet-facade-agg` | 是 | ① 现有 `ToolsCall` 新增 3 个工具(`history_list[read]` / `history_revert[write]` / `history_revert_status[read]`);② `history_revert` 工具调 `sheet/data` 时透传 scenectx**RecoverMsg 消费者**读 `Scene` 字段,按 scene 给 `memberId` 赋值后构造 recover cs。**无新 thrift**(工具按 `tool_name`+JSON 注册scene 走 ctx baggage | — |
| `bear.server.sheet_data``sheet/data` | 是 | scene 从入口透传到 `RecoverHistory``biz/history/service/recover.go`),并在 `RecoverMsg``infra/mq/producer/recover.go`)上**新增 `Scene` 字段**随消息投递。`RecoverMsg` 是 JSON Go struct加字段**非 thrift**scene 透传走 ctx baggage | 回滚 / 快照业务语义不变(`RestoreHistorySnapshot` / `QueryRecoverStatus` 等复用) |
> **`memberId` 按 scene 赋值(实现硬约束,跨 sheet/data + agg**scene 区分 doubao 与 lark-cli沿用既有 `utils.WithSceneDoubao(ctx)` 范式)。本需求要求 scene 从 ToolsCall 入口一路透传:
> 1. agg `history_revert` 工具调用 `sheet/data.RecoverHistory` 时,把 scene 经 ctx 透传;
> 2. `sheet/data` 在产出的 `RecoverMsg` 上写入 `Scene` 字段,随 MQ 投递;
> 3. **agg 的 RecoverMsg 消费者**在真正构造 recover cs 时,读 `Scene` 给 `memberId` 赋值——doubao 场景 = `10`其他lark-cli场景 = `11`。
>
> memberId 赋值发生在 **agg 消费侧**(不是同步 ToolsCall 调用栈,因为回滚是异步消息驱动)。错误的 memberId 会导致回滚归属错误的调用方身份(审计 / 权限相关)。`RecoverMsg.MemberId` 字段已存在,但本需求要求按 scene 正确赋值并据此区分两个消费方。
## 后端 / Tooling 子任务
> 说明:`lark-cli` 与 `sheet-skill-spec` 均属 Tooling按 ccm-harness 约定建 BE-*`thrift_impact: 无`。本需求无前端FE子任务。
### BE-1: lark-cli `+history-list` 历史记录列表 shortcut
```yaml
psm: tooling.lark_cli
repo: larksuite/cli
module: shortcuts/sheets
be_deploy_required: false
thrift_impact:
api: facade-agg ToolsCall::history_list[read]callTool tools/invoke_read内部对接 /space/api/v3/sheet/histories
depends_on: [BE-4]
estimate: 1.5d
```
**调用的下游服务**:经 `callTool(ToolKindRead, "history_list", ...)``shortcuts/sheets/sheet_ai_api.go`)走 `tools/invoke_read` 入口,由 facade-agg 的 `history_list` 工具内部对接 `/space/api/v3/sheet/histories`。入参:表格 token沿用现有 sheets shortcut 的 `--spreadsheet-token` / `--token` 解析)。响应裁剪 / 字段重命名 / 时间格式可在 facade-agg 工具侧或 lark-cli 侧完成(见实现要点;以 AI 友好输出为准)。
**实现要点(实现差异点落地)**
- 仅取响应中的 `minor_histories` 列表,丢弃其余顶层字段(如 major histories
- 将每条 `minor_histories``id` 字段重命名输出为 `history_version_id`
- 每条仅保留 4 个字段:`history_version_id``create_time``action``all_block_revision`
- `create_time` 序列化成 AI 优化的可读格式(如本地时区可读时间串),而非裸 unix 时间戳。
**验收场景**
- Given 一张有多个历史版本的电子表格When 执行 `lark-cli sheets +history-list --token <t>`Then 返回 JSON 数组,每条恰好含 `history_version_id` / `create_time` / `action` / `all_block_revision` 四个键,且 `create_time` 为可读格式。
- Given 一张无历史记录的表格When 执行 `+history-list`Then 返回空列表且退码 0不报错
### BE-2: lark-cli `+history-revert` 与 `+history-revert-status` 回滚流 shortcut
```yaml
psm: tooling.lark_cli
repo: larksuite/cli
module: shortcuts/sheets
be_deploy_required: false
thrift_impact:
api: facade-agg ToolsCall::history_revert[write] / history_revert_status[read]callTool tools/invoke_write|read内部对接 /space/api/v2/sheet/recover、/api/v2/sheet/recover/status
depends_on: [BE-1, BE-4, BE-5]
estimate: 1.5d
```
**调用的下游服务**
- `+history-revert``callTool(ToolKindWrite, "history_revert", ...)`agg 工具调 `sheet/data.RecoverHistory`(异步),返回 `transactionID`
- `+history-revert-status``callTool(ToolKindRead, "history_revert_status", ...)`agg 工具调 `sheet/data.QueryRecoverStatus(transactionID)` 查异步结果。
- 注:`memberId` 按 scene 赋值doubao=10 / lark-cli=11发生在 agg 的 **RecoverMsg 消费者**侧(见 BE-4 / BE-5lark-cli 侧不感知scene 由 callTool 入口read/write确定。
**实现要点**
- `+history-revert``--history-version-id`(命名对齐 BE-1 的输出字段)为必填;缺失时在 Validate 阶段给出可执行错误提示。
- 回滚为异步操作,`+history-revert` 返回受理结果,`+history-revert-status` 供轮询最终状态(成功 / 进行中 / 失败)。
**验收场景**
- Given 由 `+history-list` 取得的合法 `history_version_id`When 执行 `+history-revert --token <t> --history-version-id <id>`Then 后端受理回滚并返回可被 `+history-revert-status` 查询的标识。
- Given 一次已发起的回滚When 轮询 `+history-revert-status`Then 能区分「进行中 / 成功 / 失败」三种状态。
- Given 缺省 `--history-version-id`When 执行 `+history-revert`Then 返回明确的参数缺失错误,不发起请求。
### BE-3: sheet-skill-spec 上游事实源skill 正文 + shortcut/flag 定义)
```yaml
psm: tooling.sheet_skill_spec
repo: ee/sheet-skill-spec
module: canonical-spec
be_deploy_required: false
thrift_impact:
api: N/A
depends_on: [BE-1, BE-2]
estimate: 1d
```
**调用的下游服务**:无(构建期工作流)。
**实现要点(按 `sheet-skill-spec` README 工作流)**
- 在飞书 base 表登记 3 个新 shortcut 的 tool ↔ shortcut 映射与 flag 定义,`npm run sync:tool-shortcut-map` 镜像入仓。
-`canonical-spec/references/<相关 skill>/cli-reference.md` 补三个 shortcut 的描述 / 示例 / Validate-DryRun-Execute 约束。
-`npm run generate:all && npm run check:all` 验证,产出 `generated/lark-cli/skills/lark-sheets/``generated/lark-cli/data/{flag-defs.json,flag-schemas.json}`
-`npm run sync:cli` 把 generated 同步到 `larksuite/cli``skills/lark-sheets/`mirror`shortcuts/sheets/data/`mirror在 cli 仓作为 PR 提交。
**边界**skill 命名 / 切分 / 正文 / flag 定义一律先落 `sheet-skill-spec`,禁止直接改 cli 仓的 `generated`/`skills/lark-sheets/` 产物README「对齐原则」
**验收场景**
- Given 在 `sheet-skill-spec` 完成上述编辑When 跑 `npm run check:all`Then 全部门禁通过generated 与 canonical 一致、map 与 base 表一致)。
- Given 跑 `npm run sync:cli`Then cli 仓 `skills/lark-sheets/``shortcuts/sheets/data/` 出现对应 3 个 shortcut 的 skill 正文与 flag 定义。
### BE-4: sheet-facade-agg 在现有 ToolsCall 上新增 3 个历史/回滚工具
```yaml
psm: sheet.facade.agg
repo: ee/sheet-facade-agg
module: biz/handler
be_deploy_required: true
thrift_impact:
api: ToolsCall::history_list[read] / history_revert[write] / history_revert_status[read]
depends_on: []
estimate: 1.5d
```
**调用的下游服务**`sheet/data``RecoverHistory` / `QueryRecoverStatus`(见 BE-5+ 既有历史查询agg 的 RecoverMsg 消费者复用既有 recover 下游(`biz/service/spreadsheet.go::ProcessRecoverCs``model.RecoverParam`),不新增 thrift。
**实现要点**
- **ToolsCall 扩展**:在现有 `ToolsCall` 框架(`biz/handler/handler.go::ToolsCall` / `biz/handler/lark_cli.go::OpenAPIToolCallRead|Write`)注册 3 个新工具:`history_list`read`history_revert`write`history_revert_status`read`constants.IsReadTool` / `IsWriteTool` 归类,从 `tools/invoke_read` / `invoke_write` 入口可达。
- **scene 透传**`history_revert` / `history_revert_status` 工具调 `sheet/data` 时,把 scene 经 ctx沿用 `utils.WithSceneDoubao` 范式)透传下去,使 `sheet/data` 能写入 `RecoverMsg.Scene`
- **RecoverMsg 消费者按 scene 赋 memberId硬约束**agg 消费 `sheet/data` 投递的 `RecoverMsg`、构造真正 recover cs 时,读 `RecoverMsg.Scene``memberId` 赋值——doubao = `10`lark-cli = `11`。这是异步消费侧逻辑,不在同步 ToolsCall 调用栈。
- `history_list` 工具对接历史列表查询;响应裁剪(仅 `minor_histories``id``history_version_id`、4 字段、`create_time` AI 友好格式)建议落在此工具侧(两个消费方共享,避免 lark-cli / doubao 双实现漂移)。
**边界**:只在 ToolsCall 上加工具 + 改 RecoverMsg 消费者;不新增独立 OpenAPI 路由、不改 `ai.ToolsCallRequest` thrift 契约、不改 `RecoverHistory` 回滚业务语义。
**验收场景**
- Given lark-cli 经 `tools/invoke_read` 调用 `history_list`Then 返回裁剪后的 `minor_histories`4 字段,`history_version_id` 命名)。
- Given lark-cliscene=lark-cli`history_revert` 发起回滚When agg 消费对应 RecoverMsgThen 构造的 recover cs 中 `memberId == 11`doubao 场景下同一路径 `memberId == 10`
- Given 已发起回滚When 调用 `history_revert_status`Then 经 `QueryRecoverStatus` 返回可区分的回滚状态。
### BE-5: sheet/data 透传 scene 并在 RecoverMsg 上新增 Scene 字段
```yaml
psm: bear.server.sheet_data
repo: sheet/data
module: biz/history
be_deploy_required: true
thrift_impact:
api: bear.server.sheet_data::RecoverHistory / QueryRecoverStatus复用MQ RecoverMsg 加 Scene 字段
depends_on: []
estimate: 1d
```
**调用的下游服务**:复用既有回滚链路(`biz/history/service/recover.go::RecoverHistory``infra/mq/producer/recover.go::SendRecoverMsg`)。
**实现要点**
- **scene 透传**:把 agg 经 ctx 传入的 scene 接住,贯穿 `RecoverHistory``biz/history/service/recover.go`)到 `RecoverMsg` 构造处。
- **RecoverMsg 加 `Scene` 字段**:在 `infra/mq/producer/recover.go``RecoverMsg` struct 上新增 `Scene` 字段并在投递时赋值。`RecoverMsg` 是 JSON Go struct`recoverProducer.NewMessage`**加字段非 thrift**——`thrift_impact: 无`
- `QueryRecoverStatus` 与回滚业务语义保持不变,仅承载 scene 透传。
**边界**:不改回滚 / 历史快照业务逻辑;只加 scene 透传与 `RecoverMsg.Scene` 字段。
**scene 透传方式(已定)**:经 **ctx baggage**`metainfo` / 沿用既有 `utils.WithSceneDoubao(ctx)` 范式)从 agg 透传到 `RecoverHistory`**不**在 `RecoverHistoryReq` thrift 上加字段 → 零 IDL 变更,`thrift_impact: 无`
**验收场景**
- Given agg 以 scene=lark-cli 调 `RecoverHistory`Then 投递的 `RecoverMsg.Scene` 标识 lark-clidoubao 同理。
- Given 回滚已发起When `QueryRecoverStatus(transactionID)`Then 返回回滚状态(语义与现状一致)。
- Given lark-cliscene=lark-cli`tools/invoke_write` 调用 `history_revert`Then 构造的 recover cs 中 `memberId == 11`doubao 场景下同一工具 `memberId == 10`
- Given 已发起回滚When 调用 `history_revert_status`Then 返回可区分的回滚状态。
## API 契约引用
本需求三个接口均为飞书电子表格已上线 space 接口,契约以各仓库最新 master 为准;对应 thrift 定义按 PRD 提示在 `lark/idl` 中搜索确认(实现阶段补全精确路径):
- 查列表:`/space/api/v3/sheet/histories`(取 `minor_histories`
- 回滚:`/space/api/v2/sheet/recover`
- 回滚状态:`/api/v2/sheet/recover/status`
> 契约本体不进本 spec 正文;精确 `lark/idl/...thrift::Service::Method` 路径在实现阶段确认并回填到对应 BE-* 的 `api` 字段说明。
## 验收场景(汇总)
- 列表:`+history-list` 仅返回 `minor_histories`,每条恰好 4 个字段,`id` 重命名为 `history_version_id``create_time` 为 AI 优化可读格式。
- 回滚:`+history-revert` → agg `history_revert``sheet/data.RecoverHistory`(异步),受理后返回可查询标识。
- memberId/sceneagg 消费 `RecoverMsg` 构造 recover cs 时,按 `RecoverMsg.Scene``memberId`——lark-cli=11、doubao=10facade-agg 侧单测断言)。
- 状态:`+history-revert-status``QueryRecoverStatus` 能查询并区分回滚的进行中 / 成功 / 失败。
- skill 同步:`sheet-skill-spec` 生成产物经 `sync:cli` 落地到 cli 仓,`check:all` 全绿。
- 三个 shortcut 在 cli 中遵循统一的 Validate / DryRun / Execute 三段约定与现有 sheets shortcut 一致。
## 非功能要求与约束
- **复用既有模式**3 个 shortcut 必须沿用 `shortcuts/sheets` 现有的 token 解析(`--spreadsheet-token` / `--token` 别名)、错误封装(`errs`)、`callTool``tools/invoke_read|write`)调用与 DryRun 渲染范式不另起调用框架。facade-agg 侧必须复用现有 `ToolsCall` 接口扩展工具,不新增独立 OpenAPI 路由。
- **AI 友好输出**`+history-list` 的字段裁剪与 `create_time` 可读格式是硬约束PRD「实现差异点」目的是降低 AI 消费成本。
- **工作流约束**skill 内容与 flag 定义的唯一事实源是 `ee/sheet-skill-spec`cli 仓的 `skills/lark-sheets/``shortcuts/sheets/data/` 为同步产物,不手改。
- **回滚为异步**`+history-revert``+history-revert-status` 分离,调用方需理解「发起 → 轮询」两步语义。
- **事实基准**所有外部仓库事实space 接口、facade-agg 路由、sheet_data 能力)以各仓库最新 master 为准。
## 安全设计
- security_knowledge_ref: UNCONFIGURED
- 风险判断依据: 未配置安全知识库,待安全侧补齐。需关注点(供安全侧复核):`+history-revert` 是**写 / 不可逆**操作(覆盖当前表格内容到历史版本),必须校验操作者对目标表格具备编辑 / 回滚权限;历史版本列表可能暴露协作者操作痕迹(`action` 字段),需确认读权限边界。
- 身份归属风险memberId/scene: `memberId` 须按 scene 正确赋值lark-cli=11 / doubao=10。错配会使回滚操作归属错误的调用方身份影响审计与权限判定——属安全/审计相关,须在 agg RecoverMsg 消费侧保证赋值正确。
- 需要安全侧补充: 回滚操作的权限校验口径、历史 `action` 字段的可见性范围是否需脱敏、memberId 与真实操作者身份的映射是否需对齐审计要求。
## Codegen Delivery Plan
applicable: true
### A. Branch Plan
| `key` | value |
|---|---|
| `psm` | `sheet.facade.agg` |
| `business_branch` | `feat/sheet-history-revert` |
| `generated_branch` | `N/A` |
| `idl_branch` | `N/A` |
| `kitex_branch` | `N/A` |
### B. Delivery Targets
| repo | required | branch | artifact_paths | reason |
|---|---|---|---|---|
| larksuite/cli | yes | feat/sheet-history-revert | shortcuts/sheets/ , skills/lark-sheets/ | 3 个 shortcut 实现 + 同步落地的 skill 正文与 flag 数据 |
| ee/sheet-skill-spec | yes | feat/sheet-history-revert | canonical-spec/references/ , generated/lark-cli/ | skill / flag 上游事实源,生成后 sync 到 cli |
| ee/sheet-facade-agg | yes | feat/sheet-history-revert | biz/handler/ | 现有 ToolsCall 新增 3 个工具 + scene 透传 + RecoverMsg 消费者按 scene 赋 memberId |
| sheet/data | yes | feat/sheet-history-revert | biz/history/ , infra/mq/producer/ | RecoverHistory 透传 scene + RecoverMsg 新增 Scene 字段JSON非 thrift |
### C. Generation Decision
| `key` | value |
|---|---|
| `needs_kitex_gen` | no |
| `needs_apacana` | no |
| `needs_kite_via_sdp` | no |
| `decision_basis` | facade-agg 复用现有 ToolsCall 框架按 tool_name 注册 3 个工具JSON input不动 ai.ToolsCallRequest thriftsheet/data 仅在 RecoverMsgJSON Go struct加 Scene 字段 + 经 ctx baggage 透传 scene复用既有 RecoverHistory/QueryRecoverStatus均非 thriftlark-cli 经现有 callTool 包装。scene 已定走 ctx baggage不加 RecoverHistoryReq thrift 字段),无新增/修改 kitex/apacana/SDP 契约 |
### D. Branch Naming Rule
业务分支统一用 `feat/sheet-history-revert`;本需求无 codegen`generated_branch` / `idl_branch` / `kitex_branch` 均为 `N/A`
## thrift 变更需求清单
按推荐实现路径。三个接口的能力已存在本需求的新增内容是facade-agg 在 ToolsCall 上注册 3 个工具(`tool_name`+JSON不动 `ai.ToolsCallRequest`、sheet/data 在 `RecoverMsg`JSON Go struct上加 `Scene` 字段、scene 经 **ctx baggage** 透传——均不涉及 thrift。
**scene 透传方式:已定为 ctx baggage**`metainfo` / 沿用既有 `utils.WithSceneDoubao(ctx)` 范式),明确**不**在 `bear.server.sheet_data``RecoverHistoryReq` thrift 上加字段。故本需求确无任何 thrift struct / RPC method / enum 的新增或修改Generation Decision 三路保持 `no`、无 codegen。
## N. AI Capability Manifest
applicable: false
本需求为确定性 CLI 命令封装,不含 LLM / prompt 驱动的 AI 能力。`+history-list` 中「`create_time` 序列化成 AI 优化格式」仅指对机器/AI 更易读的时间字符串格式化,属确定性数据转换,非 AI capability。

View File

@@ -6,25 +6,16 @@ envelope on stderr; **protocol adapters** mapping CLI errors into MCP /
OAuth shapes; and **framework + business code** producing errors. This file
is the single source of truth for all three.
This document describes the **typed authoring target**. The refactor lands
in stages; some boundaries (e.g. `client.WrapDoAPIError`) still operate on
legacy shapes today — see **Migration** for what is live in each stage.
Migrating an `*output.ExitError` call site? See **Migration**. Something off
in production? See **Troubleshooting**.
Something off in production? See **Troubleshooting**.
## Invariants
1. Every error belongs to exactly one **Category**. The set is closed
(`errs/category.go`); adding a member requires deliberate review.
2. Every **newly constructed** typed error has a **Subtype** — a stable
2. Every typed error has a **Subtype** — a stable
lowercase-with-underscores identifier declared in `errs/subtypes*.go`.
Undeclared subtypes fail CI. The constraint applies only to typed
`*errs.*` literals; stage-1 legacy `*core.ConfigError` flows via the
dispatcher's `asExitError` → legacy envelope path (not the typed
taxonomy) and is unaffected. `errcompat.PromoteConfigError` is a
stage-1 passthrough; its stage-2+ typed migration will subject the
promoted typed error to this Subtype constraint at that time.
Undeclared subtypes fail CI. Every error path constructs a typed
`*errs.*` error at its origin, so the constraint applies uniformly.
3. **`Category` + `Subtype`** are wire-stable identifiers consumers may
branch on. Renaming either is a breaking change.
4. `Code` is the upstream numeric code when known (e.g. Lark API code).
@@ -35,11 +26,10 @@ in production? See **Troubleshooting**.
unchanged across the `errors.As` / `errors.Unwrap` chain.
7. For the typed-envelope path, exit codes derive from `Category` only
via `output.ExitCodeForCategory` — including `SecurityPolicyError`,
which exits `6` via `CategoryPolicy`. Unmigrated `*output.ExitError`
producers still carry a hand-set `Code` until they finish migrating.
`output.ErrBare(code)` is the lone exception: a deliberate
predicate-command signal that bypasses the envelope (see
**Predicate commands** below).
which exits `6` via `CategoryPolicy`. `output.ErrBare(code)` is the
exception: it constructs an `*output.BareError`, a deliberate
silent-exit signal (stdout already carries the answer) that bypasses
the envelope (see **Predicate commands** below).
## Wire format
@@ -73,13 +63,14 @@ Typed errors render to **stderr** as one JSON object per process exit:
| `error.hint` | informational | actionable recovery guidance |
| `error.log_id` | informational | upstream request id (server-side trace) |
| `error.retryable` | wire-stable | `true` when present; omitted when `false` |
| `error.param` | per-Subtype-stable | single offending parameter (`ValidationError`); see **Validation parameters** |
| `error.params` | per-Subtype-stable | per-parameter validation detail array (`ValidationError`); see **Validation parameters** |
| per-Subtype extension fields | per-Subtype-stable | e.g. `missing_scopes`, `console_url`, `challenge_url` |
`SecurityPolicyError` renders through the same typed envelope as every
other category. `error.type` is `"policy"`, `error.subtype` is one of
`challenge_required` / `access_denied`, and process exit is `6` via
`CategoryPolicy`. The legacy `auth_error` envelope at exit `1` has been
retired.
`CategoryPolicy`.
## Categories
@@ -119,20 +110,21 @@ Canonical mapping: `internal/output/exitcode.go` `ExitCodeForCategory`.
cmd/root.go handleRootError dispatches:
├─ output.ErrBare(code) → no envelope (stdout already written); exit = code
├─ typed (errs.ProblemOf) → typed JSON envelope; exit = ExitCodeOf(err)
│ (includes *errs.SecurityPolicyError → policy envelope, exit 6)
├─ *core.ConfigError → promoted to typed via errcompat ↑
├─ *output.ExitError → legacy JSON envelope; exit = exitErr.Code
untyped / Cobra error → plain "Error: <msg>" (no envelope); exit 1
│ (includes *errs.SecurityPolicyError → policy envelope, exit 6;
│ *errs.ConfigError, constructed typed at origin)
├─ *output.PartialFailureError → no stderr envelope (ok:false result already on stdout); exit = code
*output.BareError → no envelope (stdout already written); exit = code
└─ Cobra usage error → typed validation envelope (invalid_argument); exit 2
```
Only the typed and `*output.ExitError` branches emit a JSON envelope on
stderr. Untyped errors (including Cobra's "required flag missing" / unknown
subcommand messages) print plain text and exit `1` — consumers must
tolerate that fallback.
The dispatcher emits a JSON envelope on stderr for both the typed branch and
residual Cobra usage errors (missing required flag, unknown command,
argument validation): the latter are classified into a typed validation
envelope (`invalid_argument`) and exit `2`, matching the explicit flag and
subcommand guards.
### Predicate commands (`output.ErrBare`)
### Predicate commands (`output.BareError`)
A small class of commands is **predicates**: they answer a yes/no
question and signal the answer through the shell exit code so callers
@@ -142,19 +134,27 @@ example — its `README` contract is `exit 0 = ok, 1 = missing`.
These commands deliberately:
1. write a structured JSON answer to **stdout** themselves, and
2. return `output.ErrBare(exitCode)` to communicate the exit code to
the dispatcher without producing a `stderr` envelope.
2. return `output.ErrBare(exitCode)` — an `*output.BareError` to
communicate the exit code to the dispatcher without producing a
`stderr` envelope.
`output.ErrBare` is **not** an error in the typed-envelope sense — it
carries no category, subtype, or message. It is a one-bit output-
control signal that lives outside the contract for the same reason
`grep -q` / `diff` / `systemctl is-active` set non-zero exit codes
without printing anything to stderr: pollution of stderr by a
`*output.BareError` is **not** an error in the typed-envelope sense — it
carries no category, subtype, or message, only an exit code. It is a
one-bit output-control signal that lives outside the contract for the
same reason `grep -q` / `diff` / `systemctl is-active` set non-zero exit
codes without printing anything to stderr: pollution of stderr by a
predicate's negative answer would break `2>/dev/null` log hygiene in
caller scripts.
New code should not reach for `ErrBare` unless the command is
genuinely a predicate. Anything carrying recoverable error content
A second class also uses `ErrBare`: a command that emits its own complete
structured result envelope on **stdout** under `--json` (e.g. `update`, whose
`{ok:false, error:{type, message}}` is its established output shape) and needs
only the exit code conveyed, with no `stderr` envelope. Like a predicate, its
answer is already on stdout; `ErrBare` carries the exit code alone.
New code should not reach for `ErrBare` unless the command's full answer is
already on stdout — a predicate's yes/no, or a self-contained result envelope
as above. Anything whose error content must reach the caller on `stderr`
belongs in a typed `*errs.XxxError` — or, for a batch result, in the
partial-failure outcome below.
@@ -214,7 +214,7 @@ exitCode := output.ExitCodeOf(err) // ExitInternal for non-typed errors
out=$(lark-cli ... 2>&1)
code=$?
# Untyped / Cobra errors print plain text — guard before jq.
# Defensive guard: tolerate any non-JSON output before parsing with jq.
if ! jq -e . >/dev/null 2>&1 <<<"$out"; then
printf '%s\n' "$out" >&2
exit "$code"
@@ -303,9 +303,10 @@ Do not pick exit codes by hand in new typed producers — `ExitCodeForCategory`
maps `Category` to the shell code. A new exit-code requirement means a
new `Category`, not a one-off override at the call site.
(Legacy `*output.ExitError` retains hand-set codes until removal;
`SecurityPolicyError` retains a hand-set code on main until the framework
migration PR retires the carve-out — see **Migration**.)
(The only exits not derived from `Category` are the
`*output.BareError` and the `*output.PartialFailureError` signals, which
carry their own code by design and sit outside the typed-envelope contract —
see **Predicate commands**.)
#### Split `Message`, `Hint`, and `Cause`
@@ -340,15 +341,54 @@ Message: fmt.Sprintf("request failed: %v — retry later", ioErr)
// conflates what + what-to-do + cause into one string
```
#### `ValidationError.Param` uses the `--flag` form
#### Validation parameters: `Param` and `Params`
When a `*ValidationError` originates from a flag value, `Param` holds the
flag name with leading dashes (`"--priority"`, not `"priority"`). AI
agents grep this field literally to surface "the bad flag was `--X`".
`ValidationError` carries two additive parameter fields. Both are
optional; a producer sets whichever fits the failure.
For positional arguments, use the canonical name without dashes
**`Param string` (wire `param`)** — the single offending parameter. When a
`*ValidationError` originates from a flag value, `Param` holds the flag
name with leading dashes (`"--priority"`, not `"priority"`). AI agents
grep this field literally to surface "the bad flag was `--X`". For
positional arguments, use the canonical name without dashes
(`"target_user_id"`).
**`Params []InvalidParam` (wire `params`)** — per-parameter validation
detail, for failures that need to report *which* parameters failed and
*why*, one entry each. Each `errs.InvalidParam` is
`{Name, Reason string, Suggestions []string}`: `Name` identifies the
parameter, `Reason` states why it failed, and the optional `Suggestions`
(wire `suggestions`, omitted when empty) carries ranked candidate
corrections an agent can retry with — the did-you-mean candidates for an
unknown flag or subcommand — without parsing the human-facing `hint`. This
is the CLI's rendering of the RFC 7807 `invalid-params` extension member
(RFC 7807 §3.1). The wire key is `params`, not `invalid_params`: the
enclosing envelope already carries `type:"validation"`, so the `invalid_`
qualifier would be redundant on the wire.
`Param` and `Params` are independent additive fields, not alternates of a
single representation. Use `Param` for the common single-parameter error;
use `Params` when one failure spans several parameters or needs a
per-parameter reason. Set with `.WithParam("--flag")` / `.WithParams(...)`.
A `params` wire example (multiple parameters each carrying a reason):
```json
{
"ok": false,
"identity": "user",
"error": {
"type": "validation",
"subtype": "invalid_argument",
"message": "2 parameters failed validation",
"params": [
{ "name": "--start", "reason": "expected RFC3339, got \"yesterday\"" },
{ "name": "--end", "reason": "must be after --start" }
]
}
}
```
### Constructing typed errors
Prefer the **builder API**. The constructor pins `Category` + `Subtype` +
@@ -378,44 +418,11 @@ them on the dynamic dispatch path where a `Problem` value is composed
once and wrapped per Category branch. Outside that pattern, new code
should reach for the builder.
Legacy helpers (`output.ErrValidation`, `output.ErrAuth`, `output.ErrNetwork`)
remain callable during migration but are `// Deprecated:` — new code goes
through the builder.
#### Shortcut `Execute` walkthrough
Adapted from `shortcuts/calendar/calendar_suggestion.go:222`, whose legacy
form is `output.ErrValidation("--duration-minutes must be between 1 and
1440")`. The typed migration target (builder form):
```go
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
duration := runtime.Int("duration-minutes")
if duration < 1 || duration > 1440 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--duration-minutes must be between 1 and 1440, got %d", duration).
WithHint("pass a value in [1, 1440]").
WithParam("--duration-minutes")
}
_, err := runtime.DoAPI(req, opts)
if err != nil {
return err // already typed by the framework boundary; propagate
}
return nil
}
```
Two patterns visible: a producer site (the typed `*errs.ValidationError`
above) and a propagation site (the `return err` after `runtime.DoAPI`,
applying [Propagate typed errors unchanged](#propagate-typed-errors-unchanged)).
When the validation logic outgrows a single range check — multiple
flags, format parsing, conditional rules — extract it into a helper that
also returns the typed `*errs.ValidationError`. The helper, not
`Execute`, sets `Param` (a helper bound to one shortcut is normal in
this codebase; see `parseTimeRange` in
`shortcuts/calendar/calendar_agenda.go:144`).
When the validation logic outgrows a single range check — multiple flags,
format parsing, conditional rules — extract it into a helper that also returns
the typed `*errs.ValidationError`; the helper, not `Execute`, sets `Param` (a
helper bound to one shortcut is normal in this codebase; see `parseTimeRange`
in `shortcuts/calendar/calendar_agenda.go`).
### Wrapping upstream errors
@@ -479,7 +486,7 @@ Rare; the existing structs cover the 9 Categories with room. If you must:
1. In `errs/types.go`, add a new section with: the struct embedding `errs.Problem`, a nil-receiver-safe `Unwrap()` if it carries `Cause`, a `NewXxxError(subtype, format, args...)` constructor, and one chained `WithX` setter per extension field.
2. Add an `IsXxx` predicate in `errs/predicates.go`.
3. Add a wire-format pin in `errs/marshal_test.go` and a builder-chain pin in `errs/types_builder_test.go`.
3. Add a wire-format pin in `errs/marshal_test.go` and a builder-chain pin in `errs/types_test.go`.
`CheckProblemEmbed` enforces the `Problem` embed at lint time. New
top-level wire fields are forbidden — per-Subtype data goes into the
@@ -488,19 +495,33 @@ top level.
## CI guards
| Check | Enforces | Where |
|-------|----------|-------|
| forbidigo | business path (`shortcuts/**`, `cmd/service/**`) must not call legacy `output.*` error constructors — route through the typed classifier | `.golangci.yml` |
| `CheckProblemEmbed` | every exported `*Error` embeds `errs.Problem` | `lint/errscontract/` AST |
| `CheckNoRegistrar` | no `mergeCodeMeta` / `RegisterServiceMap` from service code | `lint/errscontract/` AST |
| `CheckAdHocSubtype` | `ad_hoc_*` Subtypes labeled for promotion (warn) | `lint/errscontract/` AST |
| `CheckDeclaredSubtype` | every `Subtype:` value is a declared constant or `ad_hoc_*` | `lint/errscontract/` AST |
| `CheckTypedErrorCompleteness` | every `*errs.<X>Error{Problem: errs.Problem{...}}` literal must set `Category`, `Subtype`, and `Message` | `lint/errscontract/` AST |
Two golangci-lint rules and the custom `errscontract` AST module enforce the
contract; CI runs all three on every PR.
CI runs `lint/` on every PR. Locally: `go run -C lint . ..`. The
lintcheck CLI lives in its own Go module so its `golang.org/x/tools`
dependency stays out of the shipped `lark-cli` binary's module graph;
see `lint/README.md` for how to add a new lint domain.
**golangci-lint** — scopes are defined in `.golangci.yml` (not duplicated here,
so this spec cannot drift from the lint config):
| Rule | Enforces |
|------|----------|
| forbidigo `errs-no-bare-wrap` | a command / wire-boundary final error must be typed (`errs.NewXxxError`), never a bare `fmt.Errorf` / `errors.New`; a genuine intermediate wrap opts out with `//nolint:forbidigo` + a reason |
| errorlint | every error wrap uses `%w` and every comparison uses `errors.Is` / `errors.As` — interior wraps stay legal but cannot break the `errors.Unwrap` chain the typed boundary relies on |
**errscontract** (`lint/errscontract/`, a separate Go module so its
`golang.org/x/tools` dependency stays out of the shipped binary; run locally
with `go run -C lint . ..`):
| Check | Enforces |
|-------|----------|
| `CheckNoLegacyEnvelopeLiteral` / `CheckNoLegacyCommonHelperCall` / `CheckNoLegacyRuntimeAPICall` | the removed `output.*` legacy error surface cannot be reintroduced anywhere |
| `CheckProblemEmbed` | every exported `*Error` embeds `errs.Problem` |
| `CheckDeclaredSubtype` | every `Subtype:` value is a declared constant (or `ad_hoc_*`) |
| `CheckTypedErrorCompleteness` | every typed-error struct literal sets `Category`, `Subtype`, and `Message` |
| `CheckAdHocSubtype` | `ad_hoc_*` Subtypes flagged for promotion (warning) |
| `CheckNoRegistrar` | no `mergeCodeMeta` / `RegisterServiceMap` from service code |
`errscontract` also carries framework-internal invariants (nil-safe `Unwrap`,
builder immutability, unwrap symmetry); see `lint/errscontract/` for the full
set and `lint/README.md` for adding a new lint domain.
## Stability
@@ -510,67 +531,13 @@ see `lint/README.md` for how to add a new lint domain.
| Additive | new Category, new declared Subtype, new extension field on an existing struct | minor release; consumers ignore unknown fields by contract |
| Experimental | `ad_hoc_*` Subtypes; fields documented as such in `errs/types.go` | may change or be promoted/removed within one release |
The deprecated `*output.ExitError` surface is outside these tiers — it
will be removed once business migration completes.
## Migration
**Strategy shift (2026-05-26).** The original plan (`docs/design/errors-refactor/spec.md` v2.12 §9) was a centrally-driven 4-PR rollout — framework → auth domain → multi-pilot → full-repo + legacy removal. That plan is **superseded** by a hybrid model: framework owner ships framework-level hardening (including a typed `*errs.*Error` migration of `internal/**`) as one focused PR; business-domain typed migration is **self-service** via [`docs/errors-guide.md`](../docs/errors-guide.md) and the builder API, with no central sweep timeline.
Why the shift: 800+ legacy call sites split across 8+ business domains do not all share a single reviewer's bandwidth, and the contract is now expressive enough that each domain owner can migrate their own code from the guide without coordinating with framework owner.
### Current state
1. **Framework slice — ✅ shipped (PR #984).** The `errs/` typed taxonomy, classifier (`internal/errclass`), promotion stub (`internal/errcompat`, passthrough), dispatcher hook (`WriteTypedErrorEnvelope`), and the `lint/errscontract` AST guards. Wire shapes preserved byte-for-byte versus pre-PR, with **one intentional semantic fix**: config-class errors (`*core.ConfigError`) now exit `3` instead of `2`, aligning with `ExitCodeForCategory` (config errors share the auth exit slot per the taxonomy). The classifier and promote helpers are *shipped but unused* in production paths — they exist so framework migration can plug in without re-architecting.
2. **Builder API — ✅ shipped (this branch).** `errs/types.go` adds the canonical producer surface (`errs.NewXxxError(subtype, format, args...).WithX(...)`) for all 10 typed types, alongside each struct declaration. Constructor signature pins `Category` (via function name) and `Subtype` + `Message` (positional), so the producer cannot mis-specify any of the three identity fields. Optional fields chain through `.WithX(...)` setters that preserve the concrete pointer type.
### Next: framework migration PR (planned)
A single PR consolidates the work the original §9 spec split across PRs 24 — restricted to framework code, no business sweep:
- **Migrate `internal/**` typed construction to the builder API.** ~16 call sites in `internal/errclass/classify.go` (BuildAPIError fanout), `internal/auth/transport.go` (SecurityPolicy), `internal/auth/uat_client.go`, `internal/errcompat/promote*.go`, `internal/client/client.go`, `internal/client/api_errors.go`.
- **Land the framework-side semantic changes** previously scoped to spec §9 PR 2: `SecurityPolicyError` exit `1→6`, `WrapDoAPIError` typed (`*NetworkError` with subtype timeout/tls/dns/server_error/transport, `*InternalError` for JSON-decode), `WrapJSONResponseParseError` typed, `errcompat.PromoteConfigError` real Type routing, `PromoteAuthError` helper + dispatcher wiring, 10 credential Lark codes registered in codeMeta, 99991543 config classification, `resolveAccessToken` typed `*AuthenticationError`, `BuildAPIError` filling `*PermissionError.MissingScopes` / `Identity` / `ConsoleURL`, deletion of `scopeAwareChecker`.
- **Add `forbidigo` rule** banning `output.Err*` constructors in `shortcuts/**` and `cmd/**` (mirrors the contract that new business code must use the builder).
- **CHANGELOG** lists the resulting ~10 shell-exit-code shifts in one release entry (vs the spec §1 spread of 11 — the remaining one site lives in `task` business code).
### Business-domain migration (self-service, no central timeline)
Each business package migrates its own `output.Err*` call sites to the builder when convenient — typically batched within one domain. The guide at [`docs/errors-guide.md`](../docs/errors-guide.md) walks owners through the 8 typical error modes (validation / authorization / authentication / config / network / api / internal / policy) with real `file:line` examples from main. The three-layer extension model (add Subtype / add field / add Category) handles cases the existing taxonomy does not cover.
Helper assertions accept both shapes during migration (see `shortcuts/mail/mail_shortcut_validation_test.go` `assertValidationError`) so domain migrations stay green incrementally.
### Legacy removal
Deferred until business migration completion approaches the asymptote. `Errorf`, `ErrAPI`, `ErrAuth`, `ErrWithHint`, `ErrBare`, `ClassifyLarkError`, `ErrDetail`, `ExitError`, and `ErrorEnvelope` are `// Deprecated:` today and stay callable. No fixed removal date.
### Before / after at a call site
```go
// before (legacy)
return output.ErrAPI(larkCode, "create event failed", resp.RawBody())
// after (typed) — cc carries Brand / AppID / Identity from the caller's context
return errclass.BuildAPIError(parsedResp, cc)
```
```go
// before (legacy validation)
return output.ErrValidation("--duration-minutes must be between 1 and 1440")
// after (builder)
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--duration-minutes must be between 1 and 1440, got %d", duration).
WithParam("--duration-minutes")
```
## Troubleshooting
**Envelope shows `type=api subtype=unknown` for what should be a more
specific category.** The Lark code is unknown to `LookupCodeMeta` and fell
through to the generic bucket (`internal/errclass/classify.go`). Add the
code to `internal/errclass/codemeta_<service>.go` with the right Category
and Subtype, plus a dispatch test in `classify_test.go`.
and Subtype, plus a dispatch test in `internal/errclass/classify_test.go`.
**Envelope shows `type=internal subtype=sdk_error`.** Origin is
`client.WrapDoAPIError` taking the non-transport branch
@@ -613,8 +580,6 @@ string cannot be classified retroactively.
- *Add a new condition?* → **Add a Subtype**
- *Consume from a shell script?* → **Consumers / Shell / AI**
- *Understand or fix a CI failure?* → **CI guards**
- *Migrate a legacy `ExitError` call site?* → **Migration** + the
Deprecated note on the symbol being replaced.
- *Read source.* → `errs/doc.go``errs/category.go``errs/types.go`
`errs/predicates.go``internal/errclass/`
`cmd/root.go` `handleRootError`.

29
errs/raw.go Normal file
View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import "errors"
// rawPassthrough marks an error as raw passthrough: the dispatcher must not
// rewrite its message or hint with local enrichment. Raw is
// dispatcher-internal routing state, not a wire field. It is deliberately not
// a typed taxonomy error (no embedded Problem) — it only wraps one.
type rawPassthrough struct{ err error }
func (e *rawPassthrough) Error() string { return e.err.Error() }
func (e *rawPassthrough) Unwrap() error { return e.err }
// MarkRaw wraps err as raw passthrough. MarkRaw(nil) returns nil.
func MarkRaw(err error) error {
if err == nil {
return nil
}
return &rawPassthrough{err: err}
}
// IsRaw reports whether err or any error in its chain is marked raw.
func IsRaw(err error) bool {
var raw *rawPassthrough
return errors.As(err, &raw)
}

96
errs/raw_test.go Normal file
View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs_test
import (
"encoding/json"
"errors"
"fmt"
"testing"
"github.com/larksuite/cli/errs"
)
func TestMarkRawNilReturnsNil(t *testing.T) {
if got := errs.MarkRaw(nil); got != nil {
t.Fatalf("MarkRaw(nil) = %v, want nil", got)
}
}
func TestIsRaw(t *testing.T) {
base := fmt.Errorf("boom")
if !errs.IsRaw(errs.MarkRaw(base)) {
t.Errorf("IsRaw(MarkRaw(err)) = false, want true")
}
if errs.IsRaw(base) {
t.Errorf("IsRaw(bare err) = true, want false")
}
if errs.IsRaw(nil) {
t.Errorf("IsRaw(nil) = true, want false")
}
// Raw marking survives further wrapping above it in the chain.
wrapped := fmt.Errorf("outer: %w", errs.MarkRaw(base))
if !errs.IsRaw(wrapped) {
t.Errorf("IsRaw(wrap(MarkRaw(err))) = false, want true")
}
}
func TestMarkRawPreservesErrorMessage(t *testing.T) {
base := fmt.Errorf("boom")
if got := errs.MarkRaw(base).Error(); got != "boom" {
t.Fatalf("MarkRaw(err).Error() = %q, want %q", got, "boom")
}
}
func TestMarkRawPreservesErrorsIsChain(t *testing.T) {
sentinel := errors.New("sentinel")
wrapped := fmt.Errorf("ctx: %w", sentinel)
if !errors.Is(errs.MarkRaw(wrapped), sentinel) {
t.Fatalf("errors.Is(MarkRaw(err), sentinel) = false, want true")
}
}
func TestProblemOfPunchesThroughMarkRaw(t *testing.T) {
typed := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad flag")
raw := errs.MarkRaw(typed)
p, ok := errs.ProblemOf(raw)
if !ok {
t.Fatalf("ProblemOf(MarkRaw(typed)) ok = false, want true")
}
if p.Category != errs.CategoryValidation {
t.Errorf("ProblemOf(MarkRaw(typed)).Category = %v, want %v", p.Category, errs.CategoryValidation)
}
// errors.As still finds the concrete typed error through the raw wrapper.
var ve *errs.ValidationError
if !errors.As(raw, &ve) {
t.Errorf("errors.As(MarkRaw(typed), *ValidationError) = false, want true")
}
}
// TestMarkRawUnwrapsToInnerTypedError pins the envelope-serialization
// contract: UnwrapTypedError must return the inner concrete typed error,
// not the rawPassthrough wrapper. The wrapper has no exported fields, so if it
// were returned the JSON envelope would marshal to an empty "{}" error.
func TestMarkRawUnwrapsToInnerTypedError(t *testing.T) {
base := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad flag")
typed, ok := errs.UnwrapTypedError(errs.MarkRaw(base))
if !ok {
t.Fatal("UnwrapTypedError(MarkRaw(typed)) must find a typed error")
}
out, err := json.Marshal(typed)
if err != nil {
t.Fatal(err)
}
if string(out) == "{}" {
t.Fatalf("UnwrapTypedError returned the opaque rawPassthrough wrapper; envelope would be empty: %s", out)
}
if got := errs.CategoryOf(typed); got != errs.CategoryValidation {
t.Fatalf("unwrapped category = %q, want validation", got)
}
}

View File

@@ -73,6 +73,7 @@ const (
const (
SubtypeChallengeRequired Subtype = "challenge_required" // user must complete browser challenge / MFA
SubtypeAccessDenied Subtype = "access_denied" // policy denies access outright
SubtypeContentSafety Subtype = "content_safety" // content-safety scanner blocked output in block mode
)
// CategoryInternal subtypes

View File

@@ -77,6 +77,10 @@ type ValidationError struct {
type InvalidParam struct {
Name string `json:"name"`
Reason string `json:"reason"`
// Suggestions holds machine-readable, ranked candidate corrections for this
// parameter (e.g. did-you-mean flags or subcommands), so an agent can retry
// without parsing the human-facing hint. Omitted when there are none.
Suggestions []string `json:"suggestions,omitempty"`
}
// Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse

View File

@@ -101,9 +101,9 @@ func TestSecurityPolicyErrorUnwrap(t *testing.T) {
// interface would panic when the root dispatcher or any caller walks the
// errors.Is / errors.Unwrap chain.
//
// The doc comments on these types claim "nil-receiver safe" but until this
// test landed nothing actually pinned that claim — exactly the
// behavioral-comment-without-test footgun caught in PR #984 review.
// The doc comments on these types claim "nil-receiver safe"; this test
// pins that claim so the behavioral comment cannot silently drift from the
// implementation.
func TestTypedErrors_UnwrapNilReceiver(t *testing.T) {
t.Helper()
checks := []struct {

View File

@@ -23,7 +23,7 @@ type ImMessageReceiveOutput struct {
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
MessageType string `json:"message_type,omitempty" desc:"Message type"`
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text. For interactive (cards) it stays as the raw JSON string and callers must fromjson to parse it."`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
}
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
@@ -55,8 +55,10 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
}
msg := envelope.Event.Message
content := msg.Content
if msg.MessageType != "interactive" {
var content string
if msg.MessageType == "interactive" {
content = convertlib.ConvertInteractiveEventContent(msg.Content, msg.Mentions)
} else {
content = convertlib.ConvertBodyContent(msg.MessageType, &convertlib.ConvertContext{
RawContent: msg.Content,
MentionMap: convertlib.BuildMentionKeyMap(msg.Mentions),

View File

@@ -7,8 +7,8 @@ import "fmt"
// AbortError is returned by a Wrapper that wants to short-circuit the
// command chain (instead of calling next). The framework converts it
// to an *output.ExitError with type "hook" so the JSON envelope carries
// the structured fields agents expect.
// to a typed errs.* error so the JSON envelope carries the structured
// fields agents expect.
//
// HookName is the framework-namespaced name ("secaudit.approval"); the
// Registrar adds the plugin-name prefix automatically.

View File

@@ -7,9 +7,9 @@ import "fmt"
// CommandDeniedError is the structured error returned by a denyStub. Every
// pruned-command execution path -- direct invocation, alias expansion,
// internal call -- returns this exact type. It is wire-compatible with the
// output.ExitError envelope via the Layer (== error.type) field and the
// detail map produced by ExitError().
// internal call -- returns this exact type. The dispatcher converts it to a
// typed errs.* error; the Layer field carries the denial layer for the
// envelope.
//
// Layer values:
//

12
go.mod
View File

@@ -27,6 +27,8 @@ require (
gopkg.in/yaml.v3 v3.0.1
)
require github.com/apache/arrow/go/v17 v17.0.0
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -42,13 +44,17 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/flatbuffers v24.3.25+incompatible // indirect
github.com/gopherjs/gopherjs v1.17.2 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/itchyny/timefmt-go v0.1.6 // indirect
github.com/jtolds/gls v4.20.0+incompatible // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
@@ -57,10 +63,16 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/smarty/assertions v1.15.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect
golang.org/x/mod v0.18.0 // indirect
golang.org/x/tools v0.22.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
)

32
go.sum
View File

@@ -2,6 +2,8 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/apache/arrow/go/v17 v17.0.0 h1:RRR2bdqKcdbss9Gxy2NS/hK8i4LDMh23L6BbkN5+F54=
github.com/apache/arrow/go/v17 v17.0.0/go.mod h1:jR7QHkODl15PfYyjM2nU+yTLScZ/qfj7OSUZmJ8putc=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -52,12 +54,16 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI=
github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
@@ -74,11 +80,16 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@@ -97,6 +108,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -133,14 +146,20 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ=
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -156,6 +175,7 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
@@ -169,10 +189,16 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
gonum.org/v1/gonum v0.15.0 h1:2lYxjRbTYyxkJxlhC+LvJIx3SsANPdRybu1tGj9/OrQ=
gonum.org/v1/gonum v0.15.0/go.mod h1:xzZVBJBtS+Mz4q0Yl2LJTk+OxOg4jiXZ7qBoM0uISGo=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -0,0 +1,396 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package apicatalog is the single navigation Module over the API metadata. It
// owns every "which services/resources/methods exist and how does a path
// resolve" question that was previously duplicated across cmd/schema,
// cmd/service, internal/schema and internal/registry. It depends only on
// internal/meta; registry is the source Adapter (EmbeddedCatalog/RuntimeCatalog),
// so apicatalog never imports registry.
package apicatalog
import (
"sort"
"strings"
"github.com/larksuite/cli/internal/meta"
)
// Source records whether a catalog includes the remote overlay. It is carried
// so callers (and tests) can assert determinism instead of guessing.
type Source string
const (
SourceEmbedded Source = "embedded" // compiled-in metadata only; deterministic
SourceRuntime Source = "runtime" // embedded + remote overlay
)
// MethodFilter optionally drops methods (e.g. by identity in strict mode).
// A nil filter includes everything.
type MethodFilter func(meta.Method) bool
// Catalog is a navigation view over services with a name index. It owns its
// ordering — New sorts by name — so WalkMethods/Resolve/Complete are
// deterministic regardless of how the source adapter ordered its input.
type Catalog struct {
source Source
services []meta.Service
byName map[string]meta.Service
}
// New builds a Catalog over the given services, owning its navigation order:
// the slice is copied and sorted by name so callers may pass any order and the
// ordering contract is not delegated to the adapter. The copy is shallow —
// meta.Service values share their Resources maps, which are treated as
// read-only.
func New(source Source, services []meta.Service) Catalog {
sorted := append([]meta.Service(nil), services...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
byName := make(map[string]meta.Service, len(sorted))
for _, s := range sorted {
byName[s.Name] = s
}
return Catalog{source: source, services: sorted, byName: byName}
}
// Source reports embedded vs runtime.
func (c Catalog) Source() Source { return c.source }
// Services returns the services in name order. Treat the result as read-only:
// it is the Catalog's own ordered slice and its element Resources maps are
// shared.
func (c Catalog) Services() []meta.Service { return c.services }
// Service looks up one service by name.
func (c Catalog) Service(name string) (meta.Service, bool) {
s, ok := c.byName[name]
return s, ok
}
// Resolve maps a path (already split into segments) to a Target. An empty path
// is TargetAll. Failures return a *ResolveError carrying the available
// candidates so the command layer can render a hint.
func (c Catalog) Resolve(parts []string) (Target, error) {
if len(parts) == 0 {
return Target{Kind: TargetAll}, nil
}
svc, ok := c.byName[parts[0]]
if !ok {
return Target{}, &ResolveError{Kind: ErrService, Subject: parts[0], Candidates: c.serviceNames()}
}
if len(parts) == 1 {
return Target{Kind: TargetService, Service: svc}, nil
}
res, path, remaining, ok := findResource(svc, parts[1:])
if !ok {
return Target{}, &ResolveError{
Kind: ErrResource,
Subject: svc.Name + "." + strings.Join(parts[1:], "."),
Candidates: resourceNames(svc),
}
}
resPath := strings.Join(path, ".")
if len(remaining) == 0 {
return Target{Kind: TargetResource, Service: svc, Resource: &ResourceRef{Service: svc, Resource: res, Path: path}}, nil
}
methodName := remaining[0]
m, ok := res.Method(methodName)
if !ok {
return Target{}, &ResolveError{
Kind: ErrMethod,
Subject: svc.Name + "." + resPath + "." + methodName,
Candidates: methodNames(res),
}
}
if len(remaining) > 1 {
// Method exists but trailing segments don't resolve — reject so a typo
// doesn't silently return this method's schema.
return Target{}, &ResolveError{
Kind: ErrPath,
Subject: svc.Name + "." + resPath + "." + strings.Join(remaining, "."),
Method: methodName,
Trailing: strings.Join(remaining[1:], "."),
}
}
return Target{Kind: TargetMethod, Service: svc, Method: &MethodRef{Service: svc, Resource: res, ResourcePath: path, Method: m}}, nil
}
// MethodRefs returns the method refs selected by a resolved Target, filtered:
// TargetAll -> every method, TargetService / TargetResource -> that subtree,
// TargetMethod -> the single method if it passes the filter (else empty). It
// unifies WalkMethods/ServiceMethods/ResourceMethods so the command layer maps a
// Target to refs in one call instead of re-deciding the walker per Kind.
func (c Catalog) MethodRefs(target Target, filter MethodFilter) []MethodRef {
switch target.Kind {
case TargetService:
return ServiceMethods(target.Service, filter)
case TargetResource:
return ResourceMethods(*target.Resource, filter)
case TargetMethod:
if filter != nil && !filter(target.Method.Method) {
return nil
}
return []MethodRef{*target.Method}
case TargetAll:
return c.WalkMethods(filter)
default:
// Unknown / zero-value Kind: return nothing rather than silently
// dumping every method (the safe direction for an invalid Target).
return nil
}
}
// WalkMethods returns one MethodRef per method across all services (optionally
// filtered), recursing nested resources, in a deterministic order: services by
// name, resources by name, methods by name.
func (c Catalog) WalkMethods(filter MethodFilter) []MethodRef {
var out []MethodRef
for _, svc := range c.services {
out = append(out, ServiceMethods(svc, filter)...)
}
return out
}
// ServiceMethods returns the method refs of one service (filtered), recursing
// nested resources, in deterministic resource/method name order.
func ServiceMethods(svc meta.Service, filter MethodFilter) []MethodRef {
var out []MethodRef
walkResources(svc, svc.ResourceList(), nil, filter, &out)
return out
}
// ResourceMethods returns the method refs under one resource (filtered), using
// the resource's resolved path as the base and recursing nested resources.
func ResourceMethods(r ResourceRef, filter MethodFilter) []MethodRef {
var out []MethodRef
for _, m := range r.Resource.MethodList() {
if filter == nil || filter(m) {
out = append(out, MethodRef{Service: r.Service, Resource: r.Resource, ResourcePath: r.Path, Method: m})
}
}
walkResources(r.Service, r.Resource.SubResources(), r.Path, filter, &out)
return out
}
func walkResources(svc meta.Service, resources []meta.Resource, parentPath []string, filter MethodFilter, out *[]MethodRef) {
for _, res := range resources {
path := append(append([]string(nil), parentPath...), res.Name)
for _, m := range res.MethodList() {
if filter == nil || filter(m) {
*out = append(*out, MethodRef{Service: svc, Resource: res, ResourcePath: path, Method: m})
}
}
walkResources(svc, res.SubResources(), path, filter, out)
}
}
// Complete returns shell-completion candidates for the schema path argument,
// supporting both the legacy single dotted arg ("im.reac") and the
// space-separated form ("im reactions"). noSpace mirrors cobra's
// ShellCompDirectiveNoSpace (so "service." / "service.resource." stay open for
// the next segment). Filtering uses the caller's MethodFilter so strict-mode
// unavailable methods are hidden.
func (c Catalog) Complete(args []string, toComplete string, filter MethodFilter) (completions []string, noSpace bool) {
// Case 1: legacy single dotted arg — no resolved args yet.
if len(args) == 0 {
parts := strings.Split(toComplete, ".")
if len(parts) <= 1 {
for _, name := range c.serviceNames() {
if strings.HasPrefix(name, toComplete) {
completions = append(completions, name+".")
}
}
return completions, true
}
svc, ok := c.byName[parts[0]]
if !ok {
return nil, false
}
completions = c.completeDotted(svc, strings.Join(parts[1:], "."), filter)
allTrailingDot := len(completions) > 0
for _, comp := range completions {
if !strings.HasSuffix(comp, ".") {
allTrailingDot = false
break
}
}
return completions, allTrailingDot
}
// Case 2: space-separated form — args holds resolved segments.
svc, ok := c.byName[args[0]]
if !ok {
return nil, false
}
resource, _, _, ok := findResource(svc, args[1:])
if !ok {
// No resource matched yet — suggest top-level resources reachable in the
// current identity mode.
return completeChildren(svc.ResourceList(), nil, toComplete, filter), false
}
// Positioned in a resource — offer its methods and its sub-resources, so the
// next segment can drill deeper, symmetric to findResource's descent.
return completeChildren(resource.SubResources(), resource.MethodList(), toComplete, filter), false
}
// completeDotted suggests dotted completions for the text after the service
// segment. It descends fully-typed "resource." segments (longest match per
// level, so flat dotted keys like "chat.members" and genuinely nested resources
// both resolve), then offers the reachable sub-resources (as "…name.") and the
// methods (as "…name") of the level it lands in whose names extend the trailing
// partial token. This descent is symmetric to findResource, so completion can
// reach every method Resolve can.
func (c Catalog) completeDotted(svc meta.Service, afterService string, filter MethodFilter) []string {
subs := svc.ResourceList()
base := svc.Name
rest := afterService
var here *meta.Resource // resource we're positioned in; nil at the service root
for {
matched, n, ok := longestResourceFollowedByDot(subs, rest)
if !ok {
break
}
base += "." + matched.Name
rest = rest[n:]
r := matched
here = &r
subs = matched.SubResources()
}
var out []string
for _, sub := range subs {
if strings.HasPrefix(sub.Name, rest) && resourceReachable(sub, filter) {
out = append(out, base+"."+sub.Name+".")
}
}
if here != nil {
for _, m := range here.MethodList() {
if (filter == nil || filter(m)) && strings.HasPrefix(m.Name, rest) {
out = append(out, base+"."+m.Name)
}
}
}
sort.Strings(out)
return out
}
// completeChildren returns the sorted next-segment candidates at one level: the
// (filtered) methods and the reachable sub-resources whose names extend prefix.
// Methods are terminal; sub-resources are bare names the caller drills into on
// the next segment.
func completeChildren(subResources []meta.Resource, methods []meta.Method, prefix string, filter MethodFilter) []string {
var out []string
for _, m := range methods {
if (filter == nil || filter(m)) && strings.HasPrefix(m.Name, prefix) {
out = append(out, m.Name)
}
}
for _, sub := range subResources {
if strings.HasPrefix(sub.Name, prefix) && resourceReachable(sub, filter) {
out = append(out, sub.Name)
}
}
sort.Strings(out)
return out
}
// longestResourceFollowedByDot finds the longest resource in resources whose
// name is a fully-typed segment of text (text begins with "name."), returning
// it, the byte length consumed (incl. the dot), and whether one matched.
func longestResourceFollowedByDot(resources []meta.Resource, text string) (meta.Resource, int, bool) {
best := meta.Resource{}
bestLen := -1
for _, r := range resources {
if len(r.Name) > bestLen && strings.HasPrefix(text, r.Name+".") {
best = r
bestLen = len(r.Name)
}
}
if bestLen < 0 {
return meta.Resource{}, 0, false
}
return best, len(best.Name) + 1, true
}
// findResource resolves a resource path against a service, descending nested
// resources. At each level it consumes the longest leading run of parts that
// names a resource at that level, so both flat dotted keys ("chat.members")
// and genuinely nested resources ("spaces" > "items") resolve. This descent is
// symmetric to walkResources, which guarantees every path WalkMethods emits
// resolves back (the round-trip contract). Returns the deepest matched resource
// (Name injected), its path segments, the unconsumed remainder, and whether
// anything matched.
//
// Descent is greedy and resource-first: the one ambiguous case is a resource
// that has BOTH a method and a sub-resource of the same name — the sub-resource
// wins and shadows the method, so Resolve can never reach that method. Real
// metadata never collides the two, so this is theoretical.
func findResource(svc meta.Service, parts []string) (res meta.Resource, path []string, remaining []string, ok bool) {
level := svc.Resources
remaining = parts
for len(remaining) > 0 {
matched, name, n := longestResourcePrefix(level, remaining)
if n == 0 {
break
}
matched.Name = name
res = matched
path = append(path, name)
remaining = remaining[n:]
level = matched.Resources
ok = true
}
return res, path, remaining, ok
}
// longestResourcePrefix finds the longest leading run of segs (joined by ".")
// that names a resource in level, returning the resource, its dotted name, and
// the number of segments consumed (0 if none match). Longest-first lets a flat
// dotted key win over its single leading segment when present.
func longestResourcePrefix(level map[string]meta.Resource, segs []string) (meta.Resource, string, int) {
for i := len(segs); i >= 1; i-- {
name := strings.Join(segs[:i], ".")
if r, ok := level[name]; ok {
return r, name, i
}
}
return meta.Resource{}, "", 0
}
// resourceReachable reports whether a resource exposes a method reachable under
// the filter — directly or in any nested sub-resource (a nil filter accepts any
// method). A resource whose methods are all filtered out but which contains a
// reachable nested method is still offerable, so completion can drill into it.
func resourceReachable(res meta.Resource, filter MethodFilter) bool {
for _, m := range res.MethodList() {
if filter == nil || filter(m) {
return true
}
}
for _, sub := range res.SubResources() {
if resourceReachable(sub, filter) {
return true
}
}
return false
}
func (c Catalog) serviceNames() []string {
names := make([]string, len(c.services))
for i, s := range c.services {
names[i] = s.Name
}
return names // c.services is already name-sorted
}
func resourceNames(svc meta.Service) []string { return sortedKeys(svc.Resources) }
func methodNames(res meta.Resource) []string { return sortedKeys(res.Methods) }
func sortedKeys[V any](m map[string]V) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

View File

@@ -0,0 +1,340 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apicatalog_test
import (
"errors"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/meta"
)
// testCatalog builds a small embedded catalog: services drive (no resources)
// and im with a dotted resource (chat.members), a multi-method resource
// (reactions, where list is user-only), and images.
func testCatalog() apicatalog.Catalog {
im := meta.ServiceFromMap(map[string]interface{}{
"name": "im",
"resources": map[string]interface{}{
"chat.members": map[string]interface{}{
"methods": map[string]interface{}{"create": map[string]interface{}{}},
},
"reactions": map[string]interface{}{
"methods": map[string]interface{}{
"create": map[string]interface{}{},
"list": map[string]interface{}{"accessTokens": []interface{}{"user"}},
},
},
"images": map[string]interface{}{
"methods": map[string]interface{}{"create": map[string]interface{}{}},
},
},
})
drive := meta.ServiceFromMap(map[string]interface{}{"name": "drive"})
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{drive, im}) // already name-sorted
}
func TestNew_PreservesOrderAndLookup(t *testing.T) {
c := testCatalog()
if c.Source() != apicatalog.SourceEmbedded {
t.Fatalf("source = %q", c.Source())
}
names := []string{}
for _, s := range c.Services() {
names = append(names, s.Name)
}
if !reflect.DeepEqual(names, []string{"drive", "im"}) {
t.Errorf("Services order = %v, want [drive im]", names)
}
if _, ok := c.Service("im"); !ok {
t.Error("Service(im) not found")
}
if _, ok := c.Service("nope"); ok {
t.Error("Service(nope) should not be found")
}
}
// TestNew_SortsAndIsolatesInput pins the ordering contract New owns: it sorts
// arbitrary input by service name and shallow-copies the slice so later caller
// mutation can't reorder the Catalog.
func TestNew_SortsAndIsolatesInput(t *testing.T) {
in := []meta.Service{
meta.ServiceFromMap(map[string]interface{}{"name": "zeta"}),
meta.ServiceFromMap(map[string]interface{}{"name": "alpha"}),
}
c := apicatalog.New(apicatalog.SourceEmbedded, in)
names := func() []string {
var out []string
for _, s := range c.Services() {
out = append(out, s.Name)
}
return out
}
if got := names(); !reflect.DeepEqual(got, []string{"alpha", "zeta"}) {
t.Errorf("New did not sort unsorted input: %v", got)
}
// Mutating the caller's slice afterward must not reorder the Catalog.
in[0] = meta.ServiceFromMap(map[string]interface{}{"name": "MUTATED"})
if got := names(); !reflect.DeepEqual(got, []string{"alpha", "zeta"}) {
t.Errorf("Catalog order changed after caller mutated its input slice: %v", got)
}
}
func TestWalkMethods_AllAndFiltered(t *testing.T) {
c := testCatalog()
all := c.WalkMethods(nil)
got := map[string]bool{}
for _, r := range all {
got[r.SchemaPath()] = true
}
want := []string{
"im.chat.members.create",
"im.images.create",
"im.reactions.create",
"im.reactions.list",
}
if len(all) != len(want) {
t.Fatalf("WalkMethods(nil) = %d refs, want %d (%v)", len(all), len(want), got)
}
for _, w := range want {
if !got[w] {
t.Errorf("WalkMethods(nil) missing %q", w)
}
}
// Deterministic order: services by name, resources by name, methods by name.
var order []string
for _, r := range all {
order = append(order, r.SchemaPath())
}
if !reflect.DeepEqual(order, want) {
t.Errorf("WalkMethods order = %v, want %v", order, want)
}
// Filter to bot-only ("tenant"): reactions.list (user-only) drops; methods
// with no accessTokens are permissive and stay.
botOnly := func(m meta.Method) bool {
if m.AccessTokens == nil {
return true
}
for _, tok := range m.AccessTokens {
if tok == "tenant" {
return true
}
}
return false
}
filtered := c.WalkMethods(botOnly)
for _, r := range filtered {
if r.SchemaPath() == "im.reactions.list" {
t.Error("filtered walk should drop user-only im.reactions.list")
}
}
if len(filtered) != len(all)-1 {
t.Errorf("filtered walk = %d, want %d", len(filtered), len(all)-1)
}
}
func TestMethodRef_Paths_DottedResourceStaysOneSegment(t *testing.T) {
c := testCatalog()
target, err := c.Resolve([]string{"im", "chat.members", "create"})
if err != nil {
t.Fatalf("resolve: %v", err)
}
if target.Kind != apicatalog.TargetMethod {
t.Fatalf("kind = %v", target.Kind)
}
m := target.Method
if m.SchemaPath() != "im.chat.members.create" {
t.Errorf("SchemaPath = %q", m.SchemaPath())
}
if !reflect.DeepEqual(m.CommandPath(), []string{"im", "chat.members", "create"}) {
t.Errorf("CommandPath = %v", m.CommandPath())
}
if m.ResourceName() != "chat.members" {
t.Errorf("ResourceName = %q, want chat.members (one segment)", m.ResourceName())
}
if m.Method.Name != "create" {
t.Errorf("Method.Name not injected: %q", m.Method.Name)
}
}
func TestResolve_DottedAndSplitFormsEquivalent(t *testing.T) {
c := testCatalog()
// schema.ParsePath splits both "im.chat.members.create" and
// "im chat.members create" into segments; findResource's longest-prefix
// must resolve the dotted resource either way.
a, errA := c.Resolve([]string{"im", "chat", "members", "create"}) // fully split
b, errB := c.Resolve([]string{"im", "chat.members", "create"}) // resource as one segment
if errA != nil || errB != nil {
t.Fatalf("errA=%v errB=%v", errA, errB)
}
if a.Method.SchemaPath() != b.Method.SchemaPath() || a.Method.SchemaPath() != "im.chat.members.create" {
t.Errorf("forms diverged: %q vs %q", a.Method.SchemaPath(), b.Method.SchemaPath())
}
}
func TestResolve_Targets(t *testing.T) {
c := testCatalog()
if tg, _ := c.Resolve(nil); tg.Kind != apicatalog.TargetAll {
t.Errorf("empty -> %v, want all", tg.Kind)
}
if tg, _ := c.Resolve([]string{"im"}); tg.Kind != apicatalog.TargetService || tg.Service.Name != "im" {
t.Errorf("[im] -> %v/%q", tg.Kind, tg.Service.Name)
}
if tg, _ := c.Resolve([]string{"im", "reactions"}); tg.Kind != apicatalog.TargetResource || tg.Resource.SchemaPath() != "im.reactions" {
t.Errorf("[im reactions] -> %v", tg.Kind)
}
}
func TestResolve_Errors(t *testing.T) {
c := testCatalog()
cases := []struct {
parts []string
kind apicatalog.ResolveErrorKind
}{
{[]string{"nope"}, apicatalog.ErrService},
{[]string{"im", "nope"}, apicatalog.ErrResource},
{[]string{"im", "reactions", "nope"}, apicatalog.ErrMethod},
{[]string{"im", "reactions", "list", "extra"}, apicatalog.ErrPath},
}
for _, tc := range cases {
_, err := c.Resolve(tc.parts)
var re *apicatalog.ResolveError
if !errors.As(err, &re) {
t.Errorf("%v -> err %v, want *ResolveError", tc.parts, err)
continue
}
if re.Kind != tc.kind {
t.Errorf("%v -> kind %q, want %q", tc.parts, re.Kind, tc.kind)
}
if tc.kind != apicatalog.ErrPath && len(re.Candidates) == 0 {
t.Errorf("%v -> expected candidates", tc.parts)
}
}
}
// nestedCatalog adds a genuinely nested resource (spaces > items) on top of a
// flat dotted resource (chat.members), so the round-trip contract is exercised
// for real nesting — not just flat dotted keys.
func nestedCatalog() apicatalog.Catalog {
im := meta.ServiceFromMap(map[string]interface{}{
"name": "im",
"resources": map[string]interface{}{
"chat.members": map[string]interface{}{
"methods": map[string]interface{}{"create": map[string]interface{}{}},
},
"spaces": map[string]interface{}{
"methods": map[string]interface{}{"create": map[string]interface{}{}},
"resources": map[string]interface{}{
"items": map[string]interface{}{
"methods": map[string]interface{}{"get": map[string]interface{}{}},
},
},
},
},
})
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{im})
}
// TestResolve_WalkMethodsRoundTrip is the core catalog contract: every method
// WalkMethods emits must Resolve back to the same method — both from its dotted
// SchemaPath (fully split) and from its CommandPath (resource as one segment).
// This pins findResource's nested-resource descent symmetric to walkResources,
// so "traversable" implies "resolvable".
func TestResolve_WalkMethodsRoundTrip(t *testing.T) {
for _, c := range []apicatalog.Catalog{testCatalog(), nestedCatalog()} {
for _, ref := range c.WalkMethods(nil) {
want := ref.SchemaPath()
for _, parts := range [][]string{
strings.Split(want, "."), // fully-split dotted form
ref.CommandPath(), // command form (resource stays one segment)
} {
tg, err := c.Resolve(parts)
if err != nil {
t.Errorf("round-trip %v: %v", parts, err)
continue
}
if tg.Kind != apicatalog.TargetMethod {
t.Errorf("round-trip %v: kind=%v, want method", parts, tg.Kind)
continue
}
if tg.Method.SchemaPath() != want {
t.Errorf("round-trip %v: resolved to %q, want %q", parts, tg.Method.SchemaPath(), want)
}
}
}
}
}
// TestComplete_Nested pins completion closure for genuinely nested resources:
// both the dotted and space forms must reach a nested method, symmetric to
// Resolve (findResource descends, so completion must too).
func TestComplete_Nested(t *testing.T) {
c := nestedCatalog()
// dotted: under a resource, offer its methods AND its sub-resources
if comps, ns := c.Complete(nil, "im.spaces.", nil); !reflect.DeepEqual(comps, []string{"im.spaces.create", "im.spaces.items."}) || ns {
t.Errorf("Complete([], im.spaces.) = %v noSpace=%v, want [im.spaces.create im.spaces.items.] false", comps, ns)
}
// dotted: drill into the nested sub-resource's method
if comps, ns := c.Complete(nil, "im.spaces.items.", nil); !reflect.DeepEqual(comps, []string{"im.spaces.items.get"}) || ns {
t.Errorf("Complete([], im.spaces.items.) = %v noSpace=%v, want [im.spaces.items.get] false", comps, ns)
}
// dotted: partial sub-resource name -> the sub-resource (NoSpace, more to type)
if comps, ns := c.Complete(nil, "im.spaces.it", nil); !reflect.DeepEqual(comps, []string{"im.spaces.items."}) || !ns {
t.Errorf("Complete([], im.spaces.it) = %v noSpace=%v, want [im.spaces.items.] true", comps, ns)
}
// space form: under a resource, offer methods AND sub-resources
if comps, _ := c.Complete([]string{"im", "spaces"}, "", nil); !reflect.DeepEqual(comps, []string{"create", "items"}) {
t.Errorf("Complete([im spaces], '') = %v, want [create items]", comps)
}
// space form: drill into the nested sub-resource's methods
if comps, _ := c.Complete([]string{"im", "spaces", "items"}, "", nil); !reflect.DeepEqual(comps, []string{"get"}) {
t.Errorf("Complete([im spaces items], '') = %v, want [get]", comps)
}
}
func TestComplete(t *testing.T) {
c := testCatalog()
// dotted: service prefix -> "im." (NoSpace)
if comps, ns := c.Complete(nil, "i", nil); !reflect.DeepEqual(comps, []string{"im."}) || !ns {
t.Errorf("Complete([], i) = %v noSpace=%v", comps, ns)
}
// dotted: resource prefix -> "im.reactions." (NoSpace)
if comps, _ := c.Complete(nil, "im.rea", nil); !reflect.DeepEqual(comps, []string{"im.reactions."}) {
t.Errorf("Complete([], im.rea) = %v", comps)
}
// space form: resource candidates under im (deterministic order)
comps, ns := c.Complete([]string{"im"}, "", nil)
if !reflect.DeepEqual(comps, []string{"chat.members", "images", "reactions"}) || ns {
t.Errorf("Complete([im], '') = %v noSpace=%v", comps, ns)
}
// space form: method candidates under reactions
if comps, _ := c.Complete([]string{"im", "reactions"}, "", nil); !reflect.DeepEqual(comps, []string{"create", "list"}) {
t.Errorf("Complete([im reactions], '') = %v", comps)
}
// filter applied: bot-only hides user-only list
botOnly := func(m meta.Method) bool {
if m.AccessTokens == nil {
return true
}
for _, tok := range m.AccessTokens {
if tok == "tenant" {
return true
}
}
return false
}
if comps, _ := c.Complete([]string{"im", "reactions"}, "", botOnly); !reflect.DeepEqual(comps, []string{"create"}) {
t.Errorf("Complete with bot filter = %v, want [create]", comps)
}
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apicatalog
import (
"strings"
"github.com/larksuite/cli/internal/meta"
)
// TargetKind classifies what a schema/command path resolves to.
type TargetKind string
const (
TargetAll TargetKind = "all" // empty path: every method
TargetService TargetKind = "service" // <service>
TargetResource TargetKind = "resource" // <service> <resource...>
TargetMethod TargetKind = "method" // <service> <resource...> <method>
)
// Target is the result of Catalog.Resolve. Resource and Method are populated
// only for TargetResource and TargetMethod respectively.
type Target struct {
Kind TargetKind
Service meta.Service
Resource *ResourceRef
Method *MethodRef
}
// ResourceRef identifies one resource within a service. Path holds the resource
// path segments (one element for the common flat dotted resource like
// "chat.members"; multiple for genuinely nested resources).
type ResourceRef struct {
Service meta.Service
Resource meta.Resource
Path []string
}
// MethodRef identifies one method, carrying the full navigation context so the
// command path and schema path can be derived without re-walking the catalog.
type MethodRef struct {
Service meta.Service
Resource meta.Resource
ResourcePath []string
Method meta.Method
}
// SchemaPath is the dotted "service.resource" identifier.
func (r ResourceRef) SchemaPath() string {
return r.Service.Name + "." + strings.Join(r.Path, ".")
}
// ServiceName returns the owning service name.
func (r MethodRef) ServiceName() string { return r.Service.Name }
// ResourceName is the dotted resource path, e.g. "chat.members".
func (r MethodRef) ResourceName() string { return strings.Join(r.ResourcePath, ".") }
// MethodName returns the method's own name.
func (r MethodRef) MethodName() string { return r.Method.Name }
// SchemaPath is the dotted "service.resource.method" identifier, e.g.
// "im.chat.members.create".
func (r MethodRef) SchemaPath() string {
return r.Service.Name + "." + strings.Join(r.ResourcePath, ".") + "." + r.Method.Name
}
// CommandPath is the CLI argv segments, e.g. ["im", "chat.members", "create"].
func (r MethodRef) CommandPath() []string {
out := make([]string, 0, len(r.ResourcePath)+2)
out = append(out, r.Service.Name)
out = append(out, r.ResourcePath...)
return append(out, r.Method.Name)
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apicatalog
import "strings"
// ParsePath normalizes positional command arguments into the path segments
// Resolve consumes. It accepts two equivalent forms:
//
// im.messages.reply -> single arg, split on "."
// im messages reply -> multiple args, used as-is
//
// "im chat.members bots" as a single quoted arg is NOT supported; quote
// arguments individually if your shell needs it. A resource keeps its internal
// dots when passed as one segment (e.g. "chat.members"); findResource's
// longest-prefix descent resolves both the split and the one-segment forms to
// the same target. Returns nil for zero args (bare invocation -> TargetAll).
func ParsePath(args []string) []string {
switch len(args) {
case 0:
return nil
case 1:
if strings.Contains(args[0], ".") {
return strings.Split(args[0], ".")
}
return []string{args[0]}
default:
return args
}
}

View File

@@ -1,11 +1,13 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package schema
package apicatalog_test
import (
"reflect"
"testing"
"github.com/larksuite/cli/internal/apicatalog"
)
func TestParsePath(t *testing.T) {
@@ -25,7 +27,7 @@ func TestParsePath(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ParsePath(tt.args)
got := apicatalog.ParsePath(tt.args)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("ParsePath(%v) = %v, want %v", tt.args, got, tt.want)
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apicatalog
// ResolveErrorKind classifies a Resolve failure so the command layer can render
// the right hint without re-deriving what was being looked up.
type ResolveErrorKind string
const (
ErrService ResolveErrorKind = "service"
ErrResource ResolveErrorKind = "resource"
ErrMethod ResolveErrorKind = "method"
ErrPath ResolveErrorKind = "path" // method exists but trailing segments don't resolve
)
// ResolveError is returned by Catalog.Resolve. Subject is the dotted thing that
// failed to resolve; Candidates lists the available names at that level (nil for
// ErrPath, which instead carries the matched Method and the unresolved Trailing).
type ResolveError struct {
Kind ResolveErrorKind
Subject string
Candidates []string
Method string
Trailing string
}
func (e *ResolveError) Error() string {
return "unknown " + string(e.Kind) + ": " + e.Subject
}

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package appmeta
import (
"context"
"encoding/json"
"fmt"
)
// FetchSubscribedCallbacks returns the app's currently subscribed callback names
// from application/get. On a successful fetch it always returns a non-nil slice
// (empty when callback_info is absent or lists no callbacks) so callers can
// distinguish "fetched, zero callbacks subscribed" — a definitive console state
// that must fail the precheck — from a fetch error (nil), which is a
// weak-dependency skip. Identity must be bot: the endpoint is app-level.
func FetchSubscribedCallbacks(ctx context.Context, client APIClient, appID string) ([]string, error) {
path := fmt.Sprintf("/open-apis/application/v6/applications/%s?lang=zh_cn", appID)
raw, err := client.CallAPI(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
var envelope struct {
Data struct {
App struct {
CallbackInfo *struct {
SubscribedCallbacks []string `json:"subscribed_callbacks"`
} `json:"callback_info"`
} `json:"app"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return nil, fmt.Errorf("decode application response: %w", err)
}
// callback_info also carries callback_type (e.g. "websocket"); it is
// intentionally not parsed or validated. Feishu open-platform callbacks are
// delivered over WebSocket only (confirmed), matching the CLI's WebSocket
// event source, so subscribed_callbacks alone is sufficient for the precheck.
// Revisit and validate callback_type if non-WebSocket delivery ever appears.
callbacks := []string{}
if ci := envelope.Data.App.CallbackInfo; ci != nil {
callbacks = append(callbacks, ci.SubscribedCallbacks...)
}
return callbacks, nil
}

View File

@@ -0,0 +1,101 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package appmeta
import (
"context"
"encoding/json"
"errors"
"testing"
)
var errFakeFetch = errors.New("fake fetch error")
type fakeCallbackClient struct {
raw string
err error
}
func (f fakeCallbackClient) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
if f.err != nil {
return nil, f.err
}
return json.RawMessage(f.raw), nil
}
func TestFetchSubscribedCallbacks_ParsesList(t *testing.T) {
raw := `{"code":0,"data":{"app":{"callback_info":{"callback_type":"websocket","subscribed_callbacks":["card.action.trigger","profile.view.get"]}}},"msg":"success"}`
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
want := []string{"card.action.trigger", "profile.view.get"}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("got[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestFetchSubscribedCallbacks_NoCallbackInfo(t *testing.T) {
// A successful fetch with no callback_info means "zero callbacks subscribed",
// which must be a non-nil empty slice (distinct from a fetch error's nil) so
// the precheck reports a required callback as missing instead of skipping.
raw := `{"code":0,"data":{"app":{"app_id":"cli_x"}},"msg":"success"}`
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got == nil {
t.Fatalf("got nil, want non-nil empty slice")
}
if len(got) != 0 {
t.Errorf("got %v, want empty", got)
}
}
func TestFetchSubscribedCallbacks_FetchError(t *testing.T) {
// A fetch error must return nil so the caller treats it as a weak-dependency skip.
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{err: errFakeFetch}, "cli_x")
if err == nil {
t.Fatal("expected error")
}
if got != nil {
t.Errorf("got %v, want nil on fetch error", got)
}
}
func TestFetchSubscribedCallbacks_CallbackInfoPresentButNull(t *testing.T) {
// callback_info present but subscribed_callbacks explicitly null → must be
// a non-nil empty slice so the precheck reports missing callbacks.
raw := `{"code":0,"data":{"app":{"callback_info":{"subscribed_callbacks":null}}},"msg":"success"}`
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got == nil {
t.Fatalf("got nil, want non-nil empty slice when subscribed_callbacks is null")
}
if len(got) != 0 {
t.Errorf("got %v, want empty", got)
}
}
func TestFetchSubscribedCallbacks_CallbackInfoPresentButOmitted(t *testing.T) {
// callback_info present but subscribed_callbacks omitted → same as null: non-nil empty.
raw := `{"code":0,"data":{"app":{"callback_info":{"callback_type":"websocket"}}},"msg":"success"}`
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got == nil {
t.Fatalf("got nil, want non-nil empty slice when subscribed_callbacks is omitted")
}
if len(got) != 0 {
t.Errorf("got %v, want empty", got)
}
}

View File

@@ -6,7 +6,6 @@ package auth
import (
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
@@ -22,7 +21,10 @@ var TokenRetryCodes = map[int]bool{
output.LarkErrTokenExpired: true,
}
// NeedAuthorizationError is thrown when no valid UAT exists.
// NeedAuthorizationError is the sentinel preserved in the Cause chain of the
// typed missing-UAT error so existing errors.As(&NeedAuthorizationError{})
// consumers keep matching after the construction site moved to the typed
// taxonomy. It is never surfaced on the wire on its own.
type NeedAuthorizationError struct {
UserOpenId string
}
@@ -32,24 +34,31 @@ func (e *NeedAuthorizationError) Error() string {
return fmt.Sprintf("%s (user: %s)", needUserAuthorizationMarker, e.UserOpenId)
}
// NewNeedUserAuthorizationError builds the typed *errs.AuthenticationError
// returned when no valid UAT exists for userOpenID. The Message keeps the
// need_user_authorization marker, the Hint converges on the same auth-login
// recovery vocabulary as the token-missing surface in internal/client, and the
// legacy *NeedAuthorizationError sentinel is preserved in the Cause chain for
// errors.As / errors.Is traversal.
func NewNeedUserAuthorizationError(userOpenID string) *errs.AuthenticationError {
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"%s (user: %s)", needUserAuthorizationMarker, userOpenID).
WithUserOpenID(userOpenID).
WithHint("run: lark-cli auth login to re-authorize").
WithCause(&NeedAuthorizationError{UserOpenId: userOpenID})
}
// IsNeedUserAuthorizationError reports whether err represents a missing-UAT
// failure, either as the original auth error or as a wrapped ExitError.
// failure. It matches the legacy *NeedAuthorizationError sentinel, which is
// preserved in the Cause chain of the typed missing-UAT error, so errors.As
// traverses into the typed *errs.AuthenticationError as well.
func IsNeedUserAuthorizationError(err error) bool {
if err == nil {
return false
}
var needAuthErr *NeedAuthorizationError
if errors.As(err, &needAuthErr) {
return true
}
// Deprecated: legacy *output.ExitError / string-match branches; removed after typed migration.
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
return strings.Contains(exitErr.Detail.Message, needUserAuthorizationMarker)
}
return strings.Contains(err.Error(), needUserAuthorizationMarker)
return errors.As(err, &needAuthErr)
}
// SecurityPolicyError is preserved as a Go type alias so existing

View File

@@ -6,7 +6,7 @@ package auth
import (
"testing"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
)
func TestIsNeedUserAuthorizationError(t *testing.T) {
@@ -22,15 +22,16 @@ func TestIsNeedUserAuthorizationError(t *testing.T) {
}
})
t.Run("wrapped exit error", func(t *testing.T) {
err := output.ErrNetwork("API call failed: %s", &NeedAuthorizationError{})
if !IsNeedUserAuthorizationError(err) {
t.Fatal("expected wrapped ExitError to match")
t.Run("typed missing-UAT error carries sentinel in cause", func(t *testing.T) {
// The typed constructor preserves the legacy sentinel in the Cause
// chain, so errors.As traverses into it.
if !IsNeedUserAuthorizationError(NewNeedUserAuthorizationError("u_1")) {
t.Fatal("expected typed missing-UAT error to match via its cause chain")
}
})
t.Run("other error", func(t *testing.T) {
err := output.ErrNetwork("API call failed: timeout")
err := errs.NewNetworkError(errs.SubtypeNetworkTransport, "API call failed: timeout")
if IsNeedUserAuthorizationError(err) {
t.Fatal("expected unrelated error not to match")
}

View File

@@ -71,7 +71,7 @@ var refreshLocks sync.Map
func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string, error) {
stored := GetStoredToken(opts.AppId, opts.UserOpenId)
if stored == nil {
return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId}
return "", NewNeedUserAuthorizationError(opts.UserOpenId)
}
status := TokenStatus(stored)
@@ -86,7 +86,7 @@ func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string,
return "", err
}
if refreshed == nil {
return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId}
return "", NewNeedUserAuthorizationError(opts.UserOpenId)
}
return refreshed.AccessToken, nil
}
@@ -99,7 +99,7 @@ func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string,
fmt.Fprintf(os.Stderr, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
}
}
return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId}
return "", NewNeedUserAuthorizationError(opts.UserOpenId)
}
// refreshWithLock acquires a file lock before attempting to refresh the token.

View File

@@ -16,7 +16,6 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
// ─────────────────────────────────────────────────────────────────────────────
@@ -264,19 +263,16 @@ func TestWrapJSONResponseParseError_Nil(t *testing.T) {
// Cross-cutting: existing tests already in this file (kept and adjusted below).
// ─────────────────────────────────────────────────────────────────────────────
// TestWrapDoAPIError_LegacyExitErrorNoLongerPassesThrough pins that legacy
// *output.ExitError (auth/validation/api flavours) is NOT a problemCarrier
// and is therefore not pass-through — only typed *errs.* values are.
// Legacy values fall through to the network/JSON branches based on their
// inner shape.
func TestWrapDoAPIError_LegacyExitErrorNoLongerPassesThrough(t *testing.T) {
// An *output.ErrAuth has no embedded Problem and no JSON-decode chain;
// it routes to the network branch with the fallback transport subtype.
got := WrapDoAPIError(output.ErrAuth("no access token available for user"))
// TestWrapDoAPIError_UntypedErrorRoutesToNetwork pins that a plain untyped
// error (no embedded Problem, no JSON-decode chain) is NOT pass-through —
// only typed *errs.* values are. It routes to the network branch with the
// fallback transport subtype.
func TestWrapDoAPIError_UntypedErrorRoutesToNetwork(t *testing.T) {
got := WrapDoAPIError(errors.New("no access token available for user"))
var ne *errs.NetworkError
if !errors.As(got, &ne) {
t.Fatalf("expected *errs.NetworkError (legacy ExitError no longer pass-through), got %T (%v)", got, got)
t.Fatalf("expected *errs.NetworkError for an untyped error, got %T (%v)", got, got)
}
// Sanity: not silently re-classified as JSON-decode.
var ie *errs.InternalError

View File

@@ -19,11 +19,9 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/errcompat"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
)
@@ -54,16 +52,11 @@ func (c *APIClient) resolveAccessToken(ctx context.Context, as core.Identity) (s
if errors.As(err, &unavailableErr) {
return "", newTokenMissingError(as, unavailableErr)
}
// NeedAuthorizationError from the credential chain (e.g. UAT refresh
// returned need_user_authorization) must surface as typed
// AuthenticationError. Without this, WrapDoAPIError would wrap the
// raw err as NetworkError, and cmd/root.go's outer-typed gate would
// then skip PromoteAuthError — leaving the user with exit 4 and no
// auth-login hint instead of exit 3 typed authentication.
var needAuthErr *internalauth.NeedAuthorizationError
if errors.As(err, &needAuthErr) {
return "", errcompat.PromoteAuthError(needAuthErr)
}
// The credential chain already emits a typed *errs.AuthenticationError
// for the missing-UAT case (e.g. UAT refresh returned
// need_user_authorization), so it flows through unchanged: the
// outer-typed gate in cmd/root.go and the idempotent WrapDoAPIError
// both preserve its authentication category and exit 3.
return "", err
}
if result.Token == "" {
@@ -120,24 +113,22 @@ func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []lark
//
// SDK Do() failures are normalised through WrapDoAPIError so every caller
// (cmd/api, RuntimeContext, shortcuts) gets the same wire shape without
// each one remembering to wrap. Today that wire shape is still the legacy
// *output.ExitError envelope (network / api_error); future framework-
// boundary migration flips WrapDoAPIError to typed *errs.NetworkError /
// *errs.InternalError per the contract in errs/ERROR_CONTRACT.md.
// Errors that arrive already-classified (legacy *output.ExitError from
// resolveAccessToken's missing-credential paths, or a typed *errs.*) flow
// through unchanged.
// each one remembering to wrap. WrapDoAPIError classifies a raw transport
// failure into a typed *errs.NetworkError / *errs.InternalError per the
// contract in errs/ERROR_CONTRACT.md. Errors that arrive already-classified
// (a typed *errs.* from resolveAccessToken's missing-credential paths or
// elsewhere) flow through unchanged.
func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
var opts []larkcore.RequestOptionFunc
token, err := c.resolveAccessToken(ctx, as)
if err != nil {
// WrapDoAPIError is idempotent on already-classified errors:
// the *output.ExitError that resolveAccessToken returns for missing
// tokens (via output.ErrAuth) passes through with its auth category
// and exit 3 intact, and any future typed *errs.* error from the
// credential chain survives the same way. Only stray untyped errors
// (raw fmt.Errorf) get the transport-or-internal fallback.
// the typed *errs.AuthenticationError that resolveAccessToken returns
// for missing tokens passes through with its auth category and exit 3
// intact, and any other typed *errs.* error from the credential chain
// survives the same way. Only stray untyped errors (raw fmt.Errorf)
// get the transport-or-internal fallback.
return nil, WrapDoAPIError(err)
}
if as.IsBot() {
@@ -162,7 +153,7 @@ func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as c
// Auth is resolved via Credential (same as DoSDKRequest). Security headers and
// any extra headers from opts are applied automatically.
// HTTP errors (status >= 400) are handled internally: the body is read (up to 4 KB),
// closed, and returned as an output.ErrNetwork — callers only receive successful responses.
// closed, and returned as a typed *errs.NetworkError — callers only receive successful responses.
func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.Identity, opts ...Option) (*http.Response, error) {
cfg := buildConfig(opts)
@@ -332,10 +323,10 @@ func (c *APIClient) DoAPI(ctx context.Context, request RawApiRequest) (*larkcore
//
// JSON parse failures are wrapped via WrapJSONResponseParseError so callers
// (notably the pagination loop and --page-all paths in cmd/api / cmd/service)
// see an *output.ExitError envelope (api_error for malformed JSON, network
// for everything else) instead of a bare fmt.Errorf — otherwise an empty
// or malformed page body would surface to the root handler as a plain-text
// "Error: ..." line and bypass the JSON stderr envelope contract.
// see a typed *errs.InternalError (invalid_response) instead of a bare
// fmt.Errorf — otherwise an empty or malformed page body would surface to the
// root handler as a plain-text "Error: ..." line and bypass the JSON stderr
// envelope contract.
func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) {
resp, err := c.DoAPI(ctx, request)
if err != nil {
@@ -350,7 +341,7 @@ func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interfa
// paginateLoop runs the core pagination loop. For each successful page (code == 0),
// it calls onResult if non-nil. It always accumulates and returns all raw page results.
func (c *APIClient) paginateLoop(ctx context.Context, request RawApiRequest, opts PaginationOptions, onResult func(interface{})) ([]interface{}, error) {
func (c *APIClient) paginateLoop(ctx context.Context, request RawApiRequest, opts PaginationOptions, onResult func(interface{}) error) ([]interface{}, error) {
var allResults []interface{}
var pageToken string
page := 0
@@ -399,7 +390,9 @@ func (c *APIClient) paginateLoop(ctx context.Context, request RawApiRequest, opt
}
if onResult != nil {
onResult(result)
if err := onResult(result); err != nil {
return allResults, err
}
}
allResults = append(allResults, result)
@@ -452,28 +445,31 @@ func (c *APIClient) PaginateAll(ctx context.Context, request RawApiRequest, opts
// StreamPages fetches all pages and streams each page's list items via onItems.
// Returns the last page result (for error checking), whether any list items were found,
// and any network error. Use this for streaming formats (ndjson, table, csv).
func (c *APIClient) StreamPages(ctx context.Context, request RawApiRequest, onItems func([]interface{}), opts PaginationOptions) (result interface{}, hasItems bool, err error) {
func (c *APIClient) StreamPages(ctx context.Context, request RawApiRequest, onItems func([]interface{}) error, opts PaginationOptions) (result interface{}, hasItems bool, err error) {
totalItems := 0
results, loopErr := c.paginateLoop(ctx, request, opts, func(r interface{}) {
results, loopErr := c.paginateLoop(ctx, request, opts, func(r interface{}) error {
resultMap, ok := r.(map[string]interface{})
if !ok {
return
return nil
}
data, ok := resultMap["data"].(map[string]interface{})
if !ok {
return
return nil
}
arrayField := output.FindArrayField(data)
if arrayField == "" {
return
return nil
}
items, ok := data[arrayField].([]interface{})
if !ok {
return
return nil
}
totalItems += len(items)
onItems(items)
if err := onItems(items); err != nil {
return err
}
hasItems = true
return nil
})
if loopErr != nil {
return nil, false, loopErr

View File

@@ -124,8 +124,9 @@ func TestStreamPages_NonBatchAPI_NoArrayField(t *testing.T) {
Method: "GET",
URL: "/open-apis/contact/v3/users/u123",
As: "bot",
}, func(items []interface{}) {
}, func(items []interface{}) error {
t.Error("onItems should not be called for non-batch API")
return nil
}, PaginationOptions{})
if err != nil {
@@ -168,8 +169,9 @@ func TestStreamPages_BatchAPI_WithArrayField(t *testing.T) {
Method: "GET",
URL: "/open-apis/contact/v3/users",
As: "bot",
}, func(items []interface{}) {
}, func(items []interface{}) error {
streamedItems = append(streamedItems, items...)
return nil
}, PaginationOptions{})
if err != nil {
@@ -189,6 +191,58 @@ func TestStreamPages_BatchAPI_WithArrayField(t *testing.T) {
}
}
func TestStreamPages_OnItemsErrorStopsPagination(t *testing.T) {
apiCalls := 0
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
apiCalls++
if apiCalls == 1 {
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": true,
"page_token": "next",
},
}), nil
}
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "2"}},
"has_more": false,
},
}), nil
})
ac, _ := newTestAPIClient(t, rt)
sentinel := errors.New("stop streaming")
var streamedItems []interface{}
result, hasItems, err := ac.StreamPages(context.Background(), RawApiRequest{
Method: "GET",
URL: "/open-apis/contact/v3/users",
As: "bot",
}, func(items []interface{}) error {
streamedItems = append(streamedItems, items...)
return sentinel
}, PaginationOptions{PageDelay: 0})
if !errors.Is(err, sentinel) {
t.Fatalf("err = %v, want sentinel", err)
}
if result != nil {
t.Fatalf("result = %#v, want nil when callback stops pagination", result)
}
if hasItems {
t.Fatal("hasItems = true, want false when callback stops before returning")
}
if apiCalls != 1 {
t.Fatalf("apiCalls = %d, want early stop after first page", apiCalls)
}
if len(streamedItems) != 1 {
t.Fatalf("streamedItems = %d, want first page only", len(streamedItems))
}
}
func TestPaginateAll_PageLimitStopsPagination(t *testing.T) {
apiCalls := 0
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
@@ -474,8 +528,7 @@ func (f *failingTokenResolver) ResolveToken(_ context.Context, spec credential.T
// TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError pins that
// the missing-token path of resolveAccessToken returns the typed
// *errs.AuthenticationError{Subtype: TokenMissing} rather than the legacy
// *output.ExitError envelope.
// *errs.AuthenticationError{Subtype: TokenMissing}.
func TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{},
@@ -500,24 +553,22 @@ func TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError(t *testing.T
}
}
// needAuthTokenResolver returns *internalauth.NeedAuthorizationError to
// exercise the P1 regression path: a credential chain that signals
// "user must re-authorize" must surface as typed AuthenticationError, not
// fall through to the generic err return which WrapDoAPIError would then
// wrap as NetworkError (the outer-typed dispatcher gate would then skip
// PromoteAuthError and the user would see exit 4 with no auth-login hint).
// needAuthTokenResolver mirrors the production credential chain: the
// missing-UAT case is constructed typed at the source (internal/auth) and
// carries the legacy *NeedAuthorizationError sentinel in its Cause chain. It
// must surface as a typed AuthenticationError and flow through resolveAccessToken
// and WrapDoAPIError unchanged (never mis-classified as NetworkError).
type needAuthTokenResolver struct {
userOpenID string
}
func (f *needAuthTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return nil, &internalauth.NeedAuthorizationError{UserOpenId: f.userOpenID}
return nil, internalauth.NewNeedUserAuthorizationError(f.userOpenID)
}
// TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication
// is the codex P1 regression test: without this branch, the credential
// chain's NeedAuthorizationError would propagate raw and WrapDoAPIError
// would mis-classify it as NetworkError.
// pins that the typed missing-UAT error from the credential chain reaches the
// caller as a typed AuthenticationError with the marker and sentinel intact.
func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{},
@@ -623,7 +674,7 @@ func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) {
// *errs.InternalError{Subtype: invalid_response} with the rawAPIJSONHint
// preserved on Problem.Hint. Pagination / cmd/api / cmd/service callers see
// the typed JSON stderr envelope (exit 5/internal) — wire `type` is
// "internal", not the legacy "api_error".
// "internal".
func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) {
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
return &http.Response{

View File

@@ -4,7 +4,6 @@
package client
import (
"context"
"fmt"
"io"
@@ -19,33 +18,6 @@ type PaginationOptions struct {
Identity core.Identity // identity passed to checkErr; defaults to AsUser when empty
}
// PaginateWithJq aggregates all pages, checks for API errors, then applies a jq filter.
// If checkErr detects an error, the raw result is printed as JSON before returning the error.
func PaginateWithJq(ctx context.Context, ac *APIClient, request RawApiRequest,
jqExpr string, out io.Writer, pagOpts PaginationOptions,
checkErr func(interface{}, core.Identity) error) error {
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return err
}
// Identity resolution honors pagOpts.Identity first, then the request's
// own identity, and only falls back to AsUser when neither caller
// supplied one. Without checking request.As, bot/auto requests would
// always be classified as user identity for checkErr.
identity := pagOpts.Identity
if identity == "" {
identity = request.As
}
if identity == "" || identity == core.AsAuto {
identity = core.AsUser
}
if apiErr := checkErr(result, identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return apiErr
}
return output.JqFilter(out, result, jqExpr)
}
func mergePagedResults(w io.Writer, results []interface{}) interface{} {
if len(results) == 0 {
return map[string]interface{}{}

View File

@@ -41,6 +41,26 @@ type ResponseOptions struct {
CheckError func(result interface{}, identity core.Identity) error
}
// httpStatusError classifies an HTTP error response by status when the body
// carries no usable business error: 5xx → NetworkError (server tier), 404 →
// APIError/not_found, any other 4xx → APIError/unknown. Used wherever a
// status >= 400 must not be swallowed — a non-JSON body, an unparseable body,
// or a JSON body whose business code is 0.
func httpStatusError(status int, rawBody []byte) error {
body := util.TruncateStrWithEllipsis(strings.TrimSpace(string(rawBody)), 500)
if status >= 500 {
return errs.NewNetworkError(errs.SubtypeNetworkServer,
"HTTP %d: %s", status, body).
WithCode(status)
}
subtype := errs.SubtypeUnknown
if status == 404 {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "HTTP %d: %s", status, body).
WithCode(status)
}
// HandleResponse routes a raw *larkcore.ApiResp to the appropriate output:
// 1. If Content-Type is JSON, check for business errors first (even with --output).
// 2. If --output is set and response is not a JSON error, save to file.
@@ -62,50 +82,64 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
}
}
// Non-JSON error responses (e.g. 404 text/plain from gateway): return error directly
// instead of falling through to the binary-save path.
// 5xx → typed NetworkError (server/transport tier); 4xx → typed APIError (client error).
// Non-JSON error responses (e.g. 404 text/plain from gateway): return error
// directly instead of falling through to the binary-save path.
if resp.StatusCode >= 400 && !IsJSONContentType(ct) && ct != "" {
body := util.TruncateStrWithEllipsis(strings.TrimSpace(string(resp.RawBody)), 500)
if resp.StatusCode >= 500 {
return errs.NewNetworkError(errs.SubtypeNetworkServer,
"HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode)
}
subtype := errs.SubtypeUnknown
if resp.StatusCode == 404 {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode)
return httpStatusError(resp.StatusCode, resp.RawBody)
}
// JSON responses: always check for business errors before saving.
if IsJSONContentType(ct) || ct == "" {
result, err := ParseJSONResponse(resp)
if err != nil {
// An unparseable / empty body on an HTTP error (common with a
// missing Content-Type) must be classified by status, not reported
// as an internal decode failure, matching the non-JSON branch above.
if resp.StatusCode >= 400 {
return httpStatusError(resp.StatusCode, resp.RawBody)
}
return WrapJSONResponseParseError(err, resp.RawBody)
}
if apiErr := check(result, identity); apiErr != nil {
return apiErr
}
// Content safety scanning
scanResult := output.ScanForSafety(opts.CommandPath, result, opts.ErrOut)
if scanResult.Blocked {
return scanResult.BlockErr
// CheckResponse treats business code 0 as success, so a 4xx/5xx whose
// JSON body omits a non-zero code would otherwise be served as a
// successful result. Classify by HTTP status so it is never swallowed.
if resp.StatusCode >= 400 {
return httpStatusError(resp.StatusCode, resp.RawBody)
}
if opts.OutputPath != "" {
// File downloads keep the existing raw-response scan path because the
// saved payload is the API response body, not the success envelope.
scanResult := output.ScanForSafety(opts.CommandPath, result, opts.ErrOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(opts.ErrOut, scanResult.Alert)
}
return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out)
}
if opts.JqExpr != "" || opts.Format == output.FormatJSON {
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: opts.CommandPath,
Identity: string(identity),
JqExpr: opts.JqExpr,
Out: opts.Out,
ErrOut: opts.ErrOut,
})
}
// Content safety scanning for non-JSON presentation formats.
scanResult := output.ScanForSafety(opts.CommandPath, result, opts.ErrOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(opts.ErrOut, scanResult.Alert)
}
if opts.JqExpr != "" {
return output.JqFilter(opts.Out, result, opts.JqExpr)
}
output.FormatValue(opts.Out, result, opts.Format)
return nil
}

View File

@@ -5,6 +5,7 @@ package client
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
@@ -16,6 +17,7 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
@@ -207,15 +209,54 @@ func TestHandleResponse_JSON(t *testing.T) {
var out bytes.Buffer
var errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{
Out: &out,
ErrOut: &errOut,
FileIO: &localfileio.LocalFileIO{},
Identity: core.AsBot,
Out: &out,
ErrOut: &errOut,
FileIO: &localfileio.LocalFileIO{},
})
if err != nil {
t.Fatalf("HandleResponse failed: %v", err)
}
if !bytes.Contains(out.Bytes(), []byte(`"code"`)) {
t.Errorf("expected JSON output, got: %s", out.String())
var got map[string]interface{}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, out.String())
}
if got["ok"] != true {
t.Fatalf("ok = %v, want true; output: %s", got["ok"], out.String())
}
if got["identity"] != "bot" {
t.Fatalf("identity = %v, want bot; output: %s", got["identity"], out.String())
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("success envelope leaked outer code field: %s", out.String())
}
data, ok := got["data"].(map[string]interface{})
if !ok {
t.Fatalf("data = %T, want object; output: %s", got["data"], out.String())
}
if data["id"] != "1" {
t.Fatalf("data.id = %v, want 1; output: %s", data["id"], out.String())
}
}
func TestHandleResponse_JSONWithJqUsesSuccessEnvelope(t *testing.T) {
body := []byte(`{"code":0,"msg":"ok","data":{"id":"1"}}`)
resp := newApiResp(body, map[string]string{"Content-Type": "application/json"})
var out bytes.Buffer
var errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{
Identity: core.AsBot,
JqExpr: ".data.id",
Out: &out,
ErrOut: &errOut,
FileIO: &localfileio.LocalFileIO{},
})
if err != nil {
t.Fatalf("HandleResponse failed: %v", err)
}
if strings.TrimSpace(out.String()) != "1" {
t.Fatalf("jq output = %q, want %q", out.String(), "1")
}
}
@@ -233,6 +274,12 @@ func TestHandleResponse_JSONWithError(t *testing.T) {
if err == nil {
t.Error("expected error for non-zero code")
}
if _, ok := errs.ProblemOf(err); !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if strings.Contains(out.String(), `"ok": true`) || strings.Contains(out.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", out.String())
}
}
func TestHandleResponse_BinaryAutoSave(t *testing.T) {
@@ -325,6 +372,76 @@ func TestHandleResponse_NonJSONError_502(t *testing.T) {
}
}
// TestHandleResponse_JSONErrorWithZeroBodyCodeNotSwallowed pins that an HTTP
// status error whose JSON body omits a non-zero business code (e.g. 400 +
// {"code":0,...}) still surfaces a typed error. CheckResponse treats code 0 as
// success, so without the HTTP-status fallback a 4xx would be served as a
// successful result and exit 0.
func TestHandleResponse_JSONErrorWithZeroBodyCodeNotSwallowed(t *testing.T) {
resp := newApiRespWithStatus(400, []byte(`{"code":0,"msg":"bad request"}`),
map[string]string{"Content-Type": "application/json"})
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
if err == nil {
t.Fatalf("HTTP 400 with code:0 body must not be swallowed; got out=%q err=nil", out.String())
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Errorf("expected *errs.APIError, got %T", err)
}
if !strings.Contains(err.Error(), "HTTP 400") {
t.Errorf("expected 'HTTP 400' in error, got: %s", err.Error())
}
if output.ExitCodeOf(err) != output.ExitAPI {
t.Errorf("expected ExitAPI (%d), got %d", output.ExitAPI, output.ExitCodeOf(err))
}
}
// TestHandleResponse_NoContentTypeError_404 pins that a 404 with an empty body
// and no Content-Type header — which falls into the JSON branch and fails to
// parse — is classified by HTTP status (api/not_found), not reported as an
// internal decode failure.
func TestHandleResponse_NoContentTypeError_404(t *testing.T) {
resp := newApiRespWithStatus(404, []byte(""), nil)
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
if err == nil {
t.Fatal("expected error for 404 with empty body and no Content-Type")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Errorf("expected *errs.APIError, got %T", err)
}
if apiErr != nil && apiErr.Subtype != errs.SubtypeNotFound {
t.Errorf("subtype = %q, want not_found", apiErr.Subtype)
}
if output.ExitCodeOf(err) != output.ExitAPI {
t.Errorf("expected ExitAPI (%d), got %d", output.ExitAPI, output.ExitCodeOf(err))
}
}
// TestHandleResponse_NoContentTypeError_502 pins that a 5xx with a non-JSON
// body and no Content-Type is classified as a NetworkError by status, not an
// internal decode failure.
func TestHandleResponse_NoContentTypeError_502(t *testing.T) {
resp := newApiRespWithStatus(502, []byte("<html>Bad Gateway</html>"), nil)
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
if err == nil {
t.Fatal("expected error for 502 with non-JSON body and no Content-Type")
}
var netErr *errs.NetworkError
if !errors.As(err, &netErr) {
t.Errorf("expected *errs.NetworkError, got %T", err)
}
if output.ExitCodeOf(err) != output.ExitNetwork {
t.Errorf("expected ExitNetwork (%d) for 5xx, got %d", output.ExitNetwork, output.ExitCodeOf(err))
}
}
func TestHandleResponse_200TextPlain_SavesFile(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()

View File

@@ -34,10 +34,24 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
)
// domainAnnotationKey is the cobra Annotation key for the business domain.
// Kept distinct from cmdutil.* keys so this package can evolve without
// disturbing existing readers.
const domainAnnotationKey = "cmdmeta.domain"
// Source identifies how a command entered the repository-owned command tree.
type Source string
const (
SourceBuiltin Source = "builtin"
SourceShortcut Source = "shortcut"
SourceService Source = "service"
)
const (
// domainAnnotationKey is the cobra Annotation key for the business domain.
// Kept distinct from cmdutil.* keys so this package can evolve without
// disturbing existing readers.
domainAnnotationKey = "cmdmeta.domain"
sourceAnnotationKey = "cmdmeta.source"
generatedAnnotationKey = "cmdmeta.generated"
)
// Meta groups the three command-level metadata axes consumed by the policy
// engine and hook selectors.
@@ -93,6 +107,24 @@ func SetDomain(cmd *cobra.Command, domain string) {
cmd.Annotations[domainAnnotationKey] = domain
}
// SetSource stores the command source on a single command. The generated flag
// is written explicitly so child commands can opt out of inherited service
// metadata.
func SetSource(cmd *cobra.Command, source Source, generated bool) {
if source == "" {
return
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[sourceAnnotationKey] = string(source)
if generated {
cmd.Annotations[generatedAnnotationKey] = "true"
} else {
cmd.Annotations[generatedAnnotationKey] = "false"
}
}
// Domain returns the nearest-ancestor domain for the command. Empty string
// when no ancestor has the annotation -- this is the "unknown" state the
// policy engine must treat as ALLOW.
@@ -108,6 +140,33 @@ func Domain(cmd *cobra.Command) string {
return ""
}
// SourceOf returns the nearest-ancestor command source.
func SourceOf(cmd *cobra.Command) (Source, bool) {
for c := cmd; c != nil; c = c.Parent() {
if c.Annotations == nil {
continue
}
if v := c.Annotations[sourceAnnotationKey]; v != "" {
return Source(v), true
}
}
return "", false
}
// Generated returns the nearest generated annotation. An explicit false on a
// child command stops inheritance from a generated parent.
func Generated(cmd *cobra.Command) bool {
for c := cmd; c != nil; c = c.Parent() {
if c.Annotations == nil {
continue
}
if v, ok := c.Annotations[generatedAnnotationKey]; ok {
return v == "true"
}
}
return false
}
// Risk returns the nearest-ancestor risk level (via cmdutil.GetRisk).
// ok=false signals "unknown" -- the policy engine treats this as
// fail-closed (deny with risk_not_annotated) whenever a Rule without

View File

@@ -141,3 +141,19 @@ func TestSetDomain_emptyIsNoop(t *testing.T) {
t.Fatalf("Domain(child) = %q, want inherited 'docs'", got)
}
}
func TestSourceGenerated_childFalseStopsParentGeneratedInheritance(t *testing.T) {
parent := &cobra.Command{Use: "docs"}
child := &cobra.Command{Use: "+fetch"}
parent.AddCommand(child)
cmdmeta.SetSource(parent, cmdmeta.SourceService, true)
cmdmeta.SetSource(child, cmdmeta.SourceShortcut, false)
if source, ok := cmdmeta.SourceOf(child); !ok || source != cmdmeta.SourceShortcut {
t.Fatalf("SourceOf(child) = (%q,%v), want (shortcut,true)", source, ok)
}
if cmdmeta.Generated(child) {
t.Fatal("Generated(child) = true, want false")
}
}

View File

@@ -4,13 +4,13 @@
package cmdpolicy_test
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -168,10 +168,15 @@ func TestBuildDeniedByPath_hybridParentOwnAllowedKeepsAlive(t *testing.T) {
}
}
// Apply with the wrapped *output.ExitError exposes BOTH paths consumers
// rely on:
// 1. cmd/root.go's envelope writer (errors.As on *output.ExitError)
// 2. in-process consumers extracting the platform.CommandDeniedError
// Apply returns a typed *errs.ValidationError that exposes BOTH paths
// consumers rely on:
// 1. cmd/root.go's envelope writer (errs.ProblemOf / failed_precondition
// subtype + exit code 2)
// 2. in-process consumers extracting the platform.CommandDeniedError as
// the typed error's Cause via errors.As
//
// The policy metadata (layer / policy_source / rule_name / reason_code)
// is folded into the Hint text rather than a separate detail map.
func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
root := buildTree()
denied := map[string]cmdpolicy.Denial{
@@ -191,31 +196,33 @@ func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
t.Fatalf("denied command should return error")
}
// Path 1: envelope-writer view.
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error chain must contain *output.ExitError, got %T", err)
// Path 1: typed-envelope view. The denial is a failed_precondition
// ValidationError so cmd/root.go renders the structured envelope and
// the process exits 2 (ExitValidation).
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error chain must contain *errs.ValidationError, got %T", err)
}
if exitErr.Detail == nil {
t.Fatalf("ExitError.Detail required for envelope to render")
if ve.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition)
}
if exitErr.Detail.Type != "command_denied" {
t.Errorf("envelope error.type = %q, want command_denied", exitErr.Detail.Type)
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want ExitValidation (%d)", code, output.ExitValidation)
}
// JSON envelope shape: detail.reason_code must be present and
// match the closed enum.
detailMap, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("envelope detail should be map[string]any, got %T", exitErr.Detail.Detail)
// The policy metadata is folded into the Hint text: reason_code,
// policy_source, and rule_name must all be discoverable there.
if !strings.Contains(ve.Hint, "write_not_allowed") {
t.Errorf("hint must carry reason_code write_not_allowed, got %q", ve.Hint)
}
if detailMap["reason_code"] != "write_not_allowed" {
t.Errorf("detail.reason_code = %v, want write_not_allowed", detailMap["reason_code"])
if !strings.Contains(ve.Hint, "plugin:secaudit") {
t.Errorf("hint must carry policy_source plugin:secaudit, got %q", ve.Hint)
}
if detailMap["policy_source"] != "plugin:secaudit" {
t.Errorf("detail.policy_source = %v, want plugin:secaudit", detailMap["policy_source"])
if !strings.Contains(ve.Hint, "secaudit-policy") {
t.Errorf("hint must carry rule_name secaudit-policy, got %q", ve.Hint)
}
// Path 2: in-process typed-error view.
// Path 2: in-process typed-error view -- the *platform.CommandDeniedError
// is preserved as the Cause so errors.As still reaches it.
var cd *platform.CommandDeniedError
if !errors.As(err, &cd) {
t.Fatalf("error chain must expose *platform.CommandDeniedError")
@@ -223,21 +230,6 @@ func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
if cd.Path != "docs/+update" || cd.ReasonCode != "write_not_allowed" {
t.Errorf("CommandDeniedError = %+v", cd)
}
// Envelope round-trip sanity (the actual JSON cmd/root.go would emit).
var buf strings.Builder
output.WriteErrorEnvelope(&buf, exitErr, "user")
if !strings.Contains(buf.String(), `"type": "command_denied"`) {
t.Errorf("envelope JSON missing type=command_denied, got:\n%s", buf.String())
}
if !strings.Contains(buf.String(), `"reason_code": "write_not_allowed"`) {
t.Errorf("envelope JSON missing reason_code, got:\n%s", buf.String())
}
// Round-trip parse to verify it's well-formed JSON.
var parsed map[string]any
if err := json.Unmarshal([]byte(buf.String()), &parsed); err != nil {
t.Fatalf("envelope JSON malformed: %v\n%s", err, buf.String())
}
}
// Regression: a pure parent group carrying AnnotationPureGroup must be

View File

@@ -6,8 +6,8 @@ package cmdpolicy
import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/output"
)
// Apply walks the command tree and installs denyStubs for every path in
@@ -24,12 +24,11 @@ import (
// cobra would intercept the call
// with "missing required flag"
// before we can return our error
// 3. cmd.RunE = denyStub(denial) -- returns *output.ExitError so
// 3. cmd.RunE = denyStub(denial) -- returns a typed
// *errs.ValidationError so
// cmd/root.go's envelope writer
// emits structured JSON (with
// error.type = denial.Layer and
// detail.reason_code = ReasonCode);
// the wrapped error chain still
// emits structured JSON; the
// wrapped error chain still
// exposes *platform.CommandDeniedError
// via errors.As for in-process
// consumers
@@ -112,42 +111,17 @@ func CommandDeniedFromDenial(path string, d Denial) *platform.CommandDeniedError
}
}
// DenialDetailMap is the canonical detail.* shape every `command_denied`
// envelope shares (see docs/extension/reason-codes.md). Use it as
// ErrDetail.Detail when constructing an envelope outside BuildDenialError.
func DenialDetailMap(cd *platform.CommandDeniedError) map[string]any {
return map[string]any{
"path": cd.Path,
"layer": cd.Layer,
"policy_source": cd.PolicySource,
"rule_name": cd.RuleName,
"reason_code": cd.ReasonCode,
"reason": cd.Reason,
}
}
// BuildDenialError is the default envelope for user-layer denials:
// Message comes from CommandDeniedError.Error(), no Hint. Callers that
// need a custom Message or an independent Hint (strict-mode) should
// compose CommandDeniedFromDenial + DenialDetailMap themselves.
//
// Deprecated: BuildDenialError produces a legacy *output.ExitError that
// predates the typed error contract introduced by errs/. New code MUST NOT
// use it — denial signals should move to a typed *errs.XxxError (a dedicated
// typed Error for policy denial is tracked for the cmdpolicy migration PR).
// This helper is retained only while existing call sites are migrated; it
// will be removed once they have moved to the typed surface.
func BuildDenialError(path string, d Denial) *output.ExitError {
// BuildDenialError is the default typed error for user-layer denials:
// Message comes from CommandDeniedError.Error(); the policy layer, source,
// rule name, and reason code are folded into the Hint. The
// *platform.CommandDeniedError is preserved as the Cause so errors.As
// works for in-process consumers.
func BuildDenialError(path string, d Denial) *errs.ValidationError {
cd := CommandDeniedFromDenial(path, d)
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "command_denied",
Message: cd.Error(),
Detail: DenialDetailMap(cd),
},
Err: cd,
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", cd.Error()).
WithHint("denied by %s policy (source %s, rule %q, reason_code %s); adjust the policy configuration to allow this command",
cd.Layer, cd.PolicySource, cd.RuleName, cd.ReasonCode).
WithCause(cd)
}
// installDenyStub mutates a cobra.Command in place. Unlike cmd/prune.go
@@ -221,9 +195,9 @@ func installDenyStub(cmd *cobra.Command, path string, d Denial) bool {
denial := d // capture by value for the closure
cmd.RunE = func(c *cobra.Command, args []string) error {
// error.type is the user-facing semantic ("a command was denied by
// policy"). detail.layer carries the implementation distinction
// ("policy" vs "strict_mode") for debugging.
// The typed message carries the user-facing semantic ("a command
// was denied"); the hint carries the layer / source / rule
// distinction ("policy" vs "strict_mode") for debugging.
return BuildDenialError(path, denial)
}
// Clear any pre-existing Run hook: cobra prefers RunE when both are

View File

@@ -9,9 +9,9 @@
// aggregation), which the Apply step consumes to install denyStubs.
//
// This package only implements the user-layer half. Strict-mode is handled
// by cmd/prune.go, which produces command_denied envelopes of the same
// shape via BuildDenialError so external agents can dispatch on
// detail.layer / reason_code uniformly regardless of which layer rejected
// by cmd/prune.go, which produces typed validation errors of the same shape
// (failed_precondition, *platform.CommandDeniedError preserved as Cause) so
// external agents see a uniform envelope regardless of which layer rejected
// the call.
package cmdpolicy

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