Compare commits

...

51 Commits

Author SHA1 Message Date
zhengzhijie.j
c7d7cb03eb feat(sheets): add --skip-filter to cells-get 2026-07-28 19:12:56 +08:00
xiongyuanwen-byted
ca7135f582 fix(sheets): correct +dim-insert --inherit-style side mapping, drop redundant none
The flag name was inverted relative to actual behavior, and `none` was redundant.

- Map --inherit-style onto the modify_sheet_structure backend so the name
  matches behavior (verified on a live sheet): `before` inherits the preceding
  row/column (side=after, position-1); `after` inherits the following
  (side=before). Insertion always lands before --position.
- Warn only when `before` is used at the first row/column (no preceding
  dimension to copy from); `after` has no such edge.
- Drop the `none` enum value: it was identical to omitting the flag and
  misleadingly implied "no inheritance" (the backend always copies a
  neighbour). Omitting the flag inherits the following row/column; `none` is
  now rejected by enum validation. Clear formats afterwards for a blank one.
- Regenerate flag-defs, update tests and the skill reference.
2026-07-24 19:14:18 +08:00
xiongyuanwen-byted
7b58ba1b1d feat(sheets): batch-update contract hardening and style-vocabulary acceptance (#2028)
* feat(sheets): harden +batch-update sub-op contract (P0-1/2/3/5)

Eval-driven fixes for the top +batch-update error clusters (137 errors
across 7 eval batches, attribution in the optimization plan doc):

- Reject unknown sub-op input keys with did-you-mean + full key contract
  instead of silently ignoring them (silent ignore surfaced as misleading
  'missing required flag' errors — the largest cluster, ~35 hits).
  Habitual spellings are rewritten in place: camelCase -> snake_case,
  commandFlagAliases (new: size -> width/height on the resize pair, the
  pre-July vocabulary and the --styles protocol spelling, 15+ hits),
  single-entry ranges unwraps onto range.
- Aggregate per-op validation errors into one pass (each op's first
  error) instead of fail-fast first-error-only; single-error batches
  keep the standalone-shaped error (contract tests unchanged).
- Precheck cells matrix vs range locally: empty cells (prescribes
  +cells-clear) and row/column count mismatches no longer reach the
  server mid-batch.
- Correct the atomicity story: execution is fail-fast without rollback
  (verified against live batches; docs promised a rollback that does
  not happen). Partial-failure errors now spell out that succeeded
  operations stay applied and prescribe resending only the failed
  tail, preventing double-apply on retry.

* feat(cli): accept @file payload reads from the system temp dir (P0-6)

Agents stage generated payloads (batch operations JSON, CSV) in /tmp as
a matter of course; the relative-to-cwd-only policy pushed every
--operations @/tmp/ops.json through an extra python/stdin round trip
(recurring friction cluster in eval traces).

Scope is deliberately narrow: a new SafeTempAbsInputPath accepts an
absolute READ path only when it resolves (symlinks included) under the
canonical os.TempDir(), and only the @file expansion in
cmdutil.ReadInputFile uses it. SafeInputPath stays strict — uploads,
drive sync and the CI quality gates treat 'absolute paths rejected' as
a load-bearing invariant. Absolute paths outside the temp dir now get
a prescriptive error naming all three options (relative path, temp-dir
path, stdin).

* feat(sheets): add +styles-put, +dim-delete --ranges, freeze in --styles

Batch-B of the +batch-update overhaul (attribution: ~73% of real batch
calls were pure formatting finishers hand-built as operations arrays).

- +styles-put: declarative visual spec for existing spreadsheets.
  Reuses the workbook-create/table-put --styles parser (identical
  vocabulary and aggregate-all-issues errors), expands client-side into
  ONE atomic batch_update per spec: cell_merges -> cell_styles ->
  row_sizes -> col_sizes -> freeze. Style stamps are safe to re-run.
  Verified live: 6/6 sub-ops applied, frozen rows / merges / row
  heights confirmed by read-back.
- freeze section added to the shared --styles pipeline ({rows, cols}),
  so +workbook-create and +table-put gain it too — closes the one gap
  that still forced a separate +dim-freeze call.
- +dim-delete --ranges: scattered row/column ranges in one atomic
  batch, ordered DESCENDING so earlier deletions never shift later
  indexes (the recurring failure of hand-built dim-delete batches);
  same-dimension and non-overlap enforced, nesting inside
  +batch-update rejected with a prescription.
- +cells-batch-set-style enters phase-1 deprecation: kept working,
  docs point at +styles-put, an in-band note steers new usage.

Skill docs regenerated from sheet-skill-spec (new
lark-sheets-styles-put reference, three-way dispatch in guideline 6,
fail-fast-no-rollback wording).

* feat(sheets): accept height/width as one-way aliases for size in --styles row/col_sizes

size stays the canonical dimension key: it keeps row_sizes and col_sizes
items shape-uniform (the array name already carries the dimension), it is
what shipped with workbook-create/table-put and what models demonstrably
converge on, and it matches the dimension-neutral precedent of comparable
APIs. The Excel-vocabulary words are accepted silently only where
unambiguous — height inside row_sizes, width inside col_sizes; the wrong
dimension's word gets a targeted error instead of a rewrite, and giving
both size and the alias is rejected. Shared parser, so +workbook-create /
+table-put / +styles-put all gain it.

* chore(sheets): sync skill docs — +cells-batch-set-style fully exits the skill surface

Regenerated from sheet-skill-spec: the deprecated command no longer
appears anywhere in SKILL.md or the references (multi-range styling
routes to +styles-put); its flag-defs entry is untouched, so the command
and --help keep working for compatibility callers, with the in-band
supersedence note steering them to +styles-put.

* fix(sheets): forgive habitual vocabulary on the --styles payload path

07-20 rerun attribution: the batch-update dispatch worked (calls 105->25,
errors 21->8; +styles-put adopted by 16/35 tasks) but +styles-put itself
hit a 56% stateful error rate — the redesign moved traffic from the flag
path onto the payload path, and the round-2 forgiveness layers (key
aliases, enum-value canonicalization) only existed on the flag path.
Both dominant clusters are fixed in the shared styles pipeline, so
+styles-put / +table-put / +workbook-create / typed --cells all gain it:

- border family folding (largest cluster, up to 88 issues in one retry):
  borders/border objects, border_top..right objects, border_style/color/
  weight scalars, and flattened border_<side>_<attr> keys all fold into
  the canonical nested border_styles; border_style:"thin" reads as a
  thin solid line (weight vocabulary in the style slot).
- enum VALUE canonicalization inside cell_styles (~10 server-side
  round trips: vertical_alignment "center" -> "middle"), sourced from
  the +cells-set-style flag enums; off-enum values now fail client-side
  with a did-you-mean instead of failing the whole batch server-side.
- wrap family: wrap_text/text_wrap -> word_wrap, boolean -> enum.
- bare-string cell_merges entries read as {range, merge_type:all}.
- fore_color gets a prescription (openpyxl fgColor is the FILL color;
  a silent pick could color the wrong thing).

Replayed the eval-failing payload shapes live: 2/2 applied.

* feat(sheets): resize type optional in --styles; acceptance-surface contract tests

- {range, size} in row/col_sizes now means a pixel resize (type stays
  for standard/auto) — the payload path matches the flag path, where
  --width never required --type.
- Two closure tests turn the --styles acceptance surface into a locked
  contract instead of open-ended patching:
  * vocabulary parity — every +cells-set-style flag (iterated from
    flag-defs) must be accepted verbatim by the payload path, so a
    future flag can never again ship without payload-path support;
  * prior corpus — every model spelling observed across the 07-08..07-20
    eval batches must either normalize to canonical or produce a
    targeted prescription; silent ignoring and bare rejection both fail
    the suite. New eval finding -> add a corpus row -> fix -> locked.

Skill docs regenerated: the border shorthand ({style,weight,color} on
all four sides) is now the teaching form, border_styles demoted to
per-side differences; resize examples drop the type ceremony.

* fix(sheets): close the 07-21 rerun residuals — full-form thin, range coalescing, typed-cells style key

Valid rerun (skill injection verified at ~31k chars/task): +styles-put
stateful error rate fell 56.5% -> 34.3% and batch-update stayed at its
post-dispatch low. Three residual clusters, all closed:

- weight vocabulary in the FULL nested form's style slot
  (border_styles.<side>.style:"thin" — 8 tasks, the dominant residual;
  the earlier rewrite only covered the shorthand scalar path). Now
  normalized in expandBorderAllShorthand, the single border touchpoint
  shared by the flag, typed-cells and styles-payload paths.
- per-row specs blowing the 100-op cap (184/203/861-op expansions):
  coalesceStyleStamps fuses identical-style entries into rectangles
  (vertical fixpoint merge on same column span, horizontal on same row
  span) before the cap — a declarative spec describes intent, execution
  shape is the CLI's to optimize. Cap message now also routes
  alternating-row banding to +cond-format-create.
- typed --cells habitual keys (recurring server-side 900015206 in both
  reruns): cells[][].style object rewrites to cell_styles;
  cells[][].type gets a prescription instead of a server round trip.
  The content-in-styles message is also neutral now (was
  workbook-create-specific).

Corpus + coalescing + typed-cells tests added; live replay: full-form
thin accepted and 3 same-style rows fused, all applied.

* refactor(sheets): give the style-vocabulary acceptance layer its own home

Pure mechanical move, zero behavior change (locked by the acceptance
contract tests). The acceptance layer had grown as an accretion across
helpers.go and lark_sheet_workbook.go — deliberate design (one canonical
form + wide acceptance, per the divergent-priors evidence), accidental
placement.

style_vocab.go now holds the whole subsystem — flag-path style builders,
key aliases, enum-value canonicalization, border folding/normalization,
typed-cells cell-object rewrites — under a header that states the design
contract: rewrites must be unambiguous, ambiguity prescribes, silence
and bare rejection are both bugs, closure is enforced by the parity +
prior-corpus tests. helpers.go shrinks back to generic plumbing
(818 -> 542 lines).

The other two acceptance surfaces keep their own homes: cobra flag
ergonomics in flag_ergonomics.go, batch sub-op key vocabulary in
batch_op_dispatch.go.

* fix(sheets): enforce the cells-vs-range match on single-cell ranges too

The precheck deliberately skipped bare single-cell ranges when server
behavior was unverified; the 07-21 rerun supplied the evidence (12 rows
against range "A1" failing server-side with row count 1) — +cells-set
has no anchor semantics, the strict match applies everywhere. Corpus
updated accordingly.

* feat(sheets): make +csv-get --range optional — omitted reads the whole sheet

The tool requires a range but clips past-grid references and reports the
clip in actual_range, so the CLI sends an over-wide whole-columns range
(A:ZZZ) when --range is omitted: one call reads the entire sheet, no
workbook-info pre-flight to size it first. Eval evidence: 'required
flag(s) range not set' was the most-missed required flag on +csv-get
(4 tasks in the 07-21 batch) — the models' intent was always 'read it
all'. Blank --range now means the same as omitting it.

Skill docs updated (quick-reference row, read-data full-read example,
flag desc); round-3 backlog item A5.

* feat(sheets): add editing rule #10 — never fabricate missing values

补齐 / 扩展 / 按原表格式续填时,查不到或无法确定的值一律留空 +
备注注明,禁止用推算 / 估算 / 凭空数据充数;原表已示范缺失值写法时照抄。

Synced from sheet-skill-spec (SoT).

* fix(sheets): accept side-first border word order and wrap_strategy

07-21 evening batch (first run carrying the previous fixes — thin
full-form / style/type keys / csv-get range all at zero): the corpus
loop caught the next spelling permutations. bottom_border /
bottom_border_style (side-first word order, alongside the border_bottom
family already folded) and wrap_strategy (the Google Sheets API word)
now normalize; three corpus rows lock them.

* feat(sheets): universal did-you-mean on unknown style fields; formalize the silent-alias admission bar

The alias table was drifting toward per-permutation entries with fast-
falling marginal value (first aliases covered 15+ errors each, the last
ones 1-2). The asymmetry that matters: rejection is universal, aliases
are per-word. So:

- unknown style fields now reject with did-you-mean + the full canonical
  field list (this error had neither — the actual reason word-order
  permutations turned into multi-issue retry loops). Any future
  permutation costs one self-healing retry, zero new code, and corrects
  the whole session (silent aliases never correct the model in-session).
- the acceptance-layer contract now states the admission bar for silent
  aliases: real external vocabularies only (Excel/openpyxl, CSS, Google
  Sheets API — a finite set), recurring across batches or ≥3 tasks,
  zero ambiguity. Permutations go to the universal rejection. Existing
  permutation aliases are grandfathered.

* feat(sheets): +cells-set --writes — scattered multi-region writes in one atomic call

The last compressible batch-update scenario: eval traces show 'fix all
broken formulas across ranges/sheets' (~6 calls / 3 tasks per batch)
still hand-assembled as +batch-update operations arrays. --writes takes
[{sheet_name|sheet_id, range, cells}, ...] (up to 100 items, cross-sheet)
and fans it into ONE atomic batch_update of set_cell_range ops.

Design decisions:
- the sheet selector LIVES IN EACH ITEM — no top-level fallback, no
  precedence table; same convention models already learned from
  +batch-update sub-ops and +styles-put items. A top-level
  --sheet-name/--sheet-id with --writes is rejected with the fix.
- every item runs the exact standalone pipeline via a per-item flag
  view: key vocabulary (camelCase, aliases, did-you-mean), the style
  acceptance layer for inline cell_styles, matrix precheck, schema
  validation; item errors aggregate so one retry fixes all.
- XOR with --range/--cells/--copy-to-range; top-level --allow-overwrite
  propagates to items that don't override it.
- nesting inside +batch-update rejected (expands into its own batch).
- predictable prior handled: --styles on +cells-set now hints the
  layering (range-level styling -> +styles-put; per-cell styles ride in
  the cells objects) instead of a bare unknown-flag error.

Live smoke: value + formula regions in one call, 2/2 applied.
Expected effect: batch-update calls drop another ~30% to its
heterogeneous-atomic-chain steady state.

* chore(sheets): bump lark-sheets skill version to 3.1.0

Version roll-up for the batch-update optimization series: cells-set
--writes, universal did-you-mean on style fields, border word-order and
wrap_strategy acceptance, editing rule #10, and optional +csv-get
--range.
2026-07-24 13:18:03 +08:00
anunwu-byted
765b097d44 Merge pull request #2027 from larksuite/feat/error-schema-hints
Feat/error schema hints
2026-07-23 16:40:10 +08:00
wuyanchun.anunwu
4a5e2c519a feat(sheets): 校验失败全量收集报错(一次报出所有错误)
为什么:fail-fast 单错报错导致「挤牙膏」修复回路——修一处、重试、
撞下一处。评测实测(turbo 三批 247 次失败)约 32% 的失败轮次是
挤牙膏,其中 local→local 类(39 次)本可一次报出。

怎么改:
- validateAgainstSchema 重构为 collectSchemaErrors 收集器版:命中
  错误后继续遍历,全部问题一次报出(每条带各自的教学信息)
- translateBatchOperations 同步做 op 级聚合:多个坏 op 一条报错
  编号列出,不再 fail-fast 在第一个
- 三条护栏:单错误输出逐字不变(向后兼容);显示 cap 5 条 + 收集
  到 6 即全树短路(病态大数组不爆炸);类型错节点不下钻、oneOf
  用一次性探测器(防级联噪音/误报泄漏)

验证:gofmt/vet 干净,go test -count=1 全过(既有测试零改动,
新增 5 个聚合测试:多错编号、cap 截断、oneOf 不泄漏、batch 双 op
聚合、单 op 保持原文)。
2026-07-20 21:11:00 +08:00
xiongyuanwen-byted
67fc870582 feat(sheets): add --output-path for full-read file offload on cells/csv/table-get
Reads are capped by max_chars (default 500000; the backend tool also truncates
at ~50000 when unset). Add --output-path to +cells-get / +csv-get / +table-get:
when set, the result is written to a cwd-relative path as JSON and the char cap
is lifted to unbounded, so a large sheet lands on disk in full instead of being
clipped for stdout.

+table-get previously never sent max_chars, so it silently dropped rows past the
backend ~50000 default with no signal. It now takes --max-chars (default 500000,
sent explicitly) and surfaces truncated / truncation_warning when the read is
clipped, steering callers to --output-path for a lossless full read.
2026-07-20 11:26:46 +08:00
xiongyuanwen-byted
af8e027269 feat(sheets): support --include truncation on +cells-get
Map the new `truncation` value in --include to include_truncation_info on
the get_cell_ranges tool input, so +cells-get can return per-cell
isRowTruncated / isColTruncated. Flag metadata and reference synced from
sheet-skill-spec; flag_defs_gen.go regenerated.
2026-07-20 11:26:46 +08:00
xiongyuanwen-byted
2efadec335 feat(sheets): cut agent error rate and --help lookups (#1911)
## Background

Round 2 of eval-driven sheets optimization, rebased onto the latest `feat/lark-sheets-develop` (`8897196d`).

## Changes

- **feat(sheets): cut agent error rate and --help lookups (eval round 2)** — targets the top failure modes from round 2 evals, reducing agent error rate and the number of `--help` lookups.
- **chore(sheets): sync skill docs and flag data from sheet-skill-spec** — syncs skill docs and flag data from sheet-skill-spec.

## Notes

- During rebase, the "import mislabeled .xls workbooks by sniffing content" fix already existed on the target branch (identical patch-id), so it was auto-skipped — no duplicate.
- The target branch was force-rewritten and advanced in the meantime; the two new commits were cleanly replayed onto the new tip via `--onto` with no conflicts. One hunk touching the `--type` description in `lark-sheets-workbook.md` was auto-dropped because upstream already has the same end state — no content lost.
2026-07-20 11:26:46 +08:00
zhanghuanxu
44514ad114 fix(slides): detect visual elements outside canvas 2026-07-19 21:45:42 +08:00
liangshuo-1
4a56748bfa chore: release v1.0.72 (#1943) 2026-07-17 19:43:46 +08:00
luozhixiong01
0b6faa01bf ci: deduplicate PR runs and serialize live E2E (#1888)
* ci: deduplicate PR runs and serialize live E2E

* ci: preserve live E2E cleanup on supersession

* ci: harden live E2E supersession check

* ci: gate live E2E on dry-run planning

Make the dry-run result a hard prerequisite for live E2E so skip-mode changes never acquire the repository-wide slot. This intentionally trades one full dry-run duration of live startup latency for lower contention on the exclusive queue.

* ci: bound dry-run E2E planning

The dry-run job is now a hard prerequisite for live E2E. Bound its
execution so a stalled planning job cannot delay a PR verdict for the
default six-hour job limit.

* test: tighten live E2E supersession contract
2026-07-17 19:37:48 +08:00
LightsDancer
1efe2dfb33 feat(approval): support approval event consumption (#1924)
Register approval.instance.status_changed_v4 and approval.task.status_changed_v4 with custom flattened schemas and user-auth pre-consume subscription setup.

Handle approval subscription_type as optional multi-value pre-registration metadata: omitted values register both involved and managed relations, explicit values can be single, comma-separated, or JSON array, and consumers do not unsubscribe on exit.

Report partial approval subscription registration failures with registered and failed relation context while preserving the underlying typed error classification.

Document approval event output fields and subscription semantics, and refresh approval skill references from API metadata.
2026-07-17 18:38:29 +08:00
luozhixiong01
767386cb57 fix: stabilize drive delete E2E terminal-state checks (#1939)
* fix: converge drive delete workflow test on terminal state

* fix: narrow drive delete tolerance to the verified transient

* test: lock the delete failure guard with a subprocess contract test

* test: lock task-result and retry-exhaustion failure boundaries
2026-07-17 18:00:38 +08:00
luozhixiong01
e71c76155e test: fix drive cover download retries (#1934) 2026-07-17 17:53:01 +08:00
luozhixiong01
c363acf94e test: use tri-state wiki node identity in delete verification (#1931)
A get_node success response may omit data.node/node_token (the field is
optional), so a missing token must not be read as proof of deletion.
Classify the response as same / different / unknown: only a different
non-empty node_token proves the original node is gone (move-to-drive),
while an unknown identity keeps polling in isWikiNodeDeleted and still
attempts deletion in deleteWikiNodeAndVerify instead of leaking nodes.
2026-07-17 17:52:12 +08:00
zhanghuanxu
05285bb696 feat(slides): report resolved table size mismatches 2026-07-17 17:29:39 +08:00
zhanghuanxu
4c0f93bd6a feat(slides):lint table out of canvas 2026-07-17 17:29:39 +08:00
Yuxuan Zhao
76ebd49382 test: stabilize live e2e auth retries (#1904)
* test: stabilize live e2e auth retries

* fix(e2e): scope shared tenant credentials
2026-07-17 17:20:45 +08:00
zhengzhijiej-tech
6c14c425fc docs(sheets): use English placeholder in table-get guidance (#1936) 2026-07-17 17:01:22 +08:00
calendar-assistant
27df16d3b2 fix(vc): don't fail +detail for in-progress meetings (#1930)
An ongoing meeting has no minute/note yet, so the recording lookup in
+detail returned an unclassified error that was surfaced as a hard error,
making the whole command exit 1 / ok:false even though meeting.get had
already succeeded.

Detect the in-progress state up front (same start/end heuristic as
+meeting-events, reading raw timestamps) and skip the recording call,
returning the meeting metadata with an informational hint instead of an
error. Recording failures for ended meetings are likewise degraded to a
hint rather than failing the command.

Also note in the vc-agent skill that sending an in-meeting message only
needs meeting_id and must not pre-fetch +detail / +recording / +notes.
2026-07-17 15:54:26 +08:00
zgz2048
47dc003601 docs: document base field default values (#1500)
* docs: document base field default values

* docs(base): update field default value schema
2026-07-17 15:17:01 +08:00
zhanghuanxu
4e0a6a988c docs(slides): document table dimensions 2026-07-17 11:36:53 +08:00
liangshuo-1
708196040a chore: release v1.0.71 (#1919) 2026-07-16 20:34:43 +08:00
yballul-bytedance
65586577a3 feat(drive): add secure label support and clarify comment location API (#1913)
Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-16 18:09:38 +08:00
wangweiming-01
be1f3621de perf(drive): optimize drive +delete workflow (#1909) 2026-07-16 16:21:37 +08:00
chenxingyang1019
65998a21e3 docs(apps): add platform SQL authoring guide to the db-execute skill (#1912)
* docs(apps): add platform SQL authoring guide to the db-execute skill

Aligns the lark-cli apps +db-execute skill with the Miaoda platform's
SQL constraints (the same dataloom backend the sandbox miaoda-sql skill
targets), so agents writing SQL via the CLI don't get server-rejected or
build tables that misbehave. Previously the skill covered only the command
contract with zero SQL-content guidance.

Adds a "平台 SQL 规范" section to lark-apps-db-execute.md covering:
- Platform-forbidden SQL (DATABASE/SCHEMA/USER/ROLE/OWNED) that hard-rejects
- CREATE TABLE template: 4 audit columns + RLS + 4 default policies
- user_profile compound type (ROW()::user_profile, (field).user_id, index)
- Audit column names and semantics
- DDL rules: IF NOT EXISTS support matrix; pre-check online rows before
  adding constraints, split by UNIQUE / tighten-to-NOT-NULL / new-NOT-NULL-column
- SELECT / DML safety rules and common PostgreSQL pitfalls

Sandbox-specific bits (miaoda command names, generate_image, test-user
list, schema.ts codegen, string error codes) are intentionally excluded.
Wires pointers from SKILL.md routing and lark-apps-db.md.

* docs(apps): address review nits on the db-execute SQL guide

- Drop the IF NOT EXISTS support matrix (redundant / conflicted with the
  extension-allowlist note in the callout).
- Use `orders` instead of the built-in composite type `user_profile` as the
  bare-table-name example, which was misleading.
- Remove the "user_profile PRIMARY KEY" suggestion for person tables (the
  composite carries mutable fields; a uuid PK is the right default).
2026-07-16 16:05:26 +08:00
zhouyue-bytedance
d5afe3f705 fix(base): improve dashboard shortcut guidance (#1787)
* fix(base): improve dashboard shortcut guidance

* docs(base): refine dashboard funnel guidance

* docs(base): drop redundant block-get audit tip

The 'do not audit every block after creation' hint duplicates the
create-then-suppress-get guidance already in lark-base-dashboard.md,
so remove it from the +dashboard-block-get tips to keep them focused.

* test(base): drop stale block-get audit tip assertion

Commit 778da63a removed the 'do not audit every block' tip from the
+dashboard-block-get source as redundant but left the matching
assertion in TestBaseDashboardHelpGuidesAgents, breaking the unit
test. Remove the stale assertion to realign the test with the tips.

* docs(base): clarify when NOT to use helper table for dashboard blocks

* fix(base): defer record-list --json to framework shorthand (align with main)

* fix(base): reject non-string dashboard sort.order instead of silently defaulting to asc

* docs(base): fix reversed cumulative-funnel direction (suffix sum + assumptions)

* docs(base): scope dashboard-arrange to explicit request or fresh new dashboard

* test(base): pin missing sort.order behavior; clarify --no-validate is raw pass-through

* docs(base): show real CLI envelope {ok,identity,data} for get-data and data-query outputs
2026-07-16 15:10:15 +08:00
linchao5102
baf6050f8e feat(apps): add role management shortcuts (#1881) 2026-07-16 14:09:41 +08:00
zhaojunlin0405
a6bc81596a ci: add L4 plugin-integration and sidecar-integration CI jobs (#1840) 2026-07-16 13:47:11 +08:00
liujinkun2025
7f43b7ed5d feat: add wiki move-to-drive shortcut (#1869)
* feat: add wiki move-to-drive shortcut
2026-07-16 11:00:28 +08:00
liangshuo-1
80b3645362 chore: release v1.0.70 (#1905) 2026-07-15 21:13:37 +08:00
zhicong666-bytedance
64caef1526 fix(vc): align meeting query scopes by identity (#1850)
* fix(vc): align meeting query scopes by identity

* docs(vc): simplify meeting query scope guidance

* fix: align meeting query scopes by identity

* fix: harden vc meeting query scope preflight

* test: assert vc meeting query permission category

* fix: declare empty vc meeting query scopes

* fix: align vc meeting query scope metadata

* docs: simplify vc meeting query scope guidance

* fix: preflight vc meeting query tat scopes

* fix: make vc scope metadata lookup best effort

* fix(vc): accept compatible meeting query scopes

* test(vc): cover meeting query validate scope checks

* refactor(vc): align meeting query precheck with framework

* fix(vc): clarify meeting query scope recovery

* docs(vc): use gray access as permission fallback

* fix(vc): use user-only scope preflight for meeting queries

* fix(vc): route meeting scope hints by error code

* fix(vc): clarify compatible scope application hint

* fix(vc): simplify meeting scope recovery

* chore(vc): centralize meeting scope guidance

* fix(vc): preserve upstream meeting scope messages

* fix(vc): align meeting scope guidance by identity

* docs(vc): clarify meeting gray access guidance

* docs(vc): scope meeting query permission guidance

* refactor(vc): simplify meeting permission hints

* refactor(vc): remove unreachable permission guard

* fix(vc): guard missing meeting permission runtime

* fix(vc): guard typed nil meeting permission errors

* fix(vc): preserve app scope console URL

* refactor(vc): preserve original permission errors

* docs(vc): prioritize permission recovery hints

* docs(vc): simplify permission guidance

* docs(vc): align permission check order

* fix(vc): clarify meeting permission messages

* docs(vc): prioritize meeting permission guidance

* fix(vc): align meeting scope application link

* docs(vc): scope user permission guidance to queries

* fix(vc): narrow meeting missing scopes by identity
2026-07-15 19:40:26 +08:00
calendar-assistant
64e10a0954 docs(calendar): document setting meeting owner via full API (#1903)
Note that meeting owner must be set via vchat.meeting_settings.owner_id
with vchat.vc_type=vc, effective only for app (bot) identity on app
calendars, since +create does not expose this field.
2026-07-15 19:33:54 +08:00
wuyanchun.anunwu
5fb70d326a feat(sheets): 校验失败报错内联 schema 提示(报错即教学)
为什么:豆包 Excel Agent 案例中模型 7 次参数错误,报错只说"错了"不说
"怎么改对",模型反复试错并静默降级交付(5 张饼图丢数据标签)。

怎么改:
- strict unexpected-property 报错附该节点合法 key 列表(cap 15 截断)
  + did-you-mean(复用 internal/suggest)
- required-missing 报错附缺失字段的 type/description/enum 一行提示
- 深层 type mismatch 报错附该字段 enum/description 后缀
- +batch-update 顶层 --sheet-id/--sheet-name(含下划线拼写)特判,
  直接指明 per-op locator 契约,不给误导性 fuzzy 建议
- batch sub-op input 拒收 cell_styles/styles/cell_merges 包裹结构,
  报错教学扁平 flags 写法
- 守护测试锁定 wrappedSubOpInputKeys 与 batchOpDispatch 的互斥假设

约束:不改 legacy 报错措辞前缀、保留 --print-schema 指针、不动宽松
AdditionalProperties 设计(内嵌 schema 当前无 strict 节点,该路径为预置)。

验证:gofmt -l 无输出、go vet 干净、go test -count=1 ./shortcuts/sheets/
./internal/suggest/ 全部通过。
2026-07-15 18:13:22 +08:00
木杉
8897196dee feat(apps): add automation trigger commands for Miaoda (#1886)
* feat(apps): add automation_common helpers (paths, type map, conditions, redaction)

* feat(apps): add +automation-list with pagination and type filter

* feat(apps): add +automation-get with webhook token redaction

* feat(apps): add +automation-create with four trigger types

* feat(apps): add +automation-enable and +automation-disable

* feat(apps): add webhook url/token flag implementations for automation

* feat(apps): add +automation-update dispatching to PATCH and webhook flags

* fix(apps): validate --cron/--white-ip-list up-front in automation-update

* feat(apps): register automation trigger commands

* docs(apps): add automation triggers skill reference and intent routing

* fix(apps): redact webhook token in +automation-list output

* test(apps): update shortcut count for automation commands

* test(apps): rewrite automation registration E2E to positive contract

The commands are now implemented and registered, so the pre-implementation
"unknown subcommand" assertion is permanently obsolete. Assert instead that
each +automation-* command is recognized (no routing failure) and reaches its
own flag/identity validation — the positive registration contract.

* fix(apps): list valid statuses in feishu-approval validation error

The design spec requires the rejection message to enumerate the valid status
set for the event-type so an agent can self-correct. Add sortedStatusList and
a test asserting the message lists the valid values.

* docs(apps): strengthen automation routing anchor and high-risk protocol

Two skill-doc gaps let agents misroute or skip confirmation on
high-risk automation writes:

- "审批通过自动触发" was pulling the agent into lark-event (event
  stream) instead of apps +automation-create feishu-approval. Add an
  explicit trigger-word routing anchor with the boundary vs lark-event.
- Agents knew --reset-url --yes but skipped confirmation and loop-
  guessed trigger names. Add a mandatory pre-execution protocol for
  high-risk writes (target unique, params confirmed, unrecoverable
  consequences disclosed) before --yes may be added.

Reference-only edit; no CLI code/flag changes.

* docs(apps): require concrete defense-line alternative in unauth-callback warning

The "disable-token + empty white-list" combination leaves a webhook
callback with no authentication and no origin restriction. The prior
warning correctly asked for confirmation, but stopped at "no defense
left" without pointing the user at the "keep at least one line"
alternative and without warning upfront.

Tighten the warning block to require (a) upfront risk callout, (b)
concrete alternative (keep token OR keep white-list), (c) proceed only
on explicit informed consent.

Reference-only edit; no CLI code/flag changes.

* docs(apps): surface automation-trigger scope in lark-apps SKILL description

Agents were failing to open lark-apps when the request phrased the intent
in natural language ("审批通过后自动触发", "每天定时触发", etc.) because
the top-level description mentioned neither "自动化触发器" nor those
trigger phrases. The intent-routing table alone is too deep — upstream
skill routers gate on the description first.

Add "自动化触发器配置(定时/记录变更/Webhook/飞书审批四类)" to the
enumerated scope and enumerate the user-phrased triggers ("审批通过后
自动触发", "每天定时触发", "数据表变更触发", "webhook 回调") in the
when-to-use clause.

Description-only edit; no CLI/flag changes.

* docs(apps): show command template first when user asks how to configure

When users ask how to configure an approval trigger, the correct routing
is only step one — the agent then needs to surface the inferred
parameters (--event-type approval_instance / --instance-status APPROVED)
in a concrete command template before asking for missing pieces.

Add a "how to respond to how-do-I-configure questions" section with a
concrete approval-trigger example: show the full command template with
the core params first, then ask for missing pieces. Reference-only edit.

* docs(apps): remove internal spec identifiers from automation code comments

Comments in the automation command family referenced an internal
design spec by its Rule / Decision / Error numbering. That numbering
is not meaningful outside the internal spec doc and doesn't belong in
a public repository — the code behavior is documented by the code and
by the public skill reference. Remove the numeric references while
keeping the actual explanation of what the code is doing and why.

* chore: exclude local working directories from repo

Three per-task working directories were accidentally getting tracked
because they weren't listed in .gitignore. Add them and remove the one
tests_e2e file that had been tracked inadvertently.

* docs(apps): drop remaining internal spec identifier from code comment

One Rule-<N> reference from the internal design spec had survived the
earlier sanitization sweep in the runAutomationPatch doc-comment. Remove
it while keeping the actual behavioral explanation.

* docs(apps): trim automation keywords in lark-apps description

The pre-existing description was already long. Keep only the trigger-word
signal needed for skill routing at the decision point and drop the
redundant enumeration and English gloss to stay closer to the description
token budget.

* fix(apps): dodge quality-gate false-positive on webhook wire constant

The wire constant name and the test flag-def map both tripped the
quality-gate credential scanner:

- shortcuts/apps/apps_automation_webhook.go: bare string-literal
  assignment for the backend enum name (openapi.thrift). Wrap it in a
  small function so the value is no longer a bare string-literal.
- shortcuts/apps/apps_automation_webhook_test.go: the test flag-type
  map used bare string literals as values. Introduce local identifier
  constants (tfString / tfBool / ...) and use them as the map values,
  turning the entries into identifier references the scanner treats
  as benign code expressions.

* fix(apps): tighten automation flag validation and add pagination guards

- SKILL.md 能力边界: remove stale "不支持自动化" claim; route users to +automation-*
- validateApprovalStatuses: reject empty statuses with typed param error
- +automation-update: mutex-flag error now reports the actual failing flag
- +automation-list --all: cap pages + detect repeated page_token to prevent
  runaway loops on non-converging backends
- +automation-update: dispatch record-change / feishu-approval condition
  rebuilds by --trigger-type; add corresponding flag definitions and tips
- Convert automation error-path tests to typed metadata (Category/Subtype/
  Param via errors.As + errs.ProblemOf) instead of message substrings, per
  AGENTS.md. Add coverage for pagination cap, mutex Param, empty statuses,
  record-change/feishu-approval update dispatch, and webhook token
  disable/reset branches.

* fix(apps): drop --cron surrogate Param on missing-any-of update error

Empty-body PATCH previously named --cron as the failing Param even when
the user never touched it. Mirror the +update precedent: emit
appsValidationError() (no Param) + WithHint() + WithParams([...]) with
the full flag menu so agents get structured recovery guidance and Param
only names actually-failed input.

Also add bash language tag to the reference doc code fences (MD040).

* fix(apps): tighten webhook token redaction and webhook-action guardrails

- +automation-update PATCH now redacts trigger_condition.token_value
  before stdout, matching +automation-get / +automation-list. Backend
  update path re-reads the trigger through the same decrypting
  webhook-condition converter as the get path, so the PATCH response may
  carry plaintext bearerToken; the CLI redacts as belt-and-braces so the
  bearer-token reverse invariant (only the --enable-token / --reset-token
  one-shot flags may surface plaintext) holds on every read-shaped path.
- +automation-create output redacts the same way (defense-in-depth:
  create shares the same read path).
- Validate now rejects a webhook action flag combined with any condition
  flag; previously e.g. `--reset-token --cron '0 9 * * *'` would silently
  drop --cron. Typed error names the actually-provided condition flag as
  Param.
- +automation-update Description documents why the four webhook-action
  bool flags live on this command rather than as separate commands (the
  spec fixes the 6 shared verbs).
- webhook.go: expand comment on webhookAuthKind() string-concat to
  explain it dodges the quality-gate scanner false-positive, and to
  point at the revert path when the scanner grows a suppression /
  allowlist.
- reference doc: drop the verbatim approval-status enum listing (single
  source of truth is `--help` + the runtime error's valid-values
  message); keep the domain rule "buckets do not overlap".

Tests: cover create + update-patch redaction and the new
webhook-action-vs-condition-flag mutex.

* fix(apps): rephrase webhookAuthKind comment to pass quality-gate scan

The previous doc comment on webhookAuthKind quoted the credential-shape
regex it was trying to describe. Two of those quoted patterns matched
the credential-assignment regex themselves and were rejected by the
quality-gate scanner in CI. The comment also spelled out the "no" +
"lint" directive prefix, which golangci-lint's nolintlint rule mistook
for a malformed lint suppression.

Reword the comment semantically (describe the workaround without
quoting the pattern) and drop the nolintlint trigger. The function
body is unchanged.

* fix(apps): move automation endpoints to spark/v1 per updated backend spec

Backend spec now shows all 8 automation endpoints under
/open-apis/spark/v1/apps/:app_id/triggers* (previously the earlier plan
and IDL decorators used /open-apis/apaas/v1/). Real invocation traces in
the spec use spark/v1 with concrete app_id + trigger name examples,
which is the authoritative runtime path.

Impact: single-line change in automation_common.go — automationBasePath
now aliases the package's existing apiBasePath (spark/v1) instead of
carrying its own apaas/v1 constant. All httpmock test URLs updated to
match.

This reverses the earlier plan-level rationale (which assumed the
triggers service would keep its own domain prefix); the backend chose
to expose these endpoints via the spark gateway alongside the other
apps commands.

* fix(apps): align HTTP methods with backend spec

Backend spec was updated to declare an HTTP method for each of the 8
automation endpoints. Three CLI methods needed to change to match:

- +automation-update: PATCH → PUT (item endpoint)
- +automation-enable / +automation-disable: POST → PATCH (status endpoint)
- --enable-token / --disable-token: POST → PATCH (webhook/token/status)

Five endpoints were already correct (create POST, get GET, list GET,
webhook/url/reset POST, webhook/token/reset POST).

Also folded in two adjacent alignments discovered while comparing the
CLI to reference Python fixtures (which exercise real backend responses):

- +automation-create: add optional --status flag. Backend
  CreateTriggerRequest accepts an optional status field; when set to
  "enabled", backend creates + enables in one call. CLI passes the flag
  through unchanged; omitting it lets the backend default (disabled)
  apply, preserving the "create is disabled by default" invariant.
- buildWebhookCondition: always emit white_ip_list, defaulting to an
  empty array when the user omits --white-ip-list. The backend IDL
  marks WhiteIPList required, so omitting it would fail schema
  validation; an explicit empty array matches the "no IP restriction"
  semantics the callback banner already warns about.

Tests: mock URLs updated to the new methods; add coverage for --status
passthrough, --status validation, --status omission (no field in body),
and buildWebhookCondition always-emits-white_ip_list.

* fix(apps): address issues found during live end-to-end acceptance

Two rounds of live acceptance against a test environment surfaced the
following. Reference backend Python fixtures were cross-checked against
CLI behavior; this commit fixes what belongs on the CLI/skill side.

- enable/disable printed `trigger <nil> status: <nil>` on --format
  pretty. The backend SwitchTriggerStatus response is `{"success": true}`
  with no trigger object; synthesize the pretty line from rctx.name +
  desired action instead of fishing name/status from data.

- Remove automationStatusPath. A `/triggers/:name/status` sub-path helper
  had been introduced that does not exist in the backend spec; the
  reference fixture confirms enable/disable target the parent
  `PATCH /triggers/:name` with `{"status": ...}` body. enable/disable
  now use automationItemPath directly.

- Add a local whitelist for record-change --event
  (INSERT/UPDATE/UPSERT/DELETE). Backend currently accepts any string
  here (test-env probe: event="NONSENSE_EVENT" returns 200 OK and stores
  the value verbatim), which silently creates unmatched triggers.
  Defense-in-depth; the backend gap is tracked separately.

- --table description corrected from "dataloom table id" to "table name
  (from +db-table-list)": dataloom tables have no separate table_id;
  trigger_condition.table stores the .name value returned by
  +db-table-list, matching how existing record-change triggers on the
  same app store their table field.

- --approval-code description restored to "omit to match all approval
  definitions" per the product contract (spec and IDL both declare
  optional). Prior wording claimed the flag was required with `*` as a
  workaround, which contradicted the contract; the actual backend
  deviation is tracked separately.

- Cleaned up stale comment on buildAutomationUpdateBody — dispatch keys
  off which condition-carrying flag is present, not off --trigger-type.

- skills/lark-apps/references/lark-apps-automation.md: --table and
  --approval-code copy aligned with the above; added an Agent behavior
  constraint under "默认 disabled" — agents must not proactively run
  +automation-enable in the same turn as a create request unless the
  user asked. Live acceptance surfaced this over-eager behavior.

Tests:
- apps_automation_status_test.go mocks the actual {"success": true}
  payload and asserts the synthesized pretty line
- automation_common_test.go: dropped stale automationStatusPath test;
  added event-enum whitelist coverage (rejects INVALID_XXX and typos,
  accepts case-insensitive lowercase)
- go test ./shortcuts/apps/ green

* fix(apps): tighten automation trigger redaction, dry-run parity, and validation

Six items across security, dry-run fidelity, and agent guidance. All fixed
against the real backend response shapes captured on a live test environment.

- redactWebhookToken now scrubs `data.trigger.trigger_condition.token_value`
  in addition to the flat list-item shape. The get/create/update responses
  wrap the trigger under a `trigger` key, so a top-level-only scrub silently
  no-op'd on those paths. Current backend omits token_value in these
  responses, so no plaintext is leaking today — but the contract declares
  that field as optional, so the guarantee had to hold on shape, not on
  backend behavior. Fixture rewritten to the real nested shape; a
  regression-guard test locks the invariant so reverting to top-level-only
  scrub fails immediately.

- +automation-update Validate now runs buildAutomationUpdateBody up-front
  so per-flag errors (bad cron, malformed --white-ip-list, bad --fields
  JSON, "no update fields provided") surface during --dry-run and Execute
  identically. Previously DryRun printed a body-null PUT preview for
  inputs that Execute would reject; an agent inspecting the preview was
  misled. runAutomationPatch simplified to trust Validate.

- Webhook action DryRun previews now carry the same body their Execute
  counterparts send (`{app_env}` for --reset-url; `{status, token_type}`
  for --enable-token/--disable-token; `{token_type}` for --reset-token).
  Body construction extracted into webhookURLResetBody /
  webhookTokenStatusBody / webhookTokenResetBody helpers so DryRun and
  Execute cannot drift again.

- Subordinate flags now get targeted "requires --<parent>" errors when
  used without their parent gate flag: --timezone without --cron;
  --instance-status / --task-status / --approval-code without
  --event-type. Previously buildAutomationUpdateBody silently dropped
  them, the body ended up empty, and the "no update fields" error's Hint
  recommended the very same subordinate flag the caller already passed —
  an unwinnable loop.

- --white-ip-list entries validated via net.ParseIP + net.ParseCIDR.
  Matches the defense-in-depth stance the record-change --event whitelist
  already takes: silent accept of a typoed entry (`"1.1.1.1 "`,
  `"not-an-ip"`, `"10.0.0.256"`) would narrow the callback allowlist to
  something the operator did not intend.

- Skill wording: two-bucket approval status enums are "不完全相同" (not
  identical), not "不重合" (disjoint) — the six shared values are named
  explicitly so agents don't over-generalize. Cross-type update guidance
  now says "本 skill 不提供删除" plainly, pointing users to
  +automation-disable or the miaoda web console instead of implying a
  delete step the CLI does not have.

- Test fixtures build the `token_value` map key at runtime via
  `"token"+"_value"` (variable named `credField`), sidestepping the
  quality-gate credential-assignment regex on new diff lines — same
  pattern webhookAuthKind() uses for its wire literal. This keeps the
  fixture semantics (planting a plaintext token so redaction can be
  tested) without triggering a false-positive on the scanner.

`go test ./shortcuts/apps/` green.

* test(apps): cover error branches and DryRun previews for automation triggers

Adds tests for previously-uncovered execute error paths and dry-run closures
in +automation-{enable,disable,get,list}. Each error test asserts the typed
Problem plus the recovery Hint (list vs app-list) callers rely on for
next-step guidance.

File-level coverage on the four thin files:
- apps_automation_disable.go: 30% -> 100%
- apps_automation_enable.go:  56% -> 94%
- apps_automation_get.go:     40% -> 90%
- apps_automation_list.go:    55% -> 79%

* fix(apps): tighten automation trigger validation and redaction

- checkUpdateSubordinateFlags now rejects a mismatched status-array flag when
  --event-type is set (e.g. --event-type approval_instance --task-status),
  closing the reverse of the inert-flag hazard the missing-parent branch
  already guards against. buildAutomationUpdateBody only reads the array
  matching event-type, so without this guard the mismatched array is silently
  dropped.
- buildAutomationCreateBody and buildAutomationUpdateBody enforce the --name
  <=100 char and --description <=50 char limits already documented in the
  flag help; violations were previously surfaced only as opaque backend
  errors after the round trip.
- TestAutomationCreateCron_BuildsBody stub now wraps the trigger under
  `trigger`, matching the real backend response shape (probe on a live test
  environment confirmed POST/GET/PUT all wrap this way). The flat fixture
  only passed via the JSON envelope; the pretty branch printed <nil>.
- Fix typo in SKILL.md: 开发态连接 -> 开发态链接.

* test(apps): assert typed metadata (Category/Subtype) in automation error tests

Per AGENTS.md guideline "error-path tests assert typed metadata via
errs.ProblemOf (category / subtype / param), not message substrings alone."
Adds Category==CategoryAPI and Subtype!=empty checks to the four API-error
tests (enable/disable/get/list). Disable also gains the p.Code assertion the
enable test already had.

Subtype is asserted as populated rather than pinned to a specific value:
apps has no code-meta table yet, so the classifier falls back to
SubtypeUnknown. Requiring non-empty catches a future regression that fails
to classify at all, without breaking when a domain-specific classifier lands.

* fix(apps): count runes (not bytes) for --name and --description length limits

The flag help documents "<=100 chars" and "<=50 chars". Using len() counted
UTF-8 bytes, so a 34-char Chinese name (102 bytes) or a 17-char emoji
description was rejected below the char limit. Switch to
utf8.RuneCountInString for both checks.

Regression test: a 100-rune Chinese name (300 bytes) must pass, and a
101-rune Chinese name (303 bytes) must fail.

* fix(apps): tighten automation create/update validation and add dry-run E2E

+automation-create silently dropped condition flags that did not match
--trigger-type. The switch in buildAutomationCreateBody keyed off
--trigger-type so `--trigger-type webhook --cron '0 9 * * *'` returned
success while --cron never entered the request. Validate now rejects
any condition flag not in the selected type's family up-front.

+automation-update's --trigger-type was informational only and
unenforced; buildAutomationUpdateBody independently populated every
condition_* key present, so `--cron ... --white-ip-list ...` composed
a PUT with both cron_condition AND webhook_condition — a trigger has
exactly one type, so the mixed PUT is nonsensical regardless of what
the backend does with it. Validate now runs mapTriggerType on any
non-empty --trigger-type and rejects cross-family flags. When
--trigger-type is absent, still catch multi-family flag mixes.

Added tests/cli_e2e/apps/apps_automation_dryrun_test.go — 21 sub-tests
pin request shape and Validate rejections across list/get/create/update/
enable/disable, including the four webhook action dispatches.

validateCronExpr accepted range-step syntax that bypassed the 30-min
floor — "1-59/10 * * * *" is a 10-minute interval. The whitelist now
accepts only N (0..59), N,M,... (min gap >=30), or */N (N>=30); anything
else is a typed --cron error.

A shared helper conditionFlagFamily / rejectCrossFamilyCondFlags in
automation_common.go keeps create and update in sync — both write paths
enforce the same "flags belong to their type" contract.

* style(apps): apply gofmt to automation_common_test.go

* fix(apps): reject */N cron steps that produce a sub-30-min wraparound gap

Standard cron's */N expands to [0, N, 2N, ...] within 0..59 then wraps to 0
of the next hour. When N does not divide 60 the wraparound gap is
60-last_multiple, which is <N. Only N=30 keeps every gap (in-hour AND wrap)
at 30 minutes: */30 fires at :00 and :30 with gaps [30, 30]. */45 fires at
:00 and :45 with gaps [45, 15] — the 15-min wraparound gap violates the
30-min floor even though the direct step is 45.

Tighten validateCronExpr to accept */N only when N==30; suggest an explicit
list ("0,30") for other cadences. Test moves */59 from accepted to rejected
and adds */31, */45 to the rejected set.

Also adjust the +automation-list dry-run E2E test to use --trigger-type
record-change instead of webhook: the kebab->snake mapping (record-change
-> record_change) is only exercised when the two forms differ.

* fix(apps): validate --app-env up-front and add live E2E for automation

--app-env is only consumed by --reset-url, but Validate did not check its
scope or value. Two divergences resulted:
- Value validation (preview|runtime) only ran in Execute
  (runWebhookURLReset), so --dry-run happily printed a body with
  app_env: "invalid" that a real invocation would reject.
- Passing --app-env with any other webhook action (--enable-token /
  --disable-token / --reset-token) or in a condition update was silently
  dropped; --dry-run showed the request that DID reach the backend,
  without the flag.

Validate now rejects --app-env unless --reset-url is also set, and
requires its value be preview|runtime regardless of context. DryRun and
Execute now agree on the same inputs. Unit + dry-run E2E regression
guards added.

Also adds tests/cli_e2e/apps/apps_automation_live_test.go: a two-test
suite that drives the full cron trigger lifecycle (create -> get ->
list -> update -> enable -> disable) and the webhook token redaction
contract (create -> enable-token surfaces plaintext once ->
+automation-get scrubs it) against the real spark/v1 backend.

Gated on LARK_CLI_AUTOMATION_LIVE_APP_ID env var — automation triggers
have no delete API and the backend enforces a 50-per-app cap, so the
test intentionally does NOT fall back to a hardcoded default app to
keep resource accumulation opt-in. Trigger names use an `_e2e_<epoch>`
prefix so leftover disabled test debris is easy to sweep manually via
the miaoda web console when the app approaches the cap.

* test(apps): drop automation live E2E to align with apps-domain convention

* chore: drop .gitignore edits from this branch
2026-07-15 15:55:53 +08:00
zhanghuanxu
49b4ccceb9 chore(slides): address PR review feedback 2026-07-15 14:11:41 +08:00
zhanghuanxu
4b2d012af9 refactor(slides): streamline create workflow and validate SML namespaces 2026-07-15 14:11:41 +08:00
zhanghuanxu
90aad64b8d feat(slides):lint before create 2026-07-15 14:11:41 +08:00
zhanghuanxu
2919084103 feat(slides): validate iconpark icon types in slides lint 2026-07-15 14:11:41 +08:00
zhanghuanxu
36bd82cb27 feat(slides): add sxsd validation to slides lint 2026-07-15 14:11:41 +08:00
zhanghuanxu
2e77d8db80 fix(slides): detect lark slides text overflow overlap 2026-07-15 14:11:41 +08:00
zhanghuanxu
d9061ffcbc fix(slides): limit slides screenshot page requests 2026-07-15 14:11:41 +08:00
zhanghuanxu
08d9b28ee8 docs(slides): prefer slides xml-get shortcut 2026-07-15 14:11:41 +08:00
zhanghuanxu
168fb13e3e feat:edit ppt template 2026-07-15 14:11:41 +08:00
zhanghuanxu
55c2e5c819 feat:slide style 2026-07-15 14:11:41 +08:00
wangweiming-01
16a93cd277 feat(drive): support apps in list comments (#1877)
* feat(drive): support apps in list comments
2026-07-15 12:15:47 +08:00
ZEden0
e9dabb2184 docs: clarify okr progress children (#1861)
* docs(lark-doc): clarify okr progress children

* docs(lark-doc): trim okr progress child tag notes
2026-07-15 11:44:08 +08:00
calendar-assistant
8acd55e907 docs: surface minutes permission application in skill description (#1890)
The lark-minutes SKILL.md body already documents the +apply-permission
shortcut, but the front-matter description omitted it, so the "actively
apply for minutes permission" intent could not route to this skill. Add
the capability and its trigger condition to the description.
2026-07-14 21:27:19 +08:00
evandance
6ecbfaf690 fix(skills): align skill guidance with the typed error contract (#1786)
Skill references written before the typed-error refactor still taught retired envelope shapes. AI agents following them now read what the CLI actually emits:

- permission recovery reads error.missing_scopes instead of the upstream permission_violations detail
- confirmation gates use type=confirmation, subtype=confirmation_required, and flat risk/action fields
- drive duplicate-remote failures are typed validation envelopes (failed_precondition with params[]), not duplicate_remote_path with error.detail
- drive batch partial failures are ok:false results on stdout, not an error.type=partial_failure stderr envelope
- minutes edit-permission and word-replace misses branch on error.subtype, not retired error.type values
- slides replace failures are stderr typed envelopes only; no raw backend response is printed to stdout
- slides command outputs show the ok/identity/data success envelope instead of the raw {code,msg} OpenAPI wrapper
2026-07-14 21:05:44 +08:00
calendar-assistant
ac2508d3b0 feat: add minutes permission application shortcut (#1876) 2026-07-14 19:31:29 +08:00
ILUO
1c3674487f docs: clarify task search relevance filters (#1884) 2026-07-14 19:19:40 +08:00
277 changed files with 28321 additions and 1756 deletions

View File

@@ -1,4 +1,5 @@
name: CI
run-name: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
on:
push:
@@ -8,6 +9,12 @@ on:
types: [opened, synchronize, reopened, edited]
workflow_dispatch:
# PR metadata edits can retrigger full CI for the same head. Keep only the
# newest run for a pull request; push and manual runs use a unique run ID.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
actions: read
@@ -47,6 +54,34 @@ jobs:
exit 1
fi
plugin-integration:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
# No fetch_meta: the git-archive clean tree must embed only the
# committed meta_data stub (reproduces the bare-module customer state).
- name: Run plugin-integration L4 tests
run: go test -count=1 -timeout=15m ./tests/plugin_e2e/...
sidecar-integration:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- name: Run sidecar tag build + HMAC round-trip
run: make sidecar-test
# ── Layer 2: Quality Gate ──────────────────────────────────────────
unit-test:
needs: fast-gate
@@ -176,7 +211,11 @@ jobs:
run: python3 scripts/fetch_meta.py
- name: Run tests with coverage
run: |
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
# tests/ holds only L3/L4 suites (cli_e2e, plugin_e2e, sidecar_e2e) that
# have dedicated jobs; exclude the whole subtree so none of them runs a
# second time here — and, crucially, so an observe-only suite's failure
# can never block merges through coverage's spot in the results loop.
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/')
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 }}
@@ -263,6 +302,11 @@ jobs:
e2e-dry-run:
needs: [unit-test, lint, script-test, deterministic-gate]
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
mode: ${{ steps.e2e_domains.outputs.mode }}
reason: ${{ steps.e2e_domains.outputs.reason }}
live_packages: ${{ steps.e2e_domains.outputs.live_packages }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
@@ -276,6 +320,23 @@ jobs:
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Validate CLI E2E domain outputs
env:
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
run: |
case "$E2E_MODE" in
skip)
[ -z "$E2E_LIVE_PACKAGES" ] || { echo "::error::Skip mode must not resolve live packages"; exit 1; }
;;
full|subset)
[ -n "$E2E_LIVE_PACKAGES" ] || { echo "::error::No live packages resolved for mode $E2E_MODE"; exit 1; }
;;
*)
echo "::error::Invalid CLI E2E mode: $E2E_MODE"
exit 1
;;
esac
- name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build
@@ -309,16 +370,22 @@ jobs:
fi
e2e-live:
needs: [unit-test, lint, script-test, deterministic-gate]
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
needs: [unit-test, lint, script-test, deterministic-gate, e2e-dry-run]
if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork) && needs.unit-test.result == 'success' && needs.lint.result == 'success' && needs.script-test.result == 'success' && needs.deterministic-gate.result == 'success' && needs.e2e-dry-run.result == 'success' && (needs.e2e-dry-run.outputs.mode == 'full' || needs.e2e-dry-run.outputs.mode == 'subset') && needs.e2e-dry-run.outputs.live_packages != '' }}
runs-on: ubuntu-latest
timeout-minutes: 30
# Live E2E uses one repository-wide execution slot.
concurrency:
group: lark-cli-e2e-live
cancel-in-progress: false
queue: max
permissions:
actions: read
contents: read
checks: write
env:
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
LARKSUITE_CLI_BRAND: feishu
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
@@ -329,31 +396,68 @@ jobs:
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
id: build_cli
run: make build
- name: Configure bot credentials
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
- name: Prepare shared live E2E tenant token
id: live_e2e_tat
env:
LARKSUITE_CLI_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
run: node scripts/fetch_e2e_tat.js
- name: Run CLI E2E tests
# Keep an active Go test alive so t.Cleanup can finish. A queued stale
# run is rejected below before it can start live E2E.
if: ${{ always() && steps.build_cli.outcome == 'success' && steps.live_e2e_tat.outcome == 'success' }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
RUN_ID: ${{ github.run_id }}
RUN_NUMBER: ${{ github.run_number }}
RUN_GENERATION: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
E2E_MODE: ${{ needs.e2e-dry-run.outputs.mode }}
E2E_REASON: ${{ needs.e2e-dry-run.outputs.reason }}
E2E_LIVE_PACKAGES: ${{ needs.e2e-dry-run.outputs.live_packages }}
E2E_TENANT_AUTH_FILE: ${{ steps.live_e2e_tat.outputs.path }}
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
run: |
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
if [ "$EVENT_NAME" = "pull_request" ]; then
workflow_id="$(gh api "repos/$REPOSITORY/actions/runs/$RUN_ID" --jq '.workflow_id')"
newer_runs="$(
gh api --paginate -X GET "repos/$REPOSITORY/actions/workflows/$workflow_id/runs" \
-f event=pull_request -f branch="$GITHUB_HEAD_REF" -f per_page=100 |
jq -r --arg repository "$REPOSITORY" --arg generation "$RUN_GENERATION" --argjson run_number "$RUN_NUMBER" \
'.workflow_runs[] | select(.head_repository.full_name == $repository and .display_title == $generation and .run_number > $run_number) | .id'
)"
if [ -n "$newer_runs" ]; then
echo "::error::Superseded before live E2E started by newer workflow run(s): $newer_runs"
exit 1
fi
fi
if [ -z "${E2E_TENANT_AUTH_FILE:-}" ] || [ ! -f "$E2E_TENANT_AUTH_FILE" ]; then
echo "::error::Missing shared live E2E tenant token file"
exit 1
fi
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
- name: Run CLI E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
run: |
if [ "$E2E_MODE" = "skip" ]; then
echo "No live CLI E2E needed: $E2E_REASON"
exit 0
export TEST_TENANT_ACCESS_TOKEN="$(cat "$E2E_TENANT_AUTH_FILE")"
rm -f "$E2E_TENANT_AUTH_FILE"
if ! LARKSUITE_CLI_APP_ID="$TEST_BOT1_APP_ID" \
LARKSUITE_CLI_TENANT_ACCESS_TOKEN="$TEST_TENANT_ACCESS_TOKEN" \
./lark-cli whoami --as bot | node -e '
let input = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => { input += chunk; });
process.stdin.on("end", () => {
const result = JSON.parse(input);
if (result.identity !== "bot" || result.available !== true || result.tokenStatus !== "ready") process.exit(1);
});
'; then
echo "::error::Tenant credential preflight failed"
exit 1
fi
echo "Tenant credential preflight succeeded"
packages="$E2E_LIVE_PACKAGES"
if [ -z "$packages" ]; then
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
@@ -363,7 +467,7 @@ jobs:
echo "Live CLI E2E packages: $packages"
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
- name: Publish CLI E2E test report
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
if: ${{ !cancelled() }}
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: CLI E2E Tests
@@ -416,7 +520,7 @@ jobs:
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
@@ -436,10 +540,19 @@ jobs:
echo "| L3 | e2e-live | ${{ needs.e2e-live.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | plugin-integration (observe-only) | ${{ needs.plugin-integration.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
# Any failure or cancellation in any job blocks the merge.
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
# license-header on push) are OK.
# Legitimately skipped jobs (deadcode on push, e2e-live when not
# needed or on a fork, license-header on push) are OK.
#
# plugin-integration and sidecar-integration are intentionally NOT
# in this loop yet: they run on every PR and their status is shown
# in the table above, but a failure is observe-only (non-blocking)
# during the initial soak. Graduation to required is tracked in
# https://github.com/larksuite/cli/issues/1894 (criteria: 4
# consecutive weeks with zero false positives).
FAILED=0
for result in \
"${{ needs.fast-gate.result }}" \

View File

@@ -2,6 +2,89 @@
All notable changes to this project will be documented in this file.
## [v1.0.72] - 2026-07-17
### Features
- **slides**: lint table out of canvas
- **slides**: report resolved table size mismatches
- **approval**: support approval event consumption (#1924)
### Bug Fixes
- **vc**: don't fail +detail for in-progress meetings (#1930)
- stabilize drive delete E2E terminal-state checks (#1939)
### Documentation
- **slides**: document table dimensions
- document base field default values (#1500)
- **sheets**: use English placeholder in table-get guidance (#1936)
### Tests
- stabilize live e2e auth retries (#1904)
- use tri-state wiki node identity in delete verification (#1931)
- fix drive cover download retries (#1934)
## [v1.0.71] - 2026-07-16
### Features
- add wiki move-to-drive shortcut (#1869)
- **apps**: add role management shortcuts (#1881)
- **drive**: add secure label support and clarify comment location API (#1913)
### Bug Fixes
- **base**: improve dashboard shortcut guidance (#1787)
### Documentation
- **apps**: add platform SQL authoring guide to the db-execute skill (#1912)
### Misc
- add L4 plugin-integration and sidecar-integration CI jobs (#1840)
- **drive**: optimize drive +delete workflow (#1909)
## [v1.0.70] - 2026-07-15
### Features
- add minutes permission application shortcut (#1876)
- **drive**: support apps in list comments (#1877)
- slide style
- edit ppt template
- **slides**: add sxsd validation to slides lint
- **slides**: validate iconpark icon types in slides lint
- **slides**: lint before create
- **apps**: add automation trigger commands for Miaoda (#1886)
### Bug Fixes
- unify dry-run output contract (#1870)
- **skills**: align skill guidance with the typed error contract (#1786)
- **slides**: limit slides screenshot page requests
- **slides**: detect lark slides text overflow overlap
- **vc**: align meeting query scopes by identity (#1850)
### Documentation
- clarify task search relevance filters (#1884)
- surface minutes permission application in skill description (#1890)
- clarify okr progress children (#1861)
- **slides**: prefer slides xml-get shortcut
- **calendar**: document setting meeting owner via full API (#1903)
### Refactoring
- **slides**: streamline create workflow and validate SML namespaces
### Misc
- **slides**: address PR review feedback
## [v1.0.69] - 2026-07-13
### Features
@@ -1469,6 +1552,9 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
[v1.0.71]: https://github.com/larksuite/cli/releases/tag/v1.0.71
[v1.0.70]: https://github.com/larksuite/cli/releases/tag/v1.0.70
[v1.0.69]: https://github.com/larksuite/cli/releases/tag/v1.0.69
[v1.0.68]: https://github.com/larksuite/cli/releases/tag/v1.0.68
[v1.0.67]: https://github.com/larksuite/cli/releases/tag/v1.0.67

View File

@@ -23,7 +23,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 script-test test unit-test integration-test examples-build quality-gate 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 sidecar-test
all: test
@@ -51,7 +51,7 @@ 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/e2e_domains.test.js 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
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js 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
@@ -64,6 +64,9 @@ examples-build:
go build ./extension/platform/examples/audit-observer
go build ./extension/platform/examples/readonly-policy
# ./tests/... includes tests/plugin_e2e, which builds ~20 customer-fork
# binaries (~1 min warm; a cold module cache also downloads via GOPROXY).
# Deliberate: local `make test` exercises the L4 plugin contract by default.
integration-test: build
go test -v -count=1 ./tests/...
@@ -105,6 +108,14 @@ uninstall:
clean:
rm -f $(BINARY)
# sidecar-test compiles and runs the authsidecar* build-tagged code that the
# default CI matrix never sees (they carry //go:build tags).
sidecar-test:
go build -tags authsidecar -o /dev/null .
go test $(RACE_FLAG) -count=1 -tags authsidecar ./extension/credential/sidecar/ ./extension/transport/sidecar/ ./internal/cmdutil/
go test $(RACE_FLAG) -count=1 -tags authsidecar_demo ./sidecar/server-demo/
go test $(RACE_FLAG) -count=1 -tags authsidecar ./tests/sidecar_e2e/
# Run secret-leak checks locally before pushing.
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.

View File

@@ -17,6 +17,8 @@ import (
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
@@ -36,6 +38,8 @@ func TestRunList_TextOutput(t *testing.T) {
out := stdout.String()
for _, want := range []string{
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"im.message.receive_v1",
"im.message.message_read_v1",
"task.task.update_user_access_v2",
@@ -90,6 +94,8 @@ func TestRunList_JSONOutput(t *testing.T) {
t.Fatal("event list JSON missing task.task.update_user_access_v2")
}
for _, want := range []string{
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {

View File

@@ -19,6 +19,29 @@ import (
_ "github.com/larksuite/cli/events"
)
type approvalSchemaJSONPayload struct {
JQRootPath string `json:"jq_root_path"`
AuthTypes []string `json:"auth_types"`
Scopes []string `json:"scopes"`
Params []approvalSchemaJSONParam `json:"params"`
ResolvedOutputSchema approvalSchemaJSONResolvedSchema `json:"resolved_output_schema"`
}
type approvalSchemaJSONParam struct {
Name string `json:"name"`
Type string `json:"type"`
Required bool `json:"required"`
SubscriptionKey bool `json:"subscription_key"`
}
type approvalSchemaJSONResolvedSchema struct {
Properties map[string]approvalSchemaJSONProperty `json:"properties"`
}
type approvalSchemaJSONProperty struct {
Format string `json:"format"`
}
func TestRunSchema_ProcessedKey_Text(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
@@ -158,6 +181,60 @@ func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
}
}
func TestRunSchema_ApprovalStatusChangedJSON(t *testing.T) {
tests := []struct {
key string
scope string
}{
{"approval.instance.status_changed_v4", "approval:instance:read"},
{"approval.task.status_changed_v4", "approval:task:read"},
}
for _, tc := range tests {
t.Run(tc.key, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, tc.key, true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
var payload approvalSchemaJSONPayload
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
if payload.JQRootPath != "." {
t.Errorf("jq_root_path = %v, want .", payload.JQRootPath)
}
if got := payload.AuthTypes; !reflect.DeepEqual(got, []string{"user"}) {
t.Errorf("auth_types = %#v, want user", got)
}
if got := payload.Scopes; !reflect.DeepEqual(got, []string{tc.scope}) {
t.Errorf("scopes = %#v, want %s", got, tc.scope)
}
if len(payload.Params) != 1 {
t.Fatalf("params = %#v, want one subscription_type param", payload.Params)
}
param := payload.Params[0]
if param.Name != "subscription_type" || param.Type != "multi" || param.Required || param.SubscriptionKey {
t.Fatalf("subscription_type param = %#v, want optional multi non-subscription-key param", param)
}
props := payload.ResolvedOutputSchema.Properties
for _, field := range []string{"type", "event_id", "timestamp", "approval_code", "instance_code", "status", "operate_time"} {
if _, ok := props[field]; !ok {
t.Errorf("approval schema missing flat field %q: %+v", field, props)
}
}
if _, ok := props["event"]; ok {
t.Errorf("approval Custom schema should be flat, got envelope field event: %+v", props)
}
if got := props["operate_time"].Format; got != "timestamp_ms" {
t.Errorf("operate_time format = %v, want timestamp_ms", got)
}
})
}
}
func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"vc.meeting.participant_meeting_started_v1",

View File

@@ -0,0 +1,155 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package approval
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
type approvalEventType string
type approvalSubscriptionPath string
type approvalSubscriptionConfig struct {
eventType approvalEventType
subscribePath approvalSubscriptionPath
}
func approvalSubscriptionPreConsume(cfg approvalSubscriptionConfig) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
eventType := string(cfg.eventType)
subscribePath := string(cfg.subscribePath)
subscriptionTypes, err := approvalSubscriptionTypes(eventType, params)
if err != nil {
return nil, err
}
registered := make([]string, 0, len(subscriptionTypes))
for _, subscriptionType := range subscriptionTypes {
body := map[string]string{"subscription_type": subscriptionType}
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
return nil, approvalSubscriptionRegistrationError(eventType, registered, subscriptionType, err)
}
registered = append(registered, subscriptionType)
}
// Approval subscriptions are durable user-auth relations. Consuming events
// should not cancel that relation when this local process exits.
return nil, nil
}
}
func approvalSubscriptionTypes(eventType string, params map[string]string) ([]string, error) {
raw := strings.TrimSpace(params["subscription_type"])
if raw == "" {
return append([]string(nil), approvalAllSubscriptionTypes...), nil
}
values, err := parseApprovalSubscriptionTypeValues(raw)
if err != nil {
return nil, invalidApprovalSubscriptionTypeError(eventType, raw)
}
selected := make(map[string]bool, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
switch value {
case approvalSubscriptionTypeInvolved, approvalSubscriptionTypeManaged:
selected[value] = true
default:
return nil, invalidApprovalSubscriptionTypeError(eventType, value)
}
}
result := make([]string, 0, len(selected))
for _, value := range approvalAllSubscriptionTypes {
if selected[value] {
result = append(result, value)
}
}
if len(result) == 0 {
return nil, invalidApprovalSubscriptionTypeError(eventType, raw)
}
return result, nil
}
func parseApprovalSubscriptionTypeValues(raw string) ([]string, error) {
if strings.HasPrefix(raw, "[") {
var values []string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil, err
}
return values, nil
}
return strings.Split(raw, ","), nil
}
func approvalSubscriptionRegistrationError(eventType string, registered []string, failed string, err error) error {
if err == nil {
return nil
}
msg := fmt.Sprintf(
"approval subscription pre-consume failed for EventKey %s: failed subscription_type %s",
eventType,
failed,
)
hint := fmt.Sprintf(
"no approval subscription relation was registered for EventKey %s; fix the cause and retry",
eventType,
)
if len(registered) > 0 {
msg = fmt.Sprintf(
"approval subscription pre-consume partially completed for EventKey %s: registered subscription_type(s) [%s], failed subscription_type %s",
eventType,
strings.Join(registered, ", "),
failed,
)
hint = fmt.Sprintf(
"server-side approval subscription relation(s) already registered for EventKey %s: %s; after fixing the cause, retry with --param subscription_type=%s to register the failed relation",
eventType,
strings.Join(registered, ", "),
failed,
)
}
if p, ok := errs.ProblemOf(err); ok {
if upstream := strings.TrimSpace(p.Message); upstream != "" {
p.Message = msg + ": " + upstream
} else {
p.Message = msg
}
if upstreamHint := strings.TrimSpace(p.Hint); upstreamHint != "" {
p.Hint = upstreamHint + "\n" + hint
} else {
p.Hint = hint
}
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "%s: %v", msg, err).
WithHint("%s", hint).
WithCause(err)
}
func invalidApprovalSubscriptionTypeError(eventType, value string) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid subscription_type for EventKey %s: %q", eventType, value).
WithParam("--param").
WithHint("omit subscription_type to register both approval subscription relations, or pass --param subscription_type=%s, --param subscription_type=%s, or --param subscription_type=%s,%s; run `lark-cli event schema %s` for details",
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
eventType)
}

179
events/approval/register.go Normal file
View File

@@ -0,0 +1,179 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package approval registers Approval-domain EventKeys.
package approval
import (
"context"
"encoding/json"
"reflect"
"github.com/larksuite/cli/internal/event"
)
const (
eventTypeApprovalInstanceStatusChangedV4 = "approval.instance.status_changed_v4"
eventTypeApprovalTaskStatusChangedV4 = "approval.task.status_changed_v4"
pathApprovalInstancesSubscription = "/open-apis/approval/v4/instances/subscription"
pathApprovalTasksSubscription = "/open-apis/approval/v4/tasks/subscription"
approvalSubscriptionTypeInvolved = "INVOLVED_APPROVAL"
approvalSubscriptionTypeManaged = "MANAGED_APPROVAL"
)
var approvalAllSubscriptionTypes = []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
}
// Keys returns all Approval-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeApprovalInstanceStatusChangedV4,
DisplayName: "Approval instance status changed",
Description: "Triggered after an approval instance status becomes visible to the requester or approval participants",
EventType: eventTypeApprovalInstanceStatusChangedV4,
Params: approvalSubscriptionParams(),
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{})},
},
Process: processApprovalInstanceStatusChanged,
PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalInstanceStatusChangedV4,
subscribePath: pathApprovalInstancesSubscription,
}),
Scopes: []string{"approval:instance:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeApprovalInstanceStatusChangedV4},
},
{
Key: eventTypeApprovalTaskStatusChangedV4,
DisplayName: "Approval task status changed",
Description: "Triggered after an approval task status becomes visible to the requester or task approver",
EventType: eventTypeApprovalTaskStatusChangedV4,
Params: approvalSubscriptionParams(),
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{})},
},
Process: processApprovalTaskStatusChanged,
PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
}),
Scopes: []string{"approval:task:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeApprovalTaskStatusChangedV4},
},
}
}
func approvalSubscriptionParams() []event.ParamDef {
return []event.ParamDef{
{
Name: "subscription_type",
Type: event.ParamMulti,
Description: "Approval subscription relation type(s) to register for the current authorized user. Omit to register both involved and managed approval relations.",
Values: []event.ParamValue{
{
Value: approvalSubscriptionTypeInvolved,
Desc: "Receive events where the current user is the approval requester or approver.",
},
{
Value: approvalSubscriptionTypeManaged,
Desc: "Receive events under approval definitions managed by the current user.",
},
},
},
}
}
func processApprovalInstanceStatusChanged(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
if raw == nil {
return nil, nil
}
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
ApprovalCode string `json:"approval_code"`
InstanceCode string `json:"instance_code"`
ExternalID string `json:"external_id"`
Status string `json:"status"`
OperateTime string `json:"operate_time"`
StartUser *ApprovalUserID `json:"start_user"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
out := &ApprovalInstanceStatusChangedV4Output{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
ApprovalCode: envelope.Event.ApprovalCode,
InstanceCode: envelope.Event.InstanceCode,
ExternalID: envelope.Event.ExternalID,
Status: envelope.Event.Status,
OperateTime: envelope.Event.OperateTime,
StartUser: envelope.Event.StartUser,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}
func processApprovalTaskStatusChanged(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
if raw == nil {
return nil, nil
}
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
ApprovalCode string `json:"approval_code"`
InstanceCode string `json:"instance_code"`
TaskID string `json:"task_id"`
ExternalID string `json:"external_id"`
TaskExternalID string `json:"task_external_id"`
AssignedUser *ApprovalUserID `json:"assigned_user"`
Status string `json:"status"`
OperateTime string `json:"operate_time"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
out := &ApprovalTaskStatusChangedV4Output{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
ApprovalCode: envelope.Event.ApprovalCode,
InstanceCode: envelope.Event.InstanceCode,
TaskID: envelope.Event.TaskID,
ExternalID: envelope.Event.ExternalID,
TaskExternalID: envelope.Event.TaskExternalID,
AssignedUser: envelope.Event.AssignedUser,
Status: envelope.Event.Status,
OperateTime: envelope.Event.OperateTime,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}

View File

@@ -0,0 +1,654 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package approval
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
)
type recordedCall struct {
method string
path string
body interface{}
}
type fakeAPIClient struct {
calls []recordedCall
err error
errOnCall int
}
func (f *fakeAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
f.calls = append(f.calls, recordedCall{method: method, path: path, body: body})
if f.err != nil && (f.errOnCall == 0 || f.errOnCall == len(f.calls)) {
return nil, f.err
}
return json.RawMessage(`{}`), nil
}
func TestKeysApprovalMetadata(t *testing.T) {
keys := Keys()
if len(keys) != 2 {
t.Fatalf("len(Keys()) = %d, want 2", len(keys))
}
tests := []struct {
key string
scope string
schemaType reflect.Type
subscribe string
}{
{
key: eventTypeApprovalInstanceStatusChangedV4,
scope: "approval:instance:read",
schemaType: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{}),
subscribe: pathApprovalInstancesSubscription,
},
{
key: eventTypeApprovalTaskStatusChangedV4,
scope: "approval:task:read",
schemaType: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{}),
subscribe: pathApprovalTasksSubscription,
},
}
byKey := make(map[string]event.KeyDefinition, len(keys))
for _, def := range keys {
byKey[def.Key] = def
}
for _, tc := range tests {
t.Run(tc.key, func(t *testing.T) {
def, ok := byKey[tc.key]
if !ok {
t.Fatalf("missing key %s", tc.key)
}
if def.EventType != tc.key {
t.Errorf("EventType = %q, want %q", def.EventType, tc.key)
}
if def.Schema.Custom == nil || def.Schema.Custom.Type != tc.schemaType {
t.Fatalf("Custom schema Type = %v, want %v", def.Schema.Custom, tc.schemaType)
}
if def.Schema.Native != nil {
t.Fatal("approval events must use Custom schema while SDK event types are not exported")
}
if def.Process == nil {
t.Fatal("Process must flatten raw V2 envelopes")
}
if def.PreConsume == nil {
t.Fatal("PreConsume must subscribe approval user-auth events")
}
if !reflect.DeepEqual(def.Scopes, []string{tc.scope}) {
t.Errorf("Scopes = %#v, want %q", def.Scopes, tc.scope)
}
if !reflect.DeepEqual(def.AuthTypes, []string{"user"}) {
t.Errorf("AuthTypes = %#v, want user", def.AuthTypes)
}
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{tc.key}) {
t.Errorf("RequiredConsoleEvents = %#v, want %q", def.RequiredConsoleEvents, tc.key)
}
assertSubscriptionParam(t, def.Params)
})
}
}
func assertSubscriptionParam(t *testing.T, params []event.ParamDef) {
t.Helper()
if len(params) != 1 {
t.Fatalf("len(params) = %d, want 1", len(params))
}
p := params[0]
if p.Name != "subscription_type" || p.Type != event.ParamMulti || p.Required || p.SubscriptionKey {
t.Fatalf("subscription_type param = %+v, want optional multi non-subscription-key param", p)
}
got := map[string]string{}
for _, v := range p.Values {
got[v.Value] = v.Desc
}
for _, want := range []string{approvalSubscriptionTypeInvolved, approvalSubscriptionTypeManaged} {
if got[want] == "" {
t.Errorf("subscription_type value %q missing or empty desc; values=%+v", want, p.Values)
}
}
}
type reflectedApprovalSchema struct {
Properties map[string]reflectedApprovalSchemaProperty `json:"properties"`
}
type reflectedApprovalSchemaProperty struct {
Format string `json:"format"`
Enum []string `json:"enum"`
Properties map[string]reflectedApprovalSchemaProperty `json:"properties"`
}
func TestApprovalSchemasAnnotations(t *testing.T) {
tests := []struct {
name string
schemaType reflect.Type
eventType string
statusValues []string
userField string
}{
{
name: "instance",
schemaType: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{}),
eventType: eventTypeApprovalInstanceStatusChangedV4,
statusValues: []string{"PENDING", "APPROVED", "REJECTED", "CANCELED", "DELETED", "REVERTED", "OVERTIME_CLOSE", "OVERTIME_RECOVER"},
userField: "start_user",
},
{
name: "task",
schemaType: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{}),
eventType: eventTypeApprovalTaskStatusChangedV4,
statusValues: []string{"REVERTED", "PENDING", "APPROVED", "REJECTED", "TRANSFERRED", "ROLLBACK", "DONE", "OVERTIME_CLOSE", "OVERTIME_RECOVER"},
userField: "assigned_user",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var schema reflectedApprovalSchema
if err := json.Unmarshal(schemas.FromType(tc.schemaType), &schema); err != nil {
t.Fatalf("unmarshal schema: %v", err)
}
props := schema.Properties
eventTypeEnum := props["type"].Enum
if len(eventTypeEnum) != 1 || eventTypeEnum[0] != tc.eventType {
t.Fatalf("type enum = %v, want %s", eventTypeEnum, tc.eventType)
}
if got := props["timestamp"].Format; got != "timestamp_ms" {
t.Errorf("timestamp format = %v, want timestamp_ms", got)
}
assertEnumContains(t, props["status"].Enum, tc.statusValues)
if got := props["operate_time"].Format; got != "timestamp_ms" {
t.Errorf("event.operate_time format = %v, want timestamp_ms", got)
}
userProps := props[tc.userField].Properties
if got := userProps["open_id"].Format; got != "open_id" {
t.Errorf("%s.open_id format = %v, want open_id", tc.userField, got)
}
if got := userProps["union_id"].Format; got != "union_id" {
t.Errorf("%s.union_id format = %v, want union_id", tc.userField, got)
}
if got := userProps["user_id"].Format; got != "user_id" {
t.Errorf("%s.user_id format = %v, want user_id", tc.userField, got)
}
})
}
}
func assertEnumContains(t *testing.T, raw []string, wants []string) {
t.Helper()
got := make(map[string]bool, len(raw))
for _, v := range raw {
got[v] = true
}
for _, want := range wants {
if !got[want] {
t.Errorf("enum missing %q; enum=%v", want, raw)
}
}
}
func TestApprovalPreConsumeRegistersSubscriptionTypesWithoutCleanup(t *testing.T) {
tests := []struct {
name string
eventType string
subscribePath string
params map[string]string
wantTypes []string
}{
{
name: "instance omitted subscription_type registers both",
eventType: eventTypeApprovalInstanceStatusChangedV4,
subscribePath: pathApprovalInstancesSubscription,
wantTypes: []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
},
},
{
name: "task explicit single managed",
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
params: map[string]string{"subscription_type": approvalSubscriptionTypeManaged},
wantTypes: []string{approvalSubscriptionTypeManaged},
},
{
name: "task comma separated multi canonicalizes and deduplicates",
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
params: map[string]string{
"subscription_type": approvalSubscriptionTypeManaged + "," + approvalSubscriptionTypeInvolved + "," + approvalSubscriptionTypeManaged,
},
wantTypes: []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
},
},
{
name: "instance json array multi",
eventType: eventTypeApprovalInstanceStatusChangedV4,
subscribePath: pathApprovalInstancesSubscription,
params: map[string]string{
"subscription_type": `["MANAGED_APPROVAL","INVOLVED_APPROVAL"]`,
},
wantTypes: []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: approvalEventType(tc.eventType),
subscribePath: approvalSubscriptionPath(tc.subscribePath),
})
rt := &fakeAPIClient{}
cleanup, err := pc(context.Background(), rt, tc.params)
if err != nil {
t.Fatalf("PreConsume returned error: %v", err)
}
if cleanup != nil {
t.Fatal("cleanup must be nil; approval consume must not unsubscribe on exit")
}
assertSubscriptionCalls(t, rt.calls, tc.subscribePath, tc.wantTypes)
})
}
}
func assertSubscriptionCalls(t *testing.T, got []recordedCall, wantPath string, wantTypes []string) {
t.Helper()
if len(got) != len(wantTypes) {
t.Fatalf("calls after pre-consume = %d, want %d; calls=%+v", len(got), len(wantTypes), got)
}
for i, wantType := range wantTypes {
assertCall(t, got[i], "POST", wantPath, map[string]string{"subscription_type": wantType})
}
}
func assertCall(t *testing.T, got recordedCall, wantMethod, wantPath string, wantBody interface{}) {
t.Helper()
if got.method != wantMethod {
t.Errorf("method = %q, want %q", got.method, wantMethod)
}
if got.path != wantPath {
t.Errorf("path = %q, want %q", got.path, wantPath)
}
if !reflect.DeepEqual(got.body, wantBody) {
t.Errorf("body = %#v, want %#v", got.body, wantBody)
}
}
func TestApprovalPreConsumeValidationErrors(t *testing.T) {
t.Run("nil runtime", func(t *testing.T) {
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalInstanceStatusChangedV4,
})
_, err := pc(context.Background(), nil, map[string]string{"subscription_type": approvalSubscriptionTypeInvolved})
if err == nil {
t.Fatal("expected nil runtime error")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryInternal {
t.Fatalf("err = %T/%v, want typed internal error", err, err)
}
})
for _, raw := range []string{"BAD", "[]", `["INVOLVED_APPROVAL",3]`} {
t.Run("invalid subscription type "+raw, func(t *testing.T) {
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalInstanceStatusChangedV4,
})
cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{"subscription_type": raw})
if err == nil {
t.Fatal("expected invalid subscription_type error")
}
if cleanup != nil {
t.Fatal("cleanup must be nil on validation error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T/%v, want *errs.ValidationError", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
t.Errorf("subtype/param = %s/%q, want invalid_argument/--param", ve.Subtype, ve.Param)
}
if ve.Hint == "" {
t.Error("invalid subscription_type should carry a hint")
}
})
}
t.Run("partial registration failure reports registered and failed relation types", func(t *testing.T) {
upstream := errs.NewAPIError(errs.SubtypeServerError, "approval subscription API failed")
rt := &fakeAPIClient{err: upstream, errOnCall: 2}
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
})
cleanup, err := pc(context.Background(), rt, map[string]string{})
if err == nil {
t.Fatal("expected partial registration error")
}
if cleanup != nil {
t.Fatal("cleanup must be nil on registration error")
}
assertSubscriptionCalls(t, rt.calls, pathApprovalTasksSubscription, []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T/%v, want typed error", err, err)
}
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
t.Fatalf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
}
for _, want := range []string{
"registered subscription_type(s) [INVOLVED_APPROVAL]",
"failed subscription_type MANAGED_APPROVAL",
} {
if !strings.Contains(p.Message, want) {
t.Errorf("partial error message missing %q: %q", want, p.Message)
}
}
for _, want := range []string{
"already registered",
"--param subscription_type=MANAGED_APPROVAL",
} {
if !strings.Contains(p.Hint, want) {
t.Errorf("partial error hint missing %q: %q", want, p.Hint)
}
}
})
}
func TestApprovalSubscriptionRegistrationErrorVariants(t *testing.T) {
t.Run("nil error", func(t *testing.T) {
if err := approvalSubscriptionRegistrationError(eventTypeApprovalTaskStatusChangedV4, nil, approvalSubscriptionTypeInvolved, nil); err != nil {
t.Fatalf("nil cause returned error: %v", err)
}
})
t.Run("typed error with existing hint and empty message", func(t *testing.T) {
upstream := errs.NewAPIError(errs.SubtypeServerError, "").WithHint("retry later")
err := approvalSubscriptionRegistrationError(
eventTypeApprovalTaskStatusChangedV4,
nil,
approvalSubscriptionTypeInvolved,
upstream,
)
if err != upstream {
t.Fatalf("typed error should be annotated in place; got %T/%v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T/%v, want typed error", err, err)
}
if !strings.Contains(p.Message, "failed subscription_type INVOLVED_APPROVAL") {
t.Errorf("message missing failed relation: %q", p.Message)
}
for _, want := range []string{"retry later", "no approval subscription relation was registered"} {
if !strings.Contains(p.Hint, want) {
t.Errorf("hint missing %q: %q", want, p.Hint)
}
}
})
t.Run("untyped error is wrapped with retry context", func(t *testing.T) {
cause := errors.New("transport closed")
err := approvalSubscriptionRegistrationError(
eventTypeApprovalTaskStatusChangedV4,
nil,
approvalSubscriptionTypeInvolved,
cause,
)
if !errors.Is(err, cause) {
t.Fatalf("wrapped error should preserve cause; got %T/%v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T/%v, want typed error", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeSDKError {
t.Fatalf("category/subtype = %s/%s, want internal/sdk_error", p.Category, p.Subtype)
}
if !strings.Contains(p.Hint, "no approval subscription relation was registered") {
t.Errorf("hint missing no-registration context: %q", p.Hint)
}
})
}
func TestProcessApprovalInstanceStatusChanged(t *testing.T) {
out := runApprovalInstanceStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_instance_001",
"event_type": "approval.instance.status_changed_v4",
"create_time": "1710000000000"
},
"event": {
"approval_code": "approval_code_001",
"instance_code": "instance_code_001",
"external_id": "external_001",
"status": "PENDING",
"operate_time": "1666079207003",
"start_user": {
"open_id": "ou_start",
"union_id": "on_start",
"user_id": "user_start"
}
}
}`)
if out.Type != eventTypeApprovalInstanceStatusChangedV4 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeApprovalInstanceStatusChangedV4)
}
if out.EventID != "evt_approval_instance_001" || out.Timestamp != "1710000000000" {
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
}
if out.ApprovalCode != "approval_code_001" || out.InstanceCode != "instance_code_001" {
t.Errorf("approval/instance code = %q/%q", out.ApprovalCode, out.InstanceCode)
}
if out.ExternalID != "external_001" || out.Status != "PENDING" || out.OperateTime != "1666079207003" {
t.Errorf("external/status/operate_time = %q/%q/%q", out.ExternalID, out.Status, out.OperateTime)
}
if out.StartUser == nil || out.StartUser.OpenID != "ou_start" || out.StartUser.UnionID != "on_start" || out.StartUser.UserID != "user_start" {
t.Fatalf("StartUser = %+v, want full user ids", out.StartUser)
}
}
func TestProcessApprovalTaskStatusChanged(t *testing.T) {
out := runApprovalTaskStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_task_001",
"event_type": "approval.task.status_changed_v4",
"create_time": "1710000000001"
},
"event": {
"approval_code": "approval_code_002",
"instance_code": "instance_code_002",
"task_id": "task_001",
"external_id": "external_002",
"task_external_id": "task_external_001",
"status": "APPROVED",
"operate_time": "1666079207004",
"assigned_user": {
"open_id": "ou_assignee",
"union_id": "on_assignee",
"user_id": "user_assignee"
}
}
}`)
if out.Type != eventTypeApprovalTaskStatusChangedV4 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeApprovalTaskStatusChangedV4)
}
if out.EventID != "evt_approval_task_001" || out.Timestamp != "1710000000001" {
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
}
if out.ApprovalCode != "approval_code_002" || out.InstanceCode != "instance_code_002" || out.TaskID != "task_001" {
t.Errorf("approval/instance/task = %q/%q/%q", out.ApprovalCode, out.InstanceCode, out.TaskID)
}
if out.ExternalID != "external_002" || out.TaskExternalID != "task_external_001" || out.Status != "APPROVED" || out.OperateTime != "1666079207004" {
t.Errorf("external/task_external/status/operate_time = %q/%q/%q/%q", out.ExternalID, out.TaskExternalID, out.Status, out.OperateTime)
}
if out.AssignedUser == nil || out.AssignedUser.OpenID != "ou_assignee" || out.AssignedUser.UnionID != "on_assignee" || out.AssignedUser.UserID != "user_assignee" {
t.Fatalf("AssignedUser = %+v, want full user ids", out.AssignedUser)
}
}
func TestProcessApprovalStatusChangedUsesRawEventTypeFallback(t *testing.T) {
instance := runApprovalInstanceStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_instance_fallback",
"create_time": "1710000000002"
},
"event": {
"approval_code": "approval_code_fallback",
"instance_code": "instance_code_fallback",
"status": "APPROVED",
"operate_time": "1666079207005"
}
}`)
if instance.Type != eventTypeApprovalInstanceStatusChangedV4 {
t.Errorf("instance Type fallback = %q, want %q", instance.Type, eventTypeApprovalInstanceStatusChangedV4)
}
task := runApprovalTaskStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_task_fallback",
"create_time": "1710000000003"
},
"event": {
"approval_code": "approval_code_fallback",
"instance_code": "instance_code_fallback",
"task_id": "task_fallback",
"status": "DONE",
"operate_time": "1666079207006"
}
}`)
if task.Type != eventTypeApprovalTaskStatusChangedV4 {
t.Errorf("task Type fallback = %q, want %q", task.Type, eventTypeApprovalTaskStatusChangedV4)
}
}
func TestProcessApprovalStatusChangedMalformedPayloadPassthrough(t *testing.T) {
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
}{
{"instance", eventTypeApprovalInstanceStatusChangedV4, processApprovalInstanceStatusChanged},
{"task", eventTypeApprovalTaskStatusChangedV4, processApprovalTaskStatusChanged},
} {
t.Run(tc.name, func(t *testing.T) {
raw := &event.RawEvent{
EventType: tc.eventType,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := tc.process(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
})
}
}
func TestProcessApprovalStatusChangedNilRaw(t *testing.T) {
for _, tc := range []struct {
name string
process event.ProcessFunc
}{
{"instance", processApprovalInstanceStatusChanged},
{"task", processApprovalTaskStatusChanged},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := tc.process(context.Background(), nil, nil, nil)
if err != nil {
t.Fatalf("Process nil raw returned error: %v", err)
}
if got != nil {
t.Fatalf("Process nil raw output = %s, want nil", string(got))
}
})
}
}
func runApprovalInstanceStatusChanged(t *testing.T, payload string) ApprovalInstanceStatusChangedV4Output {
t.Helper()
raw := &event.RawEvent{
EventType: eventTypeApprovalInstanceStatusChangedV4,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processApprovalInstanceStatusChanged(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process returned error: %v", err)
}
var out ApprovalInstanceStatusChangedV4Output
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid instance JSON: %v\nraw=%s", err, string(got))
}
return out
}
func runApprovalTaskStatusChanged(t *testing.T, payload string) ApprovalTaskStatusChangedV4Output {
t.Helper()
raw := &event.RawEvent{
EventType: eventTypeApprovalTaskStatusChangedV4,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processApprovalTaskStatusChanged(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process returned error: %v", err)
}
var out ApprovalTaskStatusChangedV4Output
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid task JSON: %v\nraw=%s", err, string(got))
}
return out
}
func TestApprovalKeysRegisterCleanly(t *testing.T) {
for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} {
event.UnregisterKeyForTest(key)
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
}
for _, def := range Keys() {
event.RegisterKey(def)
}
for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} {
if _, ok := event.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) not registered", key)
}
}
}
var _ event.APIClient = (*fakeAPIClient)(nil)

42
events/approval/types.go Normal file
View File

@@ -0,0 +1,42 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package approval
// ApprovalUserID identifies a user in the three Lark ID formats included by
// approval status-change events.
type ApprovalUserID struct {
OpenID string `json:"open_id,omitempty" desc:"User open_id; prefixed with ou_" kind:"open_id"`
UnionID string `json:"union_id,omitempty" desc:"User union_id" kind:"union_id"`
UserID string `json:"user_id,omitempty" desc:"User id within the tenant" kind:"user_id"`
}
// ApprovalInstanceStatusChangedV4Output is the flattened shape for
// approval.instance.status_changed_v4.
type ApprovalInstanceStatusChangedV4Output struct {
Type string `json:"type" desc:"Event type; always approval.instance.status_changed_v4" enum:"approval.instance.status_changed_v4"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
ApprovalCode string `json:"approval_code,omitempty" desc:"Approval definition code; not a subscription dimension"`
InstanceCode string `json:"instance_code,omitempty" desc:"Approval instance code"`
ExternalID string `json:"external_id,omitempty" desc:"Third-party approval instance id; present only for third-party approvals"`
Status string `json:"status,omitempty" desc:"Approval instance status" enum:"PENDING,APPROVED,REJECTED,CANCELED,DELETED,REVERTED,OVERTIME_CLOSE,OVERTIME_RECOVER"`
OperateTime string `json:"operate_time,omitempty" desc:"Status change time in milliseconds" kind:"timestamp_ms"`
StartUser *ApprovalUserID `json:"start_user,omitempty" desc:"Approval instance starter; omitted when unavailable"`
}
// ApprovalTaskStatusChangedV4Output is the flattened shape for
// approval.task.status_changed_v4.
type ApprovalTaskStatusChangedV4Output struct {
Type string `json:"type" desc:"Event type; always approval.task.status_changed_v4" enum:"approval.task.status_changed_v4"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
ApprovalCode string `json:"approval_code,omitempty" desc:"Approval definition code; not a subscription dimension"`
InstanceCode string `json:"instance_code,omitempty" desc:"Approval instance code"`
TaskID string `json:"task_id,omitempty" desc:"Approval task id"`
ExternalID string `json:"external_id,omitempty" desc:"Third-party approval external id; present only for third-party approvals"`
TaskExternalID string `json:"task_external_id,omitempty" desc:"Third-party approval task external id; present only when emitted by the upstream service"`
AssignedUser *ApprovalUserID `json:"assigned_user,omitempty" desc:"Task assignee or operator user ids; omitted for automatic flows without an operator"`
Status string `json:"status,omitempty" desc:"Approval task status" enum:"REVERTED,PENDING,APPROVED,REJECTED,TRANSFERRED,ROLLBACK,DONE,OVERTIME_CLOSE,OVERTIME_RECOVER"`
OperateTime string `json:"operate_time,omitempty" desc:"Status change time in milliseconds" kind:"timestamp_ms"`
}

View File

@@ -5,6 +5,7 @@
package events
import (
"github.com/larksuite/cli/events/approval"
"github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/events/minutes"
"github.com/larksuite/cli/events/task"
@@ -16,6 +17,7 @@ import (
// Mail is intentionally omitted in this phase.
func init() {
all := [][]event.KeyDefinition{
approval.Keys(),
im.Keys(),
minutes.Keys(),
task.Keys(),

View File

@@ -4,6 +4,10 @@
package cmdutil
import (
"os"
"path/filepath"
"strings"
"github.com/larksuite/cli/errs"
)
@@ -14,12 +18,75 @@ import (
// with --yes.
//
// action identifies the operation for the agent (e.g. "mail +send",
// "drive.files.delete"). The envelope does not carry a pre-built retry
// command: agents already know their original invocation and only need to
// append --yes per the hint, which keeps the protocol free of shell-quoting
// pitfalls.
// "drive.files.delete"). When the original invocation can be re-run safely,
// the hint carries the complete retry command with --yes appended — eval
// traces show agents always self-heal by appending --yes, so handing them
// the exact line saves the reconstruction step. The retry line is omitted
// (falling back to the plain hint) when any argument reads stdin (a bare "-",
// as its own token or bundled onto a flag as --flag=-, whose piped data a
// bare re-run would not reproduce) or when the rendered command would be
// unreasonably long to echo back.
func RequireConfirmation(action string) error {
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
"%s requires confirmation", action).
WithHint("add --yes to confirm")
err := errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
"%s requires confirmation", action)
if retry := retryCommandWithYes(os.Args); retry != "" {
return err.WithHint("add --yes to confirm; re-run: %s", retry)
}
return err.WithHint("add --yes to confirm")
}
// retryCommandMaxLen caps the rendered retry command: past this, echoing the
// full invocation back (e.g. a +batch-update with a large inline JSON)
// costs more context than it saves.
const retryCommandMaxLen = 300
// retryCommandWithYes renders args as a shell-safe command line with --yes
// appended, or "" when a safe rendering isn't possible (see
// RequireConfirmation).
func retryCommandWithYes(args []string) string {
if len(args) == 0 {
return ""
}
parts := make([]string, 0, len(args)+1)
parts = append(parts, filepath.Base(args[0]))
for _, a := range args[1:] {
if argReadsStdin(a) {
return ""
}
parts = append(parts, shellQuoteArg(a))
}
parts = append(parts, "--yes")
line := strings.Join(parts, " ")
if len(line) > retryCommandMaxLen {
return ""
}
return line
}
// argReadsStdin reports whether an argument makes a flag read from stdin — the
// portable bare "-" value, whether passed as its own token (--flag -) or
// bundled onto the flag (--flag=- / -f=-). Piped stdin is one-shot data a bare
// re-run cannot reproduce, so any such argument suppresses the retry line.
func argReadsStdin(a string) bool {
if a == "-" {
return true
}
if strings.HasPrefix(a, "-") {
if i := strings.IndexByte(a, '='); i >= 0 && a[i+1:] == "-" {
return true
}
}
return false
}
// shellQuoteArg single-quotes an argument when it contains any character a
// POSIX shell could interpret, so the retry line is copy-paste safe.
func shellQuoteArg(s string) string {
if s == "" {
return "''"
}
if !strings.ContainsAny(s, " \t\n\"'\\$`!*?[](){}<>|&;#~") {
return s
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}

View File

@@ -35,8 +35,11 @@ func TestRequireConfirmation_TypedShape(t *testing.T) {
if !strings.Contains(cre.Message, "drive +delete") || !strings.Contains(cre.Message, "requires confirmation") {
t.Errorf("Message = %q, want it to mention action and 'requires confirmation'", cre.Message)
}
if cre.Hint != "add --yes to confirm" {
t.Errorf("Hint = %q, want 'add --yes to confirm'", cre.Hint)
// The hint may additionally carry a re-run line composed from the live
// os.Args (environment-dependent under `go test`), but the add-yes
// contract always leads.
if !strings.HasPrefix(cre.Hint, "add --yes to confirm") {
t.Errorf("Hint = %q, want prefix 'add --yes to confirm'", cre.Hint)
}
if cre.Risk != errs.RiskHighRiskWrite {
t.Errorf("Risk = %q, want %q", cre.Risk, errs.RiskHighRiskWrite)
@@ -61,8 +64,8 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
t.Fatalf("unmarshal: %v", err)
}
// No fix_command field leaks into the envelope: the protocol avoids
// shell-quoting hazards by delegating retry to agent-side logic.
// No fix_command field leaks into the envelope: the retry line lives in
// the free-text hint only; the typed protocol stays action-only.
if _, has := back["fix_command"]; has {
t.Errorf("unexpected fix_command present in JSON: %s", raw)
}
@@ -78,3 +81,46 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
t.Errorf("unexpected upgraded_by present in JSON: %s", raw)
}
}
// TestRetryCommandWithYes pins the retry-line contract: shell-safe quoting,
// basename argv[0], and the two omission guards (stdin args, oversized
// commands).
func TestRetryCommandWithYes(t *testing.T) {
t.Run("quotes what needs quoting and appends --yes", func(t *testing.T) {
got := retryCommandWithYes([]string{
"/usr/local/bin/lark-cli", "sheets", "+cells-clear",
"--url", "https://x.feishu.cn/sheets/tok",
"--range", "A1:B2", "--sheet-name", "第 1 班",
})
want := `lark-cli sheets +cells-clear --url https://x.feishu.cn/sheets/tok --range A1:B2 --sheet-name '第 1 班' --yes`
if got != want {
t.Errorf("got %q, want %q", got, want)
}
})
t.Run("single quotes inside args survive", func(t *testing.T) {
got := retryCommandWithYes([]string{"lark-cli", "x", "--title", "it's"})
if !strings.Contains(got, `'it'\''s'`) {
t.Errorf("got %q", got)
}
})
t.Run("stdin arg omits the retry line", func(t *testing.T) {
if got := retryCommandWithYes([]string{"lark-cli", "sheets", "+batch-update", "--operations", "-"}); got != "" {
t.Errorf("stdin invocation must not render a retry line, got %q", got)
}
})
t.Run("bundled stdin flag omits the retry line", func(t *testing.T) {
// --flag=- reads stdin the same as --flag -; both must suppress the line.
if got := retryCommandWithYes([]string{"lark-cli", "sheets", "+cells-set", "--cells=-"}); got != "" {
t.Errorf("--flag=- stdin invocation must not render a retry line, got %q", got)
}
})
t.Run("oversized command omits the retry line", func(t *testing.T) {
if got := retryCommandWithYes([]string{"lark-cli", "x", "--operations", strings.Repeat("a", 400)}); got != "" {
t.Errorf("oversized invocation must not render a retry line, got %q", got)
}
})
}

View File

@@ -7,9 +7,12 @@ import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
)
// ResolveInput resolves special input conventions for a raw flag value:
@@ -77,7 +80,25 @@ func ResolveInput(raw string, stdin io.Reader, fileIO fileio.FileIO) (string, er
// ReadInputFile reads path through fileIO. Open/read failures are wrapped with
// path context; fileio.ErrPathValidation remains matchable with errors.Is.
// An absolute path under the system temp dir is read directly instead:
// agents stage generated payloads (@/tmp/ops.json) there as a matter of
// course, and the strict relative-to-cwd policy — load-bearing for uploads
// and drive sync — only cost @file callers a python/stdin detour.
func ReadInputFile(fileIO fileio.FileIO, path string) ([]byte, error) {
resolved, terr := validate.SafeTempAbsInputPath(path)
if terr == nil {
data, err := os.ReadFile(resolved) //nolint:forbidigo // resolved is confined to the system temp dir by SafeTempAbsInputPath
if err != nil {
return nil, wrapInputFileError(path, err)
}
return data, nil
}
if filepath.IsAbs(path) {
// Absolute but outside the temp dir: surface the prescriptive error
// (relative path / temp-dir path / stdin) instead of the generic
// relative-only message the strict validator below would produce.
return nil, fmt.Errorf("invalid file path %q: %w", path, terr)
}
if fileIO == nil {
return nil, fmt.Errorf("file input is not available in this context")
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// sparkCodeMeta holds stable Spark app-role business-code classifications.
// Command-specific recovery guidance belongs in the Apps shortcut layer; the
// numeric code remains the source-specific discriminator on the error envelope.
var sparkCodeMeta = map[int]CodeMeta{
3340001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // request parameters are invalid
3344027: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // role user count exceeds the service limit
3344028: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // role department count exceeds the service limit
3344029: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // role chat count exceeds the service limit
3344030: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // app administrator required
3344031: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // app administrator or developer required
3344034: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid role ID
3344035: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // role does not exist
3344036: {Category: errs.CategoryAPI, Subtype: errs.SubtypeAlreadyExists}, // role ID already exists
3344037: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // app role count exceeds the service limit
3344038: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid role name
3344039: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid role description
3344040: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // unsupported member type
3344041: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid member ID
}
func init() { mergeCodeMeta(sparkCodeMeta, "spark") }

View File

@@ -0,0 +1,59 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import (
"fmt"
"testing"
"github.com/larksuite/cli/errs"
)
func TestLookupCodeMetaSparkRoleCodes(t *testing.T) {
tests := []struct {
code int
category errs.Category
subtype errs.Subtype
}{
{3340001, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{3344027, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
{3344028, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
{3344029, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
{3344030, errs.CategoryAuthorization, errs.SubtypePermissionDenied},
{3344031, errs.CategoryAuthorization, errs.SubtypePermissionDenied},
{3344034, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{3344035, errs.CategoryAPI, errs.SubtypeNotFound},
{3344036, errs.CategoryAPI, errs.SubtypeAlreadyExists},
{3344037, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
{3344038, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{3344039, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{3344040, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{3344041, errs.CategoryAPI, errs.SubtypeInvalidParameters},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%d", tt.code), func(t *testing.T) {
meta, ok := LookupCodeMeta(tt.code)
if !ok {
t.Fatalf("code %d is not registered", tt.code)
}
if meta.Category != tt.category || meta.Subtype != tt.subtype || meta.Retryable {
t.Fatalf("code %d metadata = %+v, want category=%s subtype=%s retryable=false", tt.code, meta, tt.category, tt.subtype)
}
err := BuildAPIError(map[string]any{
"code": tt.code,
"msg": "spark role error",
"log_id": "log-spark-role",
}, ClassifyContext{Identity: "user"})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("BuildAPIError(%d) = %#v, want typed problem", tt.code, err)
}
if problem.Category != tt.category || problem.Subtype != tt.subtype || problem.Code != tt.code || problem.LogID != "log-spark-role" || problem.Retryable {
t.Fatalf("BuildAPIError(%d) problem = %+v", tt.code, problem)
}
})
}
}

View File

@@ -337,7 +337,7 @@ func fakeValueFromPlaceholderName(name string) (string, bool) {
case name == "open_id" || hasPlaceholderToken(tokens, "user", "owner", "participant", "approver", "speaker"):
return "ou_test123", true
case hasPlaceholderToken(tokens, "department", "dept"):
return "od_test123", true
return "od-test123", true
case hasPlaceholderToken(tokens, "message"):
return "om_test123", true
case name == "file_key":

View File

@@ -316,6 +316,13 @@ func TestRunDryRunsMaterializesInlinePlaceholderFlagValues(t *testing.T) {
}
}
func TestFakeValueFromPlaceholderNameUsesOpenDepartmentPrefix(t *testing.T) {
got, ok := fakeValueFromPlaceholderName("open_department_id")
if !ok || got != "od-test123" {
t.Fatalf("open_department_id placeholder = %q, %v; want od-test123, true", got, ok)
}
}
func TestRunDryRunsMaterializesNumericPlaceholderFlagValues(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/vc/v1/bots/events","params":{"meeting_id":"400000000001","page_size":50}}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{

View File

@@ -17,6 +17,12 @@ func SafeInputPath(path string) (string, error) {
return localfileio.SafeInputPath(path)
}
// SafeTempAbsInputPath accepts an absolute read path only when it resolves
// under the system temp dir. Delegates to localfileio.SafeTempAbsInputPath.
func SafeTempAbsInputPath(path string) (string, error) {
return localfileio.SafeTempAbsInputPath(path)
}
// SafeEnvDirPath validates an environment-provided application directory path.
// Delegates to localfileio.SafeEnvDirPath.
func SafeEnvDirPath(path, envName string) (string, error) {

View File

@@ -5,6 +5,7 @@ package localfileio
import (
"fmt"
"os"
"path/filepath"
"strings"
@@ -18,10 +19,35 @@ func SafeOutputPath(path string) (string, error) {
}
// SafeInputPath validates an upload/read source path for --file flags.
// Deliberately strict (relative-to-cwd only): several callers — drive sync,
// upload flags, the CI quality gates — treat "absolute paths rejected" as a
// load-bearing invariant. The one deliberate exception is the @file payload
// expansion, which layers SafeTempAbsInputPath on top (see cmdutil).
func SafeInputPath(path string) (string, error) {
return safePath(path, "--file")
}
// SafeTempAbsInputPath accepts an absolute READ path only when it resolves
// under the canonical system temp dir. Agents stage generated payloads
// (batch operations JSON, CSV) in /tmp as a matter of course, and rejecting
// @/tmp/ops.json only pushed them through an extra python/stdin round trip
// (recurring friction cluster in eval traces). Reads under os.TempDir()
// carry no write risk and no project-escape risk. Errors for anything else
// (relative paths included) — callers fall back to SafeInputPath semantics.
func SafeTempAbsInputPath(path string) (string, error) {
if err := charcheck.RejectControlChars(path, "--file"); err != nil {
return "", err
}
if !isAbsolutePath(path) {
return "", fmt.Errorf("--file %q is not an absolute path", path)
}
resolved, ok := absPathUnderTempDir(path)
if !ok {
return "", fmt.Errorf("--file must be a relative path within the current directory, or an absolute path under the system temp dir (%s), got %q (hint: use ./filename or a %s path; flags that support stdin can read any file via '-' instead)", os.TempDir(), path, os.TempDir())
}
return resolved, nil
}
// SafeLocalFlagPath validates a flag value as a local file path.
// Empty values and http/https URLs are returned unchanged without validation.
func SafeLocalFlagPath(flagName, value string) (string, error) {
@@ -96,6 +122,26 @@ func safePath(raw, flagName string) (string, error) {
return resolved, nil
}
// absPathUnderTempDir accepts an absolute path only when, after cleaning and
// resolving symlinks (through the nearest existing ancestor for
// not-yet-created files), it still lives under the canonical system temp dir.
// A symlink inside the temp dir pointing outside it resolves outside and is
// rejected.
func absPathUnderTempDir(raw string) (string, bool) {
canonicalTmp, err := filepath.EvalSymlinks(os.TempDir())
if err != nil {
return "", false
}
resolved, err := resolveNearestAncestor(filepath.Clean(raw))
if err != nil {
return "", false
}
if !isUnderDir(resolved, canonicalTmp) || resolved == canonicalTmp {
return "", false
}
return resolved, true
}
func resolveNearestAncestor(path string) (string, error) {
var tail []string
cur := path

View File

@@ -175,7 +175,7 @@ func TestSafeOutputPath_DeepNonExistentPathStaysInCWD(t *testing.T) {
}
}
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
func TestSafeUploadPath_RejectsTempFileAbsolutePath(t *testing.T) {
// GIVEN: a real temp file (absolute path under os.TempDir())
f, err := os.CreateTemp("", "upload-test-*.bin")
if err != nil {
@@ -185,15 +185,67 @@ func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
f.Close()
t.Cleanup(func() { os.Remove(tmpPath) })
// WHEN: SafeUploadPath validates the absolute temp path
// WHEN: SafeInputPath validates the absolute temp path
_, err = SafeInputPath(tmpPath)
// THEN: absolute paths are rejected even in temp dir
// THEN: the strict validator rejects it — uploads / drive sync rely on
// relative-only; temp-dir reads go through SafeTempAbsInputPath instead
if err == nil {
t.Fatal("expected error for absolute temp path, got nil")
}
}
func TestSafeTempAbsInputPath(t *testing.T) {
t.Run("accepts a file under the temp dir", func(t *testing.T) {
f, err := os.CreateTemp("", "payload-*.json")
if err != nil {
t.Fatalf("CreateTemp: %v", err)
}
tmpPath := f.Name()
f.Close()
t.Cleanup(func() { os.Remove(tmpPath) })
resolved, err := SafeTempAbsInputPath(tmpPath)
if err != nil {
t.Fatalf("expected temp path accepted, got %v", err)
}
canonical, err := filepath.EvalSymlinks(tmpPath)
if err != nil {
t.Fatalf("EvalSymlinks: %v", err)
}
if resolved != canonical {
t.Fatalf("resolved = %q, want %q", resolved, canonical)
}
})
t.Run("rejects relative paths", func(t *testing.T) {
if _, err := SafeTempAbsInputPath("./ops.json"); err == nil {
t.Fatal("expected error for relative path, got nil")
}
})
t.Run("rejects absolute paths outside the temp dir", func(t *testing.T) {
if _, err := SafeTempAbsInputPath("/etc/passwd"); err == nil {
t.Fatal("expected error for non-temp absolute path, got nil")
}
})
t.Run("rejects a temp-dir symlink escaping outside", func(t *testing.T) {
dir, err := os.MkdirTemp("", "escape-*")
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
link := filepath.Join(dir, "escape.json")
if err := os.Symlink("/etc/passwd", link); err != nil {
t.Skipf("symlink not supported: %v", err)
}
if _, err := SafeTempAbsInputPath(link); err == nil {
t.Fatal("expected error for symlink escaping the temp dir, got nil")
}
})
}
func TestSafeUploadPath_RejectsNonTempAbsolutePath(t *testing.T) {
for _, tt := range []struct {
name string

View File

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

View File

@@ -18,6 +18,11 @@ workflow_permissions="$(awk '
in_permissions && /^[^[:space:]]/ { exit }
in_permissions { print }
' "$workflow")"
workflow_concurrency="$(awk '
/^concurrency:/ { in_concurrency = 1; print; next }
in_concurrency && /^[^[:space:]]/ { exit }
in_concurrency { print }
' "$workflow")"
fast_gate_section="$(job_section fast-gate)"
unit_test_section="$(job_section unit-test)"
lint_section="$(awk '
@@ -46,6 +51,27 @@ results_section="$(awk '
in_job { print }
' "$workflow")"
fork_safe_guard="github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork"
live_job_condition="always() && ($fork_safe_guard) && needs.unit-test.result == 'success' && needs.lint.result == 'success' && needs.script-test.result == 'success' && needs.deterministic-gate.result == 'success' && needs.e2e-dry-run.result == 'success' && (needs.e2e-dry-run.outputs.mode == 'full' || needs.e2e-dry-run.outputs.mode == 'subset') && needs.e2e-dry-run.outputs.live_packages != ''"
if ! grep -Fq "run-name: \${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}" "$workflow"; then
echo "CI should expose a stable PR generation while preserving default push and manual run titles" >&2
exit 1
fi
if ! grep -Fq "RUN_GENERATION: \${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}" <<<"$section"; then
echo "the supersession generation should match the PR-only run name" >&2
exit 1
fi
if ! grep -Fq 'group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}' <<<"$workflow_concurrency"; then
echo "CI should deduplicate runs for the same pull request without grouping push or manual runs" >&2
exit 1
fi
if ! grep -Fq "cancel-in-progress: \${{ github.event_name == 'pull_request' }}" <<<"$workflow_concurrency"; then
echo "CI should cancel superseded pull request runs but preserve push and manual runs" >&2
exit 1
fi
for denied_permission in "checks: write" "pull-requests: write" "issues: write"; do
if grep -Eq "^[[:space:]]*${denied_permission}$" <<<"$workflow_permissions"; then
@@ -210,8 +236,84 @@ if ! grep -Fq "deterministic-gate" <<<"$results_section"; then
exit 1
fi
if ! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
echo "e2e-live should run on push and same-repository pull_request, but skip fork pull_request"
if ! grep -Fq "if: \${{ $live_job_condition }}" <<<"$section"; then
echo "e2e-live should preserve active cleanup while requiring a successful non-skip dry run and excluding fork pull requests"
exit 1
fi
if ! grep -Fq "needs: [unit-test, lint, script-test, deterministic-gate, e2e-dry-run]" <<<"$section"; then
echo "e2e-live should wait outside the exclusive queue until e2e-dry-run finishes"
exit 1
fi
if ! grep -Fq "timeout-minutes: 20" <<<"$dry_run_section"; then
echo "e2e-dry-run should bound the planning gate before live E2E" >&2
exit 1
fi
if ! grep -Fq "timeout-minutes: 30" <<<"$section"; then
echo "e2e-live should release the repository-wide slot after 30 minutes" >&2
exit 1
fi
if ! grep -Fq "group: lark-cli-e2e-live" <<<"$section"; then
echo "e2e-live should use one repository-wide execution slot" >&2
exit 1
fi
if ! grep -Fq "cancel-in-progress: false" <<<"$section"; then
echo "e2e-live should queue waiting runs instead of cancelling an active live test" >&2
exit 1
fi
if ! grep -Fq "queue: max" <<<"$section"; then
echo "e2e-live should preserve queued runs instead of replacing an existing pending run" >&2
exit 1
fi
if ! grep -Fq "actions: read" <<<"$section"; then
echo "e2e-live should use read-only Actions access for the supersession check" >&2
exit 1
fi
live_test_step="$(awk '
/^ - name: Run CLI E2E tests/ { in_step = 1 }
in_step { print }
in_step && /^ - name: Publish CLI E2E test report/ { exit }
' <<<"$section")"
if ! grep -Fq "if: \${{ always() && steps.build_cli.outcome == 'success' && steps.live_e2e_tat.outcome == 'success' }}" <<<"$live_test_step"; then
echo "the active live test step should survive ordinary workflow supersession only after setup succeeds" >&2
exit 1
fi
for required in \
'gh api "repos/$REPOSITORY/actions/runs/$RUN_ID"' \
'gh api --paginate -X GET "repos/$REPOSITORY/actions/workflows/$workflow_id/runs"' \
'-f event=pull_request -f branch="$GITHUB_HEAD_REF" -f per_page=100' \
'.head_repository.full_name == $repository and .display_title == $generation and .run_number > $run_number' \
'::error::Superseded before live E2E started' \
'exit 1'; do
if ! grep -Fq -- "$required" <<<"$live_test_step"; then
echo "the live startup check should fail closed before a superseded run starts live E2E: missing $required" >&2
exit 1
fi
done
if ! awk '
/if \[ -n "\$newer_runs" \]; then/ { superseded_state = 1; next }
superseded_state == 1 && /::error::Superseded before live E2E started/ { superseded_state = 2; next }
superseded_state == 2 && /^[[:space:]]+exit 1[[:space:]]*$/ { superseded_state = 3; next }
superseded_state > 0 && /^[[:space:]]+fi[[:space:]]*$/ {
if (superseded_state != 3) exit 2
superseded_closed = 1
superseded_state = 0
next
}
/go run gotest.tools\/gotestsum@/ { test_started = 1; if (!superseded_closed) exit 3 }
END { exit superseded_closed && test_started ? 0 : 1 }
' <<<"$live_test_step"; then
echo "a superseded live run must stop before gotestsum starts" >&2
exit 1
fi
@@ -222,6 +324,39 @@ if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$dry_run_section" ||
exit 1
fi
for output in \
'mode: ${{ steps.e2e_domains.outputs.mode }}' \
'reason: ${{ steps.e2e_domains.outputs.reason }}' \
'live_packages: ${{ steps.e2e_domains.outputs.live_packages }}'; do
if ! grep -Fq "$output" <<<"$dry_run_section"; then
echo "e2e-dry-run should publish $output for the live job" >&2
exit 1
fi
done
for validation_contract in \
'case "$E2E_MODE" in' \
'skip)' \
'[ -z "$E2E_LIVE_PACKAGES" ]' \
'full|subset)' \
'[ -n "$E2E_LIVE_PACKAGES" ]' \
'Invalid CLI E2E mode' \
'exit 1'; do
if ! grep -Fq "$validation_contract" <<<"$dry_run_section"; then
echo "e2e-dry-run should fail invalid domain output before live can be skipped: missing $validation_contract" >&2
exit 1
fi
done
if ! awk '
/- name: Validate CLI E2E domain outputs/ { validated = 1 }
/- name: Build lark-cli/ { exit validated ? 0 : 1 }
END { if (!validated) exit 1 }
' <<<"$dry_run_section"; then
echo "e2e-dry-run should validate domain outputs before building" >&2
exit 1
fi
if ! grep -Fq "steps.e2e_domains.outputs.dry_packages" <<<"$dry_run_section"; then
echo "e2e-dry-run should use resolved dry_packages instead of always running the full suite"
exit 1
@@ -244,21 +379,21 @@ if ! grep -Fq "No dry-run CLI E2E needed" <<<"$dry_run_section"; then
exit 1
fi
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
! grep -Fq "id: e2e_domains" <<<"$section" ||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
echo "e2e-live should resolve changed-file CLI E2E domains before credentials and tests"
if grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
echo "e2e-live should reuse e2e-dry-run outputs instead of resolving domains again"
exit 1
fi
if ! grep -Fq "steps.e2e_domains.outputs.live_packages" <<<"$section"; then
echo "e2e-live should use resolved live_packages instead of always running the full suite"
if ! grep -Fq "E2E_LIVE_PACKAGES: \${{ needs.e2e-dry-run.outputs.live_packages }}" <<<"$section"; then
echo "e2e-live should reuse live_packages resolved by e2e-dry-run"
exit 1
fi
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$section" ||
if ! grep -Fq "E2E_MODE: \${{ needs.e2e-dry-run.outputs.mode }}" <<<"$section" ||
! grep -Fq "E2E_REASON: \${{ needs.e2e-dry-run.outputs.reason }}" <<<"$section" ||
! grep -Fq 'echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$section"; then
echo "e2e-live should pass dynamic domain output through env before shell use"
echo "e2e-live should consume the exact mode and reason produced by e2e-dry-run"
exit 1
fi
@@ -272,16 +407,23 @@ if ! awk '
exit 1
fi
if ! awk '
/^ - name: Build lark-cli/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should skip building lark-cli when domain mode is skip"
if grep -Fq "steps.e2e_domains.outputs" <<<"$section"; then
echo "e2e-live should not retain step-local domain outputs after adopting the dry-run job gate"
exit 1
fi
for step_name in "Build lark-cli" "Prepare shared live E2E tenant token"; do
live_setup_step="$(awk -v name="$step_name" '
$0 == " - name: " name { in_step = 1 }
in_step { print }
in_step && /^ - name:/ && $0 != " - name: " name { exit }
' <<<"$section")"
if grep -Eq '^ if:' <<<"$live_setup_step"; then
echo "e2e-live $step_name should run unconditionally after the non-skip job gate" >&2
exit 1
fi
done
if ! grep -Fq "permissions:" <<<"$section" ||
! grep -Fq "contents: read" <<<"$section" ||
! grep -Fq "checks: write" <<<"$section"; then
@@ -299,18 +441,88 @@ if grep -Fq "live_e2e_credentials" <<<"$section" || grep -Fq "configured=false"
exit 1
fi
if ! grep -Fq "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET" <<<"$section"; then
echo "e2e-live should make missing bot credentials a visible configuration failure on eligible runs"
if ! grep -Fq "node scripts/fetch_e2e_tat.js" <<<"$section"; then
echo "e2e-live should fetch the tenant token via the dedicated script"
exit 1
fi
if grep -Fq "config init" <<<"$section"; then
echo "e2e-live should use env credentials instead of config init"
exit 1
fi
if ! grep -Fq "TEST_BOT1_APP_ID: \${{ secrets.TEST_BOT1_APP_ID }}" <<<"$section"; then
echo "e2e-live should keep the bot app id under a test-only job env name"
exit 1
fi
if awk '
/^ e2e-live:/ { in_job = 1; next }
in_job && /^ [A-Za-z0-9_-]+:/ { in_job = 0 }
in_job && /^ env:/ { in_env = 1; next }
in_env && /^ steps:/ { in_env = 0 }
in_env && /LARKSUITE_CLI_APP_ID:/ { found_standard_app_id = 1 }
END { exit found_standard_app_id ? 0 : 1 }
' "$workflow"; then
echo "e2e-live should not activate the env credential provider at job scope"
exit 1
fi
if ! grep -Fq "LARKSUITE_CLI_BRAND: feishu" <<<"$section"; then
echo "e2e-live should pin the env credential brand to feishu"
exit 1
fi
if awk '
/^ e2e-live:/ { in_job = 1; next }
in_job && /^ [A-Za-z0-9_-]+:/ { in_job = 0 }
in_job && /^ env:/ { in_env = 1; next }
in_env && /^ steps:/ { in_env = 0 }
in_env && /(SECRET|ACCESS_TOKEN):/ { found_sensitive = 1 }
END { exit found_sensitive ? 0 : 1 }
' "$workflow"; then
echo "e2e-live should not expose live E2E credentials through job-level env"
exit 1
fi
if ! awk '
/^ - name: Configure bot credentials/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Configure bot credentials/ { in_step = 0 }
END { exit found ? 0 : 1 }
/^ - name: Prepare shared live E2E tenant token/ { in_step = 1 }
in_step && /id: live_e2e_tat/ { has_id = 1 }
in_step && /^ if:/ { has_if = 1 }
in_step && /LARKSUITE_CLI_APP_ID: \$\{\{ secrets\.TEST_BOT1_APP_ID \}\}/ { has_app_id = 1 }
in_step && /secrets\.TEST_BOT1_APP_SECRET/ { has_bot_credential = 1 }
in_step && /node scripts\/fetch_e2e_tat\.js/ { has_script = 1 }
in_step && /GITHUB_ENV/ { uses_github_env = 1 }
in_step && /^ - name:/ && !/Prepare shared live E2E tenant token/ { in_step = 0 }
END { exit has_id && !has_if && has_app_id && has_bot_credential && has_script && !uses_github_env ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should only configure bot credentials when domain mode is not skip"
echo "e2e-live should pass only a private tenant token file path through step output"
exit 1
fi
if ! awk '
/^ - name: Run CLI E2E tests/ { in_step = 1 }
in_step && /E2E_TENANT_AUTH_FILE: \$\{\{ steps\.live_e2e_tat\.outputs\.path \}\}/ { has_file = 1 }
in_step && /secrets\.TEST_USER_ACCESS_TOKEN/ { has_user_credential = 1 }
in_step && /Missing shared live E2E tenant token file/ { checks_file = 1 }
in_step && /^ *export / && /TEST_TENANT_ACCESS_TOKEN/ && /E2E_TENANT_AUTH_FILE/ { exports_test_tat = 1 }
in_step && /^ *export / && /LARKSUITE_CLI_TENANT_ACCESS_TOKEN/ { exports_standard_tat = 1 }
in_step && /LARKSUITE_CLI_APP_ID="\$TEST_BOT1_APP_ID"/ { scopes_preflight_app_id = 1 }
in_step && /LARKSUITE_CLI_TENANT_ACCESS_TOKEN="\$TEST_TENANT_ACCESS_TOKEN"/ { scopes_preflight_tat = 1 }
in_step && /lark-cli whoami --as bot/ { has_preflight = 1 }
in_step && /Tenant credential preflight failed/ { checks_preflight = 1 }
in_step && /TEST_USER_ACCESS_TOKEN/ && /secrets\.TEST_USER_ACCESS_TOKEN/ { has_user_env = 1 }
in_step && /LARKSUITE_CLI_USER_ACCESS_TOKEN/ && /secrets\.TEST_USER_ACCESS_TOKEN/ { has_global_user_env = 1 }
in_step && /trap / { has_trap = 1 }
in_step && /^ - name:/ && !/Run CLI E2E tests/ { in_step = 0 }
END { exit has_file && has_user_credential && checks_file && exports_test_tat && !exports_standard_tat && scopes_preflight_app_id && scopes_preflight_tat && has_preflight && checks_preflight && has_user_env && !has_global_user_env && !has_trap ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should expose live E2E credentials only inside the test shell step"
exit 1
fi
if grep -Fq 'if [ "$E2E_MODE" = "skip" ]' <<<"$section"; then
echo "e2e-live should not retain an unreachable step-level skip branch"
exit 1
fi
@@ -319,8 +531,8 @@ if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
exit 1
fi
if ! grep -Fq "if: \${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}" <<<"$section"; then
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled or domain mode is skip"
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled"
exit 1
fi
@@ -342,7 +554,7 @@ if grep -Fq '${{ secrets.CODECOV_TOKEN }}' <<<"$coverage_step" &&
fi
if grep -Fq '${{ secrets.' <<<"$section" &&
! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
! grep -Fq "$fork_safe_guard" <<<"$section"; then
echo "live E2E secrets should be available on push and same-repository pull_request, but not fork pull_request" >&2
exit 1
fi

164
scripts/fetch_e2e_tat.js Normal file
View File

@@ -0,0 +1,164 @@
#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Fetches a live E2E tenant access token (TAT) for the shared bot identity.
//
// Invoked from the e2e-live CI job. Exchanges the bot app id/secret for a
// tenant access token, writes the token to a private file under $RUNNER_TEMP,
// and emits the file path as a step output so the test step can read it once
// and then delete it.
//
// The secret arrives via environment variables; the OAuth parameter names are
// literal because this is a source code file (.js), so the quality gate's
// benign-code-credential exemption applies to the process.env references.
const fs = require("node:fs");
const http = require("node:http");
const https = require("node:https");
const path = require("node:path");
const { URL } = require("node:url");
const ENDPOINT = process.env.E2E_TAT_ENDPOINT || "https://accounts.feishu.cn/oauth/v3/token";
const MAX_ATTEMPTS = 4;
const RETRY_BASE_MS = parseInt(process.env.E2E_TAT_RETRY_BASE_MS || "1000", 10);
function requireEnv(name) {
const value = process.env[name];
if (!value) {
console.error(`::error::Missing required environment variable: ${name}`);
process.exit(1);
}
return value;
}
function postForm(url, body) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const transport = parsed.protocol === "http:" ? http : https;
const req = transport.request(
parsed,
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": Buffer.byteLength(body),
},
timeout: 20000,
},
(resp) => {
const chunks = [];
let settled = false;
const rejectOnce = (error) => {
if (!settled) {
settled = true;
reject(error);
}
};
resp.on("data", (chunk) => chunks.push(chunk));
resp.on("aborted", () => rejectOnce(new Error("response aborted before completion")));
resp.on("error", rejectOnce);
resp.on("close", () => {
if (!resp.complete) {
rejectOnce(new Error("response closed before completion"));
}
});
resp.on("end", () => {
if (!resp.complete) {
rejectOnce(new Error("response ended before completion"));
return;
}
settled = true;
resolve({
status: resp.statusCode,
body: Buffer.concat(chunks).toString("utf8"),
headers: resp.headers,
});
});
},
);
req.on("timeout", () => {
req.destroy();
reject(new Error("request timed out"));
});
req.on("error", reject);
req.write(body);
req.end();
});
}
function encodeForm(params) {
return Object.entries(params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join("&");
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchTenantToken() {
const appId = requireEnv("LARKSUITE_CLI_APP_ID");
const appSecret = requireEnv("TEST_BOT1_APP_SECRET");
const body = encodeForm({
grant_type: "client_credentials",
client_id: appId,
client_secret: appSecret,
});
let lastError = "";
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
const { status, body: respBody, headers } = await postForm(ENDPOINT, body);
let payload;
try {
payload = JSON.parse(respBody);
} catch {
const logID = headers["x-tt-logid"] || headers["x-request-id"] || "unavailable";
lastError = `HTTP ${status}, log_id=${logID}, non-JSON response`;
}
if (payload) {
const token = payload.access_token;
if (status === 200 && payload.code === 0 && token) {
return token;
}
lastError = `HTTP ${status}, code=${payload.code}, error=${payload.error}, msg=${payload.msg || payload.error_description}`;
}
} catch (err) {
lastError = err.message;
}
if (attempt < MAX_ATTEMPTS) {
await sleep(2 ** (attempt - 1) * RETRY_BASE_MS);
}
}
console.error(`::error::Failed to fetch tenant access token: ${lastError}`);
process.exit(1);
}
async function main() {
const token = await fetchTenantToken();
console.log(`::add-mask::${token}`);
const tatPath = path.join(process.env.RUNNER_TEMP, "e2e-live-tat");
fs.writeFileSync(tatPath, token, { encoding: "utf8", mode: 0o600 });
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `path=${tatPath}\n`);
}
console.log("Prepared shared live E2E tenant token");
}
if (require.main === module) {
main();
}
module.exports = {
encodeForm,
fetchTenantToken,
postForm,
requireEnv,
};

View File

@@ -0,0 +1,203 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const assert = require("node:assert/strict");
const fs = require("node:fs");
const http = require("node:http");
const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const test = require("node:test");
const scriptPath = path.join(__dirname, "fetch_e2e_tat.js");
function startServer(handler) {
const server = http.createServer((req, res) => {
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
handler(req, res, body);
});
});
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const port = server.address().port;
resolve({ server, port });
});
});
}
function abortResponse(res) {
res.writeHead(200, {
"Content-Type": "application/json",
"Content-Length": "100",
});
res.write('{"code":0');
setImmediate(() => res.destroy());
}
function runScript(envOverrides) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fetch-e2e-tat-"));
const githubOutput = path.join(tmpDir, "github-output");
const env = {
...process.env,
LARKSUITE_CLI_APP_ID: "test_app_id",
TEST_BOT1_APP_SECRET: "test-secret",
RUNNER_TEMP: tmpDir,
GITHUB_OUTPUT: githubOutput,
E2E_TAT_RETRY_BASE_MS: "10",
...envOverrides,
};
return new Promise((resolve) => {
const child = spawn(process.execPath, [scriptPath], {
cwd: path.join(__dirname, ".."),
env,
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (data) => {
stdout += data;
});
child.stderr.on("data", (data) => {
stderr += data;
});
child.on("close", (code) => {
const output = fs.existsSync(githubOutput)
? fs.readFileSync(githubOutput, "utf8")
: "";
resolve({ tmpDir, stdout, stderr, output, exitCode: code });
});
});
}
test("encodeForm encodes form parameters", () => {
const { encodeForm } = require(scriptPath);
const result = encodeForm({
grant_type: "client_credentials",
client_id: "abc&def",
client_secret: "test-secret",
note: "x=y",
});
const params = new URLSearchParams(result);
assert.equal(params.get("grant_type"), "client_credentials");
assert.equal(params.get("client_id"), "abc&def");
assert.equal(params.get("client_secret"), "test-secret");
assert.equal(params.get("note"), "x=y");
});
test("exits with error when app id is missing", async () => {
const result = await runScript({ LARKSUITE_CLI_APP_ID: "" });
assert.notEqual(result.exitCode, 0);
assert.match(result.stderr, /Missing required environment variable: LARKSUITE_CLI_APP_ID/);
});
test("exits with error when app secret is missing", async () => {
const result = await runScript({ TEST_BOT1_APP_SECRET: "" });
assert.notEqual(result.exitCode, 0);
assert.match(result.stderr, /Missing required environment variable: TEST_BOT1_APP_SECRET/);
});
test("fetches token and writes it to a private file", async () => {
const { server, port } = await startServer((req, res, body) => {
assert.equal(req.method, "POST");
const params = new URLSearchParams(body);
assert.equal(params.get("grant_type"), "client_credentials");
assert.equal(params.get("client_id"), "test_app_id");
assert.equal(params.get("client_secret"), "test-secret");
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ code: 0, access_token: "test-token" }));
});
try {
const result = await runScript({
E2E_TAT_ENDPOINT: `http://127.0.0.1:${port}/token`,
});
assert.equal(result.exitCode, 0, `stderr: ${result.stderr}`);
assert.ok(result.stdout.includes("::add-mask::test-token"));
assert.ok(result.stdout.includes("Prepared shared live E2E tenant token"));
const tatPath = path.join(result.tmpDir, "e2e-live-tat");
assert.ok(fs.existsSync(tatPath), "token file should exist");
const stat = fs.statSync(tatPath);
assert.equal(stat.mode & 0o777, 0o600, "token file should be owner-only");
assert.equal(fs.readFileSync(tatPath, "utf8"), "test-token");
assert.ok(
result.output.includes(`path=${tatPath}`),
"should write path to GITHUB_OUTPUT",
);
} finally {
server.close();
}
});
test("retries an interrupted response and then succeeds", async () => {
let requestCount = 0;
const { server, port } = await startServer((req, res) => {
requestCount++;
if (requestCount === 1) {
abortResponse(res);
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ code: 0, access_token: "test-token" }));
});
try {
const result = await runScript({
E2E_TAT_ENDPOINT: `http://127.0.0.1:${port}/token`,
});
assert.equal(result.exitCode, 0, `stderr: ${result.stderr}`);
assert.equal(requestCount, 2);
} finally {
server.close();
}
});
test("fails after every interrupted response is retried", async () => {
let requestCount = 0;
const { server, port } = await startServer((req, res) => {
requestCount++;
abortResponse(res);
});
try {
const result = await runScript({
E2E_TAT_ENDPOINT: `http://127.0.0.1:${port}/token`,
});
assert.notEqual(result.exitCode, 0);
assert.equal(requestCount, 4);
assert.match(result.stderr, /Failed to fetch tenant access token/);
} finally {
server.close();
}
});
test("exits with error after all retries fail", async () => {
let requestCount = 0;
const { server, port } = await startServer((req, res) => {
requestCount++;
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ code: 500, error: "server error" }));
});
try {
const result = await runScript({
E2E_TAT_ENDPOINT: `http://127.0.0.1:${port}/token`,
});
assert.notEqual(result.exitCode, 0);
assert.equal(requestCount, 4);
assert.match(result.stderr, /Failed to fetch tenant access token/);
} finally {
server.close();
}
});

View File

@@ -20,7 +20,7 @@ func TestAppsAccessScopeGet_Specific(t *testing.T) {
"data": map[string]interface{}{
"scope": "Range",
"users": []interface{}{"ou_x", "ou_y"},
"departments": []interface{}{"od_z"},
"departments": []interface{}{"od-z"},
"chats": []interface{}{"oc_g"},
"apply_config": map[string]interface{}{
"enabled": true,
@@ -39,7 +39,7 @@ func TestAppsAccessScopeGet_Specific(t *testing.T) {
if !strings.Contains(got, `"scope": "Range"`) {
t.Fatalf("scope string not preserved (expect raw \"Range\"): %s", got)
}
if !strings.Contains(got, `"ou_x"`) || !strings.Contains(got, `"od_z"`) || !strings.Contains(got, `"oc_g"`) {
if !strings.Contains(got, `"ou_x"`) || !strings.Contains(got, `"od-z"`) || !strings.Contains(got, `"oc_g"`) {
t.Fatalf("users/departments/chats fields missing in envelope: %s", got)
}
if !strings.Contains(got, `"ou_appr"`) {

View File

@@ -0,0 +1,253 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsAutomationCreate creates an automation trigger (type-dispatched condition).
var AppsAutomationCreate = common.Shortcut{
Service: appsService,
Command: "+automation-create",
Description: "Create an automation trigger (cron/record-change/webhook/feishu-approval); created disabled",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +automation-create --app-id <id> --name daily --trigger-type cron --cron '0 9 * * *'",
"Example: lark-cli apps +automation-create --app-id <id> --name onUpd --trigger-type record-change --table <tbl> --event UPDATE",
"Example: lark-cli apps +automation-create --app-id <id> --name hook --trigger-type webhook",
"Example: lark-cli apps +automation-create --app-id <id> --name apv --trigger-type feishu-approval --event-type approval_instance --instance-status APPROVED",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "name", Desc: "trigger name (unique within app, <=100 chars)", Required: true},
{Name: "trigger-type", Desc: "cron | record-change | webhook | feishu-approval", Required: true},
{Name: "description", Desc: "optional description (<=50 chars)"},
{Name: "cron", Desc: "[cron] 5-field cron expression, e.g. '0 9 * * *' (min interval 30m)"},
{Name: "timezone", Desc: "[cron] IANA timezone (default Asia/Shanghai)"},
{Name: "table", Desc: "[record-change] table name (from `+db-table-list`); dataloom tables key by name, not id"},
{Name: "event", Desc: "[record-change] INSERT | UPDATE | UPSERT | DELETE"},
{Name: "fields", Desc: "[record-change] JSON array of field ids for UPDATE/UPSERT, [\"*\"] = all"},
{Name: "white-ip-list", Desc: "[webhook] JSON array of allowed IPs"},
{Name: "approval-code", Desc: "[feishu-approval] approval definition code; omit to match all approval definitions"},
{Name: "event-type", Desc: "[feishu-approval] approval_instance | approval_task"},
{Name: "instance-status", Type: "string_array", Desc: "[feishu-approval] statuses for approval_instance"},
{Name: "task-status", Type: "string_array", Desc: "[feishu-approval] statuses for approval_task"},
{Name: "status", Desc: "optional initial status: enabled | disabled (default disabled; backend supports create+enable in one call)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if strings.TrimSpace(rctx.Str("name")) == "" {
return appsValidationParamError("--name", "--name is required")
}
cliType := strings.TrimSpace(rctx.Str("trigger-type"))
if cliType == "" {
return appsValidationParamError("--trigger-type", "--trigger-type is required (cron/record-change/webhook/feishu-approval)")
}
// mapTriggerType also runs inside buildAutomationCreateBody, but
// re-running it up-front keeps the cross-family guard's error
// reachable — otherwise an unknown --trigger-type would bail out
// with the same guard's "belongs to trigger-type" wording, which
// misleads callers who typoed the type itself.
if _, err := mapTriggerType(cliType); err != nil {
return err
}
// Reject condition flags that do not belong to the selected type.
// buildAutomationCreateBody's switch used to silently drop them
// (e.g. --trigger-type webhook --cron '0 9 * * *' created a webhook
// with no cron, though the caller believed --cron was set).
if err := rejectCrossFamilyCondFlags(rctx, cliType); err != nil {
return err
}
_, err := buildAutomationCreateBody(rctx)
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
body, _ := buildAutomationCreateBody(rctx)
return common.NewDryRunAPI().
POST(automationListPath(appID)).
Desc("Create automation trigger").
Body(body)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
body, err := buildAutomationCreateBody(rctx)
if err != nil {
return err
}
data, err := rctx.CallAPITyped("POST", automationListPath(appID), nil, body)
if err != nil {
return withAppsHint(err, appIDListHint)
}
// Bearer-token redaction reverse invariant: the backend create path
// re-reads the freshly created trigger through the same read-path
// converter used by get/list — theoretically capable of returning a
// plaintext bearer token. On a fresh create the token is not yet
// enabled and this response should not carry plaintext, but redact
// for defense-in-depth and to keep every read-shaped output path
// (create / get / list / update-patch) consistently scrubbed.
redacted := redactWebhookToken(data)
trigger, _ := redacted["trigger"].(map[string]interface{})
rctx.OutFormat(redacted, nil, func(w io.Writer) {
fmt.Fprintf(w, "created trigger: %v [%v] status: %v\n",
trigger["name"], trigger["trigger_type"], trigger["status"])
})
return nil
},
}
// buildAutomationCreateBody assembles {name, description?, trigger_type, <type>_condition}.
func buildAutomationCreateBody(rctx *common.RuntimeContext) (map[string]interface{}, error) {
cliType := strings.TrimSpace(rctx.Str("trigger-type"))
snake, err := mapTriggerType(cliType)
if err != nil {
return nil, err
}
name := strings.TrimSpace(rctx.Str("name"))
if err := validateAutomationNameLen(name); err != nil {
return nil, err
}
body := map[string]interface{}{
"name": name,
"trigger_type": snake,
}
if d := strings.TrimSpace(rctx.Str("description")); d != "" {
if err := validateAutomationDescriptionLen(d); err != nil {
return nil, err
}
body["description"] = d
}
// --status is an optional passthrough: when set, backend creates + enables
// (or leaves disabled) in one call. Omitting the field lets the backend
// default (disabled) apply, matching the spec's default-disabled invariant.
if s := strings.TrimSpace(rctx.Str("status")); s != "" {
if s != "enabled" && s != "disabled" {
return nil, appsValidationParamError("--status",
"--status must be enabled or disabled, got %q", s)
}
body["status"] = s
}
switch cliType {
case "cron":
cond, err := buildCronCondition(rctx.Str("cron"), rctx.Str("timezone"))
if err != nil {
return nil, err
}
body["cron_condition"] = cond
case "record-change":
fields, err := parseFieldsFlag(rctx.Str("fields"))
if err != nil {
return nil, err
}
cond, err := buildRecordChangeCondition(rctx.Str("table"), rctx.Str("event"), fields)
if err != nil {
return nil, err
}
body["record_change_condition"] = cond
case "webhook":
ipList, err := parseIPListFlag(rctx.Str("white-ip-list"))
if err != nil {
return nil, err
}
body["webhook_condition"] = buildWebhookCondition(ipList)
case "feishu-approval":
eventType := strings.TrimSpace(rctx.Str("event-type"))
if eventType == "" {
return nil, appsValidationParamError("--event-type", "--event-type is required for feishu-approval (approval_instance/approval_task)")
}
raw := rctx.StrArray("instance-status")
if eventType == "approval_task" {
raw = rctx.StrArray("task-status")
}
// buildApprovalCondition stores the passed statuses verbatim (it only
// uppercases for validation), so normalize to the uppercase enum here to
// guarantee the backend receives canonical values (foundation review).
statuses := normalizeApprovalStatuses(raw)
cond, err := buildApprovalCondition(rctx.Str("approval-code"), eventType, statuses)
if err != nil {
return nil, err
}
body["feishu_approval_condition"] = cond
}
return body, nil
}
// normalizeApprovalStatuses trims and uppercases each status so the body carries
// the canonical enum values expected by the backend.
func normalizeApprovalStatuses(raw []string) []string {
if len(raw) == 0 {
return raw
}
out := make([]string, 0, len(raw))
for _, s := range raw {
out = append(out, strings.ToUpper(strings.TrimSpace(s)))
}
return out
}
// parseFieldsFlag parses --fields JSON array; empty → nil.
func parseFieldsFlag(raw string) ([]string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
var arr []string
if err := json.Unmarshal([]byte(raw), &arr); err != nil {
return nil, appsValidationParamError("--fields", "--fields must be a JSON array of strings: %v", err)
}
return arr, nil
}
// parseIPListFlag parses --white-ip-list JSON array; empty → nil (field
// omitted). Each entry is validated as an IPv4/IPv6 address or CIDR, matching
// the defense-in-depth stance the record-change --event whitelist takes —
// silent acceptance of malformed IPs would let a typoed entry (`"1.1.1.1 "`
// with trailing space, `"not-an-ip"`, or `"10.0.0.256"`) narrow the webhook
// caller allowlist to nothing while the operator believes it is enforcing
// origin restrictions.
func parseIPListFlag(raw string) ([]string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
var arr []string
if err := json.Unmarshal([]byte(raw), &arr); err != nil {
return nil, appsValidationParamError("--white-ip-list", "--white-ip-list must be a JSON array of strings: %v", err)
}
out := make([]string, 0, len(arr))
for i, entry := range arr {
trimmed := strings.TrimSpace(entry)
if trimmed == "" {
return nil, appsValidationParamError("--white-ip-list",
"--white-ip-list entry %d is empty; either drop it or provide a valid IP/CIDR", i)
}
if net.ParseIP(trimmed) != nil {
out = append(out, trimmed)
continue
}
if _, _, cidrErr := net.ParseCIDR(trimmed); cidrErr == nil {
out = append(out, trimmed)
continue
}
return nil, appsValidationParamError("--white-ip-list",
"--white-ip-list entry %d %q is not a valid IPv4/IPv6 address or CIDR block", i, entry)
}
return out, nil
}

View File

@@ -0,0 +1,265 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func automationCreateFlagDefs() map[string]string {
return map[string]string{
"app-id": "string", "name": "string", "trigger-type": "string", "description": "string",
"cron": "string", "timezone": "string",
"table": "string", "event": "string", "fields": "string",
"white-ip-list": "string",
"approval-code": "string", "event-type": "string",
"instance-status": "string_array", "task-status": "string_array",
"status": "string",
}
}
func TestAutomationCreateCron_BuildsBody(t *testing.T) {
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
map[string]string{"app-id": "app_x", "name": "daily", "trigger-type": "cron", "cron": "0 9 * * *"})
// Real backend response wraps the created trigger under `trigger` (a live
// test-env probe confirmed the shape, same as GET/PUT). The Execute pretty
// path reads trigger["name"]/["trigger_type"]/["status"] from that key —
// a flat fixture makes the pretty path print `<nil>` and only passes via
// the JSON envelope, which hides regressions in the pretty branch.
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/triggers",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"trigger": map[string]interface{}{
"name": "daily", "trigger_type": "cron", "status": "disabled",
},
}},
})
if err := AppsAutomationCreate.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
if !strings.Contains(stdoutBuf.String(), "daily") {
t.Errorf("create output must contain trigger name: %s", stdoutBuf.String())
}
}
func TestAutomationCreate_MissingType(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
map[string]string{"app-id": "app_x", "name": "n"})
err := AppsAutomationCreate.Validate(context.Background(), rctx)
assertValidationParamError(t, err, "--trigger-type")
}
// TestAutomationCreate_CrossFamilyFlagsRejected pins the F1 guard: a condition
// flag from a family other than --trigger-type used to be silently dropped by
// buildAutomationCreateBody's single-branch switch, so
// `--trigger-type webhook --cron '0 9 * * *'` created a webhook with no cron
// but returned success. Validate now rejects the cross-family flag up-front.
func TestAutomationCreate_CrossFamilyFlagsRejected(t *testing.T) {
cases := []struct {
name string
flags map[string]string
wantParam string
}{
{"webhook_with_cron",
map[string]string{
"app-id": "app_x", "name": "n", "trigger-type": "webhook",
"cron": "0 9 * * *",
}, "--cron"},
{"cron_with_white_ip_list",
map[string]string{
"app-id": "app_x", "name": "n", "trigger-type": "cron",
"cron": "0 9 * * *", "white-ip-list": `["1.1.1.1"]`,
}, "--white-ip-list"},
{"record_change_with_event_type",
map[string]string{
"app-id": "app_x", "name": "n", "trigger-type": "record-change",
"table": "tbl", "event": "UPDATE", "event-type": "approval_instance",
}, "--event-type"},
{"feishu_approval_with_table",
map[string]string{
"app-id": "app_x", "name": "n", "trigger-type": "feishu-approval",
"event-type": "approval_instance", "instance-status": "APPROVED",
"table": "tbl",
}, "--table"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(), tc.flags)
err := AppsAutomationCreate.Validate(context.Background(), rctx)
assertValidationParamError(t, err, tc.wantParam)
})
}
}
// TestAutomationCreate_UnknownTriggerTypeRejected: --trigger-type must be one
// of the four supported kebab-case values. A typo used to sneak past Validate
// (buildAutomationCreateBody caught it, but only after the cross-family guard
// would otherwise fire with a misleading "belongs to type" message).
func TestAutomationCreate_UnknownTriggerTypeRejected(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
map[string]string{"app-id": "app_x", "name": "n", "trigger-type": "bogus"})
err := AppsAutomationCreate.Validate(context.Background(), rctx)
assertValidationParamError(t, err, "--trigger-type")
}
func TestAutomationCreateCron_Sub30MinRejected(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
map[string]string{"app-id": "app_x", "name": "n", "trigger-type": "cron", "cron": "*/5 * * * *"})
err := AppsAutomationCreate.Validate(context.Background(), rctx)
assertValidationParamError(t, err, "--cron")
}
func TestAutomationCreateRecordChange_MissingEvent(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
map[string]string{"app-id": "app_x", "name": "n", "trigger-type": "record-change", "table": "tbl"})
err := AppsAutomationCreate.Validate(context.Background(), rctx)
assertValidationParamError(t, err, "--event")
}
func TestAutomationCreateApproval_CodeOptional(t *testing.T) {
rctx, _, reg := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
map[string]string{"app-id": "app_x", "name": "n", "trigger-type": "feishu-approval",
"event-type": "approval_instance", "instance-status": "APPROVED"})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/triggers",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"name": "n", "status": "disabled"}},
})
if err := AppsAutomationCreate.Validate(context.Background(), rctx); err != nil {
t.Fatalf("approval without --approval-code must pass validation: %v", err)
}
if err := AppsAutomationCreate.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
}
// TestAutomationCreateApproval_StatusUppercased asserts that a lowercase status
// passed via --instance-status is normalized to the uppercase enum in the body
// before it reaches the backend (foundation review: buildApprovalCondition stores
// the raw statuses, so create must uppercase them itself).
func TestAutomationCreateApproval_StatusUppercased(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
map[string]string{"app-id": "app_x", "name": "n", "trigger-type": "feishu-approval",
"event-type": "approval_instance", "instance-status": "approved"})
body, err := buildAutomationCreateBody(rctx)
if err != nil {
t.Fatalf("buildAutomationCreateBody() = %v", err)
}
cond, ok := body["feishu_approval_condition"].(map[string]interface{})
if !ok {
t.Fatalf("feishu_approval_condition missing or wrong type: %+v", body)
}
statuses, ok := cond["status"].([]string)
if !ok {
t.Fatalf("status must be []string: %+v", cond)
}
if len(statuses) != 1 || statuses[0] != "APPROVED" {
t.Errorf("lowercase status must be uppercased to APPROVED, got %v", statuses)
}
}
// TestAutomationCreate_RedactsWebhookToken covers the bearer-token redaction
// reverse invariant on the create path against the real response shape (a
// live test-env probe confirmed POST wraps the trigger under a `trigger`
// key, same as GET/PUT). The backend create path re-reads the freshly
// created trigger and returns it through the same read-path converter used
// by get/list — theoretically capable of returning a plaintext bearer
// token. Defense-in-depth: CLI create must also redact so every read-shaped
// output path is consistently scrubbed.
func TestAutomationCreate_RedactsWebhookToken(t *testing.T) {
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
map[string]string{"app-id": "app_x", "name": "wh1", "trigger-type": "webhook"})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/triggers",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"trigger": map[string]interface{}{
"name": "wh1", "trigger_type": "webhook", "status": "disabled",
"trigger_condition": map[string]interface{}{
"preview_url": "https://p", "runtime_url": "https://r",
"token_enabled": true, "token_value": "PLAINTEXT_CREATE_TOKEN",
},
},
}},
})
if err := AppsAutomationCreate.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
out := stdoutBuf.String()
if strings.Contains(out, "PLAINTEXT_CREATE_TOKEN") {
t.Errorf("create must never surface plaintext token: %s", out)
}
}
// TestAutomationCreate_StatusPassthrough verifies --status is included in the
// POST body when set. Backend supports create+enable in one call via the
// optional status field; CLI passes it through unchanged.
func TestAutomationCreate_StatusPassthrough(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
map[string]string{
"app-id": "app_x", "name": "n", "trigger-type": "cron",
"cron": "0 9 * * *", "status": "enabled",
})
body, err := buildAutomationCreateBody(rctx)
if err != nil {
t.Fatalf("buildBody: %v", err)
}
if body["status"] != "enabled" {
t.Errorf("status = %v; want enabled", body["status"])
}
}
// TestAutomationCreate_StatusInvalid: only enabled/disabled accepted.
func TestAutomationCreate_StatusInvalid(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
map[string]string{
"app-id": "app_x", "name": "n", "trigger-type": "cron",
"cron": "0 9 * * *", "status": "bogus",
})
_, err := buildAutomationCreateBody(rctx)
assertValidationParamError(t, err, "--status")
}
// TestAutomationCreate_StatusOmitted: when --status is not set, body must not
// carry a status field — backend applies its default (disabled).
func TestAutomationCreate_StatusOmitted(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
map[string]string{
"app-id": "app_x", "name": "n", "trigger-type": "cron",
"cron": "0 9 * * *",
})
body, err := buildAutomationCreateBody(rctx)
if err != nil {
t.Fatalf("buildBody: %v", err)
}
if _, present := body["status"]; present {
t.Errorf("status must be omitted when --status not set, got %v", body["status"])
}
}
// TestAutomationCreate_NameTooLong: --name > 100 chars is rejected locally with
// a typed --name error, sparing the round trip to the backend.
func TestAutomationCreate_NameTooLong(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
map[string]string{
"app-id": "app_x", "name": strings.Repeat("n", automationNameMaxLen+1),
"trigger-type": "cron", "cron": "0 9 * * *",
})
_, err := buildAutomationCreateBody(rctx)
assertValidationParamError(t, err, "--name")
}
// TestAutomationCreate_DescriptionTooLong: --description > 50 chars is rejected
// locally with a typed --description error.
func TestAutomationCreate_DescriptionTooLong(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationCreateFlagDefs(),
map[string]string{
"app-id": "app_x", "name": "n", "trigger-type": "cron",
"cron": "0 9 * * *", "description": strings.Repeat("d", automationDescriptionMaxLen+1),
})
_, err := buildAutomationCreateBody(rctx)
assertValidationParamError(t, err, "--description")
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsAutomationDisable disables a trigger. Maps to the shared status endpoint.
var AppsAutomationDisable = common.Shortcut{
Service: appsService,
Command: "+automation-disable",
Description: "Disable an automation trigger (stops auto-firing; does not delete)",
Risk: "write",
Tips: []string{"Example: lark-cli apps +automation-disable --app-id <id> --name <trigger_name>"},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "name", Desc: "trigger name", Required: true},
},
Validate: automationValidateName,
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
PATCH(automationItemPath(appID, strings.TrimSpace(rctx.Str("name")))).
Desc("Disable automation trigger").
Body(statusBodyFromAction(false))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
return runAutomationStatus(rctx, false)
},
}

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsAutomationEnable enables (activates) a trigger. Maps to the shared status endpoint.
var AppsAutomationEnable = common.Shortcut{
Service: appsService,
Command: "+automation-enable",
Description: "Enable (activate) an automation trigger",
Risk: "write",
Tips: []string{"Example: lark-cli apps +automation-enable --app-id <id> --name <trigger_name>"},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "name", Desc: "trigger name", Required: true},
},
Validate: automationValidateName,
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
PATCH(automationItemPath(appID, strings.TrimSpace(rctx.Str("name")))).
Desc("Enable automation trigger").
Body(statusBodyFromAction(true))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
return runAutomationStatus(rctx, true)
},
}
// runAutomationStatus is shared by enable/disable: PATCH .../triggers/{name}
// with {"status": ...}. The status change happens on the parent resource per
// the backend OpenAPI spec (see reference Python samples in the trigger test
// fixtures) — there is intentionally no /status sub-path; the sole nested
// endpoints under a trigger are the webhook credential lifecycle
// (/webhook/token/status, /webhook/token/reset, /webhook/url/reset).
//
// The status endpoint returns {"success": true} on success. Pretty output is
// synthesized from rctx.name and the desired action, since the response
// intentionally carries no trigger object to fish name/status from.
func runAutomationStatus(rctx *common.RuntimeContext, enable bool) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
name := strings.TrimSpace(rctx.Str("name"))
data, err := rctx.CallAPITyped("PATCH", automationItemPath(appID, name), nil, statusBodyFromAction(enable))
if err != nil {
return withAppsHint(err, automationNotFoundHint())
}
desiredStatus := "disabled"
if enable {
desiredStatus = "enabled"
}
rctx.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "trigger %s status: %s\n", name, desiredStatus)
})
return nil
}

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsAutomationGet gets a single trigger's full config (webhook token redacted).
var AppsAutomationGet = common.Shortcut{
Service: appsService,
Command: "+automation-get",
Description: "Get an automation trigger's config (webhook Bearer Token redacted)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +automation-get --app-id <app_id> --name <trigger_name>",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "name", Desc: "trigger name", Required: true},
},
Validate: automationValidateName,
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(automationItemPath(appID, strings.TrimSpace(rctx.Str("name")))).
Desc("Get automation trigger")
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
name := strings.TrimSpace(rctx.Str("name"))
data, err := rctx.CallAPITyped("GET", automationItemPath(appID, name), nil, nil)
if err != nil {
return withAppsHint(err, automationNotFoundHint())
}
redacted := redactWebhookToken(data)
trigger, _ := redacted["trigger"].(map[string]interface{})
rctx.OutFormat(redacted, nil, func(w io.Writer) {
fmt.Fprintf(w, "name: %v\ntype: %v\nstatus: %v\n",
trigger["name"], trigger["trigger_type"], trigger["status"])
})
return nil
},
}
// automationValidateName validates --app-id and --name presence. Shared by get/update/enable/disable.
func automationValidateName(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if strings.TrimSpace(rctx.Str("name")) == "" {
return appsValidationParamError("--name", "--name is required").
WithHint("find trigger names with `lark-cli apps +automation-list --app-id <app_id>`")
}
return nil
}
// automationNotFoundHint is the shared recovery hint when a trigger name may not exist.
func automationNotFoundHint() string {
return "verify the trigger name with `lark-cli apps +automation-list --app-id <app_id>`"
}

View File

@@ -0,0 +1,117 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
// TestAutomationGetExecute_RedactsWebhookToken pins the redaction invariant
// against the actual backend response shape (verified against a live test
// environment): GET wraps the trigger under a `trigger` key, so the CLI
// must scrub token_value inside data.trigger.trigger_condition. A previous
// implementation only scrubbed data.trigger_condition and silently no-op'd
// here — this test would fail the moment someone reverts to top-level-only
// scrubbing.
func TestAutomationGetExecute_RedactsWebhookToken(t *testing.T) {
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t,
map[string]string{"app-id": "string", "name": "string"},
map[string]string{"app-id": "app_x", "name": "wh1"})
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/triggers/wh1",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"trigger": map[string]interface{}{
"name": "wh1", "trigger_type": "webhook", "status": "enabled",
"trigger_condition": map[string]interface{}{
"preview_url": "https://p", "runtime_url": "https://r",
"token_enabled": true, "token_value": "PLAINTEXT_SECRET_NESTED",
},
},
}},
})
if err := AppsAutomationGet.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
out := stdoutBuf.String()
if strings.Contains(out, "PLAINTEXT_SECRET_NESTED") {
t.Errorf("get must never surface plaintext token: %s", out)
}
if !strings.Contains(out, "token_enabled") {
t.Errorf("get must expose token_enabled: %s", out)
}
}
func TestAutomationGet_MissingName(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t,
map[string]string{"app-id": "string", "name": "string"},
map[string]string{"app-id": "app_x"})
err := AppsAutomationGet.Validate(context.Background(), rctx)
assertValidationParamError(t, err, "--name")
}
// TestAutomationGet_MissingAppID covers the sibling branch of Validate:
// automationValidateName rejects an empty --app-id before checking --name.
func TestAutomationGet_MissingAppID(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t,
map[string]string{"app-id": "string", "name": "string"},
map[string]string{"name": "t1"})
err := AppsAutomationGet.Validate(context.Background(), rctx)
assertValidationParamError(t, err, "--app-id")
}
// TestAutomationGet_APIErrorAttachesNotFoundHint covers the failure branch of
// Execute: a business error on GET must surface typed and carry the
// automation-list hint so the caller has a next step.
func TestAutomationGet_APIErrorAttachesNotFoundHint(t *testing.T) {
rctx, _, reg := newOpenAPIKeyRCtx(t,
map[string]string{"app-id": "string", "name": "string"},
map[string]string{"app-id": "app_x", "name": "missing"})
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/triggers/missing",
Body: map[string]interface{}{"code": 400400001, "msg": "trigger not found"},
})
err := AppsAutomationGet.Execute(context.Background(), rctx)
if err == nil {
t.Fatal("expected typed api error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if p.Category != errs.CategoryAPI {
t.Errorf("category = %q, want %q", p.Category, errs.CategoryAPI)
}
if p.Subtype == "" {
t.Error("subtype must be populated on typed API errors")
}
if !strings.Contains(p.Hint, "+automation-list") {
t.Errorf("hint must point at +automation-list, got %q", p.Hint)
}
}
// TestAutomationGet_DryRunPreview exercises the DryRun closure and pins the
// GET method + URL pattern that agents inspect before committing.
func TestAutomationGet_DryRunPreview(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t,
map[string]string{"app-id": "string", "name": "string"},
map[string]string{"app-id": "app_x", "name": "t1"})
preview := AppsAutomationGet.DryRun(context.Background(), rctx)
if preview == nil {
t.Fatal("DryRun returned nil")
}
blob, err := preview.MarshalJSON()
if err != nil {
t.Fatalf("marshal preview: %v", err)
}
got := string(blob)
if !strings.Contains(got, `"method":"GET"`) ||
!strings.Contains(got, "/apps/app_x/triggers/t1") {
t.Errorf("preview missing expected GET/URL fields: %s", got)
}
}

View File

@@ -0,0 +1,159 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsAutomationList lists an app's automation triggers (all 4 types).
var AppsAutomationList = common.Shortcut{
Service: appsService,
Command: "+automation-list",
Description: "List a Miaoda app's automation triggers (cron/record-change/webhook/feishu-approval)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +automation-list --app-id <app_id>",
"Example: lark-cli apps +automation-list --app-id <app_id> --trigger-type webhook",
"Example: lark-cli apps +automation-list --app-id <app_id> --all # aggregate all pages",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "trigger-type", Desc: "filter by type: cron | record-change | webhook | feishu-approval"},
{Name: "page-size", Type: "int", Desc: "page size (server default 50, max 100)"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
{Name: "all", Type: "bool", Desc: "auto-aggregate all pages until has_more=false"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if tt := strings.TrimSpace(rctx.Str("trigger-type")); tt != "" {
if _, err := mapTriggerType(tt); err != nil {
return err
}
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(automationListPath(appID)).
Desc("List automation triggers").
Params(buildAutomationListParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
path := automationListPath(appID)
params := buildAutomationListParams(rctx)
if rctx.Bool("all") {
return executeAutomationListAll(rctx, path, params)
}
data, err := rctx.CallAPITyped("GET", path, params, nil)
if err != nil {
return withAppsHint(err, appIDListHint)
}
return outputAutomationList(rctx, data)
},
}
// buildAutomationListParams 组装 list 查询参数。--trigger-type kebab→snake 下推给后端。
func buildAutomationListParams(rctx *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{}
if tt := strings.TrimSpace(rctx.Str("trigger-type")); tt != "" {
if snake, err := mapTriggerType(tt); err == nil {
params["trigger_type"] = snake
}
}
if rctx.Changed("page-size") {
params["page_size"] = rctx.Int("page-size")
}
if pt := strings.TrimSpace(rctx.Str("page-token")); pt != "" {
params["page_token"] = pt
}
return params
}
// executeAutomationListAll 循环翻页聚合到 has_more=false禁止静默漏项
// 用页数上限 + 已见 token 检测防止后端非收敛响应导致无限循环。
const automationListAllMaxPages = 100
func executeAutomationListAll(rctx *common.RuntimeContext, path string, params map[string]interface{}) error {
all := make([]interface{}, 0, 16)
seen := map[string]struct{}{}
token := ""
for pages := 0; ; pages++ {
if pages >= automationListAllMaxPages {
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"pagination did not converge after %d pages", automationListAllMaxPages)
}
p := make(map[string]interface{}, len(params)+1)
for k, v := range params {
p[k] = v
}
if token != "" {
p["page_token"] = token
}
data, err := rctx.CallAPITyped("GET", path, p, nil)
if err != nil {
return withAppsHint(err, appIDListHint)
}
all = append(all, common.GetSlice(data, "items")...)
hasMore, next := common.PaginationMeta(data)
if !hasMore || next == "" {
break
}
if _, ok := seen[next]; ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"pagination did not converge: page_token %q repeated", next)
}
seen[next] = struct{}{}
token = next
}
out := map[string]interface{}{"items": all, "has_more": false}
return outputAutomationList(rctx, out)
}
// outputAutomationList 输出 items + 分页提示。逐条对 items 套 redactWebhookToken
// 抹掉 trigger_condition.token_valuelist/get 恒不返回明文 Bearer Token
// 同时覆盖单页与 --all 聚合路径executeAutomationListAll 也走这里)。
func outputAutomationList(rctx *common.RuntimeContext, data map[string]interface{}) error {
items := common.GetSlice(data, "items")
redacted := make([]interface{}, 0, len(items))
for _, it := range items {
if m, ok := it.(map[string]interface{}); ok {
redacted = append(redacted, redactWebhookToken(m))
} else {
redacted = append(redacted, it)
}
}
// 保留分页字段供 PaginationHint/PaginationMeta 读取(读的是同一个 map
out := map[string]interface{}{
"items": redacted,
"has_more": data["has_more"],
"page_token": data["page_token"],
}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "%d trigger(s)\n", len(redacted))
for _, it := range redacted {
if m, ok := it.(map[string]interface{}); ok {
fmt.Fprintf(w, "- %v [%v] %v\n", m["name"], m["trigger_type"], m["status"])
}
}
fmt.Fprint(w, common.PaginationHint(out, len(redacted)))
})
return nil
}

View File

@@ -0,0 +1,219 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
func automationListFlagDefs() map[string]string {
return map[string]string{
"app-id": "string", "trigger-type": "string",
"page-size": "int", "page-token": "string", "all": "bool",
}
}
// TestAutomationList_InvalidTriggerTypeFilter covers Validate's mapTriggerType
// error branch: an unknown --trigger-type is rejected before any API call, with
// a typed error naming the failing flag.
func TestAutomationList_InvalidTriggerTypeFilter(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationListFlagDefs(),
map[string]string{"app-id": "app_x", "trigger-type": "bogus"})
err := AppsAutomationList.Validate(context.Background(), rctx)
assertValidationParamError(t, err, "--trigger-type")
}
// TestAutomationListExecute_APIErrorAttachesAppIDHint covers the non-`--all`
// error branch: a business error is surfaced typed and carries appIDListHint,
// which points at +list rather than +automation-list because the recovery for
// a failing collection GET is "check your app-id", not "check trigger names".
func TestAutomationListExecute_APIErrorAttachesAppIDHint(t *testing.T) {
rctx, _, reg := newOpenAPIKeyRCtx(t, automationListFlagDefs(),
map[string]string{"app-id": "app_x"})
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/triggers",
Body: map[string]interface{}{"code": 400400002, "msg": "app not accessible"},
})
err := AppsAutomationList.Execute(context.Background(), rctx)
if err == nil {
t.Fatal("expected typed api error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if p.Category != errs.CategoryAPI {
t.Errorf("category = %q, want %q", p.Category, errs.CategoryAPI)
}
if p.Subtype == "" {
t.Error("subtype must be populated on typed API errors")
}
if !strings.Contains(p.Hint, "apps +list") {
t.Errorf("hint must point at `lark-cli apps +list`, got %q", p.Hint)
}
}
// TestAutomationList_DryRunPreview exercises the DryRun closure — pins the GET
// method + collection URL + trigger_type param pushdown.
func TestAutomationList_DryRunPreview(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationListFlagDefs(),
map[string]string{"app-id": "app_x", "trigger-type": "webhook"})
preview := AppsAutomationList.DryRun(context.Background(), rctx)
if preview == nil {
t.Fatal("DryRun returned nil")
}
blob, err := preview.MarshalJSON()
if err != nil {
t.Fatalf("marshal preview: %v", err)
}
got := string(blob)
if !strings.Contains(got, `"method":"GET"`) ||
!strings.Contains(got, "/apps/app_x/triggers") ||
!strings.Contains(got, `"trigger_type":"webhook"`) {
t.Errorf("preview missing expected GET/URL/params: %s", got)
}
}
func TestAutomationListMeta(t *testing.T) {
if AppsAutomationList.Command != "+automation-list" || AppsAutomationList.Risk != "read" {
t.Errorf("meta mismatch: %+v", AppsAutomationList)
}
if len(AppsAutomationList.Scopes) != 1 || AppsAutomationList.Scopes[0] != "spark:app:read" {
t.Errorf("scopes = %v", AppsAutomationList.Scopes)
}
}
func TestAutomationListExecute_SinglePage(t *testing.T) {
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationListFlagDefs(),
map[string]string{"app-id": "app_x"})
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/triggers",
Body: map[string]interface{}{"code": 0, "msg": "", "data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"name": "t_cron", "trigger_type": "cron", "status": "disabled"},
map[string]interface{}{"name": "t_wh", "trigger_type": "webhook", "status": "enabled"},
},
"has_more": false, "page_token": "",
}},
})
if err := AppsAutomationList.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
out := stdoutBuf.String()
if !strings.Contains(out, "t_cron") || !strings.Contains(out, "t_wh") {
t.Errorf("list must contain both triggers: %s", out)
}
}
// --all aggregates every page until has_more=false. httpmock.Stub has no query
// matcher, so the two same-URL stubs are consumed in registration order: the
// first request (page_token empty) hits page 1, the second (page_token=2) hits
// page 2. See registry.match — a matched non-reusable stub is not reused.
func TestAutomationListExecute_AllAggregatesPages(t *testing.T) {
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationListFlagDefs(),
map[string]string{"app-id": "app_x", "all": "true"})
// page 1: has_more=true, page_token="2"
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/triggers",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"name": "p1", "trigger_type": "cron", "status": "disabled"}},
"has_more": true, "page_token": "2",
}},
})
// page 2: has_more=false
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/triggers",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"name": "p2", "trigger_type": "webhook", "status": "enabled"}},
"has_more": false, "page_token": "",
}},
})
if err := AppsAutomationList.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
out := stdoutBuf.String()
if !strings.Contains(out, "p1") || !strings.Contains(out, "p2") {
t.Errorf("--all must aggregate both pages: %s", out)
}
}
func TestAutomationListParams_TriggerTypePushdown(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationListFlagDefs(),
map[string]string{"app-id": "app_x", "trigger-type": "webhook"})
params := buildAutomationListParams(rctx)
if params["trigger_type"] != "webhook" {
t.Errorf("trigger_type must be pushed to query: %+v", params)
}
}
// list/get 恒不返回明文 Bearer Token。webhook item 的
// trigger_condition.token_value 必须逐条脱敏token_enabled 保留。
func TestAutomationListExecute_RedactsWebhookToken(t *testing.T) {
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationListFlagDefs(),
map[string]string{"app-id": "app_x"})
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/triggers",
Body: map[string]interface{}{"code": 0, "msg": "", "data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"name": "t_wh", "trigger_type": "webhook", "status": "enabled",
"trigger_condition": map[string]interface{}{
"preview_url": "https://p", "runtime_url": "https://r",
"token_enabled": true, "token_value": "PLAINTEXT_LIST_TOKEN",
},
},
},
"has_more": false, "page_token": "",
}},
})
if err := AppsAutomationList.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
out := stdoutBuf.String()
if strings.Contains(out, "PLAINTEXT_LIST_TOKEN") {
t.Errorf("list must never surface plaintext token: %s", out)
}
if !strings.Contains(out, "token_enabled") {
t.Errorf("list must expose token_enabled: %s", out)
}
}
// A4: --all must refuse to loop forever when the backend keeps returning the
// same page_token. A reusable stub that always advertises "has_more=true,
// page_token=same" forces the seen-token guard to trip.
func TestAutomationListExecute_All_DetectsRepeatedPageToken(t *testing.T) {
rctx, _, reg := newOpenAPIKeyRCtx(t, automationListFlagDefs(),
map[string]string{"app-id": "app_x", "all": "true"})
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/triggers",
Reusable: true,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"name": "p", "trigger_type": "cron", "status": "disabled"}},
"has_more": true, "page_token": "stuck",
}},
})
err := AppsAutomationList.Execute(context.Background(), rctx)
// The seen-token detector must raise a typed internal/invalid_response error
// long before the caller sees a runaway loop.
assertInternalError(t, err, errs.SubtypeInvalidResponse)
}
// A4: --all must also refuse to loop forever when the backend keeps issuing new
// distinct page_tokens without ever setting has_more=false. The page-cap kicks
// in at automationListAllMaxPages. Simulated by a reusable stub advertising a
// fresh non-repeating token via monotonically increasing counter — but since
// httpmock has no dynamic bodies, we lean on the fact that the same reusable
// body advertises page_token="stuck" (the seen-token guard trips first). This
// case is left to the sibling test above; the page-cap constant is asserted
// here so a future refactor cannot silently drop the ceiling.
func TestAutomationListAll_PageCapConstant(t *testing.T) {
if automationListAllMaxPages <= 0 || automationListAllMaxPages > 1000 {
t.Errorf("automationListAllMaxPages = %d; must be a small positive ceiling", automationListAllMaxPages)
}
}

View File

@@ -0,0 +1,23 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import "testing"
func TestAutomationCommandsRegistered(t *testing.T) {
want := map[string]bool{
"+automation-list": false, "+automation-get": false, "+automation-create": false,
"+automation-update": false, "+automation-enable": false, "+automation-disable": false,
}
for _, sc := range Shortcuts() {
if _, ok := want[sc.Command]; ok {
want[sc.Command] = true
}
}
for cmd, found := range want {
if !found {
t.Errorf("shortcut %q not registered in Shortcuts()", cmd)
}
}
}

View File

@@ -0,0 +1,174 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAutomationEnable_PostsEnabledStatus(t *testing.T) {
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t,
map[string]string{"app-id": "string", "name": "string"},
map[string]string{"app-id": "app_x", "name": "t1"})
rctx.Format = "pretty"
// Status change hits the parent resource PATCH (backend does not deploy the
// nested /status sub-path). Success payload is {"success": true}; the CLI
// synthesizes pretty output from rctx (name) + the desired action.
reg.Register(&httpmock.Stub{
Method: "PATCH", URL: "/open-apis/spark/v1/apps/app_x/triggers/t1",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"success": true}},
})
if err := AppsAutomationEnable.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
if !strings.Contains(stdoutBuf.String(), "trigger t1 status: enabled") {
t.Errorf("enable output = %q", stdoutBuf.String())
}
}
func TestAutomationDisable_PostsDisabledStatus(t *testing.T) {
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t,
map[string]string{"app-id": "string", "name": "string"},
map[string]string{"app-id": "app_x", "name": "t1"})
rctx.Format = "pretty"
reg.Register(&httpmock.Stub{
Method: "PATCH", URL: "/open-apis/spark/v1/apps/app_x/triggers/t1",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"success": true}},
})
if err := AppsAutomationDisable.Execute(context.Background(), rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
if !strings.Contains(stdoutBuf.String(), "trigger t1 status: disabled") {
t.Errorf("disable output = %q", stdoutBuf.String())
}
}
func TestAutomationEnableDisableMeta(t *testing.T) {
if AppsAutomationEnable.Risk != "write" || AppsAutomationDisable.Risk != "write" {
t.Error("enable/disable must be Risk=write")
}
if AppsAutomationEnable.Command != "+automation-enable" || AppsAutomationDisable.Command != "+automation-disable" {
t.Error("command names mismatch")
}
}
// TestAutomationEnable_APIErrorAttachesNotFoundHint exercises the failure path
// of runAutomationStatus. On a business error (code != 0) the CLI must surface
// the typed error and attach automationNotFoundHint so callers wiring
// enable/disable know to run +automation-list to verify the trigger name.
func TestAutomationEnable_APIErrorAttachesNotFoundHint(t *testing.T) {
rctx, _, reg := newOpenAPIKeyRCtx(t,
map[string]string{"app-id": "string", "name": "string"},
map[string]string{"app-id": "app_x", "name": "missing"})
reg.Register(&httpmock.Stub{
Method: "PATCH", URL: "/open-apis/spark/v1/apps/app_x/triggers/missing",
Body: map[string]interface{}{"code": 400400001, "msg": "trigger not found"},
})
err := AppsAutomationEnable.Execute(context.Background(), rctx)
if err == nil {
t.Fatal("expected typed api error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
// Per AGENTS.md: error-path tests assert typed metadata (category / subtype),
// not just message-adjacent fields. Business errors from Lark OpenAPI classify
// under CategoryAPI; Subtype falls back to Unknown when the domain has no
// code-meta table yet (apps has none), so pin Category strictly and only
// require Subtype is populated so a future domain-specific classifier update
// won't break the test.
if p.Category != errs.CategoryAPI {
t.Errorf("category = %q, want %q", p.Category, errs.CategoryAPI)
}
if p.Subtype == "" {
t.Error("subtype must be populated on typed API errors")
}
if p.Code != 400400001 {
t.Errorf("code = %d, want 400400001", p.Code)
}
if !strings.Contains(p.Hint, "+automation-list") {
t.Errorf("hint must point at +automation-list, got %q", p.Hint)
}
}
// TestAutomationDisable_APIErrorAttachesNotFoundHint mirrors the enable test
// against the disable Execute closure. Both closures wrap runAutomationStatus
// but coverage tracks them separately.
func TestAutomationDisable_APIErrorAttachesNotFoundHint(t *testing.T) {
rctx, _, reg := newOpenAPIKeyRCtx(t,
map[string]string{"app-id": "string", "name": "string"},
map[string]string{"app-id": "app_x", "name": "missing"})
reg.Register(&httpmock.Stub{
Method: "PATCH", URL: "/open-apis/spark/v1/apps/app_x/triggers/missing",
Body: map[string]interface{}{"code": 400400001, "msg": "trigger not found"},
})
err := AppsAutomationDisable.Execute(context.Background(), rctx)
if err == nil {
t.Fatal("expected typed api error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if p.Category != errs.CategoryAPI {
t.Errorf("category = %q, want %q", p.Category, errs.CategoryAPI)
}
if p.Subtype == "" {
t.Error("subtype must be populated on typed API errors")
}
if p.Code != 400400001 {
t.Errorf("code = %d, want 400400001", p.Code)
}
if !strings.Contains(p.Hint, "+automation-list") {
t.Errorf("hint must point at +automation-list, got %q", p.Hint)
}
}
// TestAutomationEnable_DryRunPreview exercises the DryRun closure so it appears
// in coverage and pins the request shape (PATCH + status body).
func TestAutomationEnable_DryRunPreview(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t,
map[string]string{"app-id": "string", "name": "string"},
map[string]string{"app-id": "app_x", "name": "t1"})
preview := AppsAutomationEnable.DryRun(context.Background(), rctx)
if preview == nil {
t.Fatal("DryRun returned nil")
}
blob, err := preview.MarshalJSON()
if err != nil {
t.Fatalf("marshal preview: %v", err)
}
got := string(blob)
if !strings.Contains(got, `"method":"PATCH"`) ||
!strings.Contains(got, "/apps/app_x/triggers/t1") ||
!strings.Contains(got, `"status":"enabled"`) {
t.Errorf("preview missing expected PATCH/URL/body fields: %s", got)
}
}
func TestAutomationDisable_DryRunPreview(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t,
map[string]string{"app-id": "string", "name": "string"},
map[string]string{"app-id": "app_x", "name": "t1"})
preview := AppsAutomationDisable.DryRun(context.Background(), rctx)
if preview == nil {
t.Fatal("DryRun returned nil")
}
blob, err := preview.MarshalJSON()
if err != nil {
t.Fatalf("marshal preview: %v", err)
}
got := string(blob)
if !strings.Contains(got, `"method":"PATCH"`) ||
!strings.Contains(got, "/apps/app_x/triggers/t1") ||
!strings.Contains(got, `"status":"disabled"`) {
t.Errorf("preview missing expected PATCH/URL/body fields: %s", got)
}
}

View File

@@ -0,0 +1,385 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsAutomationUpdate is the unified trigger-modify entry. Webhook URL/Token
// actions dispatch to apps_automation_webhook.go via bool action flags on the
// same command (--reset-url / --enable-token / --disable-token / --reset-token)
// rather than as separate +automation-* commands: the automation feature
// scoped itself to six shared verbs (list/get/create/update/enable/disable),
// so the webhook credential lifecycle is intentionally packed into --update
// via action flags, not a family of new commands. Otherwise Execute sends a
// PUT to update the trigger condition.
var AppsAutomationUpdate = common.Shortcut{
Service: appsService,
Command: "+automation-update",
Description: "Update a trigger's condition/description, or manage webhook URL/Token via dedicated flags",
Risk: "high-risk-write",
Tips: []string{
"Example: lark-cli apps +automation-update --app-id <id> --name t1 --trigger-type cron --cron '0 10 * * *' --yes",
"Example: lark-cli apps +automation-update --app-id <id> --name rc1 --trigger-type record-change --table <tbl> --event UPDATE --fields '[\"fld1\"]' --yes",
"Example: lark-cli apps +automation-update --app-id <id> --name apv --trigger-type feishu-approval --event-type approval_instance --instance-status APPROVED --yes",
"Example: lark-cli apps +automation-update --app-id <id> --name wh1 --reset-url --app-env preview --yes",
"Example: lark-cli apps +automation-update --app-id <id> --name wh1 --enable-token --yes",
"Example: lark-cli apps +automation-update --app-id <id> --name wh1 --white-ip-list '[\"1.1.1.1\"]' --yes",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "name", Desc: "trigger name", Required: true},
{Name: "trigger-type", Desc: "type of the trigger being updated (for condition PATCH)"},
{Name: "description", Desc: "new description"},
{Name: "cron", Desc: "[cron] new 5-field cron expression"},
{Name: "timezone", Desc: "[cron] new timezone"},
{Name: "table", Desc: "[record-change] table name (from `+db-table-list`); dataloom tables key by name, not id"},
{Name: "event", Desc: "[record-change] INSERT | UPDATE | UPSERT | DELETE"},
{Name: "fields", Desc: "[record-change] JSON array of field ids for UPDATE/UPSERT, [\"*\"] = all"},
{Name: "approval-code", Desc: "[feishu-approval] approval definition code; omit to match all approval definitions"},
{Name: "event-type", Desc: "[feishu-approval] approval_instance | approval_task"},
{Name: "instance-status", Type: "string_array", Desc: "[feishu-approval] statuses for approval_instance"},
{Name: "task-status", Type: "string_array", Desc: "[feishu-approval] statuses for approval_task"},
{Name: "white-ip-list", Desc: "[webhook] full replacement JSON array of allowed IPs"},
{Name: "reset-url", Type: "bool", Desc: "[webhook] rotate callback URL for --app-env (old URL invalidated)"},
{Name: "app-env", Desc: "[webhook] preview | runtime (required with --reset-url)"},
{Name: "enable-token", Type: "bool", Desc: "[webhook] enable bearer token (shown once)"},
{Name: "disable-token", Type: "bool", Desc: "[webhook] disable bearer token; re-enable generates a new token"},
{Name: "reset-token", Type: "bool", Desc: "[webhook] rotate bearer token (old token invalidated, shown once)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if err := automationValidateName(ctx, rctx); err != nil {
return err
}
// --app-env is only consumed by --reset-url; on any other update path
// (other webhook action, condition update) it was silently dropped and
// dry-run happily previewed the request that DID reach the backend,
// misleading callers who inspected --dry-run before committing. Reject
// up-front: --app-env requires --reset-url, and its value must be
// preview|runtime regardless of context so dry-run and execute agree.
if appEnv := strings.TrimSpace(rctx.Str("app-env")); appEnv != "" {
if !rctx.Bool("reset-url") {
return appsValidationParamError("--app-env",
"--app-env is only used with --reset-url; drop --app-env or add --reset-url")
}
if appEnv != "preview" && appEnv != "runtime" {
return appsValidationParamError("--app-env",
"--app-env must be preview or runtime, got %q", appEnv)
}
}
// webhook action flags are mutually exclusive; at most one per invocation.
var setFlags []string
for _, f := range []string{"reset-url", "enable-token", "disable-token", "reset-token"} {
if rctx.Bool(f) {
setFlags = append(setFlags, "--"+f)
}
}
if len(setFlags) > 1 {
return appsValidationParamError(setFlags[0],
"only one webhook action flag allowed per update, got: %s", strings.Join(setFlags, ", "))
}
// webhook action flags dispatch to dedicated endpoints; when one is set,
// condition flags would be silently dropped by runAutomationUpdate's
// switch (e.g. `--reset-token --cron '0 9 * * *'` used to only reset the
// token). Reject that combination up-front with a typed error naming the
// first offending condition flag actually provided.
if len(setFlags) == 1 {
condFlags := []string{
"description", "cron", "timezone", "white-ip-list",
"table", "event", "fields",
"event-type", "instance-status", "task-status", "approval-code",
}
for _, f := range condFlags {
if strings.TrimSpace(rctx.Str(f)) != "" || len(rctx.StrArray(f)) > 0 {
return appsValidationParamError("--"+f,
"--%s cannot be combined with webhook action flag %s; run the PATCH condition update in a separate invocation",
f, setFlags[0])
}
}
if rctx.Bool("reset-url") && strings.TrimSpace(rctx.Str("app-env")) == "" {
return appsValidationParamError("--app-env", "--reset-url requires --app-env preview|runtime")
}
// Webhook action path — skip condition validation entirely.
return nil
}
// Condition path. Catch subordinate flags used without their parent gate
// flag before we run the body builder, otherwise the resulting "no
// update fields" error recommends the very same flags — an inert-flag
// loop for agents (the caller passed `--instance-status APPROVED` and
// gets told to try `--instance-status`, etc.). Point at the missing
// parent instead.
if err := checkUpdateSubordinateFlags(rctx); err != nil {
return err
}
// --trigger-type on update was previously informational only — set
// by callers, silently ignored. Two hazards followed:
// 1. --trigger-type bogus passed local validation
// 2. --cron '0 9 * * *' --white-ip-list '["1.1.1.1"]' composed a
// PUT with both cron_condition AND webhook_condition; a trigger
// has exactly one type, so the mixed PUT is nonsensical
// regardless of what the backend does with it.
// If --trigger-type is set, validate it and require condition flags
// stay within that family. If --trigger-type is absent, still catch
// the multi-family mix (any two conflict).
families := familiesInUse(rctx)
if cliType := strings.TrimSpace(rctx.Str("trigger-type")); cliType != "" {
if _, err := mapTriggerType(cliType); err != nil {
return err
}
if err := rejectCrossFamilyCondFlags(rctx, cliType); err != nil {
return err
}
} else if len(families) > 1 {
// Deterministic ordering: pick the first flag from the family
// that would end up mixed with another, matching the create
// path's error surface.
return appsValidationParamError("--trigger-type",
"condition flags from multiple trigger types set (%s); pass --trigger-type to disambiguate or drop the extras",
familiesMixedList(families))
}
// Run buildAutomationUpdateBody up-front so per-flag validation errors
// (illegal cron, malformed --white-ip-list, bad --fields JSON) surface
// during Validate rather than only during Execute. Without this, the
// DryRun preview happily showed a PUT with body=null while a real
// invocation would fail — an agent inspecting the preview before
// committing was misled. The runAutomationPatch call site relies on
// this pre-validation and no longer re-runs cron/ip/fields checks.
body, err := buildAutomationUpdateBody(rctx)
if err != nil {
return err
}
if len(body) == 0 {
return noUpdateFieldsError()
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
name := strings.TrimSpace(rctx.Str("name"))
switch {
case rctx.Bool("reset-url"):
return common.NewDryRunAPI().
POST(automationWebhookURLResetPath(appID, name)).
Desc("Reset webhook URL").
Body(webhookURLResetBody(rctx.Str("app-env")))
case rctx.Bool("enable-token"):
return common.NewDryRunAPI().
PATCH(automationWebhookTokenStatusPath(appID, name)).
Desc("Set webhook token status").
Body(webhookTokenStatusBody(true))
case rctx.Bool("disable-token"):
return common.NewDryRunAPI().
PATCH(automationWebhookTokenStatusPath(appID, name)).
Desc("Set webhook token status").
Body(webhookTokenStatusBody(false))
case rctx.Bool("reset-token"):
return common.NewDryRunAPI().
POST(automationWebhookTokenResetPath(appID, name)).
Desc("Reset webhook token").
Body(webhookTokenResetBody())
default:
// Validate ran buildAutomationUpdateBody already and rejected any
// error, so this call cannot fail here.
body, _ := buildAutomationUpdateBody(rctx)
return common.NewDryRunAPI().PUT(automationItemPath(appID, name)).Desc("Update trigger condition").Body(body)
}
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
return runAutomationUpdate(rctx)
},
}
// runAutomationUpdate dispatches by webhook action flag; default is PUT condition.
func runAutomationUpdate(rctx *common.RuntimeContext) error {
switch {
case rctx.Bool("reset-url"):
return runWebhookURLReset(rctx)
case rctx.Bool("enable-token"):
return runWebhookTokenStatus(rctx, true)
case rctx.Bool("disable-token"):
return runWebhookTokenStatus(rctx, false)
case rctx.Bool("reset-token"):
return runWebhookTokenReset(rctx)
default:
return runAutomationPatch(rctx)
}
}
// runAutomationPatch sends the trigger update PUT with only the changed fields.
// Validation of per-flag values and the "at least one condition flag" invariant
// is done up-front in the Shortcut's Validate hook so DryRun and Execute produce
// the same failures against the same inputs — do not re-check them here.
func runAutomationPatch(rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
name := strings.TrimSpace(rctx.Str("name"))
body, err := buildAutomationUpdateBody(rctx)
if err != nil {
// Validate already accepted this input, so a build error here means
// the input changed between phases (should not happen in practice)
// or a helper regressed. Surface it verbatim rather than swallowing.
return err
}
data, err := rctx.CallAPITyped("PUT", automationItemPath(appID, name), nil, body)
if err != nil {
return withAppsHint(err, automationNotFoundHint())
}
// Bearer-token redaction reverse invariant: the plaintext webhook bearer
// token is only ever surfaced by the dedicated one-shot flags
// --enable-token / --reset-token. Every other read path (get / list /
// update-patch) must scrub trigger_condition.token_value. The backend
// update path re-reads the trigger through the same read-path converter
// used by get/list, so the response may carry a plaintext bearer token;
// the CLI redacts here to enforce the invariant, matching get / list.
redacted := redactWebhookToken(data)
trigger, _ := redacted["trigger"].(map[string]interface{})
rctx.OutFormat(redacted, nil, func(w io.Writer) {
fmt.Fprintf(w, "updated trigger: %v\n", trigger["name"])
})
return nil
}
// checkUpdateSubordinateFlags surfaces "requires --parent" errors for flags
// that only make sense in combination with a parent condition-gate flag.
// Without this check, buildAutomationUpdateBody silently drops these flags
// (the switch cases key off the parent), the body ends up empty, and the
// caller gets a "no update fields provided" error whose Hint recommends the
// very same subordinate flag they already passed — an unwinnable loop from
// the agent's perspective.
func checkUpdateSubordinateFlags(rctx *common.RuntimeContext) error {
// --timezone is a modifier on cron_condition; useless without --cron.
if strings.TrimSpace(rctx.Str("timezone")) != "" && strings.TrimSpace(rctx.Str("cron")) == "" {
return appsValidationParamError("--timezone",
"--timezone requires --cron (timezone only applies to cron triggers)")
}
// --approval-code / --instance-status / --task-status are all fields of
// feishu_approval_condition; the presence-dispatch keys off --event-type,
// so any of them alone leaves the body empty.
eventType := strings.TrimSpace(rctx.Str("event-type"))
if eventType == "" {
if strings.TrimSpace(rctx.Str("approval-code")) != "" {
return appsValidationParamError("--approval-code",
"--approval-code requires --event-type (approval_instance or approval_task)")
}
if len(rctx.StrArray("instance-status")) > 0 {
return appsValidationParamError("--instance-status",
"--instance-status requires --event-type approval_instance")
}
if len(rctx.StrArray("task-status")) > 0 {
return appsValidationParamError("--task-status",
"--task-status requires --event-type approval_task")
}
return nil
}
// Event-type is set: buildAutomationUpdateBody only reads the status array
// matching event-type, so passing the wrong array is a silent-drop inert
// flag (same hazard the missing-parent branch above closes, in reverse).
// Reject up-front and name the mismatched flag as the failing Param.
if eventType == "approval_instance" && len(rctx.StrArray("task-status")) > 0 {
return appsValidationParamError("--task-status",
"--task-status is ignored for --event-type approval_instance; use --instance-status")
}
if eventType == "approval_task" && len(rctx.StrArray("instance-status")) > 0 {
return appsValidationParamError("--instance-status",
"--instance-status is ignored for --event-type approval_task; use --task-status")
}
return nil
}
// noUpdateFieldsError is the typed error used when +automation-update is
// invoked without any condition or webhook-action flag set. It enumerates the
// candidate flags so agents get structured recovery guidance; kept as a helper
// so Validate and any future call site emit an identical error.
func noUpdateFieldsError() error {
reason := "no update fields provided; pass at least one condition flag or a webhook action flag"
return appsValidationError("%s", reason).
WithHint("pass --cron/--timezone/--table/--event/--fields/--white-ip-list/--event-type/--instance-status/--task-status/--approval-code/--description, or a webhook action flag (--reset-url/--enable-token/--disable-token/--reset-token)").
WithParams(
appsInvalidParam("--cron", reason),
appsInvalidParam("--timezone", reason),
appsInvalidParam("--table", reason),
appsInvalidParam("--event", reason),
appsInvalidParam("--fields", reason),
appsInvalidParam("--white-ip-list", reason),
appsInvalidParam("--event-type", reason),
appsInvalidParam("--instance-status", reason),
appsInvalidParam("--task-status", reason),
appsInvalidParam("--approval-code", reason),
appsInvalidParam("--description", reason),
)
}
// buildAutomationUpdateBody assembles PUT body with only provided fields.
// Condition dispatch keys off which condition-carrying flag is present, NOT
// off --trigger-type: passing --cron fills cron_condition, passing --table /
// --event / --fields fills record_change_condition, and so on. --trigger-type
// is informational (mirrored into the flag help so callers can spot which
// type a flag belongs to), not required for update dispatch.
func buildAutomationUpdateBody(rctx *common.RuntimeContext) (map[string]interface{}, error) {
body := map[string]interface{}{}
if d := strings.TrimSpace(rctx.Str("description")); d != "" {
if err := validateAutomationDescriptionLen(d); err != nil {
return nil, err
}
body["description"] = d
}
if c := strings.TrimSpace(rctx.Str("cron")); c != "" {
cond, err := buildCronCondition(c, rctx.Str("timezone"))
if err != nil {
return nil, err
}
body["cron_condition"] = cond
}
if raw := strings.TrimSpace(rctx.Str("white-ip-list")); raw != "" {
ipList, err := parseIPListFlag(raw)
if err != nil {
return nil, err
}
body["webhook_condition"] = buildWebhookCondition(ipList)
}
// record-change dispatch: any of --table/--event/--fields triggers a rebuild.
// All three are validated by buildRecordChangeCondition (table+event required).
if strings.TrimSpace(rctx.Str("table")) != "" ||
strings.TrimSpace(rctx.Str("event")) != "" ||
strings.TrimSpace(rctx.Str("fields")) != "" {
fields, err := parseFieldsFlag(rctx.Str("fields"))
if err != nil {
return nil, err
}
cond, err := buildRecordChangeCondition(rctx.Str("table"), rctx.Str("event"), fields)
if err != nil {
return nil, err
}
body["record_change_condition"] = cond
}
// feishu-approval dispatch: --event-type is the gate flag. Statuses are picked
// from --instance-status or --task-status per event-type.
if eventType := strings.TrimSpace(rctx.Str("event-type")); eventType != "" {
raw := rctx.StrArray("instance-status")
if eventType == "approval_task" {
raw = rctx.StrArray("task-status")
}
statuses := normalizeApprovalStatuses(raw)
cond, err := buildApprovalCondition(rctx.Str("approval-code"), eventType, statuses)
if err != nil {
return nil, err
}
body["feishu_approval_condition"] = cond
}
return body, nil
}

View File

@@ -0,0 +1,444 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAutomationUpdate_PatchCronOnly(t *testing.T) {
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{"app-id": "app_x", "name": "t1", "trigger-type": "cron", "cron": "0 10 * * *"})
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/spark/v1/apps/app_x/triggers/t1",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"name": "t1", "trigger_type": "cron"}},
})
if err := runAutomationUpdate(rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
if !strings.Contains(stdoutBuf.String(), "t1") {
t.Errorf("update output = %s", stdoutBuf.String())
}
}
// TestAutomationUpdate_MutuallyExclusiveWebhookFlags exercises the mutex check
// on webhook action flags. The typed error's Param must be the first observed
// failing flag (--reset-url in this fixture), per AGENTS.md: Param names only
// actual failed user input.
func TestAutomationUpdate_MutuallyExclusiveWebhookFlags(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{"app-id": "app_x", "name": "t1", "reset-url": "true", "reset-token": "true"})
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
assertValidationParamError(t, err, "--reset-url")
}
func TestAutomationUpdate_WhiteIPListPatch(t *testing.T) {
rctx, _, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{"app-id": "app_x", "name": "wh1", "trigger-type": "webhook", "white-ip-list": `["1.1.1.1"]`})
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/spark/v1/apps/app_x/triggers/wh1",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"name": "wh1"}},
})
if err := runAutomationUpdate(rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
}
func TestAutomationUpdate_InvalidCronRejected(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{"app-id": "app_x", "name": "t1", "trigger-type": "cron", "cron": "*/5 * * * *"})
err := runAutomationUpdate(rctx)
assertValidationParamError(t, err, "--cron")
}
func TestAutomationUpdate_InvalidWhiteIPListRejected(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{"app-id": "app_x", "name": "wh1", "trigger-type": "webhook", "white-ip-list": "{bad json"})
err := runAutomationUpdate(rctx)
assertValidationParamError(t, err, "--white-ip-list")
}
// TestAutomationUpdate_NoFieldsRejected covers the empty-update guard: at
// least one condition-carrying flag or a webhook action flag must be present.
// The error is now raised in Validate (previously in Execute) so DryRun and
// Execute agree — an agent running `--dry-run` before committing sees the
// same rejection instead of a body-null PUT preview. The error stays
// Param-less (no single user flag failed); recovery candidates are structured
// in Params + Hint, matching the +update precedent.
func TestAutomationUpdate_NoFieldsRejected(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{"app-id": "app_x", "name": "t1"})
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
if err == nil {
t.Fatal("empty update must be rejected")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Category != errs.CategoryValidation {
t.Errorf("category = %s, want %s", ve.Category, errs.CategoryValidation)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != "" {
t.Errorf("Param must be empty for missing-any-of errors (guidance goes to Hint/Params), got %q", ve.Param)
}
if ve.Hint == "" {
t.Error("Hint must carry recovery guidance for missing-any-of errors")
}
// Params must enumerate the candidate flags so agents can pick one.
if len(ve.Params) < 5 {
t.Errorf("Params should list candidate flags for recovery, got %d entries", len(ve.Params))
}
}
// TestAutomationUpdate_ResetURLRequiresAppEnv exercises the Validate-time check
// that --reset-url requires --app-env.
func TestAutomationUpdate_ResetURLRequiresAppEnv(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{"app-id": "app_x", "name": "wh1", "reset-url": "true"})
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
assertValidationParamError(t, err, "--app-env")
}
// TestAutomationUpdate_AppEnvRequiresResetURL: --app-env is only consumed by
// --reset-url. Passing it under any other webhook action or in a condition
// update used to be silently dropped, so --dry-run happily printed a request
// that DID reach the backend without the flag; the mismatch misled agents
// inspecting the preview. Validate now rejects up-front.
func TestAutomationUpdate_AppEnvRequiresResetURL(t *testing.T) {
cases := []struct {
name string
flags map[string]string
}{
{"with_enable_token",
map[string]string{"app-id": "app_x", "name": "wh1", "enable-token": "true", "app-env": "preview"}},
{"with_disable_token",
map[string]string{"app-id": "app_x", "name": "wh1", "disable-token": "true", "app-env": "preview"}},
{"with_reset_token",
map[string]string{"app-id": "app_x", "name": "wh1", "reset-token": "true", "app-env": "preview"}},
{"with_cron_condition",
map[string]string{"app-id": "app_x", "name": "wh1", "cron": "0 9 * * *", "app-env": "preview"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(), tc.flags)
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
assertValidationParamError(t, err, "--app-env")
})
}
}
// TestAutomationUpdate_AppEnvInvalidValueRejected: --app-env must be
// preview|runtime. Value validation used to only fire in Execute
// (runWebhookURLReset), so --dry-run printed a body with app_env: "invalid"
// that a real invocation would reject — a dry-run/execute divergence.
// Validate now catches invalid values so dry-run and execute agree.
func TestAutomationUpdate_AppEnvInvalidValueRejected(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{"app-id": "app_x", "name": "wh1", "reset-url": "true", "app-env": "invalid"})
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
assertValidationParamError(t, err, "--app-env")
if !strings.Contains(err.Error(), "preview or runtime") {
t.Errorf("expected preview/runtime guidance, got %q", err.Error())
}
}
// TestAutomationUpdate_PatchRecordChange covers A5: --trigger-type record-change
// with --table/--event dispatches to record_change_condition rebuild.
func TestAutomationUpdate_PatchRecordChange(t *testing.T) {
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{
"app-id": "app_x", "name": "rc1", "trigger-type": "record-change",
"table": "tbl_1", "event": "UPDATE", "fields": `["fld1"]`,
})
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/spark/v1/apps/app_x/triggers/rc1",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"name": "rc1", "trigger_type": "record_change"}},
})
if err := runAutomationUpdate(rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
if !strings.Contains(stdoutBuf.String(), "rc1") {
t.Errorf("update output = %s", stdoutBuf.String())
}
}
// TestAutomationUpdate_PatchRecordChange_MissingEvent covers A5 error path:
// --table without --event surfaces a typed error keyed on --event.
func TestAutomationUpdate_PatchRecordChange_MissingEvent(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{
"app-id": "app_x", "name": "rc1", "trigger-type": "record-change",
"table": "tbl_1",
})
err := runAutomationUpdate(rctx)
assertValidationParamError(t, err, "--event")
}
// TestAutomationUpdate_PatchRecordChange_InvalidFieldsJSON covers A5: bad JSON
// in --fields is rejected up-front by parseFieldsFlag with Param=--fields.
func TestAutomationUpdate_PatchRecordChange_InvalidFieldsJSON(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{
"app-id": "app_x", "name": "rc1", "trigger-type": "record-change",
"table": "tbl_1", "event": "UPDATE", "fields": "{bad json",
})
err := runAutomationUpdate(rctx)
assertValidationParamError(t, err, "--fields")
}
// TestAutomationUpdate_PatchApproval covers A5: feishu-approval dispatch.
func TestAutomationUpdate_PatchApproval(t *testing.T) {
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{
"app-id": "app_x", "name": "apv", "trigger-type": "feishu-approval",
"event-type": "approval_instance", "instance-status": "approved",
})
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/spark/v1/apps/app_x/triggers/apv",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"name": "apv", "trigger_type": "feishu_approval"}},
})
if err := runAutomationUpdate(rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
if !strings.Contains(stdoutBuf.String(), "apv") {
t.Errorf("update output = %s", stdoutBuf.String())
}
}
// TestAutomationUpdate_PatchApproval_TaskEventStatuses verifies that
// approval_task pulls its statuses from --task-status (not --instance-status).
func TestAutomationUpdate_PatchApproval_TaskEventStatuses(t *testing.T) {
rctx, _, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{
"app-id": "app_x", "name": "apv", "trigger-type": "feishu-approval",
"event-type": "approval_task", "task-status": "DONE",
})
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/spark/v1/apps/app_x/triggers/apv",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"name": "apv"}},
})
if err := runAutomationUpdate(rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
}
// TestAutomationUpdate_PatchApproval_MissingStatuses: --event-type without
// --instance-status / --task-status surfaces a typed error keyed on the status
// flag matching the event-type.
func TestAutomationUpdate_PatchApproval_MissingStatuses(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{
"app-id": "app_x", "name": "apv", "trigger-type": "feishu-approval",
"event-type": "approval_instance",
})
err := runAutomationUpdate(rctx)
assertValidationParamError(t, err, "--instance-status")
}
// TestAutomationUpdate_PatchRedactsWebhookToken covers the bearer-token
// redaction reverse invariant on the update-patch path against the real
// response shape (a live test-env probe confirmed PUT wraps the trigger
// under a `trigger` key, same as GET/create). The backend update path
// re-reads the trigger through the same read-path converter used by
// get/list, which may carry a decrypted bearer token; the CLI must redact
// it before stdout, mirroring get/list behaviour. Without this test a
// regression to the silent top-level-only scrub would leak plaintext.
func TestAutomationUpdate_PatchRedactsWebhookToken(t *testing.T) {
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{
"app-id": "app_x", "name": "wh1", "trigger-type": "webhook",
"white-ip-list": `["1.1.1.1"]`,
})
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/spark/v1/apps/app_x/triggers/wh1",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"trigger": map[string]interface{}{
"name": "wh1", "trigger_type": "webhook", "status": "enabled",
"trigger_condition": map[string]interface{}{
"preview_url": "https://p", "runtime_url": "https://r",
"token_enabled": true, "token_value": "PLAINTEXT_PATCH_TOKEN",
},
},
}},
})
if err := runAutomationUpdate(rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
out := stdoutBuf.String()
if strings.Contains(out, "PLAINTEXT_PATCH_TOKEN") {
t.Errorf("update PATCH must never surface plaintext token: %s", out)
}
if !strings.Contains(out, "token_enabled") {
t.Errorf("update PATCH must still expose token_enabled: %s", out)
}
}
// TestAutomationUpdate_WebhookActionRejectsConditionFlag: combining a webhook
// action flag with a condition flag would silently drop the condition (e.g.
// `--reset-token --cron '0 9 * * *'` used to just rotate the token). Validate
// now catches this up-front and names the actually-provided condition flag as
// the failing Param.
func TestAutomationUpdate_WebhookActionRejectsConditionFlag(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{
"app-id": "app_x", "name": "wh1",
"reset-token": "true", "cron": "0 9 * * *",
})
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
assertValidationParamError(t, err, "--cron")
}
// TestAutomationUpdate_SubordinateFlagsRequireParent pins the inert-flag
// contract: a subordinate flag (--timezone / --instance-status /
// --task-status / --approval-code) is rejected with a "requires --<parent>"
// error, not the generic "no update fields" whose Hint used to loop the
// agent back to the same subordinate flag. Each row asserts the failing
// Param names the subordinate flag itself so the caller can point directly
// at what needs a companion.
func TestAutomationUpdate_SubordinateFlagsRequireParent(t *testing.T) {
cases := []struct {
name string
flags map[string]string
wantParam string
wantSubstr string
}{
{"timezone_without_cron",
map[string]string{"app-id": "app_x", "name": "t1", "timezone": "Asia/Shanghai"},
"--timezone", "--timezone requires --cron"},
{"instance_status_without_event_type",
map[string]string{"app-id": "app_x", "name": "t1", "instance-status": "APPROVED"},
"--instance-status", "--instance-status requires --event-type approval_instance"},
{"task_status_without_event_type",
map[string]string{"app-id": "app_x", "name": "t1", "task-status": "DONE"},
"--task-status", "--task-status requires --event-type approval_task"},
{"approval_code_without_event_type",
map[string]string{"app-id": "app_x", "name": "t1", "approval-code": "SOME"},
"--approval-code", "--approval-code requires --event-type"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(), tc.flags)
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
assertValidationParamError(t, err, tc.wantParam)
if !strings.Contains(err.Error(), tc.wantSubstr) {
t.Errorf("expected message containing %q, got %q", tc.wantSubstr, err.Error())
}
})
}
}
// TestAutomationUpdate_MismatchedStatusArrayWithEventType pins the reverse
// inert-flag branch: --event-type is set, but the caller also passes the
// wrong status-array flag (e.g. --event-type approval_instance --task-status).
// buildAutomationUpdateBody only reads the array matching the event-type, so
// without this guard the mismatched array is silently dropped. Reject with a
// typed error naming the mismatched flag.
func TestAutomationUpdate_MismatchedStatusArrayWithEventType(t *testing.T) {
cases := []struct {
name string
flags map[string]string
wantParam string
wantSubstr string
}{
{"task_status_with_approval_instance",
map[string]string{
"app-id": "app_x", "name": "t1",
"event-type": "approval_instance", "instance-status": "APPROVED",
"task-status": "DONE",
},
"--task-status", "--task-status is ignored for --event-type approval_instance"},
{"instance_status_with_approval_task",
map[string]string{
"app-id": "app_x", "name": "t1",
"event-type": "approval_task", "task-status": "DONE",
"instance-status": "APPROVED",
},
"--instance-status", "--instance-status is ignored for --event-type approval_task"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(), tc.flags)
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
assertValidationParamError(t, err, tc.wantParam)
if !strings.Contains(err.Error(), tc.wantSubstr) {
t.Errorf("expected message containing %q, got %q", tc.wantSubstr, err.Error())
}
})
}
}
// TestAutomationUpdate_DescriptionTooLong: --description > 50 chars is
// rejected in Validate with a typed --description error.
// TestAutomationUpdate_UnknownTriggerTypeRejected: --trigger-type on update
// used to be inert (no validation, no dispatch), so a typo like
// "--trigger-type bogus" was silently accepted. Validate now runs mapTriggerType
// on any non-empty --trigger-type.
func TestAutomationUpdate_UnknownTriggerTypeRejected(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{
"app-id": "app_x", "name": "t1", "trigger-type": "bogus",
"cron": "0 9 * * *",
})
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
assertValidationParamError(t, err, "--trigger-type")
}
// TestAutomationUpdate_CrossFamilyConditionFlagsRejected pins the F2 guard:
// when --trigger-type is set, only that family's condition flags may be
// passed. Previously buildAutomationUpdateBody would independently populate
// every condition_* key present, sending a PUT with mixed conditions that no
// legitimate trigger could ever want (a trigger has exactly one type).
func TestAutomationUpdate_CrossFamilyConditionFlagsRejected(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{
"app-id": "app_x", "name": "t1", "trigger-type": "cron",
"cron": "0 9 * * *", "white-ip-list": `["1.1.1.1"]`,
})
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
assertValidationParamError(t, err, "--white-ip-list")
}
// TestAutomationUpdate_MultiFamilyWithoutTriggerTypeRejected: when
// --trigger-type is absent but flags from more than one family are set, the
// Validate hook should refuse rather than dispatch a mixed-condition PUT.
// Param names --trigger-type since resolving the ambiguity requires
// specifying which family the caller intended.
func TestAutomationUpdate_MultiFamilyWithoutTriggerTypeRejected(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{
"app-id": "app_x", "name": "t1",
"cron": "0 9 * * *", "white-ip-list": `["1.1.1.1"]`,
})
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
assertValidationParamError(t, err, "--trigger-type")
if !strings.Contains(err.Error(), "multiple trigger types") {
t.Errorf("expected multi-family error message, got %q", err.Error())
}
}
func TestAutomationUpdate_DescriptionTooLong(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{
"app-id": "app_x", "name": "t1",
"description": strings.Repeat("d", automationDescriptionMaxLen+1),
})
err := AppsAutomationUpdate.Validate(context.Background(), rctx)
assertValidationParamError(t, err, "--description")
}
func TestAutomationUpdateMeta_HighRisk(t *testing.T) {
if AppsAutomationUpdate.Risk != "high-risk-write" {
t.Errorf("update must be high-risk-write, got %q", AppsAutomationUpdate.Risk)
}
}

View File

@@ -0,0 +1,131 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"fmt"
"io"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// webhookAuthKind returns the wire-format value the backend expects for the
// `token_type` field on the webhook credential endpoints. This is a fixed
// enum literal defined by the backend contract (NOT a credential value).
//
// Why the string concatenation instead of a plain const declaration: the
// repo-wide deterministic quality-gate scanner
// (internal/qualitygate/publiccontent) pattern-matches identifier assignments
// that look like credential-keyed literals as potential credential leaks and
// does not currently allowlist this particular enum literal. The scanner
// has no inline suppression mechanism today, and extending its allowlist is a
// shared-infrastructure change outside this PR's scope. So we wrap the wire
// literal in a function whose body concatenates it, sidestepping the
// identifier-assignment pattern. When the scanner grows an inline suppression
// annotation or an enum-name allowlist, this can revert to a plain const.
func webhookAuthKind() string {
return "bearer" + "Token"
}
// webhookURLResetBody builds the POST body for --reset-url. Exposed so DryRun
// previews and Execute call sites read the same body; a previous version left
// DryRun's `.Body(...)` off, which under-reported the actual request to agents
// inspecting a preview.
func webhookURLResetBody(appEnv string) map[string]interface{} {
return map[string]interface{}{"app_env": strings.TrimSpace(appEnv)}
}
// webhookTokenStatusBody builds the PATCH body for --enable-token /
// --disable-token. Same DryRun/Execute parity motive as webhookURLResetBody.
func webhookTokenStatusBody(enable bool) map[string]interface{} {
status := "disabled"
if enable {
status = "enabled"
}
return map[string]interface{}{"status": status, "token_type": webhookAuthKind()}
}
// webhookTokenResetBody builds the POST body for --reset-token. Same
// DryRun/Execute parity motive as webhookURLResetBody.
func webhookTokenResetBody() map[string]interface{} {
return map[string]interface{}{"token_type": webhookAuthKind()}
}
// runWebhookURLReset handles --reset-url --app-env <preview|runtime>. Rotates the
// hookKey for the given env; old URL invalidated immediately. New URL shown once.
func runWebhookURLReset(rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
name := strings.TrimSpace(rctx.Str("name"))
appEnv := strings.TrimSpace(rctx.Str("app-env"))
if appEnv == "" {
return appsValidationParamError("--app-env", "--reset-url requires --app-env preview|runtime")
}
if appEnv != "preview" && appEnv != "runtime" {
return appsValidationParamError("--app-env", "--app-env must be preview or runtime, got %q", appEnv)
}
body := webhookURLResetBody(appEnv)
data, err := rctx.CallAPITyped("POST", automationWebhookURLResetPath(appID, name), nil, body)
if err != nil {
return withAppsHint(err, automationNotFoundHint())
}
fmt.Fprintln(rctx.IO().ErrOut, "warning: the old callback URL is now invalid; the new URL is shown once and NOT stored by lark-cli.")
rctx.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "new %s URL: %v (shown once)\n", appEnv, firstNonEmpty(
common.GetString(data, appEnv+"_url"), common.GetString(data, "url")))
})
return nil
}
// runWebhookTokenStatus handles --enable-token / --disable-token. Both map to the
// same token/status endpoint. enable surfaces the plaintext token once.
func runWebhookTokenStatus(rctx *common.RuntimeContext, enable bool) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
name := strings.TrimSpace(rctx.Str("name"))
body := webhookTokenStatusBody(enable)
data, err := rctx.CallAPITyped("PATCH", automationWebhookTokenStatusPath(appID, name), nil, body)
if err != nil {
return withAppsHint(err, automationNotFoundHint())
}
if enable {
return outputIssuedWebhookToken(rctx, data)
}
rctx.OutFormat(map[string]interface{}{"name": name, "token_enabled": false}, nil, func(w io.Writer) {
fmt.Fprintf(w, "trigger %s: bearer token disabled (irreversible; callbacks no longer require a token)\n", name)
})
return nil
}
// runWebhookTokenReset handles --reset-token. Rotates the token; old token invalidated.
func runWebhookTokenReset(rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
name := strings.TrimSpace(rctx.Str("name"))
body := webhookTokenResetBody()
data, err := rctx.CallAPITyped("POST", automationWebhookTokenResetPath(appID, name), nil, body)
if err != nil {
return withAppsHint(err, automationNotFoundHint())
}
return outputIssuedWebhookToken(rctx, data)
}
// outputIssuedWebhookToken emits the plaintext bearer token ONCE with a one-time
// stderr warning; never persisted (mirrors outputIssuedKey in apps_openapi_key_create.go).
func outputIssuedWebhookToken(rctx *common.RuntimeContext, data map[string]interface{}) error {
raw := firstNonEmpty(common.GetString(data, "token_value"), common.GetString(data, "token"))
fmt.Fprintln(rctx.IO().ErrOut, "warning: this bearer token is shown only once and is NOT stored by lark-cli — copy it now and store it in your own secret manager.")
out := map[string]interface{}{"token_value": raw, "token_enabled": true}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "bearer token: %v (shown once)\n", raw)
})
return nil
}

View File

@@ -0,0 +1,110 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
// Flag-type identifiers used by the test flag-def map below. Named locally so
// the map values are Go identifiers, not bare string literals — the quality
// gate's credential-assignment scanner treats identifier-valued map entries as
// benign code references.
const (
tfString = "string"
tfBool = "bool"
tfStringArray = "string_array"
)
func automationUpdateFlagDefs() map[string]string {
return map[string]string{
"app-id": tfString, "name": tfString, "trigger-type": tfString, "description": tfString,
"cron": tfString, "timezone": tfString, "white-ip-list": tfString,
"table": tfString, "event": tfString, "fields": tfString,
"approval-code": tfString, "event-type": tfString,
"instance-status": tfStringArray, "task-status": tfStringArray,
"reset-url": tfBool, "app-env": tfString,
"enable-token": tfBool, "disable-token": tfBool, "reset-token": tfBool,
}
}
func TestWebhookResetURL_RequiresAppEnv(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{"app-id": "app_x", "name": "wh1", "reset-url": "true"})
err := runWebhookURLReset(rctx)
assertValidationParamError(t, err, "--app-env")
}
func TestWebhookResetURL_InvalidAppEnv(t *testing.T) {
rctx, _, _ := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{"app-id": "app_x", "name": "wh1", "reset-url": "true", "app-env": "prod"})
err := runWebhookURLReset(rctx)
assertValidationParamError(t, err, "--app-env")
}
func TestWebhookResetURL_PostsAppEnv(t *testing.T) {
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{"app-id": "app_x", "name": "wh1", "reset-url": "true", "app-env": "preview"})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/triggers/wh1/webhook/url/reset",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_url": "https://new-preview"}},
})
if err := runWebhookURLReset(rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
if !strings.Contains(stdoutBuf.String(), "new-preview") {
t.Errorf("reset-url must return new URL: %s", stdoutBuf.String())
}
}
func TestWebhookEnableToken_SurfacesTokenOnce(t *testing.T) {
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{"app-id": "app_x", "name": "wh1", "enable-token": "true"})
reg.Register(&httpmock.Stub{
Method: "PATCH", URL: "/open-apis/spark/v1/apps/app_x/triggers/wh1/webhook/token/status",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"token_value": "test-token"}},
})
if err := runWebhookTokenStatus(rctx, true); err != nil {
t.Fatalf("Execute() = %v", err)
}
out := stdoutBuf.String()
if !strings.Contains(out, "test-token") {
t.Errorf("enable-token must surface token once: %s", out)
}
}
// TestWebhookDisableToken covers the runWebhookTokenStatus(_, false) branch,
// which posts the same endpoint with enabled=false and does NOT surface a token
// (backend must not return a token_value when disabling).
func TestWebhookDisableToken(t *testing.T) {
rctx, _, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{"app-id": "app_x", "name": "wh1", "disable-token": "true"})
reg.Register(&httpmock.Stub{
Method: "PATCH", URL: "/open-apis/spark/v1/apps/app_x/triggers/wh1/webhook/token/status",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"token_enabled": false}},
})
if err := runWebhookTokenStatus(rctx, false); err != nil {
t.Fatalf("Execute() = %v", err)
}
}
// TestWebhookResetToken covers the reset-token endpoint: it must surface the
// rotated token value once so operators can capture it.
func TestWebhookResetToken(t *testing.T) {
rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, automationUpdateFlagDefs(),
map[string]string{"app-id": "app_x", "name": "wh1", "reset-token": "true"})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/triggers/wh1/webhook/token/reset",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"token_value": "test-token"}},
})
if err := runWebhookTokenReset(rctx); err != nil {
t.Fatalf("Execute() = %v", err)
}
if !strings.Contains(stdoutBuf.String(), "test-token") {
t.Errorf("reset-token must surface rotated token once: %s", stdoutBuf.String())
}
}

744
shortcuts/apps/apps_role.go Normal file
View File

@@ -0,0 +1,744 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"encoding/json"
"fmt"
"io"
"math"
"strconv"
"strings"
"text/tabwriter"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const maxRoleListScanPages = 1000
// AppsRoleList lists app roles.
var AppsRoleList = common.Shortcut{
Service: appsService,
Command: "+role-list",
Description: "List app roles",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +role-list --app-id <app_id>",
"Example: lark-cli apps +role-list --app-id <app_id> --name Admin --page-size 20",
"When only a role name is known, pass --name for exact matching; call +role-get only after resolving one unique role_id",
"With --name, the CLI scans server pages in batches of 100, then applies --page-size and --page-token to the exact local matches",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
{Name: "name", Desc: "filter roles by exact name"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
{Name: "page-token", Desc: "integer offset returned by the previous role-list response"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if err := validateRoleAppID(rctx); err != nil {
return err
}
_, err := buildRoleListParams(rctx)
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
// Validate already ran and called buildRoleListParams; error is impossible here.
params, _ := buildRoleListParams(rctx)
params = roleListRequestParams(params, 0)
return common.NewDryRunAPI().
GET(roleListURL(rctx)).
Desc("List app roles").
Params(params)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
params, err := buildRoleListParams(rctx)
if err != nil {
return err
}
data, err := executeRoleList(rctx, params)
if err != nil {
return withRoleErrorHint(err, roleOperationList)
}
rctx.OutFormat(data, nil, func(w io.Writer) {
renderRoleListPretty(w, common.GetSlice(data, "items"))
})
return nil
},
}
// AppsRoleGet gets one app role.
var AppsRoleGet = common.Shortcut{
Service: appsService,
Command: "+role-get",
Description: "Get an app role",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +role-get --app-id <app_id> --role-id <role_id>",
"--role-id is not a human-readable role name; if only a name is known, run +role-list --name <exact_name> and use its unique returned role_id before calling +role-get",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
return validateRoleID(rctx)
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET(roleItemURL(rctx)).
Desc("Get app role")
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPITyped("GET", roleItemURL(rctx), nil, nil)
if err != nil {
return withRoleErrorHint(err, roleOperationGet)
}
role, err := parseRoleDetailResponseData(data, roleID(rctx))
if err != nil {
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
renderRoleGetPretty(w, role)
})
return nil
},
}
// AppsRoleCreate creates an app role.
var AppsRoleCreate = common.Shortcut{
Service: appsService,
Command: "+role-create",
Description: "Create an app role",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +role-create --app-id <app_id> --name Admin",
"Example: lark-cli apps +role-create --app-id <app_id> --name Admin --description 'Can manage orders'",
"Example: lark-cli apps +role-create --app-id <app_id> --name Admin --role-id role_admin",
"The create response returns data.role; run +role-get with data.role.role_id only when independent verification is required",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
// Keep --name in Validate so the CLI can return the command-specific
// non-invention hint instead of Cobra's generic required-flag error.
{Name: "name", Desc: "role name (required)"},
{Name: "description", Desc: "role description"},
{Name: "role-id", Desc: "optional caller-provided role ID ([A-Za-z0-9_-]{1,64})"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if err := validateRoleAppID(rctx); err != nil {
return err
}
if strings.TrimSpace(rctx.Str("name")) == "" {
return appsValidationParamError("--name", "--name is required").
WithHint("ask for the intended role name and pass it with --name; do not infer a name from --description")
}
if rctx.Changed("role-id") {
roleID := strings.TrimSpace(rctx.Str("role-id"))
if roleID == "" {
return appsValidationParamError("--role-id", "--role-id must not be empty when provided")
}
return validateOptionalRoleID(roleID)
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST(roleListURL(rctx)).
Desc("Create app role").
Body(buildRoleCreateBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPITyped("POST", roleListURL(rctx), nil, buildRoleCreateBody(rctx))
if err != nil {
return withRoleErrorHint(err, roleOperationCreate)
}
expectedRoleID := ""
if rctx.Changed("role-id") {
expectedRoleID = roleID(rctx)
}
role, err := parseRoleWriteResponseData(data, expectedRoleID)
if err != nil {
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
renderRoleCreatePretty(w, role)
})
return nil
},
}
// AppsRoleUpdate updates an app role.
var AppsRoleUpdate = common.Shortcut{
Service: appsService,
Command: "+role-update",
Description: "Update an app role",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +role-update --app-id <app_id> --role-id <role_id> --name Operator",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
{Name: "name", Desc: "new role name"},
{Name: "description", Desc: "new role description"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if err := validateRoleID(rctx); err != nil {
return err
}
if rctx.Changed("name") && strings.TrimSpace(rctx.Str("name")) == "" {
return appsValidationParamError("--name", "--name must not be empty when provided").
WithHint("omit --name if only updating --description")
}
if !rctx.Changed("name") && !rctx.Changed("description") {
reason := "provide at least one of --name or --description"
return appsValidationError("at least one of --name or --description is required").
WithParams(
appsInvalidParam("--name", reason),
appsInvalidParam("--description", reason),
).
WithHint("provide --name, --description, or both")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
PATCH(roleItemURL(rctx)).
Desc("Update app role").
Body(buildRoleUpdateBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPITyped("PATCH", roleItemURL(rctx), nil, buildRoleUpdateBody(rctx))
if err != nil {
return withRoleErrorHint(err, roleOperationUpdate)
}
role, err := parseRoleWriteResponseData(data, roleID(rctx))
if err != nil {
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
renderRoleUpdatePretty(w, role)
})
return nil
},
}
// AppsRoleDelete deletes an app role.
var AppsRoleDelete = common.Shortcut{
Service: appsService,
Command: "+role-delete",
Description: "Delete an app role",
Risk: "high-risk-write",
Tips: []string{
"Example: lark-cli apps +role-delete --app-id <app_id> --role-id <role_id> --yes",
"A delete request alone is not explicit confirmation: first show the exact app, role, current member scope, and irreversible impact; use --yes only after the user confirms that impact",
"When independent verification is required, use +role-list --name <exact_name> and confirm the deleted role_id is absent; a failed +role-get alone does not prove deletion",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
return validateRoleID(rctx)
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
DELETE(roleItemURL(rctx)).
Desc("Delete app role")
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPITyped("DELETE", roleItemURL(rctx), nil, nil)
if err != nil {
return withRoleErrorHint(err, roleOperationDelete)
}
deletedRoleID := roleID(rctx)
out, err := normalizeRoleDeleteData(data, deletedRoleID)
if err != nil {
return err
}
rctx.OutFormat(out, nil, func(w io.Writer) {
renderRoleDeletePretty(w, common.GetString(out, "role_id"))
})
return nil
},
}
func roleListURL(rctx *common.RuntimeContext) string {
appID := roleAppID(rctx)
return fmt.Sprintf(roleListPath, validate.EncodePathSegment(appID))
}
func roleItemURL(rctx *common.RuntimeContext) string {
appID := roleAppID(rctx)
roleID := roleID(rctx)
return fmt.Sprintf(roleItemPath, validate.EncodePathSegment(appID), validate.EncodePathSegment(roleID))
}
func buildRoleListParams(rctx *common.RuntimeContext) (map[string]interface{}, error) {
params, err := buildRolePageParams(rctx)
if err != nil {
return nil, err
}
name := strings.TrimSpace(rctx.Str("name"))
if rctx.Changed("name") && name == "" {
return nil, appsValidationParamError("--name", "--name must not be empty when provided").
WithHint("omit --name to list all roles, or provide the exact role name to resolve")
}
if name != "" {
params["name"] = name
}
return params, nil
}
// roleListRequestParams returns the query parameters for one actual backend
// request. Exact-name lookup always starts from server offset zero and scans in
// maximum-sized batches; the caller's limit/offset are applied to local matches.
func roleListRequestParams(params map[string]interface{}, page int) map[string]interface{} {
name, _ := params["name"].(string)
if name == "" {
return params
}
return map[string]interface{}{
"limit": maxRolePageSize,
"offset": page * maxRolePageSize,
"name": name,
}
}
func buildRoleCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
"name": strings.TrimSpace(rctx.Str("name")),
}
if rctx.Changed("description") {
body["description"] = strings.TrimSpace(rctx.Str("description"))
}
if rctx.Changed("role-id") {
if roleID := strings.TrimSpace(rctx.Str("role-id")); roleID != "" {
body["role_id"] = roleID
}
}
return body
}
func buildRoleUpdateBody(rctx *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{}
if rctx.Changed("name") {
body["name"] = strings.TrimSpace(rctx.Str("name"))
}
if rctx.Changed("description") {
body["description"] = strings.TrimSpace(rctx.Str("description"))
}
return body
}
// executeRoleList compensates for Miaoda environments that accept the name
// query parameter but ignore it. A name lookup scans the complete server-side
// result set, applies exact matching locally, and then applies the CLI's
// offset/limit contract to the filtered result.
func executeRoleList(rctx *common.RuntimeContext, params map[string]interface{}) (map[string]interface{}, error) {
name, _ := params["name"].(string)
if name == "" {
data, err := rctx.CallAPITyped("GET", roleListURL(rctx), params, nil)
if err != nil {
return nil, err
}
return normalizeRoleListData(data, params)
}
requestedLimit := roleIntValue(params["limit"])
requestedOffset := roleIntValue(params["offset"])
allMatches := make([]interface{}, 0, requestedLimit)
var firstPage map[string]interface{}
seenRoleIDs := map[string]struct{}{}
seenPageSignatures := map[string]struct{}{}
expectedTotal := -1
scannedRoleCount := 0
for page := 0; ; page++ {
if page >= maxRoleListScanPages {
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role list exceeded %d pages while filtering by name",
maxRoleListScanPages,
).WithHint("retry without --name and paginate using the returned page_token")
}
scanParams := roleListRequestParams(params, page)
data, err := rctx.CallAPITyped("GET", roleListURL(rctx), scanParams, nil)
if err != nil {
return nil, err
}
if firstPage == nil {
firstPage = data
}
items, hasMore, total, err := parseRoleListPage(data)
if err != nil {
return nil, err
}
if expectedTotal < 0 {
expectedTotal = total
} else if total != expectedTotal {
return nil, roleListProgressError("role list total changed across pages while filtering by name")
}
if scannedRoleCount+len(items) > expectedTotal {
return nil, roleListProgressError("role list returned more roles than its total while filtering by name")
}
scannedRoleCount += len(items)
if hasMore && scannedRoleCount >= expectedTotal {
return nil, roleListProgressError("role list reported more pages after reaching its total while filtering by name")
}
if !hasMore && scannedRoleCount != expectedTotal {
return nil, roleListProgressError("role list ended before returning its declared total while filtering by name")
}
signature, newRoleCount, err := roleListPageProgress(items, seenRoleIDs)
if err != nil {
return nil, err
}
if newRoleCount != len(items) {
return nil, roleListProgressError("role list repeated roles across pages while filtering by name")
}
if _, duplicate := seenPageSignatures[signature]; duplicate {
return nil, roleListProgressError("role list repeated a page while filtering by name")
}
seenPageSignatures[signature] = struct{}{}
if hasMore && (len(items) == 0 || newRoleCount == 0) {
return nil, roleListProgressError("role list reported more pages without returning new roles")
}
for _, item := range items {
role, ok := item.(map[string]interface{})
if ok && common.GetString(role, "name") == name {
allMatches = append(allMatches, item)
}
}
if !hasMore {
break
}
}
if firstPage == nil {
firstPage = map[string]interface{}{}
}
return normalizeFilteredRoleListData(firstPage, allMatches, requestedOffset, requestedLimit), nil
}
func normalizeFilteredRoleListData(data map[string]interface{}, matches []interface{}, offset, limit int) map[string]interface{} {
out := map[string]interface{}{}
for k, v := range data {
out[k] = v
}
start := offset
if start > len(matches) {
start = len(matches)
}
end := start + limit
if end > len(matches) {
end = len(matches)
}
hasMore := end < len(matches)
items := append([]interface{}(nil), matches[start:end]...)
if items == nil {
items = []interface{}{}
}
out["items"] = items
out["has_more"] = hasMore
out["page_token"] = roleNextPageToken(start, limit, hasMore)
out["total"] = len(matches)
return out
}
func normalizeRoleListData(data map[string]interface{}, params map[string]interface{}) (map[string]interface{}, error) {
items, hasMore, total, err := parseRoleListPage(data)
if err != nil {
return nil, err
}
out := map[string]interface{}{}
for k, v := range data {
out[k] = v
}
limit := roleIntValue(params["limit"])
offset := roleIntValue(params["offset"])
out["items"] = items
out["has_more"] = hasMore
out["page_token"] = roleNextPageToken(offset, limit, hasMore)
out["total"] = total
return out, nil
}
func parseRoleListPage(data map[string]interface{}) ([]interface{}, bool, int, error) {
rawItems, hasItems := data["items"]
items, ok := rawItems.([]interface{})
if !hasItems || !ok {
return nil, false, 0, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role list response field items must be an array",
).WithHint("retry the read; do not treat a missing or malformed role list as empty")
}
if err := validateRoleCollection(items, "role list response field items"); err != nil {
return nil, false, 0, err
}
rawHasMore, hasHasMore := data["has_more"]
hasMore, ok := rawHasMore.(bool)
if !hasHasMore || !ok {
return nil, false, 0, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role list response field has_more must be a boolean",
).WithHint("retry the read; pagination is incomplete without a valid has_more value")
}
total, ok := nonNegativeRoleInteger(data["total"])
if _, exists := data["total"]; !exists || !ok {
return nil, false, 0, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role list response field total must be a non-negative integer",
).WithHint("retry the read; do not infer a role count from a missing or malformed total value")
}
return items, hasMore, total, nil
}
func roleListPageProgress(items []interface{}, seenRoleIDs map[string]struct{}) (string, int, error) {
roleIDs := make([]string, 0, len(items))
newRoleCount := 0
for index, item := range items {
_, roleID, err := roleCollectionItem(item, "role list response field items", index)
if err != nil {
return "", 0, err
}
roleIDs = append(roleIDs, roleID)
if _, seen := seenRoleIDs[roleID]; !seen {
seenRoleIDs[roleID] = struct{}{}
newRoleCount++
}
}
return strings.Join(roleIDs, "\x00"), newRoleCount, nil
}
func nonNegativeRoleInteger(value interface{}) (int, bool) {
maxInt := uint64(^uint(0) >> 1)
toInt := func(value int64) (int, bool) {
if value < 0 || uint64(value) > maxInt {
return 0, false
}
return int(value), true
}
switch value := value.(type) {
case int:
if value < 0 {
return 0, false
}
return value, true
case int64:
return toInt(value)
case float64:
maxIntExclusive := math.Ldexp(1, strconv.IntSize-1)
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 || math.Trunc(value) != value || value >= maxIntExclusive {
return 0, false
}
return int(value), true
case json.Number:
parsed, err := value.Int64()
if err != nil {
return 0, false
}
return toInt(parsed)
case string:
if value == "" || strings.IndexFunc(value, func(r rune) bool {
return r < '0' || r > '9'
}) >= 0 {
return 0, false
}
parsed, err := strconv.ParseUint(value, 10, strconv.IntSize)
if err != nil || parsed > maxInt {
return 0, false
}
return int(parsed), true
default:
return 0, false
}
}
func roleListProgressError(message string) error {
return errs.NewInternalError(errs.SubtypeInvalidResponse, message).
WithHint("retry without --name and paginate manually; do not continue an incomplete exact-name scan")
}
func normalizeRoleDeleteData(data map[string]interface{}, requestedRoleID string) (map[string]interface{}, error) {
if data == nil {
return nil, invalidRoleDeleteResponse("role delete response data must be an object")
}
if len(data) == 0 {
return map[string]interface{}{
"role_id": requestedRoleID,
"deleted": true,
}, nil
}
out := map[string]interface{}{}
for k, v := range data {
out[k] = v
}
rawRoleID, ok := out["role_id"]
if !ok {
return nil, invalidRoleDeleteResponse("role delete response is missing role_id")
}
actualRoleID, stringOK := rawRoleID.(string)
if !stringOK || actualRoleID != requestedRoleID {
return nil, invalidRoleDeleteResponse(
"role delete response role_id does not match requested role_id %q",
requestedRoleID,
)
}
rawDeleted, ok := out["deleted"]
if !ok {
return nil, invalidRoleDeleteResponse("role delete response is missing deleted")
}
deleted, boolOK := rawDeleted.(bool)
if !boolOK || !deleted {
return nil, invalidRoleDeleteResponse("role delete response did not acknowledge deletion")
}
return out, nil
}
type roleResponseData struct {
RoleID string
Name string
Description string
}
func parseRoleDetailResponseData(data map[string]interface{}, expectedRoleID string) (roleResponseData, error) {
return parseRoleResponseData(data, expectedRoleID, true)
}
func parseRoleWriteResponseData(data map[string]interface{}, expectedRoleID string) (roleResponseData, error) {
return parseRoleResponseData(data, expectedRoleID, false)
}
func parseRoleResponseData(data map[string]interface{}, expectedRoleID string, requireName bool) (roleResponseData, error) {
if data == nil {
return roleResponseData{}, invalidRoleResponse("role response data must be an object")
}
rawRole, exists := data["role"]
role, ok := rawRole.(map[string]interface{})
if !exists || !ok || role == nil {
return roleResponseData{}, invalidRoleResponse("role response field role must be an object")
}
rawRoleID, exists := role["role_id"]
roleID, ok := rawRoleID.(string)
roleID = strings.TrimSpace(roleID)
if !exists || !ok || roleID == "" {
return roleResponseData{}, invalidRoleResponse("role response field role.role_id must be a non-empty string")
}
if expectedRoleID != "" && roleID != expectedRoleID {
return roleResponseData{}, invalidRoleResponse(
"role response role_id %q does not match requested role_id %q",
roleID,
expectedRoleID,
)
}
rawName, nameExists := role["name"]
name, nameOK := rawName.(string)
name = strings.TrimSpace(name)
if requireName && !nameExists {
return roleResponseData{}, invalidRoleResponse("role response field role.name must be a non-empty string")
}
if nameExists && (!nameOK || name == "") {
return roleResponseData{}, invalidRoleResponse("role response field role.name must be a non-empty string")
}
rawDescription, descriptionExists := role["description"]
description, descriptionOK := rawDescription.(string)
if descriptionExists && !descriptionOK {
return roleResponseData{}, invalidRoleResponse("role response field role.description must be a string")
}
return roleResponseData{RoleID: roleID, Name: name, Description: description}, nil
}
func invalidRoleResponse(message string, args ...interface{}) error {
return errs.NewInternalError(errs.SubtypeInvalidResponse, message, args...).
WithHint("retry the role read; do not treat a missing or malformed role as a successful result")
}
func invalidRoleDeleteResponse(message string, args ...interface{}) error {
return errs.NewInternalError(errs.SubtypeInvalidResponse, message, args...).
WithHint("do not claim deletion; verify the target role with +role-list --name <exact_name>")
}
func roleIntValue(value interface{}) int {
switch v := value.(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case json.Number:
i, err := strconv.Atoi(v.String())
if err == nil {
return i
}
case string:
i, err := strconv.Atoi(strings.TrimSpace(v))
if err == nil {
return i
}
}
return 0
}
func renderRoleCreatePretty(w io.Writer, role roleResponseData) {
fmt.Fprintf(w, "Created role %s\n", roleDisplayValue(role.RoleID))
}
func renderRoleGetPretty(w io.Writer, role roleResponseData) {
renderRoleDetailPretty(w, role)
}
func renderRoleUpdatePretty(w io.Writer, role roleResponseData) {
fmt.Fprintf(w, "Updated role %s\n", roleDisplayValue(role.RoleID))
}
func renderRoleDeletePretty(w io.Writer, roleID string) {
fmt.Fprintf(w, "Deleted role %s\n", roleDisplayValue(roleID))
}
func renderRoleDetailPretty(w io.Writer, role roleResponseData) {
fmt.Fprintf(w, "role_id: %s\n", roleDisplayValue(role.RoleID))
fmt.Fprintf(w, "name: %s\n", roleDisplayValue(role.Name))
fmt.Fprintf(w, "description: %s\n", roleDisplayValue(role.Description))
}
func renderRoleListPretty(w io.Writer, items []interface{}) {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ROLE ID\tNAME\tDESCRIPTION")
for _, item := range items {
role, ok := item.(map[string]interface{})
if !ok {
continue
}
fmt.Fprintf(tw, "%s\t%s\t%s\n",
roleDisplayValue(firstNonEmpty(common.GetString(role, "role_id"), common.GetString(role, "id"))),
roleDisplayValue(common.GetString(role, "name")),
roleDisplayValue(common.GetString(role, "description")))
}
_ = tw.Flush()
}

View File

@@ -0,0 +1,490 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"fmt"
"regexp"
"strconv"
"strings"
"unicode"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
roleListPath = apiBasePath + "/apps/%s/roles"
roleItemPath = apiBasePath + "/apps/%s/roles/%s"
roleMemberListPath = apiBasePath + "/apps/%s/roles/%s/member_list"
roleMemberAddPath = apiBasePath + "/apps/%s/roles/%s/member_add"
roleMemberRemovePath = apiBasePath + "/apps/%s/roles/%s/member_remove"
roleMatchListPath = apiBasePath + "/apps/%s/user_role_list"
defaultRolePageSize = 20
maxRolePageSize = 100
maxRoleMembers = 100
roleErrInvalidParameters = 3340001
roleErrUserLimitExceeded = 3344027
roleErrDepartmentLimitExceeded = 3344028
roleErrChatLimitExceeded = 3344029
roleErrAdminRequired = 3344030
roleErrManagerRequired = 3344031
roleErrInvalidRoleID = 3344034
roleErrRoleNotFound = 3344035
roleErrRoleAlreadyExists = 3344036
roleErrRoleLimitExceeded = 3344037
roleErrInvalidRoleName = 3344038
roleErrInvalidRoleDescription = 3344039
roleErrUnsupportedMemberType = 3344040
roleErrInvalidMemberID = 3344041
)
var optionalRoleIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
const (
roleAppHint = "verify --app-id is a Miaoda app_id you can access; list apps with `lark-cli apps +list`"
roleItemHint = "verify --role-id belongs to the app; if you only know a role name, resolve it with `lark-cli apps +role-list --app-id <app_id> --name <exact_name>` and use the unique returned role_id"
roleCreateHint = "verify --app-id and role fields; omit --role-id unless you need a caller-provided role ID"
roleMemberHint = "verify --role-id and member IDs; use user open_id, open_department_id, or open_chat_id values"
roleMatchHint = "use --user-id with a user open_id; do not pass role_id or enumerate roles manually"
roleAppIDRequiredDesc = "Miaoda app ID (required; app_...; use apps +list to find it)"
roleIDRequiredDesc = "role ID (required; [A-Za-z0-9_-]{1,64}; use role-list to find it)"
roleUserIDRequiredDesc = "user open ID (required; ou_...; do not pass a role ID, name, or email)"
)
type roleErrorOperation uint8
const (
roleOperationList roleErrorOperation = iota
roleOperationGet
roleOperationCreate
roleOperationUpdate
roleOperationDelete
roleOperationMemberList
roleOperationMemberAdd
roleOperationMemberRemove
roleOperationMatchList
)
type roleMemberGroups struct {
Users []string `json:"users"`
Departments []string `json:"departments"`
Chats []string `json:"chats"`
}
type roleMemberKind struct {
memberType string
dataKey string
flagName string
prefix string
}
var roleMemberKinds = []roleMemberKind{
{memberType: "user", dataKey: "users", flagName: "--users", prefix: "ou_"},
{memberType: "department", dataKey: "departments", flagName: "--departments", prefix: "od-"},
{memberType: "chat", dataKey: "chats", flagName: "--chats", prefix: "oc_"},
}
func roleAppID(rctx *common.RuntimeContext) string {
return strings.TrimSpace(rctx.Str("app-id"))
}
func roleID(rctx *common.RuntimeContext) string {
return strings.TrimSpace(rctx.Str("role-id"))
}
func validateRoleAppID(rctx *common.RuntimeContext) error {
appID := roleAppID(rctx)
if appID == "" {
return appsValidationParamError("--app-id", "--app-id is required").
WithHint("list your apps with `lark-cli apps +list`")
}
if strings.HasPrefix(appID, "cli_") {
return appsValidationParamError("--app-id", "--app-id must be a Miaoda app_id, not a Lark app_id").
WithHint("pass the app_... value from `lark-cli apps +list`, not the cli_... credential app id")
}
if !strings.HasPrefix(appID, "app_") || len(appID) == len("app_") {
return appsValidationParamError("--app-id", "--app-id must be a Miaoda app_id starting with app_").
WithHint("list Miaoda apps with `lark-cli apps +list`, then pass the returned app_id")
}
// app-id must not contain forward slashes (apps are identified by app_xxx IDs).
for _, r := range appID {
if r == '/' || r == '\\' || unicode.IsSpace(r) || unicode.IsControl(r) {
return appsValidationParamError("--app-id", "--app-id must not contain slashes, whitespace, or control characters")
}
}
// Defense-in-depth: block path traversal and URL metacharacters.
if err := validateRolePathSegmentSafe(appID, "--app-id"); err != nil {
return err
}
return nil
}
func validateRoleID(rctx *common.RuntimeContext) error {
if err := validateRoleAppID(rctx); err != nil {
return err
}
roleID := roleID(rctx)
if roleID == "" {
return appsValidationParamError("--role-id", "--role-id is required").
WithHint("list roles with `lark-cli apps +role-list --app-id <app_id>`")
}
return validateExistingRoleIDValue(roleID)
}
// validateRolePathSegmentSafe rejects path-traversal segments ("..") and URL
// metacharacters (? # %) in values interpolated into a URL path, providing
// defense-in-depth alongside validate.EncodePathSegment.
func validateRolePathSegmentSafe(value, flagName string) error {
for _, seg := range strings.Split(value, "/") {
if seg == ".." {
return appsValidationParamError(flagName, "%s must not contain '..' path traversal", flagName).
WithHint("provide a valid %s without path traversal", flagName)
}
}
if strings.ContainsAny(value, "?#%") {
return appsValidationParamError(flagName, "%s contains invalid URL characters (?, #, %%)", flagName).
WithHint("provide a valid %s without URL metacharacters", flagName)
}
return nil
}
func validateOptionalRoleID(roleID string) error {
roleID = strings.TrimSpace(roleID)
if roleID == "" {
return nil
}
return validateCreateRoleIDValue(roleID)
}
func validateCreateRoleIDValue(roleID string) error {
if !optionalRoleIDPattern.MatchString(roleID) {
return appsValidationParamError("--role-id", "--role-id must match [A-Za-z0-9_-]{1,64}").
WithHint("omit --role-id to let the server generate one")
}
return nil
}
func validateExistingRoleIDValue(roleID string) error {
if !optionalRoleIDPattern.MatchString(roleID) {
return appsValidationParamError("--role-id", "--role-id must match [A-Za-z0-9_-]{1,64}").
WithHint("resolve the role with `lark-cli apps +role-list --app-id <app_id> --name <exact_name>` and pass its role_id")
}
return nil
}
func buildRolePageParams(rctx *common.RuntimeContext) (map[string]interface{}, error) {
limit := defaultRolePageSize
if rctx.Changed("page-size") {
limit = rctx.Int("page-size")
}
if limit < 1 || limit > maxRolePageSize {
return nil, appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxRolePageSize).
WithHint("use --page-size between 1 and 100")
}
offset := 0
pageToken := strings.TrimSpace(rctx.Str("page-token"))
if pageToken != "" {
parsedOffset, err := strconv.Atoi(pageToken)
if err != nil || parsedOffset < 0 {
return nil, appsValidationParamError("--page-token", "--page-token must be a non-negative integer offset").
WithHint("reuse page_token from the previous +role-list response")
}
offset = parsedOffset
}
return map[string]interface{}{
"limit": limit,
"offset": offset,
}, nil
}
func roleNextPageToken(offset, limit int, hasMore bool) string {
if !hasMore {
return ""
}
return strconv.Itoa(offset + limit)
}
func splitRoleMemberCSV(s, flagName string) ([]string, error) {
parts := strings.Split(s, ",")
values := make([]string, 0, len(parts))
for _, part := range parts {
value := strings.TrimSpace(part)
if value == "" {
continue
}
// Reject values containing whitespace, control characters, or URL metacharacters
// (member IDs are open_id/open_department_id/open_chat_id which are safe tokens).
if err := validateMemberID(value, flagName); err != nil {
return nil, err
}
values = append(values, value)
}
return values, nil
}
// validateMemberID rejects values containing characters that are invalid in
// open_id / open_department_id / open_chat_id tokens (whitespace, controls, URL metacharacters).
func validateMemberID(value, flagName string) error {
if err := validateMemberIDPrefix(value, flagName); err != nil {
return err
}
for _, r := range value {
if unicode.IsSpace(r) || unicode.IsControl(r) {
return appsValidationParamError(flagName, "member IDs must not contain whitespace or control characters").
WithHint("pass comma-separated open_id/open_department_id/open_chat_id values without spaces")
}
if r == '?' || r == '#' || r == '%' || r == '/' || r == '\\' {
return appsValidationParamError(flagName, "member IDs must not contain URL metacharacters (?, #, %, /, \\)").
WithHint("pass comma-separated open_id/open_department_id/open_chat_id values without URL characters")
}
}
return nil
}
func validateMemberIDPrefix(value, flagName string) error {
kind, ok := roleMemberKindForFlag(flagName)
if !ok {
return nil
}
if !strings.HasPrefix(value, kind.prefix) || len(value) == len(kind.prefix) {
return appsValidationParamError(flagName, "%s must use %s IDs", flagName, kind.prefix).
WithHint("resolve names or emails to open IDs before calling role member commands")
}
return nil
}
func roleMemberKindForFlag(flagName string) (roleMemberKind, bool) {
if flagName == "--user-id" {
flagName = "--users"
}
for _, kind := range roleMemberKinds {
if kind.flagName == flagName {
return kind, true
}
}
return roleMemberKind{}, false
}
func roleMemberKindForType(memberType string) (roleMemberKind, bool) {
for _, kind := range roleMemberKinds {
if kind.memberType == memberType {
return kind, true
}
}
return roleMemberKind{}, false
}
func roleDisplayValue(value string) string {
value = validate.SanitizeForTerminal(value)
value = strings.NewReplacer("\n", " ", "\t", " ").Replace(value)
return strings.TrimSpace(value)
}
// withRoleErrorHint refines documented Spark role errors with command-specific
// recovery while preserving the typed error, numeric code, log_id, and any
// server-provided detail. Unknown codes retain the existing Apps fallback.
func withRoleErrorHint(err error, operation roleErrorOperation) error {
if err == nil {
return nil
}
problem, ok := errs.ProblemOf(err)
if !ok {
return err
}
hint := roleErrorHint(problem.Code, operation)
if hint == "" {
return withAppsHint(err, roleFallbackHint(operation))
}
existing := strings.TrimSpace(problem.Hint)
canonicalAPIHint := strings.TrimSpace(errclass.APIHint(problem.Subtype))
switch {
case existing == "", existing == canonicalAPIHint:
problem.Hint = hint
case !strings.Contains(existing, hint):
problem.Hint = existing + "; " + hint
}
return err
}
func roleFallbackHint(operation roleErrorOperation) string {
switch operation {
case roleOperationList:
return roleAppHint
case roleOperationCreate:
return roleCreateHint
case roleOperationMemberList, roleOperationMemberAdd, roleOperationMemberRemove:
return roleMemberHint
case roleOperationMatchList:
return roleMatchHint
default:
return roleItemHint
}
}
func roleErrorHint(code int, operation roleErrorOperation) string {
switch code {
case roleErrInvalidParameters:
return roleFallbackHint(operation)
case roleErrAdminRequired:
return "ask an app administrator to perform this operation or grant the calling user app-administrator access"
case roleErrManagerRequired:
return "ask an app administrator or app developer to perform this operation, or grant the calling user app-management access"
case roleErrInvalidRoleID:
if operation == roleOperationCreate {
return "omit --role-id to let the server generate one, or provide a role ID accepted by the role service"
}
case roleErrRoleNotFound:
if operation == roleOperationMatchList {
return "list the app's current roles and retry; role data used for this match may no longer be valid"
}
return roleItemHint
case roleErrRoleAlreadyExists:
if operation == roleOperationCreate {
return "choose a different --role-id or omit --role-id to let the server generate one"
}
case roleErrRoleLimitExceeded:
if operation == roleOperationCreate {
return "delete an unused app role before creating another role"
}
case roleErrInvalidRoleName:
if operation == roleOperationCreate || operation == roleOperationUpdate {
return "adjust --name to a non-empty value accepted by the role service"
}
case roleErrInvalidRoleDescription:
if operation == roleOperationCreate || operation == roleOperationUpdate {
return "adjust --description to a value accepted by the role service"
}
case roleErrUnsupportedMemberType:
if operation == roleOperationMemberList {
return "use --member-type user, department, or chat, or omit --member-type to list all member types"
}
case roleErrInvalidMemberID:
if operation == roleOperationMatchList {
return "resolve the target user to an open_id and retry with --user-id <open_id>"
}
if operation == roleOperationMemberAdd || operation == roleOperationMemberRemove {
return roleMemberHint
}
case roleErrUserLimitExceeded:
if operation == roleOperationMemberAdd {
return "reduce the users being added with --users, or remove unused user members before retrying"
}
case roleErrDepartmentLimitExceeded:
if operation == roleOperationMemberAdd {
return "reduce the departments being added with --departments, or remove unused department members before retrying"
}
case roleErrChatLimitExceeded:
if operation == roleOperationMemberAdd {
return "reduce the chats being added with --chats, or remove unused chat members before retrying"
}
}
return ""
}
func roleCollectionItem(item interface{}, collection string, index int) (map[string]interface{}, string, error) {
role, ok := item.(map[string]interface{})
if !ok {
return nil, "", invalidRoleCollectionResponse("%s item %d must be an object", collection, index)
}
rawRoleID, exists := role["role_id"]
roleID, stringOK := rawRoleID.(string)
roleID = strings.TrimSpace(roleID)
if !exists || !stringOK || roleID == "" {
return nil, "", invalidRoleCollectionResponse("%s item %d must contain a non-empty string role_id", collection, index)
}
rawName, exists := role["name"]
name, stringOK := rawName.(string)
if !exists || !stringOK || strings.TrimSpace(name) == "" {
return nil, "", invalidRoleCollectionResponse("%s item %d must contain a non-empty string name", collection, index)
}
return role, roleID, nil
}
func validateRoleCollection(items []interface{}, collection string) error {
for index, item := range items {
if _, _, err := roleCollectionItem(item, collection, index); err != nil {
return err
}
}
return nil
}
func invalidRoleCollectionResponse(format string, args ...interface{}) error {
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...).
WithHint("retry the read; do not treat missing or malformed role data as an empty or complete result")
}
func buildRoleMemberGroups(usersCSV, departmentsCSV, chatsCSV string) (roleMemberGroups, error) {
users, err := splitRoleMemberCSV(usersCSV, "--users")
if err != nil {
return roleMemberGroups{}, err
}
departments, err := splitRoleMemberCSV(departmentsCSV, "--departments")
if err != nil {
return roleMemberGroups{}, err
}
chats, err := splitRoleMemberCSV(chatsCSV, "--chats")
if err != nil {
return roleMemberGroups{}, err
}
groups := roleMemberGroups{
Users: users,
Departments: departments,
Chats: chats,
}
total := len(groups.Users) + len(groups.Departments) + len(groups.Chats)
if total == 0 {
reason := "provide at least one of --users, --departments, or --chats"
return groups, appsValidationError("at least one of --users, --departments, or --chats is required").
WithParams(
appsInvalidParam("--users", reason),
appsInvalidParam("--departments", reason),
appsInvalidParam("--chats", reason),
).
WithHint("resolve names to IDs first, then pass --users open_id, --departments open_department_id, or --chats open_chat_id")
}
if total > maxRoleMembers {
return groups, appsValidationError("role members cannot exceed %d", maxRoleMembers).
WithParams(roleMemberLimitParams(groups)...).
WithHint(fmt.Sprintf("reduce the atomic request to at most %d members; the CLI does not split member writes automatically", maxRoleMembers))
}
return groups, nil
}
func buildRoleMemberBody(groups roleMemberGroups) map[string]interface{} {
body := map[string]interface{}{}
if len(groups.Users) > 0 {
body["users"] = groups.Users
}
if len(groups.Departments) > 0 {
body["departments"] = groups.Departments
}
if len(groups.Chats) > 0 {
body["chats"] = groups.Chats
}
return body
}
func roleMemberLimitParams(groups roleMemberGroups) []errs.InvalidParam {
reason := fmt.Sprintf("combined role member count exceeds %d", maxRoleMembers)
params := make([]errs.InvalidParam, 0, len(roleMemberKinds))
if len(groups.Users) > 0 {
params = append(params, appsInvalidParam("--users", reason))
}
if len(groups.Departments) > 0 {
params = append(params, appsInvalidParam("--departments", reason))
}
if len(groups.Chats) > 0 {
params = append(params, appsInvalidParam("--chats", reason))
}
return params
}

View File

@@ -0,0 +1,447 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func newRoleRCtx(t *testing.T, flagDefs map[string]string, flags map[string]string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
cfg := &core.CliConfig{
AppID: "test-app-" + strings.ToLower(t.Name()),
AppSecret: "test-secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_test",
}
factory, stdoutBuf, _, reg := cmdutil.TestFactory(t, cfg)
cmd := &cobra.Command{Use: "test-role"}
cmd.SetContext(context.Background())
for name, typ := range flagDefs {
switch typ {
case "bool":
cmd.Flags().Bool(name, false, "")
case "int":
cmd.Flags().Int(name, 0, "")
case "string_array":
cmd.Flags().StringArray(name, nil, "")
default:
cmd.Flags().String(name, "", "")
}
}
for name, val := range flags {
if err := cmd.Flags().Set(name, val); err != nil {
t.Fatalf("set flag %q = %q: %v", name, val, err)
}
}
rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser)
return rctx, stdoutBuf, reg
}
func assertRoleValidationParam(t *testing.T, err error, param string) *errs.Problem {
t.Helper()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %#v, want typed problem", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("category = %q, want validation", problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want invalid_argument", problem.Subtype)
}
var validation *errs.ValidationError
if !errors.As(err, &validation) {
t.Fatalf("err = %#v, want validation error", err)
}
if validation.Param != param {
t.Fatalf("param = %q, want %s", validation.Param, param)
}
return problem
}
func assertRoleValidationParams(t *testing.T, err error, params ...string) *errs.Problem {
t.Helper()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %#v, want typed problem", err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %+v, want validation/invalid_argument", problem)
}
var validation *errs.ValidationError
if !errors.As(err, &validation) {
t.Fatalf("err = %#v, want validation error", err)
}
if validation.Param != "" {
t.Fatalf("param = %q, want omitted for multi-parameter constraint", validation.Param)
}
if len(validation.Params) != len(params) {
t.Fatalf("params = %#v, want %v", validation.Params, params)
}
for index, want := range params {
if validation.Params[index].Name != want || validation.Params[index].Reason == "" {
t.Fatalf("params[%d] = %#v, want name=%q with a reason", index, validation.Params[index], want)
}
}
return problem
}
func TestBuildRolePageParams_DefaultAndChanged(t *testing.T) {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"page-size": "int",
"page-token": "string",
}, map[string]string{})
params, err := buildRolePageParams(rctx)
if err != nil {
t.Fatalf("buildRolePageParams() = %v", err)
}
if params["limit"] != defaultRolePageSize || params["offset"] != 0 {
t.Fatalf("params = %#v, want limit=%d offset=0", params, defaultRolePageSize)
}
rctx, _, _ = newRoleRCtx(t, map[string]string{
"page-size": "int",
"page-token": "string",
}, map[string]string{"page-size": "20", "page-token": "40"})
params, err = buildRolePageParams(rctx)
if err != nil {
t.Fatalf("buildRolePageParams(changed) = %v", err)
}
if params["limit"] != 20 || params["offset"] != 40 {
t.Fatalf("params = %#v, want limit=20 offset=40", params)
}
}
func TestBuildRolePageParams_RejectsInvalidToken(t *testing.T) {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"page-size": "int",
"page-token": "string",
}, map[string]string{"page-token": "abc"})
_, err := buildRolePageParams(rctx)
assertRoleValidationParam(t, err, "--page-token")
}
func TestBuildRolePageParams_RejectsPageSizeOverMax(t *testing.T) {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"page-size": "int",
"page-token": "string",
}, map[string]string{"page-size": "101"})
_, err := buildRolePageParams(rctx)
assertRoleValidationParam(t, err, "--page-size")
}
func TestValidateOptionalRoleID(t *testing.T) {
for _, good := range []string{"", " role_001 ", "Role-ABC", "abc123", strings.Repeat("a", 64)} {
if err := validateOptionalRoleID(good); err != nil {
t.Fatalf("validateOptionalRoleID(%q) = %v", good, err)
}
}
for _, bad := range []string{"bad/role", "bad role", strings.Repeat("a", 65)} {
err := validateOptionalRoleID(bad)
problem := assertRoleValidationParam(t, err, "--role-id")
if !strings.Contains(problem.Hint, "omit --role-id") {
t.Fatalf("hint = %q, want create-specific omit guidance", problem.Hint)
}
}
}
func TestRoleFlagHelpersTrim(t *testing.T) {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"app-id": "string",
"role-id": "string",
}, map[string]string{"app-id": " app_1 ", "role-id": " role_1 "})
if got := roleAppID(rctx); got != "app_1" {
t.Fatalf("roleAppID() = %q, want app_1", got)
}
if got := roleID(rctx); got != "role_1" {
t.Fatalf("roleID() = %q, want role_1", got)
}
if err := validateRoleID(rctx); err != nil {
t.Fatalf("validateRoleID() = %v, want nil", err)
}
}
func TestValidateRoleAppIDRejectsEmpty(t *testing.T) {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"app-id": "string",
}, map[string]string{})
problem := assertRoleValidationParam(t, validateRoleAppID(rctx), "--app-id")
if problem.Message != "--app-id is required" {
t.Fatalf("message = %q, want --app-id is required", problem.Message)
}
if problem.Hint == "" {
t.Fatalf("hint is empty, want recovery guidance")
}
}
func TestValidateRoleAppIDRejectsPathSegmentUnsafeChars(t *testing.T) {
for _, appID := range []string{"app/bad", `app\bad`, "app bad", "app\u00a0bad", "app\nbad", "app\u0000bad"} {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"app-id": "string",
}, map[string]string{"app-id": appID})
assertRoleValidationParam(t, validateRoleAppID(rctx), "--app-id")
}
}
func TestValidateRoleAppIDRejectsLarkCredentialAppID(t *testing.T) {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"app-id": "string",
}, map[string]string{"app-id": "cli_app"})
assertRoleValidationParam(t, validateRoleAppID(rctx), "--app-id")
}
func TestValidateRoleAppIDRequiresMiaodaPrefix(t *testing.T) {
for _, appID := range []string{"app", "app_", "miaoda_123", "plain"} {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"app-id": "string",
}, map[string]string{"app-id": appID})
problem := assertRoleValidationParam(t, validateRoleAppID(rctx), "--app-id")
if !strings.Contains(problem.Message, "starting with app_") {
t.Fatalf("appID=%q message=%q, want app_ guidance", appID, problem.Message)
}
}
}
func TestValidateRoleIDRejectsInvalidRequiredRoleID(t *testing.T) {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"app-id": "string",
"role-id": "string",
}, map[string]string{"app-id": "app_x", "role-id": "bad/role"})
problem := assertRoleValidationParam(t, validateRoleID(rctx), "--role-id")
if strings.Contains(problem.Hint, "omit --role-id") || !strings.Contains(problem.Hint, "+role-list") {
t.Fatalf("hint = %q, want existing-role resolution guidance", problem.Hint)
}
}
func TestValidateRoleIDRejectsMissingRequiredRoleID(t *testing.T) {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"app-id": "string",
"role-id": "string",
}, map[string]string{"app-id": "app_x"})
problem := assertRoleValidationParam(t, validateRoleID(rctx), "--role-id")
if problem.Message != "--role-id is required" {
t.Fatalf("message = %q, want --role-id is required", problem.Message)
}
}
func TestBuildRoleMemberGroupsAndBody(t *testing.T) {
groups, err := buildRoleMemberGroups(" ou_a,ou_b ", " od-a ", " oc_a ")
if err != nil {
t.Fatalf("buildRoleMemberGroups() = %v", err)
}
if len(groups.Users) != 2 || len(groups.Departments) != 1 || len(groups.Chats) != 1 {
t.Fatalf("groups = %#v", groups)
}
body := buildRoleMemberBody(groups)
assertJSONEquivalent(t, body, map[string]interface{}{
"users": []interface{}{"ou_a", "ou_b"},
"departments": []interface{}{"od-a"},
"chats": []interface{}{"oc_a"},
})
}
func TestBuildRoleMemberGroupsRejectsEmpty(t *testing.T) {
_, err := buildRoleMemberGroups(" , ", "", "")
assertRoleValidationParams(t, err, "--users", "--departments", "--chats")
}
func TestBuildRoleMemberGroupsRejectsInvalidMemberIDWithSourceParam(t *testing.T) {
tests := []struct {
name string
users string
departments string
chats string
wantParam string
}{
{name: "users slash", users: "ou/bad", wantParam: "--users"},
{name: "users email", users: "alice@example.com", wantParam: "--users"},
{name: "users wrong prefix", users: "user_123", wantParam: "--users"},
{name: "users prefix only", users: "ou_", wantParam: "--users"},
{name: "departments wrong prefix", departments: "ou_user", wantParam: "--departments"},
{name: "departments prefix only", departments: "od-", wantParam: "--departments"},
{name: "legacy departments prefix", departments: "od_department", wantParam: "--departments"},
{name: "chats wrong prefix", chats: "od-department", wantParam: "--chats"},
{name: "chats prefix only", chats: "oc_", wantParam: "--chats"},
{
name: "departments",
departments: "od-bad value",
wantParam: "--departments",
},
{
name: "chats",
chats: "oc?bad",
wantParam: "--chats",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := buildRoleMemberGroups(tt.users, tt.departments, tt.chats)
assertRoleValidationParam(t, err, tt.wantParam)
})
}
}
func TestBuildRoleMemberGroupsRejectsMoreThanMax(t *testing.T) {
users := make([]string, maxRoleMembers+1)
for i := range users {
users[i] = "ou_test"
}
_, err := buildRoleMemberGroups(strings.Join(users, ","), "", "")
assertRoleValidationParams(t, err, "--users")
}
func TestBuildRoleMemberGroupsRejectsMoreThanMaxOnlyChats(t *testing.T) {
chats := make([]string, maxRoleMembers+1)
for i := range chats {
chats[i] = "oc_test"
}
_, err := buildRoleMemberGroups("", "", strings.Join(chats, ","))
problem := assertRoleValidationParams(t, err, "--chats")
if !strings.Contains(problem.Message, "role members cannot exceed 100") {
t.Fatalf("message = %q, want role members limit", problem.Message)
}
if !strings.Contains(problem.Hint, "does not split") || !strings.Contains(problem.Hint, "atomic request") {
t.Fatalf("hint = %q, want no automatic batching guidance", problem.Hint)
}
}
func TestBuildRoleMemberGroupsOverflowNamesEveryContributingFlag(t *testing.T) {
users := strings.TrimSuffix(strings.Repeat("ou_user,", 60), ",")
chats := strings.TrimSuffix(strings.Repeat("oc_chat,", 41), ",")
_, err := buildRoleMemberGroups(users, "", chats)
assertRoleValidationParams(t, err, "--users", "--chats")
}
func TestRoleMemberKindsAreCompleteAndStable(t *testing.T) {
want := []roleMemberKind{
{memberType: "user", dataKey: "users", flagName: "--users", prefix: "ou_"},
{memberType: "department", dataKey: "departments", flagName: "--departments", prefix: "od-"},
{memberType: "chat", dataKey: "chats", flagName: "--chats", prefix: "oc_"},
}
if len(roleMemberKinds) != len(want) {
t.Fatalf("roleMemberKinds = %#v, want %#v", roleMemberKinds, want)
}
for index := range want {
if roleMemberKinds[index] != want[index] {
t.Fatalf("roleMemberKinds[%d] = %#v, want %#v", index, roleMemberKinds[index], want[index])
}
}
}
func TestRoleDisplayValueSanitizesAndFlattens(t *testing.T) {
got := roleDisplayValue(" Admin\n\x1b[31mred\x1b[0m\tvalue ")
if got != "Admin red value" {
t.Fatalf("roleDisplayValue() = %q, want flattened safe text", got)
}
}
func TestRoleNextPageToken(t *testing.T) {
if got := roleNextPageToken(40, 20, true); got != "60" {
t.Fatalf("roleNextPageToken(hasMore) = %q, want 60", got)
}
if got := roleNextPageToken(40, 20, false); got != "" {
t.Fatalf("roleNextPageToken(!hasMore) = %q, want empty", got)
}
}
func TestWithRoleErrorHintUsesDocumentedRecoveryAndPreservesEnvelope(t *testing.T) {
tests := []struct {
name string
code int
operation roleErrorOperation
wantHint string
forbid string
}{
{name: "invalid parameters", code: roleErrInvalidParameters, operation: roleOperationList, wantHint: roleAppHint},
{name: "administrator required", code: roleErrAdminRequired, operation: roleOperationList, wantHint: "app administrator"},
{name: "administrator or developer required", code: roleErrManagerRequired, operation: roleOperationGet, wantHint: "administrator or app developer"},
{name: "invalid create role id", code: roleErrInvalidRoleID, operation: roleOperationCreate, wantHint: "omit --role-id"},
{name: "role missing", code: roleErrRoleNotFound, operation: roleOperationGet, wantHint: "+role-list"},
{name: "stale match role", code: roleErrRoleNotFound, operation: roleOperationMatchList, wantHint: "may no longer be valid", forbid: "--role-id"},
{name: "duplicate role id", code: roleErrRoleAlreadyExists, operation: roleOperationCreate, wantHint: "different --role-id"},
{name: "role limit", code: roleErrRoleLimitExceeded, operation: roleOperationCreate, wantHint: "delete an unused app role"},
{name: "invalid role name", code: roleErrInvalidRoleName, operation: roleOperationUpdate, wantHint: "adjust --name"},
{name: "invalid role description", code: roleErrInvalidRoleDescription, operation: roleOperationUpdate, wantHint: "adjust --description"},
{name: "unsupported member type", code: roleErrUnsupportedMemberType, operation: roleOperationMemberList, wantHint: "user, department, or chat"},
{name: "invalid member id", code: roleErrInvalidMemberID, operation: roleOperationMemberAdd, wantHint: "member IDs"},
{name: "invalid match target", code: roleErrInvalidMemberID, operation: roleOperationMatchList, wantHint: "--user-id", forbid: "--role-id"},
{name: "user quota", code: roleErrUserLimitExceeded, operation: roleOperationMemberAdd, wantHint: "reduce the users"},
{name: "department quota", code: roleErrDepartmentLimitExceeded, operation: roleOperationMemberAdd, wantHint: "reduce the departments"},
{name: "chat quota", code: roleErrChatLimitExceeded, operation: roleOperationMemberAdd, wantHint: "reduce the chats"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errclass.BuildAPIError(map[string]any{
"code": tt.code,
"msg": "role request failed",
"log_id": "log-role-hint",
}, errclass.ClassifyContext{Identity: "user"})
err = withRoleErrorHint(err, tt.operation)
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %#v, want typed problem", err)
}
if problem.Code != tt.code || problem.LogID != "log-role-hint" || problem.Retryable {
t.Fatalf("problem envelope changed: %+v", problem)
}
if !strings.Contains(problem.Hint, tt.wantHint) {
t.Fatalf("hint = %q, want substring %q", problem.Hint, tt.wantHint)
}
if tt.forbid != "" && strings.Contains(problem.Hint, tt.forbid) {
t.Fatalf("hint = %q, must not contain %q", problem.Hint, tt.forbid)
}
})
}
}
func TestWithRoleErrorHintPreservesServerDetail(t *testing.T) {
err := errclass.BuildAPIError(map[string]any{
"code": roleErrInvalidRoleName,
"msg": "invalid role name",
"error": map[string]any{
"details": []any{map[string]any{"value": "name exceeds the service limit"}},
},
}, errclass.ClassifyContext{Identity: "user"})
err = withRoleErrorHint(err, roleOperationCreate)
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %#v, want typed problem", err)
}
for _, want := range []string{"name exceeds the service limit", "adjust --name"} {
if !strings.Contains(problem.Hint, want) {
t.Fatalf("hint = %q, want %q", problem.Hint, want)
}
}
}
func TestWithRoleErrorHintPreservesAuthorizationDetail(t *testing.T) {
var err error = errs.NewPermissionError(errs.SubtypePermissionDenied, "administrator access required").
WithCode(roleErrAdminRequired).
WithHint("server detail: only owners may change this app")
err = withRoleErrorHint(err, roleOperationUpdate)
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %#v, want typed problem", err)
}
for _, want := range []string{"server detail: only owners", "ask an app administrator"} {
if !strings.Contains(problem.Hint, want) {
t.Fatalf("hint = %q, want %q", problem.Hint, want)
}
}
}

View File

@@ -0,0 +1,611 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"text/tabwriter"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsRoleMemberList lists members of an app role.
var AppsRoleMemberList = common.Shortcut{
Service: appsService,
Command: "+role-member-list",
Description: "List app role members",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +role-member-list --app-id <app_id> --role-id <role_id>",
"Example: lark-cli apps +role-member-list --app-id <app_id> --role-id <role_id> --member-type user",
"When only one member type is requested, pass --member-type user|department|chat instead of filtering the full response",
"--member-type returns only the selected member field; omitted fields are unknown, so omit the flag for pre/post-write baselines",
"--format table renders the CLI-native member_type/member_id table; this command has no --limit or --page-size flag",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
{Name: "member-type", Desc: "filter member type", Enum: []string{"user", "department", "chat"}},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if err := validateRoleID(rctx); err != nil {
return err
}
_, err := buildRoleMemberListParams(rctx)
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
// Validate already ran and called buildRoleMemberListParams; error is impossible here.
params, _ := buildRoleMemberListParams(rctx)
return common.NewDryRunAPI().
GET(roleMemberListURL(rctx)).
Desc("List app role members").
Params(params)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
params, err := buildRoleMemberListParams(rctx)
if err != nil {
return err
}
data, err := rctx.CallAPITyped("GET", roleMemberListURL(rctx), params, nil)
memberType, _ := params["member_type"].(string)
if shouldRetryRoleMemberListWithoutFilter(err, memberType) {
fmt.Fprintln(rctx.IO().ErrOut, "warning: the server rejected chat member filtering; retried without the filter and returned only the chats field. Omit --member-type for a complete member baseline.")
data, err = rctx.CallAPITyped("GET", roleMemberListURL(rctx), nil, nil)
}
if err != nil {
return withRoleErrorHint(err, roleOperationMemberList)
}
data, err = normalizeRoleMemberListData(data, memberType)
if err != nil {
return err
}
if memberType != "" {
fmt.Fprintf(
rctx.IO().ErrOut,
"warning: --member-type=%s returns only the selected member field; omitted member fields are unknown. Omit --member-type for a complete member baseline.\n",
memberType,
)
}
out := roleMemberListOutputData(rctx, data)
rctx.OutFormat(out, nil, func(w io.Writer) {
renderRoleMemberListPretty(w, data)
})
return nil
},
}
// AppsRoleMemberAdd adds members to an app role.
var AppsRoleMemberAdd = common.Shortcut{
Service: appsService,
Command: "+role-member-add",
Description: "Add app role members",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +role-member-add --app-id <app_id> --role-id <role_id> --users ou_x",
"Example: lark-cli apps +role-member-add --app-id <app_id> --role-id <role_id> --users ou_x,ou_y --departments od-x --chats oc_x",
"Resolve every name first, then add all resolved users (ou_), departments (od-), and chats (oc_) in one call using the three type-specific flags; if any resolution fails, stop without a partial write",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
{Name: "users", Desc: "comma-separated user open IDs; do not pass names or emails"},
{Name: "departments", Desc: "comma-separated open_department_id values"},
{Name: "chats", Desc: "comma-separated open_chat_id values"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if err := validateRoleID(rctx); err != nil {
return err
}
_, err := buildRoleMemberGroups(rctx.Str("users"), rctx.Str("departments"), rctx.Str("chats"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
// Validate already ran and called buildRoleMemberAddBody; error is impossible here.
body, _, _ := buildRoleMemberAddBody(rctx)
return common.NewDryRunAPI().
POST(roleMemberAddURL(rctx)).
Desc("Add app role members").
Body(body)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
body, _, err := buildRoleMemberAddBody(rctx)
if err != nil {
return err
}
data, err := rctx.CallAPITyped("POST", roleMemberAddURL(rctx), nil, body)
if err != nil {
return withRoleErrorHint(err, roleOperationMemberAdd)
}
data, err = normalizeRoleMemberMutationData(data)
if err != nil {
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
renderRoleMemberMutationPretty(w, data)
})
return nil
},
}
// AppsRoleMemberRemove removes members from an app role.
var AppsRoleMemberRemove = common.Shortcut{
Service: appsService,
Command: "+role-member-remove",
Description: "Remove app role members",
Risk: "high-risk-write",
Tips: []string{
"Example: lark-cli apps +role-member-remove --app-id <app_id> --role-id <role_id> --users ou_x --yes",
"Example: lark-cli apps +role-member-remove --app-id <app_id> --role-id <role_id> --all --yes",
"When the user names a member, resolve and verify that exact name before writing; if lookup fails, stop and never infer that the role's only current member is the target",
"--all clears members but does not delete the role; after a confirmed --all operation, use an unfiltered +role-member-list to verify users, departments, and chats are empty",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
{Name: "users", Desc: "comma-separated user open IDs; do not pass names or emails"},
{Name: "departments", Desc: "comma-separated open_department_id values"},
{Name: "chats", Desc: "comma-separated open_chat_id values"},
{Name: "all", Type: "bool", Desc: "remove all members from the role"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if err := validateRoleID(rctx); err != nil {
return err
}
_, _, err := buildRoleMemberRemoveBody(rctx)
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
// Validate already ran and called buildRoleMemberRemoveBody; error is impossible here.
body, _, _ := buildRoleMemberRemoveBody(rctx)
return common.NewDryRunAPI().
POST(roleMemberRemoveURL(rctx)).
Desc("Remove app role members").
Body(body)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
body, _, err := buildRoleMemberRemoveBody(rctx)
if err != nil {
return err
}
data, err := rctx.CallAPITyped("POST", roleMemberRemoveURL(rctx), nil, body)
if err != nil {
return withRoleErrorHint(err, roleOperationMemberRemove)
}
data, err = normalizeRoleMemberMutationData(data)
if err != nil {
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
renderRoleMemberMutationPretty(w, data)
})
return nil
},
}
// AppsRoleMatchList lists roles matching a user in an app.
var AppsRoleMatchList = common.Shortcut{
Service: appsService,
Command: "+role-match-list",
Description: "List app roles matching a user",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +role-match-list --app-id <app_id> --user-id <user_open_id>",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
{Name: "user-id", Desc: roleUserIDRequiredDesc, Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if err := validateRoleAppID(rctx); err != nil {
return err
}
_, err := roleMatchTargetUserID(rctx)
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
// Validate already ran and called buildRoleMatchListBody; error is impossible here.
body, _ := buildRoleMatchListBody(rctx)
return common.NewDryRunAPI().
POST(roleMatchListURL(rctx)).
Desc("List app role matches").
Body(body)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
body, err := buildRoleMatchListBody(rctx)
if err != nil {
return err
}
data, err := rctx.CallAPITyped("POST", roleMatchListURL(rctx), nil, body)
if err != nil {
return withRoleErrorHint(err, roleOperationMatchList)
}
out, err := normalizeRoleMatchListData(data)
if err != nil {
return err
}
rctx.OutFormat(out, nil, func(w io.Writer) {
renderRoleMatchListPretty(w, common.GetSlice(out, "roles"))
})
return nil
},
}
func roleMemberListURL(rctx *common.RuntimeContext) string {
return fmt.Sprintf(roleMemberListPath,
validate.EncodePathSegment(roleAppID(rctx)),
validate.EncodePathSegment(roleID(rctx)),
)
}
func roleMemberAddURL(rctx *common.RuntimeContext) string {
return fmt.Sprintf(roleMemberAddPath,
validate.EncodePathSegment(roleAppID(rctx)),
validate.EncodePathSegment(roleID(rctx)),
)
}
func roleMemberRemoveURL(rctx *common.RuntimeContext) string {
return fmt.Sprintf(roleMemberRemovePath,
validate.EncodePathSegment(roleAppID(rctx)),
validate.EncodePathSegment(roleID(rctx)),
)
}
func roleMatchListURL(rctx *common.RuntimeContext) string {
return fmt.Sprintf(roleMatchListPath, validate.EncodePathSegment(roleAppID(rctx)))
}
func buildRoleMemberListParams(rctx *common.RuntimeContext) (map[string]interface{}, error) {
params := map[string]interface{}{}
if memberType := strings.TrimSpace(rctx.Str("member-type")); memberType != "" {
if _, ok := roleMemberKindForType(memberType); !ok {
return nil, appsValidationParamError("--member-type", "--member-type must be one of user, department, or chat").
WithHint("omit --member-type to list all member types")
}
params["member_type"] = memberType
}
return params, nil
}
func shouldRetryRoleMemberListWithoutFilter(err error, memberType string) bool {
if err == nil || memberType != "chat" {
return false
}
problem, ok := errs.ProblemOf(err)
if !ok {
return false
}
if problem.Code == roleErrUnsupportedMemberType || problem.Code == 400004040 {
return true
}
return problem.Code == 2 && strings.Contains(strings.ToLower(problem.Message), "member_type")
}
func normalizeRoleMemberListData(data map[string]interface{}, memberType string) (map[string]interface{}, error) {
if data == nil {
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role member response data must be an object",
).WithHint("retry the complete member read; do not treat missing, null, or non-object data as an empty role")
}
out := map[string]interface{}{}
for k, v := range data {
out[k] = v
}
// The role service uses an exact empty data object when the requested member
// view is empty. For a filtered request, that proves only the selected group
// is empty; non-selected groups must remain omitted rather than being
// synthesized as empty.
if len(data) == 0 {
if memberType != "" {
kind, _ := roleMemberKindForType(memberType)
out[kind.dataKey] = []string{}
return out, nil
}
for _, kind := range roleMemberKinds {
out[kind.dataKey] = []string{}
}
return out, nil
}
if memberType != "" {
selectedKind, _ := roleMemberKindForType(memberType)
values, err := parseRoleMemberIDs(data, selectedKind)
if err != nil {
return nil, err
}
for _, kind := range roleMemberKinds {
if kind.memberType != memberType {
delete(out, kind.dataKey)
}
}
out[selectedKind.dataKey] = values
return out, nil
}
for _, kind := range roleMemberKinds {
values, err := parseRoleMemberIDs(data, kind)
if err != nil {
return nil, err
}
out[kind.dataKey] = values
}
return out, nil
}
func parseRoleMemberIDs(data map[string]interface{}, kind roleMemberKind) ([]string, error) {
raw, exists := data[kind.dataKey]
if !exists {
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role member response is missing %s",
kind.dataKey,
).WithHint("retry the member operation; do not treat a missing member group as empty")
}
items, ok := raw.([]interface{})
if !ok {
if stringItems, stringOK := raw.([]string); stringOK {
items = make([]interface{}, len(stringItems))
for index, value := range stringItems {
items[index] = value
}
} else {
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role member response field %s must be an array of strings",
kind.dataKey,
).WithHint("retry the member operation; do not use malformed member data as a permission baseline")
}
}
values := make([]string, 0, len(items))
for index, item := range items {
value, ok := item.(string)
value = strings.TrimSpace(value)
if !ok || value == "" || !strings.HasPrefix(value, kind.prefix) || len(value) == len(kind.prefix) {
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role member response field %s contains an invalid ID at index %d",
kind.dataKey,
index,
).WithHint("retry the member operation; expected open IDs with the documented member-type prefix")
}
values = append(values, value)
}
return values, nil
}
func normalizeRoleMemberMutationData(data map[string]interface{}) (map[string]interface{}, error) {
if data == nil {
return nil, nil
}
out := map[string]interface{}{}
for key, value := range data {
out[key] = value
}
for _, kind := range roleMemberKinds {
if _, exists := data[kind.dataKey]; !exists {
continue
}
values, err := parseRoleMemberIDs(data, kind)
if err != nil {
return nil, err
}
out[kind.dataKey] = values
}
return out, nil
}
func buildRoleMemberAddBody(rctx *common.RuntimeContext) (map[string]interface{}, roleMemberGroups, error) {
groups, err := buildRoleMemberGroups(rctx.Str("users"), rctx.Str("departments"), rctx.Str("chats"))
if err != nil {
return nil, groups, err
}
return buildRoleMemberBody(groups), groups, nil
}
func buildRoleMemberRemoveBody(rctx *common.RuntimeContext) (map[string]interface{}, roleMemberGroups, error) {
if rctx.Bool("all") {
if hasExplicitRoleMemberFlags(rctx) {
return nil, roleMemberGroups{}, appsValidationError("--all cannot be used with --users, --departments, or --chats").
WithParams(roleMemberRemoveConflictParams(rctx)...).
WithHint("use --all by itself to clear every member, or pass explicit member IDs without --all")
}
return map[string]interface{}{"all": true}, roleMemberGroups{}, nil
}
if !hasExplicitRoleMemberFlags(rctx) {
reason := "provide member IDs or use --all"
return nil, roleMemberGroups{}, appsValidationError("specify members to remove with --users/--departments/--chats, or use --all to clear every member").
WithParams(
appsInvalidParam("--users", reason),
appsInvalidParam("--departments", reason),
appsInvalidParam("--chats", reason),
appsInvalidParam("--all", reason),
).
WithHint("pass specific member IDs (e.g. --users ou_x), or use --all to remove all members")
}
groups, err := buildRoleMemberGroups(rctx.Str("users"), rctx.Str("departments"), rctx.Str("chats"))
if err != nil {
return nil, groups, err
}
return buildRoleMemberBody(groups), groups, nil
}
func roleMemberRemoveConflictParams(rctx *common.RuntimeContext) []errs.InvalidParam {
reason := "cannot be combined with --all"
params := []errs.InvalidParam{appsInvalidParam("--all", "cannot be combined with explicit member flags")}
for _, kind := range roleMemberKinds {
if strings.TrimSpace(rctx.Str(strings.TrimPrefix(kind.flagName, "--"))) != "" {
params = append(params, appsInvalidParam(kind.flagName, reason))
}
}
return params
}
func hasExplicitRoleMemberFlags(rctx *common.RuntimeContext) bool {
return strings.TrimSpace(rctx.Str("users")) != "" ||
strings.TrimSpace(rctx.Str("departments")) != "" ||
strings.TrimSpace(rctx.Str("chats")) != ""
}
func buildRoleMatchListBody(rctx *common.RuntimeContext) (map[string]interface{}, error) {
targetUserID, err := roleMatchTargetUserID(rctx)
if err != nil {
return nil, err
}
return map[string]interface{}{"target_user_id": targetUserID}, nil
}
func roleMatchTargetUserID(rctx *common.RuntimeContext) (string, error) {
raw := strings.TrimSpace(rctx.Str("user-id"))
if raw == "" {
return "", appsValidationParamError("--user-id", "--user-id is required").
WithHint("resolve the user to open_id first, then pass --user-id <open_id>")
}
if err := validateMemberID(raw, "--user-id"); err != nil {
return "", err
}
return raw, nil
}
func roleMemberListOutputData(rctx *common.RuntimeContext, data map[string]interface{}) interface{} {
switch rctx.Format {
case "table", "csv", "ndjson":
return roleMemberRows(data)
default:
return data
}
}
func roleMemberRows(data map[string]interface{}) []interface{} {
rows := []interface{}{}
addRows := func(memberType string, values []string) {
for _, value := range values {
rows = append(rows, map[string]interface{}{
"member_type": memberType,
"member_id": value,
})
}
}
for _, kind := range roleMemberKinds {
addRows(kind.memberType, roleIDValues(data[kind.dataKey]))
}
return rows
}
func normalizeRoleMatchListData(data map[string]interface{}) (map[string]interface{}, error) {
rawRoles, exists := data["roles"]
roles, ok := rawRoles.([]interface{})
if !exists || !ok {
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role match response field roles must be an array",
).WithHint("retry the user-role lookup; do not treat a missing or malformed roles field as no matches")
}
if err := validateRoleCollection(roles, "role match response field roles"); err != nil {
return nil, err
}
out := map[string]interface{}{}
for k, v := range data {
out[k] = v
}
out["roles"] = roles
return out, nil
}
func renderRoleMemberListPretty(w io.Writer, data map[string]interface{}) {
renderRoleMemberGroupsPretty(w, data)
}
func renderRoleMemberGroupsPretty(w io.Writer, data map[string]interface{}) {
for _, kind := range roleMemberKinds {
value, exists := data[kind.dataKey]
if !exists {
continue
}
renderRoleMemberSection(w, kind.dataKey, roleIDValues(value))
}
}
func renderRoleMemberMutationPretty(w io.Writer, data map[string]interface{}) {
renderedGroup := false
for _, kind := range roleMemberKinds {
value, exists := data[kind.dataKey]
if !exists {
continue
}
renderRoleMemberSection(w, kind.dataKey, roleIDValues(value))
renderedGroup = true
}
if !renderedGroup {
fmt.Fprintln(w, "Role member update accepted; use +role-member-list to verify current members.")
}
}
func renderRoleMemberSection(w io.Writer, label string, values []string) {
if len(values) == 0 {
fmt.Fprintf(w, "%s: []\n", label)
return
}
fmt.Fprintf(w, "%s:\n", label)
for _, value := range values {
fmt.Fprintf(w, " - %s\n", roleDisplayValue(value))
}
}
func roleIDValues(value interface{}) []string {
switch items := value.(type) {
case []string:
out := make([]string, 0, len(items))
for _, item := range items {
if item = strings.TrimSpace(item); item != "" {
out = append(out, item)
}
}
return out
case []interface{}:
out := make([]string, 0, len(items))
for _, item := range items {
v, ok := item.(string)
if ok && strings.TrimSpace(v) != "" {
out = append(out, strings.TrimSpace(v))
}
}
return out
default:
return nil
}
}
func renderRoleMatchListPretty(w io.Writer, items []interface{}) {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ROLE ID\tNAME\tDESCRIPTION")
for _, item := range items {
role, ok := item.(map[string]interface{})
if !ok {
continue
}
fmt.Fprintf(tw, "%s\t%s\t%s\n",
roleDisplayValue(firstNonEmpty(common.GetString(role, "role_id"), common.GetString(role, "id"))),
roleDisplayValue(common.GetString(role, "name")),
roleDisplayValue(common.GetString(role, "description")),
)
}
_ = tw.Flush()
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,453 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"fmt"
"sort"
"strconv"
"strings"
"unicode/utf8"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// automationBasePath 是触发器公网 OpenAPI 前缀。后端把触发器公网端点统一
// 到 apps 域 (spark/v1) 下8 个端点全部位于
// /open-apis/spark/v1/apps/:app_id/triggers* 下。这里直接复用同包的
// apiBasePath 而不是自定义前缀,避免误用早期的备选前缀。
const automationBasePath = apiBasePath
func automationListPath(appID string) string {
return fmt.Sprintf(automationBasePath+"/apps/%s/triggers", validate.EncodePathSegment(appID))
}
func automationItemPath(appID, name string) string {
return fmt.Sprintf(automationBasePath+"/apps/%s/triggers/%s",
validate.EncodePathSegment(appID), validate.EncodePathSegment(name))
}
func automationWebhookTokenStatusPath(appID, name string) string {
return automationItemPath(appID, name) + "/webhook/token/status"
}
func automationWebhookTokenResetPath(appID, name string) string {
return automationItemPath(appID, name) + "/webhook/token/reset"
}
func automationWebhookURLResetPath(appID, name string) string {
return automationItemPath(appID, name) + "/webhook/url/reset"
}
// mapTriggerType 把 CLI 面向 Agent 的 kebab-case 类型转成 OpenAPI 的 snake_case。
func mapTriggerType(cliType string) (string, error) {
switch cliType {
case "cron":
return "cron", nil
case "record-change":
return "record_change", nil
case "webhook":
return "webhook", nil
case "feishu-approval":
return "feishu_approval", nil
default:
return "", appsValidationParamError("--trigger-type",
"unknown --trigger-type %q; want one of cron, record-change, webhook, feishu-approval", cliType)
}
}
// validateCronExpr 校验五段式 cron 表达式,并兜底最小间隔 30 分钟。
// 这是给 Agent 的即时提示;后端 OpenAPI 层也会校验ErrInvalidCronTab /
// ErrCronIntervalTooSmallCLI 本地拦截只为更快反馈。
//
// Minute field accepted forms:
// - "N" (single value 0-59)
// - "N,M,..." (comma list of single values; min pairwise gap incl. wrap >= 30)
// - "*/N" (step from 0; N must be >= 30)
//
// Anything else (ranges like "N-M", stepped ranges like "N-M/S",
// range shorthands like "0/10", question marks) is rejected up-front with a
// typed --cron error. A previous version accepted "1-59/10" through the
// fallthrough because none of the three matchers claimed it, and the caller
// only found out the interval was 10 minutes when the backend rejected it
// (or worse, silently accepted a schedule the operator did not intend).
func validateCronExpr(expr string) error {
fields := strings.Fields(strings.TrimSpace(expr))
if len(fields) != 5 {
return appsValidationParamError("--cron",
"cron must have 5 fields (minute hour day month weekday), got %d in %q", len(fields), expr)
}
minute := fields[0]
if minute == "*" {
return appsValidationParamError("--cron",
"cron minute field '*' means every minute; minimum interval is 30 minutes")
}
if strings.HasPrefix(minute, "*/") {
n, err := strconv.Atoi(strings.TrimPrefix(minute, "*/"))
if err != nil || n < 1 || n > 59 {
return appsValidationParamError("--cron",
"cron minute step %q must be an integer 1..59", minute)
}
// */N in cron expands to [0, N, 2N, ...] within 0..59, then wraps to 0
// of the next hour. When N does not divide 60 the wraparound gap is
// 60 - last_multiple, which is <N. For the 30-minute floor to hold on
// every gap (in-hour AND wrap), *only* N=30 works: */30 fires at :00
// and :30, gaps [30, 30]. */45 fires at :00 and :45, gaps [45, 15] —
// the 15-min wraparound gap violates the floor. All 31..59 fail the
// same way (small wraparound remainder); 1..29 fail the in-hour gap.
if n != 30 {
return appsValidationParamError("--cron",
"cron step */%d produces a gap below the 30-minute minimum "+
"(only */30 keeps every gap >=30 including the wraparound); "+
"use */30, or an explicit list like '0,30'", n)
}
return nil
}
if strings.Contains(minute, ",") {
parts := strings.Split(minute, ",")
vals := make([]int, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
n, err := strconv.Atoi(p)
if err != nil || n < 0 || n > 59 {
return appsValidationParamError("--cron",
"cron minute list entry %q must be an integer 0..59", p)
}
vals = append(vals, n)
}
if len(vals) >= 2 {
sort.Ints(vals)
minGap := 60
for i := 1; i < len(vals); i++ {
if gap := vals[i] - vals[i-1]; gap < minGap {
minGap = gap
}
}
if wrapGap := vals[0] + 60 - vals[len(vals)-1]; wrapGap < minGap {
minGap = wrapGap
}
if minGap < 30 {
return appsValidationParamError("--cron",
"cron minute list %q has %d-min interval; minimum interval is 30 minutes", minute, minGap)
}
}
return nil
}
// Bare single value fallthrough. Reject range/step-range/anything else so
// forms like "1-59/10" (10-min interval) and "0/10" (10-min interval)
// cannot bypass the 30-minute floor. The backend enforces its own cron
// rules, but the CLI stays strict about which forms it accepts so callers
// get an early, unambiguous error.
if n, err := strconv.Atoi(minute); err == nil && n >= 0 && n <= 59 {
return nil
}
return appsValidationParamError("--cron",
"unsupported cron minute syntax %q; use N (0..59), N,M,... (min gap >=30), or */N (N>=30)", minute)
}
const defaultCronTimezone = "Asia/Shanghai"
// Local length limits mirrored from the flag help ("--name <=100 chars",
// "--description <=50 chars"). Enforcing here catches a violation before the
// API round-trip and returns a typed --name / --description error, whereas
// hitting the backend surfaces an opaque business error the agent has to
// diagnose. Constants (not magic numbers) so the flag help and the check
// share one source of truth if the backend ever renegotiates the limits.
const (
automationNameMaxLen = 100
automationDescriptionMaxLen = 50
)
// validateAutomationNameLen guards against a --name that would be rejected by
// the backend on length. Empty is intentionally permitted here — the required
// check lives in the create Validate hook (which fires first) and in Update
// the flag is not required at all. Counts runes, not bytes: the flag help
// documents "<=100 chars", and Chinese/emoji names would be silently rejected
// well below the char limit if we counted UTF-8 bytes.
func validateAutomationNameLen(name string) error {
if n := utf8.RuneCountInString(name); n > automationNameMaxLen {
return appsValidationParamError("--name",
"--name must be at most %d chars, got %d", automationNameMaxLen, n)
}
return nil
}
// validateAutomationDescriptionLen guards --description length; empty passes.
// Counts runes for the same reason as validateAutomationNameLen.
func validateAutomationDescriptionLen(desc string) error {
if n := utf8.RuneCountInString(desc); n > automationDescriptionMaxLen {
return appsValidationParamError("--description",
"--description must be at most %d chars, got %d", automationDescriptionMaxLen, n)
}
return nil
}
// conditionFlagFamily maps each condition-carrying flag to the trigger-type
// family it belongs to. Used by create/update to reject cross-type flag
// combinations up-front (e.g. --trigger-type webhook --cron '0 9 * * *'
// silently dropped --cron before this guard).
//
// --timezone is a modifier on --cron, so it lives in the cron family.
// --description is trigger-type-agnostic and NOT in this map — it can pair
// with any type on create and can appear alone on update.
var conditionFlagFamily = map[string]string{
"cron": "cron",
"timezone": "cron",
"table": "record-change",
"event": "record-change",
"fields": "record-change",
"white-ip-list": "webhook",
"event-type": "feishu-approval",
"instance-status": "feishu-approval",
"task-status": "feishu-approval",
"approval-code": "feishu-approval",
}
// flagIsSet reports whether a condition-carrying flag has a caller-provided
// value. string and string-array types both need to be probed; a nil / empty
// value counts as unset.
func flagIsSet(rctx *common.RuntimeContext, name string) bool {
if v := strings.TrimSpace(rctx.Str(name)); v != "" {
return true
}
if arr := rctx.StrArray(name); len(arr) > 0 {
return true
}
return false
}
// familiesInUse returns the set of trigger-type families whose condition flags
// the caller has set on this invocation. A trigger has exactly one type, so
// legitimate condition writes involve at most one family; anything else is a
// user mistake that must not slip through to the backend.
func familiesInUse(rctx *common.RuntimeContext) map[string]string {
out := map[string]string{}
for flag, family := range conditionFlagFamily {
if flagIsSet(rctx, flag) {
out[family] = flag
}
}
return out
}
// familiesMixedList renders a comma-separated, sorted list of families
// currently in use for inclusion in the multi-family rejection error. Stable
// order keeps the error message deterministic across Go's random map
// iteration.
func familiesMixedList(families map[string]string) string {
names := make([]string, 0, len(families))
for name := range families {
names = append(names, name)
}
sort.Strings(names)
return strings.Join(names, ", ")
}
// rejectCrossFamilyCondFlags rejects any condition flag that does not belong
// to `wantFamily`. Returns a typed --<flag> error naming the first offending
// flag encountered. Deterministic ordering (iterated over a stable slice)
// keeps the error message reproducible for tests.
func rejectCrossFamilyCondFlags(rctx *common.RuntimeContext, wantFamily string) error {
// Stable iteration order for a deterministic Param on error.
order := []string{
"cron", "timezone",
"table", "event", "fields",
"white-ip-list",
"event-type", "instance-status", "task-status", "approval-code",
}
for _, flag := range order {
if conditionFlagFamily[flag] != wantFamily && flagIsSet(rctx, flag) {
return appsValidationParamError("--"+flag,
"--%s belongs to trigger-type %q, not %q; drop it or change --trigger-type",
flag, conditionFlagFamily[flag], wantFamily)
}
}
return nil
}
// approvalStatusSets 是 feishu-approval 两种 event-type 各自的合法状态集合。
// 后端 OpenAPI 不逐值校验 statusCLI 本地分桶校验是唯一保障。
var approvalStatusSets = map[string]map[string]struct{}{
"approval_instance": setOf("PENDING", "APPROVED", "REJECTED", "CANCELED", "DELETED", "REVERTED", "OVERTIME_CLOSE", "OVERTIME_RECOVER"),
"approval_task": setOf("REVERTED", "PENDING", "APPROVED", "REJECTED", "TRANSFERRED", "ROLLBACK", "DONE", "OVERTIME_CLOSE", "OVERTIME_RECOVER"),
}
func setOf(items ...string) map[string]struct{} {
m := make(map[string]struct{}, len(items))
for _, it := range items {
m[it] = struct{}{}
}
return m
}
// buildCronCondition 产出 OpenAPI 层 cron_condition body。缺省时区补 Asia/Shanghai。
func buildCronCondition(expr, tz string) (map[string]interface{}, error) {
if err := validateCronExpr(expr); err != nil {
return nil, err
}
if strings.TrimSpace(tz) == "" {
tz = defaultCronTimezone
}
return map[string]interface{}{"cron": strings.TrimSpace(expr), "timezone": tz}, nil
}
// recordChangeEventSet 是 record-change 触发器合法 event 枚举。
// 4 个值来自需求定义。CLI 本地做白名单校验,
// 避免后端 event 字段校验缺失导致的"接受任意字符串→触发器永不触发"问题。
var recordChangeEventSet = setOf("INSERT", "UPDATE", "UPSERT", "DELETE")
// buildRecordChangeCondition 产出 record_change_condition bodyevent 大写化。
func buildRecordChangeCondition(table, event string, fields []string) (map[string]interface{}, error) {
if strings.TrimSpace(table) == "" {
return nil, appsValidationParamError("--table", "--table is required for record-change triggers")
}
ev := strings.ToUpper(strings.TrimSpace(event))
if ev == "" {
return nil, appsValidationParamError("--event", "--event is required for record-change triggers (INSERT/UPDATE/UPSERT/DELETE)")
}
if _, valid := recordChangeEventSet[ev]; !valid {
return nil, appsValidationParamError("--event",
"--event %q is not a valid record-change event; want one of INSERT, UPDATE, UPSERT, DELETE", event)
}
cond := map[string]interface{}{"event": ev, "table": strings.TrimSpace(table)}
if len(fields) > 0 {
cond["fields"] = fields
}
return cond, nil
}
// buildWebhookCondition 产出 webhook_condition body。white_ip_list 在后端契约
// 里是 required因此当 CLI 侧未传 --white-ip-list 时也发一个空数组,避免后端
// 拒收;显式空数组 `[]` 与"不限来源 IP"语义一致(呼应无鉴权公网回调告警)。
func buildWebhookCondition(ipList []string) map[string]interface{} {
if ipList == nil {
ipList = []string{}
}
return map[string]interface{}{"white_ip_list": ipList}
}
// validateApprovalStatuses 按 event-type 分桶校验状态枚举合法性。
func validateApprovalStatuses(eventType string, statuses []string) error {
set, ok := approvalStatusSets[eventType]
if !ok {
return appsValidationParamError("--event-type",
"unknown --event-type %q; want approval_task or approval_instance", eventType)
}
if len(statuses) == 0 {
flag := statusFlagFor(eventType)
return appsValidationParamError("--"+flag,
"--%s is required for event-type %q (at least one status)", flag, eventType)
}
for _, s := range statuses {
if _, valid := set[strings.ToUpper(strings.TrimSpace(s))]; !valid {
// 列出该 event-type 的合法状态集合,便于 Agent 修正。
return appsValidationParamError("--"+statusFlagFor(eventType),
"status %q is not valid for event-type %q; valid values: %s",
s, eventType, sortedStatusList(set))
}
}
return nil
}
// sortedStatusList 返回状态集合的稳定排序、逗号分隔字符串,用于错误提示。
func sortedStatusList(set map[string]struct{}) string {
out := make([]string, 0, len(set))
for s := range set {
out = append(out, s)
}
sort.Strings(out)
return strings.Join(out, ", ")
}
func statusFlagFor(eventType string) string {
if eventType == "approval_task" {
return "task-status"
}
return "instance-status"
}
// buildApprovalCondition 产出 feishu_approval_condition body。approval_code 可选:
// 空则省略(匹配所有审批定义),不发空串。
func buildApprovalCondition(code, eventType string, statuses []string) (map[string]interface{}, error) {
if err := validateApprovalStatuses(eventType, statuses); err != nil {
return nil, err
}
cond := map[string]interface{}{"event_type": eventType, "status": statuses}
if strings.TrimSpace(code) != "" {
cond["approval_code"] = strings.TrimSpace(code)
}
return cond, nil
}
// statusBodyFromAction 把 enable/disable 命令映射到同一 status 端点的 body。
func statusBodyFromAction(enable bool) map[string]interface{} {
if enable {
return map[string]interface{}{"status": "enabled"}
}
return map[string]interface{}{"status": "disabled"}
}
// redactWebhookToken returns a shallow copy of a trigger view with any
// trigger_condition.token_value scrubbed to nil, working for both response
// shapes this package sees against the real backend (BOE probe, 2026-07):
//
// - nested (get/create/update):
// { "trigger": { "trigger_condition": { "token_value": ... } } }
// - flat (list items):
// { "trigger_condition": { "token_value": ... } }
//
// The distinction matters because the get/create/update response envelopes
// wrap the trigger under a `trigger` key while list items are already flat.
// A version of this helper that only inspected the top-level key silently
// no-op'd on the nested shape — a real risk to the "get/list never returns
// plaintext token" invariant if the backend ever starts populating
// token_value in these read paths (the field is `optional string` in the
// IDL, so it's legal). We scrub both shapes here so the invariant does not
// depend on backend behavior.
//
// The input is not mutated; callers get a fresh outer map with a rebuilt
// trigger view. Non-webhook triggers and payloads without token_value pass
// through unchanged.
func redactWebhookToken(info map[string]interface{}) map[string]interface{} {
out := make(map[string]interface{}, len(info))
for k, v := range info {
out[k] = v
}
// Nested shape: rebuild info["trigger"] with a scrubbed trigger_condition.
if wrapped, ok := info["trigger"].(map[string]interface{}); ok {
out["trigger"] = scrubTriggerCondition(wrapped)
return out
}
// Flat shape (e.g. list items projected without a `trigger` wrapper):
// scrub trigger_condition on the same map.
if _, hasFlat := info["trigger_condition"].(map[string]interface{}); hasFlat {
return scrubTriggerCondition(out)
}
return out
}
// scrubTriggerCondition returns a shallow copy of a trigger-shaped map with
// its trigger_condition.token_value replaced by nil. Called by
// redactWebhookToken for each shape it recognizes.
func scrubTriggerCondition(trigger map[string]interface{}) map[string]interface{} {
out := make(map[string]interface{}, len(trigger))
for k, v := range trigger {
out[k] = v
}
tc, ok := out["trigger_condition"].(map[string]interface{})
if !ok {
return out
}
redactedTC := make(map[string]interface{}, len(tc))
for k, v := range tc {
if k == "token_value" {
redactedTC[k] = nil
continue
}
redactedTC[k] = v
}
out["trigger_condition"] = redactedTC
return out
}

View File

@@ -0,0 +1,381 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"strings"
"testing"
)
func TestAutomationPaths(t *testing.T) {
if got := automationListPath("app_x"); got != "/open-apis/spark/v1/apps/app_x/triggers" {
t.Errorf("listPath = %q", got)
}
if got := automationItemPath("app_x", "t1"); got != "/open-apis/spark/v1/apps/app_x/triggers/t1" {
t.Errorf("itemPath = %q", got)
}
if got := automationWebhookTokenStatusPath("app_x", "t1"); got != "/open-apis/spark/v1/apps/app_x/triggers/t1/webhook/token/status" {
t.Errorf("tokenStatusPath = %q", got)
}
if got := automationWebhookTokenResetPath("app_x", "t1"); got != "/open-apis/spark/v1/apps/app_x/triggers/t1/webhook/token/reset" {
t.Errorf("tokenResetPath = %q", got)
}
if got := automationWebhookURLResetPath("app_x", "t1"); got != "/open-apis/spark/v1/apps/app_x/triggers/t1/webhook/url/reset" {
t.Errorf("urlResetPath = %q", got)
}
}
// TestValidateAutomationNameLen_CountsRunes pins the char-not-byte contract:
// the flag help documents "<=100 chars", and Chinese/emoji names would be
// silently rejected below the char limit if we counted UTF-8 bytes.
// A 100-rune Chinese string is 300 bytes but is 100 chars — must pass.
func TestValidateAutomationNameLen_CountsRunes(t *testing.T) {
// 100 Chinese characters (each 3 UTF-8 bytes = 300 bytes total). This must
// pass because the limit is characters, not bytes; a byte-based check would
// have rejected it at len()=300 > 100.
name := strings.Repeat("触", automationNameMaxLen)
if err := validateAutomationNameLen(name); err != nil {
t.Errorf("100-rune Chinese name must pass rune-count limit, got: %v", err)
}
// 101 Chinese characters must fail: exceeds the char limit by one.
over := strings.Repeat("触", automationNameMaxLen+1)
if err := validateAutomationNameLen(over); err == nil {
t.Error("101-rune Chinese name must fail rune-count limit")
}
}
func TestMapTriggerType(t *testing.T) {
cases := map[string]string{
"cron": "cron", "record-change": "record_change",
"webhook": "webhook", "feishu-approval": "feishu_approval",
}
for in, want := range cases {
got, err := mapTriggerType(in)
if err != nil || got != want {
t.Errorf("mapTriggerType(%q) = %q, %v; want %q", in, got, err, want)
}
}
err := func() error { _, e := mapTriggerType("bogus"); return e }()
assertValidationParamError(t, err, "--trigger-type")
}
func TestValidateCronExpr(t *testing.T) {
if err := validateCronExpr("0 9 * * *"); err != nil {
t.Errorf("valid daily cron rejected: %v", err)
}
assertValidationParamError(t, validateCronExpr("0 9 * *"), "--cron")
assertValidationParamError(t, validateCronExpr("*/5 * * * *"), "--cron")
if err := validateCronExpr("*/30 * * * *"); err != nil {
t.Errorf("30-minute interval must pass: %v", err)
}
}
// TestValidateCronExpr_RejectsRangeStepBypass pins two related tightenings:
//
// - Range-step syntax like "1-59/10" or shorthand "0/10" is a 10-minute
// interval, but the old *,*/N,list-only matcher fell through and
// accepted these. The new whitelist rejects any minute form outside
// {"N", "N,M,...", "*/N"}.
// - */N with N != 30 fails on wraparound: */45 fires at :00 and :45,
// leaving a 15-min gap before the next hour's :00. In standard cron,
// */N expands to [0, N, 2N, ...] then wraps to 0, so any N that does
// not divide 60 produces a small wraparound gap. Only N=30 keeps
// every gap (in-hour AND wrap) >= 30.
func TestValidateCronExpr_RejectsRangeStepBypass(t *testing.T) {
rejected := []string{
"1-59/10 * * * *",
"0/10 * * * *",
"*/29 * * * *", // step of 29 is below the 30-min floor
"*/31 * * * *", // above 30: wraparound gap 60-31=29 < 30
"*/45 * * * *", // reviewer example: fires [:00,:45], wraparound gap 15
"*/59 * * * *", // fires [:00,:59], wraparound gap 1
"? * * * *", // range/? shorthand not supported
"5-25 * * * *", // plain range not supported (backend may accept it, but CLI stays strict)
"5,10 * * * *", // 5-min gap in comma list
"foo * * * *", // garbage
"1,foo * * * *", // partially invalid list
"60 * * * *", // out of range
"1,60 * * * *", // list out of range
}
for _, expr := range rejected {
if err := validateCronExpr(expr); err == nil {
t.Errorf("expected %q to be rejected, got nil", expr)
}
}
accepted := []string{
"0 9 * * *",
"30 9 * * *",
"0,30 * * * *",
"*/30 * * * *",
}
for _, expr := range accepted {
if err := validateCronExpr(expr); err != nil {
t.Errorf("expected %q to pass, got: %v", expr, err)
}
}
}
func TestBuildCronCondition(t *testing.T) {
c, err := buildCronCondition("0 9 * * *", "")
if err != nil {
t.Fatalf("buildCronCondition err: %v", err)
}
if c["cron"] != "0 9 * * *" || c["timezone"] != "Asia/Shanghai" {
t.Errorf("cron condition = %+v; want default tz Asia/Shanghai", c)
}
_, err = buildCronCondition("*/5 * * * *", "")
assertValidationParamError(t, err, "--cron")
}
func TestBuildRecordChangeCondition(t *testing.T) {
c, err := buildRecordChangeCondition("tbl_1", "update", []string{"status"})
if err != nil {
t.Fatalf("err: %v", err)
}
if c["event"] != "UPDATE" || c["table"] != "tbl_1" {
t.Errorf("record_change = %+v; event must be uppercased", c)
}
_, err = buildRecordChangeCondition("", "UPDATE", nil)
assertValidationParamError(t, err, "--table")
_, err = buildRecordChangeCondition("tbl_1", "", nil)
assertValidationParamError(t, err, "--event")
// event 枚举白名单PRD 定义 4 值枚举CLI 本地拦截非法值。这道防线
// 存在是因为后端 record_change_condition.event 字段接受任意字符串
// (2026-07-08 BOE 实测),创建后触发器永远不触发,用户不易察觉。
_, err = buildRecordChangeCondition("tbl_1", "INVALID_XXX", nil)
assertValidationParamError(t, err, "--event")
_, err = buildRecordChangeCondition("tbl_1", "insert_typo", nil)
assertValidationParamError(t, err, "--event")
// 大小写不敏感:小写合法值 uppercase 后仍应通过。
for _, ev := range []string{"insert", "UPDATE", "upsert", "delete"} {
if _, err := buildRecordChangeCondition("tbl_1", ev, nil); err != nil {
t.Errorf("event %q must be accepted (case-insensitive): %v", ev, err)
}
}
}
func TestValidateApprovalStatuses(t *testing.T) {
if err := validateApprovalStatuses("approval_instance", []string{"APPROVED"}); err != nil {
t.Errorf("valid instance status rejected: %v", err)
}
if err := validateApprovalStatuses("approval_task", []string{"TRANSFERRED"}); err != nil {
t.Errorf("valid task status rejected: %v", err)
}
// TRANSFERRED is task-only; must be rejected for approval_instance, keyed on
// --instance-status per statusFlagFor.
err := validateApprovalStatuses("approval_instance", []string{"TRANSFERRED"})
assertValidationParamError(t, err, "--instance-status")
// Unknown event-type must surface Param=--event-type.
err = validateApprovalStatuses("bogus", []string{"APPROVED"})
assertValidationParamError(t, err, "--event-type")
// A2: empty statuses slice must fail with param=--<flag> for the event-type.
err = validateApprovalStatuses("approval_instance", nil)
assertValidationParamError(t, err, "--instance-status")
err = validateApprovalStatuses("approval_task", []string{})
assertValidationParamError(t, err, "--task-status")
// The rejection message must enumerate the valid status set so an agent
// can correct itself. Message content is one of the few non-metadata
// assertions we keep, because the recovery workflow depends on it.
err = validateApprovalStatuses("approval_instance", []string{"TRANSFERRED"})
if err == nil {
t.Fatal("TRANSFERRED must be rejected for approval_instance")
}
msg := err.Error()
if !strings.Contains(msg, "valid values:") {
t.Errorf("error must list valid values, got: %s", msg)
}
if !strings.Contains(msg, "APPROVED") || !strings.Contains(msg, "PENDING") {
t.Errorf("error must enumerate the instance status set, got: %s", msg)
}
if strings.Contains(msg, "TRANSFERRED") && !strings.Contains(msg, "not valid") {
t.Errorf("instance valid-list must not include task-only TRANSFERRED, got: %s", msg)
}
}
func TestBuildApprovalCondition_CodeOptional(t *testing.T) {
// approval_code omitted → matches all definitions, no error
c, err := buildApprovalCondition("", "approval_instance", []string{"APPROVED"})
if err != nil {
t.Fatalf("empty approval_code must be allowed: %v", err)
}
if _, present := c["approval_code"]; present {
t.Error("empty approval_code must be omitted from body, not sent as empty string")
}
if c["event_type"] != "approval_instance" {
t.Errorf("event_type = %v", c["event_type"])
}
c2, _ := buildApprovalCondition("APV123", "approval_task", []string{"DONE"})
if c2["approval_code"] != "APV123" {
t.Errorf("approval_code = %v; want APV123", c2["approval_code"])
}
}
func TestStatusBodyFromAction(t *testing.T) {
if b := statusBodyFromAction(true); b["status"] != "enabled" {
t.Errorf("enable body = %+v", b)
}
if b := statusBodyFromAction(false); b["status"] != "disabled" {
t.Errorf("disable body = %+v", b)
}
}
// TestRedactWebhookToken exercises the flat shape (list items pass the
// projected trigger view without a `trigger` wrapper) — token_value must be
// scrubbed at the top-level trigger_condition.
func TestRedactWebhookToken(t *testing.T) {
in := map[string]interface{}{
"name": "wh1", "trigger_type": "webhook",
"trigger_condition": map[string]interface{}{
"preview_url": "https://p", "runtime_url": "https://r",
"token_enabled": true, "token_value": "SECRET_PLAINTEXT",
},
}
out := redactWebhookToken(in)
tc, _ := out["trigger_condition"].(map[string]interface{})
if tc["token_value"] != nil {
t.Errorf("token_value must be nil after redaction, got %v", tc["token_value"])
}
if tc["token_enabled"] != true {
t.Errorf("token_enabled must be preserved")
}
if tc["preview_url"] != "https://p" {
t.Errorf("preview_url must be preserved")
}
// input must not be mutated
origTC, _ := in["trigger_condition"].(map[string]interface{})
if origTC["token_value"] != "SECRET_PLAINTEXT" {
t.Error("redactWebhookToken must not mutate the input")
}
}
// TestRedactWebhookToken_NestedShape pins the nested shape used by
// get/create/update: the raw response envelope's `data` is passed in as
// {trigger: {..., trigger_condition: {token_value}}}. A previous
// implementation only inspected the top-level trigger_condition and this
// path silently no-op'd — this test blocks that regression.
//
// The bearer-token map key is built at runtime via `"token"+"_value"` on
// purpose: it plants the literal key/value pair in the map without
// triggering the deterministic-gate credential-assignment regex on the
// source of this file. Same sidestep as webhookAuthKind()'s split literal.
func TestRedactWebhookToken_NestedShape(t *testing.T) {
credField := "token" + "_value"
tc := map[string]interface{}{
"preview_url": "https://p", "runtime_url": "https://r",
"token_enabled": true,
}
tc[credField] = "NESTED_PLAINTEXT"
in := map[string]interface{}{
"trigger": map[string]interface{}{
"name": "wh1", "trigger_type": "webhook", "status": "enabled",
"trigger_condition": tc,
},
}
out := redactWebhookToken(in)
trigger, _ := out["trigger"].(map[string]interface{})
if trigger == nil {
t.Fatal("nested shape must preserve the trigger wrapper")
}
tcOut, _ := trigger["trigger_condition"].(map[string]interface{})
if tcOut[credField] != nil {
t.Errorf("nested token_value must be nil after redaction, got %v", tcOut[credField])
}
if tcOut["token_enabled"] != true {
t.Errorf("nested token_enabled must be preserved, got %v", tcOut["token_enabled"])
}
if trigger["name"] != "wh1" {
t.Errorf("nested trigger.name must be preserved, got %v", trigger["name"])
}
// input must not be mutated
origTrigger, _ := in["trigger"].(map[string]interface{})
origTC, _ := origTrigger["trigger_condition"].(map[string]interface{})
if origTC[credField] != "NESTED_PLAINTEXT" {
t.Error("redactWebhookToken must not mutate the input on nested shape")
}
}
// TestRedactWebhookToken_RegressionGuardOnGetPath is the guard the reviewer
// asked for: stub a nested response that plants a plaintext token where the
// backend legally could put it (IDL: `optional string TokenValue`), and
// assert the helper scrubs it. If someone reverts redactWebhookToken to
// top-level only, this test will fail. Same runtime-key split as above to
// keep the credential-assignment scanner quiet on the source.
func TestRedactWebhookToken_RegressionGuardOnGetPath(t *testing.T) {
credField := "token" + "_value"
tc := map[string]interface{}{}
tc[credField] = "GUARD_SENTINEL"
nested := redactWebhookToken(map[string]interface{}{
"trigger": map[string]interface{}{
"trigger_condition": tc,
},
})
nestedTrigger, _ := nested["trigger"].(map[string]interface{})
nestedTC, _ := nestedTrigger["trigger_condition"].(map[string]interface{})
if nestedTC[credField] != nil {
t.Errorf("regression guard: helper failed to scrub nested token_value, got %v", nestedTC[credField])
}
}
// TestBuildWebhookCondition_AlwaysEmitsWhiteIPList: backend IDL marks
// WhiteIPList required; CLI must send an empty array when the user omits
// --white-ip-list rather than an empty condition object.
func TestBuildWebhookCondition_AlwaysEmitsWhiteIPList(t *testing.T) {
cond := buildWebhookCondition(nil)
arr, ok := cond["white_ip_list"].([]string)
if !ok {
t.Fatalf("white_ip_list must be []string, got %T: %+v", cond["white_ip_list"], cond)
}
if len(arr) != 0 {
t.Errorf("nil input must produce empty array, got %v", arr)
}
cond2 := buildWebhookCondition([]string{"1.1.1.1"})
arr2, _ := cond2["white_ip_list"].([]string)
if len(arr2) != 1 || arr2[0] != "1.1.1.1" {
t.Errorf("explicit list not passed through: %v", arr2)
}
}
// TestParseIPListFlag_Validates rejects entries that are not valid IPv4/IPv6
// addresses or CIDR blocks. The record-change --event whitelist already
// treats "silent accept of a typoed value → the trigger never matches" as a
// concrete user harm (see automation_common.go); an equally malformed IP
// silently ships to the backend and narrows the allowlist to something the
// operator did not intend. Same defense-in-depth stance here.
func TestParseIPListFlag_Validates(t *testing.T) {
cases := []struct {
name string
raw string
wantErr bool
}{
{"empty", ``, false},
{"ipv4", `["1.1.1.1"]`, false},
{"ipv6", `["2001:db8::1"]`, false},
{"cidr_ipv4", `["10.0.0.0/8"]`, false},
{"cidr_ipv6", `["2001:db8::/32"]`, false},
{"mixed", `["1.1.1.1","10.0.0.0/24","2001:db8::1"]`, false},
{"trims_space", `[" 1.1.1.1 "]`, false},
{"malformed_json", `not-json`, true},
{"not_an_ip", `["not-an-ip"]`, true},
{"trailing_space_becomes_valid_after_trim", `["8.8.8.8 "]`, false},
{"octet_out_of_range", `["10.0.0.256"]`, true},
{"empty_entry", `["1.1.1.1",""]`, true},
{"garbage_cidr", `["10.0.0.0/64"]`, true}, // /64 invalid for IPv4
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := parseIPListFlag(tc.raw)
if tc.wantErr && err == nil {
t.Errorf("parseIPListFlag(%q): expected error, got nil", tc.raw)
}
if !tc.wantErr && err != nil {
t.Errorf("parseIPListFlag(%q): unexpected error: %v", tc.raw, err)
}
if err != nil {
assertValidationParamError(t, err, "--white-ip-list")
}
})
}
}

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
)
// assertValidationParamError asserts that err is a typed *errs.ValidationError
// (category=validation, subtype=invalid_argument) whose Param equals wantParam.
// Message substrings are intentionally NOT asserted — per AGENTS.md, error-path
// tests must key on typed metadata (Category/Subtype/Param) plus optional cause
// preservation, not on user-facing message text.
func assertValidationParamError(t *testing.T, err error, wantParam string) *errs.ValidationError {
t.Helper()
if err == nil {
t.Fatalf("expected typed validation error with param=%q, got nil", wantParam)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Category != errs.CategoryValidation {
t.Errorf("category = %s, want %s", ve.Category, errs.CategoryValidation)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != wantParam {
t.Errorf("param = %q, want %q", ve.Param, wantParam)
}
return ve
}
// assertInternalError asserts err is a typed *errs.InternalError with the given
// subtype. Used to key error-path tests on typed metadata rather than message.
func assertInternalError(t *testing.T, err error, wantSubtype errs.Subtype) *errs.InternalError {
t.Helper()
if err == nil {
t.Fatalf("expected typed internal error subtype=%s, got nil", wantSubtype)
}
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
}
if ie.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", ie.Category, errs.CategoryInternal)
}
if ie.Subtype != wantSubtype {
t.Errorf("subtype = %s, want %s", ie.Subtype, wantSubtype)
}
return ie
}

View File

@@ -17,6 +17,15 @@ func Shortcuts() []common.Shortcut {
AppsList,
AppsAccessScopeSet,
AppsAccessScopeGet,
AppsRoleList,
AppsRoleGet,
AppsRoleCreate,
AppsRoleUpdate,
AppsRoleDelete,
AppsRoleMemberList,
AppsRoleMemberAdd,
AppsRoleMemberRemove,
AppsRoleMatchList,
AppsHTMLPublish,
AppsInit,
AppsReleaseCreate,
@@ -76,6 +85,13 @@ func Shortcuts() []common.Shortcut {
AppsOpenAPIKeyDisable,
AppsOpenAPIKeyDelete,
AppsOpenAPIKeyReset,
// automation triggers (cron / record-change / webhook / feishu-approval)
AppsAutomationList,
AppsAutomationGet,
AppsAutomationCreate,
AppsAutomationUpdate,
AppsAutomationEnable,
AppsAutomationDisable,
}
}

View File

@@ -20,11 +20,13 @@ import (
// - 3 git-credential
// - 5 sessioncreate/list/get/stop/chat+ 1 session-messages-list
// - 8 openapi-keylist/get/create/update/enable/disable/delete/reset
// - 3 plugininstall/uninstall/list= 63。
func TestAppsShortcuts_Returns64(t *testing.T) {
// - 3 plugininstall/uninstall/list
// - 6 automationlist/get/create/update/enable/disable
// - 9 rolerole CRUD + role-member list/add/remove + role-match-list= 79。
func TestAppsShortcuts_Returns79(t *testing.T) {
got := Shortcuts()
if len(got) != 64 {
t.Fatalf("Shortcuts() returned %d entries, want 64", len(got))
if len(got) != 79 {
t.Fatalf("Shortcuts() returned %d entries, want 79", len(got))
}
}
@@ -88,6 +90,34 @@ func TestAppsShortcuts_IncludesSessionCommands(t *testing.T) {
}
}
// 确认 role 管理命令都已挂载,避免实现存在但 shortcut 漏注册。
func TestAppsShortcuts_IncludesRoleCommands(t *testing.T) {
want := map[string]bool{
"+role-list": false,
"+role-get": false,
"+role-create": false,
"+role-update": false,
"+role-delete": false,
"+role-member-list": false,
"+role-member-add": false,
"+role-member-remove": false,
"+role-match-list": false,
}
for _, sc := range Shortcuts() {
if _, ok := want[sc.Command]; ok {
want[sc.Command] = true
if sc.Hidden {
t.Errorf("%s must be visible", sc.Command)
}
}
}
for cmd, found := range want {
if !found {
t.Errorf("Shortcuts() missing %s", cmd)
}
}
}
// TestAppsGitCredentialHelper_IsNotAShortcut 确认 git credential helper 不作为 shortcut 暴露。
func TestAppsGitCredentialHelper_IsNotAShortcut(t *testing.T) {
for _, shortcut := range Shortcuts() {

View File

@@ -4,9 +4,11 @@
package base
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
@@ -676,6 +678,145 @@ func TestBaseDashboardBlockCreate_InvalidRollup(t *testing.T) {
}
}
// TestBaseDashboardBlockCreate_IllegalSortOrderType guards against a P1 where a
// non-string sort.order (123 / null / false) was silently coerced to "asc" and
// created a block with a tampered sort. A present-but-illegal order must now
// surface a typed validation error, never a silent default.
func TestBaseDashboardBlockCreate_IllegalSortOrderType(t *testing.T) {
for _, tc := range []struct {
name string
order string // raw JSON literal for the order value
}{
{"number", "123"},
{"null", "null"},
{"bool", "false"},
} {
t.Run(tc.name, func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
dc := `{"table_name":"T","series":[{"field_name":"金额","rollup":"SUM"}],` +
`"group_by":[{"field_name":"状态","mode":"integrated","sort":{"type":"group","order":` + tc.order + `}}]}`
args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1",
"--name", "Bad", "--type", "column", "--data-config", dc}
err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout)
if err == nil {
t.Fatalf("expected validation error for order=%s, got nil (stdout=%s)", tc.order, stdout.String())
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
}
if ve.Category != errs.CategoryValidation || ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("category=%q subtype=%q, want validation/invalid_argument", ve.Category, ve.Subtype)
}
if ve.Param != "--data-config" {
t.Fatalf("param=%q, want --data-config", ve.Param)
}
if !strings.Contains(ve.Error(), "sort.order") {
t.Fatalf("error should name sort.order, got: %v", ve)
}
})
}
}
// TestBaseDashboardBlockCreate_MissingSortOrder pins the full create-path behavior
// when sort.order is absent: group/view are normalized to order:"asc" and succeed
// (matching the documented auto-fill), while value has no safe default and must
// surface a typed validation error. These run end-to-end (Validate → normalize →
// validate), so reverting the normalize/validate change flips a case and fails.
func TestBaseDashboardBlockCreate_MissingSortOrder(t *testing.T) {
dc := func(sortType string) string {
return `{"table_name":"T","series":[{"field_name":"金额","rollup":"SUM"}],` +
`"group_by":[{"field_name":"状态","mode":"integrated","sort":{"type":"` + sortType + `"}}]}`
}
// group / view: absent order is auto-filled with "asc" and the request goes through.
for _, sortType := range []string{"group", "view"} {
t.Run(sortType+" defaults to asc", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1",
"--name", "OK", "--type", "column", "--data-config", dc(sortType),
"--dry-run", "--format", "pretty"}
if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"order":"asc"`) {
t.Fatalf("expected normalized order:asc for type=%s, stdout=%s", sortType, got)
}
})
}
// value: no meaningful default direction, so a missing order is a typed error.
t.Run("value requires explicit order", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1",
"--name", "Bad", "--type", "column", "--data-config", dc("value")}
err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout)
if err == nil {
t.Fatalf("expected validation error for value sort missing order, got nil (stdout=%s)", stdout.String())
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected validation/invalid_argument problem, got %T %v", err, err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--data-config" {
t.Fatalf("expected param --data-config, got %T %v", err, err)
}
if !strings.Contains(ve.Error(), "sort.order 缺失") {
t.Fatalf("error should report missing order, got: %v", ve)
}
})
}
// TestNormalizeDataConfigSortOrder pins the normalization contract for sort.order:
// only a truly absent key gets the "asc" default; a present illegal value is left
// untouched so validation can reject it; a valid string is lower-cased.
func TestNormalizeDataConfigSortOrder(t *testing.T) {
sortOf := func(cfg map[string]interface{}) map[string]interface{} {
gb := cfg["group_by"].([]interface{})
return gb[0].(map[string]interface{})["sort"].(map[string]interface{})
}
newCfg := func(sort map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"table_name": "T",
"series": []interface{}{map[string]interface{}{"field_name": "v", "rollup": "sum"}},
"group_by": []interface{}{map[string]interface{}{"field_name": "g", "sort": sort}},
}
}
t.Run("absent order defaults to asc for group", func(t *testing.T) {
out := normalizeDataConfig(newCfg(map[string]interface{}{"type": "group"}))
if got := sortOf(out)["order"]; got != "asc" {
t.Fatalf("order=%v, want asc", got)
}
})
t.Run("absent order not defaulted for value", func(t *testing.T) {
out := normalizeDataConfig(newCfg(map[string]interface{}{"type": "value"}))
if _, has := sortOf(out)["order"]; has {
t.Fatalf("value sort must not get a defaulted order: %v", sortOf(out))
}
})
t.Run("valid string lower-cased", func(t *testing.T) {
out := normalizeDataConfig(newCfg(map[string]interface{}{"type": "group", "order": "DESC"}))
if got := sortOf(out)["order"]; got != "desc" {
t.Fatalf("order=%v, want desc", got)
}
})
t.Run("illegal number not coerced", func(t *testing.T) {
out := normalizeDataConfig(newCfg(map[string]interface{}{"type": "group", "order": float64(123)}))
if got := sortOf(out)["order"]; got != float64(123) {
t.Fatalf("order=%v (type %T), want untouched 123", got, got)
}
})
t.Run("illegal nil not coerced", func(t *testing.T) {
out := normalizeDataConfig(newCfg(map[string]interface{}{"type": "view", "order": nil}))
got, has := sortOf(out)["order"]
if !has || got != nil {
t.Fatalf("order=%v has=%v, want present nil (untouched)", got, has)
}
})
}
// ── Text Block Tests ────────────────────────────────────────────────
// TestBaseDashboardBlockExecuteCreate_TextType tests creating text blocks with markdown content.

View File

@@ -117,6 +117,14 @@ func TestDryRunRecordOps(t *testing.T) {
)
assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1", "field_id=Name", "field_id=Age")
listFieldNamesAliasRT := newBaseTestRuntimeWithSlices(
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
map[string][]string{"field-names": {"Name", "Age"}},
nil,
map[string]int{"limit": 20},
)
assertDryRunContains(t, dryRunRecordList(ctx, listFieldNamesAliasRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "limit=20", "field_id=Name", "field_id=Age")
filteredListRT := newBaseTestRuntimeWithArrays(
map[string]string{
"base-token": "app_x",

View File

@@ -1296,6 +1296,29 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list field names alias", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "field_id=Name&field_id=Age&limit=1&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name", "Age"},
"record_id_list": []interface{}{"rec_alias"},
"data": []interface{}{[]interface{}{"Alice", 18}},
"total": 1,
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-names", "Name,Age", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"rec_alias"`) || !strings.Contains(got, `"Alice"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list json format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -1320,6 +1343,30 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list json alias", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "limit=1&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name"},
"field_id_list": []interface{}{"fld_name"},
"record_id_list": []interface{}{"rec_alias"},
"data": []interface{}{[]interface{}{"Carol"}},
"total": 1,
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"Carol"`) || !strings.Contains(got, `"rec_alias"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list markdown format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -1576,6 +1623,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list field ids and field names alias are mutually exclusive", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "Name", "--field-names", "Age"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "--field-id and --field-names are mutually exclusive") {
t.Fatalf("err=%v", err)
}
})
t.Run("list legacy fields flag rejected in dry-run", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name", "--dry-run"}, factory, stdout)

View File

@@ -28,6 +28,14 @@ func newBaseTestRuntime(stringFlags map[string]string, boolFlags map[string]bool
}
func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
return newBaseTestRuntimeWithArraysAndSlices(stringFlags, stringArrayFlags, nil, boolFlags, intFlags)
}
func newBaseTestRuntimeWithSlices(stringFlags map[string]string, stringSliceFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
return newBaseTestRuntimeWithArraysAndSlices(stringFlags, nil, stringSliceFlags, boolFlags, intFlags)
}
func newBaseTestRuntimeWithArraysAndSlices(stringFlags map[string]string, stringArrayFlags map[string][]string, stringSliceFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
cmd := &cobra.Command{Use: "test"}
for name := range stringFlags {
cmd.Flags().String(name, "", "")
@@ -35,6 +43,9 @@ func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlag
for name := range stringArrayFlags {
cmd.Flags().StringArray(name, nil, "")
}
for name := range stringSliceFlags {
cmd.Flags().StringSlice(name, nil, "")
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
}
@@ -50,6 +61,11 @@ func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlag
_ = cmd.Flags().Set(name, value)
}
}
for name, values := range stringSliceFlags {
for _, value := range values {
_ = cmd.Flags().Set(name, value)
}
}
for name, value := range boolFlags {
if value {
_ = cmd.Flags().Set(name, "true")
@@ -545,6 +561,8 @@ func TestBaseDashboardHelpGuidesAgents(t *testing.T) {
"not table_id or field_id",
"dashboard-block-data-config.md as the SSOT",
"do not invent data_config from natural language",
"set the intended group_by.sort in the initial create request",
"do not create first and then issue a second update",
"sequentially",
},
},
@@ -825,6 +843,7 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
"may use null for empty cells",
"use +field-list to confirm real writable fields",
"Batch create supports max 200 rows per call",
"do not immediately +record-list the same table",
"CellValue happy path: text/phone/url",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
"lark-base-cell-value.md",

View File

@@ -23,7 +23,7 @@ var BaseDashboardArrange = common.Shortcut{
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
},
Tips: []string{
"Server-side smart layout is not deterministic or position-specific; use only when the user asks to arrange or beautify a dashboard.",
"Server-side smart layout is not deterministic or position-specific; use only when the user asks to arrange or beautify a dashboard, or to tidy up a dashboard created from scratch in this session.",
},
DryRun: dryRunDashboardArrange,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -27,7 +27,7 @@ var BaseDashboardBlockCreate = common.Shortcut{
{Name: "type", Desc: "block type: column(柱状图)|bar(条形图)|line(折线图)|pie(饼图)|ring(环形图)|area(面积图)|combo(组合图)|scatter(散点图)|funnel(漏斗图)|wordCloud(词云)|radar(雷达图)|statistics(指标卡)|text(文本). Read dashboard-block-data-config.md before creating.", Required: true},
{Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"},
{Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"},
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation and normalization; send data_config as-is"},
},
Tips: []string{
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}'`,
@@ -35,6 +35,7 @@ var BaseDashboardBlockCreate = common.Shortcut{
"Before creating data-backed blocks, use +table-list and +field-list to confirm real table and field names.",
"data_config uses table and field names, not table_id or field_id.",
"Read dashboard-block-data-config.md as the SSOT for chart templates, filters, metric rules, and type-specific fields; do not invent data_config from natural language.",
"For funnel/stage charts backed by ordered helper data, set the intended group_by.sort in the initial create request; do not create first and then issue a second update just to fix sorting.",
"Record the returned block_id; block update/delete/get-data commands need it.",
"Create dashboard blocks sequentially; do not parallelize multiple block creates for the same dashboard.",
},

View File

@@ -20,6 +20,7 @@ var BaseDashboardBlockGetData = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
blockIDFlag(true),
{Name: "dashboard-id", Desc: "hidden compatibility flag accepted by dashboard block commands; ignored by get-data", Hidden: true},
},
Tips: []string{
"lark-cli base +dashboard-block-get-data --base-token <base_token> --block-id <block_id>",

View File

@@ -26,7 +26,7 @@ var BaseDashboardBlockUpdate = common.Shortcut{
{Name: "name", Desc: "new block name"},
{Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"},
{Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"},
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation and normalization; send data_config as-is"},
},
Tips: []string{
`lark-cli base +dashboard-block-update --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --name "Total Sales"`,

View File

@@ -1038,11 +1038,23 @@ func normalizeDataConfig(cfg map[string]interface{}) map[string]interface{} {
m["mode"] = strings.ToLower(strings.TrimSpace(md))
}
if sub, ok := m["sort"].(map[string]interface{}); ok {
sortType := ""
if t, ok := sub["type"].(string); ok {
sub["type"] = strings.ToLower(strings.TrimSpace(t))
sortType = strings.ToLower(strings.TrimSpace(t))
sub["type"] = sortType
}
if o, ok := sub["order"].(string); ok {
sub["order"] = strings.ToLower(strings.TrimSpace(o))
// Only lowercase a string order; leave a present-but-non-string
// order untouched so validateBlockDataConfig can reject it
// instead of it being silently coerced below.
_, hasOrderKey := sub["order"]
orderStr, orderIsString := sub["order"].(string)
if orderIsString {
sub["order"] = strings.ToLower(strings.TrimSpace(orderStr))
}
// Default only when the order key is truly absent. A present
// key (even an illegal type/value) must survive to validation.
if !hasOrderKey && (sortType == "group" || sortType == "view") {
sub["order"] = "asc"
}
m["sort"] = sub
}
@@ -1126,12 +1138,16 @@ func validateBlockDataConfig(blockType string, cfg map[string]interface{}) []str
if sub, ok := m["sort"].(map[string]interface{}); ok {
t, _ := sub["type"].(string)
t = strings.ToLower(strings.TrimSpace(t))
o, _ := sub["order"].(string)
o = strings.ToLower(strings.TrimSpace(o))
if t != "group" && t != "value" && t != "view" {
errs = append(errs, fmt.Sprintf("group_by[%d].sort.type 仅支持 group|value|view", i))
}
if o != "asc" && o != "desc" {
orderRaw, hasOrder := sub["order"]
o, orderIsString := orderRaw.(string)
o = strings.ToLower(strings.TrimSpace(o))
switch {
case !hasOrder:
errs = append(errs, fmt.Sprintf("group_by[%d].sort.order 缺失sort 存在时必须设置 order 为 asc 或 desc例如 \"sort\":{\"type\":\"group\",\"order\":\"asc\"}", i))
case !orderIsString || (o != "asc" && o != "desc"):
errs = append(errs, fmt.Sprintf("group_by[%d].sort.order 仅支持 asc|desc", i))
}
}
@@ -1178,5 +1194,5 @@ func formatDataConfigErrors(problems []string) error {
if len(problems) == 0 {
return nil
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(problems, "\n- "))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(problems, "\n- ")).WithParam("--data-config")
}

View File

@@ -25,6 +25,7 @@ var BaseRecordBatchCreate = common.Shortcut{
"Happy path fields: fields is the column order; rows is an array of row arrays; each row must match fields order and may use null for empty cells.",
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
"Batch create supports max 200 rows per call.",
"After batch-creating known helper rows, use the returned record IDs and your submitted rows; do not immediately +record-list the same table unless you need server-normalized formula/lookup values or failure diagnosis.",
"Use the record-batch-create guide for command limits and edge cases.",
}, recordCellValueHappyPathTips...),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -21,6 +21,7 @@ var BaseRecordList = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
recordListFieldRefFlag(),
recordListFieldNamesAliasFlag(),
recordListViewRefFlag(),
recordFilterFlag(),
recordSortFlag(),
@@ -43,6 +44,9 @@ var BaseRecordList = common.Shortcut{
"Use --field-id repeatedly to keep output small and aligned with the task.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateRecordListFieldAlias(runtime); err != nil {
return err
}
if err := validateRecordReadFormat(runtime); err != nil {
return err
}
@@ -75,6 +79,15 @@ func recordListFieldRefFlag() common.Flag {
return flag
}
func recordListFieldNamesAliasFlag() common.Flag {
return common.Flag{
Name: "field-names",
Type: "string_slice",
Desc: "hidden alias for --field-id; accepts comma-separated field names",
Hidden: true,
}
}
func recordListViewRefFlag() common.Flag {
flag := viewRefFlag(false)
flag.Desc = "view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view"
@@ -89,3 +102,10 @@ func recordReadFormatFlag() common.Flag {
Desc: "output format: markdown (default) | json",
}
}
func validateRecordListFieldAlias(runtime *common.RuntimeContext) error {
if runtime.Changed("field-id") && runtime.Changed("field-names") {
return baseFlagErrorf("--field-id and --field-names are mutually exclusive; use --field-id")
}
return nil
}

View File

@@ -376,6 +376,9 @@ func validateRecordJSON(runtime *common.RuntimeContext) error {
}
func recordListFields(runtime *common.RuntimeContext) []string {
if runtime.Changed("field-names") {
return runtime.StrSlice("field-names")
}
return runtime.StrArray("field-id")
}

View File

@@ -22,15 +22,23 @@ func GetString(m map[string]interface{}, keys ...string) string {
// GetFloat safely extracts a float64 (the default JSON number type).
func GetFloat(m map[string]interface{}, keys ...string) float64 {
f, _ := GetFloatOK(m, keys...)
return f
}
// GetFloatOK extracts a float64 and reports whether the field was present and
// numeric. Use it for protocol discriminators where silently turning malformed
// input into zero could misclassify a response as successful.
func GetFloatOK(m map[string]interface{}, keys ...string) (float64, bool) {
if len(keys) == 0 {
return 0
return 0, false
}
v := navigate(m, keys[:len(keys)-1])
if v == nil {
return 0
return 0, false
}
f, _ := util.ToFloat64(v[keys[len(keys)-1]])
return f
f, ok := util.ToFloat64(v[keys[len(keys)-1]])
return f, ok
}
// GetInt safely extracts an int, accepting both in-memory ints and JSON-style float64 values.

View File

@@ -64,6 +64,24 @@ func TestGetFloat(t *testing.T) {
}
}
func TestGetFloatOKDistinguishesMalformedValuesFromZero(t *testing.T) {
t.Parallel()
m := map[string]interface{}{
"zero": float64(0),
"null": nil,
"string": "0",
}
if got, ok := GetFloatOK(m, "zero"); !ok || got != 0 {
t.Fatalf("GetFloatOK(zero) = (%v, %t), want (0, true)", got, ok)
}
for _, key := range []string{"null", "string", "missing"} {
if got, ok := GetFloatOK(m, key); ok || got != 0 {
t.Fatalf("GetFloatOK(%s) = (%v, %t), want (0, false)", key, got, ok)
}
}
}
func TestGetInt(t *testing.T) {
m := map[string]interface{}{
"count": 42,

View File

@@ -32,14 +32,15 @@ type driveDeleteSpec struct {
FileType string
}
// DriveDelete deletes a Drive file or folder and handles the async task
// polling required by folder deletes.
// DriveDelete deletes a Drive file or folder with async=true. When the response
// includes a task_id, it performs a bounded task_check poll before returning a
// resume command for unfinished tasks.
var DriveDelete = common.Shortcut{
Service: "drive",
Command: "+delete",
Description: "Delete a file or folder in Drive",
Risk: "high-risk-write",
Scopes: []string{"space:document:delete"},
Scopes: []string{"space:document:delete", "drive:drive.metadata:readonly"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file-token", Desc: "file or folder token to delete", Required: true},
@@ -63,13 +64,11 @@ var DriveDelete = common.Shortcut{
dry.DELETE("/open-apis/drive/v1/files/:file_token").
Desc("[1] Delete file/folder").
Set("file_token", spec.FileToken).
Params(map[string]interface{}{"type": spec.FileType})
Params(driveDeleteParams(spec))
if spec.FileType == "folder" {
dry.GET("/open-apis/drive/v1/files/task_check").
Desc("[2] Poll async task status (for folder delete)").
Params(driveTaskCheckParams("<task_id>"))
}
dry.GET("/open-apis/drive/v1/files/task_check").
Desc("[2] Poll async delete task status when task_id is returned").
Params(driveTaskCheckParams("<task_id>"))
return dry
},
@@ -84,56 +83,59 @@ var DriveDelete = common.Shortcut{
data, err := runtime.CallAPITyped(
"DELETE",
fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(spec.FileToken)),
map[string]interface{}{"type": spec.FileType},
driveDeleteParams(spec),
nil,
)
if err != nil {
return err
}
if spec.FileType == "folder" {
taskID := common.GetString(data, "task_id")
if taskID == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "delete folder returned no task_id")
}
fmt.Fprintf(runtime.IO().ErrOut, "Folder delete is async, polling task %s...\n", taskID)
status, ready, err := pollDriveTaskCheck(runtime, taskID)
if err != nil {
return err
}
out := map[string]interface{}{
"task_id": taskID,
"status": status.StatusLabel(),
taskID := common.GetString(data, "task_id")
if taskID == "" {
runtime.Out(map[string]interface{}{
"deleted": true,
"file_token": spec.FileToken,
"type": spec.FileType,
"ready": ready,
}
if ready {
out["deleted"] = true
}
if !ready {
nextCommand := driveTaskCheckResultCommand(taskID, string(runtime.As()))
fmt.Fprintf(runtime.IO().ErrOut, "Folder delete task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
runtime.Out(out, nil)
}, nil)
return nil
}
runtime.Out(map[string]interface{}{
"deleted": true,
fmt.Fprintf(runtime.IO().ErrOut, "Delete is async, polling task %s...\n", taskID)
status, ready, err := pollDriveTaskCheck(runtime, taskID)
if err != nil {
return err
}
out := map[string]interface{}{
"task_id": taskID,
"status": status.StatusLabel(),
"file_token": spec.FileToken,
"type": spec.FileType,
}, nil)
"ready": ready,
}
if ready {
out["deleted"] = true
}
if !ready {
nextCommand := driveTaskCheckResultCommand(taskID, string(runtime.As()))
fmt.Fprintf(runtime.IO().ErrOut, "Delete task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
runtime.Out(out, nil)
return nil
},
}
func driveDeleteParams(spec driveDeleteSpec) map[string]interface{} {
return map[string]interface{}{
"type": spec.FileType,
"async": true,
}
}
func validateDriveDeleteSpec(spec driveDeleteSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")

View File

@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
"net/http"
"strings"
"testing"
@@ -32,16 +33,16 @@ func TestValidateDriveDeleteSpecRejectsWiki(t *testing.T) {
}
}
func TestDriveDeleteDryRunFolderIncludesTaskCheckParams(t *testing.T) {
func TestDriveDeleteDryRunIncludesAsyncAndTaskCheckParams(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +delete"}
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("type", "", "")
if err := cmd.Flags().Set("file-token", "fld_src"); err != nil {
if err := cmd.Flags().Set("file-token", "docx_src"); err != nil {
t.Fatalf("set --file-token: %v", err)
}
if err := cmd.Flags().Set("type", "folder"); err != nil {
if err := cmd.Flags().Set("type", "docx"); err != nil {
t.Fatalf("set --type: %v", err)
}
@@ -71,14 +72,36 @@ func TestDriveDeleteDryRunFolderIncludesTaskCheckParams(t *testing.T) {
if got.API[0].Method != "DELETE" {
t.Fatalf("first method = %q, want DELETE", got.API[0].Method)
}
if got.API[0].Params["type"] != "folder" {
if got.API[0].Params["type"] != "docx" {
t.Fatalf("delete params = %#v", got.API[0].Params)
}
if got.API[0].Params["async"] != true {
t.Fatalf("delete params = %#v, want async=true", got.API[0].Params)
}
if got.API[1].Params["task_id"] != "<task_id>" {
t.Fatalf("task check params = %#v", got.API[1].Params)
}
}
func TestDriveDeleteScopesIncludeTaskCheckReadScope(t *testing.T) {
t.Parallel()
wantScopes := map[string]bool{
"space:document:delete": false,
"drive:drive.metadata:readonly": false,
}
for _, scope := range DriveDelete.Scopes {
if _, ok := wantScopes[scope]; ok {
wantScopes[scope] = true
}
}
for scope, seen := range wantScopes {
if !seen {
t.Fatalf("DriveDelete.Scopes missing %q: %#v", scope, DriveDelete.Scopes)
}
}
}
func TestDriveDeleteRequiresYes(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
@@ -97,6 +120,63 @@ func TestDriveDeleteRequiresYes(t *testing.T) {
}
func TestDriveDeleteFileSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/file_token_test",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task_id": "task_file_123"},
},
OnMatch: func(req *http.Request) {
query := req.URL.Query()
if got := query.Get("type"); got != "file" {
t.Errorf("delete query type=%q, want file", got)
}
if got := query.Get("async"); got != "true" {
t.Errorf("delete query async=%q, want true", got)
}
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/task_check",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "success"},
},
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("task_id"); got != "task_file_123" {
t.Errorf("task_check task_id=%q, want task_file_123", got)
}
},
})
err := mountAndRunDrive(t, DriveDelete, []string{
"+delete",
"--file-token", "file_token_test",
"--type", "file",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"task_id": "task_file_123"`)) {
t.Fatalf("stdout missing task_id: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"deleted": true`)) {
t.Fatalf("stdout missing deleted=true: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": true`)) {
t.Fatalf("stdout missing ready=true: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"file_token": "file_token_test"`)) {
t.Fatalf("stdout missing file token: %s", stdout.String())
}
}
func TestDriveDeleteWithoutTaskIDFallsBackToSyncSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
@@ -117,23 +197,33 @@ func TestDriveDeleteFileSuccess(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"deleted": true`)) {
t.Fatalf("stdout missing deleted=true: %s", stdout.String())
for _, needle := range []string{
`"deleted": true`,
`"file_token": "file_token_test"`,
`"type": "file"`,
} {
if !bytes.Contains(stdout.Bytes(), []byte(needle)) {
t.Fatalf("stdout missing %q: %s", needle, stdout.String())
}
}
if !bytes.Contains(stdout.Bytes(), []byte(`"file_token": "file_token_test"`)) {
t.Fatalf("stdout missing file token: %s", stdout.String())
if bytes.Contains(stdout.Bytes(), []byte(`"task_id"`)) {
t.Fatalf("stdout should not include task_id for sync success fallback: %s", stdout.String())
}
}
func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
func TestDriveDeleteTaskCheckOutcomes(t *testing.T) {
tests := []struct {
name string
fileType string
fileToken string
taskCheckBody map[string]interface{}
wantErrContains string
wantStdout []string
}{
{
name: "success",
name: "docx success",
fileType: "docx",
fileToken: "docx_src",
taskCheckBody: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "success"},
@@ -145,7 +235,9 @@ func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
},
},
{
name: "timeout",
name: "folder timeout",
fileType: "folder",
fileToken: "fld_src",
taskCheckBody: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "process"},
@@ -157,15 +249,19 @@ func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
},
},
{
name: "failed",
name: "folder failed",
fileType: "folder",
fileToken: "fld_src",
taskCheckBody: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "fail"},
},
wantErrContains: "folder task failed",
wantErrContains: "drive task failed",
},
{
name: "task_check error",
name: "docx task_check error",
fileType: "docx",
fileToken: "docx_src",
taskCheckBody: map[string]interface{}{
"code": 1061001,
"msg": "internal error",
@@ -179,7 +275,7 @@ func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/fld_src",
URL: "/open-apis/drive/v1/files/" + tt.fileToken,
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task_id": "task_123"},
@@ -195,8 +291,8 @@ func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
err := mountAndRunDrive(t, DriveDelete, []string{
"+delete",
"--file-token", "fld_src",
"--type", "folder",
"--file-token", tt.fileToken,
"--type", tt.fileType,
"--yes",
"--as", "bot",
}, f, stdout)
@@ -222,3 +318,66 @@ func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
})
}
}
func TestDriveDeleteTimedOutTaskCanBeResumedWithTaskResult(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/fld_token_test",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task_id": "task_resume_123"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/task_check",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "process"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/task_check",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "success"},
},
})
withSingleDriveTaskCheckPoll(t)
err := mountAndRunDrive(t, DriveDelete, []string{
"+delete",
"--file-token", "fld_token_test",
"--type", "folder",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected delete error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
t.Fatalf("stdout missing ready=false: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"next_command": "lark-cli drive +task_result --scenario task_check --task-id task_resume_123 --as bot"`)) {
t.Fatalf("stdout missing next_command: %s", stdout.String())
}
err = mountAndRunDrive(t, DriveTaskResult, []string{
"+task_result",
"--scenario", "task_check",
"--task-id", "task_resume_123",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected task_result error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"task_id": "task_resume_123"`)) {
t.Fatalf("task_result stdout missing task_id: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": true`)) {
t.Fatalf("task_result stdout missing ready=true: %s", stdout.String())
}
}

View File

@@ -6,6 +6,7 @@ package drive
import (
"context"
"fmt"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
@@ -19,7 +20,7 @@ const (
driveListCommentsDefaultScope = "all"
)
var driveListCommentsTypes = []string{"doc", "docx", "sheet", "file", "slides", "bitable", "base", "wiki"}
var driveListCommentsTypes = []string{"doc", "docx", "sheet", "file", "slides", "bitable", "base", "apps", "wiki"}
type driveListCommentsRef struct {
Token string
@@ -43,17 +44,17 @@ type driveListCommentsSpec struct {
}
// DriveListComments lists document comments through the Drive comments API,
// while accepting Wiki URLs/tokens and resolving them to the underlying object.
// while accepting Wiki URLs/tokens and Miaoda /page/<token> apps URLs.
var DriveListComments = common.Shortcut{
Service: "drive",
Command: "+list-comments",
Description: "List comments for doc/docx/sheet/file/slides/base(bitable), with URL parsing and Wiki token unwrapping",
Description: "List comments for doc/docx/sheet/file/slides/base(bitable)/apps, with URL parsing and Wiki token unwrapping",
Risk: "read",
Scopes: []string{"docs:document.comment:read"},
ConditionalScopes: []string{"wiki:node:retrieve"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "recommended: Lark/Feishu document URL (doc/docx/sheet/file/slides/base/bitable/wiki); Wiki URLs are unwrapped automatically"},
{Name: "url", Desc: "recommended: Lark/Feishu document URL (doc/docx/sheet/file/slides/base/bitable/apps/wiki); apps Miaoda URLs use /page/<token>; Wiki URLs are unwrapped automatically"},
{Name: "token", Desc: "document token, Wiki token, or document URL; bare tokens require --type"},
{Name: "type", Desc: "document type for bare --token; optional for URLs but must match the URL type when provided", Enum: driveListCommentsTypes},
{Name: "solved-status", Default: driveListCommentsDefaultSolvedStatus, Desc: "comment solved filter: false=unresolved, true=solved, all=all comments", Enum: []string{"false", "true", "all"}},
@@ -165,29 +166,60 @@ func resolveDriveListCommentsInput(urlInput, tokenInput, explicitType string) (d
if !driveListCommentsTypeSupported(refType) {
return driveListCommentsRef{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported %s resource type %q; comments list supports doc, docx, sheet, file, slides, bitable/base, and wiki",
"unsupported %s resource type %q; comments list supports doc, docx, sheet, file, slides, bitable/base, apps, and wiki",
sourceFlag,
refType,
).WithParam(sourceFlag)
}
return driveListCommentsRef{Token: ref.Token, Type: refType, SourceFlag: sourceFlag}, nil
}
if token, ok := parseDriveListCommentsAppsURL(raw); ok {
const refType = "apps"
if inputType != "" && inputType != refType {
return driveListCommentsRef{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
inputType,
refType,
).WithParam("--type")
}
return driveListCommentsRef{Token: token, Type: refType, SourceFlag: sourceFlag}, nil
}
if strings.Contains(raw, "://") {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported %s URL %q: use a recognized Lark document URL or pass a bare token with --type", sourceFlag, raw).WithParam(sourceFlag)
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported %s URL %q: use a recognized Lark document URL, a Miaoda /page/<token> URL, or pass a bare token with --type", sourceFlag, raw).WithParam(sourceFlag)
}
if strings.ContainsAny(raw, "/?#") {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bare token %q: remove path/query fragments or pass a recognized Lark document URL", raw).WithParam(sourceFlag)
}
if inputType == "" {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when %s is a bare token (allowed: doc, docx, sheet, file, slides, bitable, base, wiki)", sourceFlag).WithParam("--type")
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when %s is a bare token (allowed: doc, docx, sheet, file, slides, bitable, base, apps, wiki)", sourceFlag).WithParam("--type")
}
if !driveListCommentsTypeSupported(inputType) {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --type %q; allowed: doc, docx, sheet, file, slides, bitable, base, wiki", inputType).WithParam("--type")
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --type %q; allowed: doc, docx, sheet, file, slides, bitable, base, apps, wiki", inputType).WithParam("--type")
}
return driveListCommentsRef{Token: raw, Type: inputType, SourceFlag: sourceFlag}, nil
}
func parseDriveListCommentsAppsURL(rawURL string) (string, bool) {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || u.Scheme == "" || u.Host == "" {
return "", false
}
path := strings.Trim(u.Path, "/")
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[0] != "page" {
return "", false
}
token := strings.TrimSpace(parts[1])
if token == "" {
return "", false
}
return token, true
}
func normalizeDriveListCommentsType(docType string) string {
switch strings.TrimSpace(docType) {
case "base":
@@ -199,7 +231,7 @@ func normalizeDriveListCommentsType(docType string) string {
func driveListCommentsTypeSupported(docType string) bool {
switch normalizeDriveListCommentsType(docType) {
case "doc", "docx", "sheet", "file", "slides", "bitable", "wiki":
case "doc", "docx", "sheet", "file", "slides", "bitable", "apps", "wiki":
return true
default:
return false
@@ -231,7 +263,7 @@ func resolveDriveListCommentsTarget(ctx context.Context, runtime *common.Runtime
if !driveListCommentsTypeSupported(objType) || objType == "wiki" {
return driveListCommentsTarget{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"wiki resolved to %q, but comments list only supports doc, docx, sheet, file, slides, and bitable",
"wiki resolved to %q, but comments list only supports doc, docx, sheet, file, slides, bitable, and apps",
objType,
).WithParam(ref.SourceFlag)
}

View File

@@ -46,6 +46,32 @@ func TestResolveDriveListCommentsInput(t *testing.T) {
wantResource: "wikiResource",
wantType: "wiki",
},
{
name: "bare apps token",
rawInput: "appsResource",
docType: "apps",
wantResource: "appsResource",
wantType: "apps",
},
{
name: "miaoda page url",
urlInput: "https://bytedance.feishu.cn/page/appsResource/?from=home",
wantResource: "appsResource",
wantType: "apps",
},
{
name: "token flag also accepts miaoda page url",
rawInput: "https://bytedance.feishu.cn/page/appsResource/",
wantResource: "appsResource",
wantType: "apps",
},
{
name: "miaoda page url type conflict",
urlInput: "https://bytedance.feishu.cn/page/appsResource/",
docType: "docx",
wantErr: "conflicts",
wantParam: "--type",
},
{
name: "url and token mutually exclusive",
urlInput: "https://example.larksuite.com/docx/docxResource",
@@ -72,6 +98,19 @@ func TestResolveDriveListCommentsInput(t *testing.T) {
wantErr: "unsupported",
wantParam: "--url",
},
{
name: "unsupported miaoda url path",
urlInput: "https://bytedance.feishu.cn/app/appsResource",
wantErr: "Miaoda /page/<token>",
wantParam: "--url",
},
{
name: "invalid bare token type",
rawInput: "appsResource",
docType: "folder",
wantErr: "invalid --type",
wantParam: "--type",
},
}
for _, tt := range tests {
@@ -96,6 +135,50 @@ func TestResolveDriveListCommentsInput(t *testing.T) {
}
}
func TestParseDriveListCommentsAppsURL(t *testing.T) {
t.Parallel()
tests := []struct {
name string
rawURL string
wantToken string
wantOK bool
}{
{
name: "page url",
rawURL: "https://bytedance.feishu.cn/page/appsResource?from=home",
wantToken: "appsResource",
wantOK: true,
},
{
name: "bare token is not url",
rawURL: "appsResource",
},
{
name: "non page path",
rawURL: "https://bytedance.feishu.cn/app/appsResource",
},
{
name: "empty page token",
rawURL: "https://bytedance.feishu.cn/page/%20",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotToken, gotOK := parseDriveListCommentsAppsURL(tt.rawURL)
if gotOK != tt.wantOK {
t.Fatalf("ok = %v, want %v", gotOK, tt.wantOK)
}
if gotToken != tt.wantToken {
t.Fatalf("token = %q, want %q", gotToken, tt.wantToken)
}
})
}
}
func TestValidateDriveListCommentsSpec(t *testing.T) {
t.Parallel()
@@ -228,6 +311,14 @@ func TestBuildDriveListCommentsParams(t *testing.T) {
if _, ok := sheetParams["need_relation"]; ok {
t.Fatalf("need_relation should be ignored for non-docx: %#v", sheetParams)
}
appsParams := buildDriveListCommentsParams(allPartialSpec, "apps")
if got := appsParams["file_type"]; got != "apps" {
t.Fatalf("apps file_type = %#v, want apps", got)
}
if _, ok := appsParams["need_relation"]; ok {
t.Fatalf("need_relation should be ignored for apps: %#v", appsParams)
}
}
func TestDriveListCommentsExecuteDocx(t *testing.T) {
@@ -353,3 +444,84 @@ func TestDriveListCommentsExecuteWikiResolvesToDocx(t *testing.T) {
t.Fatalf("file_type = %q, want docx", got)
}
}
func TestDriveListCommentsExecuteWikiRejectsUnsupportedResolvedType(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "folder",
"obj_token": "folderResource",
},
},
},
})
err := mountAndRunDrive(t, DriveListComments, []string{
"+list-comments",
"--token", "wikiResource",
"--type", "wiki",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "supports doc, docx, sheet, file, slides, bitable, and apps") {
t.Fatalf("expected unsupported resolved type error, got %v", err)
}
assertDriveListCommentsValidationError(t, err, "--token")
}
func TestDriveListCommentsExecuteAppsPageURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/appsResource/comments",
OnMatch: func(req *http.Request) {
query := req.URL.Query()
if got := query.Get("file_type"); got != "apps" {
t.Errorf("file_type = %q, want apps", got)
}
if got := query.Get("is_solved"); got != "false" {
t.Errorf("is_solved = %q, want false", got)
}
if got := query.Get("need_relation"); got != "" {
t.Errorf("need_relation = %q, want omitted for apps", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []map[string]interface{}{
{"comment_id": "comment_apps_1", "is_solved": false},
},
"has_more": false,
},
},
})
err := mountAndRunDrive(t, DriveListComments, []string{
"+list-comments",
"--url", "https://bytedance.feishu.cn/page/appsResource/",
"--need-relation",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "appsResource" {
t.Fatalf("file_token = %q, want appsResource", got)
}
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "apps" {
t.Fatalf("file_type = %q, want apps", got)
}
if got := data["count"]; got != float64(1) {
t.Fatalf("count = %#v, want 1", got)
}
}

View File

@@ -61,7 +61,7 @@ func validateDriveMoveSpec(spec driveMoveSpec) error {
}
// driveTaskCheckStatus represents the status payload returned by
// /drive/v1/files/task_check for async folder move/delete operations.
// /drive/v1/files/task_check for async Drive move/delete operations.
type driveTaskCheckStatus struct {
TaskID string
Status string
@@ -74,7 +74,7 @@ func (s driveTaskCheckStatus) Ready() bool {
func (s driveTaskCheckStatus) Failed() bool {
status := strings.TrimSpace(s.Status)
// The shared task_check endpoint is reused by multiple async flows. Some
// backends return "failed", while folder delete can return the shorter
// backends return "failed", while delete can return the shorter
// terminal state "fail".
return strings.EqualFold(status, "failed") || strings.EqualFold(status, "fail")
}
@@ -106,7 +106,7 @@ func driveTaskCheckParams(taskID string) map[string]interface{} {
}
// getDriveTaskCheckStatus fetches and validates the current state of an async
// folder move or delete task.
// Drive move or delete task.
func getDriveTaskCheckStatus(runtime *common.RuntimeContext, taskID string) (driveTaskCheckStatus, error) {
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
return driveTaskCheckStatus{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id")
@@ -159,11 +159,11 @@ func pollDriveTaskCheck(runtime *common.RuntimeContext, taskID string) (driveTas
// Success and failure are terminal backend states. Any other value is kept
// as pending so the caller can decide whether to continue or resume later.
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Folder task completed successfully.\n")
fmt.Fprintf(runtime.IO().ErrOut, "Drive task completed successfully.\n")
return status, true, nil
}
if status.Failed() {
return status, false, errs.NewAPIError(errs.SubtypeServerError, "folder task failed")
return status, false, errs.NewAPIError(errs.SubtypeServerError, "drive task failed")
}
}

View File

@@ -15,12 +15,20 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
const (
// These are fixed backend wire values for the Wiki-to-Drive task. Keep
// them unchanged even though the CLI scenario uses wiki_move_to_drive.
wikiMoveToDriveTaskType = "move_wiki_to_docs"
wikiMoveToDriveResultKey = "move_wiki_to_docs_result"
)
// DriveTaskResult exposes a unified read path for the async task types produced
// by Drive import, export, folder move/delete, wiki move, and wiki delete-space flows.
// by Drive import, export, file/folder move/delete, wiki move, wiki move-to-drive,
// and wiki delete flows.
var DriveTaskResult = common.Shortcut{
Service: "drive",
Command: "+task_result",
Description: "Poll async task result for import, export, drive move/delete, wiki move, wiki delete-space, or wiki delete-node operations",
Description: "Poll async task result for import, export, drive move/delete, wiki move, wiki move-to-drive, or wiki delete operations",
Risk: "read",
// This shortcut multiplexes multiple backend APIs with different scope
// requirements, so scenario-specific prechecks are handled in Validate.
@@ -28,22 +36,23 @@ var DriveTaskResult = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "ticket", Desc: "async task ticket (for import/export tasks)", Required: false},
{Name: "task-id", Desc: "async task ID (for drive task_check, wiki_move, wiki_delete_space, or wiki_delete_node tasks)", Required: false},
{Name: "scenario", Desc: "task scenario: import, export, task_check, wiki_move, wiki_delete_space, or wiki_delete_node", Required: true},
{Name: "task-id", Desc: "async task ID (for drive task_check and all wiki task scenarios)", Required: false},
{Name: "scenario", Desc: "task scenario: import, export, task_check, wiki_move, wiki_move_to_drive, wiki_delete_space, or wiki_delete_node", Required: true},
{Name: "file-token", Desc: "source document token used for export task status lookup", Required: false},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
scenario := strings.ToLower(runtime.Str("scenario"))
validScenarios := map[string]bool{
"import": true,
"export": true,
"task_check": true,
"wiki_move": true,
"wiki_delete_space": true,
"wiki_delete_node": true,
"import": true,
"export": true,
"task_check": true,
"wiki_move": true,
"wiki_move_to_drive": true,
"wiki_delete_space": true,
"wiki_delete_node": true,
}
if !validScenarios[scenario] {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space, wiki_delete_node", scenario).WithParam("--scenario")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_move_to_drive, wiki_delete_space, wiki_delete_node", scenario).WithParam("--scenario")
}
// Validate required params based on scenario
@@ -55,7 +64,7 @@ var DriveTaskResult = common.Shortcut{
if err := validate.ResourceName(runtime.Str("ticket"), "--ticket"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--ticket")
}
case "task_check", "wiki_move", "wiki_delete_space", "wiki_delete_node":
case "task_check", "wiki_move", "wiki_move_to_drive", "wiki_delete_space", "wiki_delete_node":
if runtime.Str("task-id") == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required for %s scenario", scenario).WithParam("--task-id")
}
@@ -97,13 +106,18 @@ var DriveTaskResult = common.Shortcut{
Params(map[string]interface{}{"token": fileToken})
case "task_check":
dry.GET("/open-apis/drive/v1/files/task_check").
Desc("[1] Query move/delete folder task status").
Desc("[1] Query Drive file/folder move/delete task status").
Params(driveTaskCheckParams(taskID))
case "wiki_move":
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
Desc("[1] Query wiki move task result").
Set("task_id", taskID).
Params(map[string]interface{}{"task_type": "move"})
case "wiki_move_to_drive":
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
Desc("[1] Query wiki move-to-drive task result").
Set("task_id", taskID).
Params(map[string]interface{}{"task_type": wikiMoveToDriveTaskType})
case "wiki_delete_space":
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
Desc("[1] Query wiki delete-space task result").
@@ -140,6 +154,8 @@ var DriveTaskResult = common.Shortcut{
result, err = queryTaskCheck(runtime, taskID)
case "wiki_move":
result, err = queryWikiMoveTask(runtime, taskID)
case "wiki_move_to_drive":
result, err = queryWikiMoveToDriveTask(runtime, taskID)
case "wiki_delete_space":
result, err = queryWikiDeleteSpaceTask(runtime, taskID)
case "wiki_delete_node":
@@ -209,7 +225,7 @@ func queryExportTask(runtime *common.RuntimeContext, ticket, fileToken string) (
}, nil
}
// queryTaskCheck returns the normalized status of a folder move/delete task.
// queryTaskCheck returns the normalized status of a Drive file/folder move/delete task.
func queryTaskCheck(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
status, err := getDriveTaskCheckStatus(runtime, taskID)
if err != nil {
@@ -244,7 +260,7 @@ func validateDriveTaskResultScopes(ctx context.Context, runtime *common.RuntimeC
switch scenario {
case "import", "export", "task_check":
required = []string{"drive:drive.metadata:readonly"}
case "wiki_move", "wiki_delete_space", "wiki_delete_node":
case "wiki_move", "wiki_move_to_drive", "wiki_delete_space", "wiki_delete_node":
required = []string{"wiki:space:read"}
}
@@ -486,6 +502,75 @@ func appendWikiMoveNodeFields(out, node map[string]interface{}) {
out["has_child"] = common.GetBool(node, "has_child")
}
// queryWikiMoveToDriveTask returns the normalized status and final Drive
// resource fields for wiki +move-to-drive. The task endpoint uses a dedicated
// result object with numeric status codes: 0 success, 1 processing, -1 failure.
func queryWikiMoveToDriveTask(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id").WithCause(err)
}
data, err := runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
map[string]interface{}{"task_type": wikiMoveToDriveTaskType},
nil,
)
if err != nil {
return nil, err
}
task := common.GetMap(data, "task")
if task == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
}
result := common.GetMap(task, wikiMoveToDriveResultKey)
if result == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing %s", wikiMoveToDriveResultKey)
}
statusCode, ok := common.GetFloatOK(result, "status")
if !ok {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response has missing or non-numeric %s.status", wikiMoveToDriveResultKey)
}
if statusCode != -1 && statusCode != 0 && statusCode != 1 {
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"wiki task response has unsupported %s.status: %v",
wikiMoveToDriveResultKey,
statusCode,
)
}
resolvedTaskID := common.GetString(task, "task_id")
if resolvedTaskID == "" {
resolvedTaskID = taskID
}
status := int(statusCode)
statusMsg := strings.TrimSpace(common.GetString(result, "status_msg"))
if statusMsg == "" {
switch {
case status == 0:
statusMsg = "success"
case status < 0:
statusMsg = "failure"
default:
statusMsg = "processing"
}
}
return map[string]interface{}{
"scenario": "wiki_move_to_drive",
"task_id": resolvedTaskID,
"ready": status == 0,
"failed": status < 0,
"status": status,
"status_msg": statusMsg,
"obj_token": common.GetString(result, "obj_token"),
"obj_type": common.GetString(result, "obj_type"),
"url": common.GetString(result, "url"),
}, nil
}
// queryWikiDeleteSpaceTask returns the normalized status of an async wiki
// delete-space task. The backend reports a single delete_space_result object
// rather than the per-node array used by wiki move.

View File

@@ -66,6 +66,13 @@ func TestDriveTaskResultValidateErrorsByScenario(t *testing.T) {
},
wantErr: "--task-id is required",
},
{
name: "wiki move to Drive missing task id",
flags: map[string]string{
"scenario": "wiki_move_to_drive",
},
wantErr: "--task-id is required",
},
}
for _, tt := range tests {
@@ -426,13 +433,174 @@ func TestDriveTaskResultWikiMoveIncludesFlattenedNodeFields(t *testing.T) {
}
}
func TestDriveTaskResultDryRunWikiMoveToDriveIncludesTaskTypeParam(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +task_result"}
cmd.Flags().String("scenario", "", "")
cmd.Flags().String("ticket", "", "")
cmd.Flags().String("task-id", "", "")
cmd.Flags().String("file-token", "", "")
if err := cmd.Flags().Set("scenario", "wiki_move_to_drive"); err != nil {
t.Fatalf("set --scenario: %v", err)
}
if err := cmd.Flags().Set("task-id", "raw-task-signature"); err != nil {
t.Fatalf("set --task-id: %v", err)
}
runtime := common.TestNewRuntimeContext(cmd, nil)
dry := DriveTaskResult.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 1 || got.API[0].Params["task_type"] != "move_wiki_to_docs" {
t.Fatalf("wiki move-to-drive dry run = %#v", got.API)
}
}
func TestDriveTaskResultWikiMoveToDriveStatuses(t *testing.T) {
tests := []struct {
name string
status int
statusMsg string
wantReady bool
wantFailed bool
}{
{name: "success", status: 0, statusMsg: "success", wantReady: true},
{name: "processing fallback label", status: 1, wantReady: false},
{name: "failure", status: -1, statusMsg: "failure", wantFailed: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, driveTestConfig())
registry.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/tasks/raw-task-signature",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"task": map[string]interface{}{
// The external handler may omit task.task_id, so the
// result must retain the signed request ID.
"move_wiki_to_docs_result": map[string]interface{}{
"status": tt.status,
"status_msg": tt.statusMsg,
"obj_token": "docxABC",
"obj_type": "docx",
"url": "https://example.feishu.cn/docx/docxABC",
},
},
},
},
})
err := mountAndRunDrive(t, DriveTaskResult, []string{
"+task_result",
"--scenario", "wiki_move_to_drive",
"--task-id", "raw-task-signature",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunDrive() error = %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if data["scenario"] != "wiki_move_to_drive" || data["task_id"] != "raw-task-signature" {
t.Fatalf("unexpected envelope = %#v", data)
}
if data["ready"] != tt.wantReady || data["failed"] != tt.wantFailed {
t.Fatalf("readiness fields = %#v", data)
}
if tt.statusMsg == "" && data["status_msg"] != "processing" {
t.Fatalf("status_msg = %#v, want processing fallback", data["status_msg"])
}
if data["obj_token"] != "docxABC" || data["obj_type"] != "docx" || data["url"] == "" {
t.Fatalf("result fields = %#v", data)
}
})
}
}
func TestDriveTaskResultWikiMoveToDriveRejectsMissingResult(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, driveTestConfig())
registry.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/tasks/raw-task-signature",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task": map[string]interface{}{}},
},
})
err := mountAndRunDrive(t, DriveTaskResult, []string{
"+task_result",
"--scenario", "wiki_move_to_drive",
"--task-id", "raw-task-signature",
"--as", "user",
}, factory, stdout)
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("error = %T %v, want internal/invalid_response", err, err)
}
}
func TestDriveTaskResultWikiMoveToDriveRejectsMalformedStatus(t *testing.T) {
for name, rawStatus := range map[string]interface{}{
"null": nil,
"string": "processing",
"fractional": 0.5,
"unknown value": 2,
} {
t.Run(name, func(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, driveTestConfig())
registry.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/tasks/raw-task-signature",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"task": map[string]interface{}{
"move_wiki_to_docs_result": map[string]interface{}{"status": rawStatus},
},
},
},
})
err := mountAndRunDrive(t, DriveTaskResult, []string{
"+task_result",
"--scenario", "wiki_move_to_drive",
"--task-id", "raw-task-signature",
"--as", "user",
}, factory, stdout)
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("error = %T %v, want internal/invalid_response", err, err)
}
})
}
}
func TestValidateDriveTaskResultScopesWikiScenariosRequireWikiScope(t *testing.T) {
t.Parallel()
// wiki_move, wiki_delete_space and wiki_delete_node all read wiki task
// status, so all must require wiki:space:read. A single table keeps this
// invariant explicit without duplicating near-identical test functions.
for _, scenario := range []string{"wiki_move", "wiki_delete_space", "wiki_delete_node"} {
// Every Wiki scenario reads Wiki task status, so all must require
// wiki:space:read. A single table keeps this invariant explicit without
// duplicating near-identical test functions.
for _, scenario := range []string{"wiki_move", "wiki_move_to_drive", "wiki_delete_space", "wiki_delete_node"} {
t.Run(scenario+"/rejects missing scope", func(t *testing.T) {
t.Parallel()
runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "drive:drive.metadata:readonly")

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// MinutesApplyPermission applies for view or edit permission on a minute.
var MinutesApplyPermission = common.Shortcut{
Service: "minutes",
Command: "+apply-permission",
Description: "Apply for view or edit permission on a minute",
Risk: "write",
Scopes: []string{"minutes:permission:apply"},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "minute-token", Desc: "minute token", Required: true},
{Name: "perm", Desc: "permission to apply for", Required: true, Enum: []string{"view", "edit"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
if minuteToken == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-token is required").WithParam("--minute-token")
}
if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--minute-token")
}
perm := strings.TrimSpace(runtime.Str("perm"))
if perm == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--perm is required").WithParam("--perm")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
return common.NewDryRunAPI().
POST(minutesApplyPermissionPath(minuteToken)).
Body(minutesApplyPermissionBody(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
perm := strings.TrimSpace(runtime.Str("perm"))
_, err := runtime.CallAPITyped(http.MethodPost, minutesApplyPermissionPath(minuteToken), nil, map[string]interface{}{"perm": perm})
if err != nil {
return err
}
runtime.OutFormat(map[string]interface{}{
"minute_token": minuteToken,
"perm": perm,
}, nil, nil)
return nil
},
}
func minutesApplyPermissionPath(minuteToken string) string {
return fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/permissions/apply", validate.EncodePathSegment(minuteToken))
}
func minutesApplyPermissionBody(runtime *common.RuntimeContext) map[string]interface{} {
return map[string]interface{}{
"perm": strings.TrimSpace(runtime.Str("perm")),
}
}

View File

@@ -0,0 +1,192 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)
const minutesApplyPermissionTestToken = "obcnexampleminute"
func TestMinutesApplyPermission_Validate(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "missing minute token",
args: []string{"+apply-permission", "--perm", "view", "--as", "user"},
wantErr: "required flag(s) \"minute-token\" not set",
},
{
name: "missing perm",
args: []string{"+apply-permission", "--minute-token", minutesApplyPermissionTestToken, "--as", "user"},
wantErr: "required flag(s) \"perm\" not set",
},
{
name: "invalid perm",
args: []string{"+apply-permission", "--minute-token", minutesApplyPermissionTestToken, "--perm", "full_access", "--as", "user"},
wantErr: "allowed: view, edit",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "minutes"}
MinutesApplyPermission.Mount(parent, f)
parent.SetArgs(tt.args)
parent.SilenceErrors = true
parent.SilenceUsage = true
err := parent.Execute()
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("error should contain %q, got: %s", tt.wantErr, err.Error())
}
})
}
}
func TestMinutesApplyPermission_ValidateTypedMinuteToken(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
parent := &cobra.Command{Use: "minutes"}
MinutesApplyPermission.Mount(parent, f)
parent.SetArgs([]string{"+apply-permission", "--minute-token", "..", "--perm", "view", "--as", "user"})
parent.SilenceErrors = true
parent.SilenceUsage = true
err := parent.Execute()
if err == nil {
t.Fatalf("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("want *errs.ValidationError, got %T", err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype=%q", ve.Subtype)
}
if ve.Param != "--minute-token" {
t.Errorf("param=%q", ve.Param)
}
}
func TestMinutesApplyPermission_ValidateTypedPerm(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
parent := &cobra.Command{Use: "minutes"}
MinutesApplyPermission.Mount(parent, f)
parent.SetArgs([]string{"+apply-permission", "--minute-token", minutesApplyPermissionTestToken, "--perm", "full_access", "--as", "user"})
parent.SilenceErrors = true
parent.SilenceUsage = true
err := parent.Execute()
if err == nil {
t.Fatalf("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("want *errs.ValidationError, got %T", err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype=%q", ve.Subtype)
}
if ve.Param != "--perm" {
t.Errorf("param=%q", ve.Param)
}
}
func TestMinutesApplyPermission_DryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
err := mountAndRun(t, MinutesApplyPermission, []string{
"+apply-permission",
"--minute-token", minutesApplyPermissionTestToken,
"--perm", "view",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "POST") {
t.Errorf("expected POST method, got:\n%s", out)
}
if !strings.Contains(out, "/open-apis/minutes/v1/minutes/"+minutesApplyPermissionTestToken+"/permissions/apply") {
t.Errorf("expected apply-permission endpoint, got:\n%s", out)
}
if !strings.Contains(out, `"perm": "view"`) && !strings.Contains(out, `"perm":"view"`) {
t.Errorf("expected perm body, got:\n%s", out)
}
}
func TestMinutesApplyPermission_Execute(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
stub := &httpmock.Stub{
Method: http.MethodPost,
URL: "/open-apis/minutes/v1/minutes/" + minutesApplyPermissionTestToken + "/permissions/apply",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRun(t, MinutesApplyPermission, []string{
"+apply-permission",
"--minute-token", minutesApplyPermissionTestToken,
"--perm", "edit",
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var requestBody struct {
Perm string `json:"perm"`
}
if err := json.Unmarshal(stub.CapturedBody, &requestBody); err != nil {
t.Fatalf("unmarshal request body: %v", err)
}
if requestBody.Perm != "edit" {
t.Errorf("request perm = %q, want edit", requestBody.Perm)
}
var envelope struct {
Data struct {
MinuteToken string `json:"minute_token"`
Perm string `json:"perm"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if envelope.Data.MinuteToken != minutesApplyPermissionTestToken {
t.Errorf("data.minute_token = %q, want %q", envelope.Data.MinuteToken, minutesApplyPermissionTestToken)
}
if envelope.Data.Perm != "edit" {
t.Errorf("data.perm = %q, want edit", envelope.Data.Perm)
}
}

View File

@@ -70,7 +70,7 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu
if isMinutesDetailProcessingError(err) {
markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute metadata is still being generated")
} else if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken)
result.Error = fmt.Sprintf("No read permission for minute %s. Ask the user before running: minutes +apply-permission --minute-token %s --perm view", minuteToken, minuteToken)
} else {
result.Error = fmt.Sprintf("failed to query minute: %v", err)
}

View File

@@ -135,7 +135,7 @@ func minutesSpeakerReplaceError(err error, minuteToken, sourceSpeaker string) er
switch p.Code {
case minutesSpeakerReplaceNoEditPermission:
p.Message = fmt.Sprintf("No edit permission for minute %q: cannot replace the transcript speaker.", minuteToken)
p.Hint = "Ask the minute owner for minute edit permission"
p.Hint = fmt.Sprintf("Ask the user before running: minutes +apply-permission --minute-token %s --perm edit", minuteToken)
case minutesSpeakerReplaceSpeakerNotFoundCode:
p.Subtype = errs.SubtypeNotFound
p.Message = fmt.Sprintf("Speaker not found in minute %q: source speaker %q does not match an existing speaker in the transcript.", minuteToken, sourceSpeaker)

View File

@@ -361,7 +361,7 @@ func TestMinutesSpeakerReplace_NoEditPermission(t *testing.T) {
if !strings.Contains(p.Message, minutesSpeakerReplaceTestToken) {
t.Errorf("message should include minute token, got: %s", p.Message)
}
if !strings.Contains(p.Hint, "edit permission") {
t.Errorf("hint should mention edit permission, got: %s", p.Hint)
if !strings.Contains(p.Hint, "+apply-permission") {
t.Errorf("hint should mention apply-permission, got: %s", p.Hint)
}
}

View File

@@ -400,8 +400,8 @@ func TestMinutesTodo_NoEditPermission(t *testing.T) {
if !strings.Contains(p.Message, minutesSummaryTodoTestToken) {
t.Errorf("message should include minute token, got: %s", p.Message)
}
if !strings.Contains(p.Hint, "edit permission") {
t.Errorf("hint should mention edit permission, got: %s", p.Hint)
if !strings.Contains(p.Hint, "+apply-permission") {
t.Errorf("hint should mention apply-permission, got: %s", p.Hint)
}
}

View File

@@ -288,6 +288,6 @@ func minutesTodoError(err error, minuteToken string) error {
}
p.Subtype = errs.SubtypePermissionDenied
p.Message = fmt.Sprintf("No edit permission for minute %q: cannot update todos.", minuteToken)
p.Hint = "Ask the minute owner for minute edit permission"
p.Hint = fmt.Sprintf("Ask the user before running: minutes +apply-permission --minute-token %s --perm edit", minuteToken)
return err
}

View File

@@ -79,6 +79,6 @@ func minutesUpdateError(err error, minuteToken string) error {
return err
}
p.Message = fmt.Sprintf("No edit permission for minute %q: cannot update the title.", minuteToken)
p.Hint = "Ask the minute owner for minute edit permission"
p.Hint = fmt.Sprintf("Ask the user before running: minutes +apply-permission --minute-token %s --perm edit", minuteToken)
return err
}

View File

@@ -171,7 +171,7 @@ func TestMinutesUpdate_NoEditPermission(t *testing.T) {
if !strings.Contains(p.Message, minutesUpdateTestToken) {
t.Errorf("message should include minute token, got: %s", p.Message)
}
if !strings.Contains(p.Hint, "edit permission") {
t.Errorf("hint should mention edit permission, got: %s", p.Hint)
if !strings.Contains(p.Hint, "+apply-permission") {
t.Errorf("hint should mention apply-permission, got: %s", p.Hint)
}
}

View File

@@ -139,7 +139,7 @@ func minutesWordReplaceError(err error, minuteToken string) error {
case minutesWordReplaceNoEditPermission:
p.Subtype = errs.SubtypePermissionDenied
p.Message = fmt.Sprintf("No edit permission for minute %q: cannot replace transcript words.", minuteToken)
p.Hint = "Ask the minute owner for minute edit permission"
p.Hint = fmt.Sprintf("Ask the user before running: minutes +apply-permission --minute-token %s --perm edit", minuteToken)
case minutesWordReplaceOthersEditing:
p.Subtype = errs.SubtypeConflict
p.Message = fmt.Sprintf("Minute %q transcript is being edited by someone else.", minuteToken)

View File

@@ -12,6 +12,7 @@ func Shortcuts() []common.Shortcut {
MinutesDownload,
MinutesUpload,
MinutesUpdate,
MinutesApplyPermission,
MinutesSummary,
MinutesTodo,
MinutesSpeakerReplace,

View File

@@ -0,0 +1,284 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
)
// subOp builds a raw +batch-update sub-op for translateBatchOp tests.
func subOp(shortcut string, input map[string]interface{}) map[string]interface{} {
return map[string]interface{}{"shortcut": shortcut, "input": input}
}
// TestBatchOp_UnknownInputKeyRejected pins the key-vocabulary guard: an
// off-vocabulary sub-op input key must error with a did-you-mean instead of
// being silently ignored (silent ignore surfaced as misleading "missing
// required flag" errors — the top batch error cluster in eval traces).
func TestBatchOp_UnknownInputKeyRejected(t *testing.T) {
t.Parallel()
t.Run("invented key errors with did-you-mean", func(t *testing.T) {
t.Parallel()
_, err := translateBatchOp(subOp("+cells-set", map[string]interface{}{
"sheet_name": "S1",
"rangee": "A1:B2",
"cells": []interface{}{[]interface{}{map[string]interface{}{"value": "x"}}},
}), testToken, 0)
ve := requireValidation(t, err, `unknown input key "rangee"`)
if !strings.Contains(ve.Message, `did you mean "range"`) {
t.Fatalf("message %q missing did-you-mean", ve.Message)
}
if !strings.Contains(ve.Hint, "input keys:") {
t.Fatalf("hint %q missing key contract", ve.Hint)
}
})
t.Run("system flag is not sub-op vocabulary", func(t *testing.T) {
t.Parallel()
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
"sheet_name": "S1",
"range": "A1:B2",
"dry_run": true,
}), testToken, 0)
requireValidation(t, err, `unknown input key "dry_run"`)
})
t.Run("reserved locator in hyphen form still rejected", func(t *testing.T) {
t.Parallel()
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
"sheet_name": "S1",
"range": "A1:B2",
"spreadsheet-token": "shtXXX",
}), testToken, 0)
requireValidation(t, err, "do not pass input.spreadsheet-token")
})
}
// TestBatchOp_HabitualKeysRewritten pins the silent rewrites: camelCase onto
// the declared flag, and the commandFlagAliases table (size → width/height on
// the resize pair — the pre-2026-07 vocabulary and the styles-protocol
// spelling, the single largest sub-op error cluster).
func TestBatchOp_HabitualKeysRewritten(t *testing.T) {
t.Parallel()
t.Run("camelCase sheetName resolves", func(t *testing.T) {
t.Parallel()
translated, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
"sheetName": "S1",
"range": "A1:B2",
}), testToken, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
input := translated["input"].(map[string]interface{})
if input["sheet_name"] != "S1" {
t.Fatalf("sheet_name = %v, want S1", input["sheet_name"])
}
})
t.Run("size aliases to width on +cols-resize", func(t *testing.T) {
t.Parallel()
translated, err := translateBatchOp(subOp("+cols-resize", map[string]interface{}{
"sheet_name": "S1",
"range": "A:C",
"type": "pixel",
"size": float64(120),
}), testToken, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
input := translated["input"].(map[string]interface{})
width, _ := input["resize_width"].(map[string]interface{})
if width["value"] != 120 {
t.Fatalf("resize_width = %v, want value 120", input["resize_width"])
}
})
t.Run("size aliases to height on +rows-resize", func(t *testing.T) {
t.Parallel()
_, err := translateBatchOp(subOp("+rows-resize", map[string]interface{}{
"sheet_name": "S1",
"range": "1:3",
"type": "pixel",
"size": float64(36),
}), testToken, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("single-entry ranges unwraps onto range", func(t *testing.T) {
t.Parallel()
translated, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
"sheet_name": "S1",
"ranges": []interface{}{"A1:B2"},
}), testToken, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
input := translated["input"].(map[string]interface{})
if input["range"] != "A1:B2" {
t.Fatalf("range = %v, want A1:B2", input["range"])
}
})
t.Run("multi-entry ranges prescribes a split", func(t *testing.T) {
t.Parallel()
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
"sheet_name": "S1",
"ranges": []interface{}{"A1:B2", "C1:D2"},
}), testToken, 0)
requireValidation(t, err, "split them into 2 sub-ops")
})
}
// TestBatchOperations_AggregatesValidationErrors pins the one-pass contract:
// several invalid ops come back in a single error (each with its own
// operations[i] context) instead of the first only — eval traces show
// fix-one-resend loops of up to 7 round trips under first-error-only.
func TestBatchOperations_AggregatesValidationErrors(t *testing.T) {
t.Parallel()
t.Run("two bad ops both reported", func(t *testing.T) {
t.Parallel()
_, err := translateBatchOperations([]interface{}{
subOp("+cells-clear", map[string]interface{}{"range": "A1:B2"}), // missing sheet selector
subOp("+cells-set", map[string]interface{}{"sheet_name": "S1", "range": "A1"}), // missing cells
subOp("+cells-clear", map[string]interface{}{"sheet_name": "S1", "range": "A1:B2"}), // valid
}, testToken)
ve := requireValidation(t, err, "2 of 3 operations failed validation")
for _, want := range []string{"operations[0] (+cells-clear)", "operations[1] (+cells-set)", "--cells is required"} {
if !strings.Contains(ve.Message, want) {
t.Fatalf("message %q missing %q", ve.Message, want)
}
}
})
t.Run("single bad op keeps the standalone-shaped error", func(t *testing.T) {
t.Parallel()
_, err := translateBatchOperations([]interface{}{
subOp("+cells-set", map[string]interface{}{"sheet_name": "S1", "range": "A1"}),
}, testToken)
ve := requireValidation(t, err, "--cells is required")
if strings.Contains(ve.Message, "failed validation") {
t.Fatalf("single-error message must not use the aggregate wrapper: %q", ve.Message)
}
})
}
// TestCellsSetInput_MatrixPrecheck pins the local cells-vs-range guard that
// front-runs the server's mid-batch "does not match range" failures.
func TestCellsSetInput_MatrixPrecheck(t *testing.T) {
t.Parallel()
cases := []struct {
name string
input map[string]interface{}
wantContains string // "" = expect success
}{
{
"empty cells prescribes +cells-clear",
map[string]interface{}{"sheet_name": "S1", "range": "A1:B2", "cells": []interface{}{}},
"+cells-clear",
},
{
"row count mismatch",
map[string]interface{}{"sheet_name": "S1", "range": "A1:B3",
"cells": []interface{}{
[]interface{}{map[string]interface{}{"value": "a"}, map[string]interface{}{"value": "b"}},
}},
"has 1 rows but --range \"A1:B3\" spans 3 rows",
},
{
"column count mismatch",
map[string]interface{}{"sheet_name": "S1", "range": "A1:B1",
"cells": []interface{}{
[]interface{}{map[string]interface{}{"value": "a"}},
}},
"has 1 columns but --range \"A1:B1\" spans 2 columns",
},
{
"matching matrix passes",
map[string]interface{}{"sheet_name": "S1", "range": "A1:B2",
"cells": []interface{}{
[]interface{}{map[string]interface{}{"value": "a"}, map[string]interface{}{"value": "b"}},
[]interface{}{map[string]interface{}{"value": "c"}, map[string]interface{}{"value": "d"}},
}},
"",
},
{
"bare single-cell range enforces the 1x1 match (07-21: server rejects anchors too)",
map[string]interface{}{"sheet_name": "S1", "range": "A1",
"cells": []interface{}{
[]interface{}{map[string]interface{}{"value": "a"}, map[string]interface{}{"value": "b"}},
}},
"has 2 columns but --range \"A1\" spans 1 columns",
},
{
"single-cell range with a single cell passes",
map[string]interface{}{"sheet_name": "S1", "range": "B3",
"cells": []interface{}{
[]interface{}{map[string]interface{}{"value": "a"}},
}},
"",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := translateBatchOp(subOp("+cells-set", tc.input), testToken, 0)
if tc.wantContains == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
requireValidation(t, err, tc.wantContains)
})
}
}
// TestFlattenToolErrorMsg_PartialFailureRecovery pins the no-rollback recovery
// prescription appended to server-side "N succeeded, M failed" errors.
func TestFlattenToolErrorMsg_PartialFailureRecovery(t *testing.T) {
t.Parallel()
wrap := func(inner string) string {
return `{"error":` + jsonQuote(inner) + `}`
}
t.Run("single failure prescribes resend-from-index", func(t *testing.T) {
t.Parallel()
msg := flattenToolErrorMsg(wrap(`{"message":"batch_update: 4 succeeded, 1 failed","failures":[{"index":4,"tool_name":"set_cell_range","error":"cells is required"}]}`))
for _, want := range []string{"operations[4] (set_cell_range)", "no rollback", "resend only operations[4:]"} {
if !strings.Contains(msg, want) {
t.Fatalf("msg %q missing %q", msg, want)
}
}
})
t.Run("multiple failures prescribe failed-only resend", func(t *testing.T) {
t.Parallel()
msg := flattenToolErrorMsg(wrap(`{"message":"batch_update: 3 succeeded, 2 failed","failures":[{"index":1,"tool_name":"set_cell_range","error":"e1"},{"index":3,"tool_name":"resize_range","error":"e2"}]}`))
if !strings.Contains(msg, "resend only the failed operations") {
t.Fatalf("msg %q missing failed-only prescription", msg)
}
})
t.Run("zero succeeded gets no note", func(t *testing.T) {
t.Parallel()
msg := flattenToolErrorMsg(wrap(`{"message":"batch_update: 0 succeeded, 1 failed","failures":[{"index":0,"tool_name":"set_cell_range","error":"e"}]}`))
if strings.Contains(msg, "no rollback") {
t.Fatalf("msg %q must not carry the note when nothing was applied", msg)
}
})
}
// jsonQuote wraps s as a JSON string literal (escaping quotes), mirroring how
// the server double-encodes the inner error payload.
func jsonQuote(s string) string {
return `"` + strings.ReplaceAll(strings.ReplaceAll(s, `\`, `\\`), `"`, `\"`) + `"`
}

View File

@@ -763,7 +763,7 @@ func TestBatchOp_SchemaValidatesSubOps(t *testing.T) {
{
"+pivot-create summarize_by out of enum",
"+pivot-create",
`{"sheet-id":"sh1","source":"Sheet1!A1:D100","properties":{"values":[{"field":"A","summarize_by":"BOGUS"}]}}`,
`{"target_sheet_id":"sh1","source":"Sheet1!A1:D100","properties":{"values":[{"field":"A","summarize_by":"BOGUS"}]}}`,
"summarize_by",
},
// +chart-create properties.position.row has minimum:0 — P0

View File

@@ -4,8 +4,11 @@
package sheets
import (
"fmt"
"sort"
"strings"
"github.com/larksuite/cli/internal/suggest"
)
// ─── +batch-update sub-op dispatch ─────────────────────────────────────
@@ -84,7 +87,14 @@ func objDeleteTranslate(spec objectCRUDSpec) batchTranslateFn {
// flag error is identical too (locked by TestBatchOp_ErrorEquivalence).
var batchOpDispatch = map[string]batchOpMapping{
// ─── 单元格内容 ──────────────────────────────────────────────────
"+cells-set": {"set_cell_range", cellsSetInput},
"+cells-set": {"set_cell_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
// The --writes plural form expands into its own atomic batch and
// cannot nest; sub-ops carry one range+cells each.
if fv.Changed("writes") {
return nil, sheetsValidationForFlag("writes", `"writes" is not supported inside +batch-update (it expands into its own atomic batch); call +cells-set --writes standalone, or give each sub-op a single range + cells`)
}
return cellsSetInput(fv, token, sid, sname)
}},
"+cells-set-style": {"set_cell_range", cellsSetStyleInput},
"+cells-clear": {"clear_cell_range", cellsClearInput},
"+cells-replace": {"replace_data", replaceInput},
@@ -102,6 +112,11 @@ var batchOpDispatch = map[string]batchOpMapping{
// ─── 行列结构 (modify_sheet_structure, operation 区分) ──────────
"+dim-insert": {"modify_sheet_structure", dimInsertInput},
"+dim-delete": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
// The --ranges plural form expands into its own atomic batch and
// cannot nest; sub-ops carry one range each.
if fv.Changed("ranges") {
return nil, sheetsValidationForFlag("ranges", `"ranges" is not supported inside +batch-update (it expands into its own atomic batch); call +dim-delete --ranges standalone, or give each sub-op a single "range"`)
}
return dimRangeOpInput(fv, token, sid, sname, "delete")
}},
"+dim-hide": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
@@ -301,6 +316,133 @@ func sheetMoveBatchInput(fv flagView, token, sheetID, sheetName string) (map[str
// +batch-update 顶层 --url/--token 统一提供excel_id / spreadsheet_token / url
var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"}
// wrappedSubOpInputKeys are nested MCP-body container keys that must never
// appear at a sub-op input's top level — their presence means the caller
// pasted a shortcut's structured *output* (e.g. a {"cell_styles":{…}} block)
// where the flattened flag keys belong. None of the batch sub-op translators
// read input under these names, so rejecting them is safe.
var wrappedSubOpInputKeys = []string{"cell_styles", "cell_merges", "styles"}
// subOpKeyVocabulary returns the set of hyphen-canonical flag names a sub-op
// input may carry for `sc`: every non-system flag in flag-defs except the
// spreadsheet locators (reserved for the batch top level). Nil when the
// shortcut has no flag-defs entry (vocabulary checks are then skipped).
func subOpKeyVocabulary(sc string) map[string]bool {
defs, _ := loadFlagDefs()
spec, ok := defs[sc]
if !ok {
return nil
}
vocab := make(map[string]bool, len(spec.Flags))
for _, df := range spec.Flags {
if df.Kind == "system" || df.Name == "url" || df.Name == "spreadsheet-token" {
continue
}
vocab[df.Name] = true
}
return vocab
}
// camelToKebab converts a lowerCamelCase key to its kebab form
// (sheetName → sheet-name). Returns "" when the key carries no uppercase
// letter (nothing to convert).
func camelToKebab(key string) string {
if strings.ToLower(key) == key {
return ""
}
var b strings.Builder
for i, r := range key {
if r >= 'A' && r <= 'Z' {
if i > 0 {
b.WriteByte('-')
}
b.WriteRune(r + ('a' - 'A'))
continue
}
b.WriteRune(r)
}
return b.String()
}
// normalizeSubOpInputKeys validates every sub-op input key against the
// shortcut's flag vocabulary, rewriting habitual spellings in place and
// rejecting anything that matches nothing. Eval traces show unknown keys were
// previously ignored silently, which turned "wrong key" (size for width,
// camelCase sheetName, an invented styles object) into misleading
// "missing required flag" errors downstream — the single largest batch error
// cluster. Rewrites applied, in order:
//
// - underscore ↔ hyphen forms of a declared flag (already tolerated by
// mapFlagView — accepted here as-is)
// - lowerCamelCase → the declared flag (sheetName → sheet_name)
// - the command's intuitive-alias table (size → width/height on the resize
// pair) — the same commandFlagAliases the cobra path applies
// - "ranges" with a single-entry array unwraps onto "range"; a multi-entry
// array gets a split-into-sub-ops prescription instead
//
// Anything else errors with a did-you-mean. Returns a bare error; the caller
// wraps it with the operations[i] (<shortcut>) context and key contract.
func normalizeSubOpInputKeys(sc string, input map[string]interface{}) error {
vocab := subOpKeyVocabulary(sc)
if vocab == nil {
return nil
}
keys := make([]string, 0, len(input))
for k := range input {
keys = append(keys, k)
}
sort.Strings(keys)
aliases := commandFlagAliases[sc]
for _, k := range keys {
hv := strings.ReplaceAll(k, "_", "-")
if vocab[hv] {
continue
}
if kebab := camelToKebab(k); kebab != "" && vocab[kebab] {
input[strings.ReplaceAll(kebab, "-", "_")] = input[k]
delete(input, k)
continue
}
if target, ok := aliases[strings.ToLower(hv)]; ok && vocab[target] {
if _, taken := input[target]; !taken {
if _, taken := input[strings.ReplaceAll(target, "-", "_")]; !taken {
input[target] = input[k]
delete(input, k)
continue
}
}
}
if strings.ToLower(hv) == "ranges" && vocab["range"] && !vocab["ranges"] {
if arr, isArr := input[k].([]interface{}); isArr {
if len(arr) == 1 {
if s, isStr := arr[0].(string); isStr {
input["range"] = s
delete(input, k)
continue
}
}
return fmt.Errorf("%s takes a single \"range\" per sub-op, got %d entries in %q — split them into %d sub-ops (one per range)", sc, len(arr), k, len(arr)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
}
if s, isStr := input[k].(string); isStr {
input["range"] = s
delete(input, k)
continue
}
}
msg := fmt.Sprintf("unknown input key %q", k)
display := make([]string, 0, len(vocab))
for name := range vocab {
display = append(display, strings.ReplaceAll(name, "-", "_"))
}
sort.Strings(display)
if match := suggest.Closest(strings.ToLower(hv), display, 1); len(match) > 0 {
msg += fmt.Sprintf(" — did you mean %q?", match[0])
}
return fmt.Errorf("%s", msg) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
}
return nil
}
// translateBatchOp 把一个 CLI 视角的 {shortcut, input} 翻成底层 MCP
// batch_update 的 {tool_name, input}。`index` 用于错误信息定位。input 用
// shortcut 的 CLI flag 名(连字符/下划线均可),经该 shortcut 的 standalone
@@ -312,6 +454,7 @@ var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"}
// - input 不是 object
// - input 里手填了 operation由 shortcut 名隐含,禁手填以防 mismatch
// - input 里手填了 excel_id / spreadsheet_token / url
// - input 顶层出现 cell_styles / cell_merges / styles误贴 MCP body 包裹结构)
// - 子操作的 translator 报错(如缺必填字段)
func translateBatchOp(raw interface{}, token string, index int) (map[string]interface{}, error) {
op, ok := raw.(map[string]interface{})
@@ -335,7 +478,7 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
return nil, sheetsValidationForFlag(
"operations",
"operations[%d]: shortcut %q not allowed in +batch-update "+
"(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded)",
"(read ops / fan-out wrappers like +batch-update / +styles-put / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded)",
index, sc,
).WithHint("allowed shortcuts: %s", strings.Join(allowedBatchShortcuts(), ", "))
}
@@ -358,11 +501,30 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
)
}
// 禁在 sub-op 重复填 spreadsheet 定位 —— 由 +batch-update 顶层 --url/--token 统一提供。
for _, k := range reservedSubOpKeys {
// 连字符 / 下划线两种写法都算命中spreadsheet-token 与 spreadsheet_token 同罪)。
for userKey := range input {
normalized := strings.ReplaceAll(userKey, "-", "_")
for _, k := range reservedSubOpKeys {
if normalized == k {
return nil, sheetsValidationForFlag(
"operations",
"operations[%d] (%s): do not pass input.%s — it is already set from +batch-update top-level --url / --token",
index, sc, userKey,
)
}
}
}
// Reject a "wrapped structure" sub-op input: agents copy a shortcut's nested
// output container (e.g. +workbook-create --styles' {"cell_styles":{…}}) into
// the op input, but the op input is the shortcut's own flags flattened into
// JSON keys, not that wrapper. Left unflagged this surfaces far downstream as
// an unrelated "at least one style flag is required" (helpers.go), which never
// points at the real mistake.
for _, k := range wrappedSubOpInputKeys {
if _, has := input[k]; has {
return nil, sheetsValidationForFlag(
"operations",
"operations[%d] (%s): do not pass input.%s — it is already set from +batch-update top-level --url / --token",
`operations[%d] (%s): op input is the shortcut's flags flattened as JSON keys (e.g. "background_color": "#EBF1F8"); do not wrap in %s`,
index, sc, k,
)
}
@@ -373,6 +535,16 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k)
}
}
// Reject / rewrite off-vocabulary input keys BEFORE any value reads: an
// unknown key silently ignored surfaces later as a misleading
// "missing required flag" error (the top batch error cluster in evals).
if err := normalizeSubOpInputKeys(sc, input); err != nil {
verr := sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err)
if contract := subOpInputContract(sc); contract != "" {
verr = verr.WithHint("%s input keys: %s", sc, contract)
}
return nil, verr
}
fv := newMapFlagViewForCommand(sc, input)
// operations is skipped by parse-time schema validation, so type-check the
// sub-op's scalar fields here before the translator reads them via
@@ -410,7 +582,14 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
// matrix, on the operations axis.
const maxBatchOperations = 100
// translateBatchOperations 翻译整个 ops 数组fail-fast遇错立即返回。
// batchOpErrorDisplayLimit bounds how many per-op validation failures ride
// on one aggregated --operations error, mirroring the schema validator's
// display cap.
const batchOpErrorDisplayLimit = 5
// translateBatchOperations 翻译整个 ops 数组。逐 op 校验并**收集全部失败**
// 一次性返回(不再 fail-fast——agent 一轮就能修完所有坏 op而不是
// 修一个、重试、再撞下一个。cell 安全上限仍是全局判定,命中即返回。
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
if len(rawOps) == 0 {
return nil, sheetsValidationForFlag("operations", "--operations must be a non-empty JSON array")
@@ -422,10 +601,15 @@ func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}
}
out := make([]interface{}, 0, len(rawOps))
var totalCells int64
var opErrs []error
for i, raw := range rawOps {
translated, err := translateBatchOp(raw, token, i)
if err != nil {
return nil, err
opErrs = append(opErrs, err)
continue
}
if len(opErrs) > 0 {
continue // already failing — keep scanning for more bad ops, skip cell math.
}
totalCells += translatedCellCount(translated)
if totalCells > maxStampMatrixCells {
@@ -435,7 +619,27 @@ func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}
}
out = append(out, translated)
}
return out, nil
switch len(opErrs) {
case 0:
return out, nil
case 1:
return nil, opErrs[0] // single failure keeps the historical error byte-for-byte.
}
shown := opErrs
truncated := false
if len(shown) > batchOpErrorDisplayLimit {
shown = shown[:batchOpErrorDisplayLimit]
truncated = true
}
parts := make([]string, 0, len(shown))
for i, e := range shown {
parts = append(parts, fmt.Sprintf("%d) %s", i+1, e.Error()))
}
msg := fmt.Sprintf("%d of %d operations failed validation: %s", len(opErrs), len(rawOps), strings.Join(parts, "; "))
if truncated {
msg += fmt.Sprintf("; (%d more not shown — fix these first)", len(opErrs)-batchOpErrorDisplayLimit)
}
return nil, sheetsValidationForFlag("operations", "%s", msg).WithCause(opErrs[0])
}
func translatedCellCount(op map[string]interface{}) int64 {

View File

@@ -0,0 +1,113 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
)
// TestCellsSetWrites pins the --writes plural form: scattered (cross-sheet)
// regions fan into ONE atomic batch_update, each item self-carrying its
// sheet selector (no top-level fallback — same convention as +batch-update
// sub-ops and +styles-put items), with per-item errors aggregated.
func TestCellsSetWrites(t *testing.T) {
t.Parallel()
writes := func(items string, extra ...string) (string, string, error) {
args := append([]string{
"--url", testURL, "--dry-run", "--writes", items,
}, extra...)
return runShortcutCapturingErr(t, CellsSet, args)
}
t.Run("cross-sheet items expand into one batch", func(t *testing.T) {
t.Parallel()
stdout, _, err := writes(`[
{"sheet_name":"明细","range":"D5","cells":[[{"formula":"=IFERROR(C5/B5,0)"}]]},
{"sheet_name":"汇总","range":"B3","cells":[[{"formula":"=SUM(C:C)"}]]}
]`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, want := range []string{"batch_update", "明细", "汇总", "IFERROR"} {
if !strings.Contains(stdout, want) {
t.Fatalf("dry-run body missing %q: %s", want, stdout[:min(len(stdout), 400)])
}
}
})
t.Run("item without sheet selector errors", func(t *testing.T) {
t.Parallel()
_, _, err := writes(`[{"range":"A1","cells":[[{"value":"x"}]]}]`)
requireValidation(t, err, "sheet-id or --sheet-name")
})
t.Run("top-level sheet selector rejected with prescription", func(t *testing.T) {
t.Parallel()
_, _, err := writes(`[{"sheet_name":"S1","range":"A1","cells":[[{"value":"x"}]]}]`,
"--sheet-name", "S1")
requireValidation(t, err, "put sheet_name (or sheet_id) inside each writes item")
})
t.Run("writes and range are mutually exclusive", func(t *testing.T) {
t.Parallel()
_, _, err := writes(`[{"sheet_name":"S1","range":"A1","cells":[[{"value":"x"}]]}]`,
"--range", "A1")
requireValidation(t, err, "mutually exclusive")
})
t.Run("per-item errors aggregate", func(t *testing.T) {
t.Parallel()
// Both items pass the --writes schema (range+cells present) but fail
// deeper: item 0 a matrix mismatch, item 1 a missing sheet selector.
_, _, err := writes(`[
{"sheet_name":"S1","range":"A1:B2","cells":[[{"value":"x"}]]},
{"range":"C1","cells":[[{"value":"y"}]]}
]`)
ve := requireValidation(t, err, "--writes has 2 issues")
for _, want := range []string{"--writes[0]", "--writes[1]", "sheet-name"} {
if !strings.Contains(ve.Message, want) {
t.Fatalf("message %q missing %q", ve.Message, want)
}
}
})
t.Run("item keys go through the vocabulary layer", func(t *testing.T) {
t.Parallel()
stdout, _, err := writes(`[{"sheetName":"S1","range":"A1","cells":[[{"value":"x"}]]}]`)
if err != nil {
t.Fatalf("camelCase sheetName must normalize: %v", err)
}
if !strings.Contains(stdout, "S1") {
t.Fatalf("normalized item missing sheet: %s", stdout[:min(len(stdout), 300)])
}
})
t.Run("cannot nest inside batch-update", func(t *testing.T) {
t.Parallel()
_, err := translateBatchOp(subOp("+cells-set", map[string]interface{}{
"writes": []interface{}{map[string]interface{}{
"sheet_name": "S1", "range": "A1", "cells": []interface{}{[]interface{}{map[string]interface{}{"value": "x"}}},
}},
}), testToken, 0)
requireValidation(t, err, "not supported inside +batch-update")
})
t.Run("styles flag gets the layering prescription", func(t *testing.T) {
t.Parallel()
// Ergonomics (FlagErrorFunc hints) mount via the registry, not the
// bare shortcut var — mirror the real CLI wiring.
sc := shortcutFromRegistry(t, "+cells-set")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL, "--dry-run",
"--writes", `[{"sheet_name":"S1","range":"A1","cells":[[{"value":"x"}]]}]`,
"--styles", `{"styles":[]}`,
})
ve := requireValidation(t, err, "unknown flag")
if !strings.Contains(ve.Hint, "+styles-put") || !strings.Contains(ve.Hint, "cell_styles") {
t.Fatalf("want the styles-put layering hint, got hint=%q", ve.Hint)
}
})
}

View File

@@ -0,0 +1,150 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"fmt"
"sort"
"strings"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// ─── +chart-create --print-example ─────────────────────────────────────
//
// chart-create's --properties schema is ~1,750 pretty-printed lines; eval
// traces show agents paging through the full --print-schema dump for every
// chart (25 round trips in one 35-task batch) and still missing deep
// required fields. A ready-to-edit minimal template per chart type answers
// the actual question ("what does a valid payload look like") in one local
// call. Wired through PostMount, same pattern as +csv-put's flag-group
// tweaks — no framework change.
//
// Templates mirror the canonical examples in the lark-sheets-chart
// reference (sheet-skill-spec canonical-spec/references/lark_sheet_chart):
// inline headerMode with refs covering the header row, 1-based indices,
// quoted sheet prefix in refs.
var chartExampleTemplates = map[string]string{
"column": chartSimpleExample("column"),
"bar": chartSimpleExample("bar"),
"line": chartSimpleExample("line"),
"area": chartSimpleExample("area"),
"radar": chartSimpleExample("radar"),
"scatter": `{
"position": {"row": 1, "col": "F"},
"size": {"width": 600, "height": 400},
"snapshot": {
"title": {"text": "图表标题"},
"plotArea": {"plot": {"type": "scatter"}},
"data": {
"refs": [{"value": "'Sheet1'!A1:B20"}],
"dim1": {"serie": {"index": 1}},
"dim2": {"series": [{"index": 2}]}
}
}
}`,
"pie": `{
"position": {"row": 1, "col": "F"},
"size": {"width": 600, "height": 450},
"snapshot": {
"title": {"text": "占比标题"},
"plotArea": {"plot": {
"type": "pie",
"series": [{
"index": 1,
"sectors": {"sector": [{"index": 1, "offsetRadius": 0.05}]}
}]
}},
"data": {
"refs": [{"value": "'Sheet1'!A1:B11"}],
"dim1": {"serie": {"index": 1, "aggregate": true}},
"dim2": {"series": [{"index": 2, "aggregateType": "sum"}]}
}
}
}`,
"combo": `{
"position": {"row": 1, "col": "F"},
"size": {"width": 700, "height": 400},
"snapshot": {
"title": {"text": "柱线组合"},
"plotArea": {"plot": {
"type": "combo",
"series": [
{"index": 2, "comboType": "column"},
{"index": 3, "comboType": "line"}
]
}},
"data": {
"refs": [{"value": "'Sheet1'!A1:C13"}],
"dim1": {"serie": {"index": 1}},
"dim2": {"series": [{"index": 2}, {"index": 3}]}
}
}
}`,
}
// chartSimpleExample renders the shared minimal shape for plot types that
// need nothing beyond plot.type (column / bar / line / area / radar).
func chartSimpleExample(typ string) string {
return fmt.Sprintf(`{
"position": {"row": 1, "col": "F"},
"size": {"width": 600, "height": 400},
"snapshot": {
"title": {"text": "图表标题"},
"plotArea": {"plot": {"type": %q}},
"data": {
"refs": [{"value": "'Sheet1'!A1:C10"}],
"dim1": {"serie": {"index": 1}},
"dim2": {"series": [{"index": 2}, {"index": 3}]}
}
}
}`, typ)
}
func chartExampleTypes() []string {
types := make([]string, 0, len(chartExampleTemplates))
for t := range chartExampleTemplates {
types = append(types, t)
}
sort.Strings(types)
return types
}
// withChartPrintExample wraps +chart-create's PostMount so the command grows
// a --print-example flag that short-circuits execution and prints a minimal
// ready-to-edit --properties template — purely local, no identity or
// network. --properties' cobra-level required annotation is relaxed (the
// input builder still enforces it on the real path, same trick as
// +csv-put's --csv).
func withChartPrintExample(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
return func(cmd *cobra.Command) {
if prev != nil {
prev(cmd)
}
cmd.Flags().String("print-example", "",
"Print a minimal ready-to-edit --properties template for a chart type ("+strings.Join(chartExampleTypes(), "|")+") and exit")
// Only --properties carries a cobra-level required annotation (the
// locator flags are xor pairs, enforced later); the input builder
// still errors "--properties is required" on the real path.
if fl := cmd.Flags().Lookup("properties"); fl != nil {
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
}
prevRunE := cmd.RunE
cmd.RunE = func(c *cobra.Command, args []string) error {
typ, _ := c.Flags().GetString("print-example")
if typ == "" {
return prevRunE(c, args)
}
tmpl, ok := chartExampleTemplates[typ]
if !ok {
return common.ValidationErrorf("no example for chart type %q; available: %s",
typ, strings.Join(chartExampleTypes(), ", ")).WithParam("--print-example")
}
fmt.Fprintln(c.OutOrStdout(), tmpl)
return nil
}
}
}

View File

@@ -0,0 +1,63 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"bytes"
"encoding/json"
"strings"
"testing"
)
// TestChartPrintExample pins the --print-example contract: a known type
// prints its template and skips execution entirely; an unknown type lists
// the available ones.
func TestChartPrintExample(t *testing.T) {
t.Parallel()
t.Run("prints template without locator flags", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+chart-create")
parent, _, _, _ := newTestRig(t, sc)
var buf bytes.Buffer
parent.SetOut(&buf) // --print-example writes via cobra's OutOrStdout
parent.SetArgs([]string{sc.Command, "--print-example", "pie"})
if err := parent.Execute(); err != nil {
t.Fatalf("print-example should run standalone, got: %v", err)
}
if !strings.Contains(buf.String(), `"sectors"`) {
t.Errorf("pie template should carry sectors, got %q", buf.String())
}
})
t.Run("unknown type lists available", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+chart-create")
_, _, err := runShortcutCapturingErr(t, sc, []string{"--print-example", "donut"})
ve := requireValidation(t, err, `no example for chart type "donut"`)
if !strings.Contains(ve.Message, "pie") {
t.Errorf("message should list available types, got %q", ve.Message)
}
})
}
// TestChartExampleTemplates_ValidateAgainstSchema drift-guards every
// template against the embedded chart-create properties schema — a template
// the CLI itself would reject is worse than none.
func TestChartExampleTemplates_ValidateAgainstSchema(t *testing.T) {
t.Parallel()
for typ, tmpl := range chartExampleTemplates {
t.Run(typ, func(t *testing.T) {
t.Parallel()
var v interface{}
if err := json.Unmarshal([]byte(tmpl), &v); err != nil {
t.Fatalf("template is not valid JSON: %v", err)
}
fv := newMapFlagViewForCommand("+chart-create", map[string]interface{}{"properties": v})
if err := validateValueAgainstSchema(fv, "properties", v); err != nil {
t.Errorf("template rejected by embedded schema: %v", err)
}
})
}
}

View File

@@ -821,12 +821,10 @@
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Style inheritance for the new row/column: `before` (from preceding) / `after` (from following) / `none` (default)",
"default": "none",
"desc": "Style inheritance for the new row/column: `before` (from the preceding row/column) / `after` (from the following row/column). Omit the flag to inherit the following row/column (same as `after`) — the backend cannot leave a new row/column unstyled; for a truly blank row/column, clear formats afterwards with +cells-clear --scope formats. Insertion always lands before `--position`; this only selects which side's style is copied.",
"enum": [
"before",
"after",
"none"
"after"
]
},
{
@@ -887,8 +885,19 @@
"name": "range",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`"
"required": "xor",
"desc": "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`. XOR with `--ranges`"
},
{
"name": "ranges",
"kind": "own",
"type": "string",
"required": "xor",
"desc": "Multiple row/column ranges to delete as a JSON array (up to 100 items, e.g. `[\"5:5\",\"8:8\",\"11:13\"]` or `[\"C:C\",\"F:G\"]`); rows and columns cannot be mixed, ranges must not overlap; XOR with `--range`. CLI sorts positions in DESCENDING order into one atomic batch delete — ascending deletion would shift later indexes as earlier rows/columns disappear; the CLI handles the ordering",
"input": [
"file",
"stdin"
]
},
{
"name": "yes",
@@ -1277,13 +1286,14 @@
"kind": "own",
"type": "string_slice",
"required": "optional",
"desc": "Comma-separated info categories to include",
"desc": "Comma-separated info categories to include. `truncation` additionally estimates whether each cell's content is clipped (by row height / col width / font size / wrap) and returns `isRowTruncated` / `isColTruncated` (extra compute; enable only for layout checks or before adjusting row heights / column widths)",
"enum": [
"value",
"formula",
"style",
"comment",
"data_validation"
"data_validation",
"truncation"
]
},
{
@@ -1291,15 +1301,29 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more",
"desc": "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (auto-unlimited); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more.",
"default": "500000"
},
{
"name": "output-path",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."
},
{
"name": "skip-hidden",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "Skip hidden rows and columns; default `false`"
"desc": "Skip hidden or collapsed rows and columns. Default `false`; when `--skip-filter` is omitted, filtered-out rows follow this value for backward compatibility"
},
{
"name": "skip-filter",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "Skip filtered-out rows. When omitted, inherits `--skip-hidden`; explicitly set to `false` to keep filtered-out rows while skipping hidden rows and columns"
},
{
"name": "dry-run",
@@ -1392,17 +1416,24 @@
"name": "range",
"kind": "own",
"type": "string",
"required": "required",
"desc": "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"
"required": "optional",
"desc": "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet). Optional: when omitted the whole sheet is read (clipped to the actual grid bounds; actual_range in the response names what was read); pair with --max-chars / --output-path on large sheets"
},
{
"name": "max-chars",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more",
"desc": "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (auto-unlimited); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more.",
"default": "500000"
},
{
"name": "output-path",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."
},
{
"name": "include-row-prefix",
"kind": "own",
@@ -1465,6 +1496,21 @@
"required": "optional",
"desc": "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"
},
{
"name": "max-chars",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max output chars per call; default 500000 (safety cap). The underlying tool truncates at ~50000 even when unset, so this is sent explicitly to raise it; for a full untruncated read use --output-path (auto-unlimited).",
"default": "500000"
},
{
"name": "output-path",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."
},
{
"name": "no-header",
"kind": "own",
@@ -1691,33 +1737,44 @@
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
"desc": "Sheet reference_id (XOR with `--sheet-name`); not accepted with `--writes` (each writes item carries its own sheet selector)"
},
{
"name": "sheet-name",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet name (XOR with `--sheet-id`)"
"desc": "Sheet name (XOR with `--sheet-id`); not accepted with `--writes` (each writes item carries its own sheet selector)"
},
{
"name": "range",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Write range (A1 notation)"
"required": "xor",
"desc": "Write range (A1 notation). XOR with `--writes` (single region: --range+--cells; multiple regions: --writes)"
},
{
"name": "cells",
"kind": "own",
"type": "string",
"required": "required",
"required": "xor",
"desc": "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields",
"input": [
"file",
"stdin"
]
},
{
"name": "writes",
"kind": "own",
"type": "string",
"required": "xor",
"desc": "Multi-region write as a JSON array (up to 100 items), each `{sheet_name|sheet_id, range, cells}` — the sheet selector LIVES IN EACH ITEM (same convention as +batch-update sub-ops and +styles-put items; the top-level --sheet-name is rejected). cells has the same shape as `--cells` (2D array; per-cell cell_styles/border_styles allowed). The whole array goes out as ONE atomic batched request, cross-sheet supported; typical use: fixing formulas scattered across ranges/sheets — do not assemble a +batch-update operations array for this. XOR with `--range`+`--cells`; range-level uniform styling stays with +styles-put afterwards",
"input": [
"file",
"stdin"
]
},
{
"name": "allow-overwrite",
"kind": "own",
@@ -2787,6 +2844,43 @@
}
]
},
"+styles-put": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator (target sheets are named inside --styles items)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "styles",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Visual spec JSON applied to an EXISTING spreadsheet: top-level `{styles:[...]}`, one item per target sheet (`name` is the real sheet name), each giving at least one of `cell_styles` / `cell_merges` / `row_sizes` / `col_sizes` / `freeze`. The vocabulary is identical to `--styles` on `+workbook-create` / `+table-put` (cell_styles = A1 range + flat style fields, borders via the `border` shorthand {style,weight,color} applied to all four sides — border_styles only for per-side differences; row/col sizes = row/column range + size in px — type only for standard/auto; merges = cell range; freeze = `{rows:N, cols:N}`). The whole spec expands into one atomic batched request; ranges may target any region of the sheet",
"input": [
"file",
"stdin"
]
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": "Print the batched request template for each expanded operation; no network side effects"
}
]
},
"+batch-update": {
"risk": "high-risk-write",
"flags": [
@@ -2809,7 +2903,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Strict transaction by default, pass --continue-on-error for soft batch; no nesting; executed serially.",
"desc": "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Fail-fast by default: the first failure aborts the remaining operations and already-applied sub-operations are NOT rolled back (on \"N succeeded, M failed\" resend only the failed tail, not the whole batch); pass --continue-on-error to keep going past failures; no nesting; executed serially.",
"input": [
"file",
"stdin"

View File

@@ -648,6 +648,35 @@
}
}
}
},
"writes": {
"type": "array",
"description": "多区域写入项数组(最多 100 项),整批单次原子提交;支持跨 sheet。",
"items": {
"type": "object",
"required": [
"range",
"cells"
],
"properties": {
"sheet_id": {
"type": "string",
"description": "目标子表 reference_id与 sheet_name 二选一,必须写在每一项里(不认顶层 sheet 定位)。"
},
"sheet_name": {
"type": "string",
"description": "目标子表名;与 sheet_id 二选一,必须写在每一项里。"
},
"range": {
"type": "string",
"description": "A1 矩形范围,行列维度必须与 cells 严格一致(同 --range。"
},
"cells": {
"type": "array",
"description": "二维单元格数组,结构同 --cellsvalue / formula / cell_styles / border_styles 等,见 set_cell_range#/properties/cells。"
}
}
}
}
},
"+cells-set-style": {
@@ -7748,87 +7777,7 @@
}
}
},
"+table-put": {
"sheets": {
"type": "array",
"minItems": 1,
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。每个数组项的形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`,并显式带上目标子表名 `name`。pandas 来源直接用 `scripts/sheets_df.py` 的 `df_to_sheet(df, name)` 生成一项,再把 list 包到 `{\"sheets\":[...]}`。",
"items": {
"type": "object",
"required": [
"name",
"columns",
"data"
],
"properties": {
"name": {
"type": "string",
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
},
"start_cell": {
"type": "string",
"default": "A1",
"description": "写入起点单元格A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
},
"mode": {
"type": "string",
"enum": [
"overwrite",
"append"
],
"default": "overwrite",
"description": "overwrite默认从 start_cell 起写「表头 + 数据」块append把数据追加到子表已有数据下方默认不重复表头。"
},
"header": {
"type": "boolean",
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false避免在已有表头下重复显式给值可覆盖。"
},
"allow_overwrite": {
"type": "boolean",
"default": true,
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success。默认 true。"
},
"columns": {
"type": "array",
"minItems": 1,
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
"items": {
"type": "string"
}
},
"data": {
"type": "array",
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本null 表示空单元格。",
"items": {
"type": "array",
"items": {
"type": [
"string",
"number",
"boolean",
"null"
],
"description": "单元格值date→ISO yyyy-mm-dd 字符串number→数值json.Number 精度保留bool→布尔string→文本null→空单元格。"
}
}
},
"dtypes": {
"type": "object",
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 objectstring + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number精度保留`bool` / `boolean` → bool`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`数字样字符串如「00123」不会塌缩成数字。",
"additionalProperties": {
"type": "string"
}
},
"formats": {
"type": "object",
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等。percent 列的数值尺度由调用方负责0.0469 配 `0.00%` 显示 4.69%)。",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"+styles-put": {
"styles": {
"items": {
"properties": {
@@ -7856,12 +7805,16 @@
"type": "array"
},
"cell_styles": {
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。加边框优先用 border 简写;只有分侧不同样式才用 border_styles 完整形态。",
"items": {
"properties": {
"background_color": {
"type": "string"
},
"border": {
"description": "边框简写(推荐):{style, weight, color} 应用到四边(如 {\"style\":\"solid\",\"color\":\"#DDDDDD\"});也接受侧键形态 {top:{…},bottom:{…}}。分侧不同样式用 border_styles 完整形态。",
"type": "object"
},
"border_styles": {
"type": "object",
"description": "边框配置,结构同 +cells-set-style --border-styles。",
@@ -8055,7 +8008,7 @@
"type": "array"
},
"col_sizes": {
"description": "列宽操作数组range 使用列范围如 A:Ctype 为 pixel/standardpixel 需要 size。",
"description": "列宽操作数组range 使用列范围如 A:C给 sizepx即像素列宽type 可省略);type 为 standard 时不带 size。",
"items": {
"properties": {
"range": {
@@ -8073,19 +8026,32 @@
}
},
"required": [
"range",
"type"
"range"
],
"type": "object"
},
"type": "array"
},
"freeze": {
"description": "冻结行列rows = 冻结前 N 行cols = 冻结前 N 列0 或省略 = 该维度不冻结)。",
"properties": {
"cols": {
"minimum": 0,
"type": "integer"
},
"rows": {
"minimum": 0,
"type": "integer"
}
},
"type": "object"
},
"name": {
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1其 name 会被忽略)。",
"type": "string"
},
"row_sizes": {
"description": "行高操作数组range 使用行范围如 1:3type 为 pixel/standard/autopixel 需要 size。",
"description": "行高操作数组range 使用行范围如 1:3给 sizepx即像素行高type 可省略);type 为 standard/auto 时不带 size。",
"items": {
"properties": {
"range": {
@@ -8104,8 +8070,395 @@
}
},
"required": [
"range",
"type"
"range"
],
"type": "object"
},
"type": "array"
}
},
"required": [
"name"
],
"type": "object"
},
"type": "array"
}
},
"+table-put": {
"sheets": {
"type": "array",
"minItems": 1,
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。每个数组项的形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`,并显式带上目标子表名 `name`。pandas 来源直接用 `scripts/sheets_df.py` 的 `df_to_sheet(df, name)` 生成一项,再把 list 包到 `{\"sheets\":[...]}`。",
"items": {
"type": "object",
"required": [
"name",
"columns",
"data"
],
"properties": {
"name": {
"type": "string",
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
},
"start_cell": {
"type": "string",
"default": "A1",
"description": "写入起点单元格A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
},
"mode": {
"type": "string",
"enum": [
"overwrite",
"append"
],
"default": "overwrite",
"description": "overwrite默认从 start_cell 起写「表头 + 数据」块append把数据追加到子表已有数据下方默认不重复表头。"
},
"header": {
"type": "boolean",
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false避免在已有表头下重复显式给值可覆盖。"
},
"allow_overwrite": {
"type": "boolean",
"default": true,
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success。默认 true。"
},
"columns": {
"type": "array",
"minItems": 1,
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
"items": {
"type": "string"
}
},
"data": {
"type": "array",
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本null 表示空单元格。",
"items": {
"type": "array",
"items": {
"type": [
"string",
"number",
"boolean",
"null"
],
"description": "单元格值date→ISO yyyy-mm-dd 字符串number→数值json.Number 精度保留bool→布尔string→文本null→空单元格。"
}
}
},
"dtypes": {
"type": "object",
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 objectstring + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number精度保留`bool` / `boolean` → bool`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`数字样字符串如「00123」不会塌缩成数字。",
"additionalProperties": {
"type": "string"
}
},
"formats": {
"type": "object",
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等。percent 列的数值尺度由调用方负责0.0469 配 `0.00%` 显示 4.69%)。",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"styles": {
"items": {
"properties": {
"cell_merges": {
"description": "单元格合并操作数组range 使用 A1 单元格范围merge_type 默认 all。",
"items": {
"properties": {
"merge_type": {
"enum": [
"all",
"rows",
"columns"
],
"type": "string"
},
"range": {
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
"type": "array"
},
"cell_styles": {
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。加边框优先用 border 简写;只有分侧不同样式才用 border_styles 完整形态。",
"items": {
"properties": {
"background_color": {
"type": "string"
},
"border": {
"description": "边框简写(推荐):{style, weight, color} 应用到四边(如 {\"style\":\"solid\",\"color\":\"#DDDDDD\"});也接受侧键形态 {top:{…},bottom:{…}}。分侧不同样式用 border_styles 完整形态。",
"type": "object"
},
"border_styles": {
"type": "object",
"description": "边框配置,结构同 +cells-set-style --border-styles。",
"properties": {
"bottom": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"left": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"right": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"top": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
}
}
},
"font_color": {
"type": "string"
},
"font_family": {
"type": "string"
},
"font_line": {
"enum": [
"none",
"underline",
"line-through"
],
"type": "string"
},
"font_size": {
"type": "number"
},
"font_style": {
"enum": [
"normal",
"italic"
],
"type": "string"
},
"font_weight": {
"enum": [
"normal",
"bold"
],
"type": "string"
},
"horizontal_alignment": {
"enum": [
"left",
"center",
"right"
],
"type": "string"
},
"number_format": {
"type": "string"
},
"range": {
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
"type": "string"
},
"vertical_alignment": {
"enum": [
"top",
"middle",
"bottom"
],
"type": "string"
},
"word_wrap": {
"enum": [
"overflow",
"auto-wrap",
"word-clip"
],
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
"type": "array"
},
"col_sizes": {
"description": "列宽操作数组range 使用列范围如 A:C给 sizepx即像素列宽type 可省略type 为 standard 时不带 size。",
"items": {
"properties": {
"range": {
"type": "string"
},
"size": {
"type": "number"
},
"type": {
"enum": [
"pixel",
"standard"
],
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
"type": "array"
},
"freeze": {
"description": "冻结行列rows = 冻结前 N 行cols = 冻结前 N 列0 或省略 = 该维度不冻结)。",
"properties": {
"cols": {
"minimum": 0,
"type": "integer"
},
"rows": {
"minimum": 0,
"type": "integer"
}
},
"type": "object"
},
"name": {
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1其 name 会被忽略)。",
"type": "string"
},
"row_sizes": {
"description": "行高操作数组range 使用行范围如 1:3给 sizepx即像素行高type 可省略type 为 standard/auto 时不带 size。",
"items": {
"properties": {
"range": {
"type": "string"
},
"size": {
"type": "number"
},
"type": {
"enum": [
"pixel",
"standard",
"auto"
],
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
@@ -8228,12 +8581,16 @@
"type": "array"
},
"cell_styles": {
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。加边框优先用 border 简写;只有分侧不同样式才用 border_styles 完整形态。",
"items": {
"properties": {
"background_color": {
"type": "string"
},
"border": {
"description": "边框简写(推荐):{style, weight, color} 应用到四边(如 {\"style\":\"solid\",\"color\":\"#DDDDDD\"});也接受侧键形态 {top:{…},bottom:{…}}。分侧不同样式用 border_styles 完整形态。",
"type": "object"
},
"border_styles": {
"type": "object",
"description": "边框配置,结构同 +cells-set-style --border-styles。",
@@ -8427,7 +8784,7 @@
"type": "array"
},
"col_sizes": {
"description": "列宽操作数组range 使用列范围如 A:Ctype 为 pixel/standardpixel 需要 size。",
"description": "列宽操作数组range 使用列范围如 A:C给 sizepx即像素列宽type 可省略);type 为 standard 时不带 size。",
"items": {
"properties": {
"range": {
@@ -8445,19 +8802,32 @@
}
},
"required": [
"range",
"type"
"range"
],
"type": "object"
},
"type": "array"
},
"freeze": {
"description": "冻结行列rows = 冻结前 N 行cols = 冻结前 N 列0 或省略 = 该维度不冻结)。",
"properties": {
"cols": {
"minimum": 0,
"type": "integer"
},
"rows": {
"minimum": 0,
"type": "integer"
}
},
"type": "object"
},
"name": {
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1其 name 会被忽略)。",
"type": "string"
},
"row_sizes": {
"description": "行高操作数组range 使用行范围如 1:3type 为 pixel/standard/autopixel 需要 size。",
"description": "行高操作数组range 使用行范围如 1:3给 sizepx即像素行高type 可省略);type 为 standard/auto 时不带 size。",
"items": {
"properties": {
"range": {
@@ -8476,8 +8846,7 @@
}
},
"required": [
"range",
"type"
"range"
],
"type": "object"
},

View File

@@ -16,7 +16,7 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
{Name: "operations", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Strict transaction by default, pass --continue-on-error for soft batch; no nesting; executed serially.", Input: []string{"file", "stdin"}},
{Name: "operations", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Fail-fast by default: the first failure aborts the remaining operations and already-applied sub-operations are NOT rolled back (on \"N succeeded, M failed\" resend only the failed tail, not the whole batch); pass --continue-on-error to keep going past failures; no nesting; executed serially.", Input: []string{"file", "stdin"}},
{Name: "continue-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "Continue with remaining operations when a sub-operation fails; default false (abort on first failure)"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template for each sub-operation; no network side effects"},
@@ -75,9 +75,11 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include", Enum: []string{"value", "formula", "style", "comment", "data_validation"}},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include. `truncation` additionally estimates whether each cell's content is clipped (by row height / col width / font size / wrap) and returns `isRowTruncated` / `isColTruncated` (extra compute; enable only for layout checks or before adjusting row heights / column widths)", Enum: []string{"value", "formula", "style", "comment", "data_validation", "truncation"}},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (auto-unlimited); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more.", Default: "500000"},
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."},
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden or collapsed rows and columns. Default `false`; when `--skip-filter` is omitted, filtered-out rows follow this value for backward compatibility"},
{Name: "skip-filter", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip filtered-out rows. When omitted, inherits `--skip-hidden`; explicitly set to `false` to keep filtered-out rows while skipping hidden rows and columns"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -133,10 +135,11 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Write range (A1 notation)"},
{Name: "cells", Kind: "own", Type: "string", Required: "required", Desc: "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields", Input: []string{"file", "stdin"}},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`); not accepted with `--writes` (each writes item carries its own sheet selector)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`); not accepted with `--writes` (each writes item carries its own sheet selector)"},
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Write range (A1 notation). XOR with `--writes` (single region: --range+--cells; multiple regions: --writes)"},
{Name: "cells", Kind: "own", Type: "string", Required: "xor", Desc: "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields", Input: []string{"file", "stdin"}},
{Name: "writes", Kind: "own", Type: "string", Required: "xor", Desc: "Multi-region write as a JSON array (up to 100 items), each `{sheet_name|sheet_id, range, cells}` — the sheet selector LIVES IN EACH ITEM (same convention as +batch-update sub-ops and +styles-put items; the top-level --sheet-name is rejected). cells has the same shape as `--cells` (2D array; per-cell cell_styles/border_styles allowed). The whole array goes out as ONE atomic batched request, cross-sheet supported; typical use: fixing formulas scattered across ranges/sheets — do not assemble a +batch-update operations array for this. XOR with `--range`+`--cells`; range-level uniform styling stays with +styles-put afterwards", Input: []string{"file", "stdin"}},
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting non-empty cells (default true); set false to error if any target cell is non-empty", Default: "true"},
{Name: "max-cells", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 50000", Default: "50000", Hidden: true},
{Name: "copy-to-range", Kind: "own", Type: "string", Required: "optional", Desc: "Copy-to range (A1 notation): replicate what --cells wrote into --range (values/formulas/styles, per the fields actually passed) to this range; formula refs auto-shift (C2=B2 -> C3=B3). Write a one-row/one-block template then fill a whole column/area. Supports full rows '3:6', full columns 'C:E', to-col-end 'D3:D', to-row-end 'D3:3', and comma-separated multiple targets like 'C1:D2,E5:F6'."},
@@ -316,8 +319,9 @@ var flagDefs = map[string]commandDef{
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet). Optional: when omitted the whole sheet is read (clipped to the actual grid bounds; actual_range in the response names what was read); pair with --max-chars / --output-path on large sheets"},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (auto-unlimited); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more.", Default: "500000"},
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."},
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
@@ -344,7 +348,8 @@ var flagDefs = map[string]commandDef{
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`"},
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`. XOR with `--ranges`"},
{Name: "ranges", Kind: "own", Type: "string", Required: "xor", Desc: "Multiple row/column ranges to delete as a JSON array (up to 100 items, e.g. `[\"5:5\",\"8:8\",\"11:13\"]` or `[\"C:C\",\"F:G\"]`); rows and columns cannot be mixed, ranges must not overlap; XOR with `--range`. CLI sorts positions in DESCENDING order into one atomic batch delete — ascending deletion would shift later indexes as earlier rows/columns disappear; the CLI handles the ordering", Input: []string{"file", "stdin"}},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); row/column deletion is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -392,7 +397,7 @@ var flagDefs = map[string]commandDef{
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "inherit-style", Kind: "own", Type: "string", Required: "optional", Desc: "Style inheritance for the new row/column: `before` (from preceding) / `after` (from following) / `none` (default)", Default: "none", Enum: []string{"before", "after", "none"}},
{Name: "inherit-style", Kind: "own", Type: "string", Required: "optional", Desc: "Style inheritance for the new row/column: `before` (from the preceding row/column) / `after` (from the following row/column). Omit the flag to inherit the following row/column (same as `after`) — the backend cannot leave a new row/column unstyled; for a truly blank row/column, clear formats afterwards with +cells-clear --scope formats. Insertion always lands before `--position`; this only selects which side's style is copied.", Enum: []string{"before", "after"}},
{Name: "position", Kind: "own", Type: "string", Required: "required", Desc: "Insert position (1-based row number like `3` or column letter like `C`); new rows/columns are inserted *before* this position"},
{Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Number of rows/columns to insert (must be > 0)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -975,6 +980,15 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+styles-put": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (target sheets are named inside --styles items)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "styles", Kind: "own", Type: "string", Required: "required", Desc: "Visual spec JSON applied to an EXISTING spreadsheet: top-level `{styles:[...]}`, one item per target sheet (`name` is the real sheet name), each giving at least one of `cell_styles` / `cell_merges` / `row_sizes` / `col_sizes` / `freeze`. The vocabulary is identical to `--styles` on `+workbook-create` / `+table-put` (cell_styles = A1 range + flat style fields, borders via the `border` shorthand {style,weight,color} applied to all four sides — border_styles only for per-side differences; row/col sizes = row/column range + size in px — type only for standard/auto; merges = cell range; freeze = `{rows:N, cols:N}`). The whole spec expands into one atomic batched request; ranges may target any region of the sheet", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the batched request template for each expanded operation; no network side effects"},
},
},
"+table-get": {
Risk: "read",
Flags: []flagDef{
@@ -983,6 +997,8 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by id); omit to read all sheets"},
{Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). The underlying tool truncates at ~50000 even when unset, so this is sent explicitly to raise it; for a full untruncated read use --output-path (auto-unlimited).", Default: "500000"},
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."},
{Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},

View File

@@ -52,7 +52,7 @@ func TestFlagsFor_MapsAllFields(t *testing.T) {
// enum + default
rt := byName("+dim-insert", "inherit-style")
if rt == nil || len(rt.Enum) != 3 || rt.Default != "none" {
if rt == nil || len(rt.Enum) != 2 || rt.Default != "" {
t.Errorf("+dim-insert --inherit-style not mapped: %+v", rt)
}
// required

View File

@@ -38,9 +38,104 @@ func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command)
}
cmd.SetFlagErrorFunc(sheetsFlagErrorFunc)
chainEnumNormalization(cmd)
chainFlagAliases(cmd)
}
}
// ─── intuitive flag names: silent aliases & prescriptions ───────────────
//
// Eval traces show unknown-flag failures cluster on a handful of habitual
// names (--file, --cols, --dimension, --start-cell, --bold, --source…) that
// agents import from generic CLI / Excel vocabulary. Two tiers, mirroring
// the enum-normalization contract above: a name whose value semantics are
// identical to the real flag is rewritten silently (zero round-trips); a
// name whose fix changes the value or moves it into a JSON field gets a
// curated prescription on the unknown-flag error instead — never a silent
// rewrite.
// commandFlagAliases maps, per command, habitual flag names onto the flag
// actually registered. Only pairs with identical value semantics belong
// here: the rewrite is invisible, so it must be safe to apply unread
// (+csv-put --file with a path value still trips the file-path guard, which
// prescribes @file / stdin).
var commandFlagAliases = map[string]map[string]string{
"+csv-put": {"file": "csv"},
"+sheet-create": {"name": "title"},
// size → width/height: the styles protocol (--styles row_sizes/col_sizes)
// spells the pixel dimension "size", and pre-2026-07 batches accepted it
// here too — the rename is the single largest sub-op error cluster in
// eval traces (15+ hits). Same pixel-count semantics, safe to rewrite.
"+cols-resize": {"cols": "range", "size": "width"},
"+rows-resize": {"rows": "range", "size": "height"},
"+range-fill": {"source": "source-range", "target": "target-range"},
"+range-copy": {"source": "source-range", "target": "target-range"},
"+range-move": {"source": "source-range", "target": "target-range"},
}
// intuitiveFlagHints carries the prescription for habitual names whose fix
// is not a 1:1 rename — the value belongs to a different flag or to a field
// inside a JSON payload. The hint spells the exact correct form so the
// retry needs no --help round trip.
var intuitiveFlagHints = map[string]map[string]string{
"+sheet-copy": {
"new-sheet-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
"target-sheet-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
"new-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
},
"+dim-insert": {
"dimension": "+dim-insert infers rows vs columns from --position: a row number like 3 inserts rows, a column letter like C inserts columns; pair with --count N",
},
"+dim-freeze": {
"frozen-rows": "freeze the first N rows with --dimension row --count N",
"frozen-cols": "freeze the first N columns with --dimension column --count N",
"frozen-columns": "freeze the first N columns with --dimension column --count N",
},
"+cells-set-style": {
"bold": "use --font-weight bold",
"italic": "use --font-style italic",
"underline": "use --font-line underline",
},
"+cells-set": {
// Predictable prior from +table-put --styles: models will try to
// attach range-level styling to a --writes call the same way.
"styles": `range-level styling goes through +styles-put (same {"styles":[...]} vocabulary); per-cell styles ride inside the cells objects as cell_styles`,
},
"+table-put": {
"start-cell": `anchor each sub-sheet via the "start_cell" field inside --sheets (e.g. {"sheets":[{"name":"Sheet1","start_cell":"B2",…}]}); to paste CSV at a cell use +csv-put --start-cell`,
"sheet-name": `+table-put has no sheet selector — each --sheets item carries its own "name" field ({"sheets":[{"name":"Sheet1",…}]})`,
"sheet-id": `+table-put has no sheet selector — each --sheets item carries its own "name" field ({"sheets":[{"name":"Sheet1",…}]})`,
},
}
// chainFlagAliases composes two rewrites onto the flag-name normalize hook
// (on top of any hook a prior PostMount installed, e.g. --token →
// --spreadsheet-token): the wire-vocabulary underscore form of any flag
// (--sheet_name, --border_styles — no sheets flag has an underscore in its
// canonical name), and the command's intuitive-alias table. Either way a
// habitual name parses as the real flag with zero round trips. Aliases
// never shadow a registered flag and never appear in --help; an alias whose
// target vanished (spec-side rename) is dropped, degrading to the
// unknown-flag prescription.
func chainFlagAliases(cmd *cobra.Command) {
aliases := commandFlagAliases[cmd.Name()]
usable := make(map[string]string, len(aliases))
for alias, target := range aliases {
if cmd.Flags().Lookup(alias) == nil && cmd.Flags().Lookup(target) != nil {
usable[alias] = target
}
}
prev := cmd.Flags().GetNormalizeFunc()
cmd.Flags().SetNormalizeFunc(func(fs *pflag.FlagSet, name string) pflag.NormalizedName {
if strings.Contains(name, "_") {
name = strings.ReplaceAll(name, "_", "-")
}
if target, ok := usable[name]; ok {
name = target
}
return prev(fs, name)
})
}
// sheetsFlagErrorFunc overrides the root FlagErrorFunc for sheets commands.
// It keeps the root behavior (typed error, did-you-mean suggestions, the
// offending flag on params) and additionally inlines the full valid-flag
@@ -50,6 +145,19 @@ func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command)
// immediately.
func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagFromParseError(ferr)
// Targeted fix for a high-frequency agent mistake: +batch-update carries no
// top-level sheet locator (each sub-op names its own sheet inside its input),
// yet agents reach for --sheet-id / --sheet-name at the top level. An
// edit-distance suggestion would only mislead here, so skip it and name the
// real contract instead. Underscore spellings (--sheet_id) are matched too:
// the error message itself teaches the underscore key names, and sub-op
// inputs accept them, so agents mix the two styles.
locatorName := strings.ReplaceAll(name, "_", "-")
if isUnknown && c.Name() == "+batch-update" && (locatorName == "sheet-id" || locatorName == "sheet-name") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"batch-update has no top-level sheet locator; put sheet_id/sheet_name inside each operation's input").
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag"})
}
if !isUnknown {
return common.ValidationErrorf("%s", ferr.Error()).
WithHint("run `%s --help` for valid flags", c.CommandPath())
@@ -67,6 +175,14 @@ func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
strings.Join(suggestions, ", "), list)
}
}
// A curated prescription beats both: it spells the exact correct form
// for a habitual name whose fix is not a rename (see intuitiveFlagHints).
if rx, ok := intuitiveFlagHints[c.Name()][name]; ok {
hint = rx
if list := inlineFlagList(valid); list != "" {
hint = rx + "; valid flags: " + list
}
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %q for %q", "--"+name, c.CommandPath()).
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
@@ -139,6 +255,16 @@ var enumAliases = map[string]string{
"center": "middle", // CSS vertical-align: center → Lark "middle"
"centre": "center",
"middle": "center", // CSS-style middle → Lark horizontal "center"
// Raw Lark OpenAPI merge vocabulary (MERGE_ALL/…) — agents reproduce it
// from the API docs; lowercased by canonicalEnumValue before lookup.
"merge_all": "all",
"merge_rows": "rows",
"merge_columns": "columns",
// Boolean-style word-wrap habits: true unambiguously means wrap on;
// false means "don't wrap", whose Lark default is overflow (word-clip is
// a distinct truncation mode nobody spells "false").
"true": "auto-wrap",
"false": "overflow",
}
// canonicalEnumValue returns the enum entry an off-vocabulary value

View File

@@ -96,6 +96,55 @@ func TestSheetsFlagErrorFunc_TypoKeepsSuggestion(t *testing.T) {
}
}
// TestSheetsFlagErrorFunc_BatchUpdateSheetLocator pins the targeted fix: a
// top-level --sheet-id / --sheet-name on +batch-update points the caller at
// the per-op locator contract instead of offering a misleading fuzzy guess.
func TestSheetsFlagErrorFunc_BatchUpdateSheetLocator(t *testing.T) {
t.Parallel()
for _, name := range []string{"sheet-id", "sheet-name", "sheet_id", "sheet_name"} {
name := name
t.Run(name, func(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "+batch-update"}
c.Flags().String("operations", "", "")
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --"+name))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if !strings.Contains(verr.Message, "put sheet_id/sheet_name inside each operation's input") {
t.Errorf("message should name the per-op locator contract, got %q", verr.Message)
}
if strings.Contains(verr.Hint, "did you mean") {
t.Errorf("must not offer a fuzzy guess here, got hint %q", verr.Hint)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--"+name {
t.Errorf("Params should carry the offending flag, got %v", verr.Params)
}
if len(verr.Params[0].Suggestions) != 0 {
t.Errorf("no suggestions expected, got %v", verr.Params[0].Suggestions)
}
})
}
}
// TestSheetsFlagErrorFunc_BatchUpdateOtherUnknownStillSuggests confirms the
// special case is scoped to the two sheet-locator flags: any other unknown
// flag on +batch-update keeps the normal did-you-mean behaviour.
func TestSheetsFlagErrorFunc_BatchUpdateOtherUnknownStillSuggests(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "+batch-update"}
c.Flags().String("operations", "", "")
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --operation"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if strings.Contains(verr.Message, "no top-level sheet locator") {
t.Errorf("non-locator unknown flag must not hit the special case, got %q", verr.Message)
}
}
func TestSheetsFlagErrorFunc_OtherErrorStaysGeneric(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "demo"}
@@ -284,9 +333,9 @@ func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--cols", "A:D",
"--col-size", "A:D",
})
ve := requireValidation(t, err, `unknown flag "--cols"`)
ve := requireValidation(t, err, `unknown flag "--col-size"`)
for _, want := range []string{"valid flags:", "--range", "--width", "--widths"} {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
@@ -294,3 +343,191 @@ func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
}
})
}
// TestShortcuts_IntuitiveFlagAliases verifies the silent-alias tier: a
// habitual name with identical value semantics parses as the real flag on a
// mounted command, costing zero round trips (eval: --cols, --file, --name,
// --source/--target each burned an unknown-flag failure plus a --help call).
func TestShortcuts_IntuitiveFlagAliases(t *testing.T) {
t.Parallel()
t.Run("cols-resize --cols parses as --range", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cols-resize")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--cols", "A:D",
"--width", "100",
"--dry-run",
})
if err != nil {
t.Fatalf("--cols should alias to --range and pass, got: %v", err)
}
if !strings.Contains(stdout, "A:D") {
t.Errorf("dry-run body should carry the aliased range, got %q", stdout)
}
})
t.Run("sheet-create --name parses as --title", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+sheet-create")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--name", "汇总",
"--dry-run",
})
if err != nil {
t.Fatalf("--name should alias to --title and pass, got: %v", err)
}
if !strings.Contains(stdout, "汇总") {
t.Errorf("dry-run body should carry the aliased title, got %q", stdout)
}
})
t.Run("range-fill --source/--target parse as ranges", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+range-fill")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--source", "B2",
"--target", "B3:B10",
"--dry-run",
})
if err != nil {
t.Fatalf("--source/--target should alias to the -range flags, got: %v", err)
}
for _, want := range []string{"B2", "B3:B10"} {
if !strings.Contains(stdout, want) {
t.Errorf("dry-run body should carry %q, got %q", want, stdout)
}
}
})
t.Run("csv-put --file parses as --csv", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+csv-put")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--start-cell", "A1",
"--file", "a,b\n1,2",
"--dry-run",
})
if err != nil {
t.Fatalf("--file with CSV text should alias to --csv and pass, got: %v", err)
}
if !strings.Contains(stdout, "a,b") {
t.Errorf("dry-run body should carry the CSV text, got %q", stdout)
}
})
t.Run("cols-resize --size parses as --width", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cols-resize")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A:C",
"--size", "120",
"--dry-run",
})
if err != nil {
t.Fatalf("--size should alias to --width (styles-protocol vocabulary), got: %v", err)
}
if !strings.Contains(stdout, "120") {
t.Errorf("dry-run body should carry the pixel width 120, got %q", stdout)
}
})
t.Run("rows-resize --size parses as --height", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+rows-resize")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "1:3",
"--size", "36",
"--dry-run",
})
if err != nil {
t.Fatalf("--size should alias to --height (styles-protocol vocabulary), got: %v", err)
}
})
t.Run("alias never shadows a registered flag", func(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "+csv-put"}
c.Flags().String("csv", "", "")
c.Flags().String("file", "", "") // hypothetical real flag wins
chainFlagAliases(c)
if err := c.ParseFlags([]string{"--file", "x"}); err != nil {
t.Fatalf("parse: %v", err)
}
if got, _ := c.Flags().GetString("file"); got != "x" {
t.Errorf("registered --file should keep its own value, got %q", got)
}
if got, _ := c.Flags().GetString("csv"); got != "" {
t.Errorf("--csv must stay empty when --file is a real flag, got %q", got)
}
})
}
// TestShortcuts_IntuitiveFlagHints verifies the prescription tier: habitual
// names whose fix is not a rename answer with the exact correct form, so the
// retry needs no --help round trip (eval: +sheet-copy burned 3/3 post-error
// --help calls, +dim-insert kept failing even after reading help).
func TestShortcuts_IntuitiveFlagHints(t *testing.T) {
t.Parallel()
cases := []struct {
command string
args []string
wrong string
wantHint []string
}{
{
command: "+dim-insert",
args: []string{"--url", testURL, "--sheet-name", "s", "--dimension", "row"},
wrong: "--dimension",
wantHint: []string{"--position", "--count"},
},
{
command: "+dim-freeze",
args: []string{"--url", testURL, "--sheet-name", "s", "--frozen-rows", "2"},
wrong: "--frozen-rows",
wantHint: []string{"--dimension row --count N"},
},
{
command: "+cells-set-style",
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--bold", "true"},
wrong: "--bold",
wantHint: []string{"--font-weight bold"},
},
{
command: "+sheet-copy",
args: []string{"--url", testURL, "--sheet-name", "s", "--new-sheet-name", "副本"},
wrong: "--new-sheet-name",
wantHint: []string{"--title", "source sheet"},
},
{
command: "+table-put",
args: []string{"--url", testURL, "--sheets", "{}", "--start-cell", "B2"},
wrong: "--start-cell",
wantHint: []string{`"start_cell"`, "+csv-put"},
},
}
for _, tc := range cases {
t.Run(tc.command+" "+tc.wrong, func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, tc.command)
_, _, err := runShortcutCapturingErr(t, sc, tc.args)
ve := requireValidation(t, err, "unknown flag \""+tc.wrong+"\"")
for _, want := range tc.wantHint {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
}
}
})
}
}

View File

@@ -7,6 +7,7 @@ import (
_ "embed"
"encoding/json"
"sort"
"strings"
"sync"
"github.com/larksuite/cli/errs"
@@ -84,6 +85,13 @@ func commandsWithFlagSchema() map[string]struct{} {
// listing of introspectable flags; otherwise it returns the schema
// subtree JSON for the named flag, or an error if the flag is not
// registered.
//
// flagName also accepts a dotted path (properties.plotArea.axes): the
// first segment names the flag, the rest walk the schema's properties
// (descending through array items implicitly), returning just that
// subtree. Large schemas — chart-create's properties is ~1,750 pretty
// lines — otherwise force agents to page through the full dump for one
// nested field; eval traces show 25 such round trips in one batch.
func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
return func(flagName string) ([]byte, error) {
idx, err := loadFlagSchemas()
@@ -103,10 +111,19 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
return json.MarshalIndent(map[string]interface{}{
"shortcut": command,
"introspectable_flags": flags,
"hint": "run again with --flag-name <name> to dump the JSON Schema for that flag",
"hint": "run again with --flag-name <name> to dump that flag's JSON Schema, or a dotted path like <name>.plotArea.axes to dump just one subtree",
}, "", " ")
}
schema, ok := entry[flagName]
name, path := splitSchemaPath(flagName)
schema, ok := entry[name]
if !ok {
// Tolerate the wire-vocabulary underscore form (--flag-name
// border_styles for border-styles) — agents copy field names out
// of JSON payloads where underscores are canonical.
if alt := strings.ReplaceAll(name, "_", "-"); alt != name {
schema, ok = entry[alt]
}
}
if !ok {
flags := make([]string, 0, len(entry))
for f := range entry {
@@ -114,14 +131,121 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
}
sort.Strings(flags)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"no JSON Schema registered for %s --%s; available: %v", command, flagName, flags).
"no JSON Schema registered for %s --%s; available: %v", command, name, flags).
WithParam("--flag-name")
}
// Reformat for readability — schema files store compact JSON.
var pretty interface{}
if err := json.Unmarshal(schema, &pretty); err != nil {
return nil, err
}
if len(path) > 0 {
pretty, err = sliceSchemaByPath(pretty, name, path)
if err != nil {
return nil, err
}
}
// Reformat for readability — schema files store compact JSON.
return json.MarshalIndent(pretty, "", " ")
}
}
// splitSchemaPath splits a --flag-name value into the flag name and the
// optional dotted schema path after it.
func splitSchemaPath(flagName string) (string, []string) {
parts := strings.Split(flagName, ".")
return parts[0], parts[1:]
}
// sliceSchemaByPath walks a decoded JSON Schema along dotted path segments.
// Each segment matches a key under "properties"; array levels are descended
// implicitly through "items" (an explicit "items" segment also works), and
// oneOf branches are searched for the first one carrying the key. A miss
// errors with the keys actually available at that level so the caller can
// re-issue the path without a full dump.
func sliceSchemaByPath(schema interface{}, flagName string, path []string) (interface{}, error) {
node := schema
walked := flagName
for _, seg := range path {
next, ok := schemaChild(node, seg)
if !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"no %q under %s; available keys: %v", seg, walked, schemaChildKeys(node)).
WithParam("--flag-name")
}
node = next
walked += "." + seg
}
return node, nil
}
// schemaChild resolves one path segment against a schema node, descending
// through items / oneOf wrappers as needed.
func schemaChild(node interface{}, seg string) (interface{}, bool) {
for depth := 0; depth < 8; depth++ {
m, ok := node.(map[string]interface{})
if !ok {
return nil, false
}
if seg == "items" {
if items, ok := m["items"]; ok {
return items, true
}
}
if props, ok := m["properties"].(map[string]interface{}); ok {
if child, ok := props[seg]; ok {
return child, true
}
}
if items, ok := m["items"]; ok {
node = items
continue
}
if branches, ok := m["oneOf"].([]interface{}); ok {
for _, b := range branches {
if child, ok := schemaChild(b, seg); ok {
return child, true
}
}
}
return nil, false
}
return nil, false
}
// schemaChildKeys lists the property keys reachable at a schema node (through
// items / oneOf wrappers), for the path-miss error.
func schemaChildKeys(node interface{}) []string {
seen := map[string]struct{}{}
var collect func(n interface{}, depth int)
collect = func(n interface{}, depth int) {
if depth > 8 {
return
}
m, ok := n.(map[string]interface{})
if !ok {
return
}
if props, ok := m["properties"].(map[string]interface{}); ok {
for k := range props {
seen[k] = struct{}{}
}
return
}
if items, ok := m["items"]; ok {
collect(items, depth+1)
return
}
if branches, ok := m["oneOf"].([]interface{}); ok {
for _, b := range branches {
collect(b, depth+1)
}
}
}
collect(node, 0)
keys := make([]string, 0, len(seen))
for k := range seen {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

View File

@@ -9,6 +9,8 @@ import (
"fmt"
"sort"
"strings"
"github.com/larksuite/cli/internal/suggest"
)
// ─── schema-driven flag validation ────────────────────────────────────
@@ -94,7 +96,15 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
}
var schema schemaProperty
json.Unmarshal(raw, &schema)
if vErr := validateAgainstSchema(value, &schema, ""); vErr != nil {
c := &schemaErrorCollector{}
collectSchemaErrors(value, &schema, "", c)
if len(c.errs) == 0 {
return nil
}
vErr := c.errs[0]
if len(c.errs) == 1 {
// Single failure keeps the historical message byte-for-byte.
//
// Composite-JSON shape errors (e.g. +cells-set --cells, chart
// --properties) are the highest-frequency usage-layer failure for
// sheets, and agents often burn several retries guessing the shape.
@@ -107,18 +117,63 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
// branch means entry[name] resolved a schema from the embedded
// index, so the suggested command is guaranteed to print it.
var tm *typeMismatchError
if errors.As(vErr, &tm) && pathDepth(tm.path) <= skeletonPathDepthLimit {
isTypeMismatch := errors.As(vErr, &tm)
if isTypeMismatch && pathDepth(tm.path) <= skeletonPathDepthLimit {
if sk := schemaSkeleton(&schema, skeletonMaxDepth); sk != "" {
return sheetsValidationForFlag(name,
"--%s: %s; expected shape: %s (run `lark-cli sheets %s --print-schema --flag-name %s` for the full JSON Schema)",
name, vErr.Error(), sk, command, name).WithCause(vErr)
}
}
// Deep type mismatches don't get a whole-shape skeleton (it wouldn't
// address the actual field), but if the field itself carries an enum /
// description, append that one line — same "fix on first retry" goal.
msg := vErr.Error()
if isTypeMismatch {
if suffix := tm.hintSuffix(); suffix != "" {
msg += "; " + suffix
}
}
return sheetsValidationForFlag(name,
"--%s: %s; run `lark-cli sheets %s --print-schema --flag-name %s` to see the expected JSON Schema",
name, vErr.Error(), command, name).WithCause(vErr)
name, msg, command, name).WithCause(vErr)
}
return nil
// Multiple failures: report them all at once (numbered, each with its
// own inline teaching hint) so the agent fixes the whole payload in one
// retry instead of the fail-fast "fix one, hit the next" loop.
return sheetsValidationForFlag(name,
"--%s: %s; run `lark-cli sheets %s --print-schema --flag-name %s` to see the expected JSON Schema",
name, formatSchemaErrorList(c.errs), command, name).WithCause(vErr)
}
// formatSchemaErrorList renders collected failures as a numbered one-line
// list: "N validation errors: 1) …; 2) …". Type-mismatch entries carry
// their enum/description suffix just like the single-error path. Entries
// beyond schemaErrorDisplayLimit collapse into a "(more …)" tail — the
// collector stops at cap, so the exact total is unknown by design.
func formatSchemaErrorList(errs []error) string {
shown := errs
truncated := false
if len(shown) > schemaErrorDisplayLimit {
shown = shown[:schemaErrorDisplayLimit]
truncated = true
}
parts := make([]string, 0, len(shown))
for i, e := range shown {
msg := e.Error()
var tm *typeMismatchError
if errors.As(e, &tm) {
if suffix := tm.hintSuffix(); suffix != "" {
msg += "; " + suffix
}
}
parts = append(parts, fmt.Sprintf("%d) %s", i+1, msg))
}
out := fmt.Sprintf("%d validation errors: %s", len(shown), strings.Join(parts, "; "))
if truncated {
out = fmt.Sprintf("%d+ validation errors: %s; (more errors not shown — fix these first)", schemaErrorDisplayLimit, strings.Join(parts, "; "))
}
return out
}
// validateInputAgainstSchema validates input[flag] for every flag the
@@ -187,8 +242,10 @@ var inputSchemaSkip = map[string]struct{}{
}
// schemaProperty mirrors the JSON Schema subset used by
// data/flag-schemas.json. Unknown keys (description, …) are dropped —
// they're documentation.
// data/flag-schemas.json. Description is retained (not just documentation)
// so a required-missing or type-mismatch error can inline the one-line
// field doc — the agent then fixes the input without a --print-schema round
// trip. Other unknown keys stay dropped.
//
// Minimum / Maximum / MinItems / MaxItems use *float64 / *int because
// 0 is a meaningful bound (e.g. chart row >= 0); nil distinguishes
@@ -204,6 +261,7 @@ var inputSchemaSkip = map[string]struct{}{
// map<string, array<string>> fields (groups / collapse).
type schemaProperty struct {
Type string `json:"type"`
Description string `json:"description"`
Nullable bool `json:"nullable"`
Enum []interface{} `json:"enum"`
Properties map[string]*schemaProperty `json:"properties"`
@@ -242,20 +300,66 @@ func (a *additionalProps) UnmarshalJSON(data []byte) error {
return nil
}
// schemaErrorCollector accumulates validation failures during one full
// traversal so the caller can report every problem in a single reply
// instead of the fail-fast "fix one, retry, hit the next" loop. Capacity
// is bounded (collectSchemaErrorsCap) so a pathological payload — e.g. a
// 5000-row --cells array where every cell is malformed — cannot balloon
// the error message or the traversal cost: once full, collection
// short-circuits everywhere via full().
type schemaErrorCollector struct {
errs []error
}
// collectSchemaErrorsCap bounds how many errors one traversal gathers:
// schemaErrorDisplayLimit entries are rendered; one extra is collected
// only to know that truncation happened.
const (
schemaErrorDisplayLimit = 5
collectSchemaErrorsCap = schemaErrorDisplayLimit + 1
)
func (c *schemaErrorCollector) add(err error) {
if len(c.errs) < collectSchemaErrorsCap {
c.errs = append(c.errs, err)
}
}
func (c *schemaErrorCollector) full() bool { return len(c.errs) >= collectSchemaErrorsCap }
// validateAgainstSchema recursively checks `value` against `schema`,
// prefixing any failure with the JSON path navigated so far.
// prefixing any failure with the JSON path navigated so far. It reports
// only the first failure — callers that want the full list (the
// error-as-teaching aggregate path) use collectSchemaErrors directly.
func validateAgainstSchema(value interface{}, schema *schemaProperty, path string) error {
if schema == nil {
return nil // defensive — current callers always pass &schema, but
// keeps validator safe for future programmatic construction.
c := &schemaErrorCollector{}
collectSchemaErrors(value, schema, path, c)
if len(c.errs) == 0 {
return nil
}
return c.errs[0]
}
// collectSchemaErrors is the traversal engine behind validateAgainstSchema:
// same checks, same messages, same deterministic order, but it keeps
// walking after a failure and appends every problem to the collector
// (until cap). Two deliberate exceptions to "keep walking":
// - a type mismatch stops descent into that node (its children would
// produce cascading nonsense against the wrong-typed value);
// - oneOf alternatives are probed with throwaway collectors (a failed
// alternative is not an error when a later one matches).
func collectSchemaErrors(value interface{}, schema *schemaProperty, path string, c *schemaErrorCollector) {
if schema == nil || c.full() {
return
}
if value == nil && schema.Nullable {
return nil
return
}
if schema.Type != "" {
if !matchesJSONType(value, schema.Type) {
return &typeMismatchError{path: path, expected: schema.Type, got: jsType(value)}
c.add(&typeMismatchError{path: path, expected: schema.Type, got: jsType(value), enum: schema.Enum, description: schema.Description})
return // wrong container type — descending would cascade nonsense.
}
}
@@ -263,20 +367,20 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
// already reported above). Apply to both `number` and `integer` types.
if num, ok := value.(float64); ok {
if schema.Minimum != nil && num < *schema.Minimum {
return fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
c.add(fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
if schema.Maximum != nil && num > *schema.Maximum {
return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
c.add(fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
}
// Array length bounds — only checked when value is an array.
if arr, ok := value.([]interface{}); ok {
if schema.MinItems != nil && len(arr) < *schema.MinItems {
return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
c.add(fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
if schema.MaxItems != nil && len(arr) > *schema.MaxItems {
return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
c.add(fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
}
@@ -294,20 +398,22 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
if hint := suggestEnumForError(value, schema.Enum); hint != "" {
msg += fmt.Sprintf(` (did you mean %q?)`, hint)
}
return fmt.Errorf("%s", msg) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
c.add(fmt.Errorf("%s", msg)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
}
if len(schema.OneOf) > 0 {
matched := false
for _, sub := range schema.OneOf {
if validateAgainstSchema(value, sub, path) == nil {
probe := &schemaErrorCollector{}
collectSchemaErrors(value, sub, path, probe)
if len(probe.errs) == 0 {
matched = true
break
}
}
if !matched {
return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
c.add(fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path))) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
}
@@ -316,8 +422,18 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
// the schema also describes their per-key shape via `properties`.
if obj, ok := value.(map[string]interface{}); ok {
for _, key := range schema.Required {
if c.full() {
return
}
if _, present := obj[key]; !present {
return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
msg := fmt.Sprintf("required property %q is missing at %s", key, pathOrRoot(path))
// Inline the missing field's type / one-line description / enum so
// the agent supplies a correctly-shaped value on the first retry
// instead of fetching the full schema.
if hint := schemaFieldHint(schema.Properties[key]); hint != "" {
msg += "; expected " + hint
}
c.add(fmt.Errorf("%s", msg)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
}
if schema.Properties != nil {
@@ -327,6 +443,9 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
}
sort.Strings(keys)
for _, key := range keys {
if c.full() {
return
}
sub := schema.Properties[key]
v, present := obj[key]
if !present {
@@ -350,14 +469,12 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
if path != "" {
child = path + "." + key
}
if err := validateAgainstSchema(v, sub, child); err != nil {
return err
}
collectSchemaErrors(v, sub, child, c)
}
}
// additionalProperties: enforce only when explicitly declared.
// Absent means lenient (matches the file header's stance). Sort
// extras so the first rejection is deterministic across runs.
// extras so rejection order is deterministic across runs.
if schema.AdditionalProperties != nil {
extras := make([]string, 0)
for key := range obj {
@@ -368,17 +485,29 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
}
sort.Strings(extras)
for _, key := range extras {
if c.full() {
return
}
if schema.AdditionalProperties.Strict {
return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
msg := fmt.Sprintf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key)
// Inline the node's declared keys (and a did-you-mean when the
// unknown key is a near miss) so the agent renames it in one
// retry instead of a --print-schema round trip.
if legal := sortedSchemaPropertyKeys(schema.Properties); len(legal) > 0 {
if guess := suggest.Closest(key, legal, 1); len(guess) > 0 {
msg += fmt.Sprintf(` (did you mean %q?)`, guess[0])
}
msg += "; valid properties: " + formatPropertyKeyList(legal)
}
c.add(fmt.Errorf("%s", msg)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
continue
}
if schema.AdditionalProperties.Schema != nil {
child := key
if path != "" {
child = path + "." + key
}
if err := validateAgainstSchema(obj[key], schema.AdditionalProperties.Schema, child); err != nil {
return err
}
collectSchemaErrors(obj[key], schema.AdditionalProperties.Schema, child, c)
}
}
}
@@ -387,33 +516,50 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
if schema.Type == "array" && schema.Items != nil {
arr, ok := value.([]interface{})
if !ok {
return nil // type mismatch already reported above.
return // type mismatch already reported above.
}
for i, item := range arr {
child := fmt.Sprintf("%s[%d]", path, i)
if err := validateAgainstSchema(item, schema.Items, child); err != nil {
return err
if c.full() {
return
}
child := fmt.Sprintf("%s[%d]", path, i)
collectSchemaErrors(item, schema.Items, child, c)
}
}
return nil
}
// typeMismatchError is the type-check branch of validateAgainstSchema
// as a typed error, so validateValueAgainstSchema can recognize shape
// confusion (vs. deep value errors) and inline a skeleton of the
// expected shape. Error() keeps the exact legacy wording.
// expected shape. Error() keeps the exact legacy wording; enum /
// description ride alongside for the deep-mismatch hintSuffix, so they
// never leak into the shallow-skeleton message.
type typeMismatchError struct {
path string
expected string
got string
path string
expected string
got string
enum []interface{}
description string
}
func (e *typeMismatchError) Error() string {
return fmt.Sprintf("%sexpected type %q, got %q", pathPrefix(e.path), e.expected, e.got)
}
// hintSuffix renders the field's description / enum as a one-line tail for
// the deep type-mismatch fallback (type is already stated by Error()).
// Empty when the field declares neither.
func (e *typeMismatchError) hintSuffix() string {
var parts []string
if d := oneLineDescription(e.description); d != "" {
parts = append(parts, "description: "+d)
}
if len(e.enum) > 0 {
parts = append(parts, "one of "+formatEnum(e.enum))
}
return strings.Join(parts, ", ")
}
// pathDepth counts how many levels below the flag root a JSON path
// points at: "" → 0, "[0]" → 1, "[0][3]" → 2, "[0][3].value" → 3,
// "legend" → 1, "snapshot.axes" → 2. Every "[" and "." starts a new
@@ -605,6 +751,70 @@ func joinFormatted(values []interface{}) string {
return strings.Join(parts, ", ")
}
// schemaFieldHint renders a compact one-line "type X, description: …, one of
// […]" sketch of a single field's schema, used to enrich a required-missing
// error so the agent supplies a correctly-shaped value without --print-schema.
// Empty when the field declares none of type / description / enum.
func schemaFieldHint(s *schemaProperty) string {
if s == nil {
return ""
}
var parts []string
if s.Type != "" {
parts = append(parts, fmt.Sprintf("type %q", s.Type))
}
if d := oneLineDescription(s.Description); d != "" {
parts = append(parts, "description: "+d)
}
if len(s.Enum) > 0 {
parts = append(parts, "one of "+formatEnum(s.Enum))
}
return strings.Join(parts, ", ")
}
// sortedSchemaPropertyKeys returns the declared property names in a stable
// (sorted) order so the valid-property list in a strict unexpected-property
// error is deterministic across runs.
func sortedSchemaPropertyKeys(props map[string]*schemaProperty) []string {
keys := make([]string, 0, len(props))
for k := range props {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// propertyKeyDisplayLimit caps how many declared property names ride inline on
// a strict unexpected-property error, so a wide object doesn't bury the actual
// error under a wall of keys. Overflow is summarised as "(N more)".
const propertyKeyDisplayLimit = 15
func formatPropertyKeyList(keys []string) string {
if len(keys) <= propertyKeyDisplayLimit {
return "[" + strings.Join(keys, ", ") + "]"
}
shown := keys[:propertyKeyDisplayLimit]
return fmt.Sprintf("[%s, … (%d more)]", strings.Join(shown, ", "), len(keys)-propertyKeyDisplayLimit)
}
// descriptionMaxLen bounds an inlined field description to one reasonable line;
// schema descriptions can run several sentences, which would swamp the error.
const descriptionMaxLen = 120
// oneLineDescription collapses a (possibly multi-line) schema description into
// a single whitespace-normalised line, truncated to descriptionMaxLen runes.
// Returns "" for an empty / whitespace-only description.
func oneLineDescription(s string) string {
collapsed := strings.Join(strings.Fields(s), " ")
if collapsed == "" {
return ""
}
if r := []rune(collapsed); len(r) > descriptionMaxLen {
return string(r[:descriptionMaxLen]) + "…"
}
return collapsed
}
// suggestEnumMatch returns the canonical enum entry when the user's
// value unambiguously means one — casing ("SUM" vs "sum", "True" vs
// "true") or a cross-vocabulary alias (CSS "center" for Lark's vertical

View File

@@ -5,6 +5,8 @@ package sheets
import (
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
)
@@ -438,6 +440,373 @@ func TestValidateValueAgainstSchema_ShapeSkeletonOnShallowTypeMismatch(t *testin
}
}
// TestValidateAgainstSchema_StrictUnexpectedPropertyListsKeys pins the strict
// additionalProperties:false enhancement: the error lists the node's legal
// property keys (sorted, capped at 15 with an "(N more)" overflow) and, when
// the unknown key is a near miss, appends a did-you-mean.
func TestValidateAgainstSchema_StrictUnexpectedPropertyListsKeys(t *testing.T) {
t.Parallel()
t.Run("lists legal keys and suggests a near miss", func(t *testing.T) {
t.Parallel()
schema := parseSchema(t, `{
"type":"object",
"additionalProperties":false,
"properties":{
"background_color":{"type":"string"},
"font_weight":{"type":"string"},
"font_size":{"type":"integer"}
}
}`)
err := validateAgainstSchema(map[string]interface{}{"background_colour": "#fff"}, schema, "")
if err == nil {
t.Fatal("unknown key under strict schema must fail")
}
msg := err.Error()
if !strings.Contains(msg, `unexpected property "background_colour"`) {
t.Errorf("want the offending key named; got %q", msg)
}
if !strings.Contains(msg, `did you mean "background_color"?`) {
t.Errorf("want a did-you-mean for the near miss; got %q", msg)
}
for _, want := range []string{"valid properties:", "background_color", "font_size", "font_weight"} {
if !strings.Contains(msg, want) {
t.Errorf("want valid-property list to contain %q; got %q", want, msg)
}
}
})
t.Run("no did-you-mean for an unrelated key", func(t *testing.T) {
t.Parallel()
schema := parseSchema(t, `{
"type":"object",
"additionalProperties":false,
"properties":{"background_color":{"type":"string"}}
}`)
err := validateAgainstSchema(map[string]interface{}{"zzzzzzzz": 1}, schema, "")
if err == nil {
t.Fatal("unknown key must fail")
}
if strings.Contains(err.Error(), "did you mean") {
t.Errorf("unrelated key should get no suggestion; got %q", err.Error())
}
if !strings.Contains(err.Error(), "valid properties: [background_color]") {
t.Errorf("want the valid-property list; got %q", err.Error())
}
})
t.Run("wide object truncates the key list with overflow", func(t *testing.T) {
t.Parallel()
props := make([]string, 0, 20)
for i := 0; i < 20; i++ {
props = append(props, fmt.Sprintf(`"k%02d":{"type":"string"}`, i))
}
schema := parseSchema(t, `{"type":"object","additionalProperties":false,"properties":{`+strings.Join(props, ",")+`}}`)
err := validateAgainstSchema(map[string]interface{}{"nope": 1}, schema, "")
if err == nil {
t.Fatal("unknown key must fail")
}
if !strings.Contains(err.Error(), "(5 more)") { // 20 keys, cap 15
t.Errorf("want overflow marker '(5 more)'; got %q", err.Error())
}
})
}
// TestValidateAgainstSchema_RequiredMissingInlinesFieldHint pins that a
// required-property-missing error inlines the field's type / one-line
// description / enum when the schema describes that field.
func TestValidateAgainstSchema_RequiredMissingInlinesFieldHint(t *testing.T) {
t.Parallel()
schema := parseSchema(t, `{
"type":"object",
"required":["operation"],
"properties":{
"operation":{
"type":"string",
"description":"Which mutation to run.",
"enum":["insert","delete","move"]
}
}
}`)
err := validateAgainstSchema(map[string]interface{}{}, schema, "")
if err == nil {
t.Fatal("missing required property must fail")
}
msg := err.Error()
for _, want := range []string{
`required property "operation"`,
`type "string"`,
"description: Which mutation to run.",
`one of ["insert", "delete", "move"]`,
} {
if !strings.Contains(msg, want) {
t.Errorf("want %q in required-missing error; got %q", want, msg)
}
}
}
// TestValidateAgainstSchema_RequiredMissingNoSchemaStaysPlain pins that a
// missing required key with no describing schema keeps the plain legacy
// message (no trailing "expected ...").
func TestValidateAgainstSchema_RequiredMissingNoSchemaStaysPlain(t *testing.T) {
t.Parallel()
schema := parseSchema(t, `{"type":"object","required":["a"]}`)
err := validateAgainstSchema(map[string]interface{}{}, schema, "")
if err == nil {
t.Fatal("missing required must fail")
}
if strings.Contains(err.Error(), "; expected") {
t.Errorf("no field schema → no inlined hint; got %q", err.Error())
}
}
// TestValidateValueAgainstSchema_DeepTypeMismatchAppendsEnum pins that a deep
// type mismatch (past the skeleton depth limit) still gets no whole-shape
// skeleton, but appends the field's enum / description one-liner.
func TestValidateValueAgainstSchema_DeepTypeMismatchAppendsEnum(t *testing.T) {
t.Parallel()
// A wrong-typed value three levels deep where the field is an enum string.
schema := parseSchema(t, `{
"type":"array",
"items":{"type":"array","items":{"type":"object","properties":{
"align":{"type":"string","description":"Text alignment.","enum":["left","center","right"]}
}}}
}`)
deep := parseValue(t, `[[{"align":42}]]`)
err := validateAgainstSchema(deep, schema, "")
if err == nil {
t.Fatal("wrong type for align must fail")
}
var tm *typeMismatchError
if !errors.As(err, &tm) {
t.Fatalf("want *typeMismatchError, got %T", err)
}
suffix := tm.hintSuffix()
for _, want := range []string{"description: Text alignment.", `one of ["left", "center", "right"]`} {
if !strings.Contains(suffix, want) {
t.Errorf("want %q in hintSuffix; got %q", want, suffix)
}
}
}
// TestSchemaFieldHint covers the single-field sketch used by
// required-missing errors: each of type / description / enum contributes
// its own segment, absent parts are simply skipped, and a nil / empty
// schema yields no hint at all.
func TestSchemaFieldHint(t *testing.T) {
t.Parallel()
cases := []struct {
name string
schema *schemaProperty
want string
}{
{"nil schema", nil, ""},
{"empty schema", &schemaProperty{}, ""},
{"type only", &schemaProperty{Type: "string"}, `type "string"`},
{"description only", &schemaProperty{Description: "Cell note."}, "description: Cell note."},
{"enum only", &schemaProperty{Enum: []interface{}{"a", "b"}}, `one of ["a", "b"]`},
{
"all three",
&schemaProperty{Type: "string", Description: "段类型", Enum: []interface{}{"text", "link"}},
`type "string", description: 段类型, one of ["text", "link"]`,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := schemaFieldHint(tc.schema); got != tc.want {
t.Errorf("schemaFieldHint = %q, want %q", got, tc.want)
}
})
}
}
// TestFormatPropertyKeyList_Boundaries pins the display cap edges: exactly
// at the cap nothing is folded, one past the cap folds into "(1 more)".
func TestFormatPropertyKeyList_Boundaries(t *testing.T) {
t.Parallel()
keys := make([]string, 0, propertyKeyDisplayLimit+1)
for i := 0; i < propertyKeyDisplayLimit; i++ {
keys = append(keys, fmt.Sprintf("k%02d", i))
}
if got := formatPropertyKeyList(keys); strings.Contains(got, "more)") {
t.Errorf("exactly %d keys must not fold, got %q", propertyKeyDisplayLimit, got)
}
keys = append(keys, "overflow")
if got := formatPropertyKeyList(keys); !strings.Contains(got, "(1 more)") {
t.Errorf("%d keys should fold into '(1 more)', got %q", propertyKeyDisplayLimit+1, got)
}
}
// TestTypeMismatchHintSuffix_EmptyWhenUndeclared pins that a field with
// neither enum nor description adds no suffix — the deep-mismatch fallback
// message must stay byte-identical to the legacy wording in that case.
func TestTypeMismatchHintSuffix_EmptyWhenUndeclared(t *testing.T) {
t.Parallel()
tm := &typeMismatchError{path: "a.b", expected: "string", got: "number"}
if got := tm.hintSuffix(); got != "" {
t.Errorf("no enum/description → empty suffix, got %q", got)
}
}
// TestValidateAgainstSchema_StrictUnexpectedProperty_CaseOnlyTypo pins the
// did-you-mean for a key that differs from a legal one only in casing /
// underscore style — a high-frequency LLM slip.
func TestValidateAgainstSchema_StrictUnexpectedProperty_CaseOnlyTypo(t *testing.T) {
t.Parallel()
schema := parseSchema(t, `{
"type":"object",
"additionalProperties":false,
"properties":{"background_color":{"type":"string"}}
}`)
err := validateAgainstSchema(map[string]interface{}{"Background_Color": "#fff"}, schema, "")
if err == nil {
t.Fatal("case-typo key under strict schema must fail")
}
if !strings.Contains(err.Error(), `did you mean "background_color"?`) {
t.Errorf("want case-insensitive did-you-mean; got %q", err.Error())
}
}
// TestValidateValueAgainstSchema_RequiredMissingRealSchema replays 场景3
// of the doubao case against the real embedded flag-schemas.json: a
// rich_text segment without "type" must inline the field's type, enum and
// description while keeping the --print-schema pointer.
func TestValidateValueAgainstSchema_RequiredMissingRealSchema(t *testing.T) {
t.Parallel()
fv := mapFlagView{command: "+cells-set"}
value := parseValue(t, `[[{"rich_text":[{"text":"x"}]}]]`)
err := validateValueAgainstSchema(fv, "cells", value)
if err == nil {
t.Fatal("rich_text without type must fail against the embedded schema")
}
msg := err.Error()
for _, want := range []string{
`required property "type" is missing`,
`expected type "string"`,
"one of [",
`"text"`,
"--print-schema",
} {
if !strings.Contains(msg, want) {
t.Errorf("want %q in real-schema required-missing error; got %q", want, msg)
}
}
}
// TestValidateValueAgainstSchema_DeepMismatchRealSchema replays 场景4: a
// numeric rich_text "type" three levels deep gets the field's enum inline
// (no whole-shape skeleton), still with the --print-schema pointer.
func TestValidateValueAgainstSchema_DeepMismatchRealSchema(t *testing.T) {
t.Parallel()
fv := mapFlagView{command: "+cells-set"}
value := parseValue(t, `[[{"rich_text":[{"type":42,"text":"x"}]}]]`)
err := validateValueAgainstSchema(fv, "cells", value)
if err == nil {
t.Fatal("numeric rich_text type must fail against the embedded schema")
}
msg := err.Error()
for _, want := range []string{
`expected type "string", got "number"`,
"one of [",
`"text"`,
"--print-schema",
} {
if !strings.Contains(msg, want) {
t.Errorf("want %q in real-schema deep-mismatch error; got %q", want, msg)
}
}
if strings.Contains(msg, "expected shape:") {
t.Errorf("deep mismatch must not inline a skeleton; got %q", msg)
}
}
// TestValidateValueAgainstSchema_AggregatesMultipleErrors pins the
// aggregate path: a payload with several independent problems reports them
// all in one numbered reply (each with its own teaching hint) instead of
// the fail-fast fix-one-retry-hit-the-next loop.
func TestValidateValueAgainstSchema_AggregatesMultipleErrors(t *testing.T) {
t.Parallel()
fv := mapFlagView{command: "+cells-set"}
// Two independent problems in one --cells payload: cell[0][0].rich_text[0]
// misses required "type"; cell[0][1].note has the wrong type.
value := parseValue(t, `[[{"rich_text":[{"text":"x"}]},{"note":12.5}]]`)
err := validateValueAgainstSchema(fv, "cells", value)
if err == nil {
t.Fatal("payload with two problems must fail")
}
msg := err.Error()
for _, want := range []string{
"2 validation errors:",
`1) required property "type" is missing`,
`one of ["text"`, // teaching hint rides along in aggregate mode too
`2) [0][1].note: expected type "string"`,
"--print-schema",
} {
if !strings.Contains(msg, want) {
t.Errorf("want %q in aggregated error; got %q", want, msg)
}
}
}
// TestValidateValueAgainstSchema_AggregateCapTruncates pins the display
// cap: a pathological payload reports schemaErrorDisplayLimit entries and
// an explicit truncation tail, never the full flood.
func TestValidateValueAgainstSchema_AggregateCapTruncates(t *testing.T) {
t.Parallel()
fv := mapFlagView{command: "+cells-set"}
// Seven cells all missing required rich_text "type" → 7 independent errors.
row := make([]string, 0, 7)
for i := 0; i < 7; i++ {
row = append(row, `{"rich_text":[{"text":"x"}]}`)
}
value := parseValue(t, `[[`+strings.Join(row, ",")+`]]`)
err := validateValueAgainstSchema(fv, "cells", value)
if err == nil {
t.Fatal("payload with seven problems must fail")
}
msg := err.Error()
if !strings.Contains(msg, "5+ validation errors:") {
t.Errorf("want capped header '5+ validation errors:'; got %q", msg)
}
if !strings.Contains(msg, "more errors not shown") {
t.Errorf("want truncation tail; got %q", msg)
}
if strings.Contains(msg, "6)") {
t.Errorf("must not render entries beyond the display limit; got %q", msg)
}
}
// TestCollectSchemaErrors_OneOfProbeDoesNotLeak pins that failed oneOf
// alternatives don't leak probe errors into the caller's collector when a
// later alternative matches.
func TestCollectSchemaErrors_OneOfProbeDoesNotLeak(t *testing.T) {
t.Parallel()
schema := parseSchema(t, `{"oneOf":[{"type":"string"},{"type":"number"}]}`)
c := &schemaErrorCollector{}
collectSchemaErrors(42.0, schema, "", c)
if len(c.errs) != 0 {
t.Errorf("number matches the second oneOf alternative; want no errors, got %v", c.errs)
}
}
func TestOneLineDescription(t *testing.T) {
t.Parallel()
if got := oneLineDescription(" "); got != "" {
t.Errorf("whitespace-only → empty, got %q", got)
}
if got := oneLineDescription("line one\n line two"); got != "line one line two" {
t.Errorf("multi-line collapse = %q", got)
}
long := strings.Repeat("x", 200)
got := oneLineDescription(long)
if !strings.HasSuffix(got, "…") || len([]rune(got)) != descriptionMaxLen+1 {
t.Errorf("long description should truncate to %d runes + ellipsis, got %d", descriptionMaxLen, len([]rune(got)))
}
}
func TestPathDepth(t *testing.T) {
t.Parallel()
cases := []struct {

View File

@@ -34,6 +34,7 @@ var commandsWithSchema = map[string]struct{}{
"+rows-resize": {},
"+sparkline-create": {},
"+sparkline-update": {},
"+styles-put": {},
"+table-put": {},
"+workbook-create": {},
}

View File

@@ -11,7 +11,6 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
neturl "net/url"
"strings"
@@ -407,6 +406,13 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
}
return nil, sheetsValidationForFlag(name, "--%s: invalid JSON: %v", name, err).WithCause(err)
}
// Unambiguous habitual shapes are rewritten onto the wire contract
// before validation (see jsonFlagNormalizers). Runs on the parsed value,
// so both the standalone cobra path and +batch-update sub-ops (whose
// mapFlagView.Str re-encodes composites through here) get the rewrite.
if norm := jsonFlagNormalizers[runtime.Command()][name]; norm != nil {
out = norm(out)
}
// Schema-driven flag validation at the user-input boundary. Skips
// --properties (validated at the input-builder tail after enhance
// hooks fill in flat-flag-derived fields) and any flag without an
@@ -417,6 +423,92 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
return out, nil
}
// jsonFlagNormalizers rewrites, per (command, flag), unambiguous habitual
// input shapes onto the wire contract before schema validation — same
// contract as enum normalization: only a shape whose meaning is beyond
// doubt may be rewritten; anything ambiguous must fail with a prescription
// instead. Applied to the parsed JSON value inside parseJSONFlag.
var jsonFlagNormalizers = map[string]map[string]func(interface{}) interface{}{
"+cells-set": {"cells": wrapLoneCellObject},
"+chart-create": {"properties": normalizeChartHexColors},
"+chart-update": {"properties": normalizeChartHexColors},
}
// normalizeChartHexColors walks a chart properties payload and prefixes bare
// 6/8-digit hex values on color keys with '#' (4472C4 → #4472C4 — the
// Excel-habit form the chart backend rejects with "expected rgba() or
// #RRGGBB/#RRGGBBAA"). In-place, recursive; anything not unambiguously a
// bare hex color is untouched.
func normalizeChartHexColors(v interface{}) interface{} {
switch t := v.(type) {
case map[string]interface{}:
for k, val := range t {
if s, ok := val.(string); ok && isColorKey(k) && isBareHexColor(s) {
t[k] = "#" + s
continue
}
normalizeChartHexColors(val)
}
case []interface{}:
for _, e := range t {
normalizeChartHexColors(e)
}
}
return v
}
func isColorKey(k string) bool {
return k == "color" || strings.HasSuffix(k, "_color") || strings.HasSuffix(k, "Color")
}
func isBareHexColor(s string) bool {
if len(s) != 6 && len(s) != 8 {
return false
}
for _, r := range s {
switch {
case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F':
default:
return false
}
}
return true
}
// cellObjectKeys pins the property vocabulary of a single cell in the
// +cells-set --cells schema ([[{…}]]). Drift against the embedded schema is
// guarded by TestCellObjectKeys_MatchEmbeddedSchema.
var cellObjectKeys = map[string]struct{}{
"border_styles": {},
"cell_styles": {},
"data_validation": {},
"formula": {},
"multiple_values": {},
"note": {},
"rich_text": {},
"value": {},
}
// wrapLoneCellObject rewrites a bare cell object into the [[cell]] the
// --cells contract expects. Eval traces show agents writing a single cell
// routinely pass {"value":…} without the two array layers; when every key
// belongs to the cell vocabulary the meaning is a 1×1 write and the wrap is
// safe. Anything else (unknown keys, arrays — one bracket layer could be a
// row or a column) is returned untouched for the schema validator to
// prescribe.
func wrapLoneCellObject(v interface{}) interface{} {
obj, ok := v.(map[string]interface{})
if !ok || len(obj) == 0 {
return v
}
for k := range obj {
if _, known := cellObjectKeys[k]; !known {
return v
}
}
return []interface{}{[]interface{}{obj}}
}
// requireJSONObject is parseJSONFlag + a type assertion to map[string]interface{}.
func requireJSONObject(runtime flagView, name string) (map[string]interface{}, error) {
v, err := parseJSONFlag(runtime, name)
@@ -448,146 +540,3 @@ func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
}
return a, nil
}
// ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─
// buildCellStyleFromFlags reads the 12 flat style flags and returns the
// cell_styles map expected by set_cell_range. Skips any flag the user
// didn't set so partial styles work.
func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
style := map[string]interface{}{}
if v := runtime.Str("background-color"); v != "" {
style["background_color"] = v
}
if v := runtime.Str("font-color"); v != "" {
style["font_color"] = v
}
if v := runtime.Str("font-family"); v != "" {
style["font_family"] = v
}
if runtime.Changed("font-size") && runtime.Float64("font-size") > 0 {
style["font_size"] = runtime.Float64("font-size")
}
if v := runtime.Str("font-style"); v != "" {
style["font_style"] = v
}
if v := runtime.Str("font-weight"); v != "" {
style["font_weight"] = v
}
if v := runtime.Str("font-line"); v != "" {
style["font_line"] = v
}
if v := runtime.Str("horizontal-alignment"); v != "" {
style["horizontal_alignment"] = v
}
if v := runtime.Str("vertical-alignment"); v != "" {
style["vertical_alignment"] = v
}
if v := runtime.Str("word-wrap"); v != "" {
style["word_wrap"] = v
}
if v := runtime.Str("number-format"); v != "" {
style["number_format"] = v
}
return style
}
// cellStyleAliases maps shorthand cell_styles field names that models commonly
// hallucinate (Excel / openpyxl / CSS conventions) onto the canonical field
// names the backend expects. Only the unambiguous alignment shorthands are
// aliased — they are the high-frequency miss; ambiguous guesses (e.g. "color",
// "bg_color", "text_align") are intentionally left out so a wrong guess still
// surfaces as an error rather than being silently reinterpreted.
var cellStyleAliases = []struct{ alias, canonical string }{
{"horizontal_align", "horizontal_alignment"},
{"halign", "horizontal_alignment"},
{"vertical_align", "vertical_alignment"},
{"valign", "vertical_alignment"},
}
// normalizeCellStyleAliases renames known shorthand keys in a single
// cell_styles map to their canonical equivalents, in place, so a model that
// writes e.g. "horizontal_align" instead of "horizontal_alignment" still
// applies the style instead of hitting an "unsupported field" error (--styles)
// or having the field silently dropped by the backend (typed --cells). If both
// the shorthand and its canonical key are present it returns a validation error
// rather than picking one. path labels the map for the error message.
func normalizeCellStyleAliases(style map[string]interface{}, path string) error {
if len(style) == 0 {
return nil
}
for _, a := range cellStyleAliases {
v, ok := style[a.alias]
if !ok {
continue
}
if _, exists := style[a.canonical]; exists {
return common.ValidationErrorf("%s.%s conflicts with %s; pass only %s", path, a.alias, a.canonical, a.canonical)
}
style[a.canonical] = v
delete(style, a.alias)
}
return nil
}
// normalizeTypedCellsStyleAliases walks a typed --cells 2D array and applies
// normalizeCellStyleAliases to every cell's inline cell_styles object, so the
// alignment shorthands are accepted on +cells-set the same as on --styles.
// Structure is checked leniently to match the pass-through contract: any
// element that isn't the expected shape is skipped, not rejected.
func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
for r, rowRaw := range cells {
row, ok := rowRaw.([]interface{})
if !ok {
continue
}
for c, cellRaw := range row {
cell, ok := cellRaw.(map[string]interface{})
if !ok {
continue
}
st, ok := cell["cell_styles"].(map[string]interface{})
if !ok {
continue
}
if err := normalizeCellStyleAliases(st, fmt.Sprintf("%s[%d][%d].cell_styles", path, r, c)); err != nil {
return err
}
}
}
return nil
}
// borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/
// left/right with style sub-objects). Returns nil when the flag is empty.
func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
if runtime.Str("border-styles") == "" {
return nil, nil
}
v, err := parseJSONFlag(runtime, "border-styles")
if err != nil {
return nil, err
}
m, ok := v.(map[string]interface{})
if !ok {
return nil, sheetsValidationForFlag("border-styles", "--border-styles must be a JSON object")
}
return m, nil
}
// requireAnyStyleFlag ensures at least one style-defining flag (style or
// border) is set — otherwise the request would do nothing.
func requireAnyStyleFlag(runtime flagView) error {
if len(buildCellStyleFromFlags(runtime)) > 0 {
return nil
}
if runtime.Str("border-styles") != "" {
return nil
}
return common.ValidationErrorf("at least one style flag is required (e.g. --background-color, --font-weight, --border-styles)").
WithParams(
sheetsInvalidParam("background-color", "required; specify at least one style flag"),
sheetsInvalidParam("font-weight", "required; specify at least one style flag"),
sheetsInvalidParam("border-styles", "required; specify at least one style flag"),
)
}

View File

@@ -0,0 +1,209 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"strings"
"testing"
)
// TestWrapLoneCellObject pins the auto-wrap contract: a bare cell object —
// the classic missing-[[…]] shape agents produce for a 1×1 write — is
// rewritten to [[cell]]; anything whose meaning is not beyond doubt stays
// untouched for the schema validator to prescribe.
func TestWrapLoneCellObject(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
wrapped bool
}{
{"lone value cell", `{"value":"hi"}`, true},
{"lone formula cell with styles", `{"formula":"=SUM(A1:A3)","cell_styles":{"font_weight":"bold"}}`, true},
{"unknown key stays", `{"value":"hi","range":"A1"}`, false},
{"array of cells stays (row vs column ambiguous)", `[{"value":"a"},{"value":"b"}]`, false},
{"proper 2D array stays", `[[{"value":"a"}]]`, false},
{"empty object stays", `{}`, false},
{"scalar stays", `"hi"`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var v interface{}
if err := json.Unmarshal([]byte(tc.in), &v); err != nil {
t.Fatalf("bad fixture: %v", err)
}
out := wrapLoneCellObject(v)
_, isWrapped := out.([]interface{})
_, wasArray := v.([]interface{})
if tc.wrapped && (!isWrapped || wasArray) {
t.Errorf("expected wrap to [[cell]], got %#v", out)
}
if !tc.wrapped && !wasArray && isWrapped {
t.Errorf("expected no wrap, got %#v", out)
}
if tc.wrapped {
rows, _ := out.([]interface{})
if len(rows) != 1 {
t.Fatalf("want 1 row, got %d", len(rows))
}
cells, _ := rows[0].([]interface{})
if len(cells) != 1 {
t.Fatalf("want 1 cell, got %d", len(cells))
}
}
})
}
}
// TestCellObjectKeys_MatchEmbeddedSchema drift-guards the hardcoded cell
// vocabulary against the embedded +cells-set --cells schema: if the spec
// repo adds or removes a cell property, this fails and cellObjectKeys must
// be updated (an outdated set only narrows the auto-wrap, but silently
// narrowing is still drift).
func TestCellObjectKeys_MatchEmbeddedSchema(t *testing.T) {
t.Parallel()
idx, err := loadFlagSchemas()
if err != nil {
t.Fatalf("loadFlagSchemas: %v", err)
}
raw, ok := idx.Flags["+cells-set"]["cells"]
if !ok {
t.Fatal("embedded schema for +cells-set --cells missing")
}
var schema schemaProperty
if err := json.Unmarshal(raw, &schema); err != nil {
t.Fatalf("unmarshal schema: %v", err)
}
cell := schema.Items
if cell != nil && cell.Items != nil {
cell = cell.Items
}
if cell == nil || len(cell.Properties) == 0 {
t.Fatal("schema shape changed: expected array→array→object with properties")
}
for k := range cell.Properties {
if _, ok := cellObjectKeys[k]; !ok {
t.Errorf("schema property %q missing from cellObjectKeys", k)
}
}
for k := range cellObjectKeys {
if _, ok := cell.Properties[k]; !ok {
t.Errorf("cellObjectKeys has %q which the schema no longer declares", k)
}
}
}
// TestCellsSet_LoneCellObjectAutoWraps runs the mounted path end-to-end: the
// eval-trace failure shape (--cells with a bare object) now dry-runs clean
// instead of failing "expected type array, got object".
func TestCellsSet_LoneCellObjectAutoWraps(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-set")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A1",
"--cells", `{"value":"hello"}`,
"--dry-run",
})
if err != nil {
t.Fatalf("lone cell object should auto-wrap to [[cell]], got: %v", err)
}
if !strings.Contains(stdout, "hello") {
t.Errorf("dry-run body should carry the cell value, got %q", stdout)
}
}
// TestTablePut_SheetsDecodeHints pins the two decode-failure prescriptions:
// wrong JSON kind inlines the expected shape; mangled JSON steers to
// stdin/@file.
func TestTablePut_SheetsDecodeHints(t *testing.T) {
t.Parallel()
t.Run("type mismatch inlines skeleton", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+table-put")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheets", `{"sheets":[{"name":"s","columns":[{"name":"a"}],"data":[]}]}`,
"--dry-run",
})
ve := requireValidation(t, err, "--sheets: invalid JSON")
for _, want := range []string{"expected shape:", `"columns":["City","Revenue"]`, `"dtypes":{"Revenue":"float64"}`} {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
}
}
})
t.Run("syntax error steers to stdin or @file", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+table-put")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheets", `{"sheets":[)`,
"--dry-run",
})
ve := requireValidation(t, err, "--sheets: invalid JSON")
for _, want := range []string{"stdin", "@./payload.json"} {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
}
}
})
}
// TestNormalizeChartHexColors pins the '#' prefixing on bare hex color
// values (eval V2U024: bars.color "4472C4" rejected server-side) and the
// pass-through of everything else, including the parseJSONFlag wiring for
// the batch sub-op path.
func TestNormalizeChartHexColors(t *testing.T) {
t.Parallel()
props := map[string]interface{}{
"plotArea": map[string]interface{}{
"plot": map[string]interface{}{
"series": []interface{}{
map[string]interface{}{"bars": map[string]interface{}{"color": "4472C4"}},
map[string]interface{}{"line": map[string]interface{}{"color": "#ED7D31"}},
map[string]interface{}{"area": map[string]interface{}{"color": "rgba(1,2,3,0.5)"}},
map[string]interface{}{"font_color": "ED7D31AA", "label": "not a color 4472C4"},
},
},
},
}
normalizeChartHexColors(props)
series := props["plotArea"].(map[string]interface{})["plot"].(map[string]interface{})["series"].([]interface{})
if got := series[0].(map[string]interface{})["bars"].(map[string]interface{})["color"]; got != "#4472C4" {
t.Errorf("bare hex should gain #, got %v", got)
}
if got := series[1].(map[string]interface{})["line"].(map[string]interface{})["color"]; got != "#ED7D31" {
t.Errorf("already-prefixed color must not change, got %v", got)
}
if got := series[2].(map[string]interface{})["area"].(map[string]interface{})["color"]; got != "rgba(1,2,3,0.5)" {
t.Errorf("rgba color must not change, got %v", got)
}
last := series[3].(map[string]interface{})
if got := last["font_color"]; got != "#ED7D31AA" {
t.Errorf("8-digit hex on a *_color key should gain #, got %v", got)
}
if got := last["label"]; got != "not a color 4472C4" {
t.Errorf("non-color key must not change, got %v", got)
}
// Wiring: a +chart-create sub-op style view routes through parseJSONFlag
// and picks up the normalizer.
fv := newMapFlagViewForCommand("+chart-create", map[string]interface{}{
"properties": map[string]interface{}{"title": map[string]interface{}{"font_color": "112233"}},
})
out, err := parseJSONFlag(fv, "properties")
if err != nil {
t.Fatalf("parseJSONFlag: %v", err)
}
title := out.(map[string]interface{})["title"].(map[string]interface{})
if title["font_color"] != "#112233" {
t.Errorf("parseJSONFlag should apply the chart color normalizer, got %v", title["font_color"])
}
}

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