Compare commits

..

27 Commits

Author SHA1 Message Date
zhengzhijie
e450090ef6 fix(sheets): preserve batch scope and cross-sheet chart ranges 2026-07-31 12:34:52 +08:00
zhengzhijie
11205d2f8a feat(sheets): simplify batch chart operations 2026-07-29 12:06:19 +08:00
zhengzhijie
e665fa335c Merge remote-tracking branch 'origin/feat/lark-sheets-develop' into feat/chart-snapshot-detached-header
# Conflicts:
#	shortcuts/sheets/flag_ergonomics.go
#	shortcuts/sheets/flag_ergonomics_test.go
2026-07-29 12:05:26 +08:00
xiongyuanwen-byted
f79908483d fix(sheets): reject path-shaped --csv values instead of writing them into the sheet
A +csv-put --csv value naming a file that doesn't resolve used to be written
into the anchor cell as literal text, with a success exit code — a wrong value
in the sheet that nothing surfaces, which costs more than a rejection. The
common source is an absolute path: @ only reads relative paths, so the caller
drops the @ and retries, and the path string lands in A1 (07-28 root-cause
audit, finding D1). The existing guard only caught values naming a file that
does exist (the forgotten-@ case).

The guard now also rejects a value that is unmistakably path-shaped: no
separator or whitespace, pure ASCII, and either a .csv/.tsv extension or an
explicit ./ ../ / ~/ prefix. All three conditions are required — that is what
keeps prose that merely mentions a filename, N/A, README.md and CJK content
out of it, the misjudgments that retired the previous name-shape heuristic.
This flips one pinned case: a bare "nope.csv" was previously written verbatim
and now errs with the fix inlined.

To make the shape check safe for correct invocations, resolveInputFlags now
records which flags had their value replaced from @file or stdin, exposed as
RuntimeContext.InputResolvedFromSource; the guard skips resolved values
entirely. By Validate time a piped value is indistinguishable from a typed
one, so without the origin bit the guard would re-reject a correct
`--csv @file` whose content happens to look like a path — and stdin, which
the error text prescribes for verbatim writes, would not actually escape it.
The @@ escape stays inline and guarded. This origin bit is the one
common-layer addition; it carries no domain logic.
2026-07-29 12:00:47 +08:00
xiongyuanwen-byted
52b5910fb1 chore(sheets): sync lark-sheets skill and flag data from sheet-skill-spec
Mirrors `npm run sync:cli` output from the spec repo, which is the source of
truth for skill docs and flag data. Two independent changes ride along:

- The border and sheet-selector flag descriptions from spec commit 3dd7f9c,
  matching the help text already committed here in 0b595562 (data/flag-defs.json
  is byte-identical, so `go generate` is a no-op).
- The upstream read-flow work: SKILL.md 3.1.0 to 3.1.1, a longer read-data
  reference, and five read-side helper scripts under skills/lark-sheets/scripts.

The scripts land as machine resources and are not embedded in the binary
(content_embed.go whitelists docs only). make unit-test passes.
2026-07-28 21:06:13 +08:00
xiongyuanwen-byted
0b59556207 feat(sheets): name the enum, the required selector and the missing envelope in errors and help
Second batch from the 07-28 root-cause analysis, all aimed at the retry that
follows a rejection.

Enum-bearing type mismatches now answer with the allowed values instead of a
whole-payload skeleton. --border-styles with weight:1 used to reply "expected
type string, got number; expected shape: {"bottom": {…}, "left": {…}, …}",
which never mentions thin/medium/thick; the skeleton is for container-shape
confusion, so a field that declares an enum falls through to the hint that
names it. The --border-styles help now inlines both vocabularies (style is the
line type, weight the thickness and a string, not a pixel number), spells the
{all:{…}} shorthand, and states that no --border-all / --border-top /
--border-color exist. --word-wrap additionally accepts the Google Sheets
wrapStrategy words wrap and clip.

The sheet selector states that one of the pair is required rather than only
that they are mutually exclusive, in help across all shortcuts that take it,
and the rejection hints where the name comes from: a fresh workbook has one
sheet named Sheet1, any other needs a +workbook-info lookup. Eval traces
recover on the very next call, so the gap was which name to pass.

A --sheets payload written as a bare array now says the top level must be
{"sheets":[…]} instead of quoting Go's "cannot unmarshal array into Go value
of type struct { Sheets []sheets.tableSheetIn }", which names the internal
type rather than the fix. The skeleton hint is unchanged.
2026-07-28 20:47:48 +08:00
xiongyuanwen-byted
91743bba99 fix(sheets): make border vocabulary normalization reachable, prescribe flag and style-field fixes
Three fixes from the 07-28 root-cause analysis of failed agent traces, all
aimed at the first-try success rate rather than the recovery loop.

Border acceptance layer was unreachable on two of its three carrier paths.
expandBorderAllShorthand already moves a weight word out of the style slot
({"style":"thin"} -> style solid + weight thin), but on --border-styles and
typed --cells it ran after parseJSONFlag's schema check, so the enum error
fired first and the rewrite never happened. Move it ahead of validation via
the jsonFlagNormalizers seam; --styles already validated post-expansion and
is unchanged. An explicitly conflicting weight still takes the enum error.

Unknown-flag prescriptions for the names agents reach for most: +cells-set
--values, +dim-freeze --frozen-row-count and siblings, +cells-set-style
--font-bold / --bg-color / --wrap-strategy and the whole --border-* family
(no such flags; borders take one composite --border-styles). +sheet-rename
--new-name / --name alias to --title, matching +sheet-create.

Unsupported cell_styles field names now name the right field instead of the
nearest string: bold / font_bold -> font_weight, text_align ->
horizontal_alignment, a nested openpyxl-style font object -> the flat font_*
fields. Where no prescription applies, the edit-distance fallback is capped
at two edits, so a concept-swap neighbour (font_bold -> font_color, three
edits) stays silent rather than sending the retry the wrong way; a curated
prescription also drops the contradicting machine-readable suggestions.
2026-07-28 20:30:23 +08:00
zhengzhijie
0f93cadc4b fix(sheets): persist chart color theme updates 2026-07-28 17:01:28 +08:00
zhengzhijie
2459a2bb20 fix(sheets): allow chart color theme patches 2026-07-28 16:31:20 +08:00
zhengzhijie
ee71d92d5f feat(sheets): add dedicated chart batch shortcuts 2026-07-28 15:55:54 +08:00
zhengzhijie
f32448536d feat(sheets): improve chart creation dimension handling 2026-07-27 21:13:38 +08:00
zhengzhijie
62a32439f3 feat(sheets): harden chart update workflows 2026-07-27 14:15:14 +08:00
zhengzhijie
78c9653e8e feat(sheets): add chart data update shortcut 2026-07-27 12:00:50 +08:00
zhengzhijie
28d2bdc7a9 fix(sheets): normalize irregular chart ranges 2026-07-27 11:59:49 +08:00
zhengzhijie
69a9210a85 fix(sheets): normalize chart range and flag inputs 2026-07-27 11:59:49 +08:00
zhengzhijie
b9046f9ce9 fix(sheets): prefer semantic chart shortcuts 2026-07-27 11:59:48 +08:00
zhengzhijie
2dd4419d3b feat(sheets): improve semantic chart workflows 2026-07-27 11:59:47 +08:00
zhengzhijie
84c6d23e79 feat(sheets): add semantic chart shortcuts 2026-07-27 11:58:46 +08:00
zhengzhijie
6e20fd6e93 feat(sheets): support partial chart snapshot schemas 2026-07-27 11:58:45 +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
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
353 changed files with 20241 additions and 30631 deletions

View File

@@ -10,10 +10,9 @@
## Build & Test
```bash
make build # Build (runs fetch_meta first)
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
make live-skills-test # Opt-in real Skills CLI tests; runs with isolated user directories
make test # Full: vet + unit + integration
make build # Build (runs fetch_meta first)
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
make test # Full: vet + unit + integration
```
## Notification Opt-Outs

View File

@@ -2,62 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.74] - 2026-07-21
### Features
- **slides**: add history rollback shortcuts (#1714)
- **base**: support per-record batch updates (#1889)
### Bug Fixes
- preserve slides schema issues
- allow jq examples in quality gate dry-runs
- **im**: warn when flag pagination is truncated (#1906)
- **slides**: warn on text shape overflow
- **slides**: exempt chart roundtrip attributes from lint
- **slides**: detect image text occlusion
- **slides**: clarify xml-text-overlap-lint error for positional argument (#1986)
### Documentation
- clarify drive upload overwrite guidance (#1982)
### Tests
- isolate unit tests from user state (#1883)
### Refactoring
- converge success output through a single Emitter that owns the write (#1899)
## [v1.0.73] - 2026-07-20
### Features
- **apps**: design_html support, creative-design skill, unified TOS publish (#1901)
### Bug Fixes
- **slides**: detect visual elements outside canvas
- reduce public content credential fixture false positives
- standardize CLI shortcut text in English (#1942)
### Documentation
- **base**: reduce filter and update retry loops (#1879)
- **vc**: default transcript routing to smart notes over minutes (#1961)
- clarify local trigger automation (#1958)
### Tests
- synchronize temporary Git maintenance (#1946)
### Misc
- **slides**: update lark-slides skill to 0715 snapshot (#1933)
- [codex] support bot menu events (#1765)
## [v1.0.72] - 2026-07-17
### Features
@@ -1608,8 +1552,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
[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

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 live-skills-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
.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
@@ -58,11 +58,6 @@ unit-test: fetch_meta
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
./cmd/... ./internal/... ./shortcuts/... ./extension/...
live-skills-test: fetch_meta
LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS=1 \
go test -v -count=1 ./cmd/update \
-run '^TestUpdateCommand_(RealSkillsSyncRewritesState|SkillsSyncColdStart)$$'
# examples-build keeps the shipped plugin-SDK examples compilable. If this
# breaks, the plugin author guide's "go build ./..." path is broken.
examples-build:

View File

@@ -344,18 +344,20 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
NoticeProvider: output.GetNotice,
})
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
scanResult := output.ScanForSafety(commandPath, items, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
pf.FormatPage(items)
return nil
}, pagOpts)
if err != nil {
return errs.MarkRaw(err)

View File

@@ -1,396 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
type apiFailOnWriteWriter struct {
buf bytes.Buffer
writes int
failAt int
err error
}
func (w *apiFailOnWriteWriter) Write(p []byte) (int, error) {
w.writes++
if w.writes == w.failAt {
return 0, w.err
}
return w.buf.Write(p)
}
func newAPIPaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
previousNotice := output.PendingNotice
output.PendingNotice = nil
t.Cleanup(func() { output.PendingNotice = previousNotice })
config := &core.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: core.BrandFeishu,
}
f, out, errOut, reg := cmdutil.TestFactory(t, config)
ac, err := f.NewAPIClientWithConfig(config)
if err != nil {
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
}
ac.ErrOut = io.Discard
return ac, out, errOut, reg
}
func apiPaginateRequest() client.RawApiRequest {
return client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test/v1/items",
As: core.AsBot,
}
}
func assertAPIPaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
t.Helper()
wantBytes, err := json.MarshalIndent(want, "", " ")
if err != nil {
t.Fatalf("marshal expected JSON: %v", err)
}
wantBytes = append(wantBytes, '\n')
if !bytes.Equal(got, wantBytes) {
t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes)
}
}
func TestAPIPaginate_DefaultAggregatesAllPages(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
calls := 0
wantTokens := []string{"", "next-1", "next-2"}
for i, wantToken := range wantTokens {
page := i + 1
hasMore := page < len(wantTokens)
data := map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}},
"has_more": hasMore,
}
if hasMore {
data["page_token"] = wantTokens[page]
}
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
OnMatch: func(req *http.Request) {
calls++
if got := req.URL.Query().Get("page_token"); got != wantToken {
t.Errorf("request %d page_token = %q, want %q", page, got, wantToken)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
})
if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
}
if calls != 3 {
t.Fatalf("pagination requests = %d, want 3", calls)
}
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1"},
map[string]interface{}{"id": "2"},
map[string]interface{}{"id": "3"},
},
"has_more": false,
},
})
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
}
func TestAPIPaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
tests := []struct {
name string
format output.Format
want string
}{
{
name: "ndjson",
format: output.FormatNDJSON,
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Carol\",\"page_only\":\"ignored\"}\n",
},
{
name: "table",
format: output.FormatTable,
want: "id name \n── ─────\n1 Alice\n2 Carol\n",
},
{
name: "csv",
format: output.FormatCSV,
want: "id,name\n1,Alice\n2,Carol\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "name": "Alice"},
},
"has_more": true,
"page_token": "next-1",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "2", "name": "Carol", "page_only": "ignored"},
},
"has_more": false,
},
},
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
})
if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
}
if got := out.String(); got != tt.want {
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestAPIPaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
ac, _, errOut, reg := newAPIPaginateTestHarness(t)
sentinel := errors.New("page write failed")
out := &apiFailOnWriteWriter{failAt: 2, err: sentinel}
calls := 0
for page := 1; page <= 2; page++ {
hasMore := true
data := map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": page}},
"has_more": hasMore,
}
if hasMore {
data["page_token"] = fmt.Sprintf("next-%d", page)
}
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
OnMatch: func(*http.Request) {
calls++
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET",
client.PaginationOptions{PageLimit: 10, PageDelay: -1})
if !errors.Is(err, sentinel) {
t.Fatalf("apiPaginate() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("apiPaginate() problem = %#v, %v; want internal typed error", problem, ok)
}
if calls != 2 {
t.Fatalf("pagination requests = %d, want 2", calls)
}
if got, want := out.buf.String(), "{\"id\":1}\n"; got != want {
t.Fatalf("stdout bytes = %q, want %q", got, want)
}
}
func TestAPIPaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
},
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
}
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
})
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
if got := errOut.String(); got != wantWarning {
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
}
}
func TestAPIPaginate_BusinessErrorsWriteRawAndAreMarkedRaw(t *testing.T) {
businessResponse := map[string]interface{}{
"code": 123456,
"msg": "fixture business error",
"data": map[string]interface{}{"detail": "business failed"},
}
tests := []struct {
name string
format output.Format
jqExpr string
}{
{name: "jq", format: output.FormatJSON, jqExpr: ".data.items"},
{name: "default_json", format: output.FormatJSON},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: businessResponse,
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err == nil {
t.Fatal("apiPaginate() error = nil, want business error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
}
assertAPIPaginateJSONBytes(t, out.Bytes(), businessResponse)
if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) {
t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes())
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestAPIPaginate_TransportErrorsAreMarkedRaw(t *testing.T) {
tests := []struct {
name string
format output.Format
jqExpr string
}{
{name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"},
{name: "stream_pages", format: output.FormatNDJSON},
{name: "default_paginate_all", format: output.FormatJSON},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, _ := newAPIPaginateTestHarness(t)
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err == nil {
t.Fatal("apiPaginate() error = nil, want transport error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
}
if got := out.String(); got != "" {
t.Fatalf("stdout bytes = %q, want empty", got)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestAPIPaginate_StreamBusinessErrorIsMarkedRaw(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 123456,
"msg": "fixture business error",
"data": map[string]interface{}{},
},
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err == nil {
t.Fatal("apiPaginate() error = nil, want business error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
}
if got := out.String(); got != "" {
t.Fatalf("stdout bytes = %q, want empty", got)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
}

View File

@@ -352,9 +352,6 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
}
func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu,
})
@@ -374,33 +371,8 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
if !strings.Contains(stderr.String(), "binary response detected") {
t.Error("expected binary response hint in stderr")
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("stdout is not JSON: %v\nstdout:\n%s", err, stdout.String())
}
savedPath, _ := got["saved_path"].(string)
if savedPath == "" {
t.Fatalf("saved_path missing from output: %#v", got)
}
// The file must land inside the temporary cwd — this pins the isolation
// contract: rolling back TestChdir would leave download.bin in the repo.
wantDir, err := filepath.EvalSymlinks(dir)
if err != nil {
t.Fatal(err)
}
gotDir, err := filepath.EvalSymlinks(filepath.Dir(savedPath))
if err != nil {
t.Fatalf("saved_path %q dir not resolvable: %v", savedPath, err)
}
if gotDir != wantDir {
t.Errorf("saved_path %q is outside temp cwd %q", savedPath, wantDir)
}
content, err := os.ReadFile(savedPath)
if err != nil {
t.Fatalf("read saved file: %v", err)
}
if string(content) != "fake-binary-content" {
t.Errorf("saved file content = %q, want %q", content, "fake-binary-content")
if !strings.Contains(stdout.String(), "saved_path") {
t.Error("expected saved_path in output")
}
}

View File

@@ -1,46 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/internal/registry/registrytest"
)
// TestMain isolates auth command tests from the host machine: config, logs
// and the registry cache are redirected to a temp dir, then the registry is
// seeded from the tracked fixture and initialized eagerly. Domain-completion
// tests read the registry, so without seeding a clean checkout would either
// fail or trigger a remote metadata fetch.
//
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
// m.Run before exiting.
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-cmd-auth-test-*")
if err != nil {
println("cmd/auth test setup: MkdirTemp failed:", err.Error())
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
println("cmd/auth test setup: Setenv failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(root, "logs")); err != nil {
println("cmd/auth test setup: Setenv failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
if err := registrytest.Seed(root); err != nil {
println("cmd/auth test setup: registrytest.Seed failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
code := m.Run()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -371,11 +371,10 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
catalog := strictModeFixtureCatalog()
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--as", "user", "--dry-run",
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
})
if code != output.ExitValidation {

View File

@@ -707,18 +707,20 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
NoticeProvider: output.GetNotice,
})
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
scanResult := output.ScanForSafety(commandPath, items, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
pf.FormatPage(items)
return nil
}, pagOpts)
if err != nil {
return err

View File

@@ -1,400 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
type serviceFailOnWriteWriter struct {
buf bytes.Buffer
writes int
failAt int
err error
}
func (w *serviceFailOnWriteWriter) Write(p []byte) (int, error) {
w.writes++
if w.writes == w.failAt {
return 0, w.err
}
return w.buf.Write(p)
}
func newServicePaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
previousNotice := output.PendingNotice
output.PendingNotice = nil
t.Cleanup(func() { output.PendingNotice = previousNotice })
config := &core.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: core.BrandFeishu,
}
f, out, errOut, reg := cmdutil.TestFactory(t, config)
ac, err := f.NewAPIClientWithConfig(config)
if err != nil {
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
}
ac.ErrOut = io.Discard
return ac, out, errOut, reg
}
func servicePaginateRequest() client.RawApiRequest {
return client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test/v1/items",
As: core.AsBot,
}
}
func assertServicePaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
t.Helper()
wantBytes, err := json.MarshalIndent(want, "", " ")
if err != nil {
t.Fatalf("marshal expected JSON: %v", err)
}
wantBytes = append(wantBytes, '\n')
if !bytes.Equal(got, wantBytes) {
t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes)
}
}
func TestServicePaginate_DefaultAggregatesAllPages(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
calls := 0
wantTokens := []string{"", "next-1", "next-2"}
for i, wantToken := range wantTokens {
page := i + 1
hasMore := page < len(wantTokens)
data := map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}},
"has_more": hasMore,
}
if hasMore {
data["page_token"] = wantTokens[page]
}
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
OnMatch: func(req *http.Request) {
calls++
if got := req.URL.Query().Get("page_token"); got != wantToken {
t.Errorf("request %d page_token = %q, want %q", page, got, wantToken)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatJSON, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
}, ac.CheckResponse)
if err != nil {
t.Fatalf("servicePaginate() error = %v, want nil", err)
}
if calls != 3 {
t.Fatalf("pagination requests = %d, want 3", calls)
}
assertServicePaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1"},
map[string]interface{}{"id": "2"},
map[string]interface{}{"id": "3"},
},
"has_more": false,
},
})
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
}
func TestServicePaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
tests := []struct {
name string
format output.Format
want string
}{
{
name: "ndjson",
format: output.FormatNDJSON,
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Carol\",\"page_only\":\"ignored\"}\n",
},
{
name: "table",
format: output.FormatTable,
want: "id name \n── ─────\n1 Alice\n2 Carol\n",
},
{
name: "csv",
format: output.FormatCSV,
want: "id,name\n1,Alice\n2,Carol\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "name": "Alice"},
},
"has_more": true,
"page_token": "next-1",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "2", "name": "Carol", "page_only": "ignored"},
},
"has_more": false,
},
},
})
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
tt.format, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
}, ac.CheckResponse)
if err != nil {
t.Fatalf("servicePaginate() error = %v, want nil", err)
}
if got := out.String(); got != tt.want {
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestServicePaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
ac, _, errOut, reg := newServicePaginateTestHarness(t)
sentinel := errors.New("page write failed")
out := &serviceFailOnWriteWriter{failAt: 2, err: sentinel}
calls := 0
for page := 1; page <= 2; page++ {
hasMore := true
data := map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": page}},
"has_more": hasMore,
}
if hasMore {
data["page_token"] = fmt.Sprintf("next-%d", page)
}
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
OnMatch: func(*http.Request) {
calls++
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
client.PaginationOptions{PageLimit: 10, PageDelay: -1}, ac.CheckResponse)
if !errors.Is(err, sentinel) {
t.Fatalf("servicePaginate() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("servicePaginate() problem = %#v, %v; want internal typed error", problem, ok)
}
if calls != 2 {
t.Fatalf("pagination requests = %d, want 2", calls)
}
if got, want := out.buf.String(), "{\"id\":1}\n"; got != want {
t.Fatalf("stdout bytes = %q, want %q", got, want)
}
}
func TestServicePaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
},
})
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli test items get",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
if err != nil {
t.Fatalf("servicePaginate() error = %v, want nil", err)
}
assertServicePaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
})
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
if got := errOut.String(); got != wantWarning {
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
}
}
func TestServicePaginate_BusinessErrorsWriteRawAndRemainUnmarked(t *testing.T) {
businessResponse := map[string]interface{}{
"code": 123456,
"msg": "fixture business error",
"data": map[string]interface{}{"detail": "business failed"},
}
tests := []struct {
name string
format output.Format
jqExpr string
}{
{name: "jq", format: output.FormatJSON, jqExpr: ".data.items"},
{name: "default_json", format: output.FormatJSON},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: businessResponse,
})
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
if err == nil {
t.Fatal("servicePaginate() error = nil, want business error")
}
if errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
}
assertServicePaginateJSONBytes(t, out.Bytes(), businessResponse)
if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) {
t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes())
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestServicePaginate_TransportErrorsRemainUnmarked(t *testing.T) {
tests := []struct {
name string
format output.Format
jqExpr string
}{
{name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"},
{name: "stream_pages", format: output.FormatNDJSON},
{name: "default_paginate_all", format: output.FormatJSON},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, _ := newServicePaginateTestHarness(t)
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
if err == nil {
t.Fatal("servicePaginate() error = nil, want transport error")
}
if errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
}
if got := out.String(); got != "" {
t.Fatalf("stdout bytes = %q, want empty", got)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestServicePaginate_StreamBusinessErrorRemainsUnmarked(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 123456,
"msg": "fixture business error",
"data": map[string]interface{}{},
},
})
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
if err == nil {
t.Fatal("servicePaginate() error = nil, want business error")
}
if errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
}
if got := out.String(); got != "" {
t.Fatalf("stdout bytes = %q, want empty", got)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
}

View File

@@ -1,39 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"os"
"testing"
"github.com/larksuite/cli/internal/registry/registrytest"
)
// TestMain isolates service command tests from the host machine: config (and
// the registry cache under it) is redirected to a temp dir, then the registry
// is seeded from the tracked fixture and initialized eagerly. Tests pass on a
// clean checkout with no network, no `make fetch_meta`, and no user cache.
//
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
// m.Run before exiting.
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-cmd-service-test-*")
if err != nil {
println("cmd/service test setup: MkdirTemp failed:", err.Error())
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", root); err != nil {
println("cmd/service test setup: Setenv failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
if err := registrytest.Seed(root); err != nil {
println("cmd/service test setup: registrytest.Seed failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
code := m.Run()
os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -5,7 +5,6 @@ package cmd
import (
"context"
"flag"
"fmt"
"os"
"os/exec"
@@ -13,34 +12,11 @@ import (
"strings"
"testing"
"github.com/google/uuid"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/registry"
)
const startupBrandHelperEnv = "GO_TEST_STARTUP_BRAND_HELPER"
var _ = flag.String("startup-brand-helper", "", "internal startup brand test helper nonce")
func isStartupBrandHelper() bool {
return startupBrandHelperEnabled(os.Getenv(startupBrandHelperEnv), startupBrandHelperNonce(os.Args))
}
func startupBrandHelperEnabled(envNonce, argNonce string) bool {
return envNonce != "" && envNonce == argNonce
}
func startupBrandHelperNonce(args []string) string {
const prefix = "-startup-brand-helper="
for _, arg := range args {
if strings.HasPrefix(arg, prefix) {
return strings.TrimPrefix(arg, prefix)
}
}
return ""
}
func TestResolveStartupBrand_Precedence(t *testing.T) {
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
@@ -78,7 +54,7 @@ func TestResolveStartupBrand_Precedence(t *testing.T) {
// sync.Once, so the brand must be injected before the first catalog access.
// It runs in a subprocess because the registry is process-global.
func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
if isStartupBrandHelper() {
if os.Getenv("GO_TEST_STARTUP_BRAND_HELPER") == "1" {
// Helper: replicate Execute()'s build wiring with a lark config.
buildInternal(
context.Background(), cmdutil.InvocationContext{},
@@ -95,11 +71,9 @@ func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
t.Fatal(err)
}
nonce := uuid.NewString()
t.Setenv(startupBrandHelperEnv, nonce)
cmd := exec.Command(os.Args[0], "-test.run", "TestStartupBrandReachesRegistry_RealStartupOrder")
cmd.Args = append(cmd.Args, "-startup-brand-helper="+nonce)
cmd.Env = append(os.Environ(),
"GO_TEST_STARTUP_BRAND_HELPER=1",
"LARKSUITE_CLI_CONFIG_DIR="+tmp,
"LARKSUITE_CLI_REMOTE_META=off", // no network during the subprocess build
)
@@ -111,33 +85,3 @@ func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
t.Errorf("registry brand after real startup order = %s, want lark", out)
}
}
func TestStartupBrandHelperRequiresMatchingCommandNonce(t *testing.T) {
for _, tt := range []struct {
name string
envNonce string
argNonce string
want bool
}{
{name: "neither set"},
{name: "ambient environment only", envNonce: "ambient"},
{name: "command argument only", argNonce: "command"},
{name: "mismatch", envNonce: "ambient", argNonce: "command"},
{name: "matching", envNonce: "nonce", argNonce: "nonce", want: true},
} {
t.Run(tt.name, func(t *testing.T) {
if got := startupBrandHelperEnabled(tt.envNonce, tt.argNonce); got != tt.want {
t.Fatalf("startupBrandHelperEnabled() = %v, want %v", got, tt.want)
}
})
}
}
func TestStartupBrandHelperNonce(t *testing.T) {
if got := startupBrandHelperNonce([]string{"test", "-test.run", "brand"}); got != "" {
t.Fatalf("startupBrandHelperNonce() = %q, want empty", got)
}
if got := startupBrandHelperNonce([]string{"test", "-startup-brand-helper=nonce"}); got != "nonce" {
t.Fatalf("startupBrandHelperNonce() = %q, want nonce", got)
}
}

View File

@@ -1,46 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"os"
"testing"
"github.com/larksuite/cli/internal/registry/registrytest"
)
// TestMain isolates command-tree tests from the host machine: config (and the
// registry cache under it) is redirected to a temp dir, then the registry is
// seeded from the tracked fixture and initialized eagerly. Tests pass on a
// clean checkout with no network, no `make fetch_meta`, and no user cache.
//
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
// m.Run before exiting.
func TestMain(m *testing.M) {
if isStartupBrandHelper() {
// Re-exec helper subprocess (startup_brand_test.go): the parent test
// already provides an isolated config dir and disables remote metadata,
// and the helper must own the first registry Init to prove the startup
// order — do not seed or eagerly initialize here.
os.Exit(m.Run())
}
root, err := os.MkdirTemp("", "lark-cli-cmd-test-*")
if err != nil {
println("cmd test setup: MkdirTemp failed:", err.Error())
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", root); err != nil {
println("cmd test setup: Setenv failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
if err := registrytest.Seed(root); err != nil {
println("cmd test setup: registrytest.Seed failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
code := m.Run()
os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -1,23 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdupdate
import (
"os"
"path/filepath"
"testing"
)
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-update-test-*")
if err != nil {
panic(err)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
panic(err)
}
code := m.Run()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -24,8 +24,6 @@ import (
"github.com/larksuite/cli/internal/skillscheck"
)
const runLiveSkillsTestsEnv = "LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS"
// newTestFactory creates a test factory with minimal config.
func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
@@ -33,17 +31,13 @@ func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffe
return f, stdout, stderr
}
// mockDetect sets up newUpdater to return an Updater with the given DetectResult
// and fully mocked skills operations. Tests that only care about install-method
// detection must never fall through to the real npx skills CLI.
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
@@ -110,18 +104,6 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
}
}
func mockSkillsSync(t *testing.T) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
func TestUpdatePnpm_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
@@ -246,9 +228,6 @@ func TestNormalizeVersion(t *testing.T) {
}
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
mockSkillsSync(t)
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
@@ -277,9 +256,6 @@ func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
}
func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
mockSkillsSync(t)
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
@@ -305,7 +281,6 @@ func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
}
func TestUpdateManual_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
@@ -337,7 +312,6 @@ func TestUpdateManual_JSON(t *testing.T) {
}
func TestUpdateManual_Human(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
@@ -1187,7 +1161,6 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
}
called := false
updater := &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
called = true
return successfulSkillsCommand()(args...)
@@ -1204,10 +1177,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
updater := &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: successfulSkillsCommand(),
}
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
@@ -1227,7 +1197,6 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
t.Fatal(err)
}
updater := &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("npx failed")
@@ -1544,133 +1513,28 @@ func TestEmitSkillsTextHints_Success(t *testing.T) {
}
}
// liveSkillsIsolationEnv is the single source of truth for the user-state
// directories a live skills test must redirect under the temporary home. It
// covers the CLI's own config, the agent homes the skills CLI installs into,
// the XDG dirs it derives paths from (XDG_STATE_HOME holds its global
// .skill-lock.json), and the npm/npx overrides that take precedence over
// HOME-derived defaults (both cases: npm reads npm_config_* case-insensitively).
func liveSkillsIsolationEnv(home string) map[string]string {
return map[string]string{
"HOME": home,
"USERPROFILE": home,
"APPDATA": filepath.Join(home, "AppData", "Roaming"),
"LOCALAPPDATA": filepath.Join(home, "AppData", "Local"),
"XDG_CONFIG_HOME": filepath.Join(home, ".config"),
"XDG_DATA_HOME": filepath.Join(home, ".local", "share"),
"XDG_STATE_HOME": filepath.Join(home, ".local", "state"),
"CODEX_HOME": filepath.Join(home, ".codex"),
"CLAUDE_CONFIG_DIR": filepath.Join(home, ".claude"),
"LARKSUITE_CLI_CONFIG_DIR": filepath.Join(home, ".lark-cli"),
"npm_config_cache": filepath.Join(home, ".npm-cache"),
"NPM_CONFIG_CACHE": filepath.Join(home, ".npm-cache"),
"npm_config_prefix": filepath.Join(home, ".npm-global"),
"NPM_CONFIG_PREFIX": filepath.Join(home, ".npm-global"),
"npm_config_userconfig": filepath.Join(home, ".npmrc"),
"NPM_CONFIG_USERCONFIG": filepath.Join(home, ".npmrc"),
}
}
func prepareLiveSkillsIntegration(t *testing.T) string {
t.Helper()
if os.Getenv(runLiveSkillsTestsEnv) != "1" {
t.Skipf("live skills integration test disabled; set %s=1 to run", runLiveSkillsTestsEnv)
}
home := t.TempDir()
for key, value := range liveSkillsIsolationEnv(home) {
t.Setenv(key, value)
}
return home
}
func TestPrepareLiveSkillsIntegration(t *testing.T) {
reachedAfterGate := false
t.Run("requires explicit opt-in", func(t *testing.T) {
t.Setenv(runLiveSkillsTestsEnv, "")
prepareLiveSkillsIntegration(t)
reachedAfterGate = true
})
if reachedAfterGate {
t.Fatal("prepareLiveSkillsIntegration continued without explicit opt-in")
}
t.Run("isolates user directories", func(t *testing.T) {
t.Setenv(runLiveSkillsTestsEnv, "1")
home := prepareLiveSkillsIntegration(t)
// Pin the isolation contract by key: removing a variable from
// liveSkillsIsolationEnv must fail this list, and every redirected
// value must live under the temporary home.
required := []string{
"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA",
"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME",
"CODEX_HOME", "CLAUDE_CONFIG_DIR", "LARKSUITE_CLI_CONFIG_DIR",
"npm_config_cache", "NPM_CONFIG_CACHE",
"npm_config_prefix", "NPM_CONFIG_PREFIX",
"npm_config_userconfig", "NPM_CONFIG_USERCONFIG",
}
env := liveSkillsIsolationEnv(home)
for _, key := range required {
expected, ok := env[key]
if !ok {
t.Errorf("liveSkillsIsolationEnv dropped required key %s", key)
continue
}
if !strings.HasPrefix(expected, home) {
t.Errorf("%s = %q escapes temporary home %q", key, expected, home)
}
if got := os.Getenv(key); got != expected {
t.Errorf("%s = %q, want %q", key, got, expected)
}
}
})
}
// seedLiveSkillsGlobal verifies the real npx skills CLI is reachable, installs
// lark-calendar into the isolated global skills dir, and returns the parsed
// global skills list. The caller opted in explicitly, so every missing
// precondition is a hard failure — skipping would report "nothing verified"
// as a green run.
func seedLiveSkillsGlobal(t *testing.T) []string {
t.Helper()
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
// state file. It calls the real npx skills CLI, so the test is skipped when
// npx or the skills registry is unavailable (e.g. no network or fork PRs).
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
if _, err := exec.LookPath("npx"); err != nil {
t.Fatalf("live skills tests opted in but npx not found in PATH: %v", err)
t.Skipf("npx not found in PATH: %v", err)
}
// Three sequential npx runs against a cold cache (the isolated home starts
// empty) can be slow; with Fatal-on-timeout semantics the budget errs on
// the generous side.
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
t.Fatalf("live skills tests opted in but real skills CLI unavailable: %v", err)
}
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "-s", "lark-calendar", "-g", "-y").Run(); err != nil {
t.Fatalf("failed to seed isolated global skills: %v", err)
t.Skipf("real skills CLI unavailable: %v", err)
}
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
if err != nil {
t.Fatalf("real global skills CLI unavailable: %v", err)
t.Skipf("real global skills CLI unavailable: %v", err)
}
localSkills := skillscheck.ParseSkillsList(string(globalOut))
if len(localSkills) == 0 {
t.Fatal("seeded lark-calendar but global skills list is empty")
}
if err := ctx.Err(); err != nil {
t.Fatalf("real skills CLI availability check timed out: %v", err)
t.Skipf("real skills CLI availability check timed out: %v", err)
}
return localSkills
}
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
// state file. It calls the real npx skills CLI and only runs with explicit
// opt-in. All user directories are redirected to a temporary home.
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
prepareLiveSkillsIntegration(t)
// Phase 1: Verify the real npx skills CLI is available and seed the
// isolated global skills install.
localSkills := seedLiveSkillsGlobal(t)
// Phase 2: Seed a previous sync state simulating an upgrade from v1.0.19.
// lark-doc and lark-mail are recorded as skipped/deleted, meaning the user
@@ -1766,17 +1630,26 @@ func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
// not exist (cold start), the update command installs all official skills and
// writes a fresh state file. No skill should appear in SkippedDeletedSkills
// because there is no previous state to preserve user deletions from.
// This is a live integration test that calls the real npx skills CLI and only
// runs with explicit opt-in. All user directories are redirected to a temporary
// home.
// This is a live integration test that calls the real npx skills CLI; it is
// skipped when npx or the skills registry is unavailable.
func TestUpdateCommand_SkillsSyncColdStart(t *testing.T) {
prepareLiveSkillsIntegration(t)
// Phase 1: Verify the real npx skills CLI is available and seed one known
// official skill into the isolated global install. Cold start means no
// skills-state.json — locally installed skills may still exist, and seeding
// one keeps the Phase 4 per-skill assertions from running zero times.
localSkills := seedLiveSkillsGlobal(t)
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
if _, err := exec.LookPath("npx"); err != nil {
t.Skipf("npx not found in PATH: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
t.Skipf("real skills CLI unavailable: %v", err)
}
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
if err != nil {
t.Skipf("real global skills CLI unavailable: %v", err)
}
localSkills := skillscheck.ParseSkillsList(string(globalOut))
if err := ctx.Err(); err != nil {
t.Skipf("real skills CLI availability check timed out: %v", err)
}
// Phase 2: Use an isolated config dir with no pre-existing skills-state.json.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())

View File

@@ -1,107 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"encoding/json"
"strings"
"github.com/larksuite/cli/internal/event"
)
// BotMenuOutput is the flattened shape for application.bot.menu_v6.
type BotMenuOutput struct {
Type string `json:"type" desc:"Event type; always application.bot.menu_v6"`
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); prefers header.create_time" kind:"timestamp_ms"`
AppID string `json:"app_id,omitempty" desc:"Application ID from the event header"`
TenantKey string `json:"tenant_key,omitempty" desc:"Tenant key from the event header"`
EventKey string `json:"event_key,omitempty" desc:"Developer-defined bot menu event key"`
MenuTimestamp string `json:"menu_timestamp,omitempty" desc:"Menu click timestamp from the event body" kind:"timestamp_ms"`
OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id; kept as a short alias of operator_open_id" kind:"open_id"`
OperatorOpenID string `json:"operator_open_id,omitempty" desc:"Operator open_id" kind:"open_id"`
OperatorUnionID string `json:"operator_union_id,omitempty" desc:"Operator union_id" kind:"union_id"`
OperatorUserID string `json:"operator_user_id,omitempty" desc:"Operator user_id" kind:"user_id"`
OperatorName string `json:"operator_name,omitempty" desc:"Operator display name"`
}
func processBotMenu(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
AppID string `json:"app_id"`
TenantKey string `json:"tenant_key"`
} `json:"header"`
Event struct {
EventKey string `json:"event_key"`
Timestamp json.RawMessage `json:"timestamp"`
Operator struct {
OperatorID struct {
OpenID string `json:"open_id"`
UnionID string `json:"union_id"`
UserID string `json:"user_id"`
} `json:"operator_id"`
OperatorName string `json:"operator_name"`
} `json:"operator"`
} `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
}
menuTimestamp := timestampMillisString(envelope.Event.Timestamp)
timestamp := envelope.Header.CreateTime
if timestamp == "" {
timestamp = menuTimestamp
}
operatorID := envelope.Event.Operator.OperatorID.OpenID
out := &BotMenuOutput{
Type: eventTypeBotMenuV6,
EventID: envelope.Header.EventID,
Timestamp: timestamp,
AppID: envelope.Header.AppID,
TenantKey: envelope.Header.TenantKey,
EventKey: envelope.Event.EventKey,
MenuTimestamp: menuTimestamp,
OperatorID: operatorID,
OperatorOpenID: operatorID,
OperatorUnionID: envelope.Event.Operator.OperatorID.UnionID,
OperatorUserID: envelope.Event.Operator.OperatorID.UserID,
OperatorName: envelope.Event.Operator.OperatorName,
}
return json.Marshal(out)
}
func rawScalarString(raw json.RawMessage) string {
s := strings.TrimSpace(string(raw))
if s == "" || s == "null" {
return ""
}
var text string
if err := json.Unmarshal(raw, &text); err == nil {
return text
}
return s
}
func timestampMillisString(raw json.RawMessage) string {
s := rawScalarString(raw)
if len(s) == 10 && allDigits(s) {
return s + "000"
}
return s
}
func allDigits(s string) bool {
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return s != ""
}

View File

@@ -1,227 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"encoding/json"
"reflect"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestKeysBotMenuMetadata(t *testing.T) {
keys := Keys()
if len(keys) != 1 {
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
}
def := keys[0]
if def.Key != eventTypeBotMenuV6 {
t.Errorf("Key = %q, want %q", def.Key, eventTypeBotMenuV6)
}
if def.EventType != eventTypeBotMenuV6 {
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeBotMenuV6)
}
if def.SubscriptionType != "" {
t.Errorf("SubscriptionType = %q, want default event subscription", def.SubscriptionType)
}
if def.Schema.Custom == nil {
t.Fatal("Schema.Custom is nil")
}
if def.Schema.Custom.Type != reflect.TypeOf(BotMenuOutput{}) {
t.Errorf("custom type = %v, want BotMenuOutput", def.Schema.Custom.Type)
}
if def.Schema.Native != nil {
t.Fatal("Schema.Native must be nil for processed output")
}
if def.Process == nil {
t.Fatal("Process is nil")
}
if !reflect.DeepEqual(def.AuthTypes, []string{"bot"}) {
t.Errorf("AuthTypes = %#v", def.AuthTypes)
}
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeBotMenuV6}) {
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
}
}
func TestBotMenuRegistersCleanly(t *testing.T) {
const key = eventTypeBotMenuV6
event.UnregisterKeyForTest(key)
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
for _, def := range Keys() {
event.RegisterKey(def)
}
if _, ok := event.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) not registered", key)
}
}
func TestProcessBotMenu(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_001",
"event_type": "application.bot.menu_v6",
"create_time": "1776409469273",
"app_id": "cli_test",
"tenant_key": "tenant_test"
},
"event": {
"event_key": "start_eval",
"timestamp": 1776409469000,
"operator": {
"operator_id": {
"open_id": "ou_operator",
"union_id": "on_operator",
"user_id": "user_operator"
},
"operator_name": "Test User"
}
}
}`
out := runBotMenu(t, payload)
if out.Type != eventTypeBotMenuV6 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
}
if out.EventID != "ev_menu_001" {
t.Errorf("EventID = %q", out.EventID)
}
if out.Timestamp != "1776409469273" {
t.Errorf("Timestamp = %q", out.Timestamp)
}
if out.EventKey != "start_eval" {
t.Errorf("EventKey = %q", out.EventKey)
}
if out.MenuTimestamp != "1776409469000" {
t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
}
if out.OperatorID != "ou_operator" || out.OperatorOpenID != "ou_operator" {
t.Errorf("OperatorID/OperatorOpenID = %q/%q", out.OperatorID, out.OperatorOpenID)
}
if out.OperatorUnionID != "on_operator" {
t.Errorf("OperatorUnionID = %q", out.OperatorUnionID)
}
if out.OperatorUserID != "user_operator" {
t.Errorf("OperatorUserID = %q", out.OperatorUserID)
}
if out.OperatorName != "Test User" {
t.Errorf("OperatorName = %q", out.OperatorName)
}
if out.AppID != "cli_test" || out.TenantKey != "tenant_test" {
t.Errorf("AppID/TenantKey = %q/%q", out.AppID, out.TenantKey)
}
}
func TestProcessBotMenuStringTimestampFallback(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_002",
"event_type": "application.bot.menu_v6"
},
"event": {
"event_key": "start_eval",
"timestamp": "1776409469001",
"operator": {
"operator_id": {"open_id": "ou_operator"}
}
}
}`
out := runBotMenu(t, payload)
if out.Timestamp != "1776409469001" {
t.Errorf("Timestamp fallback = %q", out.Timestamp)
}
if out.MenuTimestamp != "1776409469001" {
t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
}
}
func TestProcessBotMenuSecondsTimestampFallback(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_seconds",
"event_type": "application.bot.menu_v6"
},
"event": {
"event_key": "start_eval",
"timestamp": 1694592375,
"operator": {
"operator_id": {"open_id": "ou_operator"}
}
}
}`
out := runBotMenu(t, payload)
if out.Timestamp != "1694592375000" {
t.Errorf("Timestamp fallback = %q, want seconds normalized to milliseconds", out.Timestamp)
}
if out.MenuTimestamp != "1694592375000" {
t.Errorf("MenuTimestamp = %q, want seconds normalized to milliseconds", out.MenuTimestamp)
}
}
func TestProcessBotMenuTypeUsesLocalConstant(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_003",
"event_type": "unexpected.event_type",
"create_time": "1776409469275"
},
"event": {
"event_key": "start_eval",
"operator": {
"operator_id": {"open_id": "ou_operator"}
}
}
}`
out := runBotMenu(t, payload)
if out.Type != eventTypeBotMenuV6 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
}
}
func TestProcessBotMenuMalformedPayload(t *testing.T) {
raw := &event.RawEvent{
EventID: "ev_bad",
EventType: eventTypeBotMenuV6,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := processBotMenu(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 runBotMenu(t *testing.T, payload string) BotMenuOutput {
t.Helper()
raw := &event.RawEvent{
EventID: "ev_test",
EventType: eventTypeBotMenuV6,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processBotMenu(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("processBotMenu: %v", err)
}
var out BotMenuOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("unmarshal output: %v\n%s", err, got)
}
return out
}

View File

@@ -1,31 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package application registers Application-domain EventKeys.
package application
import (
"reflect"
"github.com/larksuite/cli/internal/event"
)
const eventTypeBotMenuV6 = "application.bot.menu_v6"
// Keys returns all Application-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeBotMenuV6,
DisplayName: "Bot menu",
Description: "Triggered when a user clicks a custom bot menu item whose action is configured as a push event.",
EventType: eventTypeBotMenuV6,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(BotMenuOutput{})},
},
Process: processBotMenu,
AuthTypes: []string{"bot"},
RequiredConsoleEvents: []string{eventTypeBotMenuV6},
},
}
}

View File

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

View File

@@ -1,23 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"os"
"path/filepath"
"testing"
)
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-internal-auth-test-*")
if err != nil {
panic(err)
}
if err := os.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(root, "logs")); err != nil {
panic(err)
}
code := m.Run()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -132,14 +132,16 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
})
}
emitter := output.NewEmitter(output.EmitterConfig{
Out: opts.Out,
ErrOut: opts.ErrOut,
CommandPath: opts.CommandPath,
Identity: string(identity),
NoticeProvider: output.GetNotice,
})
return emitter.Success(result, output.EmitOptions{Format: opts.Format.String()})
// Content safety scanning for non-JSON presentation formats.
scanResult := output.ScanForSafety(opts.CommandPath, result, opts.ErrOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(opts.ErrOut, scanResult.Alert)
}
output.FormatValue(opts.Out, result, opts.Format)
return nil
}
// Non-JSON (binary) responses.

View File

@@ -18,7 +18,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
@@ -240,87 +239,6 @@ func TestHandleResponse_JSON(t *testing.T) {
}
}
func TestHandleResponse_NonJSONFormatsEmitExactStructuredResponseBytes(t *testing.T) {
tests := []struct {
name string
format output.Format
want string
}{
{
name: "ndjson",
format: output.FormatNDJSON,
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Bob\"}\n",
},
{
name: "table",
format: output.FormatTable,
want: "id name \n── ─────\n1 Alice\n2 Bob \n",
},
{
name: "csv",
format: output.FormatCSV,
want: "id,name\n1,Alice\n2,Bob\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
reg := &httpmock.Registry{}
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "name": "Alice"},
map[string]interface{}{"id": "2", "name": "Bob"},
},
"has_more": false,
},
},
})
httpResp, err := httpmock.NewClient(reg).Get("https://open.feishu.cn/open-apis/test/v1/items")
if err != nil {
t.Fatalf("fixture request failed: %v", err)
}
body, err := io.ReadAll(httpResp.Body)
_ = httpResp.Body.Close()
if err != nil {
t.Fatalf("read fixture response: %v", err)
}
resp := &larkcore.ApiResp{
StatusCode: httpResp.StatusCode,
Header: httpResp.Header.Clone(),
RawBody: body,
}
var out bytes.Buffer
var errOut bytes.Buffer
err = HandleResponse(resp, ResponseOptions{
Format: tt.format,
Identity: core.AsBot,
Out: &out,
ErrOut: &errOut,
CommandPath: "lark-cli api GET",
})
if err != nil {
t.Fatalf("HandleResponse() error = %v, want nil", err)
}
if got := out.String(); got != tt.want {
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
reg.Verify(t)
})
}
}
func TestHandleResponse_JSONWithJqUsesSuccessEnvelope(t *testing.T) {
body := []byte(`{"code":0,"msg":"ok","data":{"id":"1"}}`)
resp := newApiResp(body, map[string]string{"Content-Type": "application/json"})

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

@@ -1,30 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"os"
"path/filepath"
"testing"
)
func TestMain(m *testing.M) {
// Default-factory tests initialize the registry and resolve config. Keep
// them deterministic: never read the developer's real ~/.lark-cli and
// prevent background remote-metadata refreshes from touching user state.
root, err := os.MkdirTemp("", "lark-cli-cmdutil-test-*")
if err != nil {
println("internal/cmdutil test setup: MkdirTemp failed:", err.Error())
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
panic(err)
}
if err := os.Setenv("LARKSUITE_CLI_REMOTE_META", "off"); err != nil {
panic(err)
}
code := m.Run()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -1,23 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"os"
"path/filepath"
"testing"
)
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-event-test-*")
if err != nil {
panic(err)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
panic(err)
}
code := m.Run()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -1,28 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keychain
import (
"os"
"path/filepath"
"testing"
)
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-keychain-test-*")
if err != nil {
panic(err)
}
for key, value := range map[string]string{
"LARKSUITE_CLI_DATA_DIR": filepath.Join(root, "data"),
"LARKSUITE_CLI_LOG_DIR": filepath.Join(root, "logs"),
} {
if err := os.Setenv(key, value); err != nil {
panic(err)
}
}
code := m.Run()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -7,91 +7,70 @@ import (
"encoding/csv"
"fmt"
"io"
"os"
)
// FormatAsCSV formats data as CSV (with header) and writes it to w.
func FormatAsCSV(w io.Writer, data interface{}) {
// Match the other legacy wrappers: surface only a marshal failure (as the
// JSON fallback historically did); plain write failures stay swallowed.
if err := WriteCSV(w, data); isOutputMarshalError(err) {
legacyStderrf("json marshal error: %v\n", err)
}
}
// WriteCSV formats data as CSV and returns marshal or write errors.
func WriteCSV(w io.Writer, data interface{}) error {
return WriteCSVPaginated(w, data, true)
FormatAsCSVPaginated(w, data, true)
}
// FormatAsCSVPaginated formats data as CSV with pagination awareness.
// When isFirstPage is true, outputs the header row; otherwise only data rows.
func FormatAsCSVPaginated(w io.Writer, data interface{}, isFirstPage bool) {
if err := WriteCSVPaginated(w, data, isFirstPage); isOutputMarshalError(err) {
legacyStderrf("json marshal error: %v\n", err)
}
}
// WriteCSVPaginated formats data as CSV and returns marshal or write errors.
func WriteCSVPaginated(w io.Writer, data interface{}, isFirstPage bool) error {
rows, cols, isList := prepareRows(data)
if cols == nil {
if isList {
_, err := fmt.Fprintln(w, "(empty)")
return err
fmt.Fprintln(w, "(empty)")
} else {
return WriteJSON(w, data)
PrintJson(w, data)
}
return
}
if len(rows) == 0 {
if isFirstPage {
_, err := fmt.Fprintln(w, "(empty)")
return err
fmt.Fprintln(w, "(empty)")
}
return nil
return
}
if !isList {
// Single object: key,value rows
cw := csv.NewWriter(w)
if isFirstPage {
if err := cw.Write([]string{"key", "value"}); err != nil {
return err
}
cw.Write([]string{"key", "value"})
}
for _, col := range cols {
if err := cw.Write([]string{col, rows[0][col]}); err != nil {
return err
}
cw.Write([]string{col, rows[0][col]})
}
return flushCSV(cw)
flushCSV(cw)
return
}
return writeCSVRows(w, rows, cols, isFirstPage)
writeCSVRows(w, rows, cols, isFirstPage)
}
// writeCSVRows writes CSV data rows (and optionally header) using the given columns.
func writeCSVRows(w io.Writer, rows []map[string]string, cols []string, writeHeader bool) error {
func writeCSVRows(w io.Writer, rows []map[string]string, cols []string, writeHeader bool) {
cw := csv.NewWriter(w)
if writeHeader {
if err := cw.Write(cols); err != nil {
return err
}
cw.Write(cols)
}
for _, row := range rows {
record := make([]string, len(cols))
for i, col := range cols {
record[i] = row[col]
}
if err := cw.Write(record); err != nil {
return err
}
cw.Write(record)
}
return flushCSV(cw)
flushCSV(cw)
}
// flushCSV flushes the csv.Writer and returns any write error.
func flushCSV(cw *csv.Writer) error {
// flushCSV flushes the csv.Writer and reports any write error to stderr.
func flushCSV(cw *csv.Writer) {
cw.Flush()
return cw.Error()
if err := cw.Error(); err != nil {
fmt.Fprintf(os.Stderr, "csv write error: %v\n", err)
}
}

View File

@@ -50,11 +50,10 @@ func wrapBlockError(alert *extcs.Alert) error {
// WriteAlertWarning writes a human-readable content-safety warning to w.
// Used by non-JSON output paths (pretty, table, csv) in warn mode.
func WriteAlertWarning(w io.Writer, alert *extcs.Alert) error {
func WriteAlertWarning(w io.Writer, alert *extcs.Alert) {
if alert == nil {
return nil
return
}
_, err := fmt.Fprintf(w, "warning: content safety alert from %s (rules: %s)\n",
fmt.Fprintf(w, "warning: content safety alert from %s (rules: %s)\n",
alert.Provider, strings.Join(alert.MatchedRules, ", "))
return err
}

View File

@@ -1,336 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"bytes"
"encoding/json"
"fmt"
"io"
"maps"
"github.com/larksuite/cli/errs"
)
// NoticeProvider supplies the notice attached to a structured envelope.
// The provider is captured by an Emitter so emission never reads the global
// PendingNotice hook implicitly.
type NoticeProvider func() map[string]interface{}
// PrettyRenderer writes the human-readable representation of one result.
// colorEnabled is the terminal capability captured when the Emitter is built.
type PrettyRenderer func(w io.Writer, colorEnabled bool) error
// EmitterConfig contains command-scoped dependencies. A command constructs one
// Emitter and reuses it for its success result or streamed pages.
type EmitterConfig struct {
Out io.Writer
ErrOut io.Writer
CommandPath string
Identity string
ColorEnabled bool
NoticeProvider NoticeProvider
}
// EmitOptions describes one result's wire representation.
//
// The format contract is explicit: JSON (including the empty default) uses an
// Envelope; pretty, table, csv, and ndjson render naked business data. JQ takes
// precedence over Format and filters the JSON Envelope. Raw affects only JSON
// envelope encoding and jq's complex-value encoding.
//
// JQSafetyWarning preserves the legacy difference between RuntimeContext.emit
// (false) and WriteSuccessEnvelope (true) until their callers are migrated.
type EmitOptions struct {
Raw bool
Meta *Meta
Format string
JQ string
DryRun bool
Pretty PrettyRenderer
JQSafetyWarning bool
}
// StreamOptions describes one streamed page's wire representation. Streaming
// carries page items directly, so it deliberately exposes only the fields that
// affect a single page: the format and, for pretty, its renderer. It has no
// OK/Meta/DryRun/JQ — an ok:false envelope, metadata, dry-run, and jq all need
// the aggregated result, which the caller's pagination layer owns before it
// streams pages.
type StreamOptions struct {
Format string
Pretty PrettyRenderer
}
// Emitter owns all command-scoped output dependencies and pagination state.
// It deliberately has no dependency on client or cmdutil.
type Emitter struct {
out io.Writer
errOut io.Writer
commandPath string
identity string
colorEnabled bool
noticeProvider NoticeProvider
streamFormat string
streamFormatter *PaginatedFormatter
}
// NewEmitter constructs a command-scoped output emitter.
func NewEmitter(config EmitterConfig) *Emitter {
errOut := config.ErrOut
if errOut == nil {
errOut = io.Discard
}
return &Emitter{
out: config.Out,
errOut: errOut,
commandPath: config.CommandPath,
identity: config.Identity,
colorEnabled: config.ColorEnabled,
noticeProvider: config.NoticeProvider,
}
}
// Success scans and emits one command result by composing the package's leaf
// primitives. JSON and jq use the standard envelope; pretty, table, csv, and
// ndjson render the business value directly.
func (e *Emitter) Success(data interface{}, opts EmitOptions) error {
if err := e.requireOutput(); err != nil {
return err
}
if opts.JQ != "" {
return e.emitEnvelope(data, true, opts)
}
switch opts.Format {
case "", "json":
return e.emitEnvelope(data, true, opts)
case "pretty":
return e.emitPretty(data, opts)
default:
return e.emitFormatted(data, opts.Format)
}
}
// PartialFailure emits a multi-status result whose envelope honestly reports
// ok:false. It is the typed counterpart to Success for batch operations where
// some items failed but the per-item outcomes are the primary stdout output.
// Like the legacy OutPartialFailure it produces only the JSON/jq envelope; the
// caller owns the non-zero exit signal, keeping the Emitter free of exit
// semantics.
func (e *Emitter) PartialFailure(data interface{}, opts EmitOptions) error {
if err := e.requireOutput(); err != nil {
return err
}
return e.emitEnvelope(data, false, opts)
}
// StreamPage scans and emits one page while retaining table/csv columns from
// the first page. Streamed output carries page items directly, so it takes a
// StreamOptions (format + optional pretty renderer) rather than the full
// EmitOptions: ok/meta/dry-run/jq all need the aggregated result and are the
// caller's pagination-layer responsibility, not a per-page concern. Excluding
// jq from the type makes "jq requires aggregated output" a compile-time fact
// instead of a runtime rejection.
func (e *Emitter) StreamPage(data interface{}, opts StreamOptions) error {
if err := e.requireOutput(); err != nil {
return err
}
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
return wrapOutputError("write", err)
}
}
if opts.Format == "pretty" {
if opts.Pretty == nil {
return errs.NewInternalError(errs.SubtypeUnknown,
"pretty output requires a renderer")
}
return e.emit(func(w io.Writer) error {
return opts.Pretty(w, e.colorEnabled)
})
}
format, known := ParseFormat(opts.Format)
if !known && e.streamFormatter == nil && e.errOut != nil {
fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", opts.Format)
}
if e.streamFormatter == nil {
e.streamFormat = opts.Format
e.streamFormatter = NewPaginatedFormatter(nil, format)
} else if opts.Format != e.streamFormat {
return errs.NewInternalError(errs.SubtypeUnknown,
"stream output format changed from %q to %q", e.streamFormat, opts.Format)
}
return e.emit(func(w io.Writer) error {
e.streamFormatter.W = w
return e.streamFormatter.WritePage(data)
})
}
func (e *Emitter) emitEnvelope(data interface{}, ok bool, opts EmitOptions) error {
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
env := Envelope{
OK: ok,
Identity: e.identity,
DryRun: opts.DryRun,
Data: data,
Meta: opts.Meta,
Notice: e.notice(),
}
if scanResult.Alert != nil {
env.ContentSafetyAlert = scanResult.Alert
}
if opts.JQ != "" {
if scanResult.Alert != nil && opts.JQSafetyWarning {
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
return wrapOutputError("write", err)
}
}
// Buffer the jq output manually so jq's own typed error (a validation
// error for a bad expression, an api error for a runtime failure) is
// returned unchanged; only a genuine stdout write failure is wrapped as
// an internal output error.
var buf bytes.Buffer
var jqErr error
if opts.Raw {
jqErr = JqFilterRaw(&buf, env, opts.JQ)
} else {
jqErr = JqFilter(&buf, env, opts.JQ)
}
if jqErr != nil {
return jqErr
}
if _, err := io.Copy(e.out, &buf); err != nil {
return wrapOutputError("write", err)
}
return nil
}
return e.emit(func(w io.Writer) error {
if opts.Raw {
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
return enc.Encode(env)
}
return WriteJSON(w, env)
})
}
func (e *Emitter) emitPretty(data interface{}, opts EmitOptions) error {
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
return wrapOutputError("write", err)
}
}
if opts.Pretty != nil {
return e.emit(func(w io.Writer) error {
return opts.Pretty(w, e.colorEnabled)
})
}
// RuntimeContext.outFormat falls back through Out/OutRaw when no pretty
// renderer is supplied. Keep that second scan visible in the leaf contract
// until production callers are migrated and the legacy behavior is removed.
return e.emitEnvelope(data, true, opts)
}
func (e *Emitter) emitFormatted(data interface{}, rawFormat string) error {
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
return wrapOutputError("write", err)
}
}
format, known := ParseFormat(rawFormat)
if !known && e.errOut != nil {
fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", rawFormat)
}
if format == FormatJSON {
return e.printLegacyDataJSON(data)
}
return e.emit(func(w io.Writer) error {
return WriteFormatted(w, data, format)
})
}
type emitterDataMap map[string]interface{}
// printLegacyDataJSON matches FormatValue's JSON branch while sourcing notice
// data from this Emitter instead of PrintJson's global PendingNotice hook.
func (e *Emitter) printLegacyDataJSON(data interface{}) error {
// Normalise structs / named maps to plain generic types first, exactly as
// FormatValue does, so a struct or named-map payload still matches the map
// case below and keeps its injected _notice on the unknown-format fallback.
data = toGeneric(data)
if m, ok := data.(map[string]interface{}); ok {
if _, isEnvelope := m["ok"]; isEnvelope {
if notice := e.notice(); notice != nil {
m = maps.Clone(m)
m["_notice"] = notice
}
}
// The named map retains identical JSON bytes while preventing PrintJson
// from consulting its legacy global notice hook a second time.
return e.emit(func(w io.Writer) error {
return WriteJSON(w, emitterDataMap(m))
})
}
return e.emit(func(w io.Writer) error {
return WriteJSON(w, data)
})
}
func (e *Emitter) emit(render func(io.Writer) error) error {
var buf bytes.Buffer
if err := render(&buf); err != nil {
return wrapOutputError("render", err)
}
if _, err := io.Copy(e.out, &buf); err != nil {
return wrapOutputError("write", err)
}
return nil
}
func wrapOutputError(op string, err error) error {
return errs.NewInternalError(errs.SubtypeUnknown, "failed to %s command output", op).WithCause(err)
}
func (e *Emitter) notice() map[string]interface{} {
if e.noticeProvider == nil {
return nil
}
return e.noticeProvider()
}
func (e *Emitter) requireOutput() error {
if e == nil || e.out == nil {
return errs.NewInternalError(errs.SubtypeUnknown,
"success output writer is not configured")
}
return nil
}

View File

@@ -1,350 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/output"
)
type contractFailingWriter struct {
err error
}
func (w contractFailingWriter) Write([]byte) (int, error) {
return 0, w.err
}
type contractSafetyProvider struct {
alert *extcs.Alert
}
func (p *contractSafetyProvider) Name() string {
return "emitter-contract"
}
func (p *contractSafetyProvider) Scan(context.Context, extcs.ScanRequest) (*extcs.Alert, error) {
return p.alert, nil
}
func TestEmitterSuccessWritesAllBytes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
Identity: "bot",
})
data := map[string]interface{}{"id": "1"}
err := emitter.Success(data, output.EmitOptions{Format: "json"})
if err != nil {
t.Fatalf("Emitter.Success() error = %v", err)
}
want, marshalErr := json.MarshalIndent(output.Envelope{OK: true, Identity: "bot", Data: data}, "", " ")
if marshalErr != nil {
t.Fatalf("marshal expected envelope: %v", marshalErr)
}
want = append(want, '\n')
if !bytes.Equal(stdout.Bytes(), want) {
t.Fatalf("stdout bytes = %q, want %q", stdout.Bytes(), want)
}
}
func TestEmitterMarshalFailureReturnsTypedErrorWithoutOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(map[string]interface{}{"unsupported": func() {}}, output.EmitOptions{Format: "json"})
if err == nil {
t.Fatal("Emitter.Success() error = nil, want marshal failure")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
}
var unsupported *json.UnsupportedTypeError
if !errors.As(err, &unsupported) {
t.Fatalf("Emitter.Success() error = %v, want json.UnsupportedTypeError cause", err)
}
if stdout.Len() != 0 {
t.Fatalf("Emitter.Success() stdout = %q, want empty", stdout.String())
}
}
func TestEmitterWriterFailurePreservesCause(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
sentinel := errors.New("write failed")
emitter := output.NewEmitter(output.EmitterConfig{
Out: contractFailingWriter{err: sentinel},
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "json"})
if !errors.Is(err, sentinel) {
t.Fatalf("Emitter.Success() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
}
}
func TestEmitterPrettyRendererFailurePreservesCause(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
sentinel := errors.New("pretty render failed")
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
Format: "pretty",
Pretty: func(io.Writer, bool) error {
return sentinel
},
})
if !errors.Is(err, sentinel) {
t.Fatalf("Emitter.Success() error = %v, want preserved renderer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
}
if stdout.Len() != 0 {
t.Fatalf("Emitter.Success() stdout = %q, want empty", stdout.String())
}
}
func TestEmitterAlertWarningFailurePreservesCause(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
extcs.Register(&contractSafetyProvider{alert: &extcs.Alert{
Provider: "emitter-contract",
MatchedRules: []string{"fixture-rule"},
}})
t.Cleanup(func() { extcs.Register(nil) })
sentinel := errors.New("warning write failed")
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: contractFailingWriter{err: sentinel},
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success([]interface{}{map[string]interface{}{"id": "1"}}, output.EmitOptions{Format: "table"})
if !errors.Is(err, sentinel) {
t.Fatalf("Emitter.Success() error = %v, want preserved warning writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
}
if stdout.Len() != 0 {
t.Fatalf("Emitter.Success() stdout = %q, want empty", stdout.String())
}
}
func TestNewEmitterDefaultsNilErrOutToDiscard(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
extcs.Register(&contractSafetyProvider{alert: &extcs.Alert{
Provider: "emitter-contract",
MatchedRules: []string{"fixture-rule"},
}})
t.Cleanup(func() { extcs.Register(nil) })
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
CommandPath: "lark-cli fixture +emit",
})
if err := emitter.Success([]interface{}{map[string]interface{}{"id": "1"}}, output.EmitOptions{Format: "table"}); err != nil {
t.Fatalf("Emitter.Success() error = %v", err)
}
if stdout.Len() == 0 {
t.Fatal("Emitter.Success() stdout is empty")
}
}
func TestEmitterDoesNotMutateCallerMap(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
data := map[string]interface{}{"ok": true, "value": "fixture"}
want := map[string]interface{}{"ok": true, "value": "fixture"}
emitter := output.NewEmitter(output.EmitterConfig{
Out: &bytes.Buffer{},
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
NoticeProvider: func() map[string]interface{} {
return map[string]interface{}{"update": "available"}
},
})
if err := emitter.Success(data, output.EmitOptions{Format: "yaml"}); err != nil {
t.Fatalf("Emitter.Success() error = %v", err)
}
if !reflect.DeepEqual(data, want) {
t.Fatalf("caller map = %#v, want unchanged %#v", data, want)
}
}
func TestEmitterDoesNotOverwriteCallerNotice(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
existing := map[string]interface{}{"source": "caller"}
data := map[string]interface{}{"ok": true, "_notice": existing}
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
NoticeProvider: func() map[string]interface{} {
return map[string]interface{}{"source": "provider"}
},
})
if err := emitter.Success(data, output.EmitOptions{Format: "yaml"}); err != nil {
t.Fatalf("Emitter.Success() error = %v", err)
}
if got := data["_notice"]; !reflect.DeepEqual(got, existing) {
t.Fatalf("caller _notice = %#v, want unchanged %#v", got, existing)
}
var emitted map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &emitted); err != nil {
t.Fatalf("decode stdout: %v", err)
}
if got := emitted["_notice"]; !reflect.DeepEqual(got, map[string]interface{}{"source": "provider"}) {
t.Fatalf("emitted _notice = %#v, want provider notice", got)
}
}
func TestEmitterReadsNoticeProviderAtMostOncePerEmission(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
calls := 0
emitter := output.NewEmitter(output.EmitterConfig{
Out: &bytes.Buffer{},
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
NoticeProvider: func() map[string]interface{} {
calls++
return map[string]interface{}{"source": "provider"}
},
})
if err := emitter.Success(map[string]interface{}{"ok": true}, output.EmitOptions{Format: "yaml"}); err != nil {
t.Fatalf("Emitter.Success() error = %v", err)
}
if calls != 1 {
t.Fatalf("notice provider calls = %d, want 1", calls)
}
}
func TestEmitterRawJSONPropagatesWriteError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
sentinel := errors.New("write failed")
emitter := output.NewEmitter(output.EmitterConfig{
Out: contractFailingWriter{err: sentinel},
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
Raw: true, Format: "json",
})
if !errors.Is(err, sentinel) {
t.Fatalf("Emitter.Success() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
}
}
func TestEmitterInvalidJQReturnsErrorWithoutStderr(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
stderr := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: &bytes.Buffer{},
ErrOut: stderr,
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
Format: "json",
JQ: "this is not valid jq (((",
})
if err == nil {
t.Fatal("Success() with invalid jq = nil, want error")
}
if stderr.Len() != 0 {
t.Fatalf("Success() with invalid jq wrote stderr %q, want empty", stderr.String())
}
}
func TestEmitterJQRuntimeErrorPreservesTypedError(t *testing.T) {
// A valid expression that fails at runtime must surface jq's own typed error
// (an api error), not a wrapped internal output error, and must emit no
// partial stdout.
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
Format: "json",
JQ: `error("boom")`,
})
if err == nil {
t.Fatal("Success() with a runtime jq error = nil, want error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category == errs.CategoryInternal {
t.Fatalf("Success() jq runtime error problem = %#v, %v; want jq's own typed error, not internal", problem, ok)
}
if !strings.Contains(err.Error(), "jq error") {
t.Fatalf("Success() jq runtime error = %v, want jq's own error message preserved", err)
}
if stdout.Len() != 0 {
t.Fatalf("Success() jq runtime error wrote stdout %q, want empty", stdout.String())
}
}
func TestEmitterUnknownFormatStructKeepsNotice(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
type payload struct {
OK bool `json:"ok"`
Value string `json:"value"`
}
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
NoticeProvider: func() map[string]interface{} {
return map[string]interface{}{"update": map[string]interface{}{"latest": "9.9.9"}}
},
})
if err := emitter.Success(payload{OK: true, Value: "fixture"}, output.EmitOptions{Format: "yaml"}); err != nil {
t.Fatalf("Success() error = %v", err)
}
if !strings.Contains(stdout.String(), "_notice") {
t.Fatalf("struct payload on unknown-format fallback dropped _notice:\n%s", stdout.String())
}
}

View File

@@ -1,827 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Legacy oracle fixtures are frozen at base SHA 4a56748bfa941ff0ee0bfec92e65acac427732b0.
// Golden regeneration is allowed only from that base, never from the current system under test.
package output_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
type emitterCapture struct {
stdout string
stderr string
err error
}
type emitterSafetyProvider struct {
alert *extcs.Alert
err error
}
func (p *emitterSafetyProvider) Name() string { return "emitter-oracle" }
func (p *emitterSafetyProvider) Scan(context.Context, extcs.ScanRequest) (*extcs.Alert, error) {
return p.alert, p.err
}
const (
runtimeContextLegacyGoldenPath = "testdata/runtime_context_legacy.golden.json"
writeSuccessEnvelopeLegacyGoldenPath = "testdata/write_success_envelope_legacy.golden.json"
)
type runtimeContextOracleCase struct {
name string
data func() interface{}
raw bool
ok bool
meta *output.Meta
jq string
format string
useFormat bool
pretty bool
notice map[string]interface{}
safetyMode string
safetyAlert *extcs.Alert
safetyErr error
}
type runtimeContextLegacyGolden struct {
Cases map[string]emitterCaptureGolden `json:"cases"`
}
type writeSuccessEnvelopeOracleCase struct {
name string
data func() interface{}
dryRun bool
jq string
notice map[string]interface{}
safetyMode string
safetyAlert *extcs.Alert
}
type writeSuccessEnvelopeLegacyGolden struct {
Cases map[string]emitterCaptureGolden `json:"cases"`
}
type emitterCaptureGolden struct {
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Error *emitterErrorGolden `json:"error,omitempty"`
}
type emitterErrorGolden struct {
GoType string `json:"go_type"`
JSON json.RawMessage `json:"json"`
Message string `json:"message"`
ExitCode int `json:"exit_code"`
}
func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
previousNotice := output.PendingNotice
t.Cleanup(func() {
output.PendingNotice = previousNotice
extcs.Register(nil)
})
cases := []runtimeContextOracleCase{
{
name: "json_object",
data: func() interface{} {
return map[string]interface{}{"id": "1", "enabled": true}
},
ok: true,
},
{
name: "raw_json_preserves_html",
data: func() interface{} {
return map[string]interface{}{"html": "<p>a&b</p>"}
},
raw: true,
ok: true,
},
{
name: "format_raw_json_preserves_html",
data: func() interface{} {
return map[string]interface{}{"html": "<p>a&b</p>"}
},
raw: true,
ok: true,
format: "json",
useFormat: true,
},
{
name: "partial_failure_ok_false",
data: func() interface{} {
return map[string]interface{}{"succeeded": 1, "failed": 1}
},
ok: false,
},
{
name: "metadata",
data: func() interface{} {
return []interface{}{map[string]interface{}{"id": "1"}}
},
ok: true,
meta: &output.Meta{Count: 1, Rollback: "lark-cli fixture rollback"},
},
{
name: "jq_scalar",
data: func() interface{} {
return map[string]interface{}{"name": "Alice", "age": 30}
},
ok: true,
jq: ".data.name",
},
{
name: "raw_jq_complex",
data: func() interface{} {
return map[string]interface{}{"document": map[string]interface{}{"html": "<p>a&b</p>"}}
},
raw: true,
ok: true,
jq: ".data.document",
},
{
name: "jq_invalid_expression",
data: func() interface{} {
return map[string]interface{}{"id": "1"}
},
ok: false,
jq: "invalid[",
},
{
name: "notice",
data: func() interface{} {
return map[string]interface{}{"id": "1"}
},
ok: true,
notice: map[string]interface{}{"update": map[string]interface{}{"latest": "9.9.9"}},
},
{
name: "pretty",
data: func() interface{} {
return map[string]interface{}{"name": "Alice"}
},
ok: true,
format: "pretty",
useFormat: true,
pretty: true,
},
{
name: "pretty_without_renderer",
data: func() interface{} {
return map[string]interface{}{"name": "Alice"}
},
ok: true,
format: "pretty",
useFormat: true,
},
{
name: "ndjson",
data: func() interface{} {
return map[string]interface{}{"items": []interface{}{
map[string]interface{}{"id": "1"},
map[string]interface{}{"id": "2"},
}}
},
ok: true,
format: "ndjson",
useFormat: true,
},
{
name: "table_with_safety_warning",
data: func() interface{} {
return []interface{}{map[string]interface{}{"id": "1", "name": "Alice"}}
},
ok: true,
format: "table",
useFormat: true,
safetyMode: "warn",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
},
},
{
name: "csv",
data: func() interface{} {
return []interface{}{
map[string]interface{}{"id": "1", "name": "Alice"},
map[string]interface{}{"id": "2", "name": "Bob"},
}
},
ok: true,
format: "csv",
useFormat: true,
},
{
name: "jq_safety_alert_without_stderr_warning",
data: func() interface{} {
return map[string]interface{}{"id": "1"}
},
ok: true,
jq: ".data.id",
safetyMode: "warn",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
},
},
{
name: "scanner_error_fails_open",
data: func() interface{} {
return map[string]interface{}{"id": "1"}
},
ok: true,
safetyMode: "warn",
safetyErr: errors.New("scanner unavailable"),
},
{
name: "scanner_block",
data: func() interface{} {
return map[string]interface{}{"id": "blocked"}
},
ok: false,
safetyMode: "block",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
},
},
{
name: "unknown_format_data_envelope_notice",
data: func() interface{} {
return map[string]interface{}{"ok": true, "value": "fixture"}
},
ok: true,
format: "yaml",
useFormat: true,
notice: map[string]interface{}{"skills": map[string]interface{}{"current": "1.0.0"}},
},
}
golden := loadRuntimeContextLegacyGolden(t)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mode := tc.safetyMode
if mode == "" {
mode = "off"
}
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", mode)
extcs.Register(&emitterSafetyProvider{alert: tc.safetyAlert, err: tc.safetyErr})
t.Cleanup(func() { extcs.Register(nil) })
notice := tc.notice
output.PendingNotice = func() map[string]interface{} { return notice }
want, ok := golden.Cases[tc.name]
if !ok {
t.Fatalf("frozen golden case %q is missing", tc.name)
}
opts := runtimeOracleOptions{
raw: tc.raw,
ok: tc.ok,
meta: tc.meta,
jq: tc.jq,
format: tc.format,
useFormat: tc.useFormat,
pretty: tc.pretty,
}
current := runEmitterWithRuntimeContextContract(tc.data(), output.EmitterConfig{
CommandPath: "lark-cli fixture +emit",
Identity: "bot",
NoticeProvider: func() map[string]interface{} { return notice },
}, tc.ok, output.EmitOptions{
Raw: tc.raw,
Meta: tc.meta,
Format: tc.format,
JQ: tc.jq,
Pretty: emitterPrettyRenderer(tc.pretty),
})
assertEmitterGolden(t, want, current)
integrated := runRuntimeContextOracle(t, tc.data(), opts)
assertEmitterGolden(t, want, integrated)
if tc.safetyMode == "block" {
var safetyErr *errs.ContentSafetyError
if !errors.As(current.err, &safetyErr) {
t.Fatalf("Emitter.Success() error = %T, want *errs.ContentSafetyError", current.err)
}
}
})
}
if len(golden.Cases) != len(cases) {
t.Fatalf("golden case count = %d, want %d", len(golden.Cases), len(cases))
}
jqFailure := golden.Cases["jq_invalid_expression"]
if !strings.HasPrefix(jqFailure.Stderr, "error: ") || !strings.HasSuffix(jqFailure.Stderr, "\n") {
t.Fatalf("invalid jq golden stderr = %q, want error line ending in newline", jqFailure.Stderr)
}
if jqFailure.Error == nil || jqFailure.Error.ExitCode != output.ExitValidation {
t.Fatalf("invalid jq golden exit = %#v, want %d", jqFailure.Error, output.ExitValidation)
}
}
func loadRuntimeContextLegacyGolden(t *testing.T) runtimeContextLegacyGolden {
t.Helper()
contents, err := os.ReadFile(runtimeContextLegacyGoldenPath)
if err != nil {
t.Fatalf("read RuntimeContext legacy golden: %v", err)
}
var golden runtimeContextLegacyGolden
if err := json.Unmarshal(contents, &golden); err != nil {
t.Fatalf("decode RuntimeContext legacy golden: %v", err)
}
return golden
}
func captureEmitterGolden(t *testing.T, capture emitterCapture) emitterCaptureGolden {
t.Helper()
golden := emitterCaptureGolden{Stdout: capture.stdout, Stderr: capture.stderr}
if capture.err == nil {
return golden
}
errorJSON, err := json.Marshal(capture.err)
if err != nil {
t.Fatalf("marshal captured error %T: %v", capture.err, err)
}
golden.Error = &emitterErrorGolden{
GoType: fmt.Sprintf("%T", capture.err),
JSON: errorJSON,
Message: capture.err.Error(),
ExitCode: output.ExitCodeOf(capture.err),
}
return golden
}
type runtimeOracleOptions struct {
raw bool
ok bool
meta *output.Meta
jq string
format string
useFormat bool
pretty bool
}
func runRuntimeContextOracle(t *testing.T, data interface{}, opts runtimeOracleOptions) emitterCapture {
t.Helper()
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
parent := &cobra.Command{Use: "lark-cli"}
cmd := &cobra.Command{Use: "fixture"}
leaf := &cobra.Command{Use: "+emit"}
parent.AddCommand(cmd)
cmd.AddCommand(leaf)
factory := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}}
runtime := common.TestNewRuntimeContextForAPI(
context.Background(), leaf, &core.CliConfig{Brand: core.BrandFeishu}, factory, core.AsBot,
)
runtime.Format = opts.format
runtime.JqExpr = opts.jq
pretty := func(w io.Writer) {
fmt.Fprintln(w, "pretty:fixture")
}
if !opts.pretty {
pretty = nil
}
var err error
switch {
case opts.useFormat && opts.raw:
runtime.OutFormatRaw(data, opts.meta, pretty)
case opts.useFormat:
runtime.OutFormat(data, opts.meta, pretty)
case !opts.ok:
err = runtime.OutPartialFailure(data, opts.meta)
case opts.raw:
runtime.OutRaw(data, opts.meta)
default:
runtime.Out(data, opts.meta)
}
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
}
func runEmitterSuccess(data interface{}, config output.EmitterConfig, ok bool, opts output.EmitOptions) emitterCapture {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
config.Out = stdout
config.ErrOut = stderr
emitter := output.NewEmitter(config)
var err error
if ok {
err = emitter.Success(data, opts)
} else {
err = emitter.PartialFailure(data, opts)
}
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
}
func runEmitterWithRuntimeContextContract(data interface{}, config output.EmitterConfig, ok bool, opts output.EmitOptions) emitterCapture {
capture := runEmitterSuccess(data, config, ok, opts)
if capture.err != nil {
var safetyErr *errs.ContentSafetyError
if errors.As(capture.err, &safetyErr) {
return capture
}
if opts.JQ != "" {
capture.stderr += fmt.Sprintf("error: %v\n", capture.err)
return capture
}
capture.err = nil
}
if !ok {
capture.err = output.PartialFailure(output.ExitAPI)
}
return capture
}
func emitterPrettyRenderer(enabled bool) output.PrettyRenderer {
if !enabled {
return nil
}
return func(w io.Writer, _ bool) error {
_, err := fmt.Fprintln(w, "pretty:fixture")
return err
}
}
func TestEmitterMatchesWriteSuccessEnvelopeLegacyOracle(t *testing.T) {
previousNotice := output.PendingNotice
t.Cleanup(func() {
output.PendingNotice = previousNotice
extcs.Register(nil)
})
cases := []writeSuccessEnvelopeOracleCase{
{
name: "json",
data: func() interface{} { return map[string]interface{}{"id": "1"} },
},
{
name: "dry_run",
data: func() interface{} { return map[string]interface{}{"api": []interface{}{}} },
dryRun: true,
},
{
name: "jq",
data: func() interface{} { return map[string]interface{}{"id": "1"} },
jq: ".data.id",
},
{
name: "notice",
data: func() interface{} { return map[string]interface{}{"id": "1"} },
notice: map[string]interface{}{"update": map[string]interface{}{"latest": "9.9.9"}},
},
{
name: "jq_safety_warning",
data: func() interface{} { return map[string]interface{}{"id": "1"} },
jq: ".data.id",
safetyMode: "warn",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
},
},
{
name: "scanner_block",
data: func() interface{} { return map[string]interface{}{"id": "blocked"} },
safetyMode: "block",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
},
},
}
golden := loadWriteSuccessEnvelopeLegacyGolden(t)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mode := tc.safetyMode
if mode == "" {
mode = "off"
}
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", mode)
extcs.Register(&emitterSafetyProvider{alert: tc.safetyAlert})
t.Cleanup(func() { extcs.Register(nil) })
notice := tc.notice
output.PendingNotice = func() map[string]interface{} { return notice }
want, ok := golden.Cases[tc.name]
if !ok {
t.Fatalf("frozen golden case %q is missing", tc.name)
}
current := runEmitterSuccess(tc.data(), output.EmitterConfig{
CommandPath: "lark-cli fixture +emit",
Identity: "bot",
NoticeProvider: func() map[string]interface{} { return notice },
}, true, output.EmitOptions{
Format: "",
Raw: false,
JQ: tc.jq,
DryRun: tc.dryRun,
JQSafetyWarning: true,
})
assertEmitterGolden(t, want, current)
integrated := runWriteSuccessEnvelopeOracle(tc.data(), tc.dryRun, tc.jq)
assertEmitterGolden(t, want, integrated)
})
}
if len(golden.Cases) != len(cases) {
t.Fatalf("golden case count = %d, want %d", len(golden.Cases), len(cases))
}
}
func loadWriteSuccessEnvelopeLegacyGolden(t *testing.T) writeSuccessEnvelopeLegacyGolden {
t.Helper()
contents, err := os.ReadFile(writeSuccessEnvelopeLegacyGoldenPath)
if err != nil {
t.Fatalf("read WriteSuccessEnvelope legacy golden: %v", err)
}
var golden writeSuccessEnvelopeLegacyGolden
if err := json.Unmarshal(contents, &golden); err != nil {
t.Fatalf("decode WriteSuccessEnvelope legacy golden: %v", err)
}
return golden
}
func runWriteSuccessEnvelopeOracle(data interface{}, dryRun bool, jq string) emitterCapture {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
err := output.WriteSuccessEnvelope(data, output.SuccessEnvelopeOptions{
CommandPath: "lark-cli fixture +emit",
Identity: "bot",
DryRun: dryRun,
JqExpr: jq,
Out: stdout,
ErrOut: stderr,
})
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
}
func TestEmitterStreamPageMatchesPaginationLegacyOracle(t *testing.T) {
t.Cleanup(func() { extcs.Register(nil) })
type oracleCase struct {
name string
format output.Format
safetyMode string
safetyAlert *extcs.Alert
}
cases := []oracleCase{
{name: "ndjson", format: output.FormatNDJSON},
{name: "table", format: output.FormatTable},
{name: "csv", format: output.FormatCSV},
{
name: "warn",
format: output.FormatNDJSON,
safetyMode: "warn",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
},
},
{
name: "block",
format: output.FormatTable,
safetyMode: "block",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
},
},
}
pages := []interface{}{
[]interface{}{map[string]interface{}{"id": "1", "name": "Alice"}},
[]interface{}{map[string]interface{}{"id": "2", "name": "Bob", "ignored": true}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mode := tc.safetyMode
if mode == "" {
mode = "off"
}
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", mode)
extcs.Register(&emitterSafetyProvider{alert: tc.safetyAlert})
t.Cleanup(func() { extcs.Register(nil) })
legacy := runPaginationOracle(pages, tc.format)
current := runEmitterStreamPages(pages, tc.format.String())
assertEmitterBytes(t, legacy, current)
assertEquivalentError(t, legacy.err, current.err)
})
}
}
func runPaginationOracle(pages []interface{}, format output.Format) emitterCapture {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
formatter := output.NewPaginatedFormatter(stdout, format)
var emitErr error
for _, page := range pages {
scanResult := output.ScanForSafety("lark-cli fixture +emit", page, stderr)
if scanResult.Blocked {
emitErr = scanResult.BlockErr
break
}
if scanResult.Alert != nil {
output.WriteAlertWarning(stderr, scanResult.Alert)
}
formatter.FormatPage(page)
}
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: emitErr}
}
func runEmitterStreamPages(pages []interface{}, format string) emitterCapture {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: stderr,
CommandPath: "lark-cli fixture +emit",
Identity: "bot",
})
var emitErr error
for _, page := range pages {
if emitErr = emitter.StreamPage(page, output.StreamOptions{Format: format}); emitErr != nil {
break
}
}
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: emitErr}
}
func TestEmitterCapturesNoticeAndColorDependencies(t *testing.T) {
previousNotice := output.PendingNotice
output.PendingNotice = func() map[string]interface{} {
return map[string]interface{}{"source": "global"}
}
t.Cleanup(func() { output.PendingNotice = previousNotice })
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
colorSeen := false
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: stderr,
CommandPath: "lark-cli fixture +emit",
Identity: "bot",
ColorEnabled: true,
NoticeProvider: func() map[string]interface{} {
return map[string]interface{}{"source": "captured"}
},
})
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "json"}); err != nil {
t.Fatalf("Emitter.Success() error = %v", err)
}
if strings.Contains(stdout.String(), "global") || !strings.Contains(stdout.String(), "captured") {
t.Fatalf("notice source was not captured by Emitter:\n%s", stdout.String())
}
stdout.Reset()
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "pretty",
Pretty: func(w io.Writer, colorEnabled bool) error {
colorSeen = colorEnabled
_, err := fmt.Fprintln(w, "pretty")
return err
},
}); err != nil {
t.Fatalf("Emitter.Success(pretty) error = %v", err)
}
if !colorSeen {
t.Fatal("PrettyRenderer did not receive captured ColorEnabled value")
}
stdout.Reset()
if err := emitter.Success(map[string]interface{}{"ok": true, "id": "1"}, output.EmitOptions{Format: "yaml"}); err != nil {
t.Fatalf("Emitter.Success(unknown format) error = %v", err)
}
if strings.Contains(stdout.String(), "global") || !strings.Contains(stdout.String(), "captured") {
t.Fatalf("legacy JSON fallback consulted global notice:\n%s", stdout.String())
}
}
type failingEmitterWriter struct {
err error
}
func (w failingEmitterWriter) Write([]byte) (int, error) { return 0, w.err }
func TestEmitterPropagatesOutputError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
sentinel := errors.New("write failed")
emitter := output.NewEmitter(output.EmitterConfig{
Out: failingEmitterWriter{err: sentinel},
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
Raw: true, Format: "json",
JQ: ".data",
})
if !errors.Is(err, sentinel) {
t.Fatalf("Emitter.Success() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
}
}
func assertEmitterBytes(t *testing.T, legacy, current emitterCapture) {
t.Helper()
if legacy.stdout != current.stdout {
t.Fatalf("stdout byte mismatch\nlegacy (%d bytes):\n%q\nEmitter (%d bytes):\n%q",
len(legacy.stdout), legacy.stdout, len(current.stdout), current.stdout)
}
if legacy.stderr != current.stderr {
t.Fatalf("stderr byte mismatch\nlegacy (%d bytes):\n%q\nEmitter (%d bytes):\n%q",
len(legacy.stderr), legacy.stderr, len(current.stderr), current.stderr)
}
}
func assertEmitterGolden(t *testing.T, want emitterCaptureGolden, current emitterCapture) {
t.Helper()
if want.Stdout != current.stdout {
t.Fatalf("stdout byte mismatch\ngolden (%d bytes):\n%q\ncurrent (%d bytes):\n%q",
len(want.Stdout), want.Stdout, len(current.stdout), current.stdout)
}
if want.Stderr != current.stderr {
t.Fatalf("stderr byte mismatch\ngolden (%d bytes):\n%q\ncurrent (%d bytes):\n%q",
len(want.Stderr), want.Stderr, len(current.stderr), current.stderr)
}
got := captureEmitterGolden(t, current)
if (want.Error == nil) != (got.Error == nil) {
t.Fatalf("error presence mismatch: golden=%#v current=%#v", want.Error, got.Error)
}
if want.Error == nil {
return
}
if want.Error.GoType != got.Error.GoType || want.Error.Message != got.Error.Message || want.Error.ExitCode != got.Error.ExitCode {
t.Fatalf("error mismatch:\ngolden: %#v\ncurrent: %#v", want.Error, got.Error)
}
var wantJSON interface{}
if err := json.Unmarshal(want.Error.JSON, &wantJSON); err != nil {
t.Fatalf("decode golden error JSON: %v", err)
}
var gotJSON interface{}
if err := json.Unmarshal(got.Error.JSON, &gotJSON); err != nil {
t.Fatalf("decode current error JSON: %v", err)
}
if !reflect.DeepEqual(wantJSON, gotJSON) {
t.Fatalf("error JSON mismatch:\ngolden: %s\ncurrent: %s", want.Error.JSON, got.Error.JSON)
}
}
func assertEquivalentError(t *testing.T, legacy, current error) {
t.Helper()
if (legacy == nil) != (current == nil) {
t.Fatalf("error presence mismatch: legacy=%v Emitter=%v", legacy, current)
}
if legacy == nil {
return
}
legacyProblem, legacyOK := errs.ProblemOf(legacy)
currentProblem, currentOK := errs.ProblemOf(current)
if legacyOK != currentOK {
t.Fatalf("typed error mismatch: legacy=%T Emitter=%T", legacy, current)
}
if legacyOK && !reflect.DeepEqual(legacyProblem, currentProblem) {
t.Fatalf("problem mismatch:\nlegacy: %#v\nEmitter: %#v", legacyProblem, currentProblem)
}
}

View File

@@ -34,17 +34,27 @@ func SuccessEnvelopeData(result interface{}) interface{} {
// JSON output carries content-safety alerts inside the envelope. When jq is
// applied, the alert may be filtered away, so warn mode also writes stderr.
func WriteSuccessEnvelope(data interface{}, opts SuccessEnvelopeOptions) error {
return NewEmitter(EmitterConfig{
Out: opts.Out,
ErrOut: opts.ErrOut,
CommandPath: opts.CommandPath,
Identity: opts.Identity,
NoticeProvider: GetNotice,
}).Success(data, EmitOptions{
Format: "",
Raw: false,
JQ: opts.JqExpr,
DryRun: opts.DryRun,
JQSafetyWarning: true,
})
scanResult := ScanForSafety(opts.CommandPath, data, opts.ErrOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
env := Envelope{
OK: true,
Identity: opts.Identity,
DryRun: opts.DryRun,
Data: data,
Notice: GetNotice(),
}
if scanResult.Alert != nil {
env.ContentSafetyAlert = scanResult.Alert
}
if opts.JqExpr != "" {
if scanResult.Alert != nil && opts.ErrOut != nil {
WriteAlertWarning(opts.ErrOut, scanResult.Alert)
}
return JqFilter(opts.Out, env, opts.JqExpr)
}
PrintJson(opts.Out, env)
return nil
}

View File

@@ -101,44 +101,34 @@ func ExtractItems(data interface{}) []interface{} {
// FormatValue formats a single response and writes it to w.
func FormatValue(w io.Writer, data interface{}, format Format) {
err := WriteFormatted(w, data, format)
switch {
case err == nil:
return
case isOutputMarshalError(err) && format == FormatNDJSON:
legacyStderrf("ndjson marshal error: %v\n", err)
case isOutputMarshalError(err):
legacyStderrf("json marshal error: %v\n", err)
}
}
// WriteFormatted formats a single response and returns marshal or write errors.
func WriteFormatted(w io.Writer, data interface{}, format Format) error {
data = toGeneric(data)
switch format {
case FormatNDJSON:
items := ExtractItems(data)
if items != nil {
return WriteNDJSON(w, items)
PrintNdjson(w, items)
} else {
PrintNdjson(w, data)
}
return WriteNDJSON(w, data)
case FormatTable:
items := ExtractItems(data)
if items != nil {
return WriteTable(w, items)
FormatAsTable(w, items)
} else {
FormatAsTable(w, data)
}
return WriteTable(w, data)
case FormatCSV:
items := ExtractItems(data)
if items != nil {
return WriteCSV(w, items)
FormatAsCSV(w, items)
} else {
FormatAsCSV(w, data)
}
return WriteCSV(w, data)
default: // FormatJSON
return WriteJSON(w, data)
PrintJson(w, data)
}
}
@@ -158,63 +148,49 @@ func NewPaginatedFormatter(w io.Writer, format Format) *PaginatedFormatter {
// FormatPage formats one page of items.
func (pf *PaginatedFormatter) FormatPage(data interface{}) {
err := pf.WritePage(data)
if isOutputMarshalError(err) && (pf.Format == FormatJSON || pf.Format == FormatNDJSON) {
legacyStderrf("ndjson marshal error: %v\n", err)
}
}
// WritePage formats one page of items and returns marshal or write errors.
func (pf *PaginatedFormatter) WritePage(data interface{}) error {
switch pf.Format {
case FormatJSON, FormatNDJSON:
if arr, ok := data.([]interface{}); ok {
return WriteNDJSON(pf.W, arr)
PrintNdjson(pf.W, arr)
} else {
PrintNdjson(pf.W, data)
}
return WriteNDJSON(pf.W, data)
case FormatTable:
return pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) error {
pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) {
widths := computeColumnWidths(rows, cols)
if isFirst {
if err := writeHeader(w, cols, widths); err != nil {
return err
}
writeHeader(w, cols, widths)
}
for _, row := range rows {
if err := writeRow(w, row, cols, widths); err != nil {
return err
}
writeRow(w, row, cols, widths)
}
return nil
})
case FormatCSV:
return pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) error {
return writeCSVRows(w, rows, cols, isFirst)
pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) {
writeCSVRows(w, rows, cols, isFirst)
})
}
return nil
}
// formatStructuredPage handles column-locking logic shared by table and csv.
func (pf *PaginatedFormatter) formatStructuredPage(data interface{}, emit func(io.Writer, []map[string]string, []string, bool) error) error {
func (pf *PaginatedFormatter) formatStructuredPage(data interface{}, emit func(io.Writer, []map[string]string, []string, bool)) {
rows, pageCols, isList := prepareRows(data)
if len(rows) == 0 {
if pf.isFirstPage && isList {
_, err := fmt.Fprintln(pf.W, "(empty)")
return err
fmt.Fprintln(pf.W, "(empty)")
}
return nil
return
}
if pf.isFirstPage {
// Lock columns from first page
pf.cols = pageCols
pf.isFirstPage = false
return emit(pf.W, rows, pf.cols, true)
emit(pf.W, rows, pf.cols, true)
} else {
// Reuse first page's columns — missing keys become empty, extra keys ignored
return emit(pf.W, rows, pf.cols, false)
emit(pf.W, rows, pf.cols, false)
}
}

View File

@@ -5,7 +5,6 @@ package output
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
@@ -16,44 +15,12 @@ import (
// PrintJson prints data as formatted JSON to w.
func PrintJson(w io.Writer, data interface{}) {
injectNotice(data)
if err := WriteJSON(w, data); isOutputMarshalError(err) {
legacyStderrf("json marshal error: %v\n", err)
}
}
type outputMarshalError struct {
err error
}
func (e *outputMarshalError) Error() string {
return e.err.Error()
}
func (e *outputMarshalError) Unwrap() error {
return e.err
}
func isOutputMarshalError(err error) bool {
var marshalErr *outputMarshalError
return errors.As(err, &marshalErr)
}
// legacyStderrf reports a leaf-formatter marshal/format failure on os.Stderr,
// preserving the pre-Emitter behavior for direct (unmigrated) callers of the
// Print*/FormatAs* wrappers. The Emitter never uses this — it returns typed
// errors instead. Removed once the remaining direct callers migrate.
func legacyStderrf(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, format, args...) //nolint:forbidigo // legacy leaf-formatter stderr; removed in the output-ownership follow-up
}
// WriteJSON writes data as formatted JSON to w and returns marshal or write errors.
func WriteJSON(w io.Writer, data interface{}) error {
b, err := json.MarshalIndent(data, "", " ")
if err != nil {
return &outputMarshalError{err: err}
fmt.Fprintf(os.Stderr, "json marshal error: %v\n", err)
return
}
_, err = fmt.Fprintln(w, string(b))
return err
fmt.Fprintln(w, string(b))
}
// injectNotice adds a "_notice" field into CLI envelope maps.
@@ -83,38 +50,21 @@ func injectNotice(data interface{}) {
// PrintNdjson prints data as NDJSON (Newline Delimited JSON) to w.
func PrintNdjson(w io.Writer, data interface{}) {
if arr, ok := data.([]interface{}); ok {
for _, item := range arr {
if err := WriteNDJSON(w, item); isOutputMarshalError(err) {
legacyStderrf("ndjson marshal error: %v\n", err)
}
}
return
}
if err := WriteNDJSON(w, data); isOutputMarshalError(err) {
legacyStderrf("ndjson marshal error: %v\n", err)
}
}
// WriteNDJSON writes data as NDJSON and returns marshal or write errors.
func WriteNDJSON(w io.Writer, data interface{}) error {
emit := func(item interface{}) error {
emit := func(item interface{}) {
b, err := json.Marshal(item)
if err != nil {
return &outputMarshalError{err: err}
fmt.Fprintf(os.Stderr, "ndjson marshal error: %v\n", err)
return
}
_, err = fmt.Fprintln(w, string(b))
return err
fmt.Fprintln(w, string(b))
}
if arr, ok := data.([]interface{}); ok {
for _, item := range arr {
if err := emit(item); err != nil {
return err
}
emit(item)
}
return nil
} else {
emit(data)
}
return emit(data)
}
func cellStr(val interface{}) string {

View File

@@ -16,69 +16,50 @@ const maxColWidth = 100
// - map[string]interface{} (single object) → key-value two-column table
// - empty array → "(empty)"
func FormatAsTable(w io.Writer, data interface{}) {
if err := WriteTable(w, data); isOutputMarshalError(err) {
legacyStderrf("json marshal error: %v\n", err)
}
}
// WriteTable formats data as a table and returns marshal or write errors.
func WriteTable(w io.Writer, data interface{}) error {
return WriteTablePaginated(w, data, true)
FormatAsTablePaginated(w, data, true)
}
// FormatAsTablePaginated formats data as a table with pagination awareness.
// When isFirstPage is true, outputs the header; otherwise only data rows.
func FormatAsTablePaginated(w io.Writer, data interface{}, isFirstPage bool) {
if err := WriteTablePaginated(w, data, isFirstPage); isOutputMarshalError(err) {
legacyStderrf("json marshal error: %v\n", err)
}
}
// WriteTablePaginated formats data as a table and returns marshal or write errors.
func WriteTablePaginated(w io.Writer, data interface{}, isFirstPage bool) error {
rows, cols, isList := prepareRows(data)
if cols == nil {
if isList {
_, err := fmt.Fprintln(w, "(empty)")
return err
fmt.Fprintln(w, "(empty)")
} else {
// Not a list and not an object — print as JSON fallback
return WriteJSON(w, data)
PrintJson(w, data)
}
return
}
if len(rows) == 0 {
if isFirstPage {
_, err := fmt.Fprintln(w, "(empty)")
return err
fmt.Fprintln(w, "(empty)")
}
return nil
return
}
if !isList {
// Single object: key-value two-column format
return formatKeyValueTable(w, rows[0], cols)
formatKeyValueTable(w, rows[0], cols)
return
}
// Calculate column widths (clamped to maxColWidth)
widths := computeColumnWidths(rows, cols)
if isFirstPage {
if err := writeHeader(w, cols, widths); err != nil {
return err
}
writeHeader(w, cols, widths)
}
for _, row := range rows {
if err := writeRow(w, row, cols, widths); err != nil {
return err
}
writeRow(w, row, cols, widths)
}
return nil
}
// formatKeyValueTable renders a single object as a two-column key-value table.
func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) error {
func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) {
maxKeyWidth := 0
for _, col := range cols {
kw := stringWidth(col)
@@ -90,11 +71,8 @@ func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) erro
for _, col := range cols {
val := row[col]
val = truncateToWidth(val, maxColWidth)
if _, err := fmt.Fprintf(w, "%s %s\n", padToWidth(col, maxKeyWidth), val); err != nil {
return err
}
fmt.Fprintf(w, "%s %s\n", padToWidth(col, maxKeyWidth), val)
}
return nil
}
// computeColumnWidths returns display widths for each column, clamped to maxColWidth.
@@ -121,29 +99,25 @@ func computeColumnWidths(rows []map[string]string, cols []string) []int {
}
// writeHeader writes the header row and separator line.
func writeHeader(w io.Writer, cols []string, widths []int) error {
func writeHeader(w io.Writer, cols []string, widths []int) {
var header []string
var sep []string
for i, col := range cols {
header = append(header, padToWidth(col, widths[i]))
sep = append(sep, strings.Repeat("─", widths[i]))
}
if _, err := fmt.Fprintln(w, strings.Join(header, " ")); err != nil {
return err
}
_, err := fmt.Fprintln(w, strings.Join(sep, " "))
return err
fmt.Fprintln(w, strings.Join(header, " "))
fmt.Fprintln(w, strings.Join(sep, " "))
}
// writeRow writes a single data row.
func writeRow(w io.Writer, row map[string]string, cols []string, widths []int) error {
func writeRow(w io.Writer, row map[string]string, cols []string, widths []int) {
var cells []string
for i, col := range cols {
val := truncateToWidth(row[col], widths[i])
cells = append(cells, padToWidth(val, widths[i]))
}
_, err := fmt.Fprintln(w, strings.Join(cells, " "))
return err
fmt.Fprintln(w, strings.Join(cells, " "))
}
// padToWidth pads a string with spaces to reach the target display width.

View File

@@ -1,107 +0,0 @@
{
"cases": {
"csv": {
"stdout": "id,name\n1,Alice\n2,Bob\n",
"stderr": ""
},
"format_raw_json_preserves_html": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n }\n}\n",
"stderr": ""
},
"jq_invalid_expression": {
"stdout": "",
"stderr": "error: invalid jq expression: unexpected EOF\n",
"error": {
"go_type": "*errs.ValidationError",
"json": {
"type": "validation",
"subtype": "invalid_argument",
"message": "invalid jq expression: unexpected EOF"
},
"message": "invalid jq expression: unexpected EOF",
"exit_code": 2
}
},
"jq_safety_alert_without_stderr_warning": {
"stdout": "1\n",
"stderr": ""
},
"jq_scalar": {
"stdout": "Alice\n",
"stderr": ""
},
"json_object": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"enabled\": true,\n \"id\": \"1\"\n }\n}\n",
"stderr": ""
},
"metadata": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": [\n {\n \"id\": \"1\"\n }\n ],\n \"meta\": {\n \"count\": 1,\n \"rollback\": \"lark-cli fixture rollback\"\n }\n}\n",
"stderr": ""
},
"ndjson": {
"stdout": "{\"id\":\"1\"}\n{\"id\":\"2\"}\n",
"stderr": ""
},
"notice": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n },\n \"_notice\": {\n \"update\": {\n \"latest\": \"9.9.9\"\n }\n }\n}\n",
"stderr": ""
},
"partial_failure_ok_false": {
"stdout": "{\n \"ok\": false,\n \"identity\": \"bot\",\n \"data\": {\n \"failed\": 1,\n \"succeeded\": 1\n }\n}\n",
"stderr": "",
"error": {
"go_type": "*output.PartialFailureError",
"json": {
"Code": 1
},
"message": "partial failure (exit 1)",
"exit_code": 1
}
},
"pretty": {
"stdout": "pretty:fixture\n",
"stderr": ""
},
"pretty_without_renderer": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"name\": \"Alice\"\n }\n}\n",
"stderr": ""
},
"raw_jq_complex": {
"stdout": "{\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n}\n",
"stderr": ""
},
"raw_json_preserves_html": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n }\n}\n",
"stderr": ""
},
"scanner_block": {
"stdout": "",
"stderr": "",
"error": {
"go_type": "*errs.ContentSafetyError",
"json": {
"type": "policy",
"subtype": "content_safety",
"message": "content safety violation detected (rules: fixture-rule)",
"rules": [
"fixture-rule"
]
},
"message": "content safety violation detected (rules: fixture-rule)",
"exit_code": 6
}
},
"scanner_error_fails_open": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n }\n}\n",
"stderr": "warning: content safety scan error: scanner unavailable\n"
},
"table_with_safety_warning": {
"stdout": "id name \n── ─────\n1 Alice\n",
"stderr": "warning: content safety alert from emitter-oracle (rules: fixture-rule)\n"
},
"unknown_format_data_envelope_notice": {
"stdout": "{\n \"_notice\": {\n \"skills\": {\n \"current\": \"1.0.0\"\n }\n },\n \"ok\": true,\n \"value\": \"fixture\"\n}\n",
"stderr": "warning: unknown format \"yaml\", falling back to json\n"
}
}
}

View File

@@ -1,41 +0,0 @@
{
"cases": {
"dry_run": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"dry_run\": true,\n \"data\": {\n \"api\": []\n }\n}\n",
"stderr": ""
},
"jq": {
"stdout": "1\n",
"stderr": ""
},
"jq_safety_warning": {
"stdout": "1\n",
"stderr": "warning: content safety alert from emitter-oracle (rules: fixture-rule)\n"
},
"json": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n }\n}\n",
"stderr": ""
},
"notice": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n },\n \"_notice\": {\n \"update\": {\n \"latest\": \"9.9.9\"\n }\n }\n}\n",
"stderr": ""
},
"scanner_block": {
"stdout": "",
"stderr": "",
"error": {
"go_type": "*errs.ContentSafetyError",
"json": {
"type": "policy",
"subtype": "content_safety",
"message": "content safety violation detected (rules: fixture-rule)",
"rules": [
"fixture-rule"
]
},
"message": "content safety violation detected (rules: fixture-rule)",
"exit_code": 6
}
}
}
}

View File

@@ -19,18 +19,12 @@ import (
type eventPayload struct {
Comment *struct {
Body string `json:"body"`
Path string `json:"path"`
} `json:"comment"`
Review *struct {
Body string `json:"body"`
} `json:"review"`
}
type commentContent struct {
Body string
Path string
}
func main() {
eventPath := flag.String("event", os.Getenv("GITHUB_EVENT_PATH"), "GitHub event payload path")
kind := flag.String("kind", os.Getenv("GITHUB_EVENT_NAME"), "GitHub event kind")
@@ -40,11 +34,12 @@ func main() {
fmt.Fprintln(os.Stderr, "comment-audit: --event or GITHUB_EVENT_PATH is required")
os.Exit(2)
}
diags, err := auditEvent(*eventPath, *kind)
body, err := commentBody(*eventPath)
if err != nil {
fmt.Fprintf(os.Stderr, "comment-audit: %v\n", err)
os.Exit(2)
}
diags := diagnostics(publiccontent.ScanComment(*kind, body))
if len(diags) > 0 {
fmt.Fprintln(os.Stderr, auditFailureSummary(len(diags)))
}
@@ -52,44 +47,32 @@ func main() {
os.Exit(report.ExitCode(diags))
}
func auditEvent(eventPath, kind string) ([]report.Diagnostic, error) {
content, err := commentBody(eventPath)
if err != nil {
return nil, err
}
return scanCommentContent(kind, content), nil
}
func scanCommentContent(kind string, content commentContent) []report.Diagnostic {
return diagnostics(publiccontent.ScanCommentAtPath(kind, content.Path, content.Body))
}
func auditFailureSummary(count int) string {
return fmt.Sprintf("post-publication audit found public content findings: %d", count)
}
func commentBody(path string) (commentContent, error) {
func commentBody(path string) (string, error) {
safePath, err := validate.SafeInputPath(path)
if err != nil {
return commentContent{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
WithParam("--event").
WithCause(err)
}
data, err := vfs.ReadFile(safePath)
if err != nil {
return commentContent{}, err
return "", err
}
var payload eventPayload
if err := json.Unmarshal(data, &payload); err != nil {
return commentContent{}, err
return "", err
}
switch {
case payload.Comment != nil:
return commentContent{Body: payload.Comment.Body, Path: payload.Comment.Path}, nil
return payload.Comment.Body, nil
case payload.Review != nil:
return commentContent{Body: payload.Review.Body}, nil
return payload.Review.Body, nil
default:
return commentContent{}, nil
return "", nil
}
}

View File

@@ -7,11 +7,9 @@ import (
"errors"
"os"
"path/filepath"
"strconv"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
)
func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
@@ -34,92 +32,11 @@ func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
if err != nil {
t.Fatalf("commentBody() error = %v", err)
}
if got.Body != "clean comment" || got.Path != "" {
t.Fatalf("comment content = %#v", got)
if got != "clean comment" {
t.Fatalf("comment body = %q", got)
}
}
func TestCommentBodyReadsReviewCommentPath(t *testing.T) {
dir := t.TempDir()
if err := writeTestFile(filepath.Join(dir, "event.json"), `{"comment":{"body":"test suggestion","path":"cmd/agent/list_test.go"}}`); err != nil {
t.Fatal(err)
}
origDir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = os.Chdir(origDir)
})
got, err := commentBody("event.json")
if err != nil {
t.Fatalf("commentBody() error = %v", err)
}
if got.Body != "test suggestion" || got.Path != "cmd/agent/list_test.go" {
t.Fatalf("comment content = %#v", got)
}
}
func TestCommentAuditUsesReviewCommentPathForFixtureClassification(t *testing.T) {
dir := t.TempDir()
body := `CLIENT_SECRET=$(security find-generic-password -w)`
event := `{"comment":{"body":` + strconv.Quote(body) + `,"path":"scripts/config_test.sh"}}`
if err := writeTestFile(filepath.Join(dir, "event.json"), event); err != nil {
t.Fatal(err)
}
origDir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = os.Chdir(origDir)
})
diags, err := auditEvent("event.json", "pull_request_review_comment")
if err != nil {
t.Fatalf("auditEvent() error = %v", err)
}
for _, diag := range diags {
if diag.Rule == "public_content_generic_credential" {
t.Fatalf("review comment fixture should not be a credential diagnostic: %#v", diags)
}
}
pathless := publiccontent.ScanComment("pull_request_review_comment", body)
for _, finding := range pathless {
if finding.Rule == "public_content_generic_credential" {
return
}
}
t.Fatalf("test precondition failed: pathless comment should be classified as a credential: %#v", pathless)
}
func TestScanCommentContentPreservesReviewCommentPath(t *testing.T) {
providerValue := "gh" + "p_" + "1234567890abcdef" + "1234567890abcdef" + "1234"
content := commentContent{
Body: `cfg := &Config{AccessToken: "` + providerValue + `"}`,
Path: "cmd/agent/list_test.go",
}
diags := scanCommentContent("pull_request_review_comment", content)
for _, diag := range diags {
if diag.Rule != "public_content_generic_credential" {
continue
}
if diag.File != content.Path {
t.Fatalf("credential diagnostic file = %q, want %q", diag.File, content.Path)
}
return
}
t.Fatalf("missing provider credential diagnostic: %#v", diags)
}
func TestCommentBodyRejectsUnsafeEventPath(t *testing.T) {
path := filepath.Join(t.TempDir(), "event.json")
if err := writeTestFile(path, `{"comment":{"body":"clean"}}`); err != nil {

View File

@@ -6,11 +6,10 @@ package diff
import (
"context"
"os"
"os/exec"
"path/filepath"
"reflect"
"testing"
"github.com/larksuite/cli/internal/testutil/gitcmd"
)
func TestScopeIncludesChangedSkillAndRelatedDomain(t *testing.T) {
@@ -123,7 +122,8 @@ func writeFile(t *testing.T, repo, rel, content string) {
func runGit(t *testing.T, repo string, args ...string) {
t.Helper()
cmd := gitcmd.Command(repo, args...)
cmd := exec.Command("git", args...)
cmd.Dir = repo
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)
}
@@ -131,7 +131,8 @@ func runGit(t *testing.T, repo string, args ...string) {
func gitOutput(t *testing.T, repo string, args ...string) string {
t.Helper()
cmd := gitcmd.Command(repo, args...)
cmd := exec.Command("git", args...)
cmd.Dir = repo
out, err := cmd.Output()
if err != nil {
t.Fatalf("git %v failed: %v", args, err)

View File

@@ -6,11 +6,10 @@ package publiccontent
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/testutil/gitcmd"
)
func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
@@ -24,10 +23,9 @@ func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
runGit(t, repo, "add", "baseline.md")
runGit(t, repo, "commit", "-m", "base")
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "public.md"), `# Public change
api_`+`key = "`+providerValue+`"
api_`+`key = "example-public-key"
`)
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add public doc", "-m", "Change"+"-Id: I0123456789abcdef0123456789abcdef01234567")
@@ -201,14 +199,13 @@ func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "base")
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
`{"access_` + `token":"` + providerValue + `"}`,
`{"client_` + `secret": "` + providerValue + `"}`,
`{"tenantAccess` + `Token":"` + providerValue + `"}`,
`{"github` + `Token":"` + providerValue + `"}`,
`{"vendorApi` + `Key":"` + providerValue + `"}`,
`{"slackBot` + `Token":"xoxb_` + `1234567890abcdef"}`,
`{"access_` + `token":"real-json-token"}`,
`{"client_` + `secret": "real ` + `secret value"}`,
`{"tenantAccess` + `Token":"real-tenant-camel-token"}`,
`{"github` + `Token":"real-github-token"}`,
`{"vendorApi` + `Key":"real-vendor-key"}`,
`{"slackBot` + `Token":"xoxb-real-token"}`,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "add json config")
@@ -218,7 +215,14 @@ func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
for _, item := range got {
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
count++
for _, forbidden := range []string{providerValue, "xoxb_" + "1234567890abcdef"} {
for _, forbidden := range []string{
"real-json-token",
"real secret value",
"real-tenant-camel-token",
"real-github-token",
"real-vendor-key",
"xoxb-real-token",
} {
if strings.Contains(item.Excerpt, forbidden) {
t.Fatalf("JSON credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
}
@@ -302,8 +306,8 @@ func TestCollectDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
count++
}
}
if count != 2 {
t.Fatalf("angle-wrapped provider credential findings = %d, want 2: %#v", count, got)
if count != 3 {
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
}
}
@@ -334,12 +338,12 @@ func TestCollectDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
count++
}
}
if count != 4 {
t.Fatalf("provider-shaped benign-key findings = %d, want 4: %#v", count, got)
if count != 7 {
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
}
}
func TestCollectAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
@@ -354,11 +358,15 @@ func TestCollectAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
t.Fatalf("readable metadata values should not be credential findings: %#v", got)
count++
}
}
if count != 3 {
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
}
}
func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
@@ -366,7 +374,7 @@ func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
accessKey := "AK" + "IAIOSFODNN7EXAMPXX"
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"AWS_ACCESS_KEY_ID: " + accessKey,
@@ -383,7 +391,7 @@ func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
continue
}
count++
if strings.Contains(item.Excerpt, accessKey) {
if strings.Contains(item.Excerpt, "AKIAIOSFODNN7EXAMPX") {
t.Fatalf("access key finding leaked value in excerpt %q", item.Excerpt)
}
}
@@ -424,7 +432,7 @@ func TestCollectDetectsPrivateKeyAssignments(t *testing.T) {
}
}
func TestCollectAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
@@ -440,11 +448,15 @@ func TestCollectAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T)
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
t.Fatalf("readable identifiers should not be credential findings: %#v", got)
count++
}
}
if count != 4 {
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
}
}
func TestCollectAllowsBenignUnquotedTokenFields(t *testing.T) {
@@ -477,13 +489,12 @@ func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"API_KEY_OPENAI: " + providerValue,
"TOKEN_GITHUB: " + providerValue,
"CLIENT_SECRET_GOOGLE: " + providerValue,
"SECRET_KEY_BASE: " + providerValue,
"APP_PASSWORD_PROD: " + providerValue,
"API_KEY_OPENAI: real-openai-key",
"TOKEN_GITHUB: real-github-token",
"CLIENT_SECRET_GOOGLE: real-google-secret",
"SECRET_KEY_BASE: real-secret-key-base",
"APP_PASSWORD_PROD: real-prod-password",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add credential config")
@@ -495,7 +506,13 @@ func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
continue
}
count++
for _, forbidden := range []string{providerValue} {
for _, forbidden := range []string{
"real-openai-key",
"real-github-token",
"real-google-secret",
"real-secret-key-base",
"real-prod-password",
} {
if strings.Contains(item.Excerpt, forbidden) {
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
}
@@ -604,8 +621,7 @@ func TestCollectSkipsOnlyKnownQualityGateFixtureFiles(t *testing.T) {
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan_test.go"), "SECRET_TOKEN=fixture\n")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan.go"), "const privateKeyFixture = \""+privateKeyBeginPrefix+privateKeyMarker+"\"\n")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "rules.go"), "markers := []string{\"generated with automation\"}\n")
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN="+providerValue+"\n")
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN=real-leak\n")
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "add scanner fixtures")
@@ -669,11 +685,10 @@ func TestCollectScansAddedLinesInSpecialPathNames(t *testing.T) {
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "base")
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN="+providerValue+"\n")
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN="+providerValue+"\n")
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN=space-value\n")
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN=quote-value\n")
runGit(t, repo, "mv", "docs/old.md", "docs/new name.md")
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN="+providerValue+"\n")
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN=rename-value\n")
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "add special paths")
@@ -840,7 +855,8 @@ func runGit(t *testing.T, repo string, args ...string) {
if len(args) > 0 && args[0] == "commit" {
args = append([]string{"commit", "--no-verify"}, args[1:]...)
}
cmd := gitcmd.Command(repo, args...)
cmd := exec.Command("git", args...)
cmd.Dir = repo
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)
@@ -849,7 +865,8 @@ func runGit(t *testing.T, repo string, args ...string) {
func runGitOutput(t *testing.T, repo string, args ...string) []byte {
t.Helper()
cmd := gitcmd.Command(repo, args...)
cmd := exec.Command("git", args...)
cmd.Dir = repo
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)

View File

@@ -4,15 +4,8 @@
package publiccontent
func ScanComment(kind, body string) []Finding {
return ScanCommentAtPath(kind, "", body)
}
func ScanCommentAtPath(kind, path, body string) []Finding {
if kind == "" {
kind = "comment"
}
if path == "" {
path = kind
}
return scanText(path, "comment", body, isDetectorRuleFile(path))
return scanText(kind, "comment", body, false)
}

View File

@@ -3,10 +3,7 @@
package publiccontent
import (
"strings"
"testing"
)
import "testing"
func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
got := ScanComment("issue_comment", `The published comment included /tmp/harness`+`-agent/run and CCM`+`-Harness: stage-4`)
@@ -20,60 +17,3 @@ func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
}
}
}
func TestScanCommentAllowsMermaidCredentialTerminology(t *testing.T) {
body := strings.Join([]string{
"```mermaid",
"sequenceDiagram",
" participant Client",
" participant AccessTokenHashTransport",
" participant SecurityPolicyTransport",
" Client->>AccessTokenHashTransport: Send request with bearer token",
" AccessTokenHashTransport->>AccessTokenHashTransport: Clone request and inject token hash",
" Client -> ClientSecret: Resolve configured credential",
" AccessTokenHashTransport->>SecurityPolicyTransport: Forward enriched request",
"```",
}, "\n")
got := ScanComment("issue_comment", body)
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("mermaid credential terminology should not be a credential finding: %#v", got)
}
}
}
func TestScanCommentDetectsCredentialAssignmentInsideMermaidMessage(t *testing.T) {
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
credentialAssignment := "password=" + providerValue
body := strings.Join([]string{
"```mermaid",
"sequenceDiagram",
" Client->>Server: Send " + credentialAssignment,
"```",
}, "\n")
got := ScanComment("issue_comment", body)
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("credential assignment inside mermaid message should be reported: %#v", got)
}
}
func TestScanCommentAtPathAllowsTestFixtureCredentialPlaceholder(t *testing.T) {
body := `cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret"}`
got := ScanCommentAtPath("pull_request_review_comment", "cmd/agent/list_test.go", body)
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("review comment test fixture should not be a credential finding: %#v", got)
}
}
}
func TestScanCommentAtPathDetectsProviderCredentialInTestFile(t *testing.T) {
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
body := `cfg := &Config{AccessToken: "` + providerValue + `"}`
got := ScanCommentAtPath("pull_request_review_comment", "cmd/agent/list_test.go", body)
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("provider credential in review comment should be reported: %#v", got)
}
}

View File

@@ -1,88 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"encoding/base64"
"net/url"
"strings"
)
func credentialValueHasStrongEvidence(key, value string) bool {
normalized := strings.TrimRight(strings.TrimSpace(value), ",;")
normalized = strings.TrimSpace(strings.Trim(normalized, `"'<>`))
candidates := credentialEvidenceCandidates(unwrapCredentialValue(normalized))
for _, candidate := range candidates {
if providerCredentialIdentifier(candidate) {
return true
}
}
if isCredentialMetadataField(key) {
return false
}
for _, candidate := range candidates {
if highEntropyCredentialValue(strings.ToLower(candidate)) || base64PaddedCredentialValue(candidate) {
return true
}
}
return percentEncodedCredentialValue(strings.ToLower(candidates[0])) ||
commandSubstitutionLooksCredentialLike(strings.ToLower(normalized))
}
func credentialEvidenceCandidates(value string) []string {
candidates := []string{value}
for range 3 {
decoded, err := url.PathUnescape(value)
if err != nil || decoded == value {
break
}
candidates = append(candidates, decoded)
value = decoded
}
return candidates
}
func isCredentialMetadataField(key string) bool {
if isBenignTokenField(key) {
return true
}
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
if len(parts) < 2 {
return false
}
switch parts[len(parts)-1] {
case "hash", "id", "kind", "marker", "prefix", "transport":
return true
default:
return false
}
}
func base64PaddedCredentialValue(value string) bool {
if len(value) < 16 || !strings.HasSuffix(value, "=") {
return false
}
if _, err := base64.StdEncoding.DecodeString(value); err != nil {
return false
}
return shannonEntropy(strings.TrimRight(value, "=")) >= 3.5
}
func percentEncodedCredentialValue(value string) bool {
if len(value) < 16 {
return false
}
var escapes int
for i := 0; i+2 < len(value); i++ {
if value[i] == '%' && isHexByte(value[i+1]) && isHexByte(value[i+2]) {
escapes++
i += 2
}
}
return escapes >= 2
}
func isHexByte(value byte) bool {
return (value >= '0' && value <= '9') || (value >= 'a' && value <= 'f')
}

View File

@@ -13,7 +13,7 @@ import (
)
var (
credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*(?::=|[:=])\s*(?:!!str\s+)?(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\x60[^\x60]*\x60)|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\x60\s,}\]]+))`)
credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*[:=]\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\s,}\]]+))`)
jwtLikeRE = regexp.MustCompile(`\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`)
credentialURLRE = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^/\s:@]*:[^@\s/]+@[^)\s]+`)
bearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+|["']Authorization["']\s*:\s*["']Bearer\s+)[A-Za-z0-9._+/=-]{12,}`)
@@ -383,63 +383,33 @@ func anglePlaceholderIdentifier(value string) bool {
}
func credentialShapedValue(value string) bool {
normalized := strings.TrimSpace(strings.Trim(strings.TrimSpace(value), `"'<>`))
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
return credentialShapedIdentifier(normalized)
}
func credentialShapedIdentifier(value string) bool {
return providerCredentialIdentifier(value)
}
func providerCredentialIdentifier(value string) bool {
value = strings.TrimSpace(value)
switch {
case providerTokenWithBody(value, "sk_live_", 16, ""),
providerTokenWithBody(value, "sk_test_", 16, ""),
providerTokenWithBody(value, "ghp_", 16, ""),
providerTokenWithBody(value, "gho_", 16, ""),
providerTokenWithBody(value, "ghu_", 16, ""),
providerTokenWithBody(value, "github_pat_", 16, "_"),
providerTokenWithBody(value, "xoxb_", 16, "-"),
providerTokenWithBody(value, "xoxp_", 16, "-"),
providerTokenWithBody(value, "xoxa_", 16, "-"),
providerTokenWithBody(value, "xoxb-", 16, "-"),
providerTokenWithBody(value, "xoxp-", 16, "-"),
providerTokenWithBody(value, "xoxa-", 16, "-"),
awsAccessKeyIdentifier(value):
case strings.HasPrefix(value, "sk_live_"),
strings.HasPrefix(value, "sk_test_"),
strings.HasPrefix(value, "ghp_"),
strings.HasPrefix(value, "gho_"),
strings.HasPrefix(value, "ghu_"),
strings.HasPrefix(value, "github_pat_"),
strings.HasPrefix(value, "xoxb_"),
strings.HasPrefix(value, "xoxp_"),
strings.HasPrefix(value, "xoxa_"):
return true
case strings.HasPrefix(value, "real-") &&
(strings.Contains(value, "secret") ||
strings.Contains(value, "token") ||
strings.Contains(value, "key") ||
strings.Contains(value, "password")):
return true
default:
return false
}
}
func providerTokenWithBody(value, prefix string, minBodyLength int, separators string) bool {
body, ok := strings.CutPrefix(value, prefix)
if !ok || len(body) < minBodyLength {
return false
}
for _, r := range body {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || strings.ContainsRune(separators, r) {
continue
}
return false
}
return true
}
func awsAccessKeyIdentifier(value string) bool {
if len(value) != 20 || (!strings.HasPrefix(value, "AKIA") && !strings.HasPrefix(value, "ASIA")) {
return false
}
for _, r := range value[4:] {
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
continue
}
return false
}
return true
}
func resourceTokenPlaceholderValue(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
switch normalized {

View File

@@ -47,30 +47,15 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
out = append(out, newFinding("public_content_private_key_block", file, privateKeyLine, source, "private key block"))
inPrivateKey = false
}
for _, location := range credentialAssignmentRE.FindAllStringIndex(line, -1) {
rawMatch := line[location[0]:location[1]]
if !validCredentialAssignmentStart(line, location[0], rawMatch) {
continue
}
match := credentialAssignmentRE.FindStringSubmatch(rawMatch)
if !isCredentialAssignmentMatch(rawMatch) {
for _, match := range credentialAssignmentRE.FindAllStringSubmatch(line, -1) {
if !isCredentialAssignmentMatch(match[0]) {
continue
}
value := credentialAssignmentValue(match)
keyName, _ := normalizedCredentialAssignmentKey(rawMatch)
evidenceValue := value
if sourceCodeFile(file) {
if rhs, ok := sourceCodeTypedCredentialRHS(line, location[0], rawMatch); ok {
evidenceValue = rhs
}
}
if !(isWebhookCredentialKey(keyName) && webhookAssignmentValueLooksCredentialLike(value)) &&
!credentialValueHasStrongEvidence(keyName, evidenceValue) {
continue
}
keyName, _ := normalizedCredentialAssignmentKey(match[0])
if value == "" ||
isNonSecretLiteralValue(value) ||
isBenignCodeCredentialExpression(file, line, location[0], rawMatch, value) ||
isBenignCodeCredentialExpression(file, line, match[0], value) ||
isPlaceholderValue(value) ||
isPermissionScopeIdentifierAssignment(keyName, value) ||
isResourceTokenPlaceholderAssignment(keyName, value) {
@@ -79,7 +64,7 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
if looksLikeEqualityComparison(value) {
continue
}
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(rawMatch)))
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0])))
}
for _, match := range jwtLikeRE.FindAllString(line, -1) {
if !isJWTToken(match) {
@@ -138,43 +123,21 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
return out
}
func validCredentialAssignmentStart(line string, start int, match string) bool {
if start <= 0 || credentialAssignmentOperator(match) != ":" {
return true
}
prefix := strings.TrimSpace(line[:start])
for _, arrow := range []string{"-->>", "->>", "-->", "->"} {
if strings.HasSuffix(prefix, arrow) {
return false
}
}
return true
}
func credentialAssignmentOperator(match string) string {
key, ok := credentialAssignmentKey(match)
if !ok {
return ""
}
rest := strings.TrimSpace(match[len(key):])
if strings.HasPrefix(rest, ":=") {
return ":="
}
if strings.HasPrefix(rest, ":") {
return ":"
}
if strings.HasPrefix(rest, "=") {
return "="
}
return ""
}
func isCredentialAssignmentMatch(match string) bool {
name, _, ok := normalizedCredentialAssignment(match)
name, value, ok := normalizedCredentialAssignment(match)
if !ok {
return false
}
return isExplicitCredentialKey(name) || isWebhookCredentialKey(name)
if isWebhookCredentialKey(name) && webhookAssignmentValueLooksCredentialLike(value) {
return true
}
if isBenignTokenField(name) && !credentialShapedValue(value) {
return false
}
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
return false
}
return isExplicitCredentialKey(name)
}
func normalizedCredentialAssignmentKey(match string) (string, bool) {
@@ -325,7 +288,7 @@ func tokenLikePlaceholderKey(key string) bool {
func tokenLikePlaceholderValue(key, value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
if normalized == "" || credentialShapedIdentifier(strings.Trim(value, `"'`)) {
if normalized == "" || credentialShapedIdentifier(normalized) {
return false
}
if authCredentialTokenKey(key) {
@@ -360,8 +323,52 @@ func maskedTokenFixturePlaceholderValue(key, value string) bool {
return stars >= 6 && alnum > 0
}
func isWeakTokenCredentialKey(key string) bool {
if authCredentialTokenKey(key) || isStrongTokenCredentialKey(key) {
return false
}
return key == "token" ||
strings.HasSuffix(key, "_token") ||
strings.HasSuffix(key, "-token")
}
func isStrongTokenCredentialKey(key string) bool {
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
for _, phrase := range [][2]string{
{"access", "token"},
{"refresh", "token"},
{"auth", "token"},
{"bearer", "token"},
{"session", "token"},
{"service", "token"},
{"bot", "token"},
{"api", "token"},
{"secret", "token"},
} {
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
return true
}
}
return false
}
func weakTokenValueLooksCredentialLike(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
if normalized == "" ||
isNonSecretLiteralValue(value) ||
isPlaceholderValue(value) {
return false
}
candidate := unwrapCredentialValue(normalized)
return credentialShapedIdentifier(candidate) ||
highEntropyCredentialValue(candidate) ||
commandSubstitutionLooksCredentialLike(normalized) ||
(strings.Contains(normalized, "://") &&
urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)))
}
func unwrapCredentialValue(value string) string {
value = strings.TrimSpace(strings.Trim(value, "\"'<>`"))
value = strings.TrimSpace(strings.Trim(value, `"'<>`))
if strings.HasPrefix(value, "${{") && strings.HasSuffix(value, "}}") {
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
}
@@ -481,20 +488,17 @@ func numericStringPlaceholderValue(value string) bool {
return true
}
func isBenignCodeCredentialExpression(file, line string, matchStart int, match, value string) bool {
func isBenignCodeCredentialExpression(file, line, match, value string) bool {
normalized := strings.TrimSpace(value)
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
return true
}
if !sourceCodeFile(file) {
if !sourceCodeFile(file) || credentialShapedValue(value) {
return false
}
if rhs, ok := sourceCodeTypedCredentialRHS(line, matchStart, match); ok {
if rhs, ok := sourceCodeTypedCredentialRHS(line, match); ok {
return isBenignTypedCredentialRHS(rhs)
}
if credentialShapedValue(value) {
return false
}
rawValueQuoted := credentialAssignmentRawValueQuoted(match)
if sourceCodeLiteralLooksNonSecret(normalized, !rawValueQuoted) {
return true
@@ -514,16 +518,17 @@ func isBenignCodeCredentialExpression(file, line string, matchStart int, match,
return codeReferenceExpression(normalized)
}
func sourceCodeTypedCredentialRHS(line string, matchStart int, match string) (string, bool) {
if matchStart < 0 || matchStart+len(match) > len(line) || line[matchStart:matchStart+len(match)] != match {
func sourceCodeTypedCredentialRHS(line, match string) (string, bool) {
idx := strings.Index(line, match)
if idx < 0 {
return "", false
}
key, ok := credentialAssignmentKey(match)
if !ok {
return "", false
}
rest := strings.TrimSpace(line[matchStart+len(key):])
if !strings.HasPrefix(rest, ":") || strings.HasPrefix(rest, ":=") {
rest := strings.TrimSpace(line[idx+len(key):])
if !strings.HasPrefix(rest, ":") {
return "", false
}
typeAndRHS := strings.TrimSpace(strings.TrimPrefix(rest, ":"))
@@ -531,12 +536,7 @@ func sourceCodeTypedCredentialRHS(line string, matchStart int, match string) (st
if assignmentIdx < 0 {
return "", false
}
rhs := strings.TrimSpace(typeAndRHS[assignmentIdx+1:])
parsed := credentialAssignmentRE.FindStringSubmatch("client_secret=" + rhs)
if parsed == nil {
return rhs, true
}
return credentialAssignmentValue(parsed), true
return strings.TrimSpace(typeAndRHS[assignmentIdx+1:]), true
}
func isBenignTypedCredentialRHS(value string) bool {
@@ -568,7 +568,7 @@ func credentialAssignmentRawValueQuoted(match string) bool {
func sourceCodeFile(file string) bool {
switch filepath.Ext(file) {
case ".go", ".js", ".jsx", ".py", ".sh", ".ts", ".tsx":
case ".go", ".js", ".jsx", ".py", ".ts", ".tsx":
return true
default:
return false
@@ -593,7 +593,6 @@ func sourceCodeLiteralLooksNonSecret(value string, allowNumeric bool) bool {
sourceCodeFakeOrPlaceholderLiteral(literal) ||
sourceCodeCredentialTermLiteral(literal) ||
sourceCodeCredentialPrefixLiteral(literal) ||
sourceCodeStringExpressionLiteral(literal) ||
sourceCodeVocabularyLiteral(literal) ||
sourceCodeSchemaTypeLiteral(literal) ||
benignCredentialStatusLiteral(literal)
@@ -686,18 +685,6 @@ func sourceCodeCredentialPrefixLiteral(value string) bool {
}
}
func sourceCodeStringExpressionLiteral(value string) bool {
normalized := strings.TrimSpace(value)
if normalized == "" ||
credentialShapedIdentifier(normalized) ||
highEntropyCredentialValue(strings.ToLower(normalized)) {
return false
}
return strings.Contains(normalized, "${") ||
strings.Contains(normalized, "$(") ||
(strings.Contains(normalized, `\b`) && strings.ContainsAny(normalized, "|[]{}()+*?"))
}
func sourceCodeVocabularyLiteral(value string) bool {
switch strings.ToLower(value) {
case "bot", "tenant", "user":
@@ -766,7 +753,7 @@ func codeIdentifier(value string) bool {
func isNonSecretLiteralValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
case "true", "false", "null", "nil", "{", "[", `\`:
case "true", "false", "null", "nil", "{", "[":
return true
default:
return false
@@ -993,7 +980,6 @@ func credentialURLPasswordFixture(password string) bool {
normalized := strings.ToLower(strings.Trim(password, `"'`))
switch normalized {
case "p",
"p%40ss",
"pass",
"password",
"pat_abc",

View File

@@ -251,22 +251,26 @@ func TestScanFileDoesNotTreatURLEncodedCredentialAsPlaceholder(t *testing.T) {
}
}
func TestScanFileAllowsReadablePlaceholderMarkerSubstrings(t *testing.T) {
func TestScanFileDoesNotTreatPlaceholderMarkerSubstringsAsPlaceholders(t *testing.T) {
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
"API_KEY=notredactedreal",
"API_KEY=notplaceholdersecret",
"API_KEY=abcxxxxreal",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("readable credential words should not be findings: %#v", got)
count++
}
}
if count != 3 {
t.Fatalf("placeholder-marker substring findings = %d, want 3: %#v", count, got)
}
}
func TestScanFileDetectsBase64PaddedCredentialAssignments(t *testing.T) {
paddedSecretPrefix := "dGhpc2lz" + "YXNlY3JldA"
paddedTokenPrefix := "UTdrMm1O" + "OXBSNHZYOA"
paddedTokenPrefix := "YWJj" + "ZGVmZ2g"
paddedSecret := base64PaddedFixture(paddedSecretPrefix)
paddedToken := base64PaddedFixture(paddedTokenPrefix)
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
@@ -290,25 +294,17 @@ func TestScanFileDetectsBase64PaddedCredentialAssignments(t *testing.T) {
}
}
func TestScanFileAllowsReadableBase64Lookalike(t *testing.T) {
got := ScanFile("docs/config.md", []byte("client_secret=placeholder=\n"))
if findingRules(got)["public_content_generic_credential"] {
t.Fatalf("readable base64 lookalike should not be a credential finding: %#v", got)
}
}
func TestScanFileDetectsQuotedJSONCredentialAssignments(t *testing.T) {
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
jsonToken := providerValue
jsonSecret := providerValue
jsonKey := providerValue
jsonTenantToken := providerValue
jsonAppSecret := providerValue
jsonPrefixedKey := providerValue
jsonTenantCamelToken := providerValue
jsonGithubToken := providerValue
jsonVendorKey := providerValue
jsonSlackBotToken := "xoxb_" + "1234567890abcdef"
jsonToken := "real-json-token"
jsonSecret := "real " + "secret value"
jsonKey := "real-json-key"
jsonTenantToken := "real-tenant-json-token"
jsonAppSecret := "real-app-secret"
jsonPrefixedKey := "real-prefixed-key"
jsonTenantCamelToken := "real-tenant-camel-token"
jsonGithubToken := "real-github-token"
jsonVendorKey := "real-vendor-key"
jsonSlackBotToken := "xoxb-real-token"
got := ScanFile("docs/public.json", []byte(strings.Join([]string{
`{"access_` + `token":"` + jsonToken + `"}`,
`{"client_` + `secret": "` + jsonSecret + `"}`,
@@ -338,13 +334,12 @@ func TestScanFileDetectsQuotedJSONCredentialAssignments(t *testing.T) {
}
func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"API_KEY_OPENAI: " + providerValue,
"TOKEN_GITHUB: " + providerValue,
"CLIENT_SECRET_GOOGLE: " + providerValue,
"SECRET_KEY_BASE: " + providerValue,
"APP_PASSWORD_PROD: " + providerValue,
"API_KEY_OPENAI: real-openai-key",
"TOKEN_GITHUB: real-github-token",
"CLIENT_SECRET_GOOGLE: real-google-secret",
"SECRET_KEY_BASE: real-secret-key-base",
"APP_PASSWORD_PROD: real-prod-password",
}, "\n")+"\n"))
var count int
for _, item := range got {
@@ -352,7 +347,13 @@ func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
continue
}
count++
for _, forbidden := range []string{providerValue} {
for _, forbidden := range []string{
"real-openai-key",
"real-github-token",
"real-google-secret",
"real-secret-key-base",
"real-prod-password",
} {
if strings.Contains(item.Excerpt, forbidden) {
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
}
@@ -363,77 +364,85 @@ func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
}
}
func TestScanFileAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
func TestScanFileDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"API_KEY_OPENAI: prod_key",
"CLIENT_SECRET_GOOGLE: prod_secret",
"TOKEN_GITHUB: github_token",
"APP_PASSWORD_PROD: prod_password",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("readable identifiers should not be credential findings: %#v", got)
count++
}
}
if count != 4 {
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
cases := []struct {
name string
text string
want bool
}{
{name: "stripe", text: "API_KEY: <" + stripeLike + ">", want: true},
{name: "github", text: "SECRET_TOKEN: <" + patLike + ">", want: true},
{name: "readable", text: "CLIENT_SECRET: <real-client-secret-value>", want: false},
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"API_KEY: <" + stripeLike + ">",
"SECRET_TOKEN: <" + patLike + ">",
"CLIENT_SECRET: <real-client-secret-value>",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertGenericCredentialFinding(t, "docs/config.yaml", tc.text, tc.want)
})
if count != 3 {
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
}
}
func TestScanFileDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
cases := []struct {
name string
text string
want bool
}{
{name: "expiry provider token", text: `{"access_token_expires_in":"` + patLike + `"}`, want: true},
{name: "expiry provider secret", text: `{"refresh_token_expires_in":"` + stripeLike + `"}`, want: true},
{name: "status readable", text: `{"client_secret_status":"real-client-secret-value"}`, want: false},
{name: "name readable", text: `{"client_secret_name":"real-client-secret-value"}`, want: false},
{name: "app provider token", text: `{"app_token":"` + patLike + `"}`, want: true},
{name: "sync provider secret", text: `{"sync_token":"` + stripeLike + `"}`, want: true},
{name: "target readable", text: `{"target_token":"real-client-secret-value"}`, want: false},
got := ScanFile("docs/public.json", []byte(strings.Join([]string{
`{"access_token_expires_in":"` + patLike + `"}`,
`{"refresh_token_expires_in":"` + stripeLike + `"}`,
`{"client_secret_status":"real-client-secret-value"}`,
`{"client_secret_name":"real-client-secret-value"}`,
`{"app_token":"` + patLike + `"}`,
`{"sync_token":"` + stripeLike + `"}`,
`{"target_token":"real-client-secret-value"}`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertGenericCredentialFinding(t, "docs/public.json", tc.text, tc.want)
})
if count != 7 {
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
}
}
func TestScanFileAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
func TestScanFileDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"API_KEY_NAME: prod_key",
"CLIENT_SECRET_NAME: prod_secret",
"SECRET_STATUS: prod_secret",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("readable metadata values should not be credential findings: %#v", got)
count++
}
}
if count != 3 {
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
}
}
func TestScanFileDetectsAccessKeyCredentials(t *testing.T) {
accessKey := "AK" + "IAIOSFODNN7EXAMPXX"
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"AWS_ACCESS_KEY_ID: " + accessKey,
"ACCESS_KEY_ID: " + accessKey,
@@ -584,18 +593,18 @@ func TestScanFileAllowsCredentialReferenceValues(t *testing.T) {
func TestScanFileDetectsMalformedGithubExpressionCredentialValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
cases := []struct {
name string
text string
want bool
}{
{name: "provider", text: "API_KEY=${{" + stripeLike + "}}", want: true},
{name: "readable", text: "TOKEN=${{real-secret-token-value}}", want: false},
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"API_KEY=${{" + stripeLike + "}}",
"TOKEN=${{real-secret-token-value}}",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertGenericCredentialFinding(t, "docs/config.yaml", tc.text, tc.want)
})
if count != 2 {
t.Fatalf("malformed GitHub expression credential findings = %d, want 2: %#v", count, got)
}
}
@@ -639,7 +648,6 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
func TestScanFileAllowsCredentialURLFixtures(t *testing.T) {
got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{
`proxy := "http://user:pass@proxy:8080"`,
`proxy := "http://user:p%40ss@proxy:8080/path"`,
`repo := "https://u:t@h/r.git"`,
`target := "https://attacker:pw@open.feishu.cn"`,
`proxy := "http://admin:s3cret@127.0.0.1:3128"`,
@@ -813,151 +821,35 @@ func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *tes
}
}
func TestScanFileAllowsStrongAuthTokenKeysWithoutStrongValueEvidence(t *testing.T) {
func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
`{"access_token":"img_abc123"}`,
`{"api_token":"img_live_secret"}`,
`{"service_token":"ab********cd"}`,
`{"bot_token":"board_v3_example"}`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("token field names alone should not produce findings: %#v", got)
}
}
}
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(strings.Join([]string{
`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`,
`cfg := &core.CliConfig{AppID: "a", AppSecret: "s"}`,
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600)`,
`rt := &stubRoundTripper{respBody: ` + "`" + `{"access_token":"t","token_type":"Bearer"}` + "`" + `}`,
`envContent := "FEISHU_APP_ID=cli_hermes_abc\nFEISHU_APP_SECRET=hermes_secret_123\nFEISHU_DOMAIN=lark\n"`,
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_auto\nFEISHU_APP_SECRET=auto_secret\n"), 0600)`,
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_new_app\nFEISHU_APP_SECRET=new_secret\n"), 0600)`,
`if got := out.String(); got != "username=x-access-token\npassword=valid-pat\n\n" {`,
`if got := out.String(); got != "username=x-access-token\npassword=restored-pat\n\n" {`,
`if got := stdout.String(); got != "username=x-access-token\npassword=pat-token\n\n" {`,
`return &core.CliConfig{AppID: "dummy", AppSecret: "dummy"}`,
`os.WriteFile(path, []byte("API_KEY=replace-me\n"), 0600)`,
`body := "APP_ID=\"cli_xxxxx\"\nAPP_SECRET=\"xxxxx\"\n"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("test fixture secret should not be credential finding: %#v", got)
}
}
}
func TestScanFileAllowsCredentialIdentifierFields(t *testing.T) {
got := ScanFile("fixtures/openapi_key_test.go", []byte(strings.Join([]string{
`"api_key_id": "k1",`,
`"secret_id": "s1",`,
`"token_id": "t1",`,
`"private_key_id": "pk1",`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("credential identifier fields should not be credential findings: %#v", got)
}
}
}
func TestScanFileDetectsCredentialShapedIdentifierFieldValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("fixtures/openapi_key_test.go", []byte(strings.Join([]string{
`"api_key_id": "` + stripeLike + `",`,
`"token_id": "` + githubToken + `",`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 2 {
t.Fatalf("credential-shaped identifier field findings = %d, want 2: %#v", count, got)
if count != 4 {
t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got)
}
}
func TestCredentialShapedValueTrimsWhitespaceBeforeDelimiters(t *testing.T) {
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
if !credentialShapedValue(` "` + providerValue + `" `) {
t.Fatal("space-padded quoted provider credential should be recognized")
}
}
func TestScanFileDetectsProviderCredentialsAcrossAssignmentSyntaxes(t *testing.T) {
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
tests := []struct {
name string
path string
text string
}{
{name: "Go raw string", path: "pkg/config.go", text: "const clientSecret = `" + providerValue + "`"},
{name: "TypeScript template literal", path: "pkg/config.ts", text: "const clientSecret = `" + providerValue + "`;"},
{name: "shell backtick", path: "scripts/config.sh", text: "client_secret=`" + providerValue + "`"},
{name: "YAML string tag", path: "docs/config.yaml", text: "client_secret: !!str " + providerValue},
{name: "YAML string tag double quoted", path: "docs/config.yaml", text: `client_secret: !!str "` + providerValue + `"`},
{name: "YAML string tag single quoted", path: "docs/config.yaml", text: `client_secret: !!str '` + providerValue + `'`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ScanFile(tt.path, []byte(tt.text+"\n"))
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("provider credential should be reported: %#v", got)
}
})
}
}
func TestScanFileDetectsPercentEncodedProviderCredential(t *testing.T) {
providerBody := strings.Join([]string{"1234567890abcdef", "1234567890abcdef", "1234"}, "")
tests := []string{
"access_token: ghp%" + "5F" + providerBody,
"access_token_hash: ghp%" + "255F" + providerBody,
}
for _, text := range tests {
got := ScanFile("docs/config.yaml", []byte(text+"\n"))
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("percent-encoded provider credential should be reported: %#v", got)
}
}
}
func TestScanFileRequiresCompleteProviderCredentialFormats(t *testing.T) {
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"token_type: asian",
"token_prefix: ASIA",
"token_prefix: ghp_",
"api_key: sk_live_example",
"token_prefix: asianmarketsegment01",
"token_prefix: ghp_placeholder_value",
}, "\n")+"\n"))
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("incomplete provider prefixes should not be credential findings: %#v", got)
}
}
}
func TestScanFileAllowsEncodedTokenMetadataURL(t *testing.T) {
got := ScanFile("docs/config.yaml", []byte("token_url: https%3A%2F%2Fexample.invalid/oauth/token\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("encoded token metadata URL should not be credential finding: %#v", got)
t.Fatalf("test fixture secret should not be credential finding: %#v", got)
}
}
}
func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
got := ScanFile("fixtures/minutes_detail.go", []byte(strings.Join([]string{
"var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)",
"REALISTIC_TOKEN_RE=\"\\\"${TOKEN_BODY}\\\"|\\`${TOKEN_BODY}\\`|\\\\b${TOKEN_BODY}\\\\b\"",
}, "\n")+"\n"))
got := ScanFile("fixtures/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("regexp token validator should not be credential finding: %#v", got)
@@ -1035,22 +927,6 @@ func TestScanFileAllowsSourceCodeCredentialNonSecretLiterals(t *testing.T) {
}
}
func TestScanFileAllowsSourceCodeSyntheticCredentialIdentifiers(t *testing.T) {
got := ScanFile("fixtures/sheets_media.go", []byte(strings.Join([]string{
`const fakeOfficeTokenPrefix = "fake_office_"`,
`const localOfficeTokenPrefix = "local_office_"`,
`const imageLiveSecretMarker = "img_live_secret"`,
`const imageProdKeyMarker = "img_prod_key"`,
`if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {`,
`if strings.HasPrefix(spreadsheetToken, localOfficeTokenPrefix) {`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("source code token prefix references should not be credential findings: %#v", got)
}
}
}
func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
`app_secret=***`,
@@ -1065,18 +941,22 @@ func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
}
}
func TestScanFileAllowsPartiallyMaskedCredentialValues(t *testing.T) {
func TestScanFileDetectsPartiallyMaskedCredentialValues(t *testing.T) {
got := ScanFile("fixtures/config.md", []byte(strings.Join([]string{
"client_secret=realprefix***realsuffix",
"client_secret=ab********cd",
"access_token=ab********cd",
"refresh_token=realprefix********realsuffix",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("partially masked values should not be credential findings: %#v", got)
count++
}
}
if count != 4 {
t.Fatalf("partially masked credential findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
@@ -1092,7 +972,6 @@ func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
}
func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
cases := []struct {
name string
file string
@@ -1101,47 +980,32 @@ func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
{
name: "typescript simple secret",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string = "` + providerValue + `"`,
text: `const clientSecret: string = "real-client-secret-value"`,
},
{
name: "typescript terminated secret",
name: "typescript numeric password",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string = "` + providerValue + `";`,
},
{
name: "typescript secret with trailing comment",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string = "` + providerValue + `"; // production`,
},
{
name: "typescript asserted secret",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string = "` + providerValue + `" as const;`,
},
{
name: "typescript provider password",
file: "fixtures/source_secret.ts",
text: `const password: string = "` + providerValue + `"`,
text: `const password: string = "12345678901234567890"`,
},
{
name: "typescript union secret",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string | undefined = "` + providerValue + `"`,
text: `const clientSecret: string | undefined = "real-client-secret-value"`,
},
{
name: "python simple secret",
file: "fixtures/source_secret.py",
text: `self.client_secret: str = "` + providerValue + `"`,
text: `self.client_secret: str = "real-client-secret-value"`,
},
{
name: "python union secret",
file: "fixtures/source_secret.py",
text: `self.client_secret: str | None = "` + providerValue + `"`,
text: `self.client_secret: str | None = "real-client-secret-value"`,
},
{
name: "python optional secret",
file: "fixtures/source_secret.py",
text: `self.client_secret: Optional[str] = "` + providerValue + `"`,
text: `self.client_secret: Optional[str] = "real-client-secret-value"`,
},
}
for _, tc := range cases {
@@ -1154,154 +1018,24 @@ func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
}
}
func TestScanFileDetectsRepeatedTypedCredentialAssignments(t *testing.T) {
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
assertGenericCredentialFinding(t, "fixtures/source_secret.ts", `const clientSecret: string = "placeholder";`, false)
assertGenericCredentialFinding(t, "fixtures/source_secret.ts", `const clientSecret: string = "`+providerValue+`";`, true)
got := ScanFile("fixtures/source_secret.ts", []byte(
`const clientSecret: string = "placeholder"; const clientSecret: string = "`+providerValue+`";`+"\n",
))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 1 {
t.Fatalf("repeated typed credential findings = %d, want 1: %#v", count, got)
}
}
func TestScanFileDetectsCredentialShapedSourceCodeLiterals(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
cases := []struct {
name string
text string
want bool
}{
{name: "stripe", text: `const ClientSecret = "` + stripeLike + `"`, want: true},
{name: "github", text: `const GithubToken = "` + githubToken + `"`, want: true},
{name: "password number", text: `const Password = "12345678901234567890"`, want: false},
{name: "secret number", text: `const ClientSecretNumber = "12345678901234567890"`, want: false},
{name: "format literal", text: `const ClientSecretFormat = "abc%sdefreal"`, want: false},
{name: "inline format literal", text: `fmt.Println("done"); const ClientSecret = "abc%sdefreal"`, want: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertGenericCredentialFinding(t, "fixtures/source_secret.go", tc.text, tc.want)
})
}
}
func TestScanFileDetectsGoShortDeclarationCredentials(t *testing.T) {
providerSecret := "sk_" + "live_1234567890abcdef"
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("fixtures/source_secret.go", []byte(strings.Join([]string{
`clientSecret := "` + providerSecret + `"`,
`accessToken := "` + providerToken + `"`,
`const ClientSecret = "real-client-secret-value"`,
`const GithubToken = "` + githubToken + `"`,
`const Password = "12345678901234567890"`,
`const ClientSecretNumber = "12345678901234567890"`,
`const ClientSecretFormat = "abc%sdefreal"`,
`fmt.Println("done"); const ClientSecret = "abc%sdefreal"`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 2 {
t.Fatalf("Go short declaration credential findings = %d, want 2: %#v", count, got)
}
}
func TestGenericCredentialDecisionMatrix(t *testing.T) {
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
highEntropyValue := "Q7k2mN9pR4vX8cL3" + "sT6yU1aD5fG0hJ2z"
tokenHash := "6f1ed002ab559585" + "9014ebf0951522d9" +
"a0e3c1f4206254d" + "28a13efbbc8d56a30"
tests := []struct {
name string
path string
text string
comment bool
want bool
}{
{name: "source synthetic token prefix", path: "pkg/sheets.go", text: `const localOfficeTokenPrefix = "local_office_"`, want: false},
{name: "source token kind state", path: "pkg/client.py", text: `self._token_kind: TokenKind | None = None`, want: false},
{name: "documentation token prefix", path: "docs/config.yaml", text: `token_prefix: local_office_`, want: false},
{name: "documentation token kind", path: "docs/config.yaml", text: `token_kind: bearer`, want: false},
{name: "documentation token hash", path: "docs/config.yaml", text: `access_token_hash: ` + tokenHash, want: false},
{name: "comment fixture placeholder", text: `AppSecret: "fake-secret"`, comment: true, want: false},
{name: "test fixture placeholder", path: "pkg/config_test.go", text: `AppSecret: "fake-secret"`, want: false},
{name: "test real-labeled token", path: "pkg/config_test.go", text: `token: "real-tenant-access-token"`, want: false},
{name: "test ambiguous concrete secret word", path: "pkg/config_test.go", text: `AppSecret: "supersecret"`, want: false},
{name: "resource token placeholder", path: "docs/images.md", text: `"token": "img_abc123"`, want: false},
{name: "partially masked token", path: "docs/auth.md", text: `token=ab********cd`, want: false},
{name: "source readable secret words", path: "pkg/config.go", text: `const AppSecret = "customer-prod-secret"`, want: false},
{name: "documentation readable secret words", path: "docs/config.yaml", text: `client_secret: customer-prod-secret`, want: false},
{name: "comment middle fixture marker", text: `API_KEY=prod-fake-key`, comment: true, want: false},
{name: "comment negated fixture marker", text: `AppSecret: "not-fake-secret"`, comment: true, want: false},
{name: "source with credential words", path: "pkg/config.go", text: `secretWithPassword := "hunter2"`, want: false},
{name: "production filename containing sample", path: "pkg/sampler.go", text: `clientSecret := "customer-prod-secret"`, want: false},
{name: "provider token under weak key", path: "docs/config.yaml", text: `token: ` + providerToken, want: true},
{name: "provider token under hash key", path: "docs/config.yaml", text: `access_token_hash: ` + providerToken, want: true},
{name: "high entropy strong secret", path: "docs/config.yaml", text: `client_secret: ` + highEntropyValue, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got []Finding
if tt.comment {
got = ScanComment("issue_comment", tt.text)
} else {
got = ScanFile(tt.path, []byte(tt.text+"\n"))
}
if actual := findingRules(got)["public_content_generic_credential"]; actual != tt.want {
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, tt.want, got)
}
})
}
}
func TestScanFileClassifiesLowEvidenceTestFixtureCredentials(t *testing.T) {
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
highEntropyValue := "Q7k2mN9pR4vX8cL3" + "sT6yU1aD5fG0hJ2z"
tests := []struct {
name string
value string
want bool
}{
{name: "human readable access token", value: "user-access-token", want: false},
{name: "delimited secret value", value: "secret-value", want: false},
{name: "underscored secret fixture", value: "plain_secret", want: false},
{name: "short delimited fixture", value: "t-abc", want: false},
{name: "embedded test marker", value: "perm-grant-test-secret-skip", want: false},
{name: "real labeled fixture", value: "real-token", want: false},
{name: "ambiguous concrete word", value: "supersecret", want: false},
{name: "provider token", value: providerToken, want: true},
{name: "high entropy secret", value: highEntropyValue, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ScanFile("pkg/config_test.go", []byte(`AppSecret: "`+tt.value+`"`+"\n"))
if actual := findingRules(got)["public_content_generic_credential"]; actual != tt.want {
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, tt.want, got)
}
})
}
}
func TestScanFileAllowsLowEvidenceTestFixtureAssignmentSyntaxes(t *testing.T) {
got := ScanFile("pkg/config_test.go", []byte(strings.Join([]string{
`secret := "secret-value"`,
`samplePassword := "sample-password"`,
`bodyWithToken := "plain text body\\nDownload: https://example.com/file?token=tok_aaa\\n"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("low-evidence test fixture assignment should not be reported: %#v", got)
}
if count != 6 {
t.Fatalf("source code credential-shaped literal findings = %d, want 6: %#v", count, got)
}
}
@@ -1382,10 +1116,9 @@ func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
`{"client_token":"` + stripeLike + `"}`,
`{"client_token":"` + githubToken + `"}`,
`{"client_token":"real-client-secret-value"}`,
}, "\n")+"\n"))
var count int
for _, item := range got {
@@ -1419,10 +1152,9 @@ func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
`{ "resource_token": "` + stripeLike + `" }`,
`{ "block_token": "` + githubToken + `" }`,
`{ "block_token": "real-client-secret-value" }`,
}, "\n")+"\n"))
var count int
for _, item := range got {
@@ -1636,43 +1368,39 @@ func TestScanFileAllowsConventionalCredentialPlaceholders(t *testing.T) {
}
}
func TestScanFileAllowsInvalidProviderPlaceholderLookalikes(t *testing.T) {
func TestScanFileDetectsCredentialShapedPlaceholderLookalikes(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
"client_secret: " + stripeLike + "_HERE",
"api_key: YOUR_" + stripeLike,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("invalid provider placeholder lookalike should not be blocked: %#v", got)
count++
}
}
if count != 2 {
t.Fatalf("credential-shaped placeholder lookalike findings = %d, want 2: %#v", count, got)
}
}
func TestScanFileDetectsPercentWrappedCredentialValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
cases := []struct {
name string
text string
want bool
}{
{name: "stripe", text: "CLIENT_SECRET=%" + stripeLike + "%", want: true},
{name: "github", text: "GITHUB_TOKEN=%" + patLike + "%", want: true},
{name: "readable", text: "TOKEN=%real-secret-token-value%", want: false},
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
"CLIENT_SECRET=%" + stripeLike + "%",
"GITHUB_TOKEN=%" + patLike + "%",
"TOKEN=%real-secret-token-value%",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertGenericCredentialFinding(t, "docs/config.md", tc.text, tc.want)
})
}
}
func assertGenericCredentialFinding(t *testing.T, file, text string, want bool) {
t.Helper()
got := ScanFile(file, []byte(text+"\n"))
if actual := findingRules(got)["public_content_generic_credential"]; actual != want {
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, want, got)
if count != 3 {
t.Fatalf("percent-wrapped credential findings = %d, want 3: %#v", count, got)
}
}

View File

@@ -16,7 +16,6 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/qualitygate/facts"
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/qualitygate/report"
@@ -727,11 +726,7 @@ func appendDryRunArg(raw string) ([]string, error) {
return nil, fmt.Errorf("not a lark-cli command")
}
argv = truncateShellTail(argv)
var jqValid bool
argv, jqValid = stripDryRunJQFilter(argv)
if jqValid {
argv = forceDryRunJSONFormat(argv)
}
argv = forceDryRunJSONFormat(argv)
hasDryRunArg := false
dryRunEnabled := false
for _, arg := range argv[1:] {
@@ -780,73 +775,6 @@ func truncateShellTail(argv []string) []string {
return argv
}
// stripDryRunJQFilter removes valid output-only jq filters from the synthetic
// dry-run invocation. Invalid jq syntax and incompatible output flags are left
// untouched so the real CLI execution still rejects the documented command.
// The bool reports whether other output normalization remains safe.
func stripDryRunJQFilter(argv []string) ([]string, bool) {
jqExpr, outputPath, format, hasJQ, jqHasValue := dryRunOutputFlags(argv)
if !hasJQ {
return argv, true
}
if !jqHasValue || output.ValidateJqFlags(jqExpr, outputPath, format) != nil {
return argv, false
}
out := make([]string, 0, len(argv))
for i := 0; i < len(argv); i++ {
arg := argv[i]
switch {
case arg == "--":
return append(out, argv[i:]...), true
case arg == "--jq" || arg == "-q":
i++
case strings.HasPrefix(arg, "--jq=") || strings.HasPrefix(arg, "-q="):
continue
default:
out = append(out, arg)
}
}
return out, true
}
func dryRunOutputFlags(argv []string) (jqExpr, outputPath, format string, hasJQ, jqHasValue bool) {
for i := 1; i < len(argv); i++ {
arg := argv[i]
if arg == "--" {
break
}
switch {
case arg == "--jq" || arg == "-q":
hasJQ = true
jqHasValue = i+1 < len(argv)
if jqHasValue {
jqExpr = argv[i+1]
i++
}
case strings.HasPrefix(arg, "--jq=") || strings.HasPrefix(arg, "-q="):
hasJQ = true
jqHasValue = true
jqExpr = arg[strings.IndexByte(arg, '=')+1:]
case arg == "--output":
if i+1 < len(argv) {
outputPath = argv[i+1]
i++
}
case strings.HasPrefix(arg, "--output="):
outputPath = strings.TrimPrefix(arg, "--output=")
case arg == "--format":
if i+1 < len(argv) {
format = argv[i+1]
i++
}
case strings.HasPrefix(arg, "--format="):
format = strings.TrimPrefix(arg, "--format=")
}
}
return jqExpr, outputPath, format, hasJQ, jqHasValue
}
func dryRunFlagExplicitlyTrue(arg string) bool {
value, ok := strings.CutPrefix(arg, "--dry-run=")
if !ok {

View File

@@ -194,38 +194,6 @@ func TestRunDryRunsIgnoresTrailingShellComment(t *testing.T) {
}
}
func TestRunDryRunsIgnoresJQFilterWhenValidatingRequestPreview(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/im/v1/flags"}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "im +flag-list",
Runnable: true,
Identities: []string{"user"},
Flags: []manifest.Flag{
{Name: "as", TakesValue: true},
{Name: "page-all"},
{Name: "jq", Shorthand: "q", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: `lark-cli im +flag-list --as user --page-all -q '.data.flag_items[-1]'`,
SourceFile: "skills/lark-im/references/lark-im-flag-list.md",
Line: 26,
}
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
}
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
t.Fatalf("jq example should remain executable: %#v", facts)
}
wantArgs := []string{"im", "+flag-list", "--as", "user", "--page-all", "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsMaterializesPlaceholdersInsideJSONFlags(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/im/v1/messages","params":{"chat_id":"oc_test123","page_token":"page_test123"}}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
@@ -827,72 +795,6 @@ func TestAppendDryRunArgForcesInlineJSONFormat(t *testing.T) {
}
}
func TestAppendDryRunArgRemovesJQFilter(t *testing.T) {
tests := []struct {
name string
raw string
want []string
}{
{
name: "short split",
raw: `lark-cli im +flag-list --page-all -q '.data.flag_items[-1]'`,
want: []string{"im", "+flag-list", "--page-all", "--dry-run"},
},
{
name: "long split",
raw: `lark-cli im +flag-list --jq '.data.flag_items[].item_id' --page-all`,
want: []string{"im", "+flag-list", "--page-all", "--dry-run"},
},
{
name: "short inline",
raw: `lark-cli im +flag-list -q='.data.flag_items[-1]' --page-all`,
want: []string{"im", "+flag-list", "--page-all", "--dry-run"},
},
{
name: "long inline",
raw: `lark-cli im +flag-list --jq='.data.flag_items[-1]' --page-all`,
want: []string{"im", "+flag-list", "--page-all", "--dry-run"},
},
{
name: "missing value remains invalid",
raw: `lark-cli im +flag-list --page-all --jq`,
want: []string{"im", "+flag-list", "--page-all", "--jq", "--dry-run"},
},
{
name: "next flag is not accepted as jq expression",
raw: `lark-cli im +flag-list --jq --page-all`,
want: []string{"im", "+flag-list", "--jq", "--page-all", "--dry-run"},
},
{
name: "invalid expression remains invalid",
raw: `lark-cli im +flag-list --jq 'invalid[' --page-all`,
want: []string{"im", "+flag-list", "--jq", "invalid[", "--page-all", "--dry-run"},
},
{
name: "incompatible pretty format remains invalid",
raw: `lark-cli im +flag-list --jq '.data' --format pretty`,
want: []string{"im", "+flag-list", "--jq", ".data", "--format", "pretty", "--dry-run"},
},
{
name: "compatible json format preserves request preview",
raw: `lark-cli im +flag-list --jq '.data' --format json`,
want: []string{"im", "+flag-list", "--format", "json", "--dry-run"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := appendDryRunArg(tt.raw)
if err != nil {
t.Fatalf("appendDryRunArg() error = %v", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("appendDryRunArg() = %#v, want %#v", got, tt.want)
}
})
}
}
func TestAppendDryRunArgPreservesNonPrettyFormat(t *testing.T) {
for _, raw := range []string{
"lark-cli mail +watch --format data --dry-run",

View File

@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
@@ -14,7 +15,6 @@ import (
qdiff "github.com/larksuite/cli/internal/qualitygate/diff"
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/qualitygate/report"
"github.com/larksuite/cli/internal/testutil/gitcmd"
"github.com/larksuite/cli/internal/vfs"
)
@@ -203,8 +203,7 @@ func TestRunCollectsPublicContentFindingsIntoDiagnosticsAndFacts(t *testing.T) {
if err := vfs.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil {
t.Fatal(err)
}
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
publicDoc := "api_" + "key = \"" + providerValue + "\"\n" +
publicDoc := "api_" + "key = \"example-public-key\"\n" +
"Public docs describe a pri" + "vate request header and trust classification detail.\n"
if err := vfs.WriteFile(filepath.Join(repo, "docs", "public.md"), []byte(publicDoc), 0o644); err != nil {
t.Fatal(err)
@@ -600,8 +599,7 @@ func TestNormalizeDiagnosticFileHandlesAbsoluteRepo(t *testing.T) {
func runGit(t *testing.T, repo string, args ...string) {
t.Helper()
commandArgs := append([]string{"-c", "core.hooksPath=/dev/null"}, args...)
cmd := gitcmd.Command(repo, commandArgs...)
cmd := exec.Command("git", append([]string{"-c", "core.hooksPath=/dev/null", "-C", repo}, args...)...)
cmd.Env = append(os.Environ(), "GIT_AUTHOR_DATE=2026-06-17T00:00:00Z", "GIT_COMMITTER_DATE=2026-06-17T00:00:00Z")
out, err := cmd.CombinedOutput()
if err != nil {

View File

@@ -101,7 +101,6 @@ func TestSelectRecommendedScope_Empty(t *testing.T) {
}
func TestComputeMinimumScopeSet(t *testing.T) {
ensureFreshRegistry(t)
minSet := ComputeMinimumScopeSet("user")
if len(minSet) == 0 {
if len(ListFromMetaProjects()) == 0 {

View File

@@ -1,72 +0,0 @@
{
"version": "0.0.1",
"services": [
{
"name": "calendar",
"version": "v4",
"title": "Calendar API",
"servicePath": "/open-apis/calendar/v4",
"resources": {
"events": {
"methods": {
"create": {
"path": "calendars/{calendar_id}/events",
"httpMethod": "POST",
"risk": "write",
"scopes": [
"calendar:calendar.event:create"
],
"parameters": {
"calendar_id": {
"type": "string",
"location": "path",
"required": true
}
}
}
}
}
}
},
{
"name": "im",
"version": "v1",
"title": "IM API",
"servicePath": "/open-apis/im/v1",
"resources": {
"chat.members": {
"methods": {
"create": {
"path": "chats/{chat_id}/members",
"httpMethod": "POST",
"risk": "write",
"scopes": [
"im:chat",
"im:chat.members:write_only"
],
"parameters": {
"chat_id": {
"type": "string",
"location": "path",
"required": true
},
"member_id_type": {
"type": "string",
"location": "query",
"required": false
}
}
}
}
}
}
},
{
"name": "task",
"version": "v2",
"title": "Task API",
"servicePath": "/open-apis/task/v2",
"resources": {}
}
]
}

View File

@@ -1,146 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package registrytest seeds the registry with a tracked metadata fixture so
// command-tree tests pass on a clean checkout — no `make fetch_meta`, no
// network, no user cache. TestMain funcs of packages that build service
// commands call Seed after redirecting LARKSUITE_CLI_CONFIG_DIR.
package registrytest
import (
_ "embed"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/vfs"
)
// fixtureMetaJSON is a trimmed snapshot of the generated meta_data.json
// holding only the calendar, im and task services that registry-backed tests
// assert against. Its version is pinned to "0.0.1": newer than the empty
// embedded stub ("0.0.0") so it wins on a clean checkout, older than any real
// generated catalog ("1.0.0"+) so a `make fetch_meta` build keeps testing the
// full embedded data.
//
//go:embed fixture_meta.json
var fixtureMetaJSON []byte
// Seed writes fixtureMetaJSON into the registry remote-meta cache under
// LARKSUITE_CLI_CONFIG_DIR and eagerly initializes the registry. testRoot must
// be the temporary root created by the caller's TestMain; Seed rejects a config
// directory outside it before performing any write. The cache
// meta is stamped fresh so Init never sync-fetches or background-refreshes
// over the network. Eager Init pins the catalog for the whole test process before
// any individual test can re-point LARKSUITE_CLI_CONFIG_DIR elsewhere.
//
// The caller's TestMain must set LARKSUITE_CLI_CONFIG_DIR beneath testRoot
// first; Seed refuses unset, mismatched, or escaping paths so it can never
// write into a developer's real ~/.lark-cli.
func Seed(testRoot string) error {
configDir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR")
if err := validateConfigDir(testRoot, configDir); err != nil {
return err
}
var fixture struct {
Version string `json:"version"`
}
if err := json.Unmarshal(fixtureMetaJSON, &fixture); err != nil {
return err
}
cacheDir := filepath.Join(configDir, "cache")
if err := vfs.MkdirAll(cacheDir, 0o700); err != nil {
return err
}
if err := vfs.WriteFile(filepath.Join(cacheDir, "remote_meta.json"), fixtureMetaJSON, 0o644); err != nil {
return err
}
cacheMeta, err := json.Marshal(registry.CacheMeta{
LastCheckAt: time.Now().Unix(),
Version: fixture.Version,
Brand: string(core.BrandFeishu),
})
if err != nil {
return err
}
if err := vfs.WriteFile(filepath.Join(cacheDir, "remote_meta.meta.json"), cacheMeta, 0o644); err != nil {
return err
}
// Neutralize ambient knobs that would defeat the seeding: an inherited
// LARKSUITE_CLI_REMOTE_META=off would stop Init from reading the seeded
// cache at all, and LARKSUITE_CLI_META_TTL=0 would expire the freshness
// stamp and start a background network refresh from inside unit tests.
if err := os.Unsetenv("LARKSUITE_CLI_REMOTE_META"); err != nil {
return err
}
if err := os.Unsetenv("LARKSUITE_CLI_META_TTL"); err != nil {
return err
}
registry.Init()
// Init is a sync.Once, so the seed is pinned for the whole test process.
// Turning remote metadata off afterwards cannot un-seed anything; it is a
// guard for any future post-Init code path that might consult the remote
// cache again after a test re-points LARKSUITE_CLI_CONFIG_DIR elsewhere.
if err := os.Setenv("LARKSUITE_CLI_REMOTE_META", "off"); err != nil {
return err
}
// Self-check: both the fixture and any real generated catalog contain the
// im service. If it is missing, the cache seeding silently stopped working
// (e.g. the registry cache file names or freshness semantics changed) and
// every registry-backed test would fail confusingly — fail loudly here
// instead, pointing at this package.
merged, ok := registry.ServiceTyped("im")
if !ok {
return errors.New("registrytest.Seed: registry has no im service after seeding — " +
"the remote-cache format in internal/registry/remote.go may have changed; update registrytest to match")
}
// Self-check: on a fetch_meta build the real embedded catalog must win over
// the 0.0.1 fixture. If the merged im service diverges from the embedded
// one, the version arbitration flipped (e.g. the generated catalog version
// stopped parsing as semver) and unit tests would silently run against the
// stale trimmed fixture instead of the fresh catalog.
for _, service := range registry.EmbeddedServicesTyped() {
if service.Name != "im" {
continue
}
if service.Version != merged.Version {
return errors.New("registrytest.Seed: the fixture shadowed the real embedded catalog — " +
"check the meta_data.json version against the fixture's \"0.0.1\" arbitration in this package")
}
break
}
return nil
}
// validateConfigDir guards the one real hazard: a TestMain wiring mistake
// pointing LARKSUITE_CLI_CONFIG_DIR at a developer's real directory. Both
// paths come from the caller's own MkdirTemp, so a plain containment check
// is enough.
func validateConfigDir(testRoot, configDir string) error {
if testRoot == "" || configDir == "" {
return errors.New("registrytest.Seed: test root and config dir must be set")
}
if !filepath.IsAbs(testRoot) || !filepath.IsAbs(configDir) {
return errors.New("registrytest.Seed: test root and config dir must be absolute")
}
rel, err := filepath.Rel(filepath.Clean(testRoot), filepath.Clean(configDir))
if err != nil {
return err
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return errors.New("registrytest.Seed: config dir must stay inside the test root")
}
return nil
}

View File

@@ -1,229 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registrytest
import (
"net/http"
"os"
"path/filepath"
"slices"
"sort"
"testing"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/registry"
)
func TestValidateConfigDir(t *testing.T) {
root := t.TempDir()
tests := []struct {
name string
testRoot string
configDir string
wantErr bool
}{
{name: "equal", testRoot: root, configDir: root},
{name: "child", testRoot: root, configDir: filepath.Join(root, "config")},
{
name: "sibling",
testRoot: root,
configDir: filepath.Join(filepath.Dir(root), "outside"),
wantErr: true,
},
{name: "empty root", configDir: root, wantErr: true},
{name: "empty config", testRoot: root, wantErr: true},
{name: "relative root", testRoot: "relative", configDir: root, wantErr: true},
{name: "relative config", testRoot: root, configDir: "relative", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateConfigDir(tt.testRoot, tt.configDir)
if (err != nil) != tt.wantErr {
t.Fatalf("validateConfigDir() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestFixtureContract(t *testing.T) {
if len(fixtureMetaJSON) > 20<<10 {
t.Fatalf("fixture size = %d, want <= %d", len(fixtureMetaJSON), 20<<10)
}
reg, err := meta.Parse(fixtureMetaJSON)
if err != nil {
t.Fatalf("meta.Parse() error = %v", err)
}
if reg.Version != "0.0.1" {
t.Fatalf("fixture version = %q, want 0.0.1", reg.Version)
}
gotNames := make([]string, 0, len(reg.Services))
for _, service := range reg.Services {
gotNames = append(gotNames, service.Name)
}
sort.Strings(gotNames)
if !slices.Equal(gotNames, []string{"calendar", "im", "task"}) {
t.Fatalf("fixture services = %v, want [calendar im task]", gotNames)
}
calendarCreate := fixtureMethod(t, reg, "calendar", "events", "create")
assertMethodContract(t, calendarCreate, "calendars/{calendar_id}/events", http.MethodPost)
calendarID, ok := calendarCreate.Parameters["calendar_id"]
if !ok || calendarID.Location != "path" || !calendarID.Required {
t.Fatalf("calendar_id = %+v, want required path parameter", calendarID)
}
if !slices.Contains(calendarCreate.Scopes, "calendar:calendar.event:create") {
t.Fatalf("calendar create scopes = %v, want calendar:calendar.event:create", calendarCreate.Scopes)
}
imCreate := fixtureMethod(t, reg, "im", "chat.members", "create")
assertMethodContract(t, imCreate, "chats/{chat_id}/members", http.MethodPost)
chatID, ok := imCreate.Parameters["chat_id"]
if !ok || chatID.Location != "path" || !chatID.Required {
t.Fatalf("chat_id = %+v, want required path parameter", chatID)
}
memberIDType, ok := imCreate.Parameters["member_id_type"]
if !ok || memberIDType.Location != "query" || memberIDType.Required {
t.Fatalf("member_id_type = %+v, want optional query parameter", memberIDType)
}
if imCreate.Risk != "write" {
t.Fatalf("im create risk = %q, want write", imCreate.Risk)
}
for _, scope := range []string{"im:chat", "im:chat.members:write_only"} {
if !slices.Contains(imCreate.Scopes, scope) {
t.Fatalf("im create scopes = %v, want %s", imCreate.Scopes, scope)
}
}
}
func fixtureMethod(t *testing.T, reg meta.Registry, serviceName, resourceName, methodName string) meta.Method {
t.Helper()
for _, service := range reg.Services {
if service.Name != serviceName {
continue
}
resource, ok := service.Resource(resourceName)
if !ok {
t.Fatalf("fixture service %s has no resource %s", serviceName, resourceName)
}
method, ok := resource.Method(methodName)
if !ok {
t.Fatalf("fixture resource %s.%s has no method %s", serviceName, resourceName, methodName)
}
return method
}
t.Fatalf("fixture has no service %s", serviceName)
return meta.Method{}
}
func assertMethodContract(t *testing.T, method meta.Method, path, httpMethod string) {
t.Helper()
if method.Path != path || method.HTTPMethod != httpMethod {
t.Fatalf("method = %s %s, want %s %s", method.HTTPMethod, method.Path, httpMethod, path)
}
}
// TestSeedRejectsUnsafeConfigDir pins Seed's guard: it must return before
// writing anything when LARKSUITE_CLI_CONFIG_DIR is unset or escapes the
// caller's test root, so a TestMain wiring mistake can never touch a
// developer's real ~/.lark-cli.
func TestSeedRejectsUnsafeConfigDir(t *testing.T) {
root := t.TempDir()
t.Run("unset config dir", func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", "")
if err := Seed(root); err == nil {
t.Fatal("Seed() error = nil, want unset config dir rejection")
}
})
t.Run("config dir outside test root", func(t *testing.T) {
outside := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", outside)
if err := Seed(root); err == nil {
t.Fatal("Seed() error = nil, want containment rejection")
}
if _, err := os.Stat(filepath.Join(outside, "cache")); err == nil {
t.Fatal("Seed wrote into the rejected config dir")
}
})
}
// TestSeedWritesFixtureAndInitializesRegistry covers the seeding happy path:
// cache files land under the config dir, the registry initializes from them,
// and both self-checks pass.
func TestSeedWritesFixtureAndInitializesRegistry(t *testing.T) {
root := t.TempDir()
configDir := filepath.Join(root, "config")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
if err := Seed(root); err != nil {
t.Fatalf("Seed() error = %v, want nil", err)
}
for _, name := range []string{"remote_meta.json", "remote_meta.meta.json"} {
if _, err := os.Stat(filepath.Join(configDir, "cache", name)); err != nil {
t.Errorf("cache file %s: %v", name, err)
}
}
if got := os.Getenv("LARKSUITE_CLI_REMOTE_META"); got != "off" {
t.Errorf("LARKSUITE_CLI_REMOTE_META = %q, want off after seeding", got)
}
for _, service := range []string{"calendar", "im", "task"} {
if _, ok := registry.ServiceTyped(service); !ok {
t.Errorf("registry missing service %s after seeding", service)
}
}
}
// TestSeedPropagatesCacheSetupFailures pins that filesystem failures while
// materializing the cache surface as errors instead of leaving the registry
// silently unseeded. Each obstacle is a same-named file/directory in the
// way, which fails on every platform without permission tricks.
func TestSeedPropagatesCacheSetupFailures(t *testing.T) {
seedWith := func(t *testing.T, prepare func(root, configDir string)) error {
t.Helper()
root := t.TempDir()
configDir := filepath.Join(root, "config")
prepare(root, configDir)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
return Seed(root)
}
t.Run("cache dir creation fails", func(t *testing.T) {
err := seedWith(t, func(root, configDir string) {
// config is a regular file, so MkdirAll(config/cache) fails.
if err := os.WriteFile(configDir, nil, 0o600); err != nil {
t.Fatal(err)
}
})
if err == nil {
t.Fatal("Seed() error = nil, want cache dir creation failure")
}
})
t.Run("fixture write fails", func(t *testing.T) {
err := seedWith(t, func(root, configDir string) {
// remote_meta.json is a directory, so WriteFile fails.
if err := os.MkdirAll(filepath.Join(configDir, "cache", "remote_meta.json"), 0o700); err != nil {
t.Fatal(err)
}
})
if err == nil {
t.Fatal("Seed() error = nil, want fixture write failure")
}
})
t.Run("cache meta write fails", func(t *testing.T) {
err := seedWith(t, func(root, configDir string) {
// remote_meta.meta.json is a directory, so WriteFile fails.
if err := os.MkdirAll(filepath.Join(configDir, "cache", "remote_meta.meta.json"), 0o700); err != nil {
t.Fatal(err)
}
})
if err == nil {
t.Fatal("Seed() error = nil, want cache meta write failure")
}
})
}

View File

@@ -1,27 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"os"
"path/filepath"
"testing"
)
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-registry-test-*")
if err != nil {
panic(err)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
panic(err)
}
code := m.Run()
// A test that ran Init without a trailing resetInit can leave a background
// refresh goroutine alive; removing the temp root while it writes would
// let it recreate the directory after cleanup. Wait it out first.
waitBackgroundRefresh()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -1,55 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package gitcmd provides Git process helpers for tests that use temporary
// repositories.
package gitcmd
import (
"os"
"os/exec"
"strconv"
"testing"
)
const (
maintenanceAutoDetach = "maintenance.autoDetach"
gcAutoDetach = "gc.autoDetach"
)
// Command creates a Git command whose automatic maintenance stays in the
// command lifecycle, so temporary repository cleanup cannot race a detached
// maintenance process.
func Command(dir string, args ...string) *exec.Cmd {
commandArgs := make([]string, 0, len(args)+4)
commandArgs = append(commandArgs,
"-c", maintenanceAutoDetach+"=false",
"-c", gcAutoDetach+"=false",
)
commandArgs = append(commandArgs, args...)
cmd := exec.Command("git", commandArgs...)
cmd.Dir = dir
return cmd
}
// SetSynchronousMaintenanceEnv applies the same lifecycle contract to every
// Git process started by the current test, including processes created through
// production command runners. Tests using it must not run in parallel.
func SetSynchronousMaintenanceEnv(t *testing.T) {
t.Helper()
count := 0
if value, ok := os.LookupEnv("GIT_CONFIG_COUNT"); ok {
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 0 {
t.Fatalf("invalid GIT_CONFIG_COUNT %q", value)
}
count = parsed
}
for _, key := range []string{maintenanceAutoDetach, gcAutoDetach} {
index := strconv.Itoa(count)
t.Setenv("GIT_CONFIG_KEY_"+index, key)
t.Setenv("GIT_CONFIG_VALUE_"+index, "false")
count++
}
t.Setenv("GIT_CONFIG_COUNT", strconv.Itoa(count))
}

View File

@@ -1,47 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package gitcmd
import (
"os/exec"
"strings"
"testing"
)
func TestCommandDisablesDetachedMaintenance(t *testing.T) {
for _, key := range []string{"maintenance.autoDetach", "gc.autoDetach"} {
cmd := Command(t.TempDir(), "config", "--get", "--type=bool", key)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git config %s: %v\n%s", key, err, out)
}
if got := strings.TrimSpace(string(out)); got != "false" {
t.Fatalf("%s = %q, want false", key, got)
}
}
}
func TestSetSynchronousMaintenanceEnv(t *testing.T) {
t.Setenv("GIT_CONFIG_COUNT", "1")
t.Setenv("GIT_CONFIG_KEY_0", "user.name")
t.Setenv("GIT_CONFIG_VALUE_0", "Existing Test User")
SetSynchronousMaintenanceEnv(t)
for key, want := range map[string]string{
"user.name": "Existing Test User",
maintenanceAutoDetach: "false",
gcAutoDetach: "false",
} {
cmd := exec.Command("git", "config", "--get", "--type=bool", key)
if key == "user.name" {
cmd = exec.Command("git", "config", "--get", key)
}
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git config %s: %v\n%s", key, err, out)
}
if got := strings.TrimSpace(string(out)); got != want {
t.Fatalf("%s = %q, want %q", key, got, want)
}
}
}

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

@@ -34,12 +34,7 @@ func writeFixture(t *testing.T, files fixtureRepo) string {
func runGit(t *testing.T, root string, args ...string) string {
t.Helper()
commandArgs := []string{
"-c", "maintenance.autoDetach=false",
"-c", "gc.autoDetach=false",
}
commandArgs = append(commandArgs, args...)
cmd := exec.Command("git", commandArgs...)
cmd := exec.Command("git", args...)
cmd.Dir = root
out, err := cmd.CombinedOutput()
if err != nil {
@@ -48,14 +43,6 @@ func runGit(t *testing.T, root string, args ...string) string {
return strings.TrimSpace(string(out))
}
func TestRunGitDisablesDetachedMaintenance(t *testing.T) {
for _, key := range []string{"maintenance.autoDetach", "gc.autoDetach"} {
if got := runGit(t, t.TempDir(), "config", "--get", "--type=bool", key); got != "false" {
t.Fatalf("%s = %q, want false", key, got)
}
}
}
func TestLoadSubtypeAllowlist_ExtractsTypedConstValues(t *testing.T) {
root := writeFixture(t, fixtureRepo{
"errs/subtypes.go": `package errs

View File

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

View File

@@ -1,469 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
const automationSkillDoc = "../../skills/lark-apps/references/lark-apps-automation.md"
const localDevSkillDoc = "../../skills/lark-apps/references/lark-apps-local-dev.md"
const larkAppsSkillDoc = "../../skills/lark-apps/SKILL.md"
const releaseGetSkillDoc = "../../skills/lark-apps/references/lark-apps-release-get.md"
func readAutomationSkillDoc(t *testing.T) string {
return readAppsSkillDoc(t, automationSkillDoc)
}
func readLocalDevSkillDoc(t *testing.T) string {
return readAppsSkillDoc(t, localDevSkillDoc)
}
func readReleaseGetSkillDoc(t *testing.T) string {
return readAppsSkillDoc(t, releaseGetSkillDoc)
}
func readAppsSkillDoc(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read skill doc %s: %v", path, err)
}
return string(raw)
}
func skillSection(t *testing.T, doc, heading string) string {
t.Helper()
start := strings.Index(doc, heading)
if start < 0 {
t.Fatalf("missing skill section %q", heading)
}
rest := doc[start+len(heading):]
if next := strings.Index(rest, "\n## "); next >= 0 {
return rest[:next]
}
return rest
}
func skillSubsection(t *testing.T, doc, heading string) string {
t.Helper()
start := strings.Index(doc, heading)
if start < 0 {
t.Fatalf("missing skill subsection %q", heading)
}
rest := doc[start+len(heading):]
end := len(rest)
for _, marker := range []string{"\n### ", "\n## "} {
if next := strings.Index(rest, marker); next >= 0 && next < end {
end = next
}
}
return rest[:end]
}
func requireInOrder(t *testing.T, text string, tokens ...string) {
t.Helper()
offset := 0
for _, token := range tokens {
idx := strings.Index(text[offset:], token)
if idx < 0 {
t.Fatalf("missing %q after %q", token, text[:offset])
}
offset += idx + len(token)
}
}
func requireFirstOccurrencesInOrder(t *testing.T, text string, tokens ...string) {
t.Helper()
previous := -1
for _, token := range tokens {
idx := strings.Index(text, token)
if idx < 0 {
t.Fatalf("missing %q", token)
}
if idx <= previous {
t.Fatalf("first %q at %d must follow the previous contract token at %d", token, idx, previous)
}
previous = idx
}
}
func TestAutomationSkillContract_ChangedHandlerStartWaitsForThisRelease(t *testing.T) {
section := skillSubsection(t, readAutomationSkillDoc(t), "### 实现或更新 handler 后发布并启动/测试")
requireInOrder(t, section,
"仅当本轮确实需要新增或修改 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` handler",
"+automation-get",
"记录发布前状态",
"--name",
"项目 guide",
"按项目 guide 完成同名业务 handler 并本地验证。",
"在 Git 已确认/预授权时 commit然后执行",
"git push origin sprint/default",
"临时停用授权",
"+automation-disable",
"确认 disabled",
"+release-create --branch sprint/default",
"data.release_id",
"+release-get",
"data.status=finished",
"仅启动",
"+automation-enable",
"+automation-get",
"不制造 runtime probe",
"测试",
"运行时验证的操作级授权",
"完成全部 preflight",
"才执行 `+automation-enable`",
"真实 runtime",
"仅要求测试",
"恢复到发布前状态",
)
requireFirstOccurrencesInOrder(t, section,
"+automation-get",
"git push origin sprint/default",
"临时停用授权",
"+automation-disable",
"+release-create --branch sprint/default",
"data.status=finished",
"仅启动",
)
for _, boundary := range []string{
"仅当本轮确实需要新增或修改 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` handler且用户要求把这次代码发布后启动或测试时才使用此路径。",
"按项目 guide 完成同名业务 handler 并本地验证。",
"在 Git 已确认/预授权时 commit然后执行 `git push origin sprint/default`。",
"若该命令本身返回错误或未返回 `data.release_id`:视为确认未创建本轮 release新代码未上线原本 enabled 的 trigger 恢复 enabled 并回读、原本 disabled 的保持 disabled 后停止;若因超时等导致结果未知,保持 disabled先用 `+release-list --status finished --page-size 1` 核对是否已产生新 release 再决定。",
"只有 `data.status=finished` 才能继续;`publishing` 时每 20 秒继续轮询,整体最多约 5 分钟。",
"确认 `failed` 时报告发布失败,原本 enabled 的 trigger 仅在确认新代码未上线后恢复 enabled原本 disabled 的保持 disabled。",
"发布状态仍不确定时不得进入 enable、probe 或状态恢复分支。",
"**仅启动**:取得持续启动授权后执行 `+automation-enable`,并用 `+automation-get` 确认 enabled到此结束不制造 runtime probe。",
"**测试(含“启动并测试”)**:先按下节“运行时验证的操作级授权”完成全部 preflight",
"若用户仅要求测试而不是持续启动,只在本轮 release 已 `finished` 且 probe 成功后恢复到发布前状态",
"无论用户是仅测试还是启动并测试probe 失败、结果不确定或 enable 后提前结束时,一律 `+automation-disable` 并回读 disabled",
"不得把“发布前 enabled”当作失败后的恢复依据",
"没有通用的 `automation-debug` 或 trigger 日志 shortcut。",
} {
if !strings.Contains(section, boundary) {
t.Errorf("complete-start section must explain %q boundary", boundary)
}
}
}
func TestAutomationSkillContract_BindsTheExactNameAsUser(t *testing.T) {
doc := readAutomationSkillDoc(t)
for _, boundary := range []string{
"全部操作需 `--as user`AuthType: user。",
"当用户希望触发器实际执行业务代码时,先确认当前工作区是已初始化的应用项目,并读取其中与触发器任务匹配的 guide。",
"`--name` 是应用内唯一的 trigger 定位键;代码侧绑定名称必须与它逐字相同。不得用 trigger ID 或方法名代替它。具体 handler 语法和接入方式以项目 guide 为准。",
} {
if !strings.Contains(doc, boundary) {
t.Errorf("automation skill must preserve %q", boundary)
}
}
}
func TestAutomationSkillContract_RoutesAndDiagnosesUnfiredTriggers(t *testing.T) {
doc := readAutomationSkillDoc(t)
routeSection := skillSection(t, doc, "## 何时用本 skill路由锚点")
errorSection := skillSection(t, doc, "## 常见错误与决策场景")
if !strings.Contains(routeSection, "「触发器没反应 / enable 了不触发 / 为什么没执行 / 验证一下触发器」→ 先按「未触发时的诊断顺序」诊断;对 UPSERT 和 feishu-approval 仅验证配置边界,不承诺 handler 或 live 验证。") {
t.Error("routing anchors must direct unfired triggers to the bounded diagnostic flow")
}
if !strings.Contains(errorSection, "已证实的 cron、webhook、record-changeINSERT/UPDATE/DELETE按「未触发时的诊断顺序」排查UPSERT 和 feishu-approval 仅核对配置边界,不承诺 handler 或 live 验证。") {
t.Error("error table must preserve the bounded unfired-trigger diagnostic flow")
}
}
func TestAutomationSkillContract_ConfigurationStopsDisabled(t *testing.T) {
section := skillSubsection(t, readAutomationSkillDoc(t), "### 仅创建/配置触发器")
for _, boundary := range []string{
"用 `+automation-create` 创建,并省略 `--status` 或显式传 `disabled`,然后报告 name 和 disabled 状态。",
"不要传 `--status enabled`,也不要写 handler、commit/push、release 或 enable更不能把创建 API 成功称为“可运行”。",
"默认 disabled 是这个意图的终点,不是稍后自动 enable 的待办。",
} {
if !strings.Contains(section, boundary) {
t.Errorf("configuration-only section must preserve %q", boundary)
}
}
}
func TestAutomationSkillContract_EnableExistingTriggerDoesNotPublish(t *testing.T) {
doc := readAutomationSkillDoc(t)
section := skillSubsection(t, doc, "### 仅启用已有 disabled trigger")
routeSection := skillSection(t, doc, "## 何时用本 skill路由锚点")
requireInOrder(t, section,
"用户只要求启用已存在且 disabled 的 trigger",
"+automation-get",
"+release-list --status finished --page-size 1",
"已完成线上 release",
"当前线上应用",
"不能证明该 trigger name 已绑定 handler",
"+automation-enable",
"+automation-get",
"不得修改 handler、commit/push 或 release",
"对 UPSERT 或 feishu-approval 只改变配置状态",
)
if !strings.Contains(section, "未发布时不得自动创建 release也不得声称 trigger 已开始实际运行") {
t.Error("enable-only flow must distinguish configuration enablement from a published runtime")
}
if !strings.Contains(section, "即使存在 finished release也只能把 enable 报告为配置激活") {
t.Error("enable-only flow must not infer handler provenance from app release history")
}
if strings.Contains(section, "apps +get") || strings.Contains(section, "`is_published`") {
t.Error("enable-only flow must use finished release history instead of an optional app detail field")
}
for _, forbidden := range []string{"git push", "+release-create"} {
if strings.Contains(section, forbidden) {
t.Errorf("enable-only flow must not contain %q", forbidden)
}
}
if !strings.Contains(routeSection, "「启用 / 启动已有 trigger」→ 先核对现有状态;只启用时不要修改源码或发布应用。") {
t.Error("routing anchors must keep existing-trigger enablement separate from code release")
}
}
func TestAutomationSkillContract_TestExistingTriggerDoesNotPublish(t *testing.T) {
section := skillSubsection(t, readAutomationSkillDoc(t), "### 测试已有线上 trigger不改代码")
requireInOrder(t, section,
"用户要求测试已经发布的 trigger",
"+automation-get",
"+release-list --status finished --page-size 1",
"当前线上代码",
"不得为测试自动修改源码、commit/push 或 release",
"在任何临时 enable 之前完成",
"测试请求已明确包含临时 enable或另行取得 enable 授权",
"运行时验证的操作级授权",
"无论 probe 成功、失败、结果不确定,还是临时 enable 后提前结束或中断,最终都必须 `+automation-disable` 并回读 disabled",
)
for _, forbidden := range []string{"git push", "+release-create"} {
if strings.Contains(section, forbidden) {
t.Errorf("existing-trigger test flow must not contain %q", forbidden)
}
}
}
func TestAutomationSkillContract_HandlerOnlyStopsBeforeRelease(t *testing.T) {
section := skillSubsection(t, readAutomationSkillDoc(t), "### 仅完成 handler不发布/不启用)")
for _, boundary := range []string{
"创建或定位已明确 name 的 disabled trigger读取项目 guide按其要求实现同名业务 handler完成本地验证。",
"只在既有 Git 确认或预授权下 commit/push停止在 `+release-create` 和 `+automation-enable` 之前。",
"用户没有明确“发布好”时,先问,不能默认把完整应用上线。",
} {
if !strings.Contains(section, boundary) {
t.Errorf("handler-only section must preserve %q", boundary)
}
}
}
func TestAutomationSkillContract_HandlerOnlyExcludesUnverifiedRuntimeTypes(t *testing.T) {
section := skillSubsection(t, readAutomationSkillDoc(t), "### 仅完成 handler不发布/不启用)")
if !strings.Contains(section, "仅对 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` 使用此路径。") {
t.Error("handler-only flow must exclude UPSERT and feishu-approval without a verified runtime contract")
}
}
func TestAutomationSkillContract_PublishedHandlerStaysDisabled(t *testing.T) {
section := skillSubsection(t, readAutomationSkillDoc(t), "### 把 handler 发布好,但先不要启动")
for _, boundary := range []string{
"仅对 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` 使用此路径。",
"先用 `+automation-get` 定位;不存在时用 `+automation-create` 创建同名 disabled trigger再次回读确认。",
"已存在时记录它是否 enabled。",
"若 trigger 已 enabled先说明发布前必须临时停用以及可能造成的运行中断并取得这次临时停用授权未获授权时停止在发布前。",
"取得授权后,在发布前执行 `+automation-disable`,并再次用 `+automation-get` 确认 disabled。",
"按项目 guide 完成同名业务 handler 并本地验证后commit、`git push origin sprint/default`。",
"随后发布完整应用:",
"若 `+release-create` 本身返回错误或未返回 `data.release_id`:视为确认未创建本轮 release新代码未上线原本 enabled 的 trigger 恢复 enabled 并回读、原本 disabled 的保持 disabled然后停止若因超时等导致创建结果未知保持 disabled先用 `+release-list --status finished --page-size 1` 核对是否已产生新 release 再决定。",
"取得 `data.release_id` 后,对**这一轮** ID 调用 `+release-get``publishing` 时每 20 秒继续轮询,整体最多约 5 分钟;超时且状态仍不确定时报告 `release_id` 和当前 status并保持 disabled只有 `data.status=finished` 才算完成。",
"确认 `failed` 且新代码未上线时,原本 enabled 的 trigger 恢复 enabled 并回读,原本 disabled 的保持 disabled。",
"release 是整个应用上线可能影响既有线上功能未获得启动或测试授权时finished 后始终保持 disabled不执行 `+automation-enable`。",
} {
if !strings.Contains(section, boundary) {
t.Errorf("publish-without-start section must preserve %q", boundary)
}
}
requireFirstOccurrencesInOrder(t, section,
"+automation-get",
"git push origin sprint/default",
"临时停用授权",
"+automation-disable",
"+release-create",
)
}
func TestAutomationSkillContract_UPSERTAndApprovalStayConfigurationOnly(t *testing.T) {
section := skillSubsection(t, readAutomationSkillDoc(t), "### UPSERT 与飞书审批边界")
for _, boundary := range []string{
"record-change 的 UPSERT 可创建 disabled 配置,但当前没有已证实的运行时代码契约;不得静默按 UPDATE 处理,也不得承诺 handler 或 live 验证。",
"feishu-approval 可创建 disabled 配置,并读取或更新 `event_type`、对应 status 和可选 `approval_code`。",
"当前没有已证实的运行时 handler 契约或实际投递验证;不要把 enable 或审批 API 成功称为业务代码已执行。",
} {
if !strings.Contains(section, boundary) {
t.Errorf("UPSERT/approval boundary section must preserve %q", boundary)
}
}
}
func TestAutomationSkillContract_RuntimeProbeRequiresOperationScope(t *testing.T) {
section := skillSubsection(t, readAutomationSkillDoc(t), "### 运行时验证的操作级授权")
for _, boundary := range []string{
"启用 trigger 的授权不等于制造 runtime 事件的授权,测试授权也不等于任意数据库写入授权。",
"record-change 在执行任何 DML 前,必须明确并取得覆盖以下作用域的授权",
"环境、表、操作、精确测试记录或筛选条件、payload、预期结果和清理方式",
"优先使用专用测试记录",
"`DELETE`",
"[lark-apps-db-execute.md](lark-apps-db-execute.md)",
"先 `SELECT count(*)`、执行 `--dry-run`",
"取得针对该删除目标的明确授权",
"+automation-list --trigger-type record-change --all",
"同一环境、表和操作可能命中的其他 enabled trigger",
"聚合业务影响",
"恢复 UPDATE 或清理 INSERT 也可能再次触发自动化",
"缺少安全、已授权且可清理的事件入口时,记录 blocked",
} {
if !strings.Contains(section, boundary) {
t.Errorf("runtime probe section must preserve %q", boundary)
}
}
}
func TestAutomationSkillContract_UsesResolvableSharedSkillLink(t *testing.T) {
doc := readAutomationSkillDoc(t)
if strings.Contains(doc, "](../lark-shared/SKILL.md)") {
t.Error("automation reference must not resolve lark-shared inside the lark-apps directory")
}
if !strings.Contains(doc, "](../../lark-shared/SKILL.md)") {
t.Error("automation reference must link to the sibling lark-shared skill")
}
sharedSkillDoc := filepath.Clean(filepath.Join(filepath.Dir(automationSkillDoc), "../../lark-shared/SKILL.md"))
if _, err := os.Stat(sharedSkillDoc); err != nil {
t.Fatalf("automation reference target %s must exist: %v", sharedSkillDoc, err)
}
}
func TestAppsSkillContract_AllSharedSkillLinksResolve(t *testing.T) {
docs := []string{larkAppsSkillDoc}
references, err := filepath.Glob("../../skills/lark-apps/references/*.md")
if err != nil {
t.Fatalf("glob lark-apps references: %v", err)
}
docs = append(docs, references...)
sharedLink := regexp.MustCompile(`\]\(([^)]+lark-shared/SKILL\.md)\)`)
for _, docPath := range docs {
doc := readAppsSkillDoc(t, docPath)
for _, match := range sharedLink.FindAllStringSubmatch(doc, -1) {
target := filepath.Clean(filepath.Join(filepath.Dir(docPath), match[1]))
if _, err := os.Stat(target); err != nil {
t.Errorf("%s shared-skill link %q resolves to missing target %s: %v", docPath, match[1], target, err)
}
}
}
}
func TestLocalDevSkillContract_UsesProjectGuideWithoutSyncInternals(t *testing.T) {
section := skillSection(t, readLocalDevSkillDoc(t), "## Trigger guide 的项目边界")
for _, boundary := range []string{
"先查看工作区 `.agents/skills/`,读取与自动化任务匹配的 `trigger-guide`。",
"文件缺失或不能覆盖当前任务时,报告项目缺少可用的领域 guide不要在本 lark-cli reference 中猜测安装命令、版本或包内目录。",
} {
if !strings.Contains(section, boundary) {
t.Errorf("trigger-guide boundary section must explain %q", boundary)
}
}
for _, implementationShape := range []string{
"npx ", "skills sync", "data.", "skills_", "_CACHE_DIR", "nestjs-",
"@lark-apaas/miaoda-cli", "@lark-apaas/coding-steering", "miaoda-coding", "skills_common/",
} {
if strings.Contains(section, implementationShape) {
t.Errorf("local-dev skill must not expose project-sync implementation shape %q", implementationShape)
}
}
}
func TestAppsSkillContract_DoesNotExposeSteeringImplementation(t *testing.T) {
for name, doc := range map[string]string{
"automation": readAutomationSkillDoc(t),
"local-dev": readLocalDevSkillDoc(t),
} {
for _, implementationShape := range []string{
"npx ", "skills sync", "@lark-apaas/miaoda-cli", "@lark-apaas/coding-steering", "miaoda-coding", "skills_common/",
} {
if strings.Contains(doc, implementationShape) {
t.Errorf("%s skill must not expose project-sync implementation shape %q", name, implementationShape)
}
}
}
}
func TestLocalDevSkillContract_UsesEnvironmentAndDefersEnableToAutomationSOP(t *testing.T) {
doc := readLocalDevSkillDoc(t)
releaseSection := skillSection(t, doc, "## 改完代码后部署上线")
for _, legacy := range []string{"--env dev", "--env online"} {
if strings.Contains(doc, legacy) {
t.Errorf("local-dev skill must not recommend legacy %q", legacy)
}
}
for _, boundary := range []string{
"`publishing` 时每 20 秒继续轮询,整体最多约 5 分钟;超时仍未完成时停止本轮轮询、报告 `release_id` 和当前 status。",
"若本次改动包含自动化 handler在执行本节通用 commit/push/release 序列前就转到 [automation SOP](lark-apps-automation.md) 的匹配路径,由该 SOP 负责完整的状态门禁、commit/push、release 和可选 enable/test不要先按本节发布再补 trigger 状态检查。",
"用户只要求启用已有 trigger 时,转到 [automation SOP 的「仅启用已有 disabled trigger」路径](lark-apps-automation.md#仅启用已有-disabled-trigger);不得因 enable 反向修改 handler、commit/push 或 release。",
"使用 `--environment dev|online`,不要使用旧的 `--env`。只有确认应用已开启多环境时才引导 `--environment dev`;单环境应用省略 `--environment`(服务端选 online或显式传 `--environment online`。",
} {
if !strings.Contains(doc, boundary) {
t.Errorf("local-dev skill must preserve %q", boundary)
}
}
routeIndex := strings.Index(releaseSection, "若本次改动包含自动化 handler")
releaseIndex := strings.Index(releaseSection, "+release-create")
if routeIndex < 0 || releaseIndex < 0 || routeIndex >= releaseIndex {
t.Error("automation routing must appear before the generic release sequence")
}
}
func TestLocalDevSkillContract_DoesNotRequireOnlineURL(t *testing.T) {
section := skillSection(t, readLocalDevSkillDoc(t), "## 改完代码后部署上线")
if strings.Contains(section, "`finished` 成功时该命令输出已含 `online_url`") {
t.Error("release guidance must not claim every finished release includes online_url")
}
if !strings.Contains(section, "若返回 `online_url`,可直接使用;未返回时不要编造链接。") {
t.Error("release guidance must explain that online_url is optional")
}
}
func TestLocalDevSkillContract_TreatsErrorLogsAsOptional(t *testing.T) {
section := skillSection(t, readLocalDevSkillDoc(t), "## 改完代码后部署上线")
if !strings.Contains(section, "`failed` 时若返回非空 `error_logs`,据此给出失败原因;否则只报告 `release_id` 和当前 status不要编造原因") {
t.Error("release guidance must not promise error_logs on every failed release")
}
}
func TestReleaseSkillContract_TreatsOptionalOutputAsOptional(t *testing.T) {
releaseGet := readReleaseGetSkillDoc(t)
for _, boundary := range []string{
"`finished` 后才可能有 `online_url`。",
"若输出含 `online_url`,直接读取它作为本轮发布的线上访问链接;未返回时只报告发布完成,不要编造链接。",
"若输出含 `error_logs``step`/`error_log`),据此向用户转述关键失败步骤和可行动修复;未返回时不要编造失败原因。",
} {
if !strings.Contains(releaseGet, boundary) {
t.Errorf("release-get skill must preserve optional-output boundary %q", boundary)
}
}
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/client"
)
func appsValidationError(format string, args ...any) *errs.ValidationError {
@@ -73,3 +74,32 @@ func appsInputPathEntryError(path string, err error) error {
func appsFileIOError(err error, format string, args ...any) *errs.InternalError {
return errs.NewInternalError(errs.SubtypeFileIO, format, args...).WithCause(err)
}
// enrichHTMLPublishAPIError adapts a typed failure from the HTML publish
// endpoint: refines endpoint-scoped business codes, prefixes the message with
// command context, and attaches endpoint-specific recovery hints. A
// still-untyped error is lifted at the SDK boundary instead.
func enrichHTMLPublishAPIError(err error) error {
if err == nil {
return nil
}
p, ok := errs.ProblemOf(err)
if !ok {
return client.WrapDoAPIError(err)
}
// The HTML publish business codes (90001/90002) are scoped to this
// endpoint, not service-global, so their subtype classification lives
// here instead of the global errclass code table. Only an
// otherwise-unclassified API error is refined; a stronger upstream
// classification is never overridden.
if p.Category == errs.CategoryAPI && p.Subtype == errs.SubtypeUnknown && p.Code == errCodeAppNotFound {
p.Subtype = errs.SubtypeNotFound
}
if p.Message != "" {
p.Message = "html-publish failed: " + p.Message
}
if hint := buildHTMLPublishFailureHint(p.Code); hint != "" {
p.Hint = hint
}
return err
}

View File

@@ -57,3 +57,57 @@ func TestAppsFileIOError_ClassifiesInternalFileIO(t *testing.T) {
t.Fatalf("cause chain not preserved: %v", err)
}
}
func TestEnrichHTMLPublishAPIError_LiftsUntypedBoundaryError(t *testing.T) {
err := enrichHTMLPublishAPIError(errors.New("connection reset by peer"))
problem := requireAppsProblem(t, err, errs.CategoryNetwork)
if problem.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNetworkTransport)
}
}
func TestEnrichHTMLPublishAPIError_PreservesClassificationAndAddsHint(t *testing.T) {
err := errs.NewAPIError(errs.SubtypeUnknown, "build failed").
WithCode(errCodeBuildFailed).
WithLogID("logid-build-failed")
got := enrichHTMLPublishAPIError(err)
if got != err {
t.Fatalf("typed error should be enriched in place")
}
problem := requireAppsAPIProblem(t, got)
if problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("subtype = %q, want %q unchanged", problem.Subtype, errs.SubtypeUnknown)
}
if problem.Code != errCodeBuildFailed {
t.Fatalf("code = %d, want %d", problem.Code, errCodeBuildFailed)
}
if problem.LogID != "logid-build-failed" {
t.Fatalf("log_id = %q, want preserved", problem.LogID)
}
if !strings.Contains(problem.Message, "html-publish failed") {
t.Fatalf("message = %q, want html-publish context", problem.Message)
}
if problem.Hint == "" {
t.Fatalf("expected known-code recovery hint")
}
}
func TestEnrichHTMLPublishAPIError_ClassifiesAppNotFoundLocally(t *testing.T) {
err := errs.NewAPIError(errs.SubtypeUnknown, "app not found").WithCode(errCodeAppNotFound)
problem := requireAppsAPIProblem(t, enrichHTMLPublishAPIError(err))
if problem.Subtype != errs.SubtypeNotFound {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNotFound)
}
}
func TestEnrichHTMLPublishAPIError_KeepsStrongerClassification(t *testing.T) {
err := errs.NewAPIError(errs.SubtypeRateLimit, "throttled").WithCode(errCodeAppNotFound)
problem := requireAppsAPIProblem(t, enrichHTMLPublishAPIError(err))
if problem.Subtype != errs.SubtypeRateLimit {
t.Fatalf("subtype = %q, want %q unchanged", problem.Subtype, errs.SubtypeRateLimit)
}
}

View File

@@ -17,11 +17,10 @@ import (
var AppsGet = common.Shortcut{
Service: appsService,
Command: "+get",
Description: "Get a single app's detail by app ID or meta token (returns app_type, name, description, publish status, etc.)",
Description: "Get a single app's detail by app ID (returns app_type, name, description, publish status, etc.)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +get --app-id <app_id>",
"Example: lark-cli apps +get --app-id <meta_token>",
"Example: lark-cli apps +get --app-id <app_id> --dry-run",
"Tip: extract app type with --jq '.data.app.app_type'",
},
@@ -29,7 +28,7 @@ var AppsGet = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID or meta token", Required: true},
{Name: "app-id", Desc: "app ID", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
@@ -41,7 +40,7 @@ var AppsGet = common.Shortcut{
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))).
Desc("Get app detail (returns app_id, meta_token, app_type, name, description, icon_url, created_at, updated_at, is_published)")
Desc("Get app detail (returns app_id, app_type, name, description, icon_url, created_at, updated_at, is_published)")
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
@@ -55,9 +54,6 @@ var AppsGet = common.Shortcut{
return
}
fmt.Fprintf(w, "app_id: %v\n", app["app_id"])
if mt, ok := app["meta_token"].(string); ok && mt != "" {
fmt.Fprintf(w, "meta_token: %s\n", mt)
}
fmt.Fprintf(w, "app_type: %v\n", app["app_type"])
fmt.Fprintf(w, "name: %v\n", app["name"])
if desc, ok := app["description"].(string); ok && desc != "" {

View File

@@ -14,6 +14,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -37,13 +38,9 @@ var AppsHTMLPublish = common.Shortcut{
{Name: "allow-sensitive", Type: "bool", Desc: "skip the credential-file scan (allow .env / .npmrc / .aws/credentials / etc. in the publish payload)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
if appID == "" {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required")
}
if err := validateRealAppID(appID); err != nil {
return err
}
path := strings.TrimSpace(rctx.Str("path"))
if path == "" {
return appsValidationParamError("--path", "--path is required")
@@ -76,11 +73,9 @@ var AppsHTMLPublish = common.Shortcut{
appID := strings.TrimSpace(rctx.Str("app-id"))
path := strings.TrimSpace(rctx.Str("path"))
dry := common.NewDryRunAPI()
dry.Desc("Pack tar.gz → GET pre_release for TOS upload URL → PUT tar.gz to TOS → POST release-create with tos_path; returns release_id")
dry.GET(fmt.Sprintf("%s/apps/%s/pre_release", apiBasePath, validate.EncodePathSegment(appID))).
PUT("<presigned_upload_url> (from pre_release response)").
POST(fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID))).
Body(map[string]string{"tos_path": "<from pre_release response>"})
dry.Desc("Pack tar.gz and publish HTML app (actual API path determined at runtime by app type; returns url or release_id)")
dry.POST(fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID))).
Set("content_type", "multipart/form-data")
candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), path)
if err != nil {
@@ -128,7 +123,16 @@ var AppsHTMLPublish = common.Shortcut{
Path: strings.TrimSpace(rctx.Str("path")),
}
out, err := runHTMLPublishTOS(ctx, rctx, spec)
appType := queryAppType(ctx, rctx, spec.AppID)
var out map[string]interface{}
var err error
if appType == "modern_html" {
out, err = runHTMLPublishTOS(ctx, rctx, spec)
} else {
client := appsHTMLPublishAPI{runtime: rctx}
out, err = runHTMLPublish(ctx, rctx.FileIO(), client, spec)
}
if err != nil {
return err
}
@@ -260,7 +264,25 @@ func prepareHTMLPublishTarball(fio fileio.FileIO, path string) (*htmlPublishTarb
return tarball, nil
}
// runHTMLPublishTOS handles the publish path: validate → tar.gz →
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
tarball, err := prepareHTMLPublishTarball(fio, spec.Path)
if err != nil {
return nil, err
}
resp, err := publisher.HTMLPublish(ctx, spec.AppID, tarball)
if err != nil {
return nil, client.WrapDoAPIError(err)
}
out := map[string]interface{}{}
if resp.URL != "" {
out["url"] = resp.URL
}
return out, nil
}
// runHTMLPublishTOS handles the modern_html publish path: validate → tar.gz →
// call pre_release to get TOS upload URL → upload tar.gz to TOS → return
// tos_path for +release-create --tos-path.
func runHTMLPublishTOS(ctx context.Context, rctx *common.RuntimeContext, spec appsHTMLPublishSpec) (map[string]interface{}, error) {

View File

@@ -5,6 +5,7 @@ package apps
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
@@ -22,6 +23,20 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
type fakeAppsHTMLPublishClient struct {
resp *htmlPublishResponse
err error
calls []string
}
func (f *fakeAppsHTMLPublishClient) HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error) {
f.calls = append(f.calls, appID)
if f.err != nil {
return nil, f.err
}
return f.resp, nil
}
func writeAppsSampleSite(t *testing.T) string {
t.Helper()
dir := t.TempDir()
@@ -31,19 +46,71 @@ func writeAppsSampleSite(t *testing.T) string {
return dir
}
func TestPrepareHTMLPublishTarball_PathNotFound(t *testing.T) {
_, err := prepareHTMLPublishTarball(newTestFIO(), "/nonexistent")
if err == nil {
t.Fatalf("expected error")
func TestRunHTMLPublish_HappyPath(t *testing.T) {
site := writeAppsSampleSite(t)
fake := &fakeAppsHTMLPublishClient{
resp: &htmlPublishResponse{URL: "https://miaoda/app_x"},
}
out, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
if err != nil {
t.Fatalf("err=%v", err)
}
if out["url"] != "https://miaoda/app_x" {
t.Fatalf("url=%v", out["url"])
}
if len(fake.calls) != 1 || fake.calls[0] != "app_x" {
t.Fatalf("calls=%v", fake.calls)
}
}
func TestPrepareHTMLPublishTarball_DirRequiresIndexHTML(t *testing.T) {
func TestRunHTMLPublish_OnlyURLInEnvelope(t *testing.T) {
// Pin 概要设计 §5.3 不变量 4 "同步语义不会变成异步" (legacy html path only):
// envelope 只含 url未来若有人加 status / release_id 字段会被这个测试拦截。
site := writeAppsSampleSite(t)
fake := &fakeAppsHTMLPublishClient{
resp: &htmlPublishResponse{URL: "https://miaoda/app_x"},
}
out, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
if err != nil {
t.Fatalf("err=%v", err)
}
if len(out) != 1 {
t.Fatalf("envelope should only contain 'url', got %d keys: %v", len(out), out)
}
if _, ok := out["url"]; !ok {
t.Fatalf("envelope missing 'url': %v", out)
}
}
func TestRunHTMLPublish_ClientErrorPropagated(t *testing.T) {
site := writeAppsSampleSite(t)
wantErr := errors.New("server timeout")
fake := &fakeAppsHTMLPublishClient{err: wantErr}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
if !errors.Is(err, wantErr) {
t.Fatalf("err=%v", err)
}
}
func TestRunHTMLPublish_PathNotFound(t *testing.T) {
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: "/nonexistent"})
if err == nil {
t.Fatalf("expected error")
}
if len(fake.calls) != 0 {
t.Fatalf("client should not be called when path invalid")
}
}
func TestRunHTMLPublish_DirRequiresIndexHTML(t *testing.T) {
// 目录形态:缺 index.html 应该被拦
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "foo.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
if err == nil {
t.Fatalf("expected error for missing index.html")
}
@@ -54,9 +121,13 @@ func TestPrepareHTMLPublishTarball_DirRequiresIndexHTML(t *testing.T) {
if problem.Hint == "" {
t.Fatalf("expected non-empty hint")
}
if len(fake.calls) != 0 {
t.Fatalf("client should not be called when index.html missing")
}
}
func TestPrepareHTMLPublishTarball_DirWithIndexHTMLPasses(t *testing.T) {
func TestRunHTMLPublish_DirWithIndexHTMLPasses(t *testing.T) {
// 目录含 index.html 应该正常走完
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
@@ -64,49 +135,57 @@ func TestPrepareHTMLPublishTarball_DirWithIndexHTMLPasses(t *testing.T) {
if err := os.WriteFile(filepath.Join(dir, "extra.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
tarball, err := prepareHTMLPublishTarball(newTestFIO(), dir)
if err != nil {
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
t.Fatalf("err=%v", err)
}
if tarball == nil || tarball.Size == 0 {
t.Fatalf("expected non-empty tarball")
if len(fake.calls) != 1 {
t.Fatalf("client should be called when index.html present")
}
}
func TestPrepareHTMLPublishTarball_SingleFileRejectedIfNotNamedIndex(t *testing.T) {
func TestRunHTMLPublish_SingleFileRejectedIfNotNamedIndex(t *testing.T) {
// 单文件形态:文件名不是 index.html 也要拦
dir := t.TempDir()
single := filepath.Join(dir, "foo.html")
if err := os.WriteFile(single, []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
_, err := prepareHTMLPublishTarball(newTestFIO(), single)
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: single})
if err == nil {
t.Fatalf("single-file path 'foo.html' should be rejected (not named index.html)")
}
requireAppsValidationProblem(t, err)
if len(fake.calls) != 0 {
t.Fatalf("client must not be called when index.html missing")
}
}
func TestPrepareHTMLPublishTarball_SingleFileNamedIndexPasses(t *testing.T) {
func TestRunHTMLPublish_SingleFileNamedIndexPasses(t *testing.T) {
// 单文件形态:文件名恰好就是 index.html → 放行
dir := t.TempDir()
single := filepath.Join(dir, "index.html")
if err := os.WriteFile(single, []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
tarball, err := prepareHTMLPublishTarball(newTestFIO(), single)
if err != nil {
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: single}); err != nil {
t.Fatalf("err=%v", err)
}
if tarball == nil || tarball.Size == 0 {
t.Fatalf("expected non-empty tarball")
if len(fake.calls) != 1 {
t.Fatalf("client should be called for single index.html")
}
}
func TestPrepareHTMLPublishTarball_RejectsOversizeTarball(t *testing.T) {
func TestRunHTMLPublish_RejectsOversizeTarball(t *testing.T) {
// 把上限调到 100 字节验证拦截defer 恢复原值避免污染其它测试。
orig := maxHTMLPublishTarballBytes
maxHTMLPublishTarballBytes = 100
defer func() { maxHTMLPublishTarballBytes = orig }()
dir := t.TempDir()
// 写 index.html满足新加的 index 校验)+ 大文件超 100 字节上限。
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
@@ -115,7 +194,8 @@ func TestPrepareHTMLPublishTarball_RejectsOversizeTarball(t *testing.T) {
t.Fatalf("write: %v", err)
}
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
if err == nil {
t.Fatalf("expected oversize error")
}
@@ -126,6 +206,9 @@ func TestPrepareHTMLPublishTarball_RejectsOversizeTarball(t *testing.T) {
if problem.Hint == "" {
t.Fatalf("expected non-empty hint")
}
if len(fake.calls) != 0 {
t.Fatalf("client should not be called when tarball oversize")
}
}
func TestMaxHTMLPublishTarballBytes_Default(t *testing.T) {
@@ -181,17 +264,8 @@ func TestAppsHTMLPublish_DryRunPrintsManifest(t *testing.T) {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/pre_release") {
t.Fatalf("dry-run missing pre_release endpoint: %s", got)
}
if !strings.Contains(got, "presigned_upload_url") {
t.Fatalf("dry-run missing TOS PUT step: %s", got)
}
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/releases") {
t.Fatalf("dry-run missing release-create endpoint: %s", got)
}
if !strings.Contains(got, "tos_path") {
t.Fatalf("dry-run missing tos_path in release-create body: %s", got)
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code") {
t.Fatalf("dry-run missing endpoint: %s", got)
}
if !strings.Contains(got, "index.html") {
t.Fatalf("dry-run missing file list: %s", got)
@@ -426,7 +500,9 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
t.Fatalf("write: %v", err)
}
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake,
appsHTMLPublishSpec{AppID: "app_x", Path: dir})
if err == nil {
t.Fatalf("expected raw-size cap to fire")
}
@@ -434,6 +510,9 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
if !strings.Contains(problem.Message, "raw") || !strings.Contains(problem.Message, "bytes") {
t.Fatalf("expected message to explain raw-byte cap, got %q", problem.Message)
}
if len(fake.calls) != 0 {
t.Fatalf("client must not be called when raw cap hit")
}
}
func TestOversizeHTMLFiles(t *testing.T) {
@@ -476,7 +555,8 @@ func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
if err := os.WriteFile(filepath.Join(dir, "big.html"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
if err == nil {
t.Fatalf("expected per-file oversize error")
}
@@ -487,9 +567,13 @@ func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
if problem.Hint == "" {
t.Fatalf("expected non-empty hint")
}
if len(fake.calls) != 0 {
t.Fatalf("client must not be called when an HTML file is oversize")
}
}
func TestPrepareHTMLPublishTarball_IgnoresOversizeNonHTML(t *testing.T) {
func TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
// 单 .html 上限调小,但超限文件是 .png → 不被本护栏拦截,正常发布。
orig := maxHTMLPublishSingleHTMLFileBytes
maxHTMLPublishSingleHTMLFileBytes = 100
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
@@ -501,12 +585,12 @@ func TestPrepareHTMLPublishTarball_IgnoresOversizeNonHTML(t *testing.T) {
if err := os.WriteFile(filepath.Join(dir, "big.png"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
tarball, err := prepareHTMLPublishTarball(newTestFIO(), dir)
if err != nil {
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
t.Fatalf("non-html oversize must not be blocked by the .html cap: %v", err)
}
if tarball == nil || tarball.Size == 0 {
t.Fatalf("expected non-empty tarball")
if len(fake.calls) != 1 {
t.Fatalf("client should be called; calls=%v", fake.calls)
}
}

View File

@@ -74,18 +74,15 @@ type appTypePolicy struct {
// skipSkillsSync skips the conditional `npx ... skills sync --local` step on
// the non-empty (`app sync`) scaffold path.
skipSkillsSync bool
// skipAppSync skips `npx ... app sync` on the non-empty repo path.
skipAppSync bool
}
// appTypePolicies maps an app_type to its +init control strategy. Types absent
// from the map get the zero-value policy (install runs, env is pulled, skills
// are synced).
var appTypePolicies = map[string]appTypePolicy{
// modern_html / html are static HTML sites: no dependencies to install,
// no startup env vars to pull, no steering skills to sync, and no app sync.
"modern_html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true, skipAppSync: true},
"html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true, skipAppSync: true},
// modern_html is a static HTML site: no dependencies to install, no startup
// env vars to pull, and no steering skills to sync.
"modern_html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true},
}
// policyForAppType returns the +init control strategy for appType. Unlisted
@@ -125,13 +122,9 @@ var AppsInit = common.Shortcut{
{Name: "source-path", Desc: "path to existing source files (e.g. HTML output from an agent) to incorporate into the initialized project"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
if appID == "" {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required")
}
if err := validateRealAppID(appID); err != nil {
return err
}
if sp := strings.TrimSpace(rctx.Str("source-path")); sp != "" {
if err := charcheck.RejectControlChars(sp, "--source-path"); err != nil {
return appsValidationParamError("--source-path", "%v", err).WithCause(err)
@@ -341,19 +334,11 @@ func ensureMetaAppID(dir, appID string) error {
// each is not already resolvable from local/global/system config, so a
// developer's existing identity is never overwritten. Each key is handled
// independently (a machine with only user.name set still gets a default email).
func ensureGitIdentity(ctx context.Context, dir, authorName, authorEmail string) error {
name := strings.TrimSpace(authorName)
if name == "" {
name = defaultGitUserName
}
email := strings.TrimSpace(authorEmail)
if email == "" {
email = defaultGitUserEmail
}
if err := ensureGitConfigValue(ctx, dir, "user.name", name); err != nil {
func ensureGitIdentity(ctx context.Context, dir string) error {
if err := ensureGitConfigValue(ctx, dir, "user.name", defaultGitUserName); err != nil {
return err
}
return ensureGitConfigValue(ctx, dir, "user.email", email)
return ensureGitConfigValue(ctx, dir, "user.email", defaultGitUserEmail)
}
// ensureGitConfigValue sets <key>=fallback in the repo-local git config when key
@@ -415,16 +400,13 @@ func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (s
}
return scaffoldKindInit, nil
}
policy := policyForAppType(appType)
if !policy.skipAppSync {
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "sync"); err != nil {
return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err))
}
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "sync"); err != nil {
return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err))
}
if err := ensureMetaAppID(dir, appID); err != nil {
return "", err
}
if !policy.skipSkillsSync && !hasSteeringSkills(dir) {
if !policyForAppType(appType).skipSkillsSync && !hasSteeringSkills(dir) {
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
return "", appsExternalToolError(err, "npx skills sync failed: %s", gitErr(stderr, err))
}
@@ -454,38 +436,26 @@ func scaffoldInitArgs(appType, appID, sourcePath string) []string {
return base
}
// credentialInitResult holds the fields parsed from +git-credential-init output.
type credentialInitResult struct {
RepositoryURL string
CommitAuthorName string
CommitAuthorEmail string
}
// parseCredentialInitEnvelope extracts fields from a +git-credential-init JSON
// envelope ({"ok":true,"data":{"repository_url":"...","commit_author_name":"...","commit_author_email":"..."}}).
func parseCredentialInitEnvelope(stdout string) (credentialInitResult, error) {
// parseRepoURLFromEnvelope extracts data.repository_url from a lark-cli JSON
// envelope ({"ok":true,"data":{"repository_url":"..."}}). The field name
// matches the contract emitted by `apps +git-credential-init`.
func parseRepoURLFromEnvelope(stdout string) (string, error) {
var env struct {
OK bool `json:"ok"`
Data struct {
RepositoryURL string `json:"repository_url"`
CommitAuthorName string `json:"commit_author_name"`
CommitAuthorEmail string `json:"commit_author_email"`
RepositoryURL string `json:"repository_url"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
return credentialInitResult{}, appsSubprocessEnvelopeError("could not parse +git-credential-init output as JSON: %v", err)
return "", appsSubprocessEnvelopeError("could not parse +git-credential-init output as JSON: %v", err)
}
if !env.OK {
return credentialInitResult{}, appsSubprocessEnvelopeError("+git-credential-init reported failure")
return "", appsSubprocessEnvelopeError("+git-credential-init reported failure")
}
if strings.TrimSpace(env.Data.RepositoryURL) == "" {
return credentialInitResult{}, appsSubprocessEnvelopeError("+git-credential-init returned no repository_url")
return "", appsSubprocessEnvelopeError("+git-credential-init returned no repository_url")
}
return credentialInitResult{
RepositoryURL: env.Data.RepositoryURL,
CommitAuthorName: env.Data.CommitAuthorName,
CommitAuthorEmail: env.Data.CommitAuthorEmail,
}, nil
return env.Data.RepositoryURL, nil
}
// parseEnvFileFromEnvelope extracts data.env_file from a `+env-pull` success
@@ -557,10 +527,7 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
return err
}
appType, err := queryAppType(ctx, rctx, appID)
if err != nil {
return err
}
appType := queryAppType(ctx, rctx, appID)
policy := policyForAppType(appType)
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
@@ -628,16 +595,16 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
}
initLogf(rctx, "Issuing repository credentials for %s...", appID)
cred, err := issueCredentials(ctx, rctx, appID)
repoURL, err := issueCredentials(ctx, rctx, appID)
if err != nil {
return err
}
if err := validateRepoURLScheme(cred.RepositoryURL); err != nil {
if err := validateRepoURLScheme(repoURL); err != nil {
return err
}
initLogf(rctx, "Cloning into %s...", dir)
if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", cred.RepositoryURL, dir); err != nil {
if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", repoURL, dir); err != nil {
return appsExternalToolError(err, "git clone failed: %s", gitErr(stderr, err))
}
initLogf(rctx, "Checking out %s...", defaultInitBranch)
@@ -645,10 +612,9 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
return appsExternalToolError(err, "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err))
}
// Ensure a committer identity exists before the scaffold commit. Uses the
// author name/email from +git-credential-init when available; falls back
// to lark-cli-bot defaults when the server does not provide them.
if err := ensureGitIdentity(ctx, dir, cred.CommitAuthorName, cred.CommitAuthorEmail); err != nil {
// Ensure a committer identity exists before the scaffold commit; only sets
// repo-local defaults when none is configured (existing identity is kept).
if err := ensureGitIdentity(ctx, dir); err != nil {
return err
}
@@ -677,7 +643,7 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
out := map[string]interface{}{
"app_id": appID,
"repository_url": redactURLCredentials(cred.RepositoryURL),
"repository_url": redactURLCredentials(repoURL),
"branch": defaultInitBranch,
"clone_path": dir,
"scaffold": scaffold,
@@ -755,10 +721,10 @@ func pullEnv(ctx context.Context, rctx *common.RuntimeContext, appID, dir string
// issueCredentials runs `<self> apps +git-credential-init --app-id <id> --format json`
// and returns the repo_url it reports. Forwards --as when set.
func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (credentialInitResult, error) {
func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (string, error) {
self, err := os.Executable()
if err != nil {
return credentialInitResult{}, errs.NewInternalError(errs.SubtypeUnknown, "cannot locate lark-cli executable: %v", err).WithCause(err)
return "", errs.NewInternalError(errs.SubtypeUnknown, "cannot locate lark-cli executable: %v", err).WithCause(err)
}
args := []string{"apps", "+git-credential-init", "--app-id", appID, "--format", "json"}
if as := strings.TrimSpace(rctx.Str("as")); as != "" {
@@ -766,11 +732,11 @@ func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID st
}
stdout, stderr, err := initRunner.Run(ctx, "", self, args...)
if err != nil {
return credentialInitResult{}, appsExternalToolError(err, "apps +git-credential-init failed: %s", gitErr(stderr, err)).
return "", appsExternalToolError(err, "apps +git-credential-init failed: %s", gitErr(stderr, err)).
WithHint("ensure apps +git-credential-init is available and you are logged in").
WithCause(err)
}
return parseCredentialInitEnvelope(stdout)
return parseRepoURLFromEnvelope(stdout)
}
// commitAndPushIfDirty commits and pushes only when the working tree has

View File

@@ -21,7 +21,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/testutil/gitcmd"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -111,24 +110,18 @@ func TestDefaultCloneDir(t *testing.T) {
// --- pure-function tests ---
func TestParseRepoURL(t *testing.T) {
result, err := parseCredentialInitEnvelope(`{"ok":true,"data":{"repository_url":"http://u:t@h/app_x.git","commit_author_name":"Alice","commit_author_email":"alice@example.com"}}`)
url, err := parseRepoURLFromEnvelope(`{"ok":true,"data":{"repository_url":"http://u:t@h/app_x.git"}}`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.RepositoryURL != "http://u:t@h/app_x.git" {
t.Errorf("RepositoryURL got %q", result.RepositoryURL)
}
if result.CommitAuthorName != "Alice" {
t.Errorf("CommitAuthorName got %q", result.CommitAuthorName)
}
if result.CommitAuthorEmail != "alice@example.com" {
t.Errorf("CommitAuthorEmail got %q", result.CommitAuthorEmail)
if url != "http://u:t@h/app_x.git" {
t.Errorf("got %q", url)
}
}
func TestParseRepoURL_Errors(t *testing.T) {
for _, in := range []string{`not json`, `{"ok":false,"data":{}}`, `{"ok":true,"data":{}}`, `{"ok":true,"data":{"repository_url":""}}`} {
if _, err := parseCredentialInitEnvelope(in); err == nil {
if _, err := parseRepoURLFromEnvelope(in); err == nil {
t.Errorf("expected error for %q", in)
}
}
@@ -156,22 +149,6 @@ func withFakeRunner(t *testing.T, f *fakeCommandRunner) {
t.Cleanup(func() { initRunner = orig })
}
func stubAppType(reg *httpmock.Registry, appID, appType string) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/" + appID,
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": appID,
"app_type": appType,
},
},
},
})
}
func credInitOK(repoURL string) fakeCallResult {
return fakeCallResult{stdout: `{"ok":true,"data":{"repository_url":"` + repoURL + `"}}`}
}
@@ -336,8 +313,7 @@ func TestAppsInit_EmptyRepo_EndToEnd(t *testing.T) {
"git status": {stdout: " M src/app.ts\n"}, // scaffold produced changes
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected: %v", err)
@@ -378,8 +354,7 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
}
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(filepath.Join(abs, ".env.local"))}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected: %v", err)
}
@@ -448,8 +423,7 @@ func TestAppsInit_HappyPathCleanTree(t *testing.T) {
"git status": {}, // clean tree after scaffold -> no commit/push
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
dir := relCloneDir(t)
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
@@ -498,8 +472,7 @@ func TestAppsInit_DirtyTreeCommitPush(t *testing.T) {
"git status": {stdout: " M file.txt"},
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
dir := relCloneDir(t)
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
@@ -569,8 +542,7 @@ func TestAppsInit_CloneFailure(t *testing.T) {
"git clone": {stderr: "fatal: unable to access 'http://u:t@h/r.git'", err: errors.New("exit 128")},
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
dir := relCloneDir(t)
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
@@ -644,8 +616,7 @@ func TestAppsInit_AsPassthrough(t *testing.T) {
"git status": {},
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
dir := relCloneDir(t)
// AppsInit.AuthTypes is ["user"], so the framework rejects --as bot. Use
@@ -751,7 +722,7 @@ func TestIsEmptyRepo(t *testing.T) {
// newAppsExecuteFactoryWithStderr mirrors newAppsExecuteFactory but also returns
// the stderr buffer, so tests can assert on the +init progress log lines that
// initLogf writes to IO().ErrOut.
func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
@@ -761,12 +732,12 @@ func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buf
Brand: core.BrandFeishu,
UserOpenId: "ou_test",
}
factory, stdout, stderr, reg := cmdutil.TestFactory(t, cfg)
return factory, stdout, stderr, reg
factory, stdout, stderr, _ := cmdutil.TestFactory(t, cfg)
return factory, stdout, stderr
}
func TestAppsInit_Req1_Wording(t *testing.T) {
factory, stdout, _, _ := newAppsExecuteFactoryWithStderr(t)
factory, stdout, _ := newAppsExecuteFactoryWithStderr(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
@@ -795,8 +766,7 @@ func TestAppsInit_Req1_Wording(t *testing.T) {
"git status": {},
}}
withFakeRunner(t, f)
factory2, stdout2, stderr2, reg2 := newAppsExecuteFactoryWithStderr(t)
stubAppType(reg2, "app_x", "FULL_STACK")
factory2, stdout2, stderr2 := newAppsExecuteFactoryWithStderr(t)
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory2, stdout2); err != nil {
t.Fatalf("run err=%v", err)
@@ -859,8 +829,7 @@ func TestAppsInit_EmptyRepo_TwoCommits(t *testing.T) {
"git status": {stdout: " A src/app.ts\n A .spark/meta.json\n A .agent/skills/steering/x.md\n"},
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected: %v", err)
@@ -901,8 +870,7 @@ func TestAppsInit_EmptyRepo_AppCodeOnly_SingleCommit(t *testing.T) {
"git status": {stdout: " A src/app.ts\n"},
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected: %v", err)
@@ -922,8 +890,7 @@ func TestAppsInit_EmptyRepo_ConfigOnly_SingleCommit(t *testing.T) {
"git status": {stdout: " A .spark/meta.json\n"},
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected: %v", err)
@@ -943,8 +910,7 @@ func TestAppsInit_NonEmpty_SingleInitCommit(t *testing.T) {
"git status": {stdout: " M file.txt\n M .spark/meta.json\n"},
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected: %v", err)
@@ -963,7 +929,8 @@ func TestAppsInit_NonEmpty_SingleInitCommit(t *testing.T) {
// gitMust runs a git command in dir with a real binary, failing the test on error.
func gitMust(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := gitcmd.Command(dir, args...)
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v in %s failed: %v\n%s", args, dir, err, out)
@@ -979,7 +946,6 @@ func TestCommitAndPushIfDirty_RealGit_IgnoredAgentDir(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
gitcmd.SetSynchronousMaintenanceEnv(t)
// Bare remote so `git push origin sprint/default` succeeds.
remote := t.TempDir()
gitMust(t, remote, "init", "--bare", "-q", "--initial-branch", defaultInitBranch)
@@ -1101,7 +1067,6 @@ func TestCommitAndPushIfDirty_RealGit_NonEmptyUpgrade(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
gitcmd.SetSynchronousMaintenanceEnv(t)
remote := t.TempDir()
gitMust(t, remote, "init", "--bare", "-q", "--initial-branch", defaultInitBranch)
@@ -1324,8 +1289,7 @@ func TestAppsInit_EnvPull_Success(t *testing.T) {
"env-pull": envPullOK("/abs/app_x/.env.local"),
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -1363,8 +1327,7 @@ func TestAppsInit_EnvPull_NonFatal(t *testing.T) {
},
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("env-pull failure must be non-fatal, got: %v", err)
@@ -1403,8 +1366,7 @@ func TestAppsInit_AlreadyInitialized_RunsEnvPull(t *testing.T) {
envFile := filepath.Join(abs, ".env.local")
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(envFile)}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -1451,8 +1413,7 @@ func TestAppsInit_AlreadyInitialized_EnvPullFailure_NonFatal(t *testing.T) {
},
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("env-pull failure must be non-fatal, got: %v", err)
}
@@ -1744,15 +1705,13 @@ func TestScaffoldInitArgs_WithAppType(t *testing.T) {
}
func TestPolicyForAppType(t *testing.T) {
// modern_html and html decouple all control points: skip install, env-pull, skills sync, app sync.
for _, at := range []string{"modern_html", "html"} {
if p := policyForAppType(at); !p.skipInstall || !p.skipEnvPull || !p.skipSkillsSync || !p.skipAppSync {
t.Errorf("%s policy = %+v, want all skip flags set", at, p)
}
// modern_html decouples all control points: skip install, env-pull, skills sync.
if p := policyForAppType("modern_html"); !p.skipInstall || !p.skipEnvPull || !p.skipSkillsSync {
t.Errorf("modern_html policy = %+v, want all skip flags set", p)
}
// Unlisted types (including "") get the zero-value policy: everything runs.
for _, at := range []string{"full_stack", "", "backend"} {
if p := policyForAppType(at); p.skipInstall || p.skipEnvPull || p.skipSkillsSync || p.skipAppSync {
if p := policyForAppType(at); p.skipInstall || p.skipEnvPull || p.skipSkillsSync {
t.Errorf("policy for %q = %+v, want zero value", at, p)
}
}
@@ -1798,7 +1757,7 @@ func configSetValue(calls [][]string, key string) (string, bool) {
func TestEnsureGitIdentity_SetsDefaultsWhenUnset(t *testing.T) {
f := &fakeCommandRunner{} // no "git config" result → `--get` returns empty stdout
withFakeRunner(t, f)
if err := ensureGitIdentity(context.Background(), "/repo", "", ""); err != nil {
if err := ensureGitIdentity(context.Background(), "/repo"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if v, ok := configSetValue(f.calls, "user.name"); !ok || v != defaultGitUserName {
@@ -1815,7 +1774,7 @@ func TestEnsureGitIdentity_RespectsExisting(t *testing.T) {
"git config": {stdout: "Existing Dev\n"},
}}
withFakeRunner(t, f)
if err := ensureGitIdentity(context.Background(), "/repo", "", ""); err != nil {
if err := ensureGitIdentity(context.Background(), "/repo"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, ok := configSetValue(f.calls, "user.name"); ok {
@@ -1831,7 +1790,7 @@ func TestEnsureGitIdentity_SetFailurePropagates(t *testing.T) {
"git config": {stderr: "boom", err: errors.New("exit 1")},
}}
withFakeRunner(t, f)
if err := ensureGitIdentity(context.Background(), "/repo", "", ""); err == nil {
if err := ensureGitIdentity(context.Background(), "/repo"); err == nil {
t.Error("expected error when git config set fails")
}
}

View File

@@ -13,25 +13,22 @@ import (
)
// queryAppType fetches the app's type string from the server via
// GET /open-apis/spark/v1/apps/{identifier}. The identifier can be either
// an app_id or a meta_token — the server resolves both. The server returns
// uppercase app_type values ("HTML", "FULL_STACK", "MODERN_HTML");
// this function normalizes to lowercase. Returns an error when the API
// is unavailable or the response is malformed — callers must not proceed
// with a fallback type to avoid creating the wrong project scaffold.
func queryAppType(ctx context.Context, rctx *common.RuntimeContext, identifier string) (string, error) {
path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(identifier))
// GET /open-apis/spark/v1/apps/{appID}. The server returns uppercase
// values ("HTML", "FULL_STACK", "MODERN_HTML"); this function normalizes
// to lowercase. Returns "" when the API is unavailable or returns an
// error — callers fall back to legacy behavior.
func queryAppType(ctx context.Context, rctx *common.RuntimeContext, appID string) string {
path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))
data, err := rctx.CallAPITyped("GET", path, nil, nil)
if err != nil {
return "", err
fmt.Fprintf(rctx.IO().ErrOut, "→ Could not query app type: %v\n", err)
return ""
}
appRaw, _ := data["app"].(map[string]interface{})
if appRaw == nil {
return "", appsSubprocessEnvelopeError("query app type: response missing app object")
fmt.Fprintf(rctx.IO().ErrOut, "→ Could not query app type: response missing app object\n")
return ""
}
appType, _ := appRaw["app_type"].(string)
if strings.TrimSpace(appType) == "" {
return "", appsSubprocessEnvelopeError("query app type: response missing app_type")
}
return strings.ToLower(appType), nil
return strings.ToLower(appType)
}

View File

@@ -43,10 +43,7 @@ func TestQueryAppType_Success(t *testing.T) {
},
})
result, err := queryAppType(context.Background(), rt, "app_test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
result := queryAppType(context.Background(), rt, "app_test")
if result != "modern_html" {
t.Errorf("queryAppType = %q, want modern_html", result)
}
@@ -68,10 +65,7 @@ func TestQueryAppType_FullStack(t *testing.T) {
},
})
result, err := queryAppType(context.Background(), rt, "app_fs")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
result := queryAppType(context.Background(), rt, "app_fs")
if result != "full_stack" {
t.Errorf("queryAppType = %q, want full_stack", result)
}
@@ -93,10 +87,7 @@ func TestQueryAppType_Html(t *testing.T) {
},
})
result, err := queryAppType(context.Background(), rt, "app_html")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
result := queryAppType(context.Background(), rt, "app_html")
if result != "html" {
t.Errorf("queryAppType = %q, want html", result)
}
@@ -111,9 +102,9 @@ func TestQueryAppType_APIError(t *testing.T) {
Body: map[string]interface{}{"code": float64(99999), "msg": "internal error"},
})
_, err := queryAppType(context.Background(), rt, "app_bad")
if err == nil {
t.Error("expected error on API failure")
result := queryAppType(context.Background(), rt, "app_bad")
if result != "" {
t.Errorf("queryAppType = %q, want empty on error", result)
}
}
@@ -128,9 +119,9 @@ func TestQueryAppType_MissingAppObject(t *testing.T) {
},
})
_, err := queryAppType(context.Background(), rt, "app_no")
if err == nil {
t.Error("expected error when app object missing")
result := queryAppType(context.Background(), rt, "app_no")
if result != "" {
t.Errorf("queryAppType = %q, want empty when app object missing", result)
}
}
@@ -150,8 +141,8 @@ func TestQueryAppType_EmptyAppType(t *testing.T) {
},
})
_, err := queryAppType(context.Background(), rt, "app_empty")
if err == nil {
t.Error("expected error when app_type is empty")
result := queryAppType(context.Background(), rt, "app_empty")
if result != "" {
t.Errorf("queryAppType = %q, want empty when app_type is empty", result)
}
}

View File

@@ -31,13 +31,9 @@ var AppsReleaseCreate = common.Shortcut{
{Name: "branch", Desc: "release branch (server uses default if omitted)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
if appID == "" {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required")
}
if err := validateRealAppID(appID); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {

View File

@@ -30,13 +30,9 @@ var AppsReleaseGet = common.Shortcut{
{Name: "release-id", Desc: "release ID (the release_id returned by +release-create)", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
if appID == "" {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required")
}
if err := validateRealAppID(appID); err != nil {
return err
}
if strings.TrimSpace(rctx.Str("release-id")) == "" {
return appsValidationParamError("--release-id", "--release-id is required")
}

View File

@@ -41,21 +41,6 @@ func withAppsHint(err error, hint string) error {
return err
}
// validateRealAppID checks that --app-id is a real app ID (app_ prefix).
// meta_token values are rejected with a hint to resolve via +get first.
func validateRealAppID(appID string) error {
if !strings.HasPrefix(appID, "app_") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
`--app-id must be an app_id starting with "app_".`,
).WithParam("--app-id").WithHint(
`If you have a meta_token or a /page/<token>/ link, first resolve it:
lark-cli apps +get --app-id <meta_token> -q '.data.app.app_id'
Then retry this command with the returned app_id.`,
)
}
return nil
}
// rejectOutputTraversal is a defense-in-depth pre-check on a user-supplied
// --output path. The authoritative guard is the local FileIO layer
// (validate.SafeOutputPath sandboxes every write to the cwd, resolving .. and

View File

@@ -75,7 +75,6 @@ var AppsGitCredentialInit = common.Shortcut{
"save the issued PAT in the local system credential store",
"write app-scoped git credential metadata",
"configure a URL-scoped Git credential helper in global git config when possible",
"return commit_author_name and commit_author_email for repo-local git identity",
}).
Params(gitCredentialIssueParams(appID))
},
@@ -91,12 +90,6 @@ var AppsGitCredentialInit = common.Shortcut{
"repository_url": result.GitHTTPURL,
"status": initStatus(result),
}
if result.CommitAuthorName != "" {
payload["commit_author_name"] = result.CommitAuthorName
}
if result.CommitAuthorEmail != "" {
payload["commit_author_email"] = result.CommitAuthorEmail
}
if result.ConfigWarning != "" {
payload["git_config_warning"] = result.ConfigWarning
}
@@ -468,13 +461,11 @@ func issuedFromData(appID string, data map[string]interface{}) (*gitcred.IssuedC
}
}
issued := &gitcred.IssuedCredential{
AppID: firstString(source, "app_id", appID),
GitHTTPURL: firstString(source, "gitURL", "GitURL", "GitUrl", "gitUrl", "git_url", "git_http_url", "repository_url"),
Username: firstString(source, "username"),
PAT: firstString(source, "token", "Token", "pat", "password"),
ExpiresAt: firstInt64(source, "expiredTime", "ExpiredTime", "expired_time", "expires_at"),
CommitAuthorName: firstString(source, "commit_author_name"),
CommitAuthorEmail: firstString(source, "commit_author_email"),
AppID: firstString(source, "app_id", appID),
GitHTTPURL: firstString(source, "gitURL", "GitURL", "GitUrl", "gitUrl", "git_url", "git_http_url", "repository_url"),
Username: firstString(source, "username"),
PAT: firstString(source, "token", "Token", "pat", "password"),
ExpiresAt: firstInt64(source, "expiredTime", "ExpiredTime", "expired_time", "expires_at"),
}
if issued.AppID == "" {
issued.AppID = appID

View File

@@ -87,7 +87,6 @@ func TestAppsGitCredentialInitDryRunRequestShape(t *testing.T) {
"save the issued PAT in the local system credential store",
"write app-scoped git credential metadata",
"configure a URL-scoped Git credential helper in global git config when possible",
"return commit_author_name and commit_author_email for repo-local git identity",
})
}

View File

@@ -129,13 +129,7 @@ func (m *Manager) Init(ctx context.Context, profile ProfileContext, appID string
if previous != nil && previous.PATRef != "" && previous.PATRef != ref {
_ = m.Secrets.Remove(previous.PATRef)
}
result := &InitResult{
AppID: appID,
GitHTTPURL: url,
Refreshed: previous != nil,
CommitAuthorName: issued.CommitAuthorName,
CommitAuthorEmail: issued.CommitAuthorEmail,
}
result := &InitResult{AppID: appID, GitHTTPURL: url, Refreshed: previous != nil}
if m.GitConfig != nil {
if err := m.GitConfig.SetHelper(ctx, url, appID); err != nil {
result.ConfigWarning = err.Error()

View File

@@ -51,22 +51,18 @@ type CredentialRecord struct {
}
type IssuedCredential struct {
AppID string
GitHTTPURL string
Username string
PAT string
ExpiresAt int64
CommitAuthorName string
CommitAuthorEmail string
AppID string
GitHTTPURL string
Username string
PAT string
ExpiresAt int64
}
type InitResult struct {
AppID string
GitHTTPURL string
Refreshed bool
ConfigWarning string
CommitAuthorName string
CommitAuthorEmail string
AppID string
GitHTTPURL string
Refreshed bool
ConfigWarning string
}
type RemoveResult struct {

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"fmt"
"net/http"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type htmlPublishResponse struct {
URL string
}
type appsHTMLPublishClient interface {
HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error)
}
type appsHTMLPublishAPI struct {
runtime *common.RuntimeContext
}
func (api appsHTMLPublishAPI) HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error) {
fd := larkcore.NewFormdata()
fd.AddFile("file", bytes.NewReader(tarball.Body))
apiResp, err := api.runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID)),
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return nil, client.WrapDoAPIError(err)
}
data, err := api.runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return nil, enrichHTMLPublishAPIError(err)
}
url, _ := data["url"].(string)
if url == "" {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"html-publish response is missing the published app url")
}
return &htmlPublishResponse{URL: url}, nil
}
// OAPI business error codes returned by the
// /apps/{id}/upload_and_release_html_code endpoint. Owned by the backend
// service; update when new codes are documented in the OAPI spec.
const (
errCodeBuildFailed = 90001 // tar.gz uploaded but server-side build failed
errCodeAppNotFound = 90002 // app_id unknown or caller lacks permission
)
func buildHTMLPublishFailureHint(code int) string {
switch code {
case errCodeBuildFailed:
return "server-side build failed: run `lark-cli apps +html-publish --app-id <your-app-id> --path <path> --dry-run` to inspect the packaged file list"
case errCodeAppNotFound:
return "the app does not exist or the caller has no access; ask the user to confirm the app_id (extract it from the app URL https://miaoda.feishu.cn/app/app_xxx after /app/, or take the app_xxx string directly)"
default:
return ""
}
}

View File

@@ -0,0 +1,197 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"mime"
"mime/multipart"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func newAppsClientRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "test-app-" + strings.ToLower(t.Name()),
AppSecret: "test-secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_test",
}
factory, _, _, reg := cmdutil.TestFactory(t, cfg)
rctx := common.TestNewRuntimeContextForAPI(context.Background(), nil, cfg, factory, core.AsUser)
return rctx, reg
}
func TestAppsHTMLPublishAPI_Success(t *testing.T) {
rctx, reg := newAppsClientRuntime(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"url": "https://miaoda.feishu.cn/app/app_x",
},
},
}
reg.Register(stub)
api := appsHTMLPublishAPI{runtime: rctx}
tarball := &htmlPublishTarball{Body: []byte("fake"), Size: 4, SHA256: "abc"}
resp, err := api.HTMLPublish(context.Background(), "app_x", tarball)
if err != nil {
t.Fatalf("err=%v", err)
}
if resp.URL != "https://miaoda.feishu.cn/app/app_x" {
t.Fatalf("url=%q", resp.URL)
}
ct := stub.CapturedHeaders.Get("Content-Type")
mt, params, err := mime.ParseMediaType(ct)
if err != nil || mt != "multipart/form-data" {
t.Fatalf("content type %q wrong", ct)
}
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
saw := false
for {
p, err := mr.NextPart()
if err != nil {
break
}
if p.FormName() == "file" {
saw = true
}
}
if !saw {
t.Fatalf("multipart missing 'file' part")
}
}
func TestAppsHTMLPublishAPI_BusinessErrorHasHint(t *testing.T) {
rctx, reg := newAppsClientRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
Body: map[string]interface{}{
"code": 90001,
"msg": "build failed: dependency conflict",
},
})
api := appsHTMLPublishAPI{runtime: rctx}
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
if err == nil {
t.Fatalf("expected error")
}
problem := requireAppsAPIProblem(t, err)
if problem.Code != errCodeBuildFailed {
t.Fatalf("code = %d, want %d", problem.Code, errCodeBuildFailed)
}
if problem.Hint == "" {
t.Fatalf("expected non-empty hint on code 90001")
}
if !strings.Contains(problem.Message, "build failed") {
t.Fatalf("missing failure message: %v", problem.Message)
}
}
func TestAppsHTMLPublishAPI_AppNotFoundClassified(t *testing.T) {
rctx, reg := newAppsClientRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_missing/upload_and_release_html_code",
Body: map[string]interface{}{
"code": errCodeAppNotFound,
"msg": "app not found",
},
})
api := appsHTMLPublishAPI{runtime: rctx}
_, err := api.HTMLPublish(context.Background(), "app_missing", &htmlPublishTarball{Body: []byte("fake")})
problem := requireAppsAPIProblem(t, err)
if problem.Subtype != errs.SubtypeNotFound {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNotFound)
}
if problem.Hint == "" {
t.Fatalf("expected app-not-found recovery hint")
}
}
func TestAppsHTMLPublishAPI_MissingURLIsInvalidResponse(t *testing.T) {
rctx, reg := newAppsClientRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
api := appsHTMLPublishAPI{runtime: rctx}
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
problem := requireAppsProblem(t, err, errs.CategoryInternal)
if problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidResponse)
}
}
func TestBuildHTMLPublishFailureHint_UnknownCodeReturnsEmpty(t *testing.T) {
// 默认分支:未识别的 code 返回空 hint让 Agent 用 message 兜底。
if hint := buildHTMLPublishFailureHint(99999); hint != "" {
t.Fatalf("unknown code should return empty hint, got %q", hint)
}
if hint := buildHTMLPublishFailureHint(0); hint != "" {
t.Fatalf("zero code should return empty hint, got %q", hint)
}
}
func TestBuildHTMLPublishFailureHint_KnownCodes(t *testing.T) {
if hint := buildHTMLPublishFailureHint(90001); hint == "" {
t.Fatalf("code 90001 should return non-empty hint")
}
if hint := buildHTMLPublishFailureHint(90002); hint == "" {
t.Fatalf("code 90002 should return non-empty hint")
}
}
func TestBuildHTMLPublishFailureHint_NotFoundHintNoLongerMentionsList(t *testing.T) {
hint := buildHTMLPublishFailureHint(90002)
if hint == "" {
t.Fatalf("code 90002 should return non-empty hint")
}
if strings.Contains(hint, "+list") {
t.Fatalf("hint must not point at hidden +list command, got: %q", hint)
}
if !strings.Contains(hint, "app_id") {
t.Fatalf("hint should reference app_id, got: %q", hint)
}
}
func TestAppsHTMLPublishAPI_MalformedResponseIsInvalidResponse(t *testing.T) {
rctx, reg := newAppsClientRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
RawBody: []byte("{not json"),
})
api := appsHTMLPublishAPI{runtime: rctx}
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
problem := requireAppsProblem(t, err, errs.CategoryInternal)
if problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidResponse)
}
}

View File

@@ -104,22 +104,6 @@ func TestDryRunFieldOps(t *testing.T) {
assertDryRunContains(t, dryRunFieldUpdate(ctx, rt), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
assertDryRunContains(t, dryRunFieldDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
assertDryRunContains(t, dryRunFieldSearchOptions(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1/options", "offset=3", "limit=30", "query=open")
autoNumberRT := newBaseTestRuntime(
map[string]string{
"base-token": "app_x",
"table-id": "tbl_1",
"field-id": "fld_1",
"json": `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}`,
},
nil,
nil,
)
autoNumberDR := dryRunFieldUpdate(ctx, autoNumberRT)
assertDryRunContains(t, autoNumberDR, "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1", `"name":"编号"`, `"type":"auto_number"`, `"rules":[`, `"length":4`)
if out := autoNumberDR.Format(); strings.Contains(out, "auto_serial") || strings.Contains(out, "reformat_existing_records") || strings.Contains(out, "/open-apis/bitable/v1/") {
t.Fatalf("auto_number dry-run must stay on v3 field JSON, got:\n%s", out)
}
}
func TestDryRunRecordOps(t *testing.T) {
@@ -133,7 +117,7 @@ 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 := newBaseTestRuntimeWithArrays(
listFieldNamesAliasRT := newBaseTestRuntimeWithSlices(
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
map[string][]string{"field-names": {"Name", "Age"}},
nil,

View File

@@ -81,37 +81,6 @@ func runShortcutWithAuthTypes(t *testing.T, shortcut common.Shortcut, authTypes
return parent.ExecuteContext(context.Background())
}
func assertInvalidArgumentValidation(t *testing.T, err error, wantParam string, wantParams []string, messageContains string) {
t.Helper()
if err == nil {
t.Fatal("expected invalid-argument validation error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected invalid-argument validation problem, got %T %v", err, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T %v", err, err)
}
if validationErr.Param != wantParam {
t.Fatalf("param=%q, want %q", validationErr.Param, wantParam)
}
if wantParams != nil {
if len(validationErr.Params) != len(wantParams) {
t.Fatalf("params=%#v, want %v", validationErr.Params, wantParams)
}
for i, want := range wantParams {
if validationErr.Params[i].Name != want {
t.Fatalf("params=%#v, want %v", validationErr.Params, wantParams)
}
}
}
if messageContains != "" && !strings.Contains(err.Error(), messageContains) {
t.Fatalf("err=%v, want message containing %q", err, messageContains)
}
}
func TestBaseWorkspaceExecuteCreate(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stderr, _ := factory.IOStreams.ErrOut.(*bytes.Buffer)
@@ -153,7 +122,7 @@ func TestBaseWorkspaceExecuteCreate(t *testing.T) {
if grant["user_open_id"] != "ou_testuser" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_testuser")
}
if grant["message"] != "Granted the current CLI user full_access on the new base." {
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new base." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
@@ -500,6 +469,9 @@ func TestBaseWorkspaceExecuteCreateBotAutoGrantFailureDoesNotFailCreate(t *testi
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
}
if !strings.Contains(grant["message"].(string), "retry later") {
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
}
@@ -605,9 +577,8 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--dry-run"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
wantDesc := "After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base."
if got := stdout.String(); !strings.Contains(got, wantDesc) {
t.Fatalf("stdout=%s, want desc %q", got, wantDesc)
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
t.Fatalf("stdout=%s", got)
}
})
@@ -616,9 +587,8 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src", "--dry-run"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
wantDesc := "After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base."
if got := stdout.String(); !strings.Contains(got, wantDesc) {
t.Fatalf("stdout=%s, want desc %q", got, wantDesc)
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
t.Fatalf("stdout=%s", got)
}
})
@@ -627,7 +597,7 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
if err := runShortcutWithAuthTypes(t, BaseBaseCreate, authTypes(), []string{"+base-create", "--name", "Demo Base", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access") {
if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
t.Fatalf("stdout=%s", got)
}
})
@@ -849,189 +819,8 @@ func TestBaseFieldExecuteUpdate(t *testing.T) {
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `{"name":"Amount","type":"number"}`, "--yes"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
for _, want := range []string{`"updated": true`, `"fld_x"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
}
func TestFieldUpdateResultAlwaysRecommendsReadback(t *testing.T) {
tests := []struct {
name string
field interface{}
submitted map[string]interface{}
hintContains []string
}{
{
name: "direct complex server type overrides simple submitted type",
field: map[string]interface{}{"type": "auto_number"},
submitted: map[string]interface{}{"type": "number"},
hintContains: []string{`submitted type "number"`, `server returned type "auto_number"`},
},
{
name: "nested simple server type still recommends readback",
field: map[string]interface{}{"field": map[string]interface{}{"type": "number"}},
submitted: map[string]interface{}{"type": "auto_number"},
hintContains: []string{`submitted type "auto_number"`, `server returned type "number"`},
},
{
name: "submitted simple type still recommends readback when response omits type",
field: map[string]interface{}{"id": "fld_x"},
submitted: map[string]interface{}{"type": "text"},
hintContains: []string{`type "text"`, "cannot determine the previous type"},
},
{
name: "missing type is conservative",
field: map[string]interface{}{"id": "fld_x"},
submitted: map[string]interface{}{"name": "Amount"},
hintContains: []string{"unknown or uncommon field type", "+field-get"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := fieldUpdateResult(map[string]interface{}{"field": tc.field, "updated": true}, tc.submitted)
if got["field_get_recommended"] != true || got["next_step"] != "field_get" {
t.Fatalf("result=%#v, want readback recommendation", got)
}
hint, _ := got["verification_hint"].(string)
for _, want := range tc.hintContains {
if !strings.Contains(hint, want) {
t.Fatalf("verification_hint=%q, want substring %q", hint, want)
}
}
})
}
}
func TestBaseFieldExecuteUpdateNoopReturnsAPIError(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
Body: map[string]interface{}{
"code": 800070003,
"msg": "no operation produced",
},
})
err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `{"name":"Amount","type":"number"}`, "--yes"}, factory, stdout)
if err == nil {
t.Fatal("expected the API no-op response to surface as an error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed API error, got %T %v", err, err)
}
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeUnknown || p.Code != 800070003 {
t.Fatalf("category/subtype/code=%s/%s/%d", p.Category, p.Subtype, p.Code)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError, got %T %v", err, err)
}
if got := stdout.String(); strings.TrimSpace(got) != "" {
t.Fatalf("no success envelope should be emitted on a no-op API error:\n%s", got)
}
}
func TestBaseFieldExecuteUpdateAutoNumberUsesV3FieldJSON(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"field": map[string]interface{}{"id": "fld_x", "name": "编号", "type": "auto_number"},
},
},
}
reg.Register(stub)
jsonBody := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}`
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", jsonBody, "--yes"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
gotBody := string(stub.CapturedBody)
for _, want := range []string{
`"name":"编号"`,
`"type":"auto_number"`,
`"rules":[`,
`"date_format":"yyyyMM"`,
`"length":4`,
} {
if !strings.Contains(gotBody, want) {
t.Fatalf("request body missing %q:\n%s", want, gotBody)
}
}
for _, forbidden := range []string{"auto_serial", "reformat_existing_records", `"type":1005`} {
if strings.Contains(gotBody, forbidden) {
t.Fatalf("request body must not contain v1 field %q:\n%s", forbidden, gotBody)
}
}
got := stdout.String()
for _, want := range []string{`"updated": true`, `"fld_x"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
for _, forbidden := range []string{`"reformat_existing_records"`} {
if strings.Contains(got, forbidden) {
t.Fatalf("stdout must not expose %q:\n%s", forbidden, got)
}
}
}
func TestBaseFieldExecuteUpdateDoesNotRejectExtraJSONKeys(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"id": "fld_x", "name": "编号", "type": "auto_number"},
},
}
reg.Register(stub)
// Unknown v3 keys are forwarded unchanged; the server remains the source of
// truth for whether a field-update property is supported.
jsonBody := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"incremental_number","length":4}]},"reformat_existing_records":true}`
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", jsonBody, "--yes"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if gotBody := string(stub.CapturedBody); !strings.Contains(gotBody, `"reformat_existing_records":true`) {
t.Fatalf("request body must preserve unknown v3 key:\n%s", gotBody)
}
if got := stdout.String(); !strings.Contains(got, `"updated": true`) {
t.Fatalf("expected successful update, got: %s", got)
}
}
func TestBaseFieldValidateAllowsRatingMaxAboveLimit(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
shortcut common.Shortcut
runtime *common.RuntimeContext
}{
{
name: "create",
shortcut: BaseFieldCreate,
runtime: newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_x", "json": `{"name":"评分","type":"number","style":{"type":"rating","icon":"star","min":0,"max":20}}`}, nil, nil),
},
{
name: "update",
shortcut: BaseFieldUpdate,
runtime: newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_x", "field-id": "fld_x", "json": `{"name":"评分","type":"number","style":{"type":"rating","icon":"star","min":0,"max":20}}`}, nil, nil),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if err := tc.shortcut.Validate(ctx, tc.runtime); err != nil {
t.Fatalf("rating max above 10 should not be blocked by CLI validation: %v", err)
}
})
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"fld_x"`) {
t.Fatalf("stdout=%s", got)
}
}
@@ -1303,32 +1092,8 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"name":"Status","type":"text"}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
for _, want := range []string{`"created": true`, `"fld_new"`, `"field_get_recommended": false`, `"next_step": "done"`, `"verification_hint"`} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
})
t.Run("create generated field recommends readback", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"id": "fld_auto", "name": "编号", "type": "auto_number"},
},
})
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"name":"编号","type":"auto_number"}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
for _, want := range []string{`"created": true`, `"fld_auto"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"fld_new"`) {
t.Fatalf("stdout=%s", got)
}
})
@@ -1375,58 +1140,11 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
if len(fields) != 2 {
t.Fatalf("fields len=%d output=%#v", len(fields), data)
}
if data["field_get_recommended"] != false || data["next_step"] != "done" || data["verification_hint"] == nil {
t.Fatalf("simple batch create must carry field_get_recommended:false + next_step:done + verification_hint: %#v", data)
}
if !strings.Contains(string(firstStub.CapturedBody), `"name":"A"`) || !strings.Contains(string(secondStub.CapturedBody), `"name":"B"`) {
t.Fatalf("unexpected request bodies: %s / %s", firstStub.CapturedBody, secondStub.CapturedBody)
}
})
t.Run("create array with generated field recommends readback", func(t *testing.T) {
oldDelay := fieldCreateBatchDelay
fieldCreateBatchDelay = 0
t.Cleanup(func() { fieldCreateBatchDelay = oldDelay })
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
BodyFilter: func(body []byte) bool {
return strings.Contains(string(body), `"name":"Title"`)
},
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"id": "fld_title", "name": "Title", "type": "text"},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
BodyFilter: func(body []byte) bool {
return strings.Contains(string(body), `"name":"编号"`)
},
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"id": "fld_no", "name": "编号", "type": "auto_number"},
},
})
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[{"name":"Title","type":"text"},{"name":"编号","type":"auto_number"}]`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["created"] != true || data["total"] != float64(2) {
t.Fatalf("unexpected output: %#v", data)
}
if _, ok := data["fields"].([]interface{}); !ok {
t.Fatalf("batch create must keep fields array: %#v", data)
}
if data["field_get_recommended"] != true || data["next_step"] != "field_get" || data["verification_hint"] == nil {
t.Fatalf("batch with auto_number must recommend readback: %#v", data)
}
})
t.Run("delete", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -1601,32 +1319,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list field names alias preserves quoted commas and at-sign names", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "field_id=A%2CB&field_id=%40Owner&limit=1&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"A,B", "@Owner"},
"record_id_list": []interface{}{"rec_alias_special"},
"data": []interface{}{[]interface{}{"value-1", "value-2"}},
"total": 1,
},
},
})
if err := runShortcut(t, BaseRecordList, []string{
"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1",
"--field-names", `"A,B",@Owner`, "--format", "json",
}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"rec_alias_special"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list json format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -1923,162 +1615,28 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list fields alias accepts JSON array projection", 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_fields"},
"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", "--fields", `["Name","Age"]`, "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list field names alias accepts repeated projection", 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_fields"},
"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", "--field-names", "Age", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list projection aliases report only supplied ambiguous inputs", func(t *testing.T) {
baseArgs := []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x"}
cases := []struct {
name string
args []string
wantParam string
wantParams []string
}{
{name: "canonical and fields alias", args: []string{"--field-id", "Name", "--fields", `["Age"]`}, wantParam: "--field-id", wantParams: []string{"--field-id", "--fields"}},
{name: "canonical and field names alias", args: []string{"--field-id", "Name", "--field-names", "Age"}, wantParam: "--field-id", wantParams: []string{"--field-id", "--field-names"}},
{name: "compatibility aliases", args: []string{"--fields", `["Name"]`, "--field-names", "Age"}, wantParam: "--fields", wantParams: []string{"--fields", "--field-names"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
args := append(append([]string{}, baseArgs...), tc.args...)
err := runShortcut(t, BaseRecordList, args, factory, stdout)
assertInvalidArgumentValidation(t, err, tc.wantParam, tc.wantParams, "mutually exclusive")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Hint != "Use only --field-id for projection." {
t.Fatalf("hint=%q, want canonical projection guidance", validationErr.Hint)
}
})
}
})
t.Run("search json conflict reports each supplied projection parameter", func(t *testing.T) {
t.Run("list legacy fields flag rejected", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordSearch, []string{
"+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
"--json", `{"keyword":"Alice","search_fields":["Name"]}`,
"--field-names", "Age",
}, factory, stdout)
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--field-names"}, "mutually exclusive")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || !strings.Contains(validationErr.Hint, "inside --json") {
t.Fatalf("hint=%q, want JSON-body guidance", validationErr.Hint)
}
})
t.Run("list canonical and alias projections reject duplicates consistently", func(t *testing.T) {
cases := []struct {
name string
args []string
param string
}{
{name: "canonical", args: []string{"--field-id", "Cost--USD", "--field-id", "Cost--USD"}, param: "--field-id"},
{name: "fields alias", args: []string{"--fields", `["Cost--USD","Cost--USD"]`}, param: "--fields"},
{name: "field names alias", args: []string{"--field-names", "Cost--USD", "--field-names", "Cost--USD"}, param: "--field-names"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
args := append([]string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x"}, tc.args...)
err := runShortcut(t, BaseRecordList, args, factory, stdout)
assertInvalidArgumentValidation(t, err, tc.param, []string{tc.param}, "duplicate field id")
})
}
})
t.Run("search fields alias accepts JSON array projection", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
searchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name", "Age"},
"record_id_list": []interface{}{"rec_search"},
"data": []interface{}{[]interface{}{"Alice", 18}},
},
},
}
reg.Register(searchStub)
if err := runShortcut(t, BaseRecordSearch, []string{
"+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
"--keyword", "Alice", "--search-field", "Name", "--fields", `["Name","Age"]`, "--format", "json",
}, factory, stdout); err != nil {
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
t.Fatalf("err=%v", err)
}
if body := string(searchStub.CapturedBody); !strings.Contains(body, `"select_fields":["Name","Age"]`) {
t.Fatalf("captured body=%s", body)
})
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("get field names alias accepts repeated projection", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_1"},
"fields": []interface{}{"Name", "Age"},
"data": []interface{}{[]interface{}{"Alice", 18}},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{
"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1",
"--field-names", "Name", "--field-names", "Age", "--format", "json",
}, factory, stdout); err != nil {
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)
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
t.Fatalf("err=%v", err)
}
if body := string(batchStub.CapturedBody); !strings.Contains(body, `"select_fields":["Name","Age"]`) {
t.Fatalf("request body=%s", body)
}
})
t.Run("get", func(t *testing.T) {
@@ -2457,14 +2015,16 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"ignored_fields": []interface{}{"Formula"},
"has_more": false,
"record_id_list": []interface{}{"rec_1"},
"update": map[string]interface{}{"Status": "Done"},
},
},
})
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"update_records":{"rec_1":{"Status":["Done"]}}}`}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_1"],"patch":{"Status":"Done"}}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"ignored_fields"`) || !strings.Contains(got, `"Formula"`) {
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"update"`) || !strings.Contains(got, `"Done"`) {
t.Fatalf("stdout=%s", got)
}
})
@@ -2476,16 +2036,20 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_update",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_1"},
},
},
}
reg.Register(updateStub)
input := `{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", input}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_1"],"patch":{"Name":"Alice","Status":"Done"}}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) {
t.Fatalf("stdout=%s", got)
}
body := string(updateStub.CapturedBody)
if !strings.Contains(body, `"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}`) {
if !strings.Contains(body, `"record_id_list":["rec_1"]`) || !strings.Contains(body, `"patch":{"Name":"Alice","Status":"Done"}`) {
t.Fatalf("request body=%s", body)
}
})

View File

@@ -29,7 +29,7 @@ func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.D
Body(buildBaseCopyBody(runtime)).
Set("base_token", runtime.Str("base-token"))
if runtime.IsBot() {
d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base.")
d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
}
return d
}
@@ -37,7 +37,7 @@ func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.D
func dryRunBaseCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI()
if runtime.IsBot() {
d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base.")
d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
}
d.
POST("/open-apis/base/v3/bases").

View File

@@ -28,16 +28,23 @@ 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, "", "")
}
for name := range stringArrayFlags {
if name == "field-names" {
cmd.Flags().StringSlice(name, nil, "")
} else {
cmd.Flags().StringArray(name, nil, "")
}
cmd.Flags().StringArray(name, nil, "")
}
for name := range stringSliceFlags {
cmd.Flags().StringSlice(name, nil, "")
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
@@ -54,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")
@@ -465,40 +477,6 @@ func TestBaseLimitPageSizeAliasIsHidden(t *testing.T) {
}
}
func TestBaseRecordProjectionAliasesAreHidden(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
}{
{name: "record list", shortcut: BaseRecordList},
{name: "record search", shortcut: BaseRecordSearch},
{name: "record get", shortcut: BaseRecordGet},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
primary := cmd.Flags().Lookup("field-id")
if primary == nil || primary.Hidden {
t.Fatalf("public projection flag --field-id missing or hidden: %#v", primary)
}
help := cmd.Flags().FlagUsages()
for _, aliasName := range []string{"fields", "field-names"} {
alias := cmd.Flags().Lookup(aliasName)
if alias == nil || !alias.Hidden {
t.Fatalf("projection alias --%s should exist and be hidden: %#v", aliasName, alias)
}
if strings.Contains(help, "--"+aliasName) {
t.Fatalf("help should not include hidden --%s:\n%s", aliasName, help)
}
}
})
}
}
func TestBaseDashboardHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
@@ -808,8 +786,7 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
name: "record batch update json",
shortcut: BaseRecordBatchUpdate,
wantHelp: []string{
"update_records maps each record ID to its field map",
`{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`,
`batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`,
},
},
}
@@ -845,10 +822,6 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
"does not auto-upsert by business key",
"use +field-list to confirm real writable fields",
"do not write system fields, formula, lookup, or attachment fields",
"Sub-record/child-record path",
"set that link field to a parent record reference array",
`{"Parent Link":[{"id":"rec_xxx"}]}`,
"do not look for parent_record_id or a separate child-record API",
"CellValue happy path: text/phone/url",
"select -> \"Todo\"",
"multi-select -> [\"Tag A\",\"Tag B\"]",
@@ -881,11 +854,9 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
name: "record batch update",
shortcut: BaseRecordBatchUpdate,
wantTips: []string{
"Happy path field: update_records",
"update_records maps each record ID to its own field map",
`{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`,
"contains only optional ignored_fields",
"does not check whether record IDs exist",
"Happy path fields: record_id_list is the target record IDs",
"patch is a field map applied unchanged to every target record",
"Do not use +record-batch-update for per-row different values",
"use +field-list to confirm real writable fields",
"Batch update supports max 200 records per call",
"CellValue happy path: text/phone/url",
@@ -999,17 +970,11 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
t.Fatalf("flag help missing %q:\n%s", want, help)
}
}
if strings.Contains(help, "reformat-existing-records") {
t.Fatalf("+field-update must not expose a --reformat-existing-records flag:\n%s", help)
}
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
wantTips := []string{
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
`"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`,
`Example auto_number update: lark-cli base +field-update`,
`When --json.type is "auto_number", updating the numbering rules also reapplies them to existing numbers`,
"just submit the target field definition and do not add extra low-level parameters",
"full field-definition PUT semantics",
"Read the current field first with +field-get",
"Type conversion is allowlist-based",
@@ -1022,9 +987,6 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
if strings.Contains(tips, "--reformat-existing-records") {
t.Fatalf("+field-update tips must not ask agents to pass --reformat-existing-records:\n%s", tips)
}
}
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
@@ -1147,10 +1109,6 @@ func TestBaseFieldValidate(t *testing.T) {
if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": `{"name":"f1","type":"formula"}`}, map[string]bool{"i-have-read-guide": true}, nil)); err != nil {
t.Fatalf("formula update validate err=%v", err)
}
autoNumberJSON := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"incremental_number","length":4}]}}`
if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": autoNumberJSON}, nil, nil)); err != nil {
t.Fatalf("auto number update validate err=%v", err)
}
}
func TestBaseTableValidate(t *testing.T) {
@@ -1272,89 +1230,13 @@ func TestBaseRecordValidate(t *testing.T) {
)); err != nil {
t.Fatalf("record search json with sort-json validate err=%v", err)
}
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "keyword": "Bob"},
nil,
nil,
))
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--keyword"}, "mutually exclusive")
err = BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "fields": "Name"},
map[string][]string{"field-id": {"fld_name"}},
nil,
nil,
))
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--field-id", "--fields"}, "mutually exclusive")
}
func TestBaseRecordSearchProjectionLimit(t *testing.T) {
ctx := context.Background()
fields := make([]string, 51)
for i := range fields {
fields[i] = "Field " + strconv.Itoa(i+1)
)); err == nil || !strings.Contains(err.Error(), "--json is mutually exclusive") {
t.Fatalf("err=%v", err)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
map[string][]string{"search-field": {"Name"}, "field-id": fields[:50]},
nil,
nil,
)); err != nil {
t.Fatalf("50 projection fields should be accepted: %v", err)
}
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
map[string][]string{"search-field": {"Name"}, "field-id": fields},
nil,
nil,
))
assertInvalidArgumentValidation(t, err, "--field-id", []string{"--field-id"}, "maximum limit of 50")
body, marshalErr := json.Marshal(map[string]interface{}{
"keyword": "Alice",
"search_fields": []string{"Name"},
"select_fields": fields,
})
if marshalErr != nil {
t.Fatalf("marshal search body: %v", marshalErr)
}
err = BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": string(body)},
nil,
nil,
))
assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "maximum limit of 50")
}
func TestRecordSearchJSONNullProjectionIsOmitted(t *testing.T) {
runtime := newBaseTestRuntime(map[string]string{
"json": `{"keyword":"Alice","search_fields":["Name"],"select_fields":null,"sort":{"sort_config":[{"field":"Updated","desc":true}]}}`,
}, nil, nil)
body, err := recordSearchJSONBody(runtime)
if err != nil {
t.Fatalf("recordSearchJSONBody() error = %v", err)
}
if _, exists := body["select_fields"]; exists {
t.Fatalf("select_fields:null must normalize to omitted, body=%#v", body)
}
if sortConfig, ok := body["sort"].([]interface{}); !ok || len(sortConfig) != 1 {
t.Fatalf("sort normalization must continue after omitting null select_fields, body=%#v", body)
}
}
func TestBaseRecordSearchJSONProjectionParamIgnoresFlagLikeFieldNames(t *testing.T) {
ctx := context.Background()
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
map[string]string{
"base-token": "b",
"table-id": "tbl_1",
"json": `{"keyword":"cost","search_fields":["Name"],"select_fields":["Cost--USD","Cost--USD"]}`,
},
nil,
nil,
))
assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "duplicate field id")
}
func TestBasePaginationValidationRejectsOutOfRange(t *testing.T) {

View File

@@ -5,7 +5,6 @@ package base
import (
"context"
"fmt"
"strings"
"time"
@@ -37,10 +36,7 @@ func dryRunFieldGet(_ context.Context, runtime *common.RuntimeContext) *common.D
func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
bodies, err := parseFieldCreateBodies(pc, runtime.Str("json"))
if err != nil {
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
}
bodies, _ := parseFieldCreateBodies(pc, runtime.Str("json"))
dr := common.NewDryRunAPI().
Set("base_token", runtime.Str("base-token")).
Set("table_id", baseTableID(runtime))
@@ -52,10 +48,7 @@ func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *commo
func dryRunFieldUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
if err != nil {
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
}
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
return common.NewDryRunAPI().
PUT("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
Body(body).
@@ -173,10 +166,10 @@ func executeFieldCreate(runtime *common.RuntimeContext) error {
fields = append(fields, data)
}
if len(fields) == 1 {
runtime.Out(fieldCreateResult(map[string]interface{}{"field": fields[0], "created": true}, bodies[0]), nil)
runtime.Out(map[string]interface{}{"field": fields[0], "created": true}, nil)
return nil
}
runtime.Out(fieldCreateBatchResult(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, bodies), nil)
runtime.Out(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, nil)
return nil
}
@@ -204,101 +197,10 @@ func executeFieldUpdate(runtime *common.RuntimeContext) error {
if err != nil {
return err
}
runtime.Out(fieldUpdateResult(map[string]interface{}{"field": data, "updated": true}, body), nil)
runtime.Out(map[string]interface{}{"field": data, "updated": true}, nil)
return nil
}
func fieldCreateResult(result map[string]interface{}, submitted map[string]interface{}) map[string]interface{} {
readbackRecommended, reason := fieldWriteReadbackRecommendation(submitted, "create")
return attachFieldReadbackRecommendation(result, readbackRecommended, reason)
}
// fieldCreateBatchResult attaches the same top-level readback contract to a
// multi-field create. It recommends +field-get when any submitted field is a
// computed/linked/generated (or unknown) type, so agents know when to verify
// server state without breaking the existing fields/total structure.
func fieldCreateBatchResult(result map[string]interface{}, submitted []map[string]interface{}) map[string]interface{} {
recommend := false
reason := "simple fields created successfully; use +field-get only when extra properties or explicit verification are needed"
for _, body := range submitted {
if rec, r := fieldWriteReadbackRecommendation(body, "create"); rec {
recommend = true
reason = r
break
}
}
return attachFieldReadbackRecommendation(result, recommend, reason)
}
func fieldUpdateResult(result map[string]interface{}, submitted map[string]interface{}) map[string]interface{} {
returnedType := normalizeFieldType(fieldResultType(result["field"]))
submittedType := normalizeFieldType(common.GetString(submitted, "type"))
readbackRecommended, reason := fieldUpdateReadbackRecommendation(returnedType, submittedType)
return attachFieldReadbackRecommendation(result, readbackRecommended, reason)
}
func fieldUpdateReadbackRecommendation(returnedType, submittedType string) (bool, string) {
if returnedType != "" && submittedType != "" && returnedType != submittedType {
return true, fmt.Sprintf("field update submitted type %q but the server returned type %q; run +field-get and verify record values before declaring completion", submittedType, returnedType)
}
fieldType := returnedType
if fieldType == "" {
fieldType = submittedType
}
if recommended, reason := fieldTypeReadbackRecommendation(fieldType, "update"); recommended {
return true, reason + "; sample record values when generated, computed, or converted values are in scope"
}
return true, fmt.Sprintf("field update request succeeded for type %q, but +field-update cannot determine the previous type; run +field-get and sample record values if the type changed before declaring completion", fieldType)
}
func attachFieldReadbackRecommendation(result map[string]interface{}, readbackRecommended bool, reason string) map[string]interface{} {
result["field_get_recommended"] = readbackRecommended
result["verification_hint"] = reason
if readbackRecommended {
result["next_step"] = "field_get"
} else {
result["next_step"] = "done"
}
return result
}
func fieldWriteReadbackRecommendation(submitted map[string]interface{}, operation string) (bool, string) {
fieldType := normalizeFieldType(common.GetString(submitted, "type"))
return fieldTypeReadbackRecommendation(fieldType, operation)
}
func fieldTypeReadbackRecommendation(fieldType, operation string) (bool, string) {
fieldType = normalizeFieldType(fieldType)
switch fieldType {
case "formula", "lookup", "auto_number", "link":
return true, fmt.Sprintf("computed, linked, or generated field %s should be verified with +field-get before declaring completion", operation)
case "text", "number", "select", "datetime", "checkbox", "user", "group_chat", "attachment", "location":
return false, fmt.Sprintf("simple field %s returned successfully; use +field-get only when extra properties or explicit verification are needed", operation)
default:
return true, "unknown or uncommon field type; run +field-get to avoid assuming the submitted JSON fully describes server state"
}
}
func normalizeFieldType(fieldType string) string {
return strings.ToLower(strings.TrimSpace(fieldType))
}
func fieldResultType(value interface{}) string {
field, ok := value.(map[string]interface{})
if !ok {
return ""
}
if fieldType := strings.ToLower(strings.TrimSpace(common.GetString(field, "type"))); fieldType != "" {
return fieldType
}
nested, ok := field["field"].(map[string]interface{})
if !ok {
return ""
}
return strings.ToLower(strings.TrimSpace(common.GetString(nested, "type")))
}
func executeFieldDelete(runtime *common.RuntimeContext) error {
baseToken := runtime.Str("base-token")
tableIDValue := baseTableID(runtime)

View File

@@ -27,9 +27,7 @@ var BaseFieldUpdate = common.Shortcut{
baseHighRiskYesTip,
`Example text: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
`Example select: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}' --yes`,
`Example auto_number update: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "编号" --json '{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}' --yes`,
"Update uses full field-definition PUT semantics. Read the current field first with +field-get, then send the target state.",
`When --json.type is "auto_number", updating the numbering rules also reapplies them to existing numbers; just submit the target field definition and do not add extra low-level parameters.`,
"Type conversion is allowlist-based: only use CLI for safe conversions; otherwise migrate through a new field, or ask the user to finish high-risk conversions in the web UI.",
"Formula and lookup updates require reading the corresponding guide first.",
"Agent hint: use the lark-base skill's field-update guide for JSON shape, type-conversion rules, and limits.",

View File

@@ -238,14 +238,14 @@ func TestRecordSelectionHelpers(t *testing.T) {
t.Fatalf("err=%v", err)
}
fields, err = resolveRecordGetSelectFields(nil, "--field-id", map[string]interface{}{"select_fields": []interface{}{"Name"}})
fields, err = resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{"Name"}})
if err != nil || !reflect.DeepEqual(fields, []string{"Name"}) {
t.Fatalf("fields=%v err=%v", fields, err)
}
if _, err := resolveRecordGetSelectFields([]string{"Name"}, "--field-id", map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
if _, err := resolveRecordGetSelectFields([]string{"Name"}, map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err=%v", err)
}
if _, err := resolveRecordGetSelectFields(nil, "--field-id", map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
if _, err := resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
t.Fatalf("err=%v", err)
}

View File

@@ -12,19 +12,18 @@ import (
var BaseRecordBatchUpdate = common.Shortcut{
Service: "base",
Command: "+record-batch-update",
Description: "Batch update records with record-specific fields",
Description: "Batch update records",
Risk: "write",
Scopes: []string{"base:record:update"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "json", Desc: `batch update JSON object; update_records maps each record ID to its field map, e.g. {"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`, Required: true},
{Name: "json", Desc: `batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`, Required: true},
},
Tips: append([]string{
"Happy path field: update_records maps each record ID to its own field map.",
`Example: {"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}.`,
"The response contains only optional ignored_fields and does not check whether record IDs exist; read records back when confirmation is required.",
"Happy path fields: record_id_list is the target record IDs; patch is a field map applied unchanged to every target record.",
"Do not use +record-batch-update for per-row different values; call +record-upsert per record or use another supported flow.",
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
"Batch update supports max 200 records per call; use the record-batch-update guide for command limits and edge cases.",
}, recordCellValueHappyPathTips...),

View File

@@ -21,9 +21,7 @@ var BaseRecordGet = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
recordProjectionFieldFlag("field ID or name to project; repeat to keep only needed columns"),
recordProjectionAliasFlag("fields"),
recordProjectionAliasFlag("field-names"),
{Name: "field-id", Type: "string_array", Desc: "field ID or name to project; repeat to keep only needed columns"},
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
recordReadFormatFlag(),
},

View File

@@ -20,9 +20,8 @@ var BaseRecordList = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
recordProjectionFieldFlag("field ID or name to include; repeat to project only needed fields"),
recordProjectionAliasFlag("fields"),
recordProjectionAliasFlag("field-names"),
recordListFieldRefFlag(),
recordListFieldNamesAliasFlag(),
recordListViewRefFlag(),
recordFilterFlag(),
recordSortFlag(),
@@ -45,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
}
@@ -59,9 +61,6 @@ var BaseRecordList = common.Shortcut{
return err
}
}
if _, err := recordProjectionFields(runtime); err != nil {
return err
}
return validateRecordQueryOptions(runtime)
},
DryRun: dryRunRecordList,
@@ -73,6 +72,22 @@ var BaseRecordList = common.Shortcut{
},
}
func recordListFieldRefFlag() common.Flag {
flag := fieldRefFlag(false)
flag.Type = "string_array"
flag.Desc = "field ID or name to include; repeat to project only needed fields"
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"
@@ -87,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

@@ -5,18 +5,15 @@ package base
import (
"context"
"errors"
"net/url"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
const maxRecordSelectionCount = 200
const maxBatchGetSelectFieldCount = 100
const maxRecordSearchSelectFieldCount = 50
var recordCellValueHappyPathTips = []string{
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select -> "Todo"; multi-select -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
@@ -49,6 +46,7 @@ func validateRecordSelection(runtime *common.RuntimeContext) error {
func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, error) {
recordIDs := runtime.StrArray("record-id")
fieldIDs := runtime.StrArray("field-id")
jsonRaw := strings.TrimSpace(runtime.Str("json"))
if len(recordIDs) > 0 && jsonRaw != "" {
return recordSelection{}, baseFlagErrorf("--record-id and --json are mutually exclusive")
@@ -71,11 +69,7 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
if err != nil {
return recordSelection{}, err
}
projectionFields, err := recordProjectionFields(runtime)
if err != nil {
return recordSelection{}, err
}
selectFields, err := resolveRecordGetSelectFields(projectionFields, recordProjectionParam(runtime), body)
selectFields, err := resolveRecordGetSelectFields(fieldIDs, body)
if err != nil {
return recordSelection{}, err
}
@@ -89,11 +83,7 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
if err != nil {
return recordSelection{}, err
}
projectionFields, err := recordProjectionFields(runtime)
if err != nil {
return recordSelection{}, err
}
selectFields, err := resolveRecordGetSelectFields(projectionFields, recordProjectionParam(runtime), nil)
selectFields, err := resolveRecordGetSelectFields(fieldIDs, nil)
if err != nil {
return recordSelection{}, err
}
@@ -114,20 +104,20 @@ func normalizeRecordIDs(values interface{}) ([]string, error) {
})
}
func resolveRecordGetSelectFields(flagFields []string, projectionParam string, body map[string]interface{}) ([]string, error) {
func resolveRecordGetSelectFields(flagFields []string, body map[string]interface{}) ([]string, error) {
fromFlags, err := normalizeRecordGetSelectFields(flagFields)
if err != nil {
return nil, withValidationParam(err, projectionParam)
return nil, err
}
if body == nil {
return fromFlags, nil
}
rawJSONFields, ok := body["select_fields"]
if !ok || rawJSONFields == nil {
if !ok {
return fromFlags, nil
}
if len(fromFlags) > 0 {
return nil, baseFlagErrorf(`%s and --json field "select_fields" are mutually exclusive`, projectionParam)
return nil, baseFlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
}
items, ok := rawJSONFields.([]interface{})
if !ok {
@@ -138,26 +128,18 @@ func resolveRecordGetSelectFields(flagFields []string, projectionParam string, b
}
normalized, err := normalizeRecordGetSelectFields(items)
if err != nil {
return nil, withValidationParam(err, "--json")
return nil, err
}
return normalized, nil
}
func normalizeRecordGetSelectFields(values interface{}) ([]string, error) {
return normalizeRecordSelectFields(values, maxBatchGetSelectFieldCount)
}
func normalizeRecordSearchSelectFields(values interface{}) ([]string, error) {
return normalizeRecordSelectFields(values, maxRecordSearchSelectFieldCount)
}
func normalizeRecordSelectFields(values interface{}, max int) ([]string, error) {
return normalizeStringList(values, stringListNormalizeOptions{
typeError: "field selection must be a string array",
itemName: "field selection item",
duplicateName: "field id",
limitName: "field selection",
max: max,
max: maxBatchGetSelectFieldCount,
allowNil: true,
allowEmpty: true,
})
@@ -229,11 +211,7 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common
params := url.Values{}
params.Set("offset", strconv.Itoa(offset))
params.Set("limit", strconv.Itoa(limit))
fields, err := recordProjectionFields(runtime)
if err != nil {
return common.NewDryRunAPI()
}
for _, field := range fields {
for _, field := range recordListFields(runtime) {
params.Add("field_id", field)
}
if viewID := runtime.Str("view-id"); viewID != "" {
@@ -397,121 +375,11 @@ func validateRecordJSON(runtime *common.RuntimeContext) error {
return err
}
func recordProjectionFieldFlag(desc string) common.Flag {
flag := fieldRefFlag(false)
flag.Type = "string_array"
flag.Desc = desc
return flag
}
func recordProjectionAliasFlag(name string) common.Flag {
flagType := "string_array"
if name == "field-names" {
// Preserve the original compatibility contract: --field-names uses
// pflag's CSV parser, including quoted commas, and treats @ literally.
flagType = "string_slice"
func recordListFields(runtime *common.RuntimeContext) []string {
if runtime.Changed("field-names") {
return runtime.StrSlice("field-names")
}
return common.Flag{
Name: name,
Type: flagType,
Desc: "hidden alias for --field-id projection",
Hidden: true,
}
}
func recordProjectionParam(runtime *common.RuntimeContext) string {
switch {
case runtime.Changed("fields"):
return "--fields"
case runtime.Changed("field-names"):
return "--field-names"
default:
return "--field-id"
}
}
func withValidationParam(err error, param string) error {
if err == nil || param == "" {
return err
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
return err
}
reason := validationErr.Error()
// The caller knows which input produced this validation error. Replace any
// params inferred from the rendered message: field values such as Cost--USD
// must not be mistaken for a --USD flag.
validationErr.Param = param
validationErr.Params = []errs.InvalidParam{{Name: param, Reason: reason}}
return err
}
func recordProjectionFields(runtime *common.RuntimeContext) ([]string, error) {
return recordProjectionFieldsWithLimit(runtime, maxBatchGetSelectFieldCount)
}
func recordSearchProjectionFields(runtime *common.RuntimeContext) ([]string, error) {
return recordProjectionFieldsWithLimit(runtime, maxRecordSearchSelectFieldCount)
}
func recordProjectionFieldsWithLimit(runtime *common.RuntimeContext, max int) ([]string, error) {
fieldIDs := runtime.StrArray("field-id")
fieldIDsSet := runtime.Changed("field-id")
fieldsSet := runtime.Changed("fields")
fieldNamesSet := runtime.Changed("field-names")
projectionParams := make([]string, 0, 3)
if fieldIDsSet {
projectionParams = append(projectionParams, "--field-id")
}
if fieldsSet {
projectionParams = append(projectionParams, "--fields")
}
if fieldNamesSet {
projectionParams = append(projectionParams, "--field-names")
}
if len(projectionParams) > 1 {
invalidParams := make([]errs.InvalidParam, 0, len(projectionParams))
for _, param := range projectionParams {
invalidParams = append(invalidParams, errs.InvalidParam{Name: param, Reason: "mutually exclusive"})
}
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s are mutually exclusive", strings.Join(projectionParams, " and ")).
WithParam(projectionParams[0]).
WithParams(invalidParams...).
WithHint("Use only --field-id for projection.")
}
if fieldsSet {
return recordProjectionAliasFields(runtime, "fields", max)
}
if fieldNamesSet {
return recordProjectionAliasFields(runtime, "field-names", max)
}
fields, err := normalizeRecordSelectFields(fieldIDs, max)
return fields, withValidationParam(err, "--field-id")
}
func recordProjectionAliasFields(runtime *common.RuntimeContext, flagName string, max int) ([]string, error) {
var fields []string
if flagName == "field-names" {
fields = runtime.StrSlice(flagName)
} else {
pc := newParseCtx(runtime)
values := runtime.StrArray(flagName)
fields = make([]string, 0, len(values))
for _, raw := range values {
parsed, err := parseStringListFlexible(pc, raw, flagName)
if err != nil {
return nil, withValidationParam(err, "--"+flagName)
}
fields = append(fields, parsed...)
}
}
if len(fields) == 0 {
err := baseFlagErrorf("--%s must include at least one field", flagName)
return nil, withValidationParam(err, "--"+flagName)
}
normalized, err := normalizeRecordSelectFields(fields, max)
return normalized, withValidationParam(err, "--"+flagName)
return runtime.StrArray("field-id")
}
func executeRecordList(runtime *common.RuntimeContext) error {
@@ -524,10 +392,7 @@ func executeRecordList(runtime *common.RuntimeContext) error {
}
limit := getPaginationLimit(runtime)
params := map[string]interface{}{"offset": offset, "limit": limit}
fields, err := recordProjectionFields(runtime)
if err != nil {
return err
}
fields := recordListFields(runtime)
if len(fields) > 0 {
params["field_id"] = fields
}

View File

@@ -9,7 +9,6 @@ import (
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -175,10 +174,7 @@ func recordSearchFlagBody(runtime *common.RuntimeContext) (map[string]interface{
if len(searchFields) > 0 {
body["search_fields"] = searchFields
}
selectFields, err := recordSearchProjectionFields(runtime)
if err != nil {
return nil, err
}
selectFields := recordListFields(runtime)
if len(selectFields) > 0 {
body["select_fields"] = selectFields
}
@@ -207,19 +203,6 @@ func recordSearchJSONBody(runtime *common.RuntimeContext) (map[string]interface{
}
func normalizeRecordSearchJSONBody(body map[string]interface{}) error {
if rawSelectFields, ok := body["select_fields"]; ok {
if rawSelectFields == nil {
delete(body, "select_fields")
} else {
selectFields, err := normalizeRecordSearchSelectFields(rawSelectFields)
if err != nil {
return withValidationParam(err, "--json")
}
if len(selectFields) > 0 {
body["select_fields"] = selectFields
}
}
}
if rawSort, ok := body["sort"]; ok {
if sortConfig, err := normalizeRecordSortValue(rawSort, "--json.sort"); err == nil {
body["sort"] = sortConfig
@@ -236,20 +219,8 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
}
jsonRaw := strings.TrimSpace(runtime.Str("json"))
if jsonRaw != "" {
if exclusiveParams := recordSearchJSONExclusiveFlagParams(runtime); len(exclusiveParams) > 0 {
allParams := append([]string{"--json"}, exclusiveParams...)
invalidParams := make([]errs.InvalidParam, 0, len(allParams))
for _, param := range allParams {
invalidParams = append(invalidParams, errs.InvalidParam{Name: param, Reason: "mutually exclusive"})
}
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--json is mutually exclusive with %s",
strings.Join(exclusiveParams, " and "),
).
WithParam("--json").
WithParams(invalidParams...).
WithHint("Put keyword, search, projection, view, and pagination fields inside --json, or omit --json.")
if recordSearchHasJSONExclusiveFlagInputs(runtime) {
return baseFlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json")
}
_, err := recordSearchJSONBody(runtime)
return err
@@ -271,31 +242,17 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
return err
}
}
if _, err := recordSearchProjectionFields(runtime); err != nil {
return err
}
return validateRecordQueryOptions(runtime)
}
func recordSearchJSONExclusiveFlagParams(runtime *common.RuntimeContext) []string {
names := []string{
"keyword",
"search-field",
"field-id",
"fields",
"field-names",
"view-id",
"offset",
"limit",
"page-size",
}
params := make([]string, 0, len(names))
for _, name := range names {
if runtime.Changed(name) {
params = append(params, "--"+name)
}
}
return params
func recordSearchHasJSONExclusiveFlagInputs(runtime *common.RuntimeContext) bool {
return strings.TrimSpace(runtime.Str("keyword")) != "" ||
len(runtime.StrArray("search-field")) > 0 ||
len(recordListFields(runtime)) > 0 ||
runtime.Str("view-id") != "" ||
runtime.Changed("offset") ||
runtime.Changed("limit") ||
runtime.Changed("page-size")
}
func formatRecordQueryPriorityTip() string {

View File

@@ -23,9 +23,7 @@ var BaseRecordSearch = common.Shortcut{
{Name: "json", Desc: `record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`},
{Name: "keyword", Desc: "keyword for record search; required unless --json is used"},
{Name: "search-field", Type: "string_array", Desc: "field ID or name to search; repeat for multiple fields; required unless --json is used"},
recordProjectionFieldFlag("field ID or name to include; repeat to project only needed fields"),
recordProjectionAliasFlag("fields"),
recordProjectionAliasFlag("field-names"),
recordListFieldRefFlag(),
recordListViewRefFlag(),
recordFilterFlag(),
recordSortFlag(),

View File

@@ -26,7 +26,6 @@ var BaseRecordUpsert = common.Shortcut{
"Happy path JSON is a top-level field map: each key is a real field name or field ID, each value is that field's CellValue.",
"Without --record-id this creates a record; with --record-id this updates that record. It does not auto-upsert by business key.",
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
"Sub-record/child-record path: when a one-way/two-way link field represents hierarchy, create a normal record and set that link field to a parent record reference array, e.g. {\"Parent Link\":[{\"id\":\"rec_xxx\"}]}; do not look for parent_record_id or a separate child-record API.",
"Use the record-upsert guide for command limits and edge cases.",
}, recordCellValueHappyPathTips...),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -67,25 +67,6 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
return attendees, nil
}
// selfAttendeeId resolves the open_id of the identity running the command so it
// can be auto-added to the attendee list, mirroring how a human user is joined
// to their own events. For a user it comes from config; for a bot it is fetched
// from /bot/v3/info. If the bot lookup fails, we warn and return "" so the event
// is still created with the explicitly requested attendees.
func selfAttendeeId(runtime *common.RuntimeContext) string {
if !runtime.IsBot() {
return runtime.UserOpenId()
}
info, err := runtime.BotInfo()
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut,
"[calendar +create] warning: could not resolve bot identity to add it as an attendee (%v); proceeding without the bot\n",
err)
return ""
}
return info.OpenID
}
func attendeesIncludeRoom(attendees []map[string]string) bool {
for _, attendee := range attendees {
if attendee["type"] == "resource" || attendee["room_id"] != "" {
@@ -195,9 +176,7 @@ var CalendarCreate = common.Shortcut{
eventData := buildEventData(runtime, startTs, endTs)
attendeesStr := runtime.Str("attendee-ids")
if attendeesStr != "" {
// Note: dry-run doesn't network resolve the running identity's own
// open_id (user from config, bot from /bot/v3/info), so the auto-joined
// self attendee is not shown here.
// Note: dry-run doesn't network resolve the current user's open_id.
attendees, err := parseAttendees(attendeesStr, "")
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
@@ -249,8 +228,11 @@ var CalendarCreate = common.Shortcut{
// Add attendees if specified
if attendeesStr := runtime.Str("attendee-ids"); attendeesStr != "" {
selfId := selfAttendeeId(runtime)
attendees, err := parseAttendees(attendeesStr, selfId)
currentUserId := ""
if !runtime.IsBot() {
currentUserId = runtime.UserOpenId()
}
attendees, err := parseAttendees(attendeesStr, currentUserId)
if err != nil {
return withParam(err, "--attendee-ids")
}

View File

@@ -1,790 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//
// calendar +update room-availability pre-check helpers.
//
// Uses /open-apis/calendar/v4/freebusy/room_availability_check to warn the
// caller before an update either adds a new room attendee or shifts the time
// of a slot that already has a room reservation. --skip-room-check bypasses
// the check for callers that want to move fast.
package calendar
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
flagSkipRoomCheck = "skip-room-check"
roomCheckPath = "/open-apis/calendar/v4/freebusy/room_availability_check"
)
// roomAvailability mirrors a single room result from the API.
type roomAvailability struct {
RoomID string `json:"room_id,omitempty"`
RoomName string `json:"room_name,omitempty"`
Status string `json:"status,omitempty"`
UnavailableReasonType string `json:"unavailable_reason_type,omitempty"`
Strategy *roomStrategy `json:"room_strategy,omitempty"`
Requisition *roomRequisition `json:"room_requisition,omitempty"`
ApprovalInfo *roomApprovalInfo `json:"room_approval_info,omitempty"`
}
// roomStrategy mirrors the room_strategy block returned by the API on
// unavailable rooms. Every field is optional: the server only fills in the
// entries relevant to the current unavailable_reason_type.
type roomStrategy struct {
SingleMaxDuration string `json:"single_max_duration,omitempty"`
MaxAdvanceBookingTime string `json:"max_advance_booking_time,omitempty"`
DailyStartTime string `json:"daily_start_time,omitempty"`
DailyEndTime string `json:"daily_end_time,omitempty"`
Timezone string `json:"timezone,omitempty"`
DailyAdvanceWindowReleaseTime string `json:"daily_advance_window_release_time,omitempty"`
}
// roomRequisition mirrors room_requisition, returned by the API only when
// unavailable_reason_type == "during_requisition". Both fields are RFC3339
// strings and either may be empty if the server has no exact bound.
type roomRequisition struct {
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
}
// roomApprovalInfo mirrors room_approval_info, returned when the room requires
// (or may require) an approval submission before it can be booked.
//
// - ApprovalMode: "none" (no approval), "over_duration" (only when the
// booking exceeds the threshold), or "all" (every booking needs approval).
// - ApprovalDurationThreshold: seconds; only meaningful when
// ApprovalMode == "over_duration". The server returns it as a numeric
// string, matching the shape of the other duration fields.
//
// When the pre-check returns status == "need_approval" the caller renders a
// friendly reminder derived from these two fields plus the current event
// duration, so the agent knows whether to switch rooms/times or route the
// user through an approval flow.
type roomApprovalInfo struct {
ApprovalMode string `json:"approval_mode,omitempty"`
ApprovalDurationThreshold string `json:"approval_duration_threshold,omitempty"`
}
// eventSnapshot carries only the fields room-check needs from the current
// event: existing room IDs, current start/end (unix seconds string), timezone,
// and rrule.
type eventSnapshot struct {
RoomIDs []string
StartTs string
EndTs string
Timezone string
Recurrent string
}
// unavailableReasonHint maps API-declared unavailable reasons to a short
// English phrase suitable for embedding in the block message. Unknown or
// future reasons fall back to a single stable phrase so the CLI's blocked
// message stays predictable for agents that parse it.
func unavailableReasonHint(reason string) string {
switch reason {
case "reserved_by_other_event":
return "already reserved by another event"
case "past_time":
return "cannot book a room in the past"
case "beyond_advance_booking_window":
return "beyond the room's advance-booking window"
case "over_max_duration":
return "exceeds the room's max single-booking duration"
case "not_in_usable_time":
return "outside the room's daily bookable window"
case "during_requisition":
return "the room is disabled during this time and cannot be booked"
case "before_daily_advance_window_release":
return "the target date is outside the room's currently unlocked advance-booking window; the window extends by one calendar day at the daily release time"
case "recurring_exceed_approval_limit":
return "recurring event duration exceeds the limit for booking this approval-required room — shorten the duration or pick a different room"
default:
return "currently unbookable"
}
}
// strategyDetail renders the human-readable suffix appended to the reason
// phrase for a given (reason, strategy) pair. It returns an empty string when
// no strategy data is available or when the fields relevant to this reason
// are missing / invalid, so callers can safely concatenate the result.
func strategyDetail(reason string, s *roomStrategy) string {
if s == nil {
return ""
}
switch reason {
case "over_max_duration":
if d := formatDurationSeconds(s.SingleMaxDuration); d != "" {
return "the max single-booking duration is " + d
}
case "beyond_advance_booking_window":
// The API returns max_advance_booking_time as RFC3339 already;
// surface it verbatim so agents don't lose the exact instant.
if t := strings.TrimSpace(s.MaxAdvanceBookingTime); t != "" {
return "the latest bookable end time is " + t
}
case "not_in_usable_time":
start := formatDaySeconds(s.DailyStartTime)
end := formatDaySeconds(s.DailyEndTime)
zone := roomZoneLabel(s.Timezone)
switch {
case start != "" && end != "":
return fmt.Sprintf("the daily bookable window is %s - %s (%s)", start, end, zone)
case start != "":
return fmt.Sprintf("the daily bookable window starts at %s (%s)", start, zone)
case end != "":
return fmt.Sprintf("the daily bookable window ends at %s (%s)", end, zone)
}
case "before_daily_advance_window_release":
if t := formatDaySeconds(s.DailyAdvanceWindowReleaseTime); t != "" {
return fmt.Sprintf("the next unlock happens today at %s (%s), which advances the window by one day", t, roomZoneLabel(s.Timezone))
}
}
return ""
}
// requisitionDetail renders the suffix describing the room's scheduled
// disable window for a `during_requisition` block. The API sends both bounds
// as RFC3339 already, so we surface them verbatim to keep the exact instant.
// Returns "" when both bounds are missing so the caller falls back to the
// generic "pick a different time or a different room" recovery hint.
func requisitionDetail(reason string, r *roomRequisition) string {
if reason != "during_requisition" || r == nil {
return ""
}
start := strings.TrimSpace(r.StartTime)
end := strings.TrimSpace(r.EndTime)
switch {
case start != "" && end != "":
return fmt.Sprintf("the disabled period is %s to %s", start, end)
case start != "":
return "the disabled period starts at " + start
case end != "":
return "the disabled period ends at " + end
}
return ""
}
// formatDurationSeconds renders a whole-second string like "10800" as a
// compact "H hours [M minutes]" phrase. Returns "" when the value is
// missing, non-numeric, or non-positive.
func formatDurationSeconds(raw string) string {
sec, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil || sec <= 0 {
return ""
}
d := time.Duration(sec) * time.Second
h := int(d / time.Hour)
m := int((d % time.Hour) / time.Minute)
switch {
case h > 0 && m > 0:
return fmt.Sprintf("%d hours %d minutes", h, m)
case h > 0:
return fmt.Sprintf("%d hours", h)
case m > 0:
return fmt.Sprintf("%d minutes", m)
default:
return fmt.Sprintf("%d seconds", sec)
}
}
// formatDaySeconds renders a "seconds since midnight" string as "HH:MM".
// Returns "" when raw is missing, non-numeric, or outside [0, 24h). Seconds
// are truncated because the API only guarantees minute-level meaning for
// daily windows and release times.
func formatDaySeconds(raw string) string {
sec, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil || sec < 0 || sec >= 24*3600 {
return ""
}
h := sec / 3600
m := (sec % 3600) / 60
return fmt.Sprintf("%02d:%02d", h, m)
}
// roomZoneLabel renders the room's timezone as either a "GMT±X" string
// anchored to today (so DST is respected) when the IANA name resolves, or
// the IANA name itself as a fallback so agents always see the source of
// truth. Returns the local device timezone's label when raw is empty.
func roomZoneLabel(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return gmtOffsetLabel(time.Now())
}
loc, err := time.LoadLocation(raw)
if err != nil {
return raw
}
return gmtOffsetLabel(time.Now().In(loc))
}
// gmtOffsetLabel formats t's zone offset as "GMT+8" / "GMT-5:30" / "GMT".
// Minute-precision is included only when the offset has a non-zero minute
// component so the common whole-hour case stays terse.
func gmtOffsetLabel(t time.Time) string {
_, offsetSec := t.Zone()
if offsetSec == 0 {
return "GMT"
}
sign := "+"
if offsetSec < 0 {
sign = "-"
offsetSec = -offsetSec
}
h := offsetSec / 3600
m := (offsetSec % 3600) / 60
if m == 0 {
return fmt.Sprintf("GMT%s%d", sign, h)
}
return fmt.Sprintf("GMT%s%d:%02d", sign, h, m)
}
// collectAttendeeRoomIDs extracts omm_ prefixed IDs from a comma-separated
// flag value. Empty / whitespace input returns nil.
func collectAttendeeRoomIDs(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
var rooms []string
seen := map[string]struct{}{}
for _, part := range strings.Split(raw, ",") {
id := strings.TrimSpace(part)
if !strings.HasPrefix(id, "omm_") {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
rooms = append(rooms, id)
}
return rooms
}
// fetchEventSnapshot GETs the event with attendees so we can read the current
// start / end / recurrence and the room IDs already booked on the event. It is
// best-effort: any error bubbles up so the caller can降级放行 by warning.
//
// One retry is baked in: a `{uid}_{original_time}` event_id refers to a
// specific instance of a recurring series, but until that instance is edited
// and materialised as an exception, the server only knows the master
// (`{uid}_0`) and answers 193001 (event not found). We detect that shape and
// re-issue the GET against the master so the room-check pipeline still has a
// snapshot to work with.
func fetchEventSnapshot(_ context.Context, runtime *common.RuntimeContext, calendarID, eventID string) (*eventSnapshot, error) {
data, err := callEventGet(runtime, calendarID, eventID)
if err != nil {
if masterID, ok := recurringMasterEventID(eventID); ok && isEventNotFound(err) {
data, err = callEventGet(runtime, calendarID, masterID)
}
if err != nil {
return nil, err
}
}
event, _ := data["event"].(map[string]interface{})
if event == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response missing 'event' field")
}
snap := &eventSnapshot{}
if start, _ := event["start_time"].(map[string]interface{}); start != nil {
if ts, _ := start["timestamp"].(string); ts != "" {
snap.StartTs = ts
}
if tz, _ := start["timezone"].(string); tz != "" {
snap.Timezone = tz
}
}
if end, _ := event["end_time"].(map[string]interface{}); end != nil {
if ts, _ := end["timestamp"].(string); ts != "" {
snap.EndTs = ts
}
if snap.Timezone == "" {
if tz, _ := end["timezone"].(string); tz != "" {
snap.Timezone = tz
}
}
}
if r, _ := event["recurrence"].(string); r != "" {
snap.Recurrent = r
}
attendees, _ := event["attendees"].([]interface{})
seen := map[string]struct{}{}
for _, raw := range attendees {
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
if t, _ := m["type"].(string); t != "resource" {
continue
}
id, _ := m["room_id"].(string)
if id == "" {
continue
}
if status, _ := m["rsvp_status"].(string); status == "removed" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
snap.RoomIDs = append(snap.RoomIDs, id)
}
return snap, nil
}
// callEventGet issues the calendar event GET used by fetchEventSnapshot. It
// is factored out so the 193001 fallback can re-issue the request against
// the master event without duplicating the params / path plumbing.
func callEventGet(runtime *common.RuntimeContext, calendarID, eventID string) (map[string]interface{}, error) {
path := fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s",
validate.EncodePathSegment(calendarID), validate.EncodePathSegment(eventID))
params := map[string]interface{}{
"user_id_type": "open_id",
"need_attendee": true,
"max_attendee_num": 20,
}
return runtime.CallAPITyped("GET", path, params, nil)
}
// recurringMasterEventID inspects a calendar event_id shaped like
// `{uid}_{original_time}` and returns `{uid}_0` when original_time is a
// positive integer, plus true so callers know a fallback is worth trying.
// Any other shape (missing underscore, non-numeric suffix, already `_0`, or
// suffix `0` / negative) returns "", false so we don't retry pointlessly.
func recurringMasterEventID(eventID string) (string, bool) {
idx := strings.LastIndex(eventID, "_")
if idx <= 0 || idx == len(eventID)-1 {
return "", false
}
uid := eventID[:idx]
suffix := eventID[idx+1:]
n, err := strconv.ParseInt(suffix, 10, 64)
if err != nil || n <= 0 {
return "", false
}
return uid + "_0", true
}
// isEventNotFound returns true when err is a calendar 193001 (event not
// found) API error. Kept in this file rather than shared with
// unwrapCalendarAPIError because that helper returns a user-facing hint —
// here we only need the classification, not the copy.
func isEventNotFound(err error) bool {
if err == nil {
return false
}
var ae *errs.APIError
if !errors.As(err, &ae) {
return false
}
return ae.Code == 193001
}
// roomCheckPlan bundles the resolved inputs for the pre-check API call.
type roomCheckPlan struct {
RoomIDs []string
StartTs string
EndTs string
StartTimezone string
Rrule string
}
// resolveRoomCheckPlan works out which rooms to check and the target time
// window. It applies the降级放行 policy: if the event snapshot fails to load
// but we can proceed with only user-provided inputs (i.e., time changed and a
// new room is added), the pre-check still runs against those. Otherwise it
// warns and returns (nil, nil) so the caller skips the check.
//
// Returns (nil, nil) when no check is warranted.
func resolveRoomCheckPlan(ctx context.Context, runtime *common.RuntimeContext, calendarID, eventID string, newStartTs, newEndTs string, timeChanged, rruleChanged bool) (*roomCheckPlan, error) {
newRooms := collectAttendeeRoomIDs(runtime.Str("add-attendee-ids"))
removeSet := map[string]struct{}{}
for _, id := range collectAttendeeRoomIDs(runtime.Str("remove-attendee-ids")) {
removeSet[id] = struct{}{}
}
// Fast path: only trigger the check when it can find something to look at.
// - New room attendees → always check.
// - Time or rrule change → check existing rooms if any.
if len(newRooms) == 0 && !timeChanged && !rruleChanged {
return nil, nil
}
newRrule := strings.TrimSpace(runtime.Str("rrule"))
// If we don't need existing rooms and have both start/end, skip the GET.
needSnapshot := timeChanged || rruleChanged || !timeChanged && len(newRooms) > 0
var snap *eventSnapshot
if needSnapshot {
var err error
snap, err = fetchEventSnapshot(ctx, runtime, calendarID, eventID)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut,
"[calendar +update] warning: failed to fetch current event for room-availability check (%v); precheck runs only against user-supplied inputs — pass --%s to silence\n",
err, flagSkipRoomCheck)
snap = nil
}
}
plan := &roomCheckPlan{
StartTs: newStartTs,
EndTs: newEndTs,
Rrule: newRrule,
}
if plan.StartTs == "" && snap != nil {
plan.StartTs = snap.StartTs
}
if plan.EndTs == "" && snap != nil {
plan.EndTs = snap.EndTs
}
if plan.Rrule == "" && snap != nil {
plan.Rrule = snap.Recurrent
}
if snap != nil {
plan.StartTimezone = snap.Timezone
}
seen := map[string]struct{}{}
addRoom := func(id string) {
if id == "" {
return
}
if _, ok := removeSet[id]; ok {
return
}
if _, ok := seen[id]; ok {
return
}
seen[id] = struct{}{}
plan.RoomIDs = append(plan.RoomIDs, id)
}
for _, id := range newRooms {
addRoom(id)
}
if snap != nil && (timeChanged || rruleChanged) {
for _, id := range snap.RoomIDs {
addRoom(id)
}
}
if len(plan.RoomIDs) == 0 {
return nil, nil
}
// Without a target window the server has no basis to check anything;
// prefer degrading gracefully to blocking legitimate updates.
if plan.StartTs == "" || plan.EndTs == "" {
fmt.Fprintf(runtime.IO().ErrOut,
"[calendar +update] warning: room-availability check skipped because start/end could not be resolved; pass --%s to silence\n",
flagSkipRoomCheck)
return nil, nil
}
return plan, nil
}
// roomCheckPlanDurationSec returns the current booking duration in whole
// seconds derived from the resolved plan's Unix-second window, or 0 when
// either bound is missing or unparseable. Used to compare against
// approval_duration_threshold when the API asks for approval.
func roomCheckPlanDurationSec(plan *roomCheckPlan) int64 {
if plan == nil {
return 0
}
start, err := strconv.ParseInt(strings.TrimSpace(plan.StartTs), 10, 64)
if err != nil {
return 0
}
end, err := strconv.ParseInt(strings.TrimSpace(plan.EndTs), 10, 64)
if err != nil {
return 0
}
if end <= start {
return 0
}
return end - start
}
// buildRoomCheckBody assembles the request body for room_availability_check.
// The pre-check API expects start/end as RFC3339 timestamps; we take the
// Unix-second strings used elsewhere in the update flow and render them in
// the event's own timezone when available, falling back to the local device
// timezone so agents on different machines still produce a valid request.
// start_timezone is an IANA name (e.g. "Asia/Shanghai") copied from the event
// snapshot; it is omitted when unknown so the server can fall back to its own
// default.
func buildRoomCheckBody(calendarID, eventID string, plan *roomCheckPlan) map[string]interface{} {
loc := time.Local
if plan.StartTimezone != "" {
if l, err := time.LoadLocation(plan.StartTimezone); err == nil {
loc = l
}
}
body := map[string]interface{}{
"calendar_id": calendarID,
"event_id": eventID,
"start_time": formatRoomCheckTime(plan.StartTs, loc),
"end_time": formatRoomCheckTime(plan.EndTs, loc),
"room_ids": plan.RoomIDs,
}
if plan.StartTimezone != "" {
body["start_timezone"] = plan.StartTimezone
}
if plan.Rrule != "" {
body["event_rrule"] = plan.Rrule
}
return body
}
// formatRoomCheckTime renders a Unix-second string as RFC3339 in loc.
// Non-numeric input is returned unchanged so anomalies stay visible instead
// of being silently rewritten to the epoch.
func formatRoomCheckTime(unixStr string, loc *time.Location) string {
sec, err := strconv.ParseInt(strings.TrimSpace(unixStr), 10, 64)
if err != nil {
return unixStr
}
return time.Unix(sec, 0).In(loc).Format(time.RFC3339)
}
// callRoomAvailabilityCheck posts the availability request and returns per-room
// results.
func callRoomAvailabilityCheck(runtime *common.RuntimeContext, body map[string]interface{}) ([]roomAvailability, error) {
data, err := runtime.CallAPITyped("POST", roomCheckPath, nil, body)
if err != nil {
return nil, err
}
rawList, _ := data["room_availabilitys"].([]interface{})
out := make([]roomAvailability, 0, len(rawList))
for _, raw := range rawList {
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
item := roomAvailability{}
if v, ok := m["room_id"].(string); ok {
item.RoomID = v
}
if v, ok := m["room_name"].(string); ok {
item.RoomName = v
}
if v, ok := m["status"].(string); ok {
item.Status = v
}
if v, ok := m["unavailable_reason_type"].(string); ok {
item.UnavailableReasonType = v
}
if strat, ok := m["room_strategy"].(map[string]interface{}); ok {
item.Strategy = parseRoomStrategy(strat)
}
if req, ok := m["room_requisition"].(map[string]interface{}); ok {
item.Requisition = parseRoomRequisition(req)
}
if info, ok := m["room_approval_info"].(map[string]interface{}); ok {
item.ApprovalInfo = parseRoomApprovalInfo(info)
}
out = append(out, item)
}
return out, nil
}
// parseRoomStrategy extracts the optional strategy fields from a raw API
// map. Missing / non-string values are dropped so callers only see what the
// server actually sent.
func parseRoomStrategy(m map[string]interface{}) *roomStrategy {
s := &roomStrategy{}
if v, ok := m["single_max_duration"].(string); ok {
s.SingleMaxDuration = v
}
if v, ok := m["max_advance_booking_time"].(string); ok {
s.MaxAdvanceBookingTime = v
}
if v, ok := m["daily_start_time"].(string); ok {
s.DailyStartTime = v
}
if v, ok := m["daily_end_time"].(string); ok {
s.DailyEndTime = v
}
if v, ok := m["timezone"].(string); ok {
s.Timezone = v
}
if v, ok := m["daily_advance_window_release_time"].(string); ok {
s.DailyAdvanceWindowReleaseTime = v
}
return s
}
// parseRoomRequisition extracts the optional room_requisition block from a
// raw API map. Missing / non-string values are dropped.
func parseRoomRequisition(m map[string]interface{}) *roomRequisition {
r := &roomRequisition{}
if v, ok := m["start_time"].(string); ok {
r.StartTime = v
}
if v, ok := m["end_time"].(string); ok {
r.EndTime = v
}
return r
}
// parseRoomApprovalInfo extracts the optional room_approval_info block from a
// raw API map. Missing / non-string values are dropped.
func parseRoomApprovalInfo(m map[string]interface{}) *roomApprovalInfo {
info := &roomApprovalInfo{}
if v, ok := m["approval_mode"].(string); ok {
info.ApprovalMode = v
}
if v, ok := m["approval_duration_threshold"].(string); ok {
info.ApprovalDurationThreshold = v
}
return info
}
// approvalReasonHint composes the per-line phrase for a `need_approval`
// status. The API returns `room_approval_info` with:
//
// - "all" → every reservation on this room must be approved.
// - "over_duration" → only bookings longer than approval_duration_threshold
// need approval. The current event duration (eventDurationSec) is compared
// against the threshold so agents can see exactly why approval is being
// asked for — and, when the current duration is below the threshold, the
// message points at the "shorten it" recovery path.
// - anything else → generic reminder so unknown modes still surface.
//
// This function only produces the per-room fragment. The shared recovery
// clause (attendees-create, client fallback, shorten, pick another room) is
// appended once by blockOnUnavailableRooms into `.WithHint(...)` so a message
// with several approval-required rooms doesn't repeat the same recovery
// paragraph on every line.
func approvalReasonHint(info *roomApprovalInfo, eventDurationSec int64) string {
mode := ""
if info != nil {
mode = strings.TrimSpace(info.ApprovalMode)
}
switch mode {
case "all":
return "this room requires approval for every reservation"
case "over_duration":
threshold, _ := strconv.ParseInt(strings.TrimSpace(info.ApprovalDurationThreshold), 10, 64)
if threshold <= 0 {
// Server said approval-by-duration but didn't give a threshold —
// keep the mode label so agents don't lose the classification.
return "this room requires approval when the booking exceeds a duration threshold"
}
thresholdPhrase := formatDurationSeconds(info.ApprovalDurationThreshold)
if thresholdPhrase == "" {
thresholdPhrase = fmt.Sprintf("%d seconds", threshold)
}
base := fmt.Sprintf("this room requires approval when the booking exceeds %s", thresholdPhrase)
if eventDurationSec > 0 {
currentPhrase := formatDurationSeconds(strconv.FormatInt(eventDurationSec, 10))
if currentPhrase == "" {
currentPhrase = fmt.Sprintf("%d seconds", eventDurationSec)
}
if eventDurationSec >= threshold {
base += fmt.Sprintf(" (current duration is %s)", currentPhrase)
} else {
// Server flagged approval but our duration reads as below the
// threshold — surface both so the agent can reconcile rather
// than guess.
base += fmt.Sprintf(" (current duration reads as %s; server still flagged approval)", currentPhrase)
}
}
return base
default:
return "this room requires approval before it can be booked"
}
}
// roomLabel renders the room identifier for the block message. When the API
// returns a human-readable name it becomes `<room_id>[<room_name>]`; a blank
// name (or an entirely blank id, defensive) degrades to whichever is present
// so agents can still address the room. The room_id is kept as the primary
// identifier because callers act on it programmatically. Square brackets are
// used (rather than parentheses) so a room name that itself contains
// parentheses — e.g. "Room A (west wing)" — doesn't produce ambiguous nesting
// like `omm_1(Room A (west wing))`.
func roomLabel(id, name string) string {
id = strings.TrimSpace(id)
name = strings.TrimSpace(name)
switch {
case id != "" && name != "":
return fmt.Sprintf("%s[%s]", id, name)
case id != "":
return id
default:
return name
}
}
// blockOnUnavailableRooms returns a typed validation error when any room in
// results is unavailable or requires approval, or nil when everything is
// bookable. The error text carries per-room reasons plus the retry command
// hint from the PRD. When the API returns a room_strategy for a blocked room,
// the relevant limit (max duration, latest bookable time, daily window, or
// daily release time) is appended after the reason so agents can relay it to
// the user without making a follow-up request. For a `during_requisition`
// block, the disabled period (from room_requisition) is appended if available;
// a "pick a different time or a different room" recovery clause is always
// appended so the message reads coherently whether or not exact bounds are
// known.
//
// `need_approval` results are treated as blocking (the CLI cannot submit an
// approval on the user's behalf, so silently PATCHing would surprise the
// user). The line uses room_approval_info + eventDurationSec to explain the
// mode ("all" / "over_duration"), the threshold, and — for over_duration —
// how the current booking compares. The shared "how do I actually recover
// from approval" clause is folded into the hint once (not per line), so
// several approval-required rooms don't repeat the same paragraph.
func blockOnUnavailableRooms(results []roomAvailability, eventDurationSec int64) error {
var blocked []roomAvailability
for _, r := range results {
if r.Status != "available" {
blocked = append(blocked, r)
}
}
if len(blocked) == 0 {
return nil
}
var lines []string
hasNeedApproval := false
for _, r := range blocked {
var reason string
switch r.Status {
case "need_approval":
hasNeedApproval = true
reason = approvalReasonHint(r.ApprovalInfo, eventDurationSec)
default:
reason = unavailableReasonHint(r.UnavailableReasonType)
}
line := fmt.Sprintf("%s: %s", roomLabel(r.RoomID, r.RoomName), reason)
if detail := strategyDetail(r.UnavailableReasonType, r.Strategy); detail != "" {
line += ", " + detail
}
if detail := requisitionDetail(r.UnavailableReasonType, r.Requisition); detail != "" {
line += ", " + detail
}
if r.UnavailableReasonType == "during_requisition" {
line += "; pick a different time or a different room"
}
lines = append(lines, line)
}
msg := "meeting room booking will fail after this event change:\n " + strings.Join(lines, "\n ")
hint := fmt.Sprintf("do NOT auto-retry: relay the room IDs and reasons above to the user and get explicit confirmation before re-running with --%s.",
flagSkipRoomCheck)
if hasNeedApproval {
hint += " Rooms flagged need_approval: the CLI cannot submit approvals; DO NOT auto-run any recovery — ask the user first, then pick one: (a) newly added room → after the user confirms and provides `approval_reason`, run `lark-cli calendar event.attendees create --as user`; (b) time/rrule change re-triggers approval on an existing room → ask the user to update through the client; (c) shorten the meeting below the threshold or pick a different room."
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", msg).WithHint("%s", hint)
}

File diff suppressed because it is too large Load Diff

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