Compare commits

..

52 Commits

Author SHA1 Message Date
zhengzhijie
8403e3082b feat(sheets): enable bitable sheet creation 2026-07-09 20:49:44 +08:00
zhengzhijie
34f16ad9c1 fix(sheets): hide bitable sheet creation 2026-07-09 20:49:05 +08:00
zhengzhijiej-tech
f057a0ed1c fix(sheets): pass spreadsheet token to changeset tool (#1839) 2026-07-09 17:59:20 +08:00
xiongyuanwen-byted
4dddb0b0e2 chore(sheets): suppress forbidigo on csv-put stdin pipe detection
os.Stdin.Stat is intentional here - pipe detection needs the real
process fd; IOStreams.In is a plain io.Reader without Stat. Clears the
lint failure left by the stdin-fallback commit.
2026-07-09 16:44:59 +08:00
xiongyuanwen-byted
4b2fec1e5b fix(common): steer rejected @file paths to stdin instead of cd
The absolute-path rejection hint said "cd to the target directory
first" - advice the lark-sheets skill explicitly tells agents not to
follow (it pollutes the working directory). The stdin-contention hint
also demonstrated @file with an absolute path, which would itself be
rejected.

- @file failures on stdin-capable flags now show the equivalent stdin
  invocation (--csv - < /tmp/x.csv)
- the path error recommends a relative path or stdin, not cd
- the stdin-contention example uses a relative @file path

Message-text only; no control-flow change for any domain.
2026-07-09 16:44:59 +08:00
xiongyuanwen-byted
c8abdee931 feat(sheets): make validation errors prescriptive for hot failure modes
Driven by the edit-eval-extra-35Q reports: ~70% of lark-cli sheets
errors were missing-required / JSON-shape / wrong-value classes whose
messages said what broke but not how to fix it, pushing agents into
--help / --print-schema probe loops.

- composite JSON shape errors inline a compact skeleton auto-generated
  from the schema (e.g. --cells -> [[{"value": ...}]]) when the type
  mismatch is shallow container confusion
- +batch-update: missing 'shortcut' shows the entry template; a
  disallowed shortcut inlines the full allow-list; exceeding the
  100-op cap says how many batches to split into; sub-op translator
  failures append the shortcut's complete input-key contract
- +table-put: dtypes/formats keys that miss every column call out the
  A1-letter habit and inline the declared column names; empty cells in
  a date-typed column name the three ways out
- schema enum errors suggest across casing, vocabulary aliases, and
  edit distance
2026-07-09 16:44:48 +08:00
xiongyuanwen-byted
7ee6705490 feat(sheets): add sheets-scoped flag ergonomics via PostMount
Two recovery loops from the edit-eval traces burn agent round-trips:
hallucinated flag names (--cols for --range) whose unknown-flag error
only points at --help, and enum values imported from CSS/Excel
vocabulary ("center" for the vertical alignment Lark spells "middle").

- unknown-flag errors now inline the full valid-flag list (semantic
  guesses aren't rankable by edit distance; kills the --help round trip)
- enum values with an unambiguous canonical form (casing, known alias)
  are normalized in place and the call proceeds; edit-distance typos
  stay errors with a did-you-mean hint and are never auto-applied

Both ride the existing PostMount composition (same pattern as
withTokenAlias), so the common framework is untouched and no other
domain's behavior shifts.
2026-07-09 16:44:33 +08:00
xiongyuanwen-byted
8d2c6c8d40 fix(sheets): sync flag input/enum fixes from sheet-skill-spec
上游修复 spec-table 的 Input/Enum 字符串惯例后重新生成:--widths/--heights 现在带
file/stdin 输入声明,+sheet-create --type 的枚举正确进入 flag defs 与文档。
2026-07-08 15:30:55 +08:00
xiongyuanwen-byted
33e6683c5d feat(sheets): add --widths / --heights map form for per-column/row sizes
从上游 sheet-skill-spec 同步:+cols-resize --widths / +rows-resize --heights 接收
JSON map(键为单行列或闭区间,值为像素或 "standard"/"auto"),CLI 按起始位置排序后
展开为一次原子 batch_update 的多个 resize_range 操作,多列不同宽 / 多行不同高一次
调用完成,不再需要 +batch-update。map 形态与 --range/--width/--height/--type 互斥,
不可作为 +batch-update 子操作嵌入(batch_update 不支持嵌套)。列宽 < 20px 拒绝并提示
Excel 字符单位换算(px ≈ 字符数×8+16);--print-schema --flag-name widths/heights
可查 schema。
2026-07-08 15:22:42 +08:00
xiongyuanwen-byted
b53d2b5330 docs(sheets): 更新 lark-sheets skill 版本至 3.0.2
将 SKILL.md 版本号从 3.0.1 升至 3.0.2,同步近期 sheets
命令改动(+rows-resize/+cols-resize 改 --height/--width、
+csv-put 支持 stdin 回退等)后的技能版本。
2026-07-08 11:22:21 +08:00
xiongyuanwen-byted
c9705ad44d feat(sheets): rework +rows-resize / +cols-resize to --height / --width
从上游 sheet-skill-spec 同步:+cols-resize 用 --width、+rows-resize 用 --height 直接给像素值,
--type 变为可选(省略等价于 pixel)。--type standard/auto 走非像素模式,不能与像素 flag 同传;
--type pixel 与 --width/--height 共存时视为等价形式。--size 已删除。
2026-07-07 16:06:39 +08:00
xiongyuanwen-byted
16738410ef feat(sheets): let +csv-put fall back to piped stdin when --csv is omitted
Agents routinely redirect a CSV into stdin but forget the `--csv -`, so
`+csv-put ... < data.csv` failed its first try on a missing --csv and cost
an extra round-trip (error, then --help, then retry).

Relax --csv's cobra required-gate in the shortcut's PostMount and install a
PreRunE that defaults an omitted --csv to "-" when stdin is a non-interactive
pipe, so the standard stdin-resolution path reads it. The pipe guard keeps an
interactive terminal from blocking on stdin, and a genuine miss (no piped
data) still surfaces csvPutInput's typed "--csv is required" instead of
cobra's bare "required flag(s) ... not set".

Scoped entirely to the sheets domain — no changes to the shared runner or the
flag schema.
2026-07-07 11:31:43 +08:00
xiongyuanwen-byted
0fbdb83f27 feat(sheets): drop pre-refactor aliases from sheets --help listing
The refactored + commands have been the default for over a month. Hide the
deprecated pre-refactor aliases from `sheets --help` via a custom cobra
usage template that skips the deprecated group. Aliases stay registered
and executable: their own `sheets <alias> --help` still shows the
(→ +new-command) pointer, unknown-subcommand suggestions still span them,
and execution still returns the _notice.
2026-07-06 13:23:36 +08:00
zhengzhijiej-tech
a0e56715c7 docs(sheets): sync chart schema and labels guidance (#1716)
* chore(sheets): update chart flag schema

* docs(sheets): clarify chart labels field is presence-toggle, not value-toggle

Synced from sheet-skill-spec. Chart labels (plotArea.plot.labels and per-series
labels) are toggled by object existence — passing labels at all turns data
labels on, even when value/category/series/percentage are all false (server
falls back to showing value). Models repeatedly try `{ value: false, category:
false, series: false }` to disable, which silently shows the value fallback.
The reference doc now spells out both directions: pass labels to show, omit
the whole labels field to hide.

Also picks up earlier spec-side drift not yet propagated:
- pivot-table reference: +pivot-list info return + overlap validation
- flag-defs: cell-matrix fan-out cap default 200000 -> 50000 (#1578)
2026-07-06 11:44:49 +08:00
wenzhuozhen
9a2a437fa0 Merge pull request #1734 from larksuite/feat/sheet-skill-optimize
docs: tighten formula verify workflow guidance
2026-07-06 11:09:56 +08:00
xiongyuanwen-byted
4d795429dc docs(sheets): warn that cells-clear --scope all is irreversibly destructive 2026-07-05 23:50:26 +08:00
xiongyuanwen-byted
97ca586181 docs(sheets): steer import-vs-append onto sheet-copy for existing workbooks 2026-07-05 22:49:53 +08:00
xiongyuanwen-byted
4d5a05db6c docs(sheets): warn that +csv-put silently coerces numeric-looking labels
Add guidance that +csv-put numericizes date-like/ID-like columns whose values are all digits (12.10 becomes 12.1 losing the trailing zero, 001 becomes 1 losing the leading zero); recommend +table-put with dtypes=object/datetime64 or +cells-set + number_format="@". Also fix the batch-update example to use sheet_name instead of sheet_id.
2026-07-05 01:14:46 +08:00
xiongyuanwen-byted
3955bbee8c feat(sheets): let typed writes style blank cells past the data extent
+workbook-create / +table-put apply cell_styles by writing them into the
in-memory matrix, whose size was fixed to the data (cols × rows). A style
range reaching past that extent was rejected as "outside the write range",
so blank cells (reserved regions, decorative headers, empty borders) could
not be styled on the typed --sheets path — only the untyped --values path
padded for it.

Pad the matrix down/right to cover every cell_styles range before applying
(empty cells appended for the uncovered positions), mirroring the --values
behavior. writeSheetData now derives the written width/range from the padded
matrix; both dry-run previews and sheetCreateDims account for the style
extent so the physical grid and the plan match Execute. Ranges above/left of
the write anchor stay rejected (the matrix only grows down/right).
2026-07-03 22:46:39 +08:00
wenzhuozhen
b70ca02067 docs: align formula verify refs with file names 2026-07-03 15:09:13 +08:00
wenzhuozhen
f2f0fe2be7 docs: tighten formula verify workflow guidance 2026-07-03 14:16:32 +08:00
xiongyuanwen-byted
a814c8cb43 fix(sheets): satisfy errs-no-bare-wrap forbidigo and errorlint rules from main
main introduced the errs-no-bare-wrap forbidigo rule and errorlint
coverage that flag 27 issues in existing sheets code after the merge:

- Replace direct *errs.ValidationError type assertions with errors.As
  in sheetsInputStatError and validateSheetMediaUploadFile so wrapped
  errors still match (errorlint).
- Type the embedded flag-schemas.json parse failure as an InternalError
  with cause; it reaches the user directly via --print-schema.
- Annotate genuine intermediate errors (recursive schema validator,
  batch sub-op raw type checks, A1 range/position parsers) with
  //nolint:forbidigo; every caller wraps them into typed flag
  validation errors.
2026-07-03 11:39:03 +08:00
xiongyuanwen-byted
f17b5b1708 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-07-03 11:15:37 +08:00
xiongyuanwen-byted
c378dbfae1 fix(sheets): replace undefined common.FlagErrorf with sheetsValidationForFlag
changesetRevisions called common.FlagErrorf, which does not exist,
breaking the build. Use sheetsValidationForFlag so the errors carry the
offending flag param like the rest of the sheets validation paths.

Also reword two doc comments in lark_sheet_history_revert.go that used
'' for an empty shell string: gofmt (Go 1.19+) rewrites '' in doc
comments to a curly quote, leaving the file permanently unformatted.
2026-07-03 11:14:30 +08:00
xiongyuanwen-byted
fa7e4ffede feat(sheets): accept local_office_ token prefix for image parent_type
The synthetic token prefix for imported office spreadsheets is being
renamed from fake_office_ to local_office_. Accept either prefix when
mapping a spreadsheet token to the drive media parent_type so image
uploads keep working across the rename (main package and backward
compat copy).
2026-07-03 11:14:21 +08:00
xiongyuanwen-byted
df1e7c01dc docs(sheets): dedupe +changeset-get flag def and skill reference entry 2026-07-03 10:22:55 +08:00
zhengzhijiej-tech
6857876d85 Feat/lark sheets develop wzz (#1719)
* feat(sheets): add +changeset-get shortcut for changeset review

Wrap the get_changeset read tool: fetch the raw changeset (edit actions)
between two versions to review whether an AI edit fulfilled the request.
--start-revision required, --end-revision optional (defaults to latest),
gap capped at 100. Adds flag-defs entry + regenerated gen, the ChangesetGet
shortcut + tests, and skill docs.

* feat(sheets): add +get-revision shortcut

Return a spreadsheet's current document revision without pulling the full
sub-sheet listing. +get-revision is a read-only derivative over
get_workbook_structure (the lightest read — token only, no range) that
projects the response down to the single revision field.

Adds flag-defs entries and a unit test for the projection helper.

* feat: 同步 spec 修改

* feat(sheets): rename +get-revision to +revision-get

* feat: 移除 ppe 环境请求头

---------

Co-authored-by: wenzhuozhen <wenzhuozhen@bytedance.com>
2026-07-02 20:03:28 +08:00
xiongyuanwen-byted
7cca3f39cd docs(sheets): tighten number-vs-text guidance and dedupe write-cells reference 2026-07-02 18:40:13 +08:00
xiongyuanwen-byted
f41e6c4d74 docs(sheets): type by data nature, add pre-write reference column and chart/cond-format/filter rows
- SKILL.md quick-reference: add a "read before acting" column pointing each
  intent at its reference doc; add chart / cond-format / filter rows.
- Reframe number-vs-text decision to follow the data's nature (measure vs
  identifier), not whether the current task happens to sort/sum; a
  leaderboard/report "display only" use does not make a percentage text.
- write-cells reference: mirror the same rule and the +cells-set fallback
  for layouts +table-put cannot express.
2026-07-02 13:41:09 +08:00
xiongyuanwen-byted
77dda3ddaa docs(sheets): clarify number-vs-text typing and copy-to-range template guidance in references 2026-07-01 16:22:33 +08:00
xiongyuanwen-byted
4318f57c12 docs(sheets): bump lark-sheets skill version to 3.0.1 2026-07-01 13:01:14 +08:00
xiongyuanwen-byted
082625d2f1 docs(sheets): clarify workbook-import over read-then-recreate in skill 2026-07-01 10:42:32 +08:00
xiongyuanwen-byted
906826d4a1 fix(sheets): lower cells-set --max-cells default to 50000 2026-07-01 10:42:31 +08:00
wenzhuozhen
aa1a065802 Merge pull request #1653 from larksuite/feat/sheet-history-revert
feat(sheets): add +history-list / +history-revert / +history-revert-status shortcuts
2026-06-30 15:22:44 +08:00
wenzhuozhen
017d752ed9 docs(sheets): sync history skill reference required badges from spec
Companion to commit 9fa73312 (transaction-id) and 6ca35b06
(history-version-id): the two flag tables in
skills/lark-sheets/references/lark-sheets-history.md still showed
'optional' even though the canonical contract — and shortcuts/sheets/data/
flag-defs.json — already moved to 'required'. The earlier syncs only
picked up the data file from spec; the skill markdown drift slipped
through. Pull in the spec-side regenerated reference (ee/sheet-skill-spec
@9ca814d) so the human-readable doc matches the wire contract.
2026-06-30 14:42:43 +08:00
wenzhuozhen
909f78ed58 fix(sheets): make +history-revert-status --transaction-id cobra-required (match +history-revert)
Companion to commit 6ca35b06: same gating model now applies to both history
receipts.
- shortcuts/sheets/lark_sheet_history_revert.go: transactionIDFlag.Required=true.
  Validate keeps a trim/empty-string guard for '--transaction-id ""'.
- shortcuts/sheets/data/flag-defs.json: +history-revert-status --transaction-id
  required: optional -> required (synced from sheet-skill-spec @9ca814d).
- shortcuts/sheets/flag_defs_gen.go: regenerated.
- shortcuts/sheets/lark_sheet_history_test.go:
  TestHistoryRevert_MissingRequiredFlag/+history-revert-status moved to the
  cobra "required flag(s)" text contract (the test rig invokes the shortcut
  via cmd.Execute, which sees the raw cobra error directly without the
  dispatcher's typed wrap). Drop now-unused `errors` and `errs` imports.

Validation:
- go test ./shortcuts/sheets/... PASS (sheets + backward)
- TestFlagsFor_EveryRegisteredCommandHasDefs: PASS
- TestFlagDefsGen_MatchesJSON: PASS
- TestHistoryRevert_MissingRequiredFlag (both subtests): PASS
2026-06-30 14:42:43 +08:00
wenzhuozhen
047f0675ac fix(sheets): make +history-revert --history-version-id cobra-required + revert max-cells default drift
Two issues surfaced during MR !37 review:

1) +history-revert --history-version-id requiredness was set as
   "optional" in the spec table (BE-2 fix dc5fe0ea) so cobra wouldn't
   block before Validate. Per upstream review the flag should be
   required-by-cobra so the user gets the standard "required flag(s)"
   gate immediately and the runtime contract matches the JSON shape.
   - shortcuts/sheets/lark_sheet_history_revert.go: historyVersionIDFlag
     now sets Required: true. Validate keeps a trim/empty-string guard
     so '--history-version-id ""' still fails as a typed
     *errs.ValidationError (cobra accepts empty strings as "set").
   - shortcuts/sheets/data/flag-defs.json: +history-revert
     --history-version-id required: optional -> required.
   - shortcuts/sheets/flag_defs_gen.go: regenerated.
   - shortcuts/sheets/lark_sheet_history_test.go:
     TestHistoryRevert_MissingRequiredFlag split into per-shortcut
     subtests; +history-revert asserts cobra's "required flag(s)"
     contract (raw err — the test rig calls cmd.Execute directly so it
     doesn't see the cmd dispatcher's typed envelope wrap);
     +history-revert-status keeps the typed *errs.ValidationError
     contract (its --transaction-id stays cobra-optional + Validate-enforced).

2) max-cells safety cap was accidentally rewritten from 200000 to
   50000 by the last sync from sheet-skill-spec (the spec canonical
   side fell out of date — fixed separately on the spec MR follow-up).
   Restore desc: "Safety cap; default 200000" / default: "200000" so
   +cells-get / +csv-get keep the documented cap.

Validation:
- go test ./shortcuts/sheets/...                                     PASS
- TestHistoryRevert_MissingRequiredFlag (both subtests)              PASS
- TestHistoryShortcuts_DryRun (incl. +history-list pagination case)  PASS
- TestFlagsFor_EveryRegisteredCommandHasDefs                         PASS
- TestFlagDefsGen_MatchesJSON                                        PASS
2026-06-30 14:42:42 +08:00
wenzhuozhen
983c6e72ec feat(sheets): +history-list --end-version for backward pagination
Spec follow-up sheet-history-revert: thread the history_list pagination
contract through the +history-list shortcut.

- shortcuts/sheets/lark_sheet_history_list.go:
  + --end-version (int, optional). Mapped to the tool input's `end_version`
    only when explicitly set (so the server treats absence as
    "first page / latest"), via runtime.Changed / runtime.Int (matches the
    +formula-verify --max-locations precedent).
  + Tip: pass next_end_version from the response on the next call;
    capture exits the pagination loop when the server omits the field.

- shortcuts/sheets/lark_sheet_history_test.go: + dry-run case asserting
  --end-version 12345 lands as input.end_version=12345 (post-JSON
  unmarshal float64).

- skills/lark-sheets/references/lark-sheets-history.md: synced from
  ee/sheet-skill-spec (commit 39c6b61). Adds the "倒序分页" caveat row +
  --end-version flag + pagination Examples line. Drops the internal
  MajorHistory.Version implementation detail per spec follow-up.

- shortcuts/sheets/data/flag-defs.json: synced from spec (+history-list
  +--end-version int optional).

- shortcuts/sheets/flag_defs_gen.go: regenerated via
  `go generate ./shortcuts/sheets/...`.

Companion changes:
- ee/sheet-skill-spec MR !37: spec-tables + tool-schemas pagination
  contract (commits 09e8604, 39c6b61).
- ee/sheet-facade-agg MR !1028: history_list tool plumbs end_version,
  emits next_end_version + has_more (omitted at earliest page),
  defaults PageSize=20 to datarpc.

Validation:
- go build ./shortcuts/sheets/...                 PASS
- go test ./shortcuts/sheets/...                  PASS (sheets + backward)
- TestHistoryShortcuts_DryRun (5 cases incl. new --end-version case): PASS
- TestHistoryRevert_MissingRequiredFlag:           PASS
- TestFlagsFor_EveryRegisteredCommandHasDefs:      PASS
- TestFlagDefsGen_MatchesJSON:                     PASS
2026-06-30 14:42:42 +08:00
wenzhuozhen
41101e8dad chore(sheets): sync lark-sheets-history reference from spec (BE-2 transaction-id)
Mirror the upstream BE-2 fix in canonical-spec/references/lark_sheet_history/
cli-reference.md: +history-revert-status now uses --transaction-id (taken from
the async receipt returned by +history-revert), and +history-revert's
--history-version-id flips required→optional (Validate enforces requiredness
at runtime).

This file is the only history-only delta from the upstream sheet-skill-spec
sync; the rest of skills/lark-sheets/ stays on the cli's newer baseline
(/wiki/ URL support, +cells-set-image / +float-image-create, etc.) to match
commit 8ae516db's history-only mirror policy.

Spec source companion change: feat/sheet-history-revert in
ee/sheet-skill-spec, canonical-spec/{tool-shortcut-map.json,references/
lark_sheet_history/cli-reference.md}.
2026-06-30 14:42:42 +08:00
wenzhuozhen
66d4cf9b49 fix(sheets): align history flag-defs with inline shortcuts (green TestFlagsFor)
TestFlagsFor_EveryRegisteredCommandHasDefs was RED: generated flag-defs drifted
from the hand-written history shortcuts.
- +history-revert-status: flag-defs had --history-version-id; the BE-2 fix switched
  the shortcut to --transaction-id. Updated the entry to transaction-id.
- +history-revert / -status --history-version-id were marked required="required",
  but the inline flags are cobra-optional (requiredness enforced in Validate).
  Set required="optional" to match. Regenerated flag_defs_gen.go.

NOTE: canonical source is sheet-skill-spec (BE-3); apply the same change upstream
or the next sync:cli will regress this.
2026-06-30 14:42:41 +08:00
wenzhuozhen
d2c010bda6 fix(sheets): +history-revert-status keys on --transaction-id, not version id
BE-2 gap surfaced by PPE E2E: +history-revert-status sent history_version_id,
but the facade-agg history_revert_status tool keys on transaction_id (the async
receipt returned by +history-revert), so it returned "[40400] transaction_id is
required". Give the status shortcut its own --transaction-id flag + input
(excel_id + transaction_id); revert keeps --history-version-id. Tests updated.
2026-06-30 14:42:41 +08:00
wenzhuozhen
1eb300d6ab chore(sheets): sync lark_sheet_history skill + flag defs from sheet-skill-spec (BE-3)
Synced artifacts for the history shortcuts from ee/sheet-skill-spec (SSOT),
landed surgically (history-only) to avoid regressing this branch's newer
skills/lark-sheets content:
- skills/lark-sheets/references/lark-sheets-history.md (new, mirrored).
- skills/lark-sheets/SKILL.md: + Lark Sheet History references-table row only.
- shortcuts/sheets/data/flag-defs.json: + 3 history shortcuts (additive; no existing entries touched).
- shortcuts/sheets/flag_defs_gen.go: regenerated via go generate ./shortcuts/sheets/...
  (this also resolves the pre-existing flag-defs/gen drift — TestFlagDefsGen_MatchesJSON now passes).

NOT a full mirror: the rest of skills/lark-sheets/ + flag-schemas.json on this
branch (feat/lark-sheets-develop) are NEWER than the sheet-skill-spec worktree's
canonical (e.g. /wiki/ URL support, schema_version 3). A wholesale sync:cli would
have reverted them, so only the history delta is taken here. Full re-sync should
happen once sheet-skill-spec canonical is realigned with this branch.

Validation: go generate clean; go test ./shortcuts/sheets/
(TestFlagDefsGen_MatchesJSON, TestHistory*) PASS.

Spec source: active@2acd94a24ac3f835357a274a02344f78435bcc1c39ad0d695ce587f0cbddfb21
2026-06-30 14:42:41 +08:00
wenzhuozhen
69ebac97c7 feat(sheets): add +history-list / +history-revert / +history-revert-status shortcuts
BE-1 + BE-2 (larksuite/cli lark-sheets) for spec sheet-history-revert.
Three thin callTool wrappers over facade-agg history tools, following the
existing sheets Validate/DryRun/Execute + --url/--spreadsheet-token(/--token)
locator convention:
- +history-list (read, history_list): passes the tool output through verbatim;
  facade-agg already does the minor_histories/4-field/RFC3339 transform.
- +history-revert (write, history_revert): --history-version-id required,
  enforced at Validate stage with a typed *errs.ValidationError (no request on
  missing); returns the async receipt.
- +history-revert-status (read, history_revert_status): polls in-progress /
  success / failure.

Flags declared inline (not via *_gen.go) — flag_defs_gen.go / data/flag-defs.json
are synced from sheet-skill-spec (BE-3) and must not be hand-edited.

Notes:
- history_revert / history_revert_status depend on facade-agg's downstream RPC
  wiring, a DEFERRED follow-up; the tools return a "not wired yet" guard today.
  These CLI wrappers are correct and go live when the backend follow-up lands.
  +history-list is fully functional now.
- TestFlagDefsGen_MatchesJSON fails on baseline (pre-existing BE-3 gen/json
  drift); resolves once BE-3 sync:cli regenerates flag defs for these shortcuts.

Validation: go build ./shortcuts/sheets/... PASS; new tests
(TestHistoryShortcuts_DryRun, TestHistoryRevert_MissingVersionID) PASS.

Spec source: active@2acd94a24ac3f835357a274a02344f78435bcc1c39ad0d695ce587f0cbddfb21
2026-06-30 14:42:40 +08:00
anunwu-byted
5323e8e444 Merge pull request #1638 from larksuite/feat/pivot-info
feat(pivot): lark-sheets pivot reference 补 +pivot-list info 说明与落点覆盖校验
2026-06-29 17:35:13 +08:00
wenzhuozhen
4ace5ca4da Merge pull request #1626 from larksuite/feat/sheets-formula-verify
feat(sheets): add +formula-verify shortcut for verify_formula tool
2026-06-29 17:30:00 +08:00
wenzhuozhen
3e3f1bbf3b feat(sheets): add +formula-verify shortcut for verify_formula tool
Wraps the new verify_formula read tool in a CLI shortcut so AI agents
can run write-then-zero-error verification end-to-end:

  lark-cli sheets +formula-verify --url <url>

Scans formulas + cell error states across one or more sub-sheets and
returns a JSON status report (success / errors_found / partial).
Aggregates all 7 Excel error categories (#REF! / #DIV/0! / #VALUE! /
#NAME? / #NULL! / #NUM! / #N/A) plus compile failures into one
envelope; the tool always reports every error in the scan window —
callers needing a subset filter the returned error_summary
client-side. The internal scan cap is hidden from callers; when it
trips the response sets has_more=true and includes a warning_message
asking the caller to narrow --range / split --sheet-id and continue.

Flags follow the lark-sheets convention:
- --url / --spreadsheet-token (XOR public)
- --sheet-id / --sheet-name (repeat or comma-separate; mutually
  exclusive)
- --range (repeatable A1)
- --max-locations (default 20)
- --exit-on-error (CI gate: status='errors_found' → exit 2 with
  failed_precondition)

Generated artifacts (skills/lark-sheets/{SKILL.md, references/
lark-sheets-formula-verify.md}, shortcuts/sheets/data/flag-defs.json,
shortcuts/sheets/flag_defs_gen.go) are mirrored from sheet-skill-spec
generated/ via 'npm run sync:cli'. shortcuts.go registers
FormulaVerify alongside the other lark_sheet_formula_verify skill
shortcuts so +formula-verify is discoverable from
'lark-cli sheets --help'.

Tests cover the dry-run wire shape (excel_id + sheet_ids/sheet_names/
ranges/max_locations packing), the read scope (invoke_read URL), the
mutually-exclusive selector validation, the non-positive
--max-locations guard, and the --exit-on-error status matrix
(success/partial/errors_found/unknown).
2026-06-29 16:43:55 +08:00
wuyanchun.anunwu
9d15b70179 feat(pivot): lark-sheets pivot reference 补 +pivot-list info 说明与落点覆盖校验
+pivot-list 返回 info(page_range/content_range/error_state 等):
1) 判断目标单元格在透视表内(改配置 +pivot-update)还是区域外(改值 +cells-set);
2) 透视表展开后会覆盖已有数据,落点强烈优先默认自动新建子表;
3) 创建后用 info.error_state / content_range 校验有没有覆盖/冲突。
2026-06-29 14:13:21 +08:00
zhengzhijiej-tech
a179900d53 perf(sheets): cap fan-out cell-matrix materialization to prevent OOM (#1578)
* perf(sheets): cap fan-out cell-matrix materialization to prevent OOM

The +cells-set-style / +dropdown-set / +cells-batch-set-style /
+dropdown-update shortcuts expand a single A1 range into a rows×cols
matrix of per-cell maps client-side (the backing set_cell_range tool
takes an explicit cells matrix). rangeDimensions() had no upper bound,
so a tiny input like "A1:Z100000" balloons into ~2.6M heap maps (~900MB,
doubled again by json.Marshal) and can OOM the process before the
request is even sent.

Add a 50000-cell safety cap (checkStampMatrixBudget) gating every
fan-out materialization point, matching the documented but never-wired
--max-cells default. Oversized ranges now fail fast with a clear
validation error instead of allocating. Also preallocate the per-op
slices now that the range count is known up front.

Adds benchmarks + a boundary test as regression guards.

* perf(sheets): cap table-put/batch fan-out materialization (siblings of the cell-matrix cap)

The single-range fan-out cap (maxStampMatrixCells) left three sibling
ingress paths uncapped, each able to materialize an unbounded matrix or
op set in memory before the request leaves:

- +table-put / +workbook-create --sheets/--values: buildSheetMatrix
  builds the whole rows×cols matrix before slicing it into per-write
  batches; tablePutMaxCellsPerWrite only bounds the batch size, not the
  total input. Add tablePayload.checkCellBudget (1M-cell guardrail),
  enforced in validate() and in buildValuesPayload (the --values path
  bypasses validate()).

- batch fan-out (+cells-batch-set-style / +dropdown-update): per-range
  checkStampMatrixBudget can't stop many ranges from summing past the
  cap. Add an aggregate cell budget (checkBatchStampBudget) and a shared
  maxBatchRanges (100) count cap in validateDropdownRanges — covering
  all fan-out commands and replacing the now-redundant +dropdown-delete
  count check.

- +batch-update: cap --operations at maxBatchOperations (100) in
  translateBatchOperations.

Adds boundary regression tests for each cap. go vet + gofmt clean; full
shortcuts/sheets + backward suites green.

* test(sheets): measure table-put matrix materialization cost

Add BenchmarkBuildSheetMatrix_* and TestTablePutMatrixPeakMemory mirroring
the fan-out probes. Confirms the +table-put/+workbook-create ingress has the
same OOM profile as the single-range stamp: 2.6M cells → ~917 MB / 5.3M allocs
(+875 MB resident heap) materialized before the first write — now rejected up
front by checkCellBudget.
2026-06-29 14:03:12 +08:00
zhengzhijiej-tech
646304a1c7 feat(sheets): add --type bitable to +sheet-create for creating bitable sub-sheets (#1520) 2026-06-29 11:42:01 +08:00
xiongyuanwen-byted
1870348fc9 docs(sheets): inline editing rules into SKILL.md and clarify flag descriptions
- Move cross-cutting editing rules and execution notes into the root
  SKILL.md and drop the now-redundant core-operations reference
- Clarify flag descriptions: offset must be explicit inside +batch-update,
  range prefixes written bare (no quotes), chart requires a dim index,
  untyped --values lose date/number types, ungroup level semantics
- Sync the corresponding reference docs
2026-06-29 10:35:10 +08:00
xiongyuanwen-byted
d46e3ccad2 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-06-27 22:43:31 +08:00
zhengzhijiej-tech
e3e5944c86 feat(sheets): support font_family in cell styles (#1549)
Add a font_family field to cell_styles so a cell's font name can be set
and read back through every style entry point:

- +cells-set (--cells JSON) and +cells-set-style / +cells-batch-set-style
  gain a font_family field / --font-family flat flag
- +workbook-create / +table-put --styles accept font_family in cell_styles
- +cells-get returns font_family

helpers.go buildCellStyleFromFlags reads the --font-family flag;
lark_sheet_workbook.go allows font_family in the --styles cell_styles
whitelist; data/ + skills/ are synced from sheet-skill-spec.
2026-06-25 11:33:53 +08:00
317 changed files with 8421 additions and 13965 deletions

View File

@@ -86,10 +86,8 @@ jobs:
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
- name: Run golangci-lint
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev="$QUALITY_GATE_CHANGED_FROM"
- name: Run source-contract lint guards (lintcheck)
- name: Run errs/ lint guards (lintcheck)
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
- name: Run lint module tests
run: go test -C lint ./... -count=1
script-test:
needs: fast-gate
@@ -265,19 +263,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build
- name: Run dry-run E2E tests
env:
@@ -285,28 +277,7 @@ jobs:
LARKSUITE_CLI_APP_ID: dry-run
LARKSUITE_CLI_APP_SECRET: dry-run
LARKSUITE_CLI_BRAND: feishu
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
E2E_DRY_ROOT_PACKAGE: ${{ steps.e2e_domains.outputs.dry_root_package }}
E2E_DRY_PACKAGES: ${{ steps.e2e_domains.outputs.dry_packages }}
run: |
if [ "$E2E_MODE" = "skip" ]; then
echo "No dry-run CLI E2E needed: $E2E_REASON"
exit 0
fi
if [ -z "$E2E_DRY_ROOT_PACKAGE" ] && [ -z "$E2E_DRY_PACKAGES" ]; then
echo "::error::No dry-run CLI E2E packages resolved for mode $E2E_MODE"
exit 1
fi
echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"
if [ -n "$E2E_DRY_ROOT_PACKAGE" ]; then
echo "Dry-run CLI E2E root package: $E2E_DRY_ROOT_PACKAGE"
go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"
fi
if [ -n "$E2E_DRY_PACKAGES" ]; then
echo "Dry-run CLI E2E packages: $E2E_DRY_PACKAGES"
go test -v -count=1 -timeout=5m $E2E_DRY_PACKAGES -run 'DryRun|Regression'
fi
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
e2e-live:
needs: [unit-test, lint, script-test, deterministic-gate]
@@ -321,22 +292,15 @@ jobs:
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build
- name: Configure bot credentials
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: |
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
@@ -346,24 +310,16 @@ jobs:
- name: Run CLI E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
run: |
if [ "$E2E_MODE" = "skip" ]; then
echo "No live CLI E2E needed: $E2E_REASON"
exit 0
fi
packages="$E2E_LIVE_PACKAGES"
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
if [ -z "$packages" ]; then
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
echo "No CLI E2E packages to test after exclusions."
exit 1
fi
echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"
echo "Live CLI E2E packages: $packages"
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
- name: Publish CLI E2E test report
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
if: ${{ !cancelled() }}
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: CLI E2E Tests

3
.gitignore vendored
View File

@@ -27,9 +27,6 @@ Thumbs.db
# Go
docs/ref
docs/
!tests/cli_e2e/docs/
!tests/cli_e2e/docs/*.go
!tests/cli_e2e/docs/*.md
vendor/

View File

@@ -2,89 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.68] - 2026-07-09
### Features
- **drive**: Strengthen lark-drive high-risk write operations and read-only recognition boundaries. (#1801)
- **slides**: add slides chart demo reference
### Bug Fixes
- register and consume --json shorthand for custom-format shortcuts (#1737)
- **drive**: abort push on parent sibling limit (#1813)
### Documentation
- require native charts in slide planning
- register knowledge organize workflow (#1828)
## [v1.0.67] - 2026-07-08
### Features
- **mail**: add message modify and trash shortcuts (#1567)
- support whiteboard file inputs in docs XML (#1784)
- **vc**: refine meeting-events output and reaction forwarding (#1674)
- **affordance**: usage guidance for shortcuts and per-command skills (#1793)
### Bug Fixes
- accept opaque wiki node tokens (#1789)
- **apps**: make db --environment optional, auto-select branch server-side (#1735)
- preserve original filename in multipart file upload (#1767)
### Documentation
- restore one-time authorization guidance in lark-apps skill (#1794)
### Misc
- e2e: harden CLI E2E retry, cleanup, and domain selection (#1709)
## [v1.0.66] - 2026-07-07
### Features
- support semantic recurring calendar operations (#1723)
- minute wait (#1768)
### Bug Fixes
- guide drive import concurrency conflicts (#1751)
- **calendar**: guide approval room booking fallback (#1637)
- support pnpm global installs in self-update (#1705)
- resolve schema against runtime metadata in plugin builds; gate cache overlay by version (#1764)
### Documentation
- tighten doc creation validation workflow (#1759)
- clarify success envelope contract — judge success by ok, not code (#1730)
### Refactoring
- **envvars**: consolidate agent env value access (#1757)
### Misc
- Improve agent-facing error guidance for drive, markdown, and wiki (#1779)
## [v1.0.65] - 2026-07-03
### Features
- **doc**: Add `+history-list`, `+history-revert`, and `+history-revert-status` shortcuts for document version history (#1612)
### Bug Fixes
- **minutes**: `+speaker-replace` no longer refetches the speaker list — `--from-speaker-id` is passed through as-is (#1731)
### Documentation
- **drive**: Document 30-char query limit for `+search` (#1560)
- **doc**: Add mindnote guidance to lark-doc skill (#1581)
- **doc**: Sync lark-doc skill content from online-doc (#1701)
## [v1.0.64] - 2026-07-02
### Features
@@ -1438,10 +1355,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.68]: https://github.com/larksuite/cli/releases/tag/v1.0.68
[v1.0.67]: https://github.com/larksuite/cli/releases/tag/v1.0.67
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61

View File

@@ -51,7 +51,7 @@ script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
$(NODE) --test scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta

View File

@@ -233,24 +233,6 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
--format csv # Comma-separated values
```
### JSON Output Contract
With `--format json` (the default), success and error envelopes are distinct.
Success goes to **stdout**, exit code `0`:
```json
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
```
Errors go to **stderr**, non-zero exit code:
```json
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
```
To check whether a command succeeded, test `ok == true` (or the exit code) — **not** `code == 0`. Unlike raw OpenAPI responses (`{"code": 0, "msg": "ok", ...}`), the success envelope carries no `code` or `msg` field; `code` appears only inside `error` as the upstream OpenAPI code. See [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md) for the full error taxonomy.
### Pagination
```bash

View File

@@ -234,24 +234,6 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
--format csv # 逗号分隔值
```
### JSON 输出契约
`--format json`(默认)下,成功与错误的信封结构不同。
成功信封写入 **stdout**,退出码 0
```json
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
```
错误信封写入 **stderr**,退出码非 0
```json
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
```
判断命令是否成功,请检查 `ok == true`(或进程退出码),**不要用 `code == 0`**。与原始 OpenAPI 响应(`{"code": 0, "msg": "ok", ...}`)不同,成功信封没有 `code``msg` 字段;`code` 只出现在错误信封的 `error` 内,含义是上游 OpenAPI 的 numeric code。完整错误分类见 [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md)。
### 分页
```bash

View File

@@ -10,33 +10,18 @@ step. Maintain these files alongside `skills/` and `shortcuts/`.
A small, fixed markdown subset; each file describes one domain:
# <domain> optional `> skill: <name>` applies to every command below
## <command> the command as typed, minus `lark-cli <domain>`; a
+-prefixed heading (## +create) targets that shortcut
## <command> the command as typed, minus `lark-cli <domain>`
<lead paragraph> when to use this command
### Avoid when when not to use it / which command to use instead
### Prerequisites what you must have first (e.g. an id, and where it comes from)
### Tips gotchas and constraints
### Examples **description** lines, each followed by a fenced command
### Skills bullet skill names, or name/relpath references
(lark-contact/references/x.md), to read for usage;
merged with the domain `> skill:` default (deduped,
domain first)
### <other heading> a custom section; flows through verbatim
Reference another command with `[[command]]` — it renders as `command` in help.
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
("… from [[command]]") it means "get the input there first".
Both service-API commands (`## messages get`) and `+`-prefixed shortcuts
(`## +create`) take entries. A `### Skills` entry is a skill name (validated
against `<name>/SKILL.md`) or a `name/relpath` reference into that skill
(validated against the path); help drops any that don't resolve, so a typo shows
nothing. Point a command at its own reference (e.g. `+search-user`
`lark-contact/references/lark-contact-search-user.md`) rather than re-listing the
domain skill, which the `> skill:` default already covers. When a shortcut also
sets a hand-authored `Tips` list in Go, the overlay's `### Tips` win — they
replace the Go tips (not merged), so keep tips in one place.
## Example
## messages get
@@ -62,5 +47,3 @@ replace the Go tips (not merged), so keep tips in one place.
anything the schema and flags already show; the agent infers the rest.
- Command-form headings resolve to method ids via the registry, so plural resource
names (`messages`) map to the singular method id (`message`) automatically.
`+`-prefixed shortcut headings are matched verbatim (no plural/space folding),
so the heading must equal the shortcut command exactly (`## +history-revert`).

View File

@@ -1,42 +1,6 @@
# contact
> skill: lark-contact
## +search-user
The primary user lookup for user identity: search by keyword or email, resolve known ids with --user-ids, or get yourself with --user-ids me — it does by-id reads too, so as a user you rarely need `+get-user`. Each match returns an open_id and p2p_chat_id to chain into follow-ups.
### Skills
- lark-contact/references/lark-contact-search-user.md
### Avoid when
- Running as a bot — this shortcut is user-only; use [[+get-user]] instead (it supports bot identity)
- You only need users' personal status for ids you already hold → use [[user_profiles batch_query]]
### Examples
**Find a user by name**
```bash
lark-cli contact +search-user --query "alice" --as user
```
**Fetch known users by open_id (me = yourself)**
```bash
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
```
## +get-user
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.
### Skills
- lark-contact/references/lark-contact-get-user.md
### Avoid when
- You don't have the user's id yet, or want to match by name/keyword → use [[+search-user]]
- Running as a user — [[+search-user]] --user-ids covers by-id reads and more in one tool
### Tips
- Self lookup (omit --user-id) needs user identity; a bot must pass --user-id
- --user-id-type must match the id you pass (default open_id)
## user_profiles batch_query
Bulk-fetch personal status and signature for user ids you already have.

View File

@@ -4,14 +4,10 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"mime"
"mime/multipart"
"os"
"path/filepath"
"sort"
"strings"
"testing"
@@ -24,28 +20,13 @@ import (
"github.com/spf13/cobra"
)
func newTestApiCmd(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
cmd := NewCmdApi(f, runF)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
return cmd
}
func newTestRootCmd() *cobra.Command {
return &cobra.Command{
Use: "lark-cli",
SilenceErrors: true,
SilenceUsage: true,
}
}
func TestApiCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -73,7 +54,7 @@ func TestApiCmd_DryRun(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
err := cmd.Execute()
if err != nil {
@@ -96,7 +77,7 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("--params null with --page-size should not error, got: %v", err)
@@ -117,7 +98,7 @@ func TestApiCmd_BotMode(t *testing.T) {
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
err := cmd.Execute()
if err != nil {
@@ -144,7 +125,7 @@ func TestApiCmd_MissingArgs(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET"}) // missing path
err := cmd.Execute()
if err == nil {
@@ -157,7 +138,7 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
err := cmd.Execute()
if err == nil {
@@ -170,7 +151,7 @@ func TestApiValidArgsFunction(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
fn := cmd.ValidArgsFunction
tests := []struct {
@@ -236,7 +217,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
flag := cmd.Flags().Lookup("as")
if flag == nil {
t.Fatal("expected --as flag to be registered")
@@ -255,7 +236,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -274,7 +255,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
err := cmd.Execute()
if err == nil {
@@ -291,7 +272,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return apiRun(opts)
})
@@ -316,7 +297,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
ContentType: "application/octet-stream",
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
err := cmd.Execute()
if err != nil {
@@ -347,7 +328,7 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err != nil {
@@ -387,7 +368,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
err := cmd.Execute()
// Should return an error
@@ -428,7 +409,7 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err != nil {
@@ -467,7 +448,7 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err == nil {
@@ -502,7 +483,7 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -568,8 +549,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
},
})
root := newTestRootCmd()
root.AddCommand(newTestApiCmd(f, nil))
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -619,8 +600,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
},
})
root := newTestRootCmd()
root.AddCommand(newTestApiCmd(f, nil))
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -675,8 +656,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
},
})
root := newTestRootCmd()
root.AddCommand(newTestApiCmd(f, nil))
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := root.Execute()
if err == nil {
@@ -740,7 +721,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -760,7 +741,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -779,7 +760,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
@@ -810,7 +791,7 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
err := cmd.Execute()
if err != nil {
@@ -831,7 +812,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
@@ -849,7 +830,7 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
@@ -878,7 +859,7 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
err := cmd.Execute()
if err != nil {
@@ -899,7 +880,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -918,7 +899,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -936,7 +917,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
@@ -953,7 +934,7 @@ func TestApiCmd_FileWithGET(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
@@ -970,7 +951,7 @@ func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
@@ -993,7 +974,7 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
err := cmd.Execute()
if err != nil {
@@ -1034,7 +1015,7 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
err := cmd.Execute()
if err == nil {
@@ -1060,7 +1041,7 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -1073,157 +1054,3 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
t.Errorf("expected method GET, got %s", gotOpts.Method)
}
}
// parseMultipartFilenames drives one api --file upload through the mock
// transport and returns a map of field name -> part filename parsed from the
// captured multipart body, plus the map of text form fields. It fails the test
// if the captured request is not multipart/form-data.
func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) (map[string]string, map[string]string) {
t.Helper()
ct := stub.CapturedHeaders.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(ct)
if err != nil {
t.Fatalf("parse Content-Type %q: %v", ct, err)
}
if !strings.HasPrefix(mediaType, "multipart/") {
t.Fatalf("Content-Type = %q, want multipart/*", mediaType)
}
filenames := map[string]string{}
fields := map[string]string{}
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
for {
part, err := mr.NextPart()
if err != nil {
break
}
if fn := part.FileName(); fn != "" {
filenames[part.FormName()] = fn
} else {
buf := &bytes.Buffer{}
_, _ = buf.ReadFrom(part)
fields[part.FormName()] = buf.String()
}
}
return filenames, fields
}
func TestApiCmd_FileUpload_PreservesFilename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile(filepath.Join(dir, "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "invoice.pdf"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, _ := parseMultipartFilenames(t, stub)
if got := filenames["file"]; got != "invoice.pdf" {
t.Fatalf("part filename for field %q = %q, want %q", "file", got, "invoice.pdf")
}
}
func TestApiCmd_FileUpload_FieldPrefixKeepsBasename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0700); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "sub", "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "upload=sub/invoice.pdf"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, _ := parseMultipartFilenames(t, stub)
if _, ok := filenames["upload"]; !ok {
t.Fatalf("expected field name %q from field=path form, got fields %v", "upload", filenames)
}
if got := filenames["upload"]; got != "invoice.pdf" {
t.Fatalf("part filename for field %q = %q, want %q (basename only)", "upload", got, "invoice.pdf")
}
}
func TestApiCmd_FileUpload_WithDataFields(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile(filepath.Join(dir, "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot",
"--file", "invoice.pdf", "--data", `{"type":"attachment"}`})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, fields := parseMultipartFilenames(t, stub)
if got := filenames["file"]; got != "invoice.pdf" {
t.Fatalf("part filename = %q, want %q", got, "invoice.pdf")
}
if got := fields["type"]; got != "attachment" {
t.Fatalf("text field type = %q, want %q", got, "attachment")
}
}
func TestApiCmd_FileUpload_StdinFallsBackToUnknown(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
f.IOStreams.In = bytes.NewReader([]byte("stdin-bytes"))
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "-"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, _ := parseMultipartFilenames(t, stub)
if got := filenames["file"]; got != "unknown-file" {
t.Fatalf("stdin part filename = %q, want %q (no stable local name, fallback)", got, "unknown-file")
}
}

View File

@@ -25,10 +25,8 @@ import (
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
@@ -44,18 +42,6 @@ type buildConfig struct {
skipStrictMode bool
skipService bool
serviceCatalog *apicatalog.Catalog
startupBrand core.LarkBrand
}
// WithStartupBrand initializes the API registry with the given brand before
// any command registration touches the runtime catalog. Without it the
// registry's sync.Once locks onto the Feishu default at first catalog access,
// long before the lazily-resolved config brand is known — see
// ResolveStartupBrand for the caller-side resolution.
func WithStartupBrand(brand core.LarkBrand) BuildOption {
return func(c *buildConfig) {
c.startupBrand = brand
}
}
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
@@ -168,12 +154,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
cfg.streams = cmdutil.SystemIO()
}
// Initialize the registry brand before anything touches the runtime
// catalog (its sync.Once would otherwise lock onto the Feishu default).
if cfg.startupBrand != "" {
registry.InitWithBrand(cfg.startupBrand)
}
f := cmdutil.NewDefault(cfg.streams, inv)
if cfg.keychain != nil {
f.Keychain = cfg.keychain

View File

@@ -916,6 +916,25 @@ func TestReadDotenv_ValueWithEquals(t *testing.T) {
}
}
func TestNormalizeBrand(t *testing.T) {
tests := []struct {
input string
want string
}{
{"", "feishu"},
{"feishu", "feishu"},
{"lark", "lark"},
{"LARK", "lark"},
{" lark ", "lark"},
{"Lark", "lark"},
}
for _, tt := range tests {
if got := normalizeBrand(tt.input); got != tt.want {
t.Errorf("normalizeBrand(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestResolveOpenClawConfigPath_Overrides(t *testing.T) {
t.Run("OPENCLAW_CONFIG_PATH wins", func(t *testing.T) {
custom := filepath.Join(t.TempDir(), "custom.json")

View File

@@ -205,7 +205,7 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
return &core.AppConfig{
AppId: selected.AppID,
AppSecret: stored,
Brand: core.ParseBrand(selected.Brand),
Brand: core.LarkBrand(normalizeBrand(selected.Brand)),
}, nil
}
@@ -261,7 +261,7 @@ func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
return &core.AppConfig{
AppId: appID,
AppSecret: stored,
Brand: core.ParseBrand(b.envMap["FEISHU_DOMAIN"]),
Brand: core.LarkBrand(normalizeBrand(b.envMap["FEISHU_DOMAIN"])),
}, nil
}
@@ -326,7 +326,7 @@ func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
return &core.AppConfig{
AppId: appID,
AppSecret: stored,
Brand: core.ParseBrand(b.cfg.Accounts.App.Tenant),
Brand: core.LarkBrand(normalizeBrand(b.cfg.Accounts.App.Tenant)),
}, nil
}
@@ -350,6 +350,16 @@ func sourceDisplayName(source string) string {
}
}
// normalizeBrand applies .strip().lower() and defaults to "feishu".
// Aligns with Hermes gateway/platforms/feishu.py:1119 behavior.
func normalizeBrand(raw string) string {
s := strings.TrimSpace(strings.ToLower(raw))
if s == "" {
return "feishu"
}
return s
}
// resolveHermesEnvPath returns the path to Hermes's .env file.
// Respects HERMES_HOME override; defaults to ~/.hermes/.env.
//

View File

@@ -5,9 +5,7 @@ package config
import (
"context"
"errors"
"fmt"
"net"
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/internal/build"
@@ -182,9 +180,9 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
// Use the shared proxy-plugin-aware transport so registration traffic is not
// a bypass of proxy plugin mode.
httpClient := transport.NewHTTPClient(0)
authResp, err := larkauth.RequestAppRegistration(ctx, httpClient, larkBrand, f.IOStreams.ErrOut)
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
if err != nil {
return nil, classifyRegistrationBeginError(err)
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
}
// Step 2: Build and display verification URL + QR code
@@ -210,17 +208,33 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScanNonTTY)
}
// Step 4: Poll for credentials (brand discovery lives in internal/auth);
// this layer only classifies the terminal error and saves the result.
result, finalBrand, err := larkauth.RegisterAppWithDiscovery(ctx, httpClient, authResp, f.IOStreams.ErrOut)
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if err != nil {
return nil, classifyRegistrationError(err)
return nil, errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).WithCause(err)
}
// Step 4: Handle Lark brand special case
// If tenant_brand=lark and no client_secret, retry with lark brand endpoint
if result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
// fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant)
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "lark endpoint retry failed: %v", err).WithCause(err)
}
}
if result.ClientID == "" || result.ClientSecret == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret")
}
// Determine final brand from response
finalBrand := larkBrand
if result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
finalBrand = core.BrandLark
} else if result.UserInfo != nil && result.UserInfo.TenantBrand == "feishu" {
finalBrand = core.BrandFeishu
}
fmt.Fprintln(f.IOStreams.ErrOut)
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.AppCreated, result.ClientID))
@@ -231,40 +245,3 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
AppSecret: result.ClientSecret,
}, nil
}
// classifyRegistrationBeginError keeps transport/cancellation failures out of
// the invalid-client category: the begin request sends no app credentials.
func classifyRegistrationBeginError(err error) error {
switch {
case errors.Is(err, context.Canceled):
return errs.NewAuthenticationError(errs.SubtypeUnknown, "app registration cancelled").WithCause(err)
case errors.Is(err, context.DeadlineExceeded):
return errs.NewNetworkError(errs.SubtypeNetworkTimeout, "app registration begin timed out: %v", err).WithCause(err)
}
var netErr net.Error
if errors.As(err, &netErr) {
subtype := errs.SubtypeNetworkTransport
if netErr.Timeout() {
subtype = errs.SubtypeNetworkTimeout
}
return errs.NewNetworkError(subtype, "app registration begin failed: %v", err).WithCause(err)
}
return errs.NewAPIError(errs.SubtypeUnknown, "app registration begin failed: %v", err).WithCause(err)
}
// classifyRegistrationError maps registration terminal outcomes to typed
// errors, preserving causes.
func classifyRegistrationError(err error) error {
switch {
case errors.Is(err, larkauth.ErrRegistrationDenied):
return errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).
WithHint("re-run `lark-cli config init --new` and approve the authorization request").
WithCause(err)
case errors.Is(err, larkauth.ErrRegistrationExpired), errors.Is(err, larkauth.ErrRegistrationTimedOut):
return errs.NewAuthenticationError(errs.SubtypeTokenExpired, "%v", err).
WithHint("re-run `lark-cli config init --new` and complete the scan before the code expires").
WithCause(err)
default:
return errs.NewAuthenticationError(errs.SubtypeUnknown, "app registration failed: %v", err).WithCause(err)
}
}

View File

@@ -1,70 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"context"
"errors"
"net"
"testing"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
)
func assertRegistrationProblem(t *testing.T, got, cause error, category errs.Category, subtype errs.Subtype) *errs.Problem {
t.Helper()
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("error %T is not typed: %v", got, got)
}
if p.Category != category || p.Subtype != subtype {
t.Errorf("problem = (%q, %q), want (%q, %q)", p.Category, p.Subtype, category, subtype)
}
if !errors.Is(got, cause) {
t.Errorf("error %v does not preserve cause %v", got, cause)
}
return p
}
func TestClassifyRegistrationBeginError(t *testing.T) {
tests := []struct {
name string
err error
category errs.Category
subtype errs.Subtype
}{
{"cancelled", context.Canceled, errs.CategoryAuthentication, errs.SubtypeUnknown},
{"deadline", context.DeadlineExceeded, errs.CategoryNetwork, errs.SubtypeNetworkTimeout},
{"transport", &net.DNSError{Err: "lookup failed", Name: "accounts.example"}, errs.CategoryNetwork, errs.SubtypeNetworkTransport},
{"response", errors.New("response not JSON"), errs.CategoryAPI, errs.SubtypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assertRegistrationProblem(t, classifyRegistrationBeginError(tt.err), tt.err, tt.category, tt.subtype)
})
}
}
func TestClassifyRegistrationError(t *testing.T) {
tests := []struct {
name string
err error
subtype errs.Subtype
hint bool
}{
{"denied", larkauth.ErrRegistrationDenied, errs.SubtypeUnknown, true},
{"expired", larkauth.ErrRegistrationExpired, errs.SubtypeTokenExpired, true},
{"timed-out", larkauth.ErrRegistrationTimedOut, errs.SubtypeTokenExpired, true},
{"cancelled", context.Canceled, errs.SubtypeUnknown, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := assertRegistrationProblem(t, classifyRegistrationError(tt.err), tt.err, errs.CategoryAuthentication, tt.subtype)
if (p.Hint != "") != tt.hint {
t.Errorf("hint = %q, want non-empty=%v", p.Hint, tt.hint)
}
})
}
}

View File

@@ -107,7 +107,6 @@ func Execute() int {
ctx, inv,
WithIO(os.Stdin, os.Stdout, os.Stderr),
HideProfile(isSingleAppMode()),
WithStartupBrand(ResolveStartupBrand(inv.Profile)),
)
// --- Notices (non-blocking) ---
@@ -680,11 +679,7 @@ func installTipsHelpFunc(root *cobra.Command) {
defaultHelp(cmd, args)
return
}
if service.PrepareMethodHelp(cmd, embeddedSkillContent) {
defaultHelp(cmd, args)
return
}
if service.PrepareShortcutHelp(cmd, embeddedSkillContent) {
if service.PrepareMethodHelp(cmd) {
defaultHelp(cmd, args)
return
}

View File

@@ -14,13 +14,11 @@ import (
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/update"
@@ -105,11 +103,6 @@ func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
}
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
t.Helper()
return buildStrictModeIntegrationRootCmdWithCatalog(t, f, nil)
}
func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Factory, catalog *apicatalog.Catalog) *cobra.Command {
t.Helper()
rootCmd := &cobra.Command{Use: "lark-cli"}
rootCmd.SilenceErrors = true
@@ -120,11 +113,7 @@ func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Facto
}
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(api.NewCmdApi(f, nil))
if catalog != nil {
service.RegisterServiceCommandsFromCatalog(context.Background(), rootCmd, f, *catalog)
} else {
service.RegisterServiceCommands(rootCmd, f)
}
service.RegisterServiceCommands(rootCmd, f)
shortcuts.RegisterShortcuts(rootCmd, f)
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
pruneForStrictMode(rootCmd, mode)
@@ -132,29 +121,6 @@ func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Facto
return rootCmd
}
func strictModeFixtureCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{
{
Name: "fixture",
ServicePath: "/open-apis/fixture/v1",
Resources: map[string]meta.Resource{
"things": {
Methods: map[string]meta.Method{
"create": {
Path: "things",
HTTPMethod: "POST",
AccessTokens: []meta.Token{meta.TokenTenant},
RequestBody: map[string]meta.Field{
"name": {Type: "string"},
},
},
},
},
},
},
})
}
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
t.Setenv(envvars.CliAppID, "")
@@ -389,11 +355,10 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
catalog := strictModeFixtureCatalog()
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--dry-run",
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
})
if code != output.ExitValidation {

View File

@@ -65,13 +65,13 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
return cmd
}
// completeSchemaPath is a thin adapter over the schema catalog's Complete.
// It uses the same source as schema execution so completion candidates match
// what `schema` can resolve.
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
// It uses the embedded source so completion candidates match what `schema`
// execution can resolve (both overlay-free).
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
mode := f.ResolveStrictMode(cmd.Context())
completions, noSpace := registry.SchemaCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
directive := cobra.ShellCompDirectiveNoFileComp
if noSpace {
directive |= cobra.ShellCompDirectiveNoSpace
@@ -86,19 +86,13 @@ func schemaRun(opts *SchemaOptions) error {
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
}
// runSchema resolves the path through the schema catalog and renders the
// runSchema resolves the path through the embedded catalog and renders the
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
// output shape — a single resolved method renders as one envelope object,
// anything broader as an array — and maps resolve failures to hints.
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
catalog := registry.SchemaCatalog()
if len(catalog.Services()) == 0 {
// No embedded metadata and the runtime fallback is empty too: offline
// with a cold cache, remote meta off, or an unwritable cache dir.
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "No API metadata available").
WithHint("this binary has no embedded API metadata; run any command with network access to the open platform once so metadata can be fetched and cached")
}
catalog := registry.EmbeddedCatalog()
target, err := catalog.Resolve(parts)
if err != nil {
return resolveError(err)

View File

@@ -71,18 +71,11 @@ func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
}
// domainHelpBase returns the description to seed domain help with — the
// hand-authored Long when present, else the Short.
// hand-authored Long when present, else the Short — captured once into an
// annotation so re-rendering reuses the pristine text instead of the
// already-augmented Long.
func domainHelpBase(cmd *cobra.Command) string {
return captureHelpBase(cmd, domainBaseAnnotation)
}
// captureHelpBase records a command's pristine lead text once — its
// hand-authored Long, or Short when Long is empty — into the given annotation,
// so lazy re-renders compose onto the original text instead of onto an
// already-augmented Long. This is what lets a shortcut's PostMount-authored
// Long survive: it becomes the base the affordance block is appended below.
func captureHelpBase(cmd *cobra.Command, key string) string {
if base, ok := cmd.Annotations[key]; ok {
if base, ok := cmd.Annotations[domainBaseAnnotation]; ok {
return base
}
base := cmd.Long
@@ -92,7 +85,7 @@ func captureHelpBase(cmd *cobra.Command, key string) string {
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[key] = base
cmd.Annotations[domainBaseAnnotation] = base
return base
}
@@ -108,12 +101,12 @@ func methodLong(description, schemaPath, paramsOnly string) string {
}
// Annotation keys PrepareMethodHelp reads to rebuild a method command's Long.
// The affordance overlay coordinates live in cmdmeta (shared with shortcuts).
const (
schemaPathAnnotation = "method-schema-path"
paramsOnlyAnnotation = "method-params-only"
domainBaseAnnotation = "affordance-domain-base"
shortcutBaseAnnotation = "affordance-shortcut-base"
affordanceServiceAnnotation = "affordance-service"
affordanceMethodAnnotation = "affordance-method"
schemaPathAnnotation = "method-schema-path"
paramsOnlyAnnotation = "method-params-only"
domainBaseAnnotation = "affordance-domain-base"
)
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
@@ -122,7 +115,10 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmdmeta.SetAffordanceRef(cmd, service, methodID)
if service != "" && methodID != "" {
cmd.Annotations[affordanceServiceAnnotation] = service
cmd.Annotations[affordanceMethodAnnotation] = methodID
}
cmd.Annotations[schemaPathAnnotation] = schemaPath
if paramsOnly != "" {
cmd.Annotations[paramsOnlyAnnotation] = paramsOnly
@@ -132,11 +128,8 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
// PrepareMethodHelp rebuilds a generated method command's Long with the agent
// guidance at the TOP (Risk, then the affordance block, then the schema
// pointer), returning false for non-method commands. The overlay is parsed
// here — only when help is rendered. skillFS (nil-safe) gates the related-skill
// pointers: each is emitted only when it resolves in the skill tree (see
// affordance.SkillStatPath), so a typo or a build without embedded skills never
// prints a `skills read` that cannot be opened.
func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
// here — only when help is rendered.
func PrepareMethodHelp(cmd *cobra.Command) bool {
ann := cmd.Annotations
if ann == nil {
return false
@@ -148,15 +141,22 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
var b strings.Builder
b.WriteString(cmd.Short)
writeRisk(&b, cmd)
if level, ok := cmdutil.GetRisk(cmd); ok {
// --yes asserts the USER confirmed; the agent must not self-approve.
if level == cmdutil.RiskHighRiskWrite {
fmt.Fprintf(&b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
} else {
fmt.Fprintf(&b, "\n\nRisk: %s", level)
}
}
var skills []string
if raw, ok := affordanceRaw(cmd); ok {
if block := renderAffordance(meta.Method{Affordance: raw}); block != "" {
b.WriteString("\n\n")
b.WriteString(block)
}
if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok {
if block := renderAffordanceValue(a); block != "" {
b.WriteString("\n\n")
b.WriteString(block)
}
skills = a.Skills
}
}
@@ -164,93 +164,15 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
b.WriteString(ann[paramsOnlyAnnotation])
writeRelatedSkills(&b, skills, skillFS)
cmd.Long = b.String()
return true
}
// PrepareShortcutHelp composes a +-prefixed shortcut's Long from its affordance
// overlay — the same top layout as method help (description, Risk, guidance
// block, related skills) minus the schema pointer, which shortcuts have none
// of. Returns false when the command is not a shortcut or carries no overlay
// entry, so shortcuts without guidance keep the default help plus the bottom
// risk/tips append.
//
// The lead is the command's pristine base (captureHelpBase): a shortcut that
// set a hand-authored Long in PostMount (e.g. the docs shortcuts' "agents MUST
// read the skill" directive) keeps it — the affordance block is appended below,
// never clobbering it.
//
// Tips precedence (intentional, not a bug): the overlay's ### Tips win. The
// shortcut's declarative Tips (the Go Tips field) are only a fallback used when
// the overlay declares none; when the overlay has tips, the Go tips are dropped
// (replaced, not merged) so tips never render twice. Authoring a ### Tips block
// therefore silently retires that shortcut's Go Tips — consolidate into one.
func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceShortcut {
return false
}
raw, ok := affordanceRaw(cmd)
if !ok {
return false
}
a, ok := (meta.Method{Affordance: raw}).ParsedAffordance()
if !ok {
return false
}
if len(a.Tips) == 0 {
a.Tips = cmdutil.GetTips(cmd)
}
var b strings.Builder
b.WriteString(captureHelpBase(cmd, shortcutBaseAnnotation))
writeRisk(&b, cmd)
if block := renderAffordanceValue(a); block != "" {
b.WriteString("\n\n")
b.WriteString(block)
}
writeRelatedSkills(&b, a.Skills, skillFS)
cmd.Long = b.String()
return true
}
// writeRisk appends the "Risk: <level>" line, warning agents not to self-approve
// high-risk-write commands. A no-op when the command has no risk annotation.
func writeRisk(b *strings.Builder, cmd *cobra.Command) {
level, ok := cmdutil.GetRisk(cmd)
if !ok {
return
}
// --yes asserts the USER confirmed; the agent must not self-approve.
if level == cmdutil.RiskHighRiskWrite {
fmt.Fprintf(b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
} else {
fmt.Fprintf(b, "\n\nRisk: %s", level)
}
}
// writeRelatedSkills appends the "Related skills" block for the entries that
// exist in skillFS. Nothing is written when skillFS is nil or no entry resolves,
// so help never prints a `skills read` pointer that cannot be opened.
func writeRelatedSkills(b *strings.Builder, skills []string, skillFS fs.FS) {
if skillFS == nil || len(skills) == 0 {
return
}
var avail []string
for _, s := range skills {
if _, err := fs.Stat(skillFS, affordance.SkillStatPath(s)); err == nil {
avail = append(avail, s)
if len(skills) > 0 {
b.WriteString("\n\nWorkflow skill (end-to-end usage):")
for _, s := range skills {
fmt.Fprintf(&b, "\n lark-cli skills read %s", s)
}
}
if len(avail) == 0 {
return
}
b.WriteString("\n\nRelated skills (read for end-to-end usage):")
for _, s := range avail {
fmt.Fprintf(b, "\n lark-cli skills read %s", s)
}
cmd.Long = b.String()
return true
}
// affordanceLookup is the overlay source; a package var so tests can inject.
@@ -267,8 +189,12 @@ func RenderAffordanceForCmd(cmd *cobra.Command) string {
}
func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) {
service, methodID, ok := cmdmeta.AffordanceRef(cmd)
if !ok {
if cmd.Annotations == nil {
return nil, false
}
service := cmd.Annotations[affordanceServiceAnnotation]
methodID := cmd.Annotations[affordanceMethodAnnotation]
if service == "" || methodID == "" {
return nil, false
}
return affordanceLookup(service, methodID)
@@ -281,13 +207,7 @@ func renderAffordance(m meta.Method) string {
if !ok {
return ""
}
return renderAffordanceValue(a)
}
// renderAffordanceValue renders an already-parsed affordance. Split from
// renderAffordance so callers can render a value they have adjusted first (e.g.
// a shortcut folding its declarative tips into an overlay that has none).
func renderAffordanceValue(a meta.Affordance) string {
var sections []string
bullets := func(title string, items []string) {
var nonEmpty []string

View File

@@ -7,7 +7,6 @@ import (
"encoding/json"
"strings"
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
@@ -71,8 +70,8 @@ func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
t.Errorf("affordance must not be baked into Long (lazy):\n%s", cmd.Long)
}
// The lookup ref is recorded so the help path can resolve it later.
if svc, method, ok := cmdmeta.AffordanceRef(cmd); !ok || svc != "im" || method != "messages.create" {
t.Errorf("affordance ref = %q/%q (ok=%v), want im/messages.create", svc, method, ok)
if cmd.Annotations[affordanceServiceAnnotation] != "im" || cmd.Annotations[affordanceMethodAnnotation] != "messages.create" {
t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations)
}
}
@@ -120,7 +119,7 @@ func TestPrepareMethodHelp(t *testing.T) {
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
if !PrepareMethodHelp(cmd, nil) {
if !PrepareMethodHelp(cmd) {
t.Fatal("PrepareMethodHelp returned false for a service-method command")
}
long := cmd.Long
@@ -137,133 +136,11 @@ func TestPrepareMethodHelp(t *testing.T) {
}
// A non-service command (no schema-path annotation) is left untouched.
if PrepareMethodHelp(&cobra.Command{Use: "plain"}, nil) {
if PrepareMethodHelp(&cobra.Command{Use: "plain"}) {
t.Error("PrepareMethodHelp should return false for a non-service command")
}
}
// PrepareShortcutHelp composes a shortcut's Long from its overlay with the same
// top layout as method help (no schema pointer), folding declarative tips when
// the overlay declares none, and leaves shortcuts without an overlay entry (and
// non-shortcut commands) for the default help path.
func TestPrepareShortcutHelp(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(service, methodID string) (json.RawMessage, bool) {
if service == "calendar" && methodID == "+create" {
return json.RawMessage(`{"use_when":["高层创建日程"],"skills":["lark-calendar"]}`), true
}
return nil, false
}
sc := &cobra.Command{Use: "+create", Short: "Create an event"}
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
cmdmeta.SetAffordanceRef(sc, "calendar", "+create")
cmdutil.SetRisk(sc, "write")
cmdutil.SetTips(sc, []string{"start/end 收 ISO 8601"})
if !PrepareShortcutHelp(sc, nil) {
t.Fatal("PrepareShortcutHelp returned false for a shortcut with an overlay")
}
for _, want := range []string{"Create an event", "Risk: write", "When to use:", "高层创建日程", "Tips:", "start/end 收 ISO 8601"} {
if !strings.Contains(sc.Long, want) {
t.Errorf("shortcut Long missing %q:\n%s", want, sc.Long)
}
}
if strings.Contains(sc.Long, "Full parameter schema:") {
t.Errorf("shortcut Long must not carry a schema pointer:\n%s", sc.Long)
}
// No overlay entry -> leave it for the default help path.
bare := &cobra.Command{Use: "+bare", Short: "x"}
cmdmeta.SetSource(bare, cmdmeta.SourceShortcut, false)
cmdmeta.SetAffordanceRef(bare, "calendar", "+bare")
if PrepareShortcutHelp(bare, nil) {
t.Error("PrepareShortcutHelp should return false when the shortcut has no overlay")
}
// Non-shortcut source is ignored even with a ref.
notSc := &cobra.Command{Use: "create", Short: "x"}
cmdmeta.SetAffordanceRef(notSc, "calendar", "+create")
if PrepareShortcutHelp(notSc, nil) {
t.Error("PrepareShortcutHelp should return false for a non-shortcut command")
}
}
// Related-skill pointers are gated on existence: a skill that resolves in the
// skill FS renders, a typo is dropped (never print an unopenable `skills read`),
// and a nil skill FS suppresses the whole block.
func TestRelatedSkillsStatGating(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
return json.RawMessage(`{"use_when":["x"],"skills":["lark-real","lark-typo","lark-real/references/deep.md","lark-real/references/missing.md"]}`), true
}
skillFS := fstest.MapFS{
"lark-real/SKILL.md": {Data: []byte("# real")},
"lark-real/references/deep.md": {Data: []byte("# deep")},
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "d"}
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
if !PrepareMethodHelp(cmd, skillFS) {
t.Fatal("PrepareMethodHelp returned false")
}
if !strings.Contains(cmd.Long, "skills read lark-real\n") {
t.Errorf("existing bare-name skill should render on its own line; got:\n%s", cmd.Long)
}
if strings.Contains(cmd.Long, "lark-typo") {
t.Errorf("nonexistent skill must be dropped, not printed as an unopenable pointer; got:\n%s", cmd.Long)
}
// A name/relpath reference to an existing file renders; a missing one drops.
if !strings.Contains(cmd.Long, "skills read lark-real/references/deep.md") {
t.Errorf("existing reference entry should render; got:\n%s", cmd.Long)
}
if strings.Contains(cmd.Long, "references/missing.md") {
t.Errorf("nonexistent reference must be dropped; got:\n%s", cmd.Long)
}
// nil skill FS: the whole Related-skills block is suppressed.
bare := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
PrepareMethodHelp(bare, nil)
if strings.Contains(bare.Long, "Related skills") {
t.Errorf("nil skillFS should suppress the skills block; got:\n%s", bare.Long)
}
}
// A shortcut that set a hand-authored Long (as the docs shortcuts do in
// PostMount) keeps it as the lead: the affordance block is appended below, not
// clobbered, and re-rendering does not double-append.
func TestPrepareShortcutHelp_PreservesPostMountLong(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
return json.RawMessage(`{"use_when":["高层创建日程"]}`), true
}
const authored = "Custom docs help. AI agents MUST read the skill first."
sc := &cobra.Command{Use: "+create", Short: "Create", Long: authored}
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
cmdmeta.SetAffordanceRef(sc, "calendar", "+create")
if !PrepareShortcutHelp(sc, nil) {
t.Fatal("PrepareShortcutHelp returned false for a shortcut with an overlay")
}
if !strings.HasPrefix(sc.Long, authored) {
t.Errorf("hand-authored Long must lead, not be clobbered; got:\n%s", sc.Long)
}
if !strings.Contains(sc.Long, "When to use:") {
t.Errorf("affordance block should be appended below the base; got:\n%s", sc.Long)
}
// Re-render must reuse the captured base, not append the block twice.
PrepareShortcutHelp(sc, nil)
if n := strings.Count(sc.Long, "When to use:"); n != 1 {
t.Errorf("affordance appended %d times across re-renders, want 1:\n%s", n, sc.Long)
}
}
// domainCmd wires a domain-tagged command with a subcommand under a root, the
// shape PrepareDomainHelp expects.
func domainCmd(short, long string) *cobra.Command {

View File

@@ -4,14 +4,10 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"mime"
"mime/multipart"
"os"
"path/filepath"
"strings"
"testing"
@@ -1136,63 +1132,6 @@ func TestDetectFileFields(t *testing.T) {
}
}
// parseMultipartFilenames drives one service-method --file upload through the
// mock transport and returns a map of field name -> part filename parsed from
// the captured multipart body. Mirrors cmd/api's helper of the same name
// (inlined here rather than shared, since the two live in different packages)
// to give BuildFormdata's shared local-file fix a second real entry-point
// covering it.
func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) map[string]string {
t.Helper()
ct := stub.CapturedHeaders.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(ct)
if err != nil {
t.Fatalf("parse Content-Type %q: %v", ct, err)
}
if !strings.HasPrefix(mediaType, "multipart/") {
t.Fatalf("Content-Type = %q, want multipart/*", mediaType)
}
filenames := map[string]string{}
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
for {
part, err := mr.NextPart()
if err != nil {
break
}
if fn := part.FileName(); fn != "" {
filenames[part.FormName()] = fn
}
}
return filenames
}
func TestServiceMethod_FileUpload_PreservesFilename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, testConfig)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("fake-image"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/im/v1/images",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"image_key": "img_xxx"}},
}
reg.Register(stub)
cmd := NewCmdServiceMethod(f, imSpec(), imImageMethod(), "create", "images", nil)
cmd.SetArgs([]string{"--file", "photo.jpg", "--data", `{"image_type":"message"}`, "--as", "bot"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames := parseMultipartFilenames(t, stub)
if got := filenames["image"]; got != "photo.jpg" {
t.Fatalf("part filename for field %q = %q, want %q", "image", got, "photo.jpg")
}
}
func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)

View File

@@ -1,28 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"os"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
// ResolveStartupBrand resolves the brand before the command tree is built, so
// the registry's remote metadata overlay uses the configured brand from the
// first catalog access. It mirrors the credential chain's brand precedence —
// environment, then the active profile's raw config entry — without touching
// the keychain (no secrets are needed to know the brand).
func ResolveStartupBrand(profile string) core.LarkBrand {
if raw := os.Getenv(envvars.CliBrand); raw != "" {
return core.ParseBrand(raw)
}
if cfg, err := core.LoadMultiAppConfig(); err == nil {
if app := cfg.CurrentAppConfig(profile); app != nil {
return core.ParseBrand(string(app.Brand))
}
}
return core.BrandFeishu
}

View File

@@ -1,87 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/registry"
)
func TestResolveStartupBrand_Precedence(t *testing.T) {
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_BRAND", "")
os.Unsetenv("LARKSUITE_CLI_BRAND")
// No config at all → default brand.
if got := ResolveStartupBrand(""); got != core.BrandFeishu {
t.Errorf("empty state brand = %q, want feishu", got)
}
// Raw config supplies the active profile's brand — no keychain involved.
raw := `{"currentApp":"feishu-app","apps":[` +
`{"name":"feishu-app","appId":"cli_f","appSecret":"test-secret","brand":"feishu","users":[]},` +
`{"name":"lark-prof","appId":"cli_l","appSecret":"test-secret","brand":"LARK","users":[]}]}`
if err := os.WriteFile(filepath.Join(tmp, "config.json"), []byte(raw), 0600); err != nil {
t.Fatal(err)
}
if got := ResolveStartupBrand(""); got != core.BrandFeishu {
t.Errorf("default profile brand = %q, want feishu", got)
}
if got := ResolveStartupBrand("lark-prof"); got != core.BrandLark {
t.Errorf("lark profile brand = %q, want lark (normalized)", got)
}
// Environment wins over the config file.
t.Setenv("LARKSUITE_CLI_BRAND", "lark")
if got := ResolveStartupBrand(""); got != core.BrandLark {
t.Errorf("env brand = %q, want lark", got)
}
}
// TestStartupBrandReachesRegistry_RealStartupOrder proves the fix for the
// production startup sequence: building the command tree locks the registry's
// 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 os.Getenv("GO_TEST_STARTUP_BRAND_HELPER") == "1" {
// Helper: replicate Execute()'s build wiring with a lark config.
buildInternal(
context.Background(), cmdutil.InvocationContext{},
WithIO(strings.NewReader(""), os.Stdout, os.Stderr),
WithStartupBrand(ResolveStartupBrand("")),
)
fmt.Printf("CONFIGURED_BRAND=%s\n", registry.ConfiguredBrand())
os.Exit(0)
}
tmp := t.TempDir()
raw := `{"apps":[{"appId":"cli_l","appSecret":"test-secret","brand":"lark","users":[]}]}`
if err := os.WriteFile(filepath.Join(tmp, "config.json"), []byte(raw), 0600); err != nil {
t.Fatal(err)
}
cmd := exec.Command(os.Args[0], "-test.run", "TestStartupBrandReachesRegistry_RealStartupOrder")
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
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("subprocess failed: %v\n%s", err, out)
}
if !strings.Contains(string(out), "CONFIGURED_BRAND=lark") {
t.Errorf("registry brand after real startup order = %s, want lark", out)
}
}

View File

@@ -5,7 +5,6 @@ package cmdupdate
import (
"fmt"
stdio "io"
"runtime"
"strings"
@@ -14,7 +13,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/selfupdate"
"github.com/larksuite/cli/internal/skillscheck"
@@ -104,8 +102,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
Long: `Update lark-cli to the latest version.
Detects the installation method automatically:
- npm install: runs npm install -g @larksuite/cli@<version>
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
- npm install: runs npm install -g @larksuite/cli@<version>
- manual/other: shows GitHub Releases download URL
Use --json for structured output (for AI agents and scripts).
@@ -127,15 +124,13 @@ func updateRun(opts *UpdateOptions) error {
io := opts.Factory.IOStreams
cur := currentVersion()
updater := newUpdater()
// Brand only steers skills sync. updateRun skips that resolution in --check,
// where the Updater's zero-value brand retains the Feishu default.
if !opts.Check {
updater.Brand = resolveSkillsBrand(opts.Factory, io.ErrOut)
updater.CleanupStaleFiles()
}
output.PendingNotice = nil
// 1. Fetch latest version.
// 1. Fetch latest version
latest, err := fetchLatest()
if err != nil {
return reportError(opts, io, "network",
@@ -157,7 +152,7 @@ func updateRun(opts *UpdateOptions) error {
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
}
// 4. Detect installation method.
// 4. Detect installation method
detect := updater.DetectInstallMethod()
// 5. --check
@@ -169,23 +164,7 @@ func updateRun(opts *UpdateOptions) error {
if !detect.CanAutoUpdate() {
return doManualUpdate(opts, io, cur, latest, detect, updater)
}
return doAutoUpdate(opts, io, cur, latest, detect, updater)
}
// resolveSkillsBrand returns the skills-source brand: resolved config first,
// then the active profile's raw config entry (the brand is not a secret; a
// locked keychain must not flip the source), then the default with a notice.
func resolveSkillsBrand(f *cmdutil.Factory, errOut stdio.Writer) core.LarkBrand {
if cfg, err := f.Config(); err == nil && cfg != nil {
return core.ParseBrand(string(cfg.Brand))
}
if raw, err := core.LoadMultiAppConfig(); err == nil {
if app := raw.CurrentAppConfig(f.Invocation.Profile); app != nil {
return core.ParseBrand(string(app.Brand))
}
}
fmt.Fprintf(errOut, "note: could not resolve the configured brand; syncing skills from the default source\n")
return core.BrandFeishu
return doNpmUpdate(opts, io, cur, latest, updater)
}
// --- Output helpers ---
@@ -247,23 +226,12 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
if detect.Method == selfupdate.InstallPnpm {
fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n pnpm dlx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
} else {
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
}
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
emitSkillsTextHints(io, skillsResult)
return nil
}
func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
pm := "npm"
install := updater.RunNpmInstall
if detect.Method == selfupdate.InstallPnpm {
pm = "pnpm"
install = updater.RunPnpmInstall
}
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
restore, err := updater.PrepareSelfReplace()
if err != nil {
return reportError(opts, io, "update_error",
@@ -271,19 +239,19 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
}
if !opts.JSON {
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via %s ...\n", cur, symArrow(), latest, pm)
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
}
npmResult := install(latest)
npmResult := updater.RunNpmInstall(latest)
if npmResult.Err != nil {
restore()
combined := npmResult.CombinedOutput()
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "error": map[string]interface{}{
"type": "update_error", "message": fmt.Sprintf("%s install failed: %s", pm, npmResult.Err),
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
"detail": selfupdate.Truncate(combined, maxNpmOutput),
"hint": permissionHint(combined, pm),
"hint": permissionHint(combined),
},
})
return output.ErrBare(output.ExitAPI)
@@ -295,7 +263,7 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
}
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
if hint := permissionHint(combined, pm); hint != "" {
if hint := permissionHint(combined); hint != "" {
fmt.Fprintf(io.ErrOut, " %s\n", hint)
}
return output.ErrBare(output.ExitAPI)
@@ -306,7 +274,7 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
if err := updater.VerifyBinary(latest); err != nil {
restore()
msg := fmt.Sprintf("new binary verification failed: %s", err)
hint := verificationFailureHint(updater, latest, pm)
hint := verificationFailureHint(updater, latest)
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false,
@@ -336,33 +304,23 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
if skillsResult != nil {
skillsPM := "npx"
if detect.Method == selfupdate.InstallPnpm && detect.PnpmAvailable {
skillsPM = "pnpm dlx"
}
fmt.Fprintf(io.ErrOut, "\nUpdating skills via %s ...\n", skillsPM)
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
}
emitSkillsTextHints(io, skillsResult)
return nil
}
func permissionHint(pmOutput, pm string) string {
if !strings.Contains(pmOutput, "EACCES") || isWindows() {
return ""
func permissionHint(npmOutput string) string {
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
}
if pm == "pnpm" {
return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see https://pnpm.io/pnpm-cli"
}
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
return ""
}
func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string {
func verificationFailureHint(updater *selfupdate.Updater, latest string) string {
if updater.CanRestorePreviousVersion() {
return "the previous version has been restored"
}
if pm == "pnpm" {
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
}
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
}

View File

@@ -9,9 +9,7 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
@@ -59,27 +57,6 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
t.Cleanup(func() { newUpdater = origNew })
}
// mockDetectAndPnpm mirrors mockDetectAndNpm but wires the pnpm install path
// and fails the test if the npm install path is invoked.
func mockDetectAndPnpm(t *testing.T, result selfupdate.DetectResult, pnpmFn func(string) *selfupdate.NpmResult) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.PnpmInstallOverride = pnpmFn
u.NpmInstallOverride = func(string) *selfupdate.NpmResult {
t.Errorf("npm install must not be called for a pnpm install")
return &selfupdate.NpmResult{}
}
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
return func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
@@ -104,110 +81,6 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
}
}
func TestUpdatePnpm_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndPnpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out := stdout.String(); !strings.Contains(out, `"action": "updated"`) {
t.Errorf("expected updated in output, got: %s", out)
}
}
func TestUpdatePnpm_Human(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndPnpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "via pnpm") {
t.Errorf("expected 'via pnpm' in stderr, got: %s", out)
}
if !strings.Contains(out, "Updating skills via pnpm dlx ...") {
t.Errorf("expected skills sync to report pnpm dlx launcher, got: %s", out)
}
if !strings.Contains(out, "Successfully updated") {
t.Errorf("expected success message, got: %s", out)
}
}
func TestUpdatePnpm_InstallError_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndPnpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{Err: errors.New("pnpm boom")} },
)
err := cmd.Execute()
if err == nil {
t.Fatal("expected error exit")
}
if out := stdout.String(); !strings.Contains(out, `"ok": false`) || !strings.Contains(out, "update_error") {
t.Errorf("expected failure envelope, got: %s", out)
}
if out := stdout.String(); !strings.Contains(out, "pnpm install failed") {
t.Errorf("expected message to report pnpm as the package manager, got: %s", out)
}
}
func TestUpdatePnpm_Unavailable_ManualFallback(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: false})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "installed via pnpm, but pnpm is not available in PATH") {
t.Errorf("expected pnpm manual reason, got: %s", out)
}
if !strings.Contains(out, "pnpm add -g") {
t.Errorf("expected pnpm add -g hint, got: %s", out)
}
}
func TestNormalizeVersion(t *testing.T) {
tests := []struct {
input string
@@ -393,9 +266,6 @@ func TestUpdateNpm_Human(t *testing.T) {
if !strings.Contains(out, "Successfully updated") {
t.Errorf("expected success message in stderr, got: %s", out)
}
if !strings.Contains(out, "Updating skills via npx ...") {
t.Errorf("expected skills sync to report npx launcher for npm install, got: %s", out)
}
}
func TestUpdateForce_JSON(t *testing.T) {
@@ -869,9 +739,9 @@ func TestPermissionHint(t *testing.T) {
origOS := currentOS
defer func() { currentOS = origOS }()
// Linux + npm: EACCES should produce a hint with npm prefix guidance.
// Linux: EACCES should produce a hint with npm prefix guidance.
currentOS = "linux"
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'", "npm")
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'")
if !strings.Contains(hint, "npm global prefix") {
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
}
@@ -879,25 +749,16 @@ func TestPermissionHint(t *testing.T) {
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
}
// Linux + pnpm: EACCES should point at pnpm setup, not npm prefix/sudo.
pnpmHint := permissionHint("EACCES: permission denied, access '/Users/x/Library/pnpm'", "pnpm")
if !strings.Contains(pnpmHint, "pnpm setup") {
t.Errorf("expected pnpm setup hint, got: %s", pnpmHint)
}
if strings.Contains(pnpmHint, "npm global prefix") || strings.Contains(pnpmHint, "sudo") {
t.Errorf("pnpm hint must not reference npm prefix or sudo, got: %s", pnpmHint)
}
// Windows: EACCES hint is suppressed (no EACCES on Windows).
currentOS = "windows"
hint = permissionHint("EACCES: permission denied", "npm")
hint = permissionHint("EACCES: permission denied")
if hint != "" {
t.Errorf("expected empty hint on Windows, got: %s", hint)
}
// Non-EACCES error: always empty.
currentOS = "linux"
if got := permissionHint("some other error", "npm"); got != "" {
if got := permissionHint("some other error"); got != "" {
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
}
}
@@ -1733,64 +1594,3 @@ func containsString(values []string, target string) bool {
}
return false
}
func TestResolveSkillsBrand_LayeredFallback(t *testing.T) {
// Layer 1: resolved config wins.
var errBuf bytes.Buffer
f := &cmdutil.Factory{Config: func() (*core.CliConfig, error) {
return &core.CliConfig{Brand: core.LarkBrand(" LARK ")}, nil
}}
if got := resolveSkillsBrand(f, &errBuf); got != core.BrandLark {
t.Errorf("resolved-config brand = %q, want lark", got)
}
// Layer 2: credential resolution fails, raw config file still supplies the
// brand (a locked keychain must not flip a Lark profile to Feishu).
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
raw := `{"apps":[{"appId":"cli_x","appSecret":"test-secret","brand":"lark","users":[]}]}`
if err := os.WriteFile(filepath.Join(tmp, "config.json"), []byte(raw), 0600); err != nil {
t.Fatal(err)
}
f = &cmdutil.Factory{Config: func() (*core.CliConfig, error) { return nil, errors.New("keychain locked") }}
errBuf.Reset()
if got := resolveSkillsBrand(f, &errBuf); got != core.BrandLark {
t.Errorf("raw-config brand = %q, want lark", got)
}
if errBuf.Len() != 0 {
t.Errorf("unexpected notice when raw config supplied the brand: %q", errBuf.String())
}
// Layer 3: nothing readable → default brand with a notice.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
errBuf.Reset()
if got := resolveSkillsBrand(f, &errBuf); got != core.BrandFeishu {
t.Errorf("fallback brand = %q, want feishu", got)
}
if !strings.Contains(errBuf.String(), "could not resolve the configured brand") {
t.Errorf("expected fallback notice, got %q", errBuf.String())
}
}
// The raw-config fallback must read the active profile, not the default one.
func TestResolveSkillsBrand_RespectsActiveProfile(t *testing.T) {
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
raw := `{"currentApp":"feishu-app","apps":[` +
`{"name":"feishu-app","appId":"cli_f","appSecret":"test-secret","brand":"feishu","users":[]},` +
`{"name":"lark-prof","appId":"cli_l","appSecret":"test-secret","brand":"lark","users":[]}]}`
if err := os.WriteFile(filepath.Join(tmp, "config.json"), []byte(raw), 0600); err != nil {
t.Fatal(err)
}
f := &cmdutil.Factory{
Invocation: cmdutil.InvocationContext{Profile: "lark-prof"},
Config: func() (*core.CliConfig, error) { return nil, errors.New("keychain locked") },
}
var errBuf bytes.Buffer
if got := resolveSkillsBrand(f, &errBuf); got != core.BrandLark {
t.Errorf("brand = %q, want lark (the active profile's brand)", got)
}
if errBuf.Len() != 0 {
t.Errorf("unexpected notice: %q", errBuf.String())
}
}

View File

@@ -72,28 +72,6 @@ other category. `error.type` is `"policy"`, `error.subtype` is one of
`challenge_required` / `access_denied`, and process exit is `6` via
`CategoryPolicy`.
### Success envelope (stdout)
For contrast: success responses render to **stdout** as an
`output.Envelope` (`internal/output/envelope.go`), exit code `0`:
```json
{
"ok": true,
"identity": "user",
"data": { "guid": "e297d3d0-..." },
"meta": { "count": 1 }
}
```
Consumers must branch on `ok` (or the process exit code). The success
envelope has **no top-level `code` or `msg` field**`code` exists only
inside `error`, where it is the upstream numeric code (invariant 4).
Wrappers that follow the raw OpenAPI convention and test `code == 0`
will misclassify every successful call as a failure, which is
especially dangerous around write commands (e.g. retrying a create that
already succeeded).
## Categories
| Category | When | Exit | Typed struct |

View File

@@ -9,7 +9,6 @@ import (
"os"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
@@ -42,7 +41,10 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
Reason: envvars.CliAppID + " is set but no app secret or access token is available",
}
}
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
brand := credential.Brand(os.Getenv(envvars.CliBrand))
if brand == "" {
brand = credential.BrandFeishu
}
acct := &credential.Account{AppID: appID, AppSecret: appSecret, Brand: brand}
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {

View File

@@ -22,13 +22,13 @@ func TestProvider_Name(t *testing.T) {
func TestResolveAccount_BothSet(t *testing.T) {
t.Setenv(envvars.CliAppID, "cli_test")
t.Setenv(envvars.CliAppSecret, "secret_test")
t.Setenv(envvars.CliBrand, " LARK ")
t.Setenv(envvars.CliBrand, "feishu")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
}
if acct.AppID != "cli_test" || acct.AppSecret != "secret_test" || acct.Brand != "lark" {
if acct.AppID != "cli_test" || acct.AppSecret != "secret_test" || acct.Brand != "feishu" {
t.Errorf("unexpected: %+v", acct)
}
}

View File

@@ -16,7 +16,6 @@ import (
"os"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
)
@@ -59,7 +58,10 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
}
}
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
brand := credential.Brand(os.Getenv(envvars.CliBrand))
if brand == "" {
brand = credential.BrandFeishu
}
acct := &credential.Account{
AppID: appID,

View File

@@ -56,7 +56,7 @@ func TestResolveAccount_Active(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envvars.CliProxyKey, "test-key")
setEnv(t, envvars.CliAppID, "cli_test123")
setEnv(t, envvars.CliBrand, " LARK ")
setEnv(t, envvars.CliBrand, "lark")
unsetEnv(t, envvars.CliDefaultAs)
unsetEnv(t, envvars.CliStrictMode)

2
go.mod
View File

@@ -10,7 +10,7 @@ require (
github.com/gofrs/flock v0.8.1
github.com/google/uuid v1.6.0
github.com/itchyny/gojq v0.12.17
github.com/larksuite/oapi-sdk-go/v3 v3.7.2
github.com/larksuite/oapi-sdk-go/v3 v3.5.4
github.com/sergi/go-diff v1.4.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/smartystreets/goconvey v1.8.1

4
go.sum
View File

@@ -79,8 +79,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/larksuite/oapi-sdk-go/v3 v3.7.2 h1:SCIcXHRmtpQbiaZgDTDi1NYNCzrusi7ePJBR9uKoduE=
github.com/larksuite/oapi-sdk-go/v3 v3.7.2/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=

View File

@@ -77,15 +77,20 @@ func loadService(service string) map[string]json.RawMessage {
// space→dot fallback covers domains where the two already coincide.
func commandFormResolver(service string) func(string) string {
byForm := map[string]string{}
if svc, ok := registry.SchemaCatalog().Service(service); ok {
for _, svc := range registry.EmbeddedServicesTyped() {
if svc.Name != service {
continue
}
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
}
break
}
return func(h string) string {
if id, ok := byForm[strings.TrimSpace(h)]; ok {
h = strings.TrimSpace(h)
if id, ok := byForm[h]; ok {
return id
}
return headingToKey(h) // one home for the shortcut/method key convention
return strings.ReplaceAll(h, " ", ".")
}
}

View File

@@ -7,8 +7,6 @@ import (
"encoding/json"
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/meta"
)
// fixtureMD is a minimal affordance source: two methods, each with a lead
@@ -86,38 +84,3 @@ func TestParseDomainMD_ParagraphNotDropped(t *testing.T) {
t.Errorf("custom-section paragraph not flowed through: %+v", a.Extensions)
}
}
// The ### Skills section merges with the domain `> skill:` default: domain
// first, then per-command entries, de-duplicated. A command with no ### Skills
// still inherits the domain default.
func TestParseDomainMD_SkillsMerge(t *testing.T) {
md := "# d\n> skill: lark-d\n\n" +
"## foo\ndoes foo.\n\n### Skills\n- lark-workflow\n- lark-d\n\n" + // lark-d duplicates the domain default
"## bar\ndoes bar.\n"
got := parseDomainMD([]byte(md), nil)
if a := got["foo"]; len(a.Skills) != 2 || a.Skills[0] != "lark-d" || a.Skills[1] != "lark-workflow" {
t.Errorf("foo skills = %v, want [lark-d lark-workflow] (domain first, deduped)", a.Skills)
}
if a := got["bar"]; len(a.Skills) != 1 || a.Skills[0] != "lark-d" {
t.Errorf("bar skills = %v, want [lark-d] (domain default inherited)", a.Skills)
}
}
// A +-prefixed shortcut heading keys verbatim (no space->dot folding), so it
// matches the shortcut command as mounted.
func TestParseDomainMD_ShortcutHeadingVerbatim(t *testing.T) {
md := "# d\n\n## +create\ncreate via shortcut.\n"
got := parseDomainMD([]byte(md), nil)
if _, ok := got["+create"]; !ok {
t.Errorf("shortcut heading should key as %q; got keys %v", "+create", keysOf(got))
}
}
func keysOf(m map[string]meta.Affordance) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}

View File

@@ -19,7 +19,6 @@ import (
// ### Prerequisites -> prerequisites (a "…来自 [[x]]" link is a sequence edge)
// ### Tips -> tips
// ### Examples -> examples: **description** + a ```fenced``` command
// ### Skills -> skills: bullet skill names, added to the domain default
// ### <other> -> extensions[] (custom section, flows through verbatim)
// [[cmd]] -> a command reference, rendered as `cmd`
//
@@ -35,56 +34,16 @@ var standardSection = map[string]string{
"Prerequisites": "prerequisites",
"Tips": "tips",
"Examples": "examples",
"Skills": "skills",
}
// mergeSkills returns the domain-default skill followed by a command's own skill
// entries, de-duplicated in author order and empties dropped. Backticks (left by
// the shared bullet parse) are stripped so each entry is a bare skill name.
func mergeSkills(domain string, extra []string) []string {
var out []string
seen := map[string]bool{}
add := func(s string) {
s = strings.Trim(strings.TrimSpace(s), "`")
if s == "" || seen[s] {
return
}
seen[s] = true
out = append(out, s)
}
add(domain)
for _, s := range extra {
add(s)
}
return out
}
func linkToBacktick(s string) string { return mdLink.ReplaceAllString(s, "`$1`") }
// SkillStatPath maps a `### Skills` entry to the path (relative to the skill
// tree) whose existence gates it: a bare skill name resolves to its SKILL.md,
// while an entry containing a slash is a name/relative-path reference (e.g.
// "lark-contact/references/lark-contact-search-user.md") and resolves to that
// path directly. Both render as `lark-cli skills read <entry>` — the slash form
// skills read already accepts — so a per-command entry can point at that
// command's own reference file, not just re-point the domain skill.
func SkillStatPath(entry string) string {
if strings.Contains(entry, "/") {
return entry
}
return entry + "/SKILL.md"
}
// headingToKey maps a command heading ("instances get") to its affordance key
// ("instances.get"). The space→dot rule holds where the command form matches
// the method id; domains whose resource names differ (e.g. plural "messages"
// vs id segment "message") need the registry's authoritative resource↔id table.
func headingToKey(h string) string {
h = strings.TrimSpace(h)
if strings.HasPrefix(h, "+") { // shortcut command: key is the command verbatim
return h
}
return strings.ReplaceAll(h, " ", ".")
return strings.ReplaceAll(strings.TrimSpace(h), " ", ".")
}
type mdSection struct {
@@ -123,7 +82,6 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
if len(useWhen) > 0 {
a.UseWhen = useWhen
}
var perCmdSkills []string
for _, s := range secs {
switch standardSection[s.label] {
case "avoid_when":
@@ -134,14 +92,12 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
a.Tips = s.items
case "examples":
a.Examples = s.cases
case "skills":
perCmdSkills = s.items
default:
a.Extensions = append(a.Extensions, meta.AffordanceSection{Label: s.label, Items: s.items})
}
}
if s := mergeSkills(skill, perCmdSkills); len(s) > 0 {
a.Skills = s
if skill != "" {
a.Skills = []string{skill}
}
out[curKey] = a
}
@@ -201,7 +157,7 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
inFence, fence = true, nil
} else {
inFence = false
sec.cases = append(sec.cases, meta.AffordanceCase{Description: linkToBacktick(pending), Command: strings.Join(fence, "\n")})
sec.cases = append(sec.cases, meta.AffordanceCase{Description: pending, Command: strings.Join(fence, "\n")})
pending = ""
}
continue

View File

@@ -6,7 +6,6 @@ package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -17,46 +16,6 @@ import (
"github.com/larksuite/cli/internal/core"
)
// Terminal registration outcomes, exposed for typed classification by callers.
var (
ErrRegistrationDenied = errors.New("app registration denied by user")
ErrRegistrationExpired = errors.New("device code expired, please try again")
ErrRegistrationTimedOut = errors.New("app registration timed out, please try again")
)
// Protocol defaults, mirroring the official SDK registration flow.
const (
registrationBootstrapBrand = core.BrandFeishu
defaultPollIntervalSeconds = 5
defaultExpireInSeconds = 600
beginRequestTimeout = 30 * time.Second
maxPollIntervalSeconds = 60
)
// normalizedInterval clamps a non-positive poll interval to the protocol default.
func normalizedInterval(v int) int {
if v <= 0 {
return defaultPollIntervalSeconds
}
return v
}
// normalizedExpireIn clamps a non-positive expiry budget to the protocol default.
func normalizedExpireIn(v int) int {
if v <= 0 {
return defaultExpireInSeconds
}
return v
}
// registrationContextError maps a done context to its terminal reason, keeping the cause.
func registrationContextError(ctx context.Context) error {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return fmt.Errorf("%w: %w", ErrRegistrationTimedOut, ctx.Err())
}
return fmt.Errorf("app registration cancelled: %w", ctx.Err())
}
// AppRegistrationResponse is the response from the app registration begin endpoint.
type AppRegistrationResponse struct {
DeviceCode string
@@ -80,24 +39,15 @@ type AppRegUserInfo struct {
TenantBrand string // "feishu" or "lark"
}
// appRegistrationEndpoint returns the brand's accounts registration endpoint.
func appRegistrationEndpoint(brand core.LarkBrand) string {
return core.ResolveEndpoints(brand).Accounts + PathAppRegistration
}
// RequestAppRegistration initiates the device flow. The registration protocol
// always bootstraps on Feishu; brand selects the user-facing verification host.
// The request is bounded by ctx and a begin timeout.
func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) {
// RequestAppRegistration initiates the app registration device flow.
func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) {
if errOut == nil {
errOut = io.Discard
}
ctx, cancel := context.WithTimeout(ctx, beginRequestTimeout)
defer cancel()
ep := core.ResolveEndpoints(brand)
endpoint := appRegistrationEndpoint(registrationBootstrapBrand)
regEp := core.ResolveEndpoints(core.BrandFeishu) // registration begin always uses feishu
endpoint := regEp.Accounts + PathAppRegistration
form := url.Values{}
form.Set("action", "begin")
@@ -105,7 +55,7 @@ func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand
form.Set("auth_method", "client_secret")
form.Set("request_user_info", "open_id tenant_brand")
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(form.Encode()))
req, err := http.NewRequest("POST", endpoint, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
@@ -120,7 +70,7 @@ func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("app registration failed: read body: %w", err)
return nil, fmt.Errorf("app registration failed: read body: %v", err)
}
var data map[string]interface{}
@@ -140,26 +90,15 @@ func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand
return nil, fmt.Errorf("app registration failed: %s", msg)
}
// The protocol field is expire_in; accept the legacy expires_in spelling,
// then normalize to protocol defaults.
expiresIn := getInt(data, "expire_in", 0)
if expiresIn <= 0 {
expiresIn = getInt(data, "expires_in", 0)
}
expiresIn = normalizedExpireIn(expiresIn)
interval := normalizedInterval(getInt(data, "interval", 0))
deviceCode := getStr(data, "device_code")
if deviceCode == "" {
return nil, fmt.Errorf("app registration failed: response missing device_code")
}
expiresIn := getInt(data, "expires_in", 300)
interval := getInt(data, "interval", 5)
userCode := getStr(data, "user_code")
verificationUri := getStr(data, "verification_uri")
verificationUriComplete := fmt.Sprintf("%s/page/cli?user_code=%s", ep.Open, userCode)
return &AppRegistrationResponse{
DeviceCode: deviceCode,
DeviceCode: getStr(data, "device_code"),
UserCode: getStr(data, "user_code"),
VerificationUri: verificationUri,
VerificationUriComplete: verificationUriComplete,
@@ -179,97 +118,72 @@ func BuildVerificationURL(baseURL, cliVersion string) string {
"&from=cli"
}
// pollOnce performs one ctx-bound poll request and decodes the payload.
func pollOnce(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, deviceCode string) (map[string]interface{}, error) {
form := url.Values{}
form.Set("action", "poll")
form.Set("device_code", deviceCode)
req, err := http.NewRequestWithContext(ctx, "POST", appRegistrationEndpoint(brand), strings.NewReader(form.Encode()))
if err != nil {
return nil, fmt.Errorf("poll request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("poll network error: %w", err)
}
defer resp.Body.Close()
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("poll read error: %w", err)
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("poll parse error: %w", err)
}
return data, nil
}
// RegisterAppWithDiscovery polls for credentials, mirroring the official SDK
// flow: the first poll and the (at most one) cross-brand switch are immediate,
// non-error responses without complete credentials keep polling, and one
// deadline from the begin expiry bounds all waits and in-flight requests.
// The returned brand is the one the credentials were issued on.
func RegisterAppWithDiscovery(ctx context.Context, httpClient *http.Client, resp *AppRegistrationResponse, errOut io.Writer) (*AppRegistrationResult, core.LarkBrand, error) {
// PollAppRegistration polls the app registration endpoint until the app is created or the flow times out.
// If the result has ClientSecret == "" and UserInfo.TenantBrand == "lark", the caller should
// retry with BrandLark to get the secret from accounts.larksuite.com.
func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) (*AppRegistrationResult, error) {
if errOut == nil {
errOut = io.Discard
}
// Interval and expiry arrive normalized from begin-response parsing
// (normalizedInterval floors them there); the loop trusts them as-is.
interval := resp.Interval
ctx, cancel := context.WithDeadline(ctx,
time.Now().Add(time.Duration(resp.ExpiresIn)*time.Second))
defer cancel()
const maxPollInterval = 60
const maxPollAttempts = 200
currentBrand := registrationBootstrapBrand
effectiveBrand := currentBrand
switched := false
waitBeforePoll := false
ep := core.ResolveEndpoints(brand)
endpoint := ep.Accounts + PathAppRegistration
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)
currentInterval := interval
attempts := 0
for {
if waitBeforePoll {
select {
case <-time.After(time.Duration(interval) * time.Second):
case <-ctx.Done():
return nil, effectiveBrand, registrationContextError(ctx)
}
}
waitBeforePoll = true
for time.Now().Before(deadline) && attempts < maxPollAttempts {
attempts++
if ctx.Err() != nil {
return nil, effectiveBrand, registrationContextError(ctx)
return nil, fmt.Errorf("polling was cancelled")
}
data, err := pollOnce(ctx, httpClient, currentBrand, resp.DeviceCode)
select {
case <-time.After(time.Duration(currentInterval) * time.Second):
case <-ctx.Done():
return nil, fmt.Errorf("polling was cancelled")
}
form := url.Values{}
form.Set("action", "poll")
form.Set("device_code", deviceCode)
req, err := http.NewRequest("POST", endpoint, strings.NewReader(form.Encode()))
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: %v\n", err)
interval = minInt(interval+1, maxPollIntervalSeconds)
continue
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: poll network error: %v\n", err)
currentInterval = minInt(currentInterval+1, maxPollInterval)
continue
}
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: poll read error: %v\n", err)
currentInterval = minInt(currentInterval+1, maxPollInterval)
continue
}
// A cross-brand tenant report switches the polled domain (once,
// immediately) regardless of the accompanying status — the signal can
// arrive alongside authorization_pending, mirroring the official SDK.
if !switched {
if userInfoRaw, ok := data["user_info"].(map[string]interface{}); ok {
if tb := getStr(userInfoRaw, "tenant_brand"); tb != "" {
if actual := core.ParseBrand(tb); actual != currentBrand {
currentBrand = actual
effectiveBrand = actual
switched = true
waitBeforePoll = false
continue
}
}
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: poll parse error: %v\n", err)
currentInterval = minInt(currentInterval+1, maxPollInterval)
continue
}
errStr := getStr(data, "error")
if errStr == "" {
// Success: client_id present
if errStr == "" && getStr(data, "client_id") != "" {
result := &AppRegistrationResult{
ClientID: getStr(data, "client_id"),
ClientSecret: getStr(data, "client_secret"),
@@ -280,37 +194,34 @@ func RegisterAppWithDiscovery(ctx context.Context, httpClient *http.Client, resp
TenantBrand: getStr(userInfoRaw, "tenant_brand"),
}
}
if result.ClientID != "" && result.ClientSecret != "" {
// The issuing domain is authoritative; a contradictory final
// tenant report is a protocol violation, not a brand override.
if result.UserInfo != nil && result.UserInfo.TenantBrand != "" &&
core.ParseBrand(result.UserInfo.TenantBrand) != effectiveBrand {
return nil, effectiveBrand, fmt.Errorf("app registration returned credentials with a contradictory tenant brand %q", result.UserInfo.TenantBrand)
}
return result, effectiveBrand, nil
}
// Incomplete credentials without an error: keep polling.
continue
return result, nil
}
switch errStr {
case "authorization_pending":
continue
case "slow_down":
interval = minInt(interval+5, maxPollIntervalSeconds)
fmt.Fprintf(errOut, "[lark-cli] app-registration: slow_down, interval increased to %ds\n", interval)
currentInterval = minInt(currentInterval+5, maxPollInterval)
fmt.Fprintf(errOut, "[lark-cli] app-registration: slow_down, interval increased to %ds\n", currentInterval)
continue
case "access_denied":
return nil, effectiveBrand, ErrRegistrationDenied
return nil, fmt.Errorf("app registration denied by user")
case "expired_token", "invalid_grant":
return nil, effectiveBrand, ErrRegistrationExpired
return nil, fmt.Errorf("device code expired, please try again")
}
desc := getStr(data, "error_description")
if desc == "" {
desc = errStr
}
return nil, effectiveBrand, fmt.Errorf("app registration failed: %s", desc)
if desc == "" {
desc = "Unknown error"
}
return nil, fmt.Errorf("app registration failed: %s", desc)
}
if attempts >= maxPollAttempts {
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: max poll attempts (%d) reached\n", maxPollAttempts)
}
return nil, fmt.Errorf("app registration timed out, please try again")
}

View File

@@ -4,28 +4,11 @@
package auth
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/smartystreets/goconvey/convey"
)
// jsonResponse builds a canned registration response (transport fakes reuse
// roundTripFunc from device_flow_test.go).
func jsonResponse(body string) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}
}
// Test_BuildVerificationURL verifies that tracking parameters are correctly appended.
func Test_BuildVerificationURL(t *testing.T) {
t.Run("URL不含问号则添加?分隔符", func(t *testing.T) {
@@ -48,358 +31,3 @@ func Test_BuildVerificationURL(t *testing.T) {
})
})
}
func TestAppRegistrationEndpoint(t *testing.T) {
cases := []struct {
brand core.LarkBrand
want string
}{
{core.BrandFeishu, "https://accounts.feishu.cn" + PathAppRegistration},
{core.BrandLark, "https://accounts.larksuite.com" + PathAppRegistration},
}
for _, c := range cases {
if got := appRegistrationEndpoint(c.brand); got != c.want {
t.Errorf("brand %q: endpoint = %q, want %q", c.brand, got, c.want)
}
}
}
func TestRequestAppRegistration_UsesFeishuBootstrapAndConfiguredVerificationBrand(t *testing.T) {
cases := []struct {
brand core.LarkBrand
verificationHost string
}{
{core.BrandFeishu, "open.feishu.cn"},
{core.BrandLark, "open.larksuite.com"},
}
for _, c := range cases {
t.Run(string(c.brand), func(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
if got, want := r.URL.Host, "accounts.feishu.cn"; got != want {
t.Errorf("begin host = %q, want bootstrap host %q", got, want)
}
return jsonResponse(`{"device_code":"d","user_code":"TEST-CODE","expire_in":60,"interval":5}`), nil
})}
resp, err := RequestAppRegistration(context.Background(), client, c.brand, io.Discard)
if err != nil {
t.Fatalf("RequestAppRegistration(%q) error = %v", c.brand, err)
}
if !strings.HasPrefix(resp.VerificationUriComplete, "https://"+c.verificationHost+"/page/cli?") {
t.Errorf("verification URL = %q, want host %q", resp.VerificationUriComplete, c.verificationHost)
}
})
}
}
// Full Lark routing contract: Lark selects the Lark verification page, while
// registration bootstraps on Feishu and switches only after the tenant signal.
// The Lark credential response omits user_info, so the effective domain must
// still determine the saved brand.
func TestRegisterAppWithDiscovery_LarkFlowUsesProtocolBootstrap(t *testing.T) {
var calls []string
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
if err := r.ParseForm(); err != nil {
t.Fatalf("parse form: %v", err)
}
action := r.Form.Get("action")
calls = append(calls, action+"@"+r.URL.Host)
if action == "begin" {
return jsonResponse(`{"device_code":"device","user_code":"TEST-CODE","expire_in":60,"interval":0}`), nil
}
switch r.URL.Host {
case "accounts.feishu.cn":
return jsonResponse(`{"user_info":{"open_id":"ou_x","tenant_brand":"lark"}}`), nil
case "accounts.larksuite.com":
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret"}`), nil
}
t.Errorf("unexpected host polled: %s", r.URL.Host)
return jsonResponse(`{}`), nil
})}
resp, err := RequestAppRegistration(context.Background(), client, core.BrandLark, io.Discard)
if err != nil {
t.Fatalf("RequestAppRegistration error = %v", err)
}
if got, want := resp.VerificationUriComplete, "https://open.larksuite.com/page/cli?user_code=TEST-CODE"; got != want {
t.Errorf("verification URL = %q, want %q", got, want)
}
result, finalBrand, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
if err != nil {
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
}
if finalBrand != core.BrandLark {
t.Errorf("finalBrand = %q, want %q (credentials were issued on the lark domain)", finalBrand, core.BrandLark)
}
if result.ClientID != "cli_x" || result.ClientSecret != "test-secret" {
t.Errorf("credentials = (%q, %q), want (cli_x, test-secret)", result.ClientID, result.ClientSecret)
}
want := []string{"begin@accounts.feishu.cn", "poll@accounts.feishu.cn", "poll@accounts.larksuite.com"}
if len(calls) != len(want) {
t.Fatalf("calls = %v, want %v", calls, want)
}
for i := range want {
if calls[i] != want[i] {
t.Errorf("calls = %v, want %v", calls, want)
break
}
}
}
// Plain path: the bootstrap domain can return complete Feishu credentials in
// one poll, even when user_info is absent.
func TestRegisterAppWithDiscovery_BootstrapBrandSinglePoll(t *testing.T) {
polls := 0
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
polls++
if got, want := r.URL.Host, "accounts.feishu.cn"; got != want {
t.Errorf("poll host = %q, want %q", got, want)
}
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret"}`), nil
})}
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 5}
_, finalBrand, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
if err != nil {
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
}
if finalBrand != core.BrandFeishu {
t.Errorf("finalBrand = %q, want %q", finalBrand, core.BrandFeishu)
}
if polls != 1 {
t.Errorf("polls = %d, want 1", polls)
}
}
// The discovery deadline must cancel in-flight requests: the fake transport
// hangs until the request context is done.
func TestRegisterAppWithDiscovery_DeadlineBoundsInFlightRequests(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
<-r.Context().Done()
return nil, r.Context().Err()
})}
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 1}
start := time.Now()
_, _, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
if err == nil {
t.Fatal("expected timeout error, got nil")
}
if !strings.Contains(err.Error(), "timed out") {
t.Errorf("error = %v, want a timed-out terminal reason", err)
}
if elapsed := time.Since(start); elapsed > 3*time.Second {
t.Errorf("discovery not bounded by its deadline: took %v", elapsed)
}
}
// Empty payloads and incomplete same-brand responses are not terminal.
func TestRegisterAppWithDiscovery_PollsUntilCredentials(t *testing.T) {
responses := []string{
`{}`,
`{"client_id":"cli_x","user_info":{"open_id":"ou_x","tenant_brand":"feishu"}}`,
`{"client_id":"cli_x","client_secret":"test-secret","user_info":{"open_id":"ou_x","tenant_brand":"feishu"}}`,
}
polls := 0
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
body := responses[polls]
polls++
return jsonResponse(body), nil
})}
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 5}
result, finalBrand, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
if err != nil {
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
}
if polls != 3 {
t.Errorf("polls = %d, want 3", polls)
}
if result.ClientSecret != "test-secret" || finalBrand != core.BrandFeishu {
t.Errorf("result = (%q, %q), want (test-secret, feishu)", result.ClientSecret, finalBrand)
}
}
// Neither the first poll nor the cross-brand switch waits out the interval
// (a 5s interval would blow the elapsed bound).
func TestRegisterAppWithDiscovery_ImmediateFirstPollAndSwitch(t *testing.T) {
var polledHosts []string
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
polledHosts = append(polledHosts, r.URL.Host)
if r.URL.Host == "accounts.feishu.cn" {
return jsonResponse(`{"user_info":{"open_id":"ou_x","tenant_brand":"lark"}}`), nil
}
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret"}`), nil
})}
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 5, ExpiresIn: 60}
start := time.Now()
result, finalBrand, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
if err != nil {
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
}
if elapsed := time.Since(start); elapsed > 2*time.Second {
t.Errorf("discovery waited an interval somewhere: took %v", elapsed)
}
if finalBrand != core.BrandLark || result.ClientSecret != "test-secret" {
t.Errorf("result = (%q, %q), want (test-secret, lark)", result.ClientSecret, finalBrand)
}
want := []string{"accounts.feishu.cn", "accounts.larksuite.com"}
if len(polledHosts) != 2 || polledHosts[0] != want[0] || polledHosts[1] != want[1] {
t.Errorf("polled hosts = %v, want %v", polledHosts, want)
}
}
// Denial and expiry map to sentinels; cancellation preserves its cause.
func TestRegisterAppWithDiscovery_TerminalSentinels(t *testing.T) {
deny := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
return jsonResponse(`{"error":"access_denied"}`), nil
})}
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 5}
_, _, err := RegisterAppWithDiscovery(context.Background(), deny, resp, io.Discard)
if !errors.Is(err, ErrRegistrationDenied) {
t.Errorf("denied err = %v, want ErrRegistrationDenied", err)
}
expired := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
return jsonResponse(`{"error":"expired_token"}`), nil
})}
_, _, err = RegisterAppWithDiscovery(context.Background(), expired, resp, io.Discard)
if !errors.Is(err, ErrRegistrationExpired) {
t.Errorf("expired err = %v, want ErrRegistrationExpired", err)
}
cancelledCtx, cancel := context.WithCancel(context.Background())
cancel()
_, _, err = RegisterAppWithDiscovery(cancelledCtx, deny, resp, io.Discard)
if !errors.Is(err, context.Canceled) {
t.Errorf("cancelled err = %v, want a context.Canceled cause", err)
}
}
// Begin parsing: expire_in (legacy expires_in fallback), normalization, and
// required device_code.
func TestRequestAppRegistration_ProtocolFields(t *testing.T) {
serve := func(body string) *http.Client {
return &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
return jsonResponse(body), nil
})}
}
resp, err := RequestAppRegistration(context.Background(),
serve(`{"device_code":"d","expire_in":60,"interval":3}`), core.BrandFeishu, io.Discard)
if err != nil {
t.Fatalf("begin error = %v", err)
}
if resp.ExpiresIn != 60 || resp.Interval != 3 {
t.Errorf("parsed (expire=%d, interval=%d), want (60, 3)", resp.ExpiresIn, resp.Interval)
}
resp, err = RequestAppRegistration(context.Background(),
serve(`{"device_code":"d","expires_in":45}`), core.BrandFeishu, io.Discard)
if err != nil {
t.Fatalf("legacy begin error = %v", err)
}
if resp.ExpiresIn != 45 || resp.Interval != 5 {
t.Errorf("legacy parsed (expire=%d, interval=%d), want (45, 5 — normalized default)", resp.ExpiresIn, resp.Interval)
}
resp, err = RequestAppRegistration(context.Background(),
serve(`{"device_code":"d","interval":0}`), core.BrandFeishu, io.Discard)
if err != nil {
t.Fatalf("defaults begin error = %v", err)
}
if resp.ExpiresIn != 600 || resp.Interval != 5 {
t.Errorf("defaults parsed (expire=%d, interval=%d), want (600, 5)", resp.ExpiresIn, resp.Interval)
}
if _, err := RequestAppRegistration(context.Background(),
serve(`{"interval":5}`), core.BrandFeishu, io.Discard); err == nil {
t.Error("missing device_code: expected error, got nil")
}
}
// A tenant signal arriving alongside authorization_pending must still switch
// the polled domain (the official SDK checks the signal before the error).
func TestRegisterAppWithDiscovery_PendingWithTenantSignalSwitches(t *testing.T) {
var polledHosts []string
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
polledHosts = append(polledHosts, r.URL.Host)
if r.URL.Host == "accounts.feishu.cn" {
return jsonResponse(`{"error":"authorization_pending","user_info":{"tenant_brand":"lark"}}`), nil
}
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret"}`), nil
})}
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 5}
result, finalBrand, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
if err != nil {
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil", err)
}
if finalBrand != core.BrandLark || result.ClientSecret != "test-secret" {
t.Errorf("result = (%q, %q), want (test-secret, lark)", result.ClientSecret, finalBrand)
}
want := []string{"accounts.feishu.cn", "accounts.larksuite.com"}
if len(polledHosts) != 2 || polledHosts[0] != want[0] || polledHosts[1] != want[1] {
t.Errorf("polled hosts = %v, want %v", polledHosts, want)
}
}
// Polling has no attempt cap: only the expiry budget terminates the loop.
func TestRegisterAppWithDiscovery_NoAttemptCap(t *testing.T) {
polls := 0
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
polls++
if polls <= 250 {
return jsonResponse(`{"error":"authorization_pending"}`), nil
}
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret"}`), nil
})}
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 30}
result, _, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
if err != nil {
t.Fatalf("RegisterAppWithDiscovery error = %v, want nil (no attempts cap)", err)
}
if polls != 251 || result.ClientSecret != "test-secret" {
t.Errorf("polls = %d (want 251), secret = %q", polls, result.ClientSecret)
}
}
// A final tenant report contradicting the issuing domain is a protocol
// violation, not a brand override: the saved brand must never diverge from
// the domain that issued the credentials.
func TestRegisterAppWithDiscovery_ContradictoryFinalBrandFails(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Host == "accounts.feishu.cn" {
return jsonResponse(`{"error":"authorization_pending","user_info":{"tenant_brand":"lark"}}`), nil
}
// The lark domain issues credentials but reports a feishu tenant.
return jsonResponse(`{"client_id":"cli_x","client_secret":"test-secret","user_info":{"tenant_brand":"feishu"}}`), nil
})}
resp := &AppRegistrationResponse{DeviceCode: "device", Interval: 0, ExpiresIn: 5}
_, _, err := RegisterAppWithDiscovery(context.Background(), client, resp, io.Discard)
if err == nil || !strings.Contains(err.Error(), "contradictory tenant brand") {
t.Errorf("err = %v, want contradictory-tenant-brand protocol error", err)
}
}
// A cancelled body read during begin must keep its context cause so the
// command layer classifies it as a cancellation, not an API failure.
func TestRequestAppRegistration_BodyReadCancelKeepsCause(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(&errReader{err: context.Canceled}),
Header: make(http.Header),
}, nil
})}
_, err := RequestAppRegistration(context.Background(), client, core.BrandFeishu, io.Discard)
if !errors.Is(err, context.Canceled) {
t.Errorf("err = %v, want a context.Canceled cause", err)
}
}
type errReader struct{ err error }
func (r *errReader) Read([]byte) (int, error) { return 0, r.err }

View File

@@ -2,11 +2,9 @@
// SPDX-License-Identifier: MIT
// Package cmdmeta is the single source of truth for command metadata that the
// policy engine, the hook selector, and help rendering consume. It wraps the
// existing cmdutil annotations (risk_level, supportedIdentities) and adds the
// "domain" axis that the hook selector and Rule path globs need, plus the
// affordance ref (service, method id) that lets service-method and shortcut
// help share one usage-guidance lookup path.
// policy engine and the hook selector both consume. It wraps the existing
// cmdutil annotations (risk_level, supportedIdentities) and adds the
// "domain" axis that the hook selector and Rule path globs need.
//
// Three axes:
//
@@ -53,12 +51,6 @@ const (
sourceAnnotationKey = "cmdmeta.source"
generatedAnnotationKey = "cmdmeta.generated"
// affordance{Service,Method}Key locate the command's usage-guidance overlay
// entry (see internal/affordance). Both service-method commands and
// +-prefixed shortcuts set these so help rendering shares one lookup path.
affordanceServiceKey = "cmdmeta.affordance.service"
affordanceMethodKey = "cmdmeta.affordance.method"
)
// Meta groups the three command-level metadata axes consumed by the policy
@@ -133,35 +125,6 @@ func SetSource(cmd *cobra.Command, source Source, generated bool) {
}
}
// SetAffordanceRef records which affordance overlay entry (service, method id)
// a command maps to, so help rendering can look up its usage guidance. Stored
// on the command itself (no inheritance): each method / shortcut owns its ref.
// A no-op if either coordinate is empty.
func SetAffordanceRef(cmd *cobra.Command, service, method string) {
if service == "" || method == "" {
return
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[affordanceServiceKey] = service
cmd.Annotations[affordanceMethodKey] = method
}
// AffordanceRef returns the command's own affordance overlay coordinates.
// ok is false when the command carries no ref.
func AffordanceRef(cmd *cobra.Command) (service, method string, ok bool) {
if cmd.Annotations == nil {
return "", "", false
}
service = cmd.Annotations[affordanceServiceKey]
method = cmd.Annotations[affordanceMethodKey]
if service == "" || method == "" {
return "", "", false
}
return service, method, true
}
// Domain returns the nearest-ancestor domain for the command. Empty string
// when no ancestor has the annotation -- this is the "unknown" state the
// policy engine must treat as ALLOW.

View File

@@ -7,7 +7,6 @@ import (
"bytes"
"fmt"
"io"
"path/filepath"
"strconv"
"strings"
@@ -129,7 +128,7 @@ func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin boo
WithParam("--file").
WithCause(err)
}
fd.AddFileWithName(fieldName, filepath.Base(filePath), bytes.NewReader(data))
fd.AddFile(fieldName, bytes.NewReader(data))
}
// Add top-level JSON keys as text form fields.

View File

@@ -6,10 +6,12 @@ package cmdutil
import (
"context"
"net/http"
"os"
"reflect"
"runtime/debug"
"strings"
"sync"
"unicode"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
@@ -38,6 +40,8 @@ const (
BuildKindUnknown = "unknown"
officialModulePath = "github.com/larksuite/cli"
agentTraceMaxLen = 1024
)
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
@@ -45,6 +49,25 @@ func UserAgentValue() string {
return SourceValue + "/" + build.Version
}
// AgentTraceValue returns a header-safe value from the
// LARKSUITE_CLI_AGENT_TRACE environment variable. It trims
// surrounding whitespace, rejects values containing any Unicode
// control character or exceeding agentTraceMaxLen, and returns ""
// for any invalid or empty value. Callers can use the result
// directly in HTTP headers without further sanitisation.
func AgentTraceValue() string {
v := strings.TrimSpace(os.Getenv(envvars.CliAgentTrace))
if v == "" || len(v) > agentTraceMaxLen {
return ""
}
for _, r := range v {
if unicode.IsControl(r) {
return ""
}
}
return v
}
// BaseSecurityHeaders returns headers that every request must carry.
func BaseSecurityHeaders() http.Header {
h := make(http.Header)
@@ -52,7 +75,7 @@ func BaseSecurityHeaders() http.Header {
h.Set(HeaderVersion, build.Version)
h.Set(HeaderBuild, DetectBuildKind())
h.Set(HeaderUserAgent, UserAgentValue())
if v := envvars.AgentTrace(); v != "" {
if v := AgentTraceValue(); v != "" {
h.Set(HeaderAgentTrace, v)
}
return h

View File

@@ -6,6 +6,7 @@ package cmdutil
import (
"context"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/extension/credential"
@@ -263,9 +264,88 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
}
// ---------------------------------------------------------------------------
// HeaderAgentTrace injection (via BaseSecurityHeaders)
// AgentTraceValue / HeaderAgentTrace
// ---------------------------------------------------------------------------
func TestAgentTraceValue_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty when env unset", got)
}
}
func TestAgentTraceValue_ReturnsCleanValue(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "trace-abc-123")
if got := AgentTraceValue(); got != "trace-abc-123" {
t.Fatalf("AgentTraceValue() = %q, want %q", got, "trace-abc-123")
}
}
func TestAgentTraceValue_TrimsWhitespace(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
if got := AgentTraceValue(); got != "trace-trim" {
t.Fatalf("AgentTraceValue() = %q, want %q (whitespace trimmed)", got, "trace-trim")
}
}
func TestAgentTraceValue_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " ")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for whitespace-only value", got)
}
}
func TestAgentTraceValue_RejectsCRLF(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for CR/LF value", got)
}
}
func TestAgentTraceValue_RejectsLF(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for LF value", got)
}
}
func TestAgentTraceValue_RejectsTab(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\tinjected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for tab value", got)
}
}
func TestAgentTraceValue_RejectsControlChar(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\x01injected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for control char value", got)
}
}
func TestAgentTraceValue_RejectsDEL(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\x7finjected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for DEL value", got)
}
}
func TestAgentTraceValue_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentTraceMaxLen+1)
t.Setenv(envvars.CliAgentTrace, longVal)
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
}
}
func TestAgentTraceValue_AcceptsMaxLengthValue(t *testing.T) {
val := strings.Repeat("a", agentTraceMaxLen)
t.Setenv(envvars.CliAgentTrace, val)
if got := AgentTraceValue(); got != val {
t.Fatalf("AgentTraceValue() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
}
}
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
h := BaseSecurityHeaders()

View File

@@ -268,7 +268,7 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
ProfileName: app.ProfileName(),
AppID: app.AppId,
AppSecret: secret,
Brand: ParseBrand(string(app.Brand)),
Brand: app.Brand,
Lang: app.Lang,
DefaultAs: app.DefaultAs,
}

View File

@@ -230,20 +230,3 @@ func TestCliConfig_CanBot(t *testing.T) {
})
}
}
// Runtime configs must never carry raw brand casing: the config ingress
// normalizes it, so downstream equality checks see canonical values.
func TestResolveConfigFromMulti_NormalizesBrand(t *testing.T) {
multi := &MultiAppConfig{Apps: []AppConfig{{
AppId: "cli_x",
AppSecret: PlainSecret("test-secret"),
Brand: LarkBrand(" LARK "),
}}}
cfg, err := ResolveConfigFromMulti(multi, nil, "")
if err != nil {
t.Fatalf("ResolveConfigFromMulti error = %v", err)
}
if cfg.Brand != BrandLark {
t.Errorf("Brand = %q, want %q (normalized at ingress)", cfg.Brand, BrandLark)
}
}

View File

@@ -3,11 +3,9 @@
package core
import "strings"
// LarkBrand represents the Lark platform brand.
// "feishu" targets China-mainland, "lark" targets international.
// ParseBrand and ResolveEndpoints map unrecognized values to BrandFeishu.
// Any other string is treated as a custom base URL.
type LarkBrand string
const (
@@ -15,10 +13,10 @@ const (
BrandLark LarkBrand = "lark"
)
// ParseBrand normalizes a brand string (case-insensitive, whitespace-tolerant);
// anything other than "lark" normalizes to BrandFeishu.
// ParseBrand normalizes a brand string to a LarkBrand constant.
// Unrecognized values default to BrandFeishu.
func ParseBrand(value string) LarkBrand {
if strings.ToLower(strings.TrimSpace(value)) == "lark" {
if value == "lark" {
return BrandLark
}
return BrandFeishu
@@ -38,10 +36,9 @@ type Endpoints struct {
AppLink string // e.g. "https://applink.feishu.cn"
}
// ResolveEndpoints resolves endpoint URLs for the brand, normalizing its
// input so stored values with unusual casing still resolve correctly.
// ResolveEndpoints resolves endpoint URLs based on brand.
func ResolveEndpoints(brand LarkBrand) Endpoints {
switch ParseBrand(string(brand)) {
switch brand {
case BrandLark:
return Endpoints{
Open: "https://open.larksuite.com",

View File

@@ -57,37 +57,3 @@ func TestResolveOpenBaseURL(t *testing.T) {
t.Errorf("ResolveOpenBaseURL(lark) = %q", got)
}
}
func TestParseBrand(t *testing.T) {
cases := []struct {
in string
want LarkBrand
}{
{"", BrandFeishu},
{"feishu", BrandFeishu},
{"lark", BrandLark},
{"LARK", BrandLark},
{" lark ", BrandLark},
{"Lark", BrandLark},
{"xyz", BrandFeishu},
}
for _, c := range cases {
if got := ParseBrand(c.in); got != c.want {
t.Errorf("ParseBrand(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// TestResolveEndpoints_NormalizesBrand locks the boundary invariant: the
// resolver normalizes its brand input, so historical config values with
// unusual casing or whitespace still resolve to their intended endpoints.
func TestResolveEndpoints_NormalizesBrand(t *testing.T) {
for _, raw := range []string{"LARK", " lark ", "Lark"} {
if got := ResolveEndpoints(LarkBrand(raw)).Open; got != "https://open.larksuite.com" {
t.Errorf("ResolveEndpoints(%q).Open = %q, want the lark endpoint", raw, got)
}
}
if got := ResolveEndpoints(LarkBrand("unexpected")).Open; got != "https://open.feishu.cn" {
t.Errorf("ResolveEndpoints(unexpected).Open = %q, want the feishu default", got)
}
}

View File

@@ -72,8 +72,7 @@ func AccountFromCliConfig(cfg *core.CliConfig) *Account {
}
}
// ToCliConfig copies the credential-layer account into the downstream config
// shape, normalizing the brand so runtime consumers never see raw casing.
// ToCliConfig copies the credential-layer account into the downstream config shape.
func (a *Account) ToCliConfig() *core.CliConfig {
if a == nil {
return nil
@@ -82,7 +81,7 @@ func (a *Account) ToCliConfig() *core.CliConfig {
ProfileName: a.ProfileName,
AppID: a.AppID,
AppSecret: normalizeAccountAppSecret(a.AppSecret),
Brand: core.ParseBrand(string(a.Brand)),
Brand: a.Brand,
DefaultAs: a.DefaultAs,
UserOpenId: a.UserOpenId,
UserName: a.UserName,

View File

@@ -130,11 +130,3 @@ func TestRuntimeAppSecret_TokenOnlyUsesPlaceholder(t *testing.T) {
t.Fatalf("RuntimeAppSecret(real) = %q, want %q", got, "secret-1")
}
}
// The credential-layer ingress normalizes brand casing for all runtime consumers.
func TestToCliConfig_NormalizesBrand(t *testing.T) {
acct := &Account{AppID: "cli_x", Brand: " LARK "}
if got := acct.ToCliConfig().Brand; got != core.BrandLark {
t.Errorf("Brand = %q, want %q", got, core.BrandLark)
}
}

View File

@@ -19,7 +19,6 @@ const (
// Content safety scanning mode
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"

View File

@@ -1,36 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package envvars
import (
"os"
"strings"
"unicode"
)
const (
agentNameMaxLen = 128
agentTraceMaxLen = 1024
)
func AgentName() string {
return sanitizeSingleLine(os.Getenv(CliAgentName), agentNameMaxLen)
}
func AgentTrace() string {
return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen)
}
func sanitizeSingleLine(raw string, maxLen int) string {
v := strings.TrimSpace(raw)
if v == "" || len(v) > maxLen {
return ""
}
for _, r := range v {
if unicode.IsControl(r) {
return ""
}
}
return v
}

View File

@@ -1,131 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package envvars
import (
"strings"
"testing"
)
func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(CliAgentName, "")
if got := AgentName(); got != "" {
t.Fatalf("AgentName() = %q, want empty when env unset", got)
}
}
func TestAgentName_ReturnsCleanValue(t *testing.T) {
t.Setenv(CliAgentName, "claude-code")
if got := AgentName(); got != "claude-code" {
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
}
}
func TestAgentName_TrimsWhitespace(t *testing.T) {
t.Setenv(CliAgentName, " cursor ")
if got := AgentName(); got != "cursor" {
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
}
}
func TestAgentName_RejectsCRLFInjection(t *testing.T) {
t.Setenv(CliAgentName, "agent\r\nX-Evil: attack")
if got := AgentName(); got != "" {
t.Fatalf("AgentName() = %q, want empty for CR/LF value", got)
}
}
func TestAgentName_RejectsControlChar(t *testing.T) {
t.Setenv(CliAgentName, "agent\x01injected")
if got := AgentName(); got != "" {
t.Fatalf("AgentName() = %q, want empty for control char value", got)
}
}
func TestAgentName_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentNameMaxLen+1)
t.Setenv(CliAgentName, longVal)
if got := AgentName(); got != "" {
t.Fatalf("AgentName() returned non-empty for %d-byte value (max %d)", len(longVal), agentNameMaxLen)
}
}
func TestAgentTrace_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(CliAgentTrace, "")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty when env unset", got)
}
}
func TestAgentTrace_ReturnsCleanValue(t *testing.T) {
t.Setenv(CliAgentTrace, "trace-abc-123")
if got := AgentTrace(); got != "trace-abc-123" {
t.Fatalf("AgentTrace() = %q, want %q", got, "trace-abc-123")
}
}
func TestAgentTrace_TrimsWhitespace(t *testing.T) {
t.Setenv(CliAgentTrace, " trace-trim ")
if got := AgentTrace(); got != "trace-trim" {
t.Fatalf("AgentTrace() = %q, want %q (whitespace trimmed)", got, "trace-trim")
}
}
func TestAgentTrace_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
t.Setenv(CliAgentTrace, " ")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for whitespace-only value", got)
}
}
func TestAgentTrace_RejectsCRLF(t *testing.T) {
t.Setenv(CliAgentTrace, "val\r\nX-Evil: attack")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for CR/LF value", got)
}
}
func TestAgentTrace_RejectsLF(t *testing.T) {
t.Setenv(CliAgentTrace, "val\nX-Evil: attack")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for LF value", got)
}
}
func TestAgentTrace_RejectsTab(t *testing.T) {
t.Setenv(CliAgentTrace, "val\tinjected")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for tab value", got)
}
}
func TestAgentTrace_RejectsControlChar(t *testing.T) {
t.Setenv(CliAgentTrace, "val\x01injected")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for control char value", got)
}
}
func TestAgentTrace_RejectsDEL(t *testing.T) {
t.Setenv(CliAgentTrace, "val\x7finjected")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for DEL value", got)
}
}
func TestAgentTrace_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentTraceMaxLen+1)
t.Setenv(CliAgentTrace, longVal)
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
}
}
func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) {
val := strings.Repeat("a", agentTraceMaxLen)
t.Setenv(CliAgentTrace, val)
if got := AgentTrace(); got != val {
t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
}
}

View File

@@ -113,7 +113,6 @@ func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
{"230027 user_not_authorized", 230027, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 3, "PermissionError"},
{"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.SubtypePermissionDenied, 3, "PermissionError"},
{"1470400 task_invalid_params", 1470400, errs.CategoryAPI, errs.SubtypeInvalidParameters, 1, "APIError"},
{"1062507 drive_parent_sibling_limit", 1062507, errs.CategoryAPI, errs.SubtypeQuotaExceeded, 1, "APIError"},
{"99991400 rate_limit", 99991400, errs.CategoryAPI, errs.SubtypeRateLimit, 1, "APIError"},
{"99991661 token_missing", 99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, 3, "AuthenticationError"},
{"21000 challenge_required", 21000, errs.CategoryPolicy, errs.Subtype("challenge_required"), 6, "SecurityPolicyError"},

View File

@@ -10,23 +10,20 @@ import "github.com/larksuite/cli/errs"
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var driveCodeMeta = map[int]CodeMeta{
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
1062507: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // parent folder child count limit exceeded
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
233523001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive/docs transient server error
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
}
func init() { mergeCodeMeta(driveCodeMeta, "drive") }

View File

@@ -114,35 +114,8 @@ func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
{1061101, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{2200, errs.CategoryAPI, errs.SubtypeServerError, true},
{233523001, errs.CategoryAPI, errs.SubtypeServerError, true},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
got, ok := LookupCodeMeta(tc.code)
if !ok {
t.Fatalf("LookupCodeMeta(%d) ok=false, want true", tc.code)
}
if got.Category != tc.wantCat || got.Subtype != tc.wantSubtype || got.Retryable != tc.wantRetry {
t.Fatalf("LookupCodeMeta(%d) = %+v, want Category=%v Subtype=%v Retryable=%v",
tc.code, got, tc.wantCat, tc.wantSubtype, tc.wantRetry)
}
})
}
}
func TestLookupCodeMeta_WikiCodes(t *testing.T) {
cases := []struct {
code int
wantCat errs.Category
wantSubtype errs.Subtype
wantRetry bool
}{
{131002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{131005, errs.CategoryAPI, errs.SubtypeNotFound, false},
{131006, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {

View File

@@ -1,17 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// wikiCodeMeta holds wiki-service Lark code -> CodeMeta mappings observed from
// wiki shortcut failure telemetry. Keep these to wiki-wide meanings only; add
// command-specific recovery guidance at the shortcut layer.
var wikiCodeMeta = map[int]CodeMeta{
131002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // param err: space_id is not int / invalid page_token
131005: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // wiki node / space not found
131006: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // wiki space/node read permission denied
}
func init() { mergeCodeMeta(wikiCodeMeta, "wiki") }

View File

@@ -8,11 +8,8 @@ import "encoding/json"
// Affordance is the typed usage guidance overlaid on a method. It is the single
// model the envelope renderer and the command help both parse, so the
// vocabulary is defined once; the JSON tags double as the envelope wire shape.
// Skills entries are either a bare skill name (e.g. "lark-doc") or a
// name/relative-path reference (e.g. "lark-contact/references/x.md"); both
// render as runnable `lark-cli skills read <entry>` pointers. Help validates
// each against the embedded skill tree (a name → its SKILL.md, a reference →
// that path) and drops any that do not resolve.
// Skills entries are skill names (or name/path) rendered as runnable
// `lark-cli skills read <entry>` pointers.
type Affordance struct {
UseWhen []string `json:"use_when,omitempty"`
AvoidWhen []string `json:"avoid_when,omitempty"`

View File

@@ -9,15 +9,12 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/qualitygate/facts"
"github.com/larksuite/cli/internal/qualitygate/semantic"
)
func TestRunLoadsPolicyAndWaivers(t *testing.T) {
freezeNow(t, time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC))
repo := t.TempDir()
writeSemanticConfig(t, repo, `{
"schema_version": 1,
@@ -68,8 +65,6 @@ func TestRunLoadsPolicyAndWaivers(t *testing.T) {
}
func TestRunLoadsWaiversFromOverrideFile(t *testing.T) {
freezeNow(t, time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC))
repo := t.TempDir()
writeSemanticConfig(t, repo, `{
"schema_version": 1,
@@ -375,13 +370,6 @@ func writeSemanticConfig(t *testing.T, repo, policy, models, waivers string) {
}
}
func freezeNow(t *testing.T, fixed time.Time) {
t.Helper()
original := now
now = func() time.Time { return fixed }
t.Cleanup(func() { now = original })
}
func readDecision(t *testing.T, path string) semantic.Decision {
t.Helper()
data, err := os.ReadFile(path)

View File

@@ -6,7 +6,8 @@ package registry
import "github.com/larksuite/cli/internal/apicatalog"
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
// metadata — deterministic across machines, for golden tests and schema lint.
// metadata — deterministic across machines, for `lark-cli schema`, golden tests
// and schema lint.
func EmbeddedCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
}
@@ -17,14 +18,3 @@ func EmbeddedCatalog() apicatalog.Catalog {
func RuntimeCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
}
// SchemaCatalog returns the embedded catalog when metadata is compiled in,
// otherwise the merged runtime catalog. Binaries built from the bare Go module
// embed only the empty meta_data_default.json stub, so the embedded view has
// nothing to resolve; the merged view is the only data such binaries have.
func SchemaCatalog() apicatalog.Catalog {
if len(EmbeddedServicesTyped()) > 0 {
return EmbeddedCatalog()
}
return RuntimeCatalog()
}

View File

@@ -1,67 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/larksuite/cli/internal/apicatalog"
)
// swapEmbeddedMeta replaces the compiled-in metadata bytes for one test and
// restores them (with a full state reset) on cleanup.
func swapEmbeddedMeta(t *testing.T, data []byte) {
t.Helper()
resetInit()
orig := embeddedMetaJSON
embeddedMetaJSON = data
t.Cleanup(func() {
waitBackgroundRefresh()
embeddedMetaJSON = orig
resetInit()
})
}
func TestSchemaCatalog_EmbeddedWhenCompiledIn(t *testing.T) {
swapEmbeddedMeta(t, testCacheJSON("embedded_svc"))
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
c := SchemaCatalog()
if c.Source() != apicatalog.SourceEmbedded {
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceEmbedded)
}
if _, ok := c.Service("embedded_svc"); !ok {
t.Fatal("expected embedded_svc from embedded metadata")
}
}
// TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded simulates a binary built
// from the bare Go module (plugin builds): only the empty meta_data_default.json
// stub is compiled in, so SchemaCatalog must serve the merged runtime view that
// Init seeds via sync fetch.
func TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded(t *testing.T) {
swapEmbeddedMeta(t, embeddedMetaDataDefaultJSON)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write(testEnvelopeJSON("remote_svc"))
}))
defer ts.Close()
testMetaURL = ts.URL
c := SchemaCatalog()
if c.Source() != apicatalog.SourceRuntime {
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceRuntime)
}
if _, ok := c.Service("remote_svc"); !ok {
t.Fatal("expected remote_svc from runtime fallback")
}
}

View File

@@ -15,7 +15,6 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/update"
)
//go:embed scope_priorities.json scope_overrides.json
@@ -69,12 +68,6 @@ func Init() {
InitWithBrand(core.BrandFeishu)
}
// ConfiguredBrand reports the brand the registry was initialized with
// (empty before initialization). Diagnostics and startup-order tests use it.
func ConfiguredBrand() core.LarkBrand {
return configuredBrand
}
// InitWithBrand initializes the registry by loading embedded data and optionally
// overlaying cached remote data. The brand determines which remote API host to use.
// It is safe to call multiple times (sync.Once).
@@ -92,9 +85,7 @@ func InitWithBrand(brand core.LarkBrand) {
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
if !brandChanged {
// After a CLI upgrade the embedded data can be fresher than an old
// cache; an equal/older cache must not shadow it.
if cached, err := loadCachedMerged(); err == nil && update.IsNewer(cached.Version, embeddedVersion) {
if cached, err := loadCachedMerged(); err == nil {
overlayMergedServices(cached)
}
}

View File

@@ -1,102 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta"
)
// seedCache writes a cache file + cache meta for one service whose Title is
// marker, tagged with the given top-level data version and brand.
func seedCache(t *testing.T, dir, name, marker, version, brand string) {
t.Helper()
cDir := filepath.Join(dir, "cache")
if err := os.MkdirAll(cDir, 0700); err != nil {
t.Fatal(err)
}
reg := MergedRegistry{
Version: version,
Services: []meta.Service{{Name: name, Version: "cache", Title: marker}},
}
data, _ := json.Marshal(reg)
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.json"), data, 0644); err != nil {
t.Fatal(err)
}
cm := CacheMeta{LastCheckAt: time.Now().Unix(), Version: version, Brand: brand}
mData, _ := json.Marshal(cm)
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), mData, 0644); err != nil {
t.Fatal(err)
}
}
// initWithCache runs a fresh feishu-brand init with remote on, a high TTL and a
// recent LastCheckAt (so no refresh fires), embedded meta at embeddedVer and a
// pre-seeded cache at cacheVer — the overlay version gate is the only variable.
func initWithCache(t *testing.T, embeddedVer, cacheVer string) {
t.Helper()
embedded, _ := json.Marshal(MergedRegistry{
Version: embeddedVer,
Services: []meta.Service{{Name: "svc", Version: "embedded", Title: "EMBEDDED"}},
})
swapEmbeddedMeta(t, embedded)
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
t.Setenv("LARKSUITE_CLI_META_TTL", "3600")
seedCache(t, tmp, "svc", "CACHE", cacheVer, "feishu")
InitWithBrand(core.BrandFeishu)
}
func titleOf(t *testing.T, name string) string {
t.Helper()
svc, ok := ServiceTyped(name)
if !ok {
t.Fatalf("service %q not loaded", name)
}
return svc.Title
}
func TestOverlayGate_EqualVersion_UsesEmbedded(t *testing.T) {
initWithCache(t, "1.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("equal version: got %q, want EMBEDDED (cache must not overlay)", got)
}
}
func TestOverlayGate_OlderCache_UsesEmbedded(t *testing.T) {
initWithCache(t, "2.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("older cache: got %q, want EMBEDDED", got)
}
}
func TestOverlayGate_NewerCache_OverlaysCache(t *testing.T) {
initWithCache(t, "1.0.0", "2.0.0")
if got := titleOf(t, "svc"); got != "CACHE" {
t.Errorf("newer cache: got %q, want CACHE", got)
}
}
func TestOverlayGate_UnparseableCacheVersion_UsesEmbedded(t *testing.T) {
initWithCache(t, "1.0.0", "not-a-semver")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("unparseable cache version: got %q, want EMBEDDED", got)
}
}
func TestOverlayGate_StubEmbedded_OverlaysRealCache(t *testing.T) {
// The bare-module stub baseline is "0.0.0"; a real cache version must win so
// plugin builds without compiled meta_data.json still get remote data.
initWithCache(t, "0.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "CACHE" {
t.Errorf("stub-embedded baseline: got %q, want CACHE", got)
}
}

View File

@@ -75,7 +75,13 @@ func remoteMetaURL(version string) string {
if testMetaURL != "" {
return testMetaURL
}
base := core.ResolveEndpoints(configuredBrand).Open + "/api/tools/open/api_definition"
var base string
switch configuredBrand {
case core.BrandLark:
base = "https://open.larksuite.com/api/tools/open/api_definition"
default:
base = "https://open.feishu.cn/api/tools/open/api_definition"
}
q := "protocol=meta&client_version=" + url.QueryEscape(build.Version)
if version != "" {
q += "&data_version=" + url.QueryEscape(version)

View File

@@ -72,11 +72,9 @@ func hasEmbeddedServices() bool {
}
// testRegistry returns a minimal MergedRegistry with one service.
// The version is a real semver newer than the embedded stub baseline ("0.0.0")
// so cache overlay passes the version gate in InitWithBrand.
func testRegistry(name string) MergedRegistry {
return MergedRegistry{
Version: "1.0.0",
Version: "test-1.0",
Services: []meta.Service{
{
Name: name,
@@ -162,7 +160,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) {
}
func TestCacheHit_WithinTTL(t *testing.T) {
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
resetInit()
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
@@ -199,7 +197,7 @@ func TestCacheHit_WithinTTL(t *testing.T) {
}
func TestNetworkError_SilentDegradation(t *testing.T) {
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
resetInit()
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
@@ -373,8 +371,8 @@ func TestFetchRemoteMerged_200(t *testing.T) {
if data == nil {
t.Fatal("expected non-nil data")
}
if reg.Version != "1.0.0" {
t.Errorf("expected version 1.0.0, got %s", reg.Version)
if reg.Version != "test-1.0" {
t.Errorf("expected version test-1.0, got %s", reg.Version)
}
}

View File

@@ -16,7 +16,6 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/vfs"
)
@@ -33,7 +32,6 @@ type InstallMethod int
const (
InstallNpm InstallMethod = iota
InstallPnpm
InstallManual
)
@@ -50,39 +48,27 @@ const (
var (
skillsIndexFetchTimeout = 10 * time.Second
// officialSkillsIndexURL overrides the brand-derived skills index URL in
// tests; empty in production.
officialSkillsIndexURL = ""
officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json"
)
// DetectResult holds installation detection results.
type DetectResult struct {
Method InstallMethod
ResolvedPath string
NpmAvailable bool
PnpmAvailable bool
Method InstallMethod
ResolvedPath string
NpmAvailable bool
}
// CanAutoUpdate returns true if the CLI can update itself automatically.
func (d DetectResult) CanAutoUpdate() bool {
switch d.Method {
case InstallNpm:
return d.NpmAvailable
case InstallPnpm:
return d.PnpmAvailable
}
return false
return d.Method == InstallNpm && d.NpmAvailable
}
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
func (d DetectResult) ManualReason() string {
switch {
case d.Method == InstallNpm && !d.NpmAvailable:
if d.Method == InstallNpm && !d.NpmAvailable {
return "installed via npm, but npm is not available in PATH"
case d.Method == InstallPnpm && !d.PnpmAvailable:
return "installed via pnpm, but pnpm is not available in PATH"
}
return "not installed via npm or pnpm"
return "not installed via npm"
}
// NpmResult holds the result of an npm install or skills update execution.
@@ -104,12 +90,8 @@ func (r *NpmResult) CombinedOutput() string {
// Override DetectOverride / NpmInstallOverride / SkillsCommandOverride / VerifyOverride
// / RestoreAvailableOverride for testing.
type Updater struct {
// Brand selects the skills index/source endpoints (zero value = feishu).
Brand core.LarkBrand
DetectOverride func() DetectResult
NpmInstallOverride func(version string) *NpmResult
PnpmInstallOverride func(version string) *NpmResult
SkillsIndexFetchOverride func() *NpmResult
SkillsCommandOverride func(args ...string) *NpmResult
VerifyOverride func(expectedVersion string) error
@@ -119,51 +101,17 @@ type Updater struct {
// running binary is successfully renamed to .old. Used by
// CanRestorePreviousVersion to report whether rollback is possible.
backupCreated bool
// detectCache memoizes the first real DetectInstallMethod result. How this
// binary was installed cannot change during a single process, so caching is
// the correct semantics — and it is required for correctness: the update
// flow mutates the install (pnpm add -g / npm install -g) before syncing
// skills, so a re-detection at skills time could resolve a now-stale
// os.Executable path and misclassify. Seeded pre-update by the first call
// (updateRun), it keeps the post-update skills launcher consistent with the
// launcher reported to the user. Not goroutine-safe; the update flow is
// sequential.
detectCache *DetectResult
}
// New creates an Updater with default (real) behavior.
func New() *Updater { return &Updater{} }
// skillsIndexURL returns the brand's well-known skills index URL.
func (u *Updater) skillsIndexURL() string {
if officialSkillsIndexURL != "" {
return officialSkillsIndexURL
}
return core.ResolveEndpoints(u.Brand).Open + "/.well-known/skills/index.json"
}
// skillsSource returns the brand's skills source host for `npx skills add`.
func (u *Updater) skillsSource() string {
return core.ResolveEndpoints(u.Brand).Open
}
// DetectInstallMethod determines how the CLI was installed and whether the
// owning package manager is available for auto-update.
// DetectInstallMethod determines how the CLI was installed and whether
// npm is available for auto-update.
func (u *Updater) DetectInstallMethod() DetectResult {
if u.DetectOverride != nil {
return u.DetectOverride()
}
if u.detectCache != nil {
return *u.detectCache
}
result := u.detectInstallMethod()
u.detectCache = &result
return result
}
// detectInstallMethod performs the real (uncached) detection.
func (u *Updater) detectInstallMethod() DetectResult {
exe, err := vfs.Executable()
if err != nil {
return DetectResult{Method: InstallManual}
@@ -172,54 +120,24 @@ func (u *Updater) detectInstallMethod() DetectResult {
if err != nil {
return DetectResult{Method: InstallManual, ResolvedPath: exe}
}
_, npmErr := exec.LookPath("npm")
_, pnpmErr := exec.LookPath("pnpm")
return detectFromResolved(resolved, npmErr == nil, pnpmErr == nil)
}
// detectFromResolved classifies the resolved binary path into an install
// method and records package-manager availability. Split out from
// DetectInstallMethod so the classification is unit-testable without touching
// the filesystem or PATH.
func detectFromResolved(resolved string, npmOnPath, pnpmOnPath bool) DetectResult {
method := InstallManual
if strings.Contains(resolved, "node_modules") {
if containsPnpmMarker(resolved) {
method = InstallPnpm
} else {
method = InstallNpm
}
method = InstallNpm
}
d := DetectResult{Method: method, ResolvedPath: resolved}
switch method {
case InstallNpm:
d.NpmAvailable = npmOnPath
case InstallPnpm:
d.PnpmAvailable = pnpmOnPath
}
return d
}
// containsPnpmMarker reports whether the resolved binary path belongs to a
// pnpm-managed install. pnpm exposes two layouts: the classic virtual store
// (a ".pnpm" directory segment) and the global content-addressable store,
// whose resolved path runs through pnpm's home directory (e.g.
// "~/Library/pnpm/store/v11/links/...") — a "pnpm" segment immediately
// followed by "store". Matching only these two shapes (rather than any bare
// "pnpm" segment) avoids misclassifying an npm install that merely lives under
// a directory named "pnpm". Windows separators are normalized to "/" so the
// classification is OS-independent and unit-testable anywhere.
func containsPnpmMarker(p string) bool {
parts := strings.Split(strings.ReplaceAll(p, `\`, "/"), "/")
for i, part := range parts {
if part == ".pnpm" {
return true
}
if part == "pnpm" && i+1 < len(parts) && parts[i+1] == "store" {
return true
npmAvailable := false
if method == InstallNpm {
if _, err := exec.LookPath("npm"); err == nil {
npmAvailable = true
}
}
return false
return DetectResult{
Method: method,
ResolvedPath: resolved,
NpmAvailable: npmAvailable,
}
}
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
@@ -245,29 +163,6 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
return r
}
// RunPnpmInstall executes pnpm add -g @larksuite/cli@<version>.
func (u *Updater) RunPnpmInstall(version string) *NpmResult {
if u.PnpmInstallOverride != nil {
return u.PnpmInstallOverride(version)
}
r := &NpmResult{}
pnpmPath, err := exec.LookPath("pnpm")
if err != nil {
r.Err = fmt.Errorf("pnpm not found in PATH: %w", err)
return r
}
ctx, cancel := context.WithTimeout(context.Background(), npmInstallTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, pnpmPath, "add", "-g", NpmPackage+"@"+version)
cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr
r.Err = cmd.Run()
if ctx.Err() == context.DeadlineExceeded {
r.Err = fmt.Errorf("pnpm install timed out after %s", npmInstallTimeout)
}
return r
}
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
if u.SkillsIndexFetchOverride != nil {
return u.SkillsIndexFetchOverride()
@@ -277,7 +172,7 @@ func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
ctx, cancel := context.WithTimeout(context.Background(), skillsIndexFetchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.skillsIndexURL(), nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, officialSkillsIndexURL, nil)
if err != nil {
r.Err = err
return r
@@ -316,7 +211,7 @@ func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
}
func (u *Updater) ListOfficialSkills() *NpmResult {
r := u.runSkillsListOfficial(u.skillsSource())
r := u.runSkillsListOfficial("https://open.feishu.cn")
if r.Err != nil {
r = u.runSkillsListOfficial("larksuite/cli")
}
@@ -332,7 +227,7 @@ func (u *Updater) ListGlobalSkillsJSON() *NpmResult {
}
func (u *Updater) InstallSkill(nameList []string) *NpmResult {
r := u.runSkillsInstall(u.skillsSource(), nameList)
r := u.runSkillsInstall("https://open.feishu.cn", nameList)
if r.Err != nil {
r = u.runSkillsInstall("larksuite/cli", nameList)
}
@@ -340,7 +235,7 @@ func (u *Updater) InstallSkill(nameList []string) *NpmResult {
}
func (u *Updater) InstallAllSkills() *NpmResult {
r := u.runSkillsAdd(u.skillsSource())
r := u.runSkillsAdd("https://open.feishu.cn")
if r.Err != nil {
r = u.runSkillsAdd("larksuite/cli")
}
@@ -366,40 +261,19 @@ func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult
return u.runSkillsCommand(args...)
}
// skillsInvocation decides how to launch the `skills` CLI. When the lark-cli
// itself was installed via pnpm and pnpm is available, it uses `pnpm dlx` so
// pnpm-only environments (pnpm's standalone installer bundles Node without
// putting npm/npx on PATH) can still sync skills after a self-update.
// Otherwise it uses `npx`. The npx auto-confirm flag "-y", when present as the
// leading arg, maps to `pnpm dlx`'s default non-interactive behavior and is
// dropped for the pnpm launcher. Kept pure (no exec/PATH access) so the
// launcher selection is unit-testable on any platform.
func skillsInvocation(method InstallMethod, pnpmAvailable bool, args []string) (launcher string, rest []string) {
if method == InstallPnpm && pnpmAvailable {
r := args
if len(r) > 0 && r[0] == "-y" {
r = r[1:]
}
return "pnpm", append([]string{"dlx"}, r...)
}
return "npx", args
}
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
if u.SkillsCommandOverride != nil {
return u.SkillsCommandOverride(args...)
}
r := &NpmResult{}
det := u.DetectInstallMethod()
launcher, cmdArgs := skillsInvocation(det.Method, det.PnpmAvailable, args)
binPath, err := exec.LookPath(launcher)
npxPath, err := exec.LookPath("npx")
if err != nil {
r.Err = fmt.Errorf("%s not found in PATH: %w", launcher, err)
r.Err = fmt.Errorf("npx not found in PATH: %w", err)
return r
}
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, binPath, cmdArgs...)
cmd := exec.CommandContext(ctx, npxPath, args...)
cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr
r.Err = cmd.Run()

View File

@@ -17,7 +17,6 @@ import (
"testing"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/vfs"
)
@@ -372,167 +371,3 @@ func TestListOfficialSkillsFallsBack(t *testing.T) {
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
}
}
func TestContainsPnpmMarker(t *testing.T) {
cases := []struct {
path string
want bool
}{
// Classic virtual-store layout (.pnpm segment).
{"/Users/x/Library/pnpm/global/5/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true},
{`C:\Users\x\AppData\Local\pnpm\global\5\node_modules\.pnpm\@larksuite+cli@1.0.44\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
// Global content-addressable store layout (pnpm 11): resolved path runs
// through the pnpm home store, a "pnpm" segment with no ".pnpm".
{"/Users/x/Library/pnpm/store/v11/links/@larksuite/cli/1.0.59/abc123/node_modules/@larksuite/cli/bin/lark-cli", true},
{"/home/x/.local/share/pnpm/store/v10/@larksuite/cli/node_modules/@larksuite/cli/bin/lark-cli", true},
{`C:\Users\x\AppData\Local\pnpm\store\v11\links\@larksuite\cli\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
// npm and non-package installs — no pnpm/.pnpm segment.
{"/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
{"/usr/local/bin/lark-cli", false},
// Substrings that must NOT match: segment must be exactly .pnpm, or
// "pnpm" immediately followed by "store".
{"/opt/homebrew/.pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
{"/opt/pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
// A bare "pnpm" directory NOT followed by "store" (e.g. an npm install
// living under a dir named pnpm) must not be misclassified as pnpm.
{"/opt/pnpm/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
}
for _, c := range cases {
if got := containsPnpmMarker(c.path); got != c.want {
t.Errorf("containsPnpmMarker(%q) = %v, want %v", c.path, got, c.want)
}
}
}
func TestDetectInstallMethod_Pnpm(t *testing.T) {
u := &Updater{DetectOverride: nil}
u.DetectOverride = func() DetectResult {
// Exercise the real classification by feeding a resolved path via a small shim.
return detectFromResolved("/x/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true, true)
}
got := u.DetectInstallMethod()
if got.Method != InstallPnpm {
t.Errorf("Method = %v, want InstallPnpm", got.Method)
}
if !got.PnpmAvailable {
t.Errorf("PnpmAvailable = false, want true")
}
}
func TestDetectInstallMethod_NpmVsManual(t *testing.T) {
if m := detectFromResolved("/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", true, false).Method; m != InstallNpm {
t.Errorf("npm path Method = %v, want InstallNpm", m)
}
if m := detectFromResolved("/usr/local/bin/lark-cli", false, false).Method; m != InstallManual {
t.Errorf("manual path Method = %v, want InstallManual", m)
}
}
func TestCanAutoUpdate_Pnpm(t *testing.T) {
if !(DetectResult{Method: InstallPnpm, PnpmAvailable: true}).CanAutoUpdate() {
t.Error("pnpm available should CanAutoUpdate")
}
if (DetectResult{Method: InstallPnpm, PnpmAvailable: false}).CanAutoUpdate() {
t.Error("pnpm unavailable should not CanAutoUpdate")
}
}
func TestManualReason_Pnpm(t *testing.T) {
if got := (DetectResult{Method: InstallPnpm, NpmAvailable: false, PnpmAvailable: false}).ManualReason(); got != "installed via pnpm, but pnpm is not available in PATH" {
t.Errorf("pnpm reason = %q", got)
}
if got := (DetectResult{Method: InstallManual}).ManualReason(); got != "not installed via npm or pnpm" {
t.Errorf("manual reason = %q", got)
}
}
func TestRunPnpmInstall_Override(t *testing.T) {
u := &Updater{PnpmInstallOverride: func(version string) *NpmResult {
r := &NpmResult{}
r.Stdout.WriteString("added @larksuite/cli@" + version)
return r
}}
got := u.RunPnpmInstall("2.0.0")
if got.Err != nil {
t.Fatalf("unexpected err: %v", got.Err)
}
if !strings.Contains(got.CombinedOutput(), "2.0.0") {
t.Errorf("output = %q, want version echoed", got.CombinedOutput())
}
}
func TestRunPnpmInstall_Error(t *testing.T) {
wantErr := errors.New("boom")
u := &Updater{PnpmInstallOverride: func(string) *NpmResult { return &NpmResult{Err: wantErr} }}
if got := u.RunPnpmInstall("2.0.0"); !errors.Is(got.Err, wantErr) {
t.Errorf("err = %v, want %v", got.Err, wantErr)
}
}
func TestSkillsInvocation(t *testing.T) {
addArgs := []string{"-y", "skills", "add", "https://open.feishu.cn", "-g", "-y"}
cases := []struct {
name string
method InstallMethod
pnpmAvailable bool
args []string
wantLauncher string
wantRest []string
}{
{"pnpm install + pnpm available → pnpm dlx, drop leading -y", InstallPnpm, true, addArgs,
"pnpm", []string{"dlx", "skills", "add", "https://open.feishu.cn", "-g", "-y"}},
{"pnpm install but pnpm unavailable → npx unchanged", InstallPnpm, false, addArgs,
"npx", addArgs},
{"npm install → npx unchanged", InstallNpm, false, addArgs,
"npx", addArgs},
{"manual install → npx unchanged", InstallManual, false, []string{"-y", "skills", "ls", "-g"},
"npx", []string{"-y", "skills", "ls", "-g"}},
{"pnpm without a leading -y → prepend dlx only", InstallPnpm, true, []string{"skills", "ls", "-g"},
"pnpm", []string{"dlx", "skills", "ls", "-g"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
gotLauncher, gotRest := skillsInvocation(c.method, c.pnpmAvailable, c.args)
if gotLauncher != c.wantLauncher {
t.Errorf("launcher = %q, want %q", gotLauncher, c.wantLauncher)
}
if strings.Join(gotRest, " ") != strings.Join(c.wantRest, " ") {
t.Errorf("rest = %v, want %v", gotRest, c.wantRest)
}
})
}
}
// TestDetectInstallMethod_Caches locks the fix for the post-update re-detection
// hazard: DetectInstallMethod must return the first (pre-update) detection on
// subsequent calls, so the skills launcher chosen after the binary is replaced
// stays consistent with what was detected — and reported — before the update.
func TestDetectInstallMethod_Caches(t *testing.T) {
u := New()
cached := DetectResult{Method: InstallPnpm, PnpmAvailable: true, ResolvedPath: "/x/pnpm/store/v11/links/@larksuite/cli/1.0.0/node_modules/@larksuite/cli/bin/lark-cli"}
u.detectCache = &cached
got := u.DetectInstallMethod()
if got.Method != InstallPnpm || !got.PnpmAvailable {
t.Errorf("expected cached pnpm result to be returned, got %+v", got)
}
}
func TestSkillsBrandHosts(t *testing.T) {
cases := []struct {
brand core.LarkBrand
wantIndex string
wantSource string
}{
{core.BrandFeishu, "https://open.feishu.cn/.well-known/skills/index.json", "https://open.feishu.cn"},
{core.BrandLark, "https://open.larksuite.com/.well-known/skills/index.json", "https://open.larksuite.com"},
}
for _, c := range cases {
u := &Updater{Brand: c.brand}
if got := u.skillsIndexURL(); got != c.wantIndex {
t.Errorf("brand %q: skillsIndexURL = %q, want %q", c.brand, got, c.wantIndex)
}
if got := u.skillsSource(); got != c.wantSource {
t.Errorf("brand %q: skillsSource = %q, want %q", c.brand, got, c.wantSource)
}
}
}

View File

@@ -65,7 +65,7 @@ func safePath(raw, flagName string) (string, error) {
}
if isAbsolutePath(raw) {
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: use a relative path like ./filename; flags that support stdin can read an out-of-tree file via '-' instead)", flagName, raw)
}
path := filepath.Clean(raw)

View File

@@ -30,42 +30,8 @@ lint/
├── rule_subtype_classifier.go
├── rule_typed_error_completeness.go
└── *_test.go
└── domaincontract/ # endpoint domain contract: no hardcoded resolver hosts
├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry
└── scan_test.go
```
## Endpoint domain contract (`domaincontract`)
`domaincontract` is a syntax-level regression guard for the resolver-owned
Open, Accounts, MCP, and AppLink hosts used by the Go CLI. In production `.go`
files it rejects:
- string literals containing a resolver-owned host FQDN
(`{open,accounts,mcp,applink}.{feishu.cn,larksuite.com}`), and
- direct references to the SDK base-URL globals (`FeishuBaseUrl` / `LarkBaseUrl`)
selected off an import of the SDK root package, which pick a host without
going through the resolver. Unrelated identifiers sharing the name are not
flagged.
Host literals are permitted only inside the resolver's `ResolveEndpoints`
function body (`internal/core/types.go`) and in this rule's own host list
(`lint/domaincontract/scan.go`); a helper elsewhere in the resolver file
returning a hardcoded host is still rejected. Comments and `_test.go` files
are not scanned. Literals are unquoted before matching (escape sequences
cannot hide a host) and match case-insensitively, and dot-imports of the SDK
root package are rejected outright (they would hide the globals from this
parse-level guard). The forbidden-host list is bound to the resolver source by
`TestForbiddenHostsMatchResolver`, so adding a resolver domain without updating
the guard fails the lint module's tests.
This is not a general outbound-URL or data-flow analyzer. It does not inspect
non-Go assets, hosts assembled from string fragments, SDK constructor option
flow, or previously unknown Feishu/Lark hosts. The literal rule and code review
remain the backstop for those cases.
To add or change an outbound endpoint, edit the resolver — never hardcode a host.
## Running
```bash
@@ -76,7 +42,7 @@ go run -C lint . ..
`-C lint` switches Go's working directory to `lint/`; the `..` argument
is the repo root to scan (relative to `lint/`).
CI: `.github/workflows/ci.yml` step `Run source-contract lint guards (lintcheck)`.
CI: `.github/workflows/ci.yml` step `Run errs/ lint guards (lintcheck)`.
Exit codes follow `lint/main.go`:

View File

@@ -1,45 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package domaincontract
import (
"errors"
"os"
"os/exec"
"strings"
"testing"
)
// TestLintcheckExitCode proves the guard gates CI end to end: a violating
// fixture must make the lintcheck binary exit 1, and a clean tree exit 0.
func TestLintcheckExitCode(t *testing.T) {
if testing.Short() {
t.Skip("compiles the lintcheck binary")
}
dirty := t.TempDir()
writeFile(t, dirty, "internal/x/x.go", "package x\n\nvar h = \"https://open.feishu.cn\"\n")
run := func(dir string) (string, error) {
cmd := exec.Command("go", "run", "..", dir)
cmd.Dir = "." // lint/domaincontract — `..` is the lintcheck main package
cmd.Env = os.Environ()
out, err := cmd.CombinedOutput()
return string(out), err
}
out, err := run(dirty)
if err == nil || !strings.Contains(out, "no-hardcoded-endpoint") {
t.Fatalf("violating fixture: err=%v out=%s (want exit 1 with a no-hardcoded-endpoint REJECT)", err, out)
}
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 1 {
t.Fatalf("violating fixture exit = %v, want 1", err)
}
clean := t.TempDir()
writeFile(t, clean, "internal/x/x.go", "package x\n\nvar ok = 1\n")
if out, err := run(clean); err != nil {
t.Fatalf("clean fixture: err=%v out=%s (want exit 0)", err, out)
}
}

View File

@@ -1,190 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package domaincontract guards the Go CLI against direct reuse of the current
// resolver-owned host FQDNs outside core.ResolveEndpoints.
package domaincontract
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"strconv"
"strings"
"github.com/larksuite/cli/lint/lintapi"
)
// forbiddenHosts are the resolver-owned FQDNs. They may only appear as string
// literals in the allowlisted resolver source.
var forbiddenHosts = []string{
"open.feishu.cn", "accounts.feishu.cn", "mcp.feishu.cn", "applink.feishu.cn",
"open.larksuite.com", "accounts.larksuite.com", "mcp.larksuite.com", "applink.larksuite.com",
}
// forbiddenIdents are the SDK root package's base-URL globals; referencing
// them picks a host without the resolver. Matched as selectors on an SDK root
// import, so unrelated same-name identifiers are not flagged.
var forbiddenIdents = map[string]bool{
"FeishuBaseUrl": true,
"LarkBaseUrl": true,
}
// sdkModulePrefix identifies imports of the Lark OAPI SDK.
const sdkModulePrefix = "github.com/larksuite/oapi-sdk-go/"
// sdkImportAliases returns the file's local names for the SDK root package
// (subpackages do not export the base-URL globals).
func sdkImportAliases(file *ast.File) map[string]bool {
aliases := map[string]bool{}
for _, imp := range file.Imports {
path, err := strconv.Unquote(imp.Path.Value)
if err != nil || !strings.HasPrefix(path, sdkModulePrefix) {
continue
}
if strings.Contains(strings.TrimPrefix(path, sdkModulePrefix), "/") {
continue // subpackage, not the root
}
name := "lark" // the SDK root package's package name
if imp.Name != nil {
name = imp.Name.Name
}
aliases[name] = true
}
return aliases
}
// allowlist holds the only file allowed to carry the literals wholesale:
// this rule's own host list. The resolver file is scoped per-function instead
// (see resolverPath).
var allowlist = map[string]bool{
filepath.FromSlash("lint/domaincontract/scan.go"): true,
}
// resolverPath is the resolver source; host literals are permitted only
// inside its ResolveEndpoints function body.
var resolverPath = filepath.FromSlash("internal/core/types.go")
func skipDir(name string) bool {
switch name {
case "vendor", "testdata", "node_modules", ".git", ".claude":
return true
}
return false
}
// ScanRepo walks production .go files under root and flags string literals
// containing a forbidden resolver host outside the allowlist. Comments and
// _test.go files are not scanned.
func ScanRepo(root string) ([]lintapi.Violation, error) {
var out []lintapi.Violation
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if skipDir(d.Name()) {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
rel, relErr := filepath.Rel(root, path)
if relErr == nil && allowlist[rel] {
return nil
}
fset := token.NewFileSet()
file, perr := parser.ParseFile(fset, path, nil, 0)
if perr != nil {
return nil // unparseable file: not our concern
}
display := path
if relErr == nil {
display = rel
}
var allowedFrom, allowedTo token.Pos
if relErr == nil && rel == resolverPath {
for _, d := range file.Decls {
if fd, ok := d.(*ast.FuncDecl); ok && fd.Recv == nil && fd.Name.Name == "ResolveEndpoints" && fd.Body != nil {
allowedFrom, allowedTo = fd.Body.Pos(), fd.Body.End()
break
}
}
}
inResolverBody := func(p token.Pos) bool {
return allowedFrom != token.NoPos && p >= allowedFrom && p <= allowedTo
}
// Dot-imports of the SDK root would hide its globals from this
// parse-level guard, so the import form itself is rejected.
for _, imp := range file.Imports {
path, uerr := strconv.Unquote(imp.Path.Value)
if uerr != nil || imp.Name == nil || imp.Name.Name != "." {
continue
}
if strings.HasPrefix(path, sdkModulePrefix) &&
!strings.Contains(strings.TrimPrefix(path, sdkModulePrefix), "/") {
pos := fset.Position(imp.Pos())
out = append(out, lintapi.Violation{
Rule: "no-hardcoded-endpoint",
Action: lintapi.ActionReject,
File: display,
Line: pos.Line,
Message: "dot-import of the SDK root package defeats the endpoint guard",
Suggestion: "import the SDK with a package name",
})
}
}
sdkAliases := sdkImportAliases(file)
ast.Inspect(file, func(n ast.Node) bool {
switch node := n.(type) {
case *ast.SelectorExpr:
pkg, ok := node.X.(*ast.Ident)
if ok && pkg.Obj == nil && forbiddenIdents[node.Sel.Name] && sdkAliases[pkg.Name] {
pos := fset.Position(node.Pos())
out = append(out, lintapi.Violation{
Rule: "no-hardcoded-endpoint",
Action: lintapi.ActionReject,
File: display,
Line: pos.Line,
Message: "SDK base-URL global " + pkg.Name + "." + node.Sel.Name + " bypasses the resolver — use core.ResolveEndpoints",
Suggestion: "derive the host from core.ResolveEndpoints(brand) instead of the SDK global",
})
}
case *ast.BasicLit:
if node.Kind != token.STRING {
return true
}
if inResolverBody(node.Pos()) {
return true
}
// Unquote and lowercase so escapes or casing cannot hide a host.
value := node.Value
if v, err := strconv.Unquote(value); err == nil {
value = v
}
lower := strings.ToLower(value)
for _, host := range forbiddenHosts {
if strings.Contains(lower, host) {
pos := fset.Position(node.Pos())
out = append(out, lintapi.Violation{
Rule: "no-hardcoded-endpoint",
Action: lintapi.ActionReject,
File: display,
Line: pos.Line,
Message: "hardcoded resolver host " + host + " — outbound domains must come from core.ResolveEndpoints",
Suggestion: "use core.ResolveEndpoints(brand) instead of a literal host",
})
return true
}
}
}
return true
})
return nil
})
return out, err
}

View File

@@ -1,231 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package domaincontract
import (
"go/ast"
"go/parser"
"go/token"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/larksuite/cli/lint/lintapi"
)
// requireEnforced pins every violation to the rejecting rule: a regression
// that downgrades the guard to an advisory action must fail here.
func requireEnforced(t *testing.T, vs []lintapi.Violation) {
t.Helper()
for _, v := range vs {
if v.Rule != "no-hardcoded-endpoint" || v.Action != lintapi.ActionReject {
t.Fatalf("violation not CI-enforced: rule=%q action=%q (%s:%d)", v.Rule, v.Action, v.File, v.Line)
}
}
}
func writeFile(t *testing.T, root, rel, content string) {
t.Helper()
p := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestScanRepo(t *testing.T) {
root := t.TempDir()
// Negative: the resolver may hold the literals inside ResolveEndpoints.
writeFile(t, root, "internal/core/types.go", "package core\n\nfunc ResolveEndpoints(b string) string {\n\treturn \"https://open.feishu.cn\"\n}\n")
// Negative: non-resolver hosts + a comment reference must not trip the guard.
writeFile(t, root, "shortcuts/x/display.go", "package x\n\n// see https://open.feishu.cn/document/foo\nvar h = \"https://www.feishu.cn\"\nvar e = \"https://example.feishu.cn\"\nvar r = \"https://registry.npmjs.org/pkg\"\n")
// Negative: _test.go files may assert literals.
writeFile(t, root, "internal/y/y_test.go", "package y\n\nvar w = \"https://open.larksuite.com\"\n")
// Positive: production literal outside the allowlist.
writeFile(t, root, "internal/z/z.go", "package z\n\nvar bad = \"https://accounts.larksuite.com/oauth\"\n")
vs, err := ScanRepo(root)
if err != nil {
t.Fatal(err)
}
requireEnforced(t, vs)
if len(vs) != 1 {
t.Fatalf("got %d violations, want 1: %+v", len(vs), vs)
}
if filepath.Base(vs[0].File) != "z.go" {
t.Errorf("violation in %q, want z.go", vs[0].File)
}
}
// SDK base-URL globals are rejected only when selected off an SDK root
// import; same-name identifiers elsewhere pass.
func TestScanRepoSDKConstants(t *testing.T) {
root := t.TempDir()
// Positive: default and renamed imports of the SDK root package.
writeFile(t, root, "shortcuts/x/ws.go",
"package x\n\nimport \"github.com/larksuite/oapi-sdk-go/v3\"\n\nvar d = lark.FeishuBaseUrl\n")
writeFile(t, root, "shortcuts/x/ws2.go",
"package x\n\nimport sdk \"github.com/larksuite/oapi-sdk-go/v3\"\n\nvar e = sdk.LarkBaseUrl\n")
// Negative: test file may reference the globals.
writeFile(t, root, "shortcuts/x/ws_test.go",
"package x\n\nimport lark \"github.com/larksuite/oapi-sdk-go/v3\"\n\nvar p = lark.LarkBaseUrl\n")
// Negative: same-name local identifier without the SDK import.
writeFile(t, root, "shortcuts/y/local.go",
"package y\n\nvar FeishuBaseUrl = \"local\"\nvar q = FeishuBaseUrl\n")
// Negative: same-name symbol from an unrelated package.
writeFile(t, root, "shortcuts/z/other.go",
"package z\n\nimport other \"example.com/other\"\n\nvar r = other.FeishuBaseUrl\n")
// Negative: SDK subpackage import does not export the globals.
writeFile(t, root, "shortcuts/w/sub.go",
"package w\n\nimport larkws \"github.com/larksuite/oapi-sdk-go/v3/ws\"\n\nvar s = larkws.FeishuBaseUrl\n")
// Negative: a local value shadowing the SDK import alias is not the package.
writeFile(t, root, "shortcuts/v/shadow.go",
"package v\n\nimport lark \"github.com/larksuite/oapi-sdk-go/v3\"\n\ntype endpoint struct { FeishuBaseUrl string }\nvar _ *lark.Client\nfunc local() string { lark := endpoint{}; return lark.FeishuBaseUrl }\n")
vs, err := ScanRepo(root)
if err != nil {
t.Fatal(err)
}
requireEnforced(t, vs)
if len(vs) != 2 {
t.Fatalf("got %d violations, want 2: %+v", len(vs), vs)
}
files := map[string]bool{}
for _, v := range vs {
files[filepath.Base(v.File)] = true
}
if !files["ws.go"] || !files["ws2.go"] {
t.Errorf("violations in %v, want ws.go and ws2.go", files)
}
}
// forbiddenHosts must equal the https hosts in the resolver source, both ways;
// a resolver domain change without a guard update fails here.
func TestForbiddenHostsMatchResolver(t *testing.T) {
src := filepath.Join("..", "..", "internal", "core", "types.go")
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, src, nil, 0)
if err != nil {
t.Fatalf("parse resolver source: %v", err)
}
// Walk only the receiverless ResolveEndpoints body — the same scope the
// production scanner exempts — so unrelated URLs in the file cannot skew
// the parity check.
var resolverBody ast.Node
for _, d := range file.Decls {
if fd, ok := d.(*ast.FuncDecl); ok && fd.Recv == nil && fd.Name.Name == "ResolveEndpoints" && fd.Body != nil {
resolverBody = fd.Body
break
}
}
if resolverBody == nil {
t.Fatal("ResolveEndpoints function not found in resolver source")
}
resolverHosts := map[string]bool{}
ast.Inspect(resolverBody, func(n ast.Node) bool {
lit, ok := n.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
v, err := strconv.Unquote(lit.Value)
if err != nil || !strings.HasPrefix(v, "https://") {
return true
}
// Parse instead of prefix-stripping so a resolver URL that ever gains a
// path component still compares by bare host against forbiddenHosts.
u, err := url.Parse(v)
if err != nil || u.Host == "" {
return true
}
resolverHosts[u.Host] = true
return true
})
guardHosts := map[string]bool{}
for _, h := range forbiddenHosts {
guardHosts[h] = true
}
for h := range resolverHosts {
if !guardHosts[h] {
t.Errorf("resolver host %q is not in the guard's forbidden list", h)
}
}
for h := range guardHosts {
if !resolverHosts[h] {
t.Errorf("guard forbids %q which the resolver does not define", h)
}
}
}
// Dot-import rejection and case-insensitive literal matching.
func TestScanRepoDotImportAndCase(t *testing.T) {
root := t.TempDir()
// Positive: dot-import of the SDK root package.
writeFile(t, root, "shortcuts/a/dot.go",
"package a\n\nimport . \"github.com/larksuite/oapi-sdk-go/v3\"\n\nvar d = FeishuBaseUrl\n")
// Positive: uppercase host literal.
writeFile(t, root, "shortcuts/b/upper.go",
"package b\n\nvar u = \"https://OPEN.FEISHU.CN/api\"\n")
// Negative: dot-import of an SDK subpackage is out of the globals' scope.
writeFile(t, root, "shortcuts/c/sub.go",
"package c\n\nimport . \"github.com/larksuite/oapi-sdk-go/v3/ws\"\n\nvar s = 1\n")
vs, err := ScanRepo(root)
if err != nil {
t.Fatal(err)
}
requireEnforced(t, vs)
if len(vs) != 2 {
t.Fatalf("got %d violations, want 2: %+v", len(vs), vs)
}
files := map[string]bool{}
for _, v := range vs {
files[filepath.Base(v.File)] = true
}
if !files["dot.go"] || !files["upper.go"] {
t.Errorf("violations in %v, want dot.go and upper.go", files)
}
}
// The resolver file is scoped per-function: a hardcoded host outside the
// ResolveEndpoints body is rejected.
func TestScanRepoResolverFunctionScope(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "internal/core/types.go",
"package core\n\nfunc ResolveEndpoints(b string) string {\n\treturn \"https://open.feishu.cn\"\n}\n\nfunc bypass() string { return \"https://open.feishu.cn\" }\n\ntype localResolver struct{}\nfunc (localResolver) ResolveEndpoints() string { return \"https://open.feishu.cn\" }\n")
vs, err := ScanRepo(root)
if err != nil {
t.Fatal(err)
}
if len(vs) != 2 {
t.Fatalf("got %d violations, want 2 (helper and receiver method): %+v", len(vs), vs)
}
for _, v := range vs {
if filepath.Base(v.File) != "types.go" {
t.Errorf("violation in %q, want types.go", v.File)
}
}
}
// Escape sequences cannot hide a host: literals are unquoted before matching.
func TestScanRepoEscapedLiteral(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "internal/e/e.go",
"package e\n\nvar h = \"https://open.feishu\\u002ecn\"\n")
vs, err := ScanRepo(root)
if err != nil {
t.Fatal(err)
}
requireEnforced(t, vs)
if len(vs) != 1 {
t.Fatalf("got %d violations, want 1: %+v", len(vs), vs)
}
}

View File

@@ -1,9 +1,10 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Command lintcheck runs repository source-contract guards that golangci-lint
// cannot express directly. It currently covers typed-error contracts and the
// resolver-owned endpoint contract.
// Command lintcheck runs the source-level errs/ contract guards (all four checks).
// The fifth contract rule (business path must use typed errors) lives in
// .golangci.yml as a forbidigo entry; the four checks here are AST-level
// guards that golangci-lint cannot express.
//
// lintcheck lives in its own Go module under lint/ so its build-time
// dependency on golang.org/x/tools/go/packages does not leak into the
@@ -29,7 +30,6 @@ import (
"fmt"
"os"
"github.com/larksuite/cli/lint/domaincontract"
"github.com/larksuite/cli/lint/errscontract"
"github.com/larksuite/cli/lint/lintapi"
)
@@ -43,9 +43,6 @@ type scanner struct {
var scanners = []scanner{
{name: "errscontract", fn: errscontract.ScanRepoWithOptions},
{name: "domaincontract", fn: func(root string, _ errscontract.ScanOptions) ([]lintapi.Violation, error) {
return domaincontract.ScanRepo(root)
}},
}
func main() {

View File

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

View File

@@ -215,73 +215,6 @@ if ! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
exit 1
fi
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$dry_run_section" ||
! grep -Fq "id: e2e_domains" <<<"$dry_run_section" ||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$dry_run_section"; then
echo "e2e-dry-run should resolve changed-file CLI E2E domains before running tests"
exit 1
fi
if ! grep -Fq "steps.e2e_domains.outputs.dry_packages" <<<"$dry_run_section"; then
echo "e2e-dry-run should use resolved dry_packages instead of always running the full suite"
exit 1
fi
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$dry_run_section" ||
! grep -Fq 'echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$dry_run_section"; then
echo "e2e-dry-run should pass dynamic domain output through env before shell use"
exit 1
fi
if ! grep -Fq "E2E_DRY_ROOT_PACKAGE: \${{ steps.e2e_domains.outputs.dry_root_package }}" <<<"$dry_run_section" ||
! grep -Fq 'go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"' <<<"$dry_run_section"; then
echo "e2e-dry-run should run the root CLI E2E harness package without the DryRun/Regression filter"
exit 1
fi
if ! grep -Fq "No dry-run CLI E2E needed" <<<"$dry_run_section"; then
echo "e2e-dry-run should explicitly skip when domain mode is skip"
exit 1
fi
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
! grep -Fq "id: e2e_domains" <<<"$section" ||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
echo "e2e-live should resolve changed-file CLI E2E domains before credentials and tests"
exit 1
fi
if ! grep -Fq "steps.e2e_domains.outputs.live_packages" <<<"$section"; then
echo "e2e-live should use resolved live_packages instead of always running the full suite"
exit 1
fi
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$section" ||
! grep -Fq 'echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$section"; then
echo "e2e-live should pass dynamic domain output through env before shell use"
exit 1
fi
if ! awk '
/^ - name: Build lark-cli/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$dry_run_section"; then
echo "e2e-dry-run should skip building lark-cli when domain mode is skip"
exit 1
fi
if ! awk '
/^ - name: Build lark-cli/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should skip building lark-cli when domain mode is skip"
exit 1
fi
if ! grep -Fq "permissions:" <<<"$section" ||
! grep -Fq "contents: read" <<<"$section" ||
! grep -Fq "checks: write" <<<"$section"; then
@@ -304,23 +237,13 @@ if ! grep -Fq "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_A
exit 1
fi
if ! awk '
/^ - name: Configure bot credentials/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Configure bot credentials/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should only configure bot credentials when domain mode is not skip"
exit 1
fi
if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
echo "e2e-live build, configure, test, and report steps should not be gated by a skip-state output"
exit 1
fi
if ! grep -Fq "if: \${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}" <<<"$section"; then
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled or domain mode is skip"
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled"
exit 1
fi

View File

@@ -1,54 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("node:fs");
const path = require("node:path");
const DOMAIN_MAP_PATH = path.join(__dirname, "domain-map.json");
const domainMap = JSON.parse(fs.readFileSync(DOMAIN_MAP_PATH, "utf8"));
function normalizeRepoPath(input) {
return String(input || "").trim().replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
}
const pathMappingsBySpecificity = (domainMap.pathMappings || [])
.map((entry) => ({ ...entry, prefix: normalizeRepoPath(entry.prefix) }))
.sort((a, b) => b.prefix.length - a.prefix.length);
function findPathMapping(filePath) {
const normalized = normalizeRepoPath(filePath);
return pathMappingsBySpecificity.find((entry) => normalized.startsWith(entry.prefix));
}
function labelDomainsForPath(filePath) {
const mapping = findPathMapping(filePath);
return mapping ? [...(mapping.labelDomains || [])] : [];
}
function e2eDomainsForPath(filePath) {
const mapping = findPathMapping(filePath);
return mapping ? [...(mapping.e2eDomains || [])] : [];
}
function matchesFullFallback(filePath) {
const normalized = normalizeRepoPath(filePath);
return (domainMap.fullFallbackPrefixes || []).some((prefix) => normalized.startsWith(prefix));
}
function isSkippablePath(filePath) {
const normalized = normalizeRepoPath(filePath);
const basename = path.posix.basename(normalized);
return (domainMap.skipPrefixes || []).some((prefix) => normalized.startsWith(prefix))
|| (domainMap.skipSuffixes || []).some((suffix) => normalized.endsWith(suffix))
|| (domainMap.skipFilenames || []).includes(basename);
}
module.exports = {
domainMap,
e2eDomainsForPath,
findPathMapping,
isSkippablePath,
labelDomainsForPath,
matchesFullFallback,
normalizeRepoPath,
};

View File

@@ -1,71 +0,0 @@
{
"pathMappings": [
{ "prefix": "shortcuts/im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
{ "prefix": "shortcuts/vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
{ "prefix": "shortcuts/calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
{ "prefix": "shortcuts/doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
{ "prefix": "shortcuts/sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
{ "prefix": "shortcuts/drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
{ "prefix": "shortcuts/wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
{ "prefix": "shortcuts/base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
{ "prefix": "shortcuts/mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
{ "prefix": "shortcuts/task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
{ "prefix": "shortcuts/contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
{ "prefix": "shortcuts/apps/", "labelDomains": [], "e2eDomains": ["apps"] },
{ "prefix": "shortcuts/markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
{ "prefix": "shortcuts/minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
{ "prefix": "shortcuts/okr/", "labelDomains": [], "e2eDomains": ["okr"] },
{ "prefix": "shortcuts/slides/", "labelDomains": [], "e2eDomains": ["slides"] },
{ "prefix": "shortcuts/note/", "labelDomains": [], "e2eDomains": ["note"] },
{ "prefix": "shortcuts/event/", "labelDomains": [], "e2eDomains": ["event"] },
{ "prefix": "skills/lark-im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
{ "prefix": "skills/lark-vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
{ "prefix": "skills/lark-doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
{ "prefix": "skills/lark-wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
{ "prefix": "skills/lark-drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
{ "prefix": "skills/lark-sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
{ "prefix": "skills/lark-base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
{ "prefix": "skills/lark-mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
{ "prefix": "skills/lark-calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
{ "prefix": "skills/lark-task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
{ "prefix": "skills/lark-contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
{ "prefix": "skills/lark-apps/", "labelDomains": [], "e2eDomains": ["apps"] },
{ "prefix": "skills/lark-markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
{ "prefix": "skills/lark-minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
{ "prefix": "skills/lark-okr/", "labelDomains": [], "e2eDomains": ["okr"] },
{ "prefix": "skills/lark-slides/", "labelDomains": [], "e2eDomains": ["slides"] },
{ "prefix": "skills/lark-note/", "labelDomains": [], "e2eDomains": ["note"] },
{ "prefix": "skills/lark-event/", "labelDomains": [], "e2eDomains": ["event"] }
],
"fullFallbackPrefixes": [
"shortcuts/common/",
"cmd/",
"internal/",
"pkg/",
"extension/",
"registry/",
"go.mod",
"go.sum",
"Makefile",
".github/workflows/",
"scripts/"
],
"skipPrefixes": [
"docs/",
".changeset/"
],
"skipSuffixes": [
".md",
".mdx",
".txt",
".rst"
],
"skipFilenames": [
"readme.md",
"readme.zh.md",
"changelog.md",
"license",
"cla.md"
]
}

View File

@@ -1,224 +0,0 @@
#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const {
e2eDomainsForPath,
findPathMapping,
isSkippablePath,
matchesFullFallback,
normalizeRepoPath,
} = require("./domain-map");
const ROOT = process.env.E2E_DOMAINS_ROOT || path.join(__dirname, "..");
process.chdir(ROOT);
function execLines(command, args) {
return execFileSync(command, args, { encoding: "utf8" })
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
function modulePath() {
return execLines("go", ["list", "-m"])[0];
}
function rootPackage(moduleName) {
return `${moduleName}/tests/cli_e2e`;
}
function allLivePackages(moduleName) {
return execLines("go", ["list", "./tests/cli_e2e/..."])
.filter((pkg) => pkg !== rootPackage(moduleName))
.filter((pkg) => !pkg.endsWith("/demo"));
}
function allDryPackages(moduleName) {
return allLivePackages(moduleName);
}
const domainExistsCache = new Map();
function domainExists(domain) {
if (domainExistsCache.has(domain)) {
return domainExistsCache.get(domain);
}
let exists = false;
try {
execFileSync("go", ["list", `./tests/cli_e2e/${domain}`], { stdio: "ignore" });
exists = true;
} catch {
exists = false;
}
domainExistsCache.set(domain, exists);
return exists;
}
function readChangedFiles() {
const changedFilesPath = process.env.E2E_DOMAIN_CHANGED_FILES;
if (changedFilesPath) {
return fs.readFileSync(changedFilesPath, "utf8")
.split(/\r?\n/)
.map(normalizeRepoPath)
.filter(Boolean);
}
if (process.env.GITHUB_EVENT_NAME !== "pull_request") {
return null;
}
const baseRef = process.env.GITHUB_BASE_REF || "main";
try {
execFileSync("git", ["rev-parse", "--verify", `origin/${baseRef}`], { stdio: "ignore" });
return execLines("git", ["diff", "--name-only", `origin/${baseRef}...HEAD`]).map(normalizeRepoPath);
} catch {
return null;
}
}
function addDomain(domains, domain) {
if (domain && domainExists(domain)) {
domains.add(domain);
return true;
}
return false;
}
function classifyPath(filePath, domains) {
const normalized = normalizeRepoPath(filePath);
if (!normalized) return { matched: false };
const e2eMatch = normalized.match(/^tests\/cli_e2e\/([^/]+)\//);
if (e2eMatch) {
const domain = e2eMatch[1];
if (domain === "demo") return { matched: false };
if (domainExists(domain)) {
addDomain(domains, domain);
return { matched: true };
}
if (isSkippablePath(normalized)) return { matched: false };
return { fullReason: `unknown CLI E2E domain path: ${normalized}` };
}
if (normalized.startsWith("tests/cli_e2e/")) {
return { fullReason: `shared CLI E2E harness changed: ${normalized}` };
}
if (matchesFullFallback(normalized)) {
return { fullReason: `shared/runtime path changed: ${normalized}` };
}
const mappedDomains = e2eDomainsForPath(normalized);
if (mappedDomains.length > 0) {
const missingDomains = [];
for (const domain of mappedDomains) {
if (!addDomain(domains, domain)) missingDomains.push(domain);
}
if (missingDomains.length > 0) {
return { fullReason: `mapped CLI E2E domain has no package: ${missingDomains.join(",")} (${normalized})` };
}
return { matched: true };
}
if (findPathMapping(normalized)) {
return { fullReason: `mapped path has no CLI E2E package: ${normalized}` };
}
if (normalized.match(/^shortcuts\/[^/]+\//) || normalized.match(/^skills\/lark-[^/]+\//)) {
return { fullReason: `unmapped CLI E2E domain path: ${normalized}` };
}
if (isSkippablePath(normalized)) return { matched: false };
return { fullReason: `unclassified path changed: ${normalized}` };
}
function resolveDomains(changedFiles) {
const moduleName = modulePath();
const rootDryPackage = rootPackage(moduleName);
if (changedFiles === null) {
return {
mode: "full",
reason: "non-pull_request run or unavailable diff",
domains: ["all"],
dryRootPackage: rootDryPackage,
dryPackages: allDryPackages(moduleName),
livePackages: allLivePackages(moduleName),
};
}
const domains = new Set();
let matchedRelevant = false;
let fullReason = "";
for (const file of changedFiles) {
const result = classifyPath(file, domains);
if (result.matched) matchedRelevant = true;
if (result.fullReason && !fullReason) fullReason = result.fullReason;
}
if (fullReason) {
return {
mode: "full",
reason: fullReason,
domains: ["all"],
dryRootPackage: rootDryPackage,
dryPackages: allDryPackages(moduleName),
livePackages: allLivePackages(moduleName),
};
}
if (matchedRelevant && domains.size > 0) {
const sortedDomains = [...domains].sort();
const packages = sortedDomains.map((domain) => `${moduleName}/tests/cli_e2e/${domain}`);
return {
mode: "subset",
reason: "business domain changes",
domains: sortedDomains,
dryRootPackage: rootDryPackage,
dryPackages: packages,
livePackages: packages,
};
}
return {
mode: "skip",
reason: "docs-only or no live CLI E2E impact",
domains: [],
dryRootPackage: "",
dryPackages: [],
livePackages: [],
};
}
function emit(resolved) {
const values = {
mode: resolved.mode,
reason: resolved.reason,
domains: resolved.domains.join(","),
dry_root_package: resolved.dryRootPackage,
dry_packages: resolved.dryPackages.join(" "),
live_packages: resolved.livePackages.join(" "),
};
const lines = Object.entries(values).map(([key, value]) => `${key}=${value}`);
console.log(lines.join("\n"));
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`);
}
}
if (require.main === module) {
emit(resolveDomains(readChangedFiles()));
}
module.exports = {
classifyPath,
readChangedFiles,
resolveDomains,
};

View File

@@ -1,94 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const test = require("node:test");
const scriptPath = path.join(__dirname, "e2e_domains.js");
function parseOutput(raw) {
const result = {};
for (const line of raw.trim().split(/\r?\n/)) {
const idx = line.indexOf("=");
if (idx === -1) continue;
result[line.slice(0, idx)] = line.slice(idx + 1);
}
return result;
}
function runDomains(files) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-domains-"));
const file = path.join(dir, "changed.txt");
fs.writeFileSync(file, `${files.join("\n")}\n`);
try {
return parseOutput(execFileSync(process.execPath, [scriptPath], {
cwd: path.join(__dirname, ".."),
encoding: "utf8",
env: { ...process.env, E2E_DOMAIN_CHANGED_FILES: file },
}));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
test("maps shortcut changes to one business domain package", () => {
const output = runDomains(["shortcuts/im/messages/send.go"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "im");
assert.match(output.dry_root_package, /github\.com\/larksuite\/cli\/tests\/cli_e2e$/);
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/im/);
assert.doesNotMatch(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
});
test("maps doc shortcuts to docs package", () => {
const output = runDomains(["shortcuts/doc/update.go"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "docs");
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/docs/);
});
test("maps direct e2e domain package changes", () => {
const output = runDomains(["tests/cli_e2e/drive/helpers.go"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "drive");
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
});
test("falls back to full for shared e2e harness changes", () => {
const output = runDomains(["tests/cli_e2e/core.go"]);
assert.equal(output.mode, "full");
assert.equal(output.domains, "all");
assert.match(output.reason, /shared CLI E2E harness changed/);
});
test("falls back to full for runtime changes", () => {
const output = runDomains(["cmd/root.go"]);
assert.equal(output.mode, "full");
assert.equal(output.domains, "all");
assert.match(output.reason, /shared\/runtime path changed/);
});
test("skips docs-only changes", () => {
const output = runDomains(["docs/usage.md", "README.md"]);
assert.equal(output.mode, "skip");
assert.equal(output.domains, "");
assert.equal(output.dry_root_package, "");
assert.equal(output.live_packages, "");
});
test("uses shared map for skill domain changes", () => {
const output = runDomains(["skills/lark-sheets/SKILL.md"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "sheets");
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/sheets/);
});
test("falls back to full when a mapped path has no e2e package", () => {
const output = runDomains(["shortcuts/whiteboard/export.go"]);
assert.equal(output.mode, "full");
assert.match(output.reason, /unmapped CLI E2E domain path/);
});

View File

@@ -4,7 +4,6 @@
const fs = require("node:fs/promises");
const path = require("node:path");
const { labelDomainsForPath } = require("../domain-map");
// ============================================================================
// Constants & Configuration
@@ -36,6 +35,33 @@ const CORE_PREFIXES = ["internal/auth/", "internal/engine/", "internal/config/",
const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]);
const LOW_RISK_TYPES = new Set(["docs", "ci", "test", "chore"]);
// CODEOWNERS-based path to domain label mapping
// Maps shortcuts and skills paths to business domain labels
const PATH_TO_DOMAIN_MAP = {
// shortcuts
"shortcuts/im/": "im",
"shortcuts/vc/": "vc",
"shortcuts/calendar/": "calendar",
"shortcuts/doc/": "ccm",
"shortcuts/sheets/": "ccm",
"shortcuts/drive/": "ccm",
"shortcuts/wiki/": "ccm",
"shortcuts/base/": "base",
"shortcuts/mail/": "mail",
"shortcuts/task/": "task",
"shortcuts/contact/": "contact",
// skills
"skills/lark-im/": "im",
"skills/lark-vc/": "vc",
"skills/lark-doc/": "ccm",
"skills/lark-wiki/": "ccm",
"skills/lark-base/": "base",
"skills/lark-mail/": "mail",
"skills/lark-calendar/": "calendar",
"skills/lark-task/": "task",
"skills/lark-contact/": "contact",
};
const SENSITIVE_PATTERN = /(^|\/)(auth|permission|permissions|security)(\/|_|\.|$)/;
const CLASS_STANDARDS = {
@@ -259,7 +285,13 @@ function skillDomainForPath(filePath) {
// Get business domain label based on CODEOWNERS path mapping
function getBusinessDomain(filePath) {
return labelDomainsForPath(filePath)[0] || "";
const normalized = normalizePath(filePath);
for (const [prefix, domain] of Object.entries(PATH_TO_DOMAIN_MAP)) {
if (normalized.startsWith(prefix)) {
return domain;
}
}
return "";
}
async function detectNewShortcutDomain(files) {

View File

@@ -8,17 +8,7 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
script="$repo_root/scripts/resolve-changed-from.sh"
tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$"
cleanup_tmp() {
local attempt
for attempt in 1 2 3; do
rm -rf "$tmp" && return 0
sleep 1
done
rm -rf "$tmp"
}
trap cleanup_tmp EXIT
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp"
git_init() {

View File

@@ -9,7 +9,6 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -62,7 +61,6 @@ func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
// --app-type is constrained to the lowercase enum (html / full_stack) by the
// flag's Enum, so send it through verbatim. Legacy uppercase compatibility is
// a server concern and is intentionally not surfaced by the CLI.
agent := envvars.AgentName()
body := map[string]interface{}{
"name": strings.TrimSpace(rctx.Str("name")),
"app_type": rctx.Str("app-type"),
@@ -73,8 +71,5 @@ func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
if icon := strings.TrimSpace(rctx.Str("icon-url")); icon != "" {
body["icon_url"] = icon
}
if agent != "" {
body["source_agent"] = agent
}
return body
}

View File

@@ -273,93 +273,3 @@ func TestAppsCreate_FullstackDryRun(t *testing.T) {
t.Fatalf("dry-run should not contain message: %s", got)
}
}
func TestAppsCreate_WithAgentEnvVar(t *testing.T) {
t.Setenv("LARKSUITE_CLI_AGENT_NAME", "doubao")
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"app": map[string]interface{}{"app_id": "app_d", "name": "Demo"},
},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "html", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if sent["source_agent"] != "doubao" {
t.Fatalf("body.source_agent = %v, want doubao", sent["source_agent"])
}
}
func TestAppsCreate_WithoutAgentEnvVar(t *testing.T) {
t.Setenv("LARKSUITE_CLI_AGENT_NAME", "")
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"app": map[string]interface{}{"app_id": "app_d", "name": "Demo"},
},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "html", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if _, present := sent["source_agent"]; present {
t.Fatalf("source_agent should not be present when env var is empty: %v", sent)
}
}
func TestAppsCreate_AgentEnvVarNotSet(t *testing.T) {
t.Setenv("LARKSUITE_CLI_AGENT_NAME", "")
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"app": map[string]interface{}{"app_id": "app_d", "name": "Demo"},
},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "html", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if _, present := sent["source_agent"]; present {
t.Fatalf("source_agent should not be present when env var is unset: %v", sent)
}
}

View File

@@ -40,7 +40,7 @@ var AppsDBAuditList = common.Shortcut{
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -145,10 +145,7 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
existing := map[string]bool{}
token := ""
for {
params := map[string]interface{}{"page_size": 100}
if env != "" {
params["env"] = env
}
params := map[string]interface{}{"env": env, "page_size": 100}
if token != "" {
params["page_token"] = token
}
@@ -171,11 +168,7 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
// fetchAuditEnabledTables 拉审计状态返回当前已开启审计的表名集合status 命令同源接口)。
func fetchAuditEnabledTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
statusParams := map[string]interface{}{}
if env != "" {
statusParams["env"] = env
}
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), statusParams, nil)
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), map[string]interface{}{"env": env}, nil)
if err != nil {
return nil, err
}
@@ -215,10 +208,11 @@ func auditListTables(rctx *common.RuntimeContext) []string {
// buildAuditListParams 组装 audit_list 查询参数env / tables(逗号拼接) / page_size 及可选 since/until/page_token。
func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} {
params := dbEnvParams(rctx, map[string]interface{}{
params := map[string]interface{}{
"env": dbEnv(rctx),
"tables": strings.Join(tables, ","),
"page_size": rctx.Int("page-size"),
})
}
addStr := func(flag, key string) {
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
params[key] = v

View File

@@ -35,7 +35,7 @@ var AppsDBAuditEnable = common.Shortcut{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "table to enable audit for", Required: true},
{Name: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -47,7 +47,7 @@ var AppsDBAuditEnable = common.Shortcut{
return common.NewDryRunAPI().
POST(appAuditSetPath(appID)).
Desc("Enable table audit").
Params(dbEnvParams(rctx, map[string]interface{}{})).
Params(map[string]interface{}{"env": dbEnv(rctx)}).
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": true, "retention": rctx.Str("retention")})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -60,7 +60,7 @@ var AppsDBAuditEnable = common.Shortcut{
stop := rctx.StartSpinner("Enabling audit logging for " + table)
defer stop()
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
dbEnvParams(rctx, map[string]interface{}{}),
map[string]interface{}{"env": dbEnv(rctx)},
map[string]interface{}{"table": table, "enabled": true, "retention": retention})
stop()
if err != nil {
@@ -96,7 +96,7 @@ var AppsDBAuditDisable = common.Shortcut{
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "table to disable audit for", Required: true},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -108,7 +108,7 @@ var AppsDBAuditDisable = common.Shortcut{
return common.NewDryRunAPI().
POST(appAuditSetPath(appID)).
Desc("Disable table audit").
Params(dbEnvParams(rctx, map[string]interface{}{})).
Params(map[string]interface{}{"env": dbEnv(rctx)}).
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -118,7 +118,7 @@ var AppsDBAuditDisable = common.Shortcut{
}
table := strings.TrimSpace(rctx.Str("table"))
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
dbEnvParams(rctx, map[string]interface{}{}),
map[string]interface{}{"env": dbEnv(rctx)},
map[string]interface{}{"table": table, "enabled": false})
if err != nil {
return withAppsHint(err, dbAuditSetHint)

View File

@@ -30,7 +30,7 @@ var AppsDBAuditStatus = common.Shortcut{
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "show status for a single table (default: all configured tables)"},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -75,7 +75,7 @@ var AppsDBAuditStatus = common.Shortcut{
// buildAuditStatusParams 组装 audit_status 查询参数env 及可选 table单表查询
func buildAuditStatusParams(rctx *common.RuntimeContext) map[string]interface{} {
params := dbEnvParams(rctx, map[string]interface{}{})
params := map[string]interface{}{"env": dbEnv(rctx)}
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
params["table"] = t
}

View File

@@ -39,7 +39,7 @@ var AppsDBChangelogList = common.Shortcut{
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -77,9 +77,10 @@ var AppsDBChangelogList = common.Shortcut{
// buildChangelogParams 组装 changelog_list 查询参数env / page_size 及可选 table/change_id/since/until/page_token。
func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} {
params := dbEnvParams(rctx, map[string]interface{}{
params := map[string]interface{}{
"env": dbEnv(rctx),
"page_size": rctx.Int("page-size"),
})
}
addStr := func(flag, key string) {
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
params[key] = v

View File

@@ -47,7 +47,7 @@ var AppsDBDataExport = common.Shortcut{
{Name: "table", Desc: "source table", Required: true},
{Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default: <table>.csv)"},
{Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"},
}, dbEnvFlags("", []string{"dev", "online"}, "source db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "source db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -75,10 +75,10 @@ var AppsDBDataExport = common.Shortcut{
return common.NewDryRunAPI().
GET(appDataExportPath(appID)).
Desc("Export Miaoda app table data (raw bytes)").
Params(dbEnvParams(rctx, map[string]interface{}{
"table": strings.TrimSpace(rctx.Str("table")),
Params(map[string]interface{}{
"env": dbEnv(rctx), "table": strings.TrimSpace(rctx.Str("table")),
"format": format, "limit": rctx.Int("limit"),
}))
})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
@@ -95,18 +95,15 @@ var AppsDBDataExport = common.Shortcut{
// total 查询失败不阻断导出——回退到按导出文件内容数行。
total, totalErr := queryExportTotal(rctx, appID, dbEnv(rctx), table)
exportQuery := larkcore.QueryParams{
"table": []string{table},
"format": []string{format},
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
}
if env := dbEnv(rctx); env != "" {
exportQuery["env"] = []string{env}
}
resp, err := rctx.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: appDataExportPath(appID),
QueryParams: exportQuery,
HttpMethod: http.MethodGet,
ApiPath: appDataExportPath(appID),
QueryParams: larkcore.QueryParams{
"env": []string{dbEnv(rctx)},
"table": []string{table},
"format": []string{format},
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
},
})
if err != nil {
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint)
@@ -160,11 +157,8 @@ var AppsDBDataExport = common.Shortcut{
// queryExportTotal 调 GetAppTableRecordListpage_size=1取 total符合条件的记录总数
// 该接口与 +db-data-export 同为 spark:app:read scope避免导出命令被迫升级到写权限。
func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) {
params := map[string]interface{}{"page_size": 1}
if env != "" {
params["env"] = env
}
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table), params, nil)
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table),
map[string]interface{}{"env": env, "page_size": 1}, nil)
if err != nil {
return 0, err
}

View File

@@ -44,7 +44,7 @@ var AppsDBDataImport = common.Shortcut{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "file", Desc: "local data file (.csv/.json), relative to cwd", Required: true},
{Name: "table", Desc: "target table (default: file name without extension)"},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -76,7 +76,7 @@ var AppsDBDataImport = common.Shortcut{
return common.NewDryRunAPI().
POST(appDataImportPath(appID)).
Desc("Import data file into Miaoda app table (multipart upload)").
Params(dbEnvParams(rctx, map[string]interface{}{"table": importTableName(rctx)})).
Params(map[string]interface{}{"env": dbEnv(rctx), "table": importTableName(rctx)}).
Body(map[string]interface{}{"file_name": fileName, "file": "<contents of --file>"})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -100,14 +100,10 @@ var AppsDBDataImport = common.Shortcut{
fd.AddField("file_name", fileName)
fd.AddFile("file", bytes.NewReader(content))
importQuery := larkcore.QueryParams{"table": []string{table}}
if env := dbEnv(rctx); env != "" {
importQuery["env"] = []string{env}
}
resp, err := rctx.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: appDataImportPath(appID),
QueryParams: importQuery,
QueryParams: larkcore.QueryParams{"env": []string{dbEnv(rctx)}, "table": []string{table}},
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {

View File

@@ -121,31 +121,6 @@ func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) {
}
}
// TestAppsDBDataImport_DryRunOmitsEnvWhenUnset 验证不传 --environment 时 dry-run 的 query
// 不带 env 键(交服务端按应用形态自动选分支),但仍携带 table。
func TestAppsDBDataImport_DryRunOmitsEnvWhenUnset(t *testing.T) {
chdirTemp(t)
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBDataImport,
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
p := env.API[0].Params
if _, ok := p["env"]; ok {
t.Fatalf("no --environment → env key must be omitted, got params=%v", p)
}
if p["table"] != "orders" {
t.Fatalf("table should still default to file basename, got params=%v", p)
}
}
// TestAppsDBDataImport_Success 验证成功导入后输出含 table、rows 与回显的 file 名。
func TestAppsDBDataImport_Success(t *testing.T) {
chdirTemp(t)

View File

@@ -97,16 +97,6 @@ var AppsDBEnvMigrate = common.Shortcut{
if err != nil {
return err
}
// 先 dry_run 预览拿待发布变更数(对齐 miaoda-cli 的 diff-then-apply服务端在未经
// dry_run 预热时直接 apply虽发布成功却把 changes_applied 回填成 0展示「Migrated (0 changes)」)。
// 这一步既预热服务端计数、又作为 apply 仍回 0 时的兜底数。dry_run 报错(如无待发布变更)不阻断,
// 交由下面真实 apply 统一报同样的业务错。
pending := 0
var previewFrom, previewTo string
if preview, perr := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": true}); perr == nil {
pending = len(projectMigrationChanges(preview["changes"]))
previewFrom, previewTo = common.GetString(preview, "from"), common.GetString(preview, "to")
}
stop := rctx.StartSpinner("Applying migration (dev → online)")
defer stop()
submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false})
@@ -114,12 +104,6 @@ var AppsDBEnvMigrate = common.Shortcut{
return withAppsHint(err, dbEnvMigrateHint)
}
from, to := common.GetString(submit, "from"), common.GetString(submit, "to")
if from == "" {
from = previewFrom
}
if to == "" {
to = previewTo
}
taskID := common.GetString(submit, "task_id")
applied := intFromAny(submit["changes_applied"])
if applied == 0 {
@@ -147,10 +131,6 @@ var AppsDBEnvMigrate = common.Shortcut{
applied = n
}
}
// 服务端把发布成功的变更数回 0 时,用发布前 dry_run 预览的 pending 数兜底,避免误显示「(0 changes)」。
if applied == 0 && pending > 0 {
applied = pending
}
stop() // clear spinner before printing the result
out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied}
rctx.OutFormat(out, nil, func(w io.Writer) {

View File

@@ -105,10 +105,8 @@ func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) {
// 异步submit 返 task_idstatus 立刻 applied → CLI 对外统一 migrated。
func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
// ReusableExecute 现在会先打一次 dry_run 预览拿待发布数、再打 apply对齐 miaoda-cli 的
// diff-then-apply兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
reg.Register(&httpmock.Stub{
Method: "POST", URL: dbEnvMigrateURL, Reusable: true,
Method: "POST", URL: dbEnvMigrateURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
})
reg.Register(&httpmock.Stub{
@@ -128,10 +126,8 @@ func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
// TestAppsDBEnvMigrate_PollFailedSurfacesError 验证轮询到 failed 时返回 API/server_error 类型错误,携带服务端 message 与恢复 hint。
func TestAppsDBEnvMigrate_PollFailedSurfacesError(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
// ReusableExecute 现在会先打一次 dry_run 预览拿待发布数、再打 apply对齐 miaoda-cli 的
// diff-then-apply兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
reg.Register(&httpmock.Stub{
Method: "POST", URL: dbEnvMigrateURL, Reusable: true,
Method: "POST", URL: dbEnvMigrateURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
})
reg.Register(&httpmock.Stub{
@@ -323,31 +319,6 @@ func TestAppsDBQuotaGet_WithQuotaPretty(t *testing.T) {
}
// 配额未对接storage_quota_bytes=0→ json 删 quota/usage_percent仅留已用量与 tables/views。
// TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset 验证不传 --environment 时 quota-get 的 dry-run
// query 不带 env 键(交服务端按应用形态自动选分支)。
func TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBQuotaGet,
[]string{"+db-quota-get", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "GET" || a.URL != dbQuotaURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
if _, ok := a.Params["env"]; ok {
t.Fatalf("no --environment → env key must be omitted, got params=%v", a.Params)
}
}
func TestAppsDBQuotaGet_NoQuotaOmitsFields(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{

View File

@@ -66,7 +66,7 @@ var AppsDBExecute = common.Shortcut{
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
Input: []string{common.Stdin}},
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -291,9 +291,10 @@ func parseErrorSentinel(data string) (int, string) {
//
// CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。
func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
return dbEnvParams(rctx, map[string]interface{}{
return map[string]interface{}{
"env": dbEnv(rctx),
"transactional": false,
})
}
}
// resolveExecuteSQL 返回要执行的 SQL在用时DryRun/Execute现读使 --file 的内容

View File

@@ -29,7 +29,7 @@ var AppsDBQuotaGet = common.Shortcut{
HasFormat: true,
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -41,14 +41,14 @@ var AppsDBQuotaGet = common.Shortcut{
return common.NewDryRunAPI().
GET(appDbQuotaPath(appID)).
Desc("Get Miaoda app database storage usage").
Params(dbEnvParams(rctx, map[string]interface{}{}))
Params(map[string]interface{}{"env": dbEnv(rctx)})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil)
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), map[string]interface{}{"env": dbEnv(rctx)}, nil)
if err != nil {
return withAppsHint(err, appIDListHint)
}

View File

@@ -32,23 +32,19 @@ var AppsDBRecoveryDiff = common.Shortcut{
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: append([]common.Flag{
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if err := rejectLegacyEnvFlag(rctx); err != nil {
return err
}
return normalizeTimeFlags(rctx, "target")
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Preview PITR recovery").
Params(dbEnvParams(rctx, map[string]interface{}{})).
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": true})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -85,23 +81,19 @@ var AppsDBRecoveryApply = common.Shortcut{
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: append([]common.Flag{
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if err := rejectLegacyEnvFlag(rctx); err != nil {
return err
}
return normalizeTimeFlags(rctx, "target")
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Apply PITR recovery").
Params(dbEnvParams(rctx, map[string]interface{}{})).
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": false})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -112,7 +104,7 @@ var AppsDBRecoveryApply = common.Shortcut{
target := rctx.Str("target")
stop := rctx.StartSpinner("Restoring database (target: " + target + ")")
defer stop()
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": false})
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": false})
if err != nil {
return withAppsHint(err, dbRecoveryHint)
}
@@ -127,7 +119,7 @@ var AppsDBRecoveryApply = common.Shortcut{
}
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 2*time.Minute,
func() (map[string]interface{}, error) {
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil)
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil)
},
func(d map[string]interface{}) (bool, error) {
switch strings.ToLower(common.GetString(d, "status")) {
@@ -165,7 +157,7 @@ var AppsDBRecoveryApply = common.Shortcut{
func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[string]interface{}, error) {
stop := rctx.StartSpinner("Previewing recovery impact (target: " + target + ")")
defer stop()
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": true})
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": true})
if err != nil {
return nil, withAppsHint(err, dbRecoveryHint)
}
@@ -175,7 +167,7 @@ func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[
}
return pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute,
func() (map[string]interface{}, error) {
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), dbEnvParams(rctx, map[string]interface{}{"preview_request_id": prid}), nil)
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil)
},
func(d map[string]interface{}) (bool, error) {
switch strings.ToLower(common.GetString(d, "preview_status")) {
@@ -203,13 +195,13 @@ type recoveryChange struct {
// recoveryDiffOutput 组装 diff 输出target / tables_affected / changes[] / estimated_seconds。
func recoveryDiffOutput(target string, preview map[string]interface{}) map[string]interface{} {
arr, _ := preview["changes"].([]interface{})
raw := make([]recoveryChange, 0, len(arr))
changes := make([]recoveryChange, 0, len(arr))
for _, it := range arr {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
raw = append(raw, recoveryChange{
changes = append(changes, recoveryChange{
Table: common.GetString(m, "table"),
Inserted: m["inserted"],
Deleted: m["deleted"],
@@ -217,33 +209,16 @@ func recoveryDiffOutput(target string, preview map[string]interface{}) map[strin
DroppedAt: common.GetString(m, "dropped_at"),
})
}
// 服务端可能对同一张表既下发 schema 动作(drop/restore/alter)、又下发纯数据行变更。
// schema 动作已涵盖数据结果(如 drop 隐含删光行),丢弃该表的冗余数据行那条,避免同表
// 两行 + tables_affected 翻倍。
hasSchema := map[string]bool{}
for _, c := range raw {
if c.Action != "" {
hasSchema[c.Table] = true
}
}
changes := make([]recoveryChange, 0, len(raw))
for _, c := range raw {
if c.Action == "" && hasSchema[c.Table] {
continue
}
changes = append(changes, c)
}
// tables_affected 按去重后的不同表数计(而非变更条数)。
seen := map[string]bool{}
for _, c := range changes {
seen[c.Table] = true
tablesAffected := intFromAny(preview["tables_affected"])
if tablesAffected == 0 {
tablesAffected = len(changes)
}
est := intFromAny(preview["estimated_seconds"])
if est == 0 {
est = 30 // PRD 兜底
}
return map[string]interface{}{
"target": target, "tables_affected": len(seen),
"target": target, "tables_affected": tablesAffected,
"changes": changes, "estimated_seconds": est,
}
}

View File

@@ -37,7 +37,7 @@ var AppsDBTableGet = common.Shortcut{
Flags: append([]common.Flag{
{Name: "app-id", Desc: "app id", Required: true},
{Name: "table", Desc: "table name", Required: true},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -80,7 +80,7 @@ var AppsDBTableGet = common.Shortcut{
// CLI 检测 rctx.Format == "pretty" 时给 server 带 format=ddl要求返 CREATE 语句文本;
// 其他 format含默认 json不传该参数让 server 返默认结构化字段。
func buildDBTableGetParams(rctx *common.RuntimeContext) map[string]interface{} {
params := dbEnvParams(rctx, map[string]interface{}{})
params := map[string]interface{}{"env": dbEnv(rctx)}
if rctx.Format == "pretty" {
params["format"] = "ddl"
}

View File

@@ -8,7 +8,6 @@ import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"github.com/larksuite/cli/shortcuts/common"
@@ -43,7 +42,7 @@ var AppsDBTableList = common.Shortcut{
{Name: "app-id", Desc: "app id", Required: true},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -111,9 +110,10 @@ func projectTableListItems(raw interface{}) []dbTableListItem {
}
func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} {
params := dbEnvParams(rctx, map[string]interface{}{
params := map[string]interface{}{
"env": dbEnv(rctx),
"page_size": rctx.Int("page-size"),
})
}
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
params["page_token"] = token
}
@@ -286,17 +286,6 @@ func numericAsFloat(raw interface{}) (float64, bool) {
return 0, false
}
return f, true
case string:
// 服务端有些数值字段(如 recovery diff 的 inserted/deleted 行数)以字符串下发。
s := strings.TrimSpace(v)
if s == "" {
return 0, false
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, false
}
return f, true
case nil:
return 0, false
}

View File

@@ -236,11 +236,7 @@ func TestNumericAsFloat_AllTypes(t *testing.T) {
{"json.Number valid", json.Number("13.5"), 13.5, true},
{"json.Number invalid", json.Number("abc"), 0, false},
{"nil", nil, 0, false},
{"non-numeric string", "x", 0, false},
{"numeric string", "13.5", 13.5, true},
{"numeric string int", "2", 2, true},
{"numeric string padded", " 13.5 ", 13.5, true},
{"empty string", "", 0, false},
{"unsupported string", "x", 0, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {

View File

@@ -44,9 +44,8 @@ func appsExternalToolError(err error, format string, args ...any) *errs.Internal
return errs.NewInternalError(errs.SubtypeExternalTool, format, args...).WithCause(err)
}
// appsSubprocessEnvelopeError classifies a malformed or unexpected response
// structure as internal/invalid_response. Used for subprocess envelopes
// (+git-credential-init / +env-pull) and server responses (e.g. pre_release).
// appsSubprocessEnvelopeError classifies a malformed or failed envelope from a
// lark-cli subprocess (+git-credential-init / +env-pull) as internal/invalid_response.
func appsSubprocessEnvelopeError(format string, args ...any) *errs.InternalError {
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...)
}

View File

@@ -1,67 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsGet fetches a single app's detail by app ID.
var AppsGet = common.Shortcut{
Service: appsService,
Command: "+get",
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 <app_id> --dry-run",
"Tip: extract app type with --jq '.data.app.app_type'",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
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, 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"))
data, err := rctx.CallAPITyped("GET", fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID)), nil, nil)
if err != nil {
return withAppsHint(err, appIDListHint)
}
rctx.OutFormat(data, nil, func(w io.Writer) {
app, _ := data["app"].(map[string]interface{})
if app == nil {
return
}
fmt.Fprintf(w, "app_id: %v\n", app["app_id"])
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 != "" {
fmt.Fprintf(w, "description: %s\n", desc)
}
fmt.Fprintf(w, "is_published: %v\n", app["is_published"])
fmt.Fprintf(w, "updated_at: %v\n", app["updated_at"])
})
return nil
},
}

View File

@@ -1,109 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAppsGet_Success(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_test",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_test",
"app_type": "html",
"name": "TestApp",
"description": "A test application",
"icon_url": "https://example.com/icon.svg",
"is_published": true,
"created_at": "2026-05-18T10:00:00Z",
"updated_at": "2026-06-01T12:00:00Z",
},
},
},
})
if err := runAppsShortcut(t, AppsGet,
[]string{"+get", "--app-id", "app_test", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "app_test") {
t.Fatalf("stdout missing app_id: %s", got)
}
if !strings.Contains(got, "html") {
t.Fatalf("stdout missing app_type: %s", got)
}
}
func TestAppsGet_RequiresAppID(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsGet,
[]string{"+get", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "app-id") {
t.Fatalf("expected app-id required error, got %v", err)
}
}
func TestAppsGet_EmptyAppID(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsGet,
[]string{"+get", "--app-id", "", "--as", "user"}, factory, stdout)
requireAppsValidationProblem(t, err)
}
func TestAppsGet_DryRun(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsGet,
[]string{"+get", "--app-id", "app_test", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_test") {
t.Fatalf("dry-run missing API path: %s", got)
}
}
func TestAppsGet_PrettyOutput(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_test",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_test",
"app_type": "html",
"name": "PrettyApp",
"description": "A pretty test app",
"is_published": true,
"updated_at": "2026-06-01T12:00:00Z",
},
},
},
})
if err := runAppsShortcut(t, AppsGet,
[]string{"+get", "--app-id", "app_test", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, want := range []string{"app_id:", "app_type:", "name:", "is_published:"} {
if !strings.Contains(got, want) {
t.Fatalf("pretty output missing %q: %s", want, got)
}
}
}

View File

@@ -4,32 +4,29 @@
package apps
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"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"
)
// AppsHTMLPublish packs --path as tar.gz and publishes an HTML app.
// AppsHTMLPublish packs --path as tar.gz and uploads + publishes via one multipart POST.
var AppsHTMLPublish = common.Shortcut{
Service: appsService,
Command: "+html-publish",
Description: "Publish HTML to an app (returns url or release_id depending on app type)",
Description: "Publish HTML to an app (single multipart POST returns the access URL)",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +html-publish --app-id <app_id> --path ./dist",
"Example: lark-cli apps +html-publish --app-id <app_id> --path ./site --dry-run",
},
Scopes: []string{"spark:app:write", "spark:app:read"},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
@@ -73,7 +70,7 @@ 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 and publish HTML app (actual API path determined at runtime by app type; returns url or release_id)")
dry.Desc("Upload tar.gz + publish HTML (multipart, returns url)")
dry.POST(fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID))).
Set("content_type", "multipart/form-data")
@@ -122,17 +119,8 @@ var AppsHTMLPublish = common.Shortcut{
AppID: strings.TrimSpace(rctx.Str("app-id")),
Path: strings.TrimSpace(rctx.Str("path")),
}
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)
}
client := appsHTMLPublishAPI{runtime: rctx}
out, err := runHTMLPublish(ctx, rctx.FileIO(), client, spec)
if err != nil {
return err
}
@@ -140,9 +128,6 @@ var AppsHTMLPublish = common.Shortcut{
if url, ok := out["url"].(string); ok && url != "" {
fmt.Fprintf(w, "url: %s\n", url)
}
if rid, ok := out["release_id"].(string); ok && rid != "" {
fmt.Fprintf(w, "release_id: %s\n", rid)
}
})
return nil
},
@@ -229,11 +214,8 @@ func ensureIndexHTML(candidates []htmlPublishCandidate) error {
WithHint("index.html is the app entrypoint; for a directory put index.html at the root, or pass a single file named index.html")
}
// prepareHTMLPublishTarball validates candidates under path and builds a
// tar.gz payload ready for upload. Shared by runHTMLPublish and
// runHTMLPublishTOS.
func prepareHTMLPublishTarball(fio fileio.FileIO, path string) (*htmlPublishTarball, error) {
candidates, err := walkHTMLPublishCandidates(fio, path)
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
candidates, err := walkHTMLPublishCandidates(fio, spec.Path)
if err != nil {
return nil, err
}
@@ -256,19 +238,12 @@ func prepareHTMLPublishTarball(fio fileio.FileIO, path string) (*htmlPublishTarb
if err != nil {
return nil, err
}
if tarball.Size > maxHTMLPublishTarballBytes {
return nil, appsValidationParamError("--path",
"packed tar.gz size %d bytes exceeds %d bytes limit", tarball.Size, maxHTMLPublishTarballBytes).
WithHint("reduce --path contents, remove unrelated large files, then retry")
}
return tarball, nil
}
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 {
@@ -281,74 +256,3 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPu
}
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) {
tarball, err := prepareHTMLPublishTarball(rctx.FileIO(), spec.Path)
if err != nil {
return nil, err
}
// Step 1: call pre_release to get TOS upload URL and tos_path.
preReleasePath := fmt.Sprintf("%s/apps/%s/pre_release", apiBasePath, validate.EncodePathSegment(spec.AppID))
preData, err := rctx.CallAPITyped("GET", preReleasePath, nil, nil)
if err != nil {
return nil, err
}
kvs, _ := preData["kvs"].([]interface{})
if len(kvs) == 0 {
return nil, appsSubprocessEnvelopeError("pre_release returned no kvs")
}
kvm := make(map[string]string, len(kvs))
for _, item := range kvs {
kv, _ := item.(map[string]interface{})
if kv == nil {
continue
}
k, _ := kv["key"].(string)
v, _ := kv["value"].(string)
if k != "" {
kvm[k] = v
}
}
uploadURL := kvm["upload_url"]
tosPath := kvm["tos_path"]
if uploadURL == "" || tosPath == "" {
return nil, appsSubprocessEnvelopeError("pre_release kvs missing upload_url or tos_path")
}
// Step 2: upload tar.gz to TOS via presigned URL (bypasses Lark gateway).
//nolint:forbidigo // presigned TOS upload bypasses the Lark gateway — raw http is required; not a Lark API call, so RuntimeContext.DoAPI does not apply.
req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, bytes.NewReader(tarball.Body))
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "build TOS upload request").WithCause(err)
}
req.ContentLength = tarball.Size
req.Header.Set("Content-Type", "application/gzip")
resp, err := newFileTransferClient().Do(req) //nolint:forbidigo // presigned TOS upload, see above.
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "TOS upload failed").WithCause(err).WithRetryable()
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
if resp.StatusCode >= 500 {
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer, "TOS upload failed: HTTP %d", resp.StatusCode).WithRetryable()
}
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "TOS upload failed: HTTP %d", resp.StatusCode)
}
// Step 3: call release-create with tos_path to trigger deployment.
releasePath := fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(spec.AppID))
releaseData, err := rctx.CallAPITyped("POST", releasePath, nil, map[string]interface{}{
"tos_path": tosPath,
})
if err != nil {
return nil, err
}
return map[string]interface{}{
"release_id": common.GetString(releaseData, "release_id"),
}, nil
}

View File

@@ -6,21 +6,10 @@ package apps
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
type fakeAppsHTMLPublishClient struct {
@@ -64,7 +53,7 @@ func TestRunHTMLPublish_HappyPath(t *testing.T) {
}
func TestRunHTMLPublish_OnlyURLInEnvelope(t *testing.T) {
// Pin 概要设计 §5.3 不变量 4 "同步语义不会变成异步" (legacy html path only):
// Pin 概要设计 §5.3 不变量 4 "同步语义不会变成异步":
// envelope 只含 url未来若有人加 status / release_id 字段会被这个测试拦截。
site := writeAppsSampleSite(t)
fake := &fakeAppsHTMLPublishClient{
@@ -593,226 +582,3 @@ func TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
t.Fatalf("client should be called; calls=%v", fake.calls)
}
}
// ── runHTMLPublishTOS tests ──
// permissiveFIOProvider wraps permissiveFIO as a fileio.Provider for tests
// that call runHTMLPublishTOS (which obtains FileIO via rctx.FileIO()).
type permissiveFIOProvider struct{}
func (permissiveFIOProvider) Name() string { return "test-permissive" }
func (permissiveFIOProvider) ResolveFileIO(context.Context) fileio.FileIO { return permissiveFIO{} }
// newTOSTestRuntime builds a RuntimeContext with httpmock registry and a
// permissive FileIO provider, ready for runHTMLPublishTOS unit tests.
func newTOSTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
t.Helper()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "test-app-" + strings.ToLower(t.Name()),
AppSecret: "test-secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_test",
}
factory, _, _, reg := cmdutil.TestFactory(t, cfg)
factory.FileIOProvider = permissiveFIOProvider{}
rt := common.TestNewRuntimeContextForAPI(
context.Background(),
&cobra.Command{Use: "+tos-test"},
cfg, factory, core.AsUser,
)
return rt, reg
}
func TestRunHTMLPublishTOS_Success(t *testing.T) {
site := writeAppsSampleSite(t)
rt, reg := newTOSTestRuntime(t)
// Start httptest server to accept the TOS upload.
tosServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
t.Errorf("TOS upload method = %s, want PUT", r.Method)
}
if ct := r.Header.Get("Content-Type"); ct != "application/gzip" {
t.Errorf("TOS upload Content-Type = %s, want application/gzip", ct)
}
w.WriteHeader(http.StatusOK)
}))
defer tosServer.Close()
// Register pre_release API stub.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"kvs": []interface{}{
map[string]interface{}{"key": "upload_url", "value": tosServer.URL},
map[string]interface{}{"key": "tos_path", "value": "tos://bucket/key"},
},
},
},
})
// Register release-create API stub.
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_tos/releases",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"release_id": "rel_123",
"status": "publishing",
},
},
})
out, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
AppID: "app_tos",
Path: site,
})
if err != nil {
t.Fatalf("err=%v", err)
}
if out["release_id"] != "rel_123" {
t.Fatalf("release_id=%v, want rel_123", out["release_id"])
}
}
func TestRunHTMLPublishTOS_MissingIndexHTML(t *testing.T) {
dir := t.TempDir()
// Create a file that is NOT named index.html.
if err := os.WriteFile(filepath.Join(dir, "foo.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
rt, _ := newTOSTestRuntime(t)
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
AppID: "app_tos",
Path: dir,
})
if err == nil {
t.Fatalf("expected error for missing index.html")
}
if !strings.Contains(err.Error(), "index.html") {
t.Fatalf("error should mention index.html, got: %v", err)
}
}
func TestRunHTMLPublishTOS_PreReleaseError(t *testing.T) {
site := writeAppsSampleSite(t)
rt, reg := newTOSTestRuntime(t)
// Register pre_release API stub that returns an error code.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
Body: map[string]interface{}{
"code": float64(99999),
"msg": "internal server error",
},
})
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
AppID: "app_tos",
Path: site,
})
if err == nil {
t.Fatalf("expected error from pre_release API failure")
}
}
func TestRunHTMLPublishTOS_MissingParams(t *testing.T) {
site := writeAppsSampleSite(t)
rt, reg := newTOSTestRuntime(t)
// Register pre_release API stub that returns empty kvs list.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"kvs": []interface{}{},
},
},
})
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
AppID: "app_tos",
Path: site,
})
if err == nil {
t.Fatalf("expected error for empty kvs")
}
problem := requireAppsProblem(t, err, errs.CategoryInternal)
if !strings.Contains(problem.Message, "no kvs") {
t.Fatalf("error should mention 'no kvs', got: %q", problem.Message)
}
}
func TestRunHTMLPublishTOS_MissingParamsObject(t *testing.T) {
site := writeAppsSampleSite(t)
rt, reg := newTOSTestRuntime(t)
// Register pre_release API stub that returns no kvs key at all.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{},
},
})
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
AppID: "app_tos",
Path: site,
})
if err == nil {
t.Fatalf("expected error for missing kvs")
}
problem := requireAppsProblem(t, err, errs.CategoryInternal)
if !strings.Contains(problem.Message, "no kvs") {
t.Fatalf("error should mention 'no kvs', got: %q", problem.Message)
}
}
func TestRunHTMLPublishTOS_UploadFails(t *testing.T) {
site := writeAppsSampleSite(t)
rt, reg := newTOSTestRuntime(t)
// Start httptest server that returns 500.
tosServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer tosServer.Close()
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_tos/pre_release",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"kvs": []interface{}{
map[string]interface{}{"key": "upload_url", "value": tosServer.URL},
map[string]interface{}{"key": "tos_path", "value": "tos://bucket/key"},
},
},
},
})
_, err := runHTMLPublishTOS(context.Background(), rt, appsHTMLPublishSpec{
AppID: "app_tos",
Path: site,
})
if err == nil {
t.Fatalf("expected error from TOS upload failure")
}
problem := requireAppsProblem(t, err, errs.CategoryNetwork)
if !strings.Contains(problem.Message, "500") {
t.Fatalf("error should mention HTTP 500, got: %q", problem.Message)
}
}

View File

@@ -11,7 +11,6 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"unicode"
@@ -39,58 +38,17 @@ const (
)
const (
miaodaCLIPkg = "@lark-apaas/miaoda-cli@0.1.20-alpha.dd573f8"
npmRegistry = "https://registry.npmmirror.com"
miaodaCLIPkg = "@lark-apaas/miaoda-cli@latest"
defaultTemplate = "nestjs-react-fullstack"
metaRelPath = ".spark/meta.json"
steeringRelPath = ".agent/skills/steering"
seedReadme = "README.md"
)
// Fallback committer identity written to the cloned repo's LOCAL git config when
// no user.name/user.email is resolvable (from local, global, or system config).
// The scaffold's `git commit` would otherwise fail with "please tell me who you
// are"; an existing identity (e.g. the developer's global config) is respected.
const (
defaultGitUserName = "lark-cli-bot"
defaultGitUserEmail = "lark-cli-bot@miaoda.com"
)
// initRunner is the commandRunner used by +init. Package-level so unit tests
// can swap in a fakeCommandRunner. Production uses execCommandRunner.
var initRunner commandRunner = execCommandRunner{}
// appTypePolicy captures the per-app-type control points +init toggles, keeping
// each knob out of the inline `appType == "..."` checks that would otherwise be
// scattered through the flow. Add a field here (and set it in appTypePolicies)
// for each new control point rather than threading another type comparison
// through appsInitExecute.
type appTypePolicy struct {
// skipInstall passes --skip-install to `npx ... app init`, so scaffolding
// runs no dependency install.
skipInstall bool
// skipEnvPull skips the post-init `+env-pull` step, on both the fresh-init
// tail and the already-initialized refresh path.
skipEnvPull bool
// skipSkillsSync skips the conditional `npx ... skills sync --local` step on
// the non-empty (`app sync`) scaffold path.
skipSkillsSync 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 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
// types (including "") get the zero-value policy.
func policyForAppType(appType string) appTypePolicy {
return appTypePolicies[appType]
}
// AppsInit initializes an app's code and local development environment.
var AppsInit = common.Shortcut{
Service: appsService,
@@ -101,15 +59,13 @@ var AppsInit = common.Shortcut{
"Example: lark-cli apps +init --app-id <app_id> --dir <dir>",
"Example: lark-cli apps +init --app-id <app_id> --dir <dir> --dry-run",
},
// +init calls queryAppType (GET /apps/{id}) which requires spark:app:read;
// the scope is declared as conditional since the call is non-fatal.
// The git credential subprocess enforces its own scopes independently.
// Explicit []string{} (not nil) per the convention enforced by
// TestAllShortcutsScopesNotNil.
Scopes: []string{},
ConditionalScopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
// +init makes no direct lark API calls (it shells out to the
// +git-credential-init subprocess, which enforces its own scopes), so it
// declares no scopes of its own. Explicit []string{} (not nil) per the
// convention enforced by TestAllShortcutsScopesNotNil.
Scopes: []string{},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
// NOTE: --app-id is intentionally NOT Required:true. The framework maps
// Required:true to cobra's MarkFlagRequired, whose error is plain-text
@@ -119,28 +75,24 @@ var AppsInit = common.Shortcut{
// check lives in Validate (typed validation error -> exit 2).
{Name: "app-id", Desc: "app ID"},
{Name: "dir", Desc: "clone target directory; absolute or relative path (default ./<app-id>)"},
{Name: "source-path", Desc: "path to existing source files (e.g. HTML output from an agent) to incorporate into the initialized project"},
{Name: "template", Desc: "code-init template for an empty repo; optional — if omitted, derived from the app's tech stack"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required")
}
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)
}
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
template := resolveTemplate(rctx, appID)
dry := common.NewDryRunAPI().
Desc("Initialize app code (credential-init, clone, checkout, npx code-init, optional commit/push)").
Set("credential_init", fmt.Sprintf("apps +git-credential-init --app-id %s --format json", appID)).
Set("checkout", "git checkout "+defaultInitBranch).
Set("scaffold", fmt.Sprintf("empty repo: npx -y --prefer-online %s app init --app-type <appType> --app-id %s; non-empty: npx -y --prefer-online %s app sync + .spark/meta.json app_id patch + conditional skills sync --local", miaodaCLIPkg, appID, miaodaCLIPkg)).
Set("scaffold", fmt.Sprintf("empty repo: npx -y --prefer-online %s app init --template %s --app-id %s; non-empty: npx -y --prefer-online %s app sync + .spark/meta.json app_id patch + conditional skills sync --local", miaodaCLIPkg, template, appID, miaodaCLIPkg)).
Set("commit_push", "conditional: git add -A + commit + push origin "+defaultInitBranch+" when the working tree has changes").
Set("template", "derived from queryAppType (fallback: full_stack)").
Set("template", template).
Set("env_pull", fmt.Sprintf("apps +env-pull --app-id %s --project-path <clone_path> --format json (after successful init)", appID))
dir, err := resolveTargetPath(rctx, appID)
if err != nil {
@@ -170,6 +122,20 @@ func defaultCloneDir(appID string) string {
return filepath.Join(".", appID)
}
// resolveTemplate returns the scaffold template for an empty-repo `app init`.
// An explicit --template wins. When omitted, it should be derived from the
// app's tech stack.
// TODO(apps-init): look up the app by appID via the apps API (e.g. `apps +list`
// or a get-app endpoint), read its tech stack, and map tech-stack -> template
// through a (future) enum. Until that lands, fall back to defaultTemplate.
func resolveTemplate(rctx *common.RuntimeContext, appID string) string {
if t := strings.TrimSpace(rctx.Str("template")); t != "" {
return t
}
// TODO(apps-init): derive from app tech stack (apps API + enum mapping).
return defaultTemplate
}
// initLogf writes a one-line progress message to stderr. stdout stays reserved
// for the structured JSON envelope, so progress never pollutes it. Callers must
// never pass a raw repository_url (it may embed a token) — pass step names,
@@ -328,34 +294,6 @@ func ensureMetaAppID(dir, appID string) error {
return nil
}
// ensureGitIdentity guarantees the cloned repo has a committer identity so the
// scaffold's `git commit` cannot fail with "please tell me who you are". It sets
// the repo-LOCAL user.name/user.email to the lark-cli-bot defaults ONLY when
// 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 string) error {
if err := ensureGitConfigValue(ctx, dir, "user.name", defaultGitUserName); err != nil {
return err
}
return ensureGitConfigValue(ctx, dir, "user.email", defaultGitUserEmail)
}
// ensureGitConfigValue sets <key>=fallback in the repo-local git config when key
// resolves to no value. `git config --get` exits non-zero (or prints nothing)
// when the key is unset at every scope; any resolved value (including one
// inherited from global/system) is left untouched.
func ensureGitConfigValue(ctx context.Context, dir, key, fallback string) error {
stdout, _, err := initRunner.Run(ctx, dir, "git", "config", "--get", key)
if err == nil && strings.TrimSpace(stdout) != "" {
return nil // already configured at some scope — respect it
}
if _, stderr, e := initRunner.Run(ctx, dir, "git", "config", key, fallback); e != nil {
return appsExternalToolError(e, "git config %s failed: %s", key, gitErr(stderr, e))
}
return nil
}
// hasSteeringSkills reports whether <dir>/.agent/skills/steering exists as a dir.
func hasSteeringSkills(dir string) bool {
info, err := os.Stat(filepath.Join(dir, steeringRelPath)) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Stat rejects absolute paths.
@@ -388,54 +326,34 @@ func isEmptyRepo(ctx context.Context, dir string) (bool, error) {
// runScaffold runs the npx scaffolding step inside the cloned repo (cwd=dir).
// Empty repo -> `app init`; non-empty -> `app sync` + meta app_id patch +
// conditional `skills sync`. Returns "init" or "upgrade".
func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (string, error) {
func runScaffold(ctx context.Context, dir, appID, template string) (string, error) {
empty, err := isEmptyRepo(ctx, dir)
if err != nil {
return "", err
}
if empty {
args := scaffoldInitArgs(appType, appID, sourcePath)
if _, stderr, err := initRunner.Run(ctx, dir, "npx", args...); err != nil {
// isEmptyRepo treats a repo with no tracked files — or only the backend's
// seed README.md — as empty. If other seed files (e.g. .gitignore) can
// appear, extend isEmptyRepo's allow-list accordingly.
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", template, "--app-id", appID); err != nil {
return "", appsExternalToolError(err, "npx app init failed: %s", gitErr(stderr, err))
}
return scaffoldKindInit, nil
}
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "sync"); err != nil {
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", 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 !policyForAppType(appType).skipSkillsSync && !hasSteeringSkills(dir) {
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
if !hasSteeringSkills(dir) {
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
return "", appsExternalToolError(err, "npx skills sync failed: %s", gitErr(stderr, err))
}
}
return scaffoldKindUpgrade, nil
}
// scaffoldInitArgs builds the npx argument list for `app init`.
// appType from queryAppType is passed as --app-type; falls back to "full_stack"
// when empty. sourcePath is appended as --source-path when non-empty.
// --skip-install is appended per the app_type's policy (see appTypePolicy):
// types whose policy sets skipInstall (e.g. modern_html) skip the dependency
// install; others run it as usual.
func scaffoldInitArgs(appType, appID, sourcePath string) []string {
base := []string{"-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "init"}
at := appType
if at == "" {
at = "full_stack"
}
base = append(base, "--app-type", at, "--app-id", appID)
if sourcePath != "" {
base = append(base, "--source-path", sourcePath)
}
if policyForAppType(appType).skipInstall {
base = append(base, "--skip-install")
}
return base
}
// 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`.
@@ -527,9 +445,6 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
return err
}
appType := queryAppType(ctx, rctx, appID)
policy := policyForAppType(appType)
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
// initialized app repo -> skip clone/scaffold/commit, but still refresh
// the local env so a re-run picks up the latest startup env vars.
@@ -542,19 +457,6 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
"committed": false,
"pushed": false,
}
if appType != "" {
out["app_type"] = appType
}
if policy.skipEnvPull {
out["env_pulled"] = false
out["env_pull_skipped"] = true
out["message"] = "Repository already initialized. You can start developing."
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ Already initialized at %s\n", dir)
fmt.Fprintln(w, "仓库已初始化完成,可以开始开发了。")
})
return nil
}
initLogf(rctx, "Pulling local environment variables...")
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
envPulled := envPullErr == ""
@@ -612,21 +514,8 @@ 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; only sets
// repo-local defaults when none is configured (existing identity is kept).
if err := ensureGitIdentity(ctx, dir); err != nil {
return err
}
initLogf(rctx, "Initializing app code (running miaoda-cli)...")
sourcePath := strings.TrimSpace(rctx.Str("source-path"))
if sourcePath != "" {
sourcePath, err = filepath.Abs(sourcePath) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); sourcePath is control-char-validated in Validate.
if err != nil {
return appsValidationParamError("--source-path", "--source-path cannot be resolved: %v", err)
}
}
scaffold, err := runScaffold(ctx, dir, appID, appType, sourcePath)
scaffold, err := runScaffold(ctx, dir, appID, resolveTemplate(rctx, appID))
if err != nil {
return err
}
@@ -641,6 +530,15 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
initLogf(rctx, "Working tree clean — skipped commit/push")
}
initLogf(rctx, "Pulling local environment variables...")
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
envPulled := envPullErr == ""
if envPulled {
initLogf(rctx, "Local environment written to %s", envFile)
} else {
initLogf(rctx, "Could not pull local env vars: %s", envPullErr)
}
out := map[string]interface{}{
"app_id": appID,
"repository_url": redactURLCredentials(repoURL),
@@ -649,38 +547,21 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
"scaffold": scaffold,
"committed": committed,
"pushed": pushed,
"env_pulled": envPulled,
"message": "Repository initialized. You can start developing.",
}
if appType != "" {
out["app_type"] = appType
}
if policy.skipEnvPull {
out["env_pulled"] = false
out["env_pull_skipped"] = true
if envPulled {
out["env_file"] = envFile
} else {
initLogf(rctx, "Pulling local environment variables...")
envFile, envPullErr := pullEnv(ctx, rctx, appID, dir)
envPulled := envPullErr == ""
out["env_pulled"] = envPulled
if envPulled {
initLogf(rctx, "Local environment written to %s", envFile)
out["env_file"] = envFile
} else {
initLogf(rctx, "Could not pull local env vars: %s", envPullErr)
out["env_pull_error"] = envPullErr
out["message"] = fmt.Sprintf("Repository initialized. Could not pull local env vars automatically — run `lark-cli apps +env-pull --app-id %s` to retry.", appID)
}
out["env_pull_error"] = envPullErr
out["message"] = fmt.Sprintf("Repository initialized. Could not pull local env vars automatically — run `lark-cli apps +env-pull --app-id %s` to retry.", appID)
}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ Repository initialized at %s\n", dir)
fmt.Fprintf(w, " branch: %s\n scaffold: %s\n", defaultInitBranch, scaffold)
if policy.skipEnvPull {
fmt.Fprintln(w, " (env pull skipped)")
} else if envPulled, _ := out["env_pulled"].(bool); envPulled {
fmt.Fprintf(w, "✓ Local environment written to %s\n", out["env_file"])
} else if envPullErr, ok := out["env_pull_error"].(string); ok {
if envPulled {
fmt.Fprintf(w, "✓ Local environment written to %s\n", envFile)
} else {
fmt.Fprintf(w, "⚠ Could not pull local env vars: %s\n", envPullErr)
fmt.Fprintf(w, " run `lark-cli apps +env-pull --app-id %s` to retry\n", appID)
}

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