Compare commits

...

12 Commits

Author SHA1 Message Date
shanglei
041bf48e0e feat: unify output surface contract for flags, formats and errors
Consolidate the CLI output/error contract so agents can predict behavior
from a command's declaration:

- --json / --format resolved through a single FormatCapabilities set;
  explicit --format wins over --json across api, service, shortcut and
  auth scopes
- --format pretty renders as indented JSON via its own Format case,
  no longer aliased to table
- a single root FlagErrorFunc classifies pflag typed errors; the mail
  and sheets local FlagErrorFunc hooks are removed
- cobra argument, required-flag and flag-group errors are typed at their
  validation stage; the usage-error text markers are removed
- root and group unknown commands return structured suggestions
- manifest-export marks pure group nodes as non-runnable
2026-07-14 19:04:01 +08:00
liangshuo-1
37d490a198 fix: unify dry-run output contract (#1870)
* fix: unify dry-run output contract

* fix: address dry-run review feedback

* fix(dryrun): tighten preview contract and unify data shape

- transcribe HTTP method verbatim in previews (HEAD/OPTIONS were
  reported as GET); reject an empty method in api with a typed error
- unify the dry-run data payload across api/service/shortcut paths:
  {api, context?: {app_id, user_open_id}}; drop data.as — the envelope
  top-level identity is the single identity source
- mark pretty dry-run stdout with '# dry-run: request not sent' so logs
  that drop stderr still show it was a preview
- extract the shared preview builder, collapse PrintDryRunWithFile's
  loose params into FileUploadMeta, and fail loudly on nil previews
- revert description-marker identity parsing: stale prose must not
  override corrected accessTokens (blocks legal user calls on
  images.create); identity gating keys off accessTokens only
- pin the new contracts with tests: verbatim method, three-way context
  parity, nil-preview error, empty-context omission, marker line

* docs(agents): add typed-data, faithful-transcription, and contract-test conventions

- typed struct at the boundary over map[string]interface{} threading;
  distinct types where values could swap silently (internal/meta.Token)
- transcribe input verbatim in previews/transformations; reject
  unhonorable flag combinations with typed errors instead of silently
  substituting behavior
- contract tests must fail when the implementation is reverted

* test: migrate dry-run tests grown on main to the envelope format

main gained raw-format dry-run readers while the PR was in flight
(wiki drive export #1802, drive list comments #1845, slash commands,
sheets history, docs fetch, mail draft-send/triage, vc meeting events).
Migrate them to the envelope accessors (clie2e.DryRunGet / data-wrapped
decoders) and drop the now-redundant DryRunData extractions in files
unified on DryRunGet.

---------

Co-authored-by: guokexin.02 <264159873+Tantanz20020918@users.noreply.github.com>
2026-07-14 10:54:16 +08:00
liangshuo-1
4e44e51bef chore: release v1.0.69 (#1868) 2026-07-13 22:28:17 +08:00
zhengzhijiej-tech
e79d49e7e4 Merge lark sheets development branch (#1833)
* 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.

* 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

* feat(sheets): add --type bitable to +sheet-create for creating bitable sub-sheets (#1520)

* 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.

* 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 校验有没有覆盖/冲突。

* 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).

* 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

* 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

* 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.

* 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.

* 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}.

* 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

* 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

* 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

* 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.

* fix(sheets): lower cells-set --max-cells default to 50000

* docs(sheets): clarify workbook-import over read-then-recreate in skill

* docs(sheets): bump lark-sheets skill version to 3.0.1

* docs(sheets): clarify number-vs-text typing and copy-to-range template guidance in references

* 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.

* docs(sheets): tighten number-vs-text guidance and dedupe write-cells reference

* 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>

* docs(sheets): dedupe +changeset-get flag def and skill reference entry

* 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).

* 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.

* 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.

* docs: tighten formula verify workflow guidance

* docs: align formula verify refs with file names

* 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).

* 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.

* docs(sheets): steer import-vs-append onto sheet-copy for existing workbooks

* docs(sheets): warn that cells-clear --scope all is irreversibly destructive

* 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)

* 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.

* 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.

* 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 已删除。

* 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 回退等)后的技能版本。

* 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。

* fix(sheets): sync flag input/enum fixes from sheet-skill-spec

上游修复 spec-table 的 Input/Enum 字符串惯例后重新生成:--widths/--heights 现在带
file/stdin 输入声明,+sheet-create --type 的枚举正确进入 flag defs 与文档。

* 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.

* 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

* 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.

* 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.

* fix(sheets): pass spreadsheet token to changeset tool (#1839)

* fix(sheets): hide bitable sheet creation (#1843)

* fix(sheets): resolve revision wiki URLs

* fix(sheets): reject overlapping resize ranges

* fix(sheets): address remaining review feedback

* fix(sheets): avoid credential scanner false positive

* fix(sheets): import mislabeled .xls workbooks by sniffing content

Local .xls files that are actually OOXML (an .xlsx exported or renamed to
.xls) failed +workbook-import with a cryptic backend
"xml_version_not_support" because the CLI trusted the file name extension.

+workbook-import now sniffs the file's leading magic bytes (PK -> xlsx,
OLE2 -> xls) and passes the true extension to the drive import core via a
new optional ImportParams.FileExtension override, correcting both the
file_extension and the staged media file name (the latter avoids the
backend's "import file extension not match", code 1069910). A declared
Excel file whose bytes match neither container is rejected locally with a
prescriptive error instead of the opaque backend failure.

The drive import core gains only the neutral FileExtension override
(empty = infer from the file name, i.e. unchanged behavior for
drive +import); all Excel sniffing/correction policy lives in the sheets
shortcut.

* fix(ci): keep semantic waiver fixture active

* fix(sheets): close remaining safety gaps

* fix(sheets): align history shortcuts with generated flags

Use generated flag defs for history revert commands, enforce control-character validation, and sync the refreshed lark-sheets references from sheet-skill-spec.

* fix(sheets): require confirmation for history revert

* fix(sheets): require explicit csv input

---------

Co-authored-by: xiongyuanwen-byted <xiongyuanwen@bytedance.com>
Co-authored-by: wuyanchun.anunwu <wuyanchun.anunwu@bytedance.com>
Co-authored-by: wenzhuozhen <wenzhuozhen@bytedance.com>
2026-07-13 21:29:43 +08:00
91-enjoy
83352fe00b feat: surface reply context and mentions in im.message.receive_v1 (#1798)
This PR improves the im.message.receive_v1 event output by exposing structural
metadata fields (reply context, sender type, mentions) that were previously only
available in the raw V2 envelope. It also syncs the same structural fields to the legacy
+subscribe --compact pipeline.
2026-07-13 20:47:14 +08:00
91-enjoy
21bfa84edd feat: validate IM idempotency key length (#1797)
Previously, keys longer than the OpenAPI uuid limit were sent to the server and returned a generic field validation failed error. This change rejects overlong keys locally with a typed validation error that identifies
--idempotency-key and the 50-character limit.
2026-07-13 20:46:51 +08:00
leave330
fc8d212a4f feat: add application domain with slash command management shortcuts (#1806) 2026-07-13 20:43:19 +08:00
wangweiming-01
35049e8d30 feat: support wiki sources in drive export (#1802) 2026-07-13 19:50:48 +08:00
wangweiming-01
d8782e715a feat: add drive list comments shortcut (#1845) 2026-07-13 19:50:44 +08:00
sammi-bytedance
7675185f9d feat(im): show bot sender display names when reading messages (#1829)
Read the server-provided sender_name for both user and bot senders (previously
only users resolved) so message-read commands display bot names instead of raw
ids. The CLI opts into server-side name filling by sending with_sender_name=true
on chat-messages-list, threads-messages-list, messages-mget and messages-search,
as well as on the inline fetches that render nested senders: merge_forward
sub-messages and auto-expanded thread replies. Without it those nested-only
senders carry no sender_name and, with no fallback, render as raw ids.

Names come solely from the server (single source of truth): there is no contact
or mention fallback, so the contact scope is dropped from these four commands and
the contact/mention resolution code is removed. A sender the server does not name
falls back to its id; system messages show no name. The resolved name is exposed
in the existing `name` field (backward compatible); the duplicate raw
`sender_name` is stripped while the full `sender_i18n_names` map and `open_bot_id`
are preserved for consumers. No new permission scope is required. Updates the
lark-im skill docs.
2026-07-13 19:39:03 +08:00
anngo-nk
1ab853023a feat(apps): support modern_html app type with TOS publish path and app type querying
* feat(apps): read LARKSUITE_CLI_AGENT env var and pass app_source in +create

* feat(apps): add queryAppMeta shared function for app_type/arch_type lookup

* feat(apps): skip scaffold for legacy html apps, pass app_type/arch_type for arch_type=4 html in +init

* feat(apps): add zip packaging for arch_type=4 html publish path

* feat(apps): add TOS upload path for arch_type=4 html in +html-publish with arch_type-based routing

* refactor(apps): replace appMeta struct with queryAppType string for simpler routing

* refactor(apps): simplify to source_agent in +create, unified scaffold in +init, revert html-publish changes

* feat(apps): add --source-path flag to +init for existing source file incorporation

* fix(apps): align queryAppType with actual API path and response structure

* refactor(apps): use appInfo struct to parse GET /apps/{id} response

* test(apps): add full_stack scaffold test case

* feat(apps): surface sync field in +release-create response

* refactor(apps): remove --template flag from +init, derive template from queryAppType with full_stack fallback

* style(apps): fix gofmt formatting in apps_init.go

* test(apps): improve coverage for sync field, queryAppType, and scaffoldInitArgs

* chore(apps): pin miaoda-cli to alpha version 0.1.20-alpha.dd573f8

* feat(apps): add modern_html enum and pass --app-type instead of --template to miaoda-cli

* chore: add global PPE headers for testing (x-use-ppe, x-tt-env)

* feat(apps): add TOS upload path in +html-publish for modern_html, add --tos-path to +release-create

* feat(apps): unify html-publish output structure with app_id for both html and modern_html

* fix(apps): use newFileTransferClient for TOS presigned upload to satisfy forbidigo lint

* test(apps): add coverage for runHTMLPublishTOS success, errors, and upload failures

* fix(apps): change pre_release API method from POST to GET

* fix(apps): adapt pre_release response from map to list<KV> format

* fix(apps): use PUT method and Content-Length for TOS presigned upload

* fix(apps): use tos_path instead of tosPath in release-create request body

* chore(apps): add npmmirror registry for npx miaoda-cli, fix TOS upload test to expect PUT

* refactor(apps): use envvars.AgentName() for source_agent in +create

* feat(apps): integrate release-create into html-publish for modern_html, auto-detect modern_html from doubao agent env

* refactor(apps): remove --tos-path flag from +release-create (now internal to html-publish)

* refactor(apps): remove app_id from html-publish output, update skill doc

* docs(apps): update html-publish description to reflect dual return values

* refactor(apps): remove doubao app_type conversion in +create, let server decide via source_agent

* feat(apps): skip env-pull for modern_html apps in +init

* test(apps): add tests for modern_html env-pull skip in +init

* refactor(apps): introduce appTypePolicy for init control points (skipInstall, skipEnvPull, skipSkillsSync)

* feat(apps): add init step timing and default git config for +init

* feat(apps): add +get shortcut to fetch single app detail by app_id

* chore(apps): remove init step timing (not ready for production)

* test(apps): add coverage for +get shortcut

* chore: remove PPE headers and revert miaoda-cli to @latest for production

* refactor(apps): extract shared prepareHTMLPublishTarball, fix stale comments, simplify queryAppType

* fix(apps): update html-publish dry-run desc, remove hardcoded API path

* fix(apps): use rctx.IO().ErrOut instead of os.Stderr, remove unused appInfo struct

* fix(apps): restore dry-run API path output for E2E compatibility

* refactor(apps): move --source-path control char validation to Validate for dry-run coverage

* fix(apps): update stale --template comments to --app-type in init tests

* test(apps): explicitly unset agent env var for test isolation
2026-07-13 16:43:07 +08:00
HanShaoshuai-k
e96c4fa581 test: isolate semantic waiver fixtures from wall clock 2026-07-13 14:05:47 +08:00
268 changed files with 14838 additions and 3010 deletions

View File

@@ -105,6 +105,20 @@ Signatures that are easy to guess wrong:
Program output (JSON envelopes) goes to stdout. Progress, warnings, hints go to stderr. Mixing them corrupts pipe chains.
### Typed data over loose maps
Parse `map[string]interface{}` into a typed struct at the boundary — one projection function per shape — and let everything downstream consume struct fields, not string keys. A typo'd map key compiles fine and fails at runtime, which an agent then debugs blind.
Use distinct types when two values could be swapped silently: see `internal/meta.Token` — a bare string compiles on either side of a string/string signature, a distinct type does not.
Legacy loose-map code exists in older paths. Match its call sites when touching it, but do not copy the pattern into new code.
### Transcribe faithfully — no silent fallbacks
When code echoes input onward (request previews, transformations, proxies), transcribe verbatim. A `default:` branch that coerces unrecognized input into a plausible value ("unknown HTTP verb → GET") makes the output lie, and an agent reasons from the lie.
The same rule applies to flag combinations and internal wiring: if a requested option cannot be honored, return a typed validation error — never silently substitute another behavior and exit 0. Silent guesses (defaulting a missing identity, discarding writes on a nil writer) are bugs even when every current caller happens to avoid them.
### Use `vfs.*` instead of `os.*`
All filesystem access goes through `internal/vfs`. This enables test mocking.
@@ -116,6 +130,7 @@ CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInput
### Tests
- Every behavior change needs a test alongside the change.
- A contract test must fail if the implementation is reverted. If you can undo the code change and the suite stays green, the contract is not pinned — assert the new field/behavior directly, not a happy-path substring.
- `cmdutil.TestFactory(t, config)` for test factories.
- `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state.

View File

@@ -2,6 +2,37 @@
All notable changes to this project will be documented in this file.
## [v1.0.69] - 2026-07-13
### Features
- support docs fetch selection anchors (#1815)
- **apps**: support modern_html app type with TOS publish path and app type querying
- **im**: show bot sender display names when reading messages (#1829)
- add drive list comments shortcut (#1845)
- support wiki sources in drive export (#1802)
- add application domain with slash command management shortcuts (#1806)
- validate IM idempotency key length (#1797)
- surface reply context and mentions in im.message.receive_v1 (#1798)
### Bug Fixes
- route brand-sensitive endpoints through the resolver (#1836)
### Documentation
- document OKR block XML guidance (#1648)
- refine doubao whiteboard workflow routing (#1841)
- clarify Mindnote token handling (#1827)
### Tests
- isolate semantic waiver fixtures from wall clock
### Misc
- Merge lark sheets development branch (#1833)
## [v1.0.68] - 2026-07-09
### Features
@@ -1438,6 +1469,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.69]: https://github.com/larksuite/cli/releases/tag/v1.0.69
[v1.0.68]: https://github.com/larksuite/cli/releases/tag/v1.0.68
[v1.0.67]: https://github.com/larksuite/cli/releases/tag/v1.0.67
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66

View File

@@ -40,6 +40,7 @@ type APIOptions struct {
PageLimit int
PageDelay int
Format string
JSON bool
JqExpr string
DryRun bool
File string
@@ -88,6 +89,11 @@ Examples:
opts.Cmd = cmd
opts.Ctx = cmd.Context()
opts.As = core.Identity(asStr)
format, err := output.StandardFormats.Resolve(opts.Format, cmd.Flags().Changed("format"), opts.JSON)
if err != nil {
return err
}
opts.Format = format
if runF != nil {
return runF(opts)
}
@@ -103,8 +109,8 @@ Examples:
cmd.Flags().IntVar(&opts.PageSize, "page-size", 0, "page size (0 = use API default)")
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVar(&opts.Format, "format", "json", output.StandardFormats.Usage())
cmd.Flags().BoolVar(&opts.JSON, "json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")
@@ -116,7 +122,7 @@ Examples:
return nil, cobra.ShellCompDirectiveNoFileComp
}
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
return output.StandardFormats.Names(), cobra.ShellCompDirectiveNoFileComp
})
cmdutil.SetRisk(cmd, "write")
@@ -130,6 +136,13 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
stdin := opts.Factory.IOStreams.In
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
if opts.Method == "" {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"HTTP method must not be empty").
WithHint("pass the verb as the first argument, e.g. lark-cli api GET /open-apis/...").
WithParam("<method>")
}
// Validate --file mutual exclusions first.
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
return client.RawApiRequest{}, nil, err
@@ -243,9 +256,9 @@ func apiRun(opts *APIOptions) error {
if opts.DryRun {
if fileMeta != nil {
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
return cmdutil.PrintDryRunWithFile(request, config, dryRunOutputOptions(f, opts), *fileMeta)
}
return apiDryRun(f, request, config, opts.Format)
return apiDryRun(f, request, config, opts)
}
// Identity info is now included in the JSON envelope; skip stderr printing.
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
@@ -256,10 +269,7 @@ func apiRun(opts *APIOptions) error {
}
out := f.IOStreams.Out
format, formatOK := output.ParseFormat(opts.Format)
if !formatOK {
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
}
format, _ := output.ParseFormat(opts.Format)
if opts.PageAll {
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
@@ -297,8 +307,19 @@ func apiRun(opts *APIOptions) error {
return nil
}
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *APIOptions) error {
return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts))
}
func dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions) cmdutil.DryRunOutputOptions {
return cmdutil.DryRunOutputOptions{
Format: opts.Format,
JqExpr: opts.JqExpr,
CommandPath: opts.Cmd.CommandPath(),
Identity: opts.As,
Out: f.IOStreams.Out,
ErrOut: f.IOStreams.ErrOut,
}
}
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
@@ -325,6 +346,24 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
}
switch format {
case output.FormatPretty:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return errs.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatPretty)
return errs.MarkRaw(apiErr)
}
scanResult := output.ScanForSafety(commandPath, result, errOut)
if scanResult.Blocked {
return errs.MarkRaw(scanResult.BlockErr)
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
output.FormatValue(out, result, output.FormatPretty)
return nil
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {

View File

@@ -68,8 +68,44 @@ func TestApiCmd_FlagParsing(t *testing.T) {
}
}
func TestApiCmd_OutputFormatResolution(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "json shorthand", args: []string{"--json"}, want: "json"},
{name: "explicit format wins", args: []string{"--format", "table", "--json"}, want: "table"},
{name: "pretty format", args: []string{"--format", "PRETTY"}, want: "pretty"},
}
for _, tt := range tests {
t.Run(tt.name, func(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 {
gotOpts = opts
return nil
})
args := []string{"GET", "/open-apis/test", "--as", "bot"}
cmd.SetArgs(append(args, tt.args...))
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected options to be captured")
}
if gotOpts.Format != tt.want {
t.Fatalf("format = %q, want %q", gotOpts.Format, tt.want)
}
})
}
}
func TestApiCmd_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
@@ -79,12 +115,42 @@ func TestApiCmd_DryRun(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "Dry Run") {
t.Error("expected dry run output")
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
}
if !strings.Contains(output, "/open-apis/test") {
t.Error("expected path in dry run output")
if got["ok"] != true || got["identity"] != "bot" || got["dry_run"] != true {
t.Fatalf("unexpected dry-run envelope: %#v", got)
}
data, ok := got["data"].(map[string]interface{})
if !ok {
t.Fatalf("data = %#v, want object", got["data"])
}
api, ok := data["api"].([]interface{})
if !ok || len(api) != 1 {
t.Fatalf("api = %#v, want one call", data["api"])
}
call, ok := api[0].(map[string]interface{})
if !ok || call["url"] != "/open-apis/test" {
t.Fatalf("api[0] = %#v", api[0])
}
if strings.Contains(stdout.String(), "=== Dry Run ===") {
t.Fatalf("stdout should not contain dry-run banner: %s", stdout.String())
}
}
func TestApiCmd_DryRunWithJq(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run", "--jq", ".data.api[0].url"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := strings.TrimSpace(stdout.String()); got != "/open-apis/test" {
t.Fatalf("jq output = %q, want /open-apis/test", got)
}
}
@@ -139,6 +205,43 @@ func TestApiCmd_BotMode(t *testing.T) {
}
}
func TestApiCmd_PrettyFormatsRealResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pretty", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"name": "Alice"}},
},
},
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--format", "pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("pretty output should be valid JSON: %v\n%s", err, out)
}
data, ok := got["data"].(map[string]interface{})
if !ok {
t.Fatalf("pretty output data = %#v", got["data"])
}
items, ok := data["items"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("pretty output data.items = %#v", data["items"])
}
if !strings.Contains(out, "\n \"data\": {") || strings.Contains(out, "─") {
t.Fatalf("pretty output should be indented JSON rather than a table, got:\n%s", out)
}
}
func TestApiCmd_MissingArgs(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -152,6 +255,22 @@ func TestApiCmd_MissingArgs(t *testing.T) {
}
}
func TestApiCmd_EmptyMethodRejected(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"", "/open-apis/test", "--as", "bot", "--dry-run"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected validation error for empty HTTP method")
}
if !strings.Contains(err.Error(), "method") {
t.Fatalf("error should name the method argument, got: %v", err)
}
}
func TestApiCmd_InvalidParamsJSON(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -525,6 +644,46 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
}
}
func TestApiCmd_PageAll_PrettyAggregatesIndentedJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-pretty", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("page-all pretty output should be valid JSON: %v\n%s", err, out)
}
data, ok := got["data"].(map[string]interface{})
if !ok {
t.Fatalf("page-all pretty output data = %#v", got["data"])
}
items, ok := data["items"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("page-all pretty output data.items = %#v", data["items"])
}
if !strings.Contains(out, "\n \"data\": {") || strings.Contains(out, "─") {
t.Fatalf("page-all pretty output should be aggregated indented JSON, got:\n%s", out)
}
}
type apiContentSafetyProvider struct {
called bool
path string
@@ -1000,11 +1159,23 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "image") {
t.Errorf("expected dry-run output to mention file field, got: %s", out)
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, out)
}
if !strings.Contains(out, "Dry Run") {
t.Errorf("expected dry-run header, got: %s", out)
if env["dry_run"] != true {
t.Fatalf("dry_run = %#v, want true", env["dry_run"])
}
data := env["data"].(map[string]interface{})
api := data["api"].([]interface{})
call := api[0].(map[string]interface{})
body := call["body"].(map[string]interface{})
file := body["file"].(map[string]interface{})
if file["field"] != "image" || file["path"] != tmpFile {
t.Fatalf("unexpected file dry-run body: %#v", body)
}
if strings.Contains(out, "=== Dry Run ===") {
t.Fatalf("stdout should not contain dry-run banner: %s", out)
}
}

View File

@@ -355,7 +355,31 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
func TestAuthScopesCmd_JSONShorthand(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *ScopesOptions
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected options to be captured")
}
if !gotOpts.JSON || gotOpts.Format != "json" {
t.Fatalf("JSON = %v, format = %q; want true, json", gotOpts.JSON, gotOpts.Format)
}
}
func TestAuthScopesCmd_ExplicitFormatWinsOverJSONShorthand(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
@@ -376,8 +400,8 @@ func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
if gotOpts.Format != "json" {
t.Errorf("expected format json, got %s", gotOpts.Format)
if gotOpts.Format != "pretty" {
t.Errorf("expected explicit format pretty, got %s", gotOpts.Format)
}
}

View File

@@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
// (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json.
func getShortcutOnlyDomainNames() []string {
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
return []string{"application", "base", "contact", "docs", "markdown", "apps", "note"}
}

View File

@@ -31,9 +31,11 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
Short: "Query scopes enabled for the app",
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
if opts.JSON {
opts.Format = "json"
format, err := output.JSONPrettyFormats.Resolve(opts.Format, cmd.Flags().Changed("format"), opts.JSON)
if err != nil {
return err
}
opts.Format = format
if runF != nil {
return runF(opts)
}
@@ -41,8 +43,11 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
},
}
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmd.Flags().StringVar(&opts.Format, "format", "json", output.JSONPrettyFormats.Usage())
cmd.Flags().BoolVar(&opts.JSON, "json", false, "shorthand for --format json")
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return output.JSONPrettyFormats.Names(), cobra.ShellCompDirectiveNoFileComp
})
cmdutil.SetRisk(cmd, "read")
return cmd
@@ -75,10 +80,10 @@ func authScopesRun(opts *ScopesOptions) error {
"failed to get app scope info: %v", err).WithCause(err)
}
if opts.Format == "pretty" {
fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID)
fmt.Fprintf(f.IOStreams.ErrOut, "Enabled scopes (%d):\n\n", len(appInfo.UserScopes))
fmt.Fprintf(f.IOStreams.Out, "App ID: %s\n", config.AppID)
fmt.Fprintf(f.IOStreams.Out, "Enabled scopes (%d):\n\n", len(appInfo.UserScopes))
for _, s := range appInfo.UserScopes {
fmt.Fprintf(f.IOStreams.ErrOut, " • %s\n", s)
fmt.Fprintf(f.IOStreams.Out, " • %s\n", s)
}
} else {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{

View File

@@ -4,9 +4,11 @@
package auth
import (
"bytes"
"context"
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
@@ -26,6 +28,35 @@ func stubGetAppInfoErr(t *testing.T, errToReturn error) {
t.Cleanup(func() { getAppInfoFn = prev })
}
func TestAuthScopesRun_PrettyWritesBulletedScopesToStdout(t *testing.T) {
prev := getAppInfoFn
getAppInfoFn = func(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
return &appInfo{UserScopes: []string{"im:message"}}, nil
}
t.Cleanup(func() { getAppInfoFn = prev })
opts := scopesTestFactory(t)
opts.Format = "pretty"
out, ok := opts.Factory.IOStreams.Out.(*bytes.Buffer)
if !ok {
t.Fatalf("stdout type = %T, want *bytes.Buffer", opts.Factory.IOStreams.Out)
}
errOut, ok := opts.Factory.IOStreams.ErrOut.(*bytes.Buffer)
if !ok {
t.Fatalf("stderr type = %T, want *bytes.Buffer", opts.Factory.IOStreams.ErrOut)
}
if err := authScopesRun(opts); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(out.String(), " • im:message\n") {
t.Fatalf("stdout missing bulleted scope: %q", out.String())
}
if strings.Contains(errOut.String(), "im:message") {
t.Fatalf("scope should remain on stdout, stderr = %q", errOut.String())
}
}
// scopesTestFactory builds a Factory + ScopesOptions pair sufficient to drive
// authScopesRun. Config has a non-empty AppID so we get past the config gate
// and reach the getAppInfoFn call.

View File

@@ -234,6 +234,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
groupRootCommands(rootCmd)
installUnknownSubcommandGuard(rootCmd)
installCobraValidationGuards(rootCmd)
// Bare `lark-cli` in an interactive terminal offers an interactive upgrade
// before printing help; non-bare invocations and non-TTY are unaffected.
installRootUpgradePrompt(f, rootCmd)

View File

@@ -4,11 +4,15 @@
package cmd
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
@@ -26,6 +30,85 @@ func TestBuildWithoutPluginsStillBuildsBuiltinCommands(t *testing.T) {
}
}
func buildValidationTestRoot(t *testing.T) *cobra.Command {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
return Build(context.Background(), cmdutil.InvocationContext{},
WithIO(strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}),
WithoutPlugins(),
WithoutServiceCommands(),
WithoutStrictMode(),
)
}
func TestBuiltRoot_TopLevelTypoReturnsStructuredSuggestion(t *testing.T) {
root := buildValidationTestRoot(t)
root.SetArgs([]string{"imm"})
err := root.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if len(validationErr.Params) != 1 || validationErr.Params[0].Name != "imm" {
t.Fatalf("params = %v, want one entry named imm", validationErr.Params)
}
found := false
for _, candidate := range validationErr.Params[0].Suggestions {
if candidate == "im" {
found = true
}
}
if !found {
t.Fatalf("suggestions = %v, want im", validationErr.Params[0].Suggestions)
}
}
func TestBuiltRoot_SheetsOneRequiredGroupReturnsValidationExit(t *testing.T) {
root := buildValidationTestRoot(t)
root.SetArgs([]string{
"sheets", "+csv-put",
"--url", "https://example.com/sheets/token",
"--sheet-name", "Sheet1",
"--csv", "a,b",
})
err := root.Execute()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
}
}
func TestBuiltRoot_MailUnknownFlagUsesSharedSuggestions(t *testing.T) {
root := buildValidationTestRoot(t)
root.SetArgs([]string{"mail", "+send", "--tos", "alice@example.com"})
err := root.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if len(validationErr.Params) != 1 || validationErr.Params[0].Name != "--tos" {
t.Fatalf("params = %v, want one entry named --tos", validationErr.Params)
}
found := false
for _, candidate := range validationErr.Params[0].Suggestions {
if candidate == "--to" {
found = true
}
}
if !found {
t.Fatalf("suggestions = %v, want --to", validationErr.Params[0].Suggestions)
}
}
func findCommand(root *cobra.Command, path string) *cobra.Command {
parts := strings.Fields(path)
cmd := root

View File

@@ -96,6 +96,40 @@ func TestRunSchema_JSONOutput(t *testing.T) {
}
}
func TestRunSchema_ReceiveMessageAgentFieldsJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, "im.message.receive_v1", true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
var payload map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
resolved := payload["resolved_output_schema"].(map[string]interface{})
props := resolved["properties"].(map[string]interface{})
for _, field := range []string{
"root_id",
"thread_id",
"reply_to",
"sender_type",
"mentions",
} {
if _, ok := props[field]; !ok {
t.Errorf("receive schema missing field %q", field)
}
}
msgDesc := props["message_id"].(map[string]interface{})["description"].(string)
if !strings.Contains(msgDesc, "Recommended idempotency key") {
t.Errorf("message_id description should guide deduplication, got %q", msgDesc)
}
eventDesc := props["event_id"].(map[string]interface{})["description"].(string)
if strings.Contains(eventDesc, "safe for deduplication") {
t.Errorf("event_id description should not recommend deduplication, got %q", eventDesc)
}
}
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})

View File

@@ -9,28 +9,18 @@ import (
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
func TestUnknownFlagName(t *testing.T) {
cases := []struct {
in string
name string
ok bool
}{
{"unknown flag: --query", "query", true},
{"unknown flag: --with-styles", "with-styles", true},
{"unknown shorthand flag: 'z' in -z", "", false},
{"flag needs an argument: --find", "", false},
{`invalid argument "x" for "--count"`, "", false},
}
for _, c := range cases {
name, ok := unknownFlagName(errors.New(c.in))
if name != c.name || ok != c.ok {
t.Errorf("unknownFlagName(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
}
func parseFlagError(t *testing.T, c *cobra.Command, args ...string) error {
t.Helper()
err := c.Flags().Parse(args)
if err == nil {
t.Fatalf("Parse(%v) returned nil", args)
}
return err
}
func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
@@ -39,7 +29,7 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
c.Flags().String("find", "", "")
c.Flags().Bool("dry-run", false, "")
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
err := flagDidYouMean(c, parseFlagError(t, c, "--rang")) // typo of --range
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
@@ -82,23 +72,86 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
c := &cobra.Command{Use: "demo"}
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
c.Flags().String("find", "", "")
err := flagDidYouMean(c, parseFlagError(t, c, "--find"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
// Non-unknown-flag errors stay generic: invalid_argument subtype, no
// structured param, generic --help hint (no "did you mean" suggestion).
// Non-unknown-flag errors retain the same validation shape and identify the
// flag from pflag's typed ValueRequiredError.
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument (non-unknown-flag errors stay generic)", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if verr.Param != "" || len(verr.Params) != 0 {
t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params)
if len(verr.Params) != 1 || verr.Params[0].Name != "--find" {
t.Errorf("Params=%v, want one entry named --find", verr.Params)
}
if strings.Contains(verr.Hint, "did you mean") {
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
}
}
func TestFlagDidYouMean_SheetsListsVisibleFlags(t *testing.T) {
root := &cobra.Command{Use: "root"}
sheets := &cobra.Command{Use: "sheets"}
cmdmeta.SetDomain(sheets, "sheets")
root.AddCommand(sheets)
sheets.Flags().String("range", "", "")
sheets.Flags().Int("width", 0, "")
err := flagDidYouMean(sheets, parseFlagError(t, sheets, "--cols"))
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
for _, want := range []string{"--range", "--width"} {
if !strings.Contains(validationErr.Hint, want) {
t.Errorf("hint should include %q, got %q", want, validationErr.Hint)
}
}
}
func TestFlagDidYouMean_InvalidValueTypedError(t *testing.T) {
c := &cobra.Command{Use: "demo"}
c.Flags().Int("width", 0, "")
// A non-numeric value for a typed flag surfaces pflag's InvalidValueError.
err := flagDidYouMean(c, parseFlagError(t, c, "--width=abc"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--width" || verr.Params[0].Reason != "invalid flag value" {
t.Errorf("Params = %v, want one --width entry with reason 'invalid flag value'", verr.Params)
}
}
func TestFlagDidYouMean_InvalidSyntaxTypedError(t *testing.T) {
c := &cobra.Command{Use: "demo"}
c.Flags().String("range", "", "")
// An empty flag name is bad flag syntax and surfaces pflag's InvalidSyntaxError.
err := flagDidYouMean(c, parseFlagError(t, c, "--=oops"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if len(verr.Params) != 1 || verr.Params[0].Reason != "invalid flag syntax" {
t.Errorf("Params = %v, want one entry with reason 'invalid flag syntax'", verr.Params)
}
}

View File

@@ -241,10 +241,10 @@ func configureFlagCompletions(args []string) {
// dispatcher no longer promotes any legacy shape here.
// 2. PartialFailure / BareError signals: the result envelope is already on
// stdout; honor the exit code and write nothing to stderr.
// 3. Residual cobra usage errors (missing required flag, unknown command,
// argument validation): typed as an invalid_argument envelope (exit 2),
// matching the explicit flag/subcommand guards. Flag parse errors are
// already typed upstream by the root FlagErrorFunc.
// 3. Any untyped error that reaches this boundary is an internal fault.
// Cobra argument, required-flag and flag-group errors are typed at their
// execution stages by installCobraValidationGuards; flag parse errors are
// typed by the root FlagErrorFunc.
func handleRootError(f *cmdutil.Factory, err error) int {
errOut := f.IOStreams.ErrOut
@@ -283,57 +283,14 @@ func handleRootError(f *cmdutil.Factory, err error) int {
return bareErr.Code
}
// Errors reaching here are untyped: every RunE returns a typed errs.* error
// and flag-parse errors are typed by the root FlagErrorFunc. The remainder
// is either a cobra usage mistake (missing required flag, unknown command,
// wrong arg count), which cobra surfaces as a plain error identified by its
// stable text — the same external contract unknownFlagName relies on — or an
// untyped error that leaked past the typed boundary. Classify the former as
// invalid_argument (exit 2, like the explicit guards); treat the latter as an
// internal fault (exit 5) rather than blaming the user's input. The message
// is preserved either way, and the typed envelope still carries any pending
// deprecation notice.
var fallback error
if isCobraUsageError(err) {
fallback = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error())
} else {
fallback = errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err)
}
// Every user-input stage is typed before execution. A bare error here has
// crossed that boundary unexpectedly and must remain visible as an internal
// fault instead of being guessed from English message fragments.
fallback := errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err)
output.WriteTypedErrorEnvelope(errOut, fallback, string(f.ResolvedIdentity))
return output.ExitCodeOf(fallback)
}
// cobraUsageErrorMarkers are the stable error-text fragments cobra / pflag
// (pinned at v1.10.2) emit for usage mistakes — missing required flag, unknown
// command / flag, wrong argument count. Cobra surfaces these as plain errors,
// not a typed value we can match on, so the dispatcher recognizes them by text;
// this is the same external contract unknownFlagName already depends on. A
// residual error matching none of these has leaked the typed boundary and is
// treated as an internal fault, not a user error.
var cobraUsageErrorMarkers = []string{
"unknown command ",
"unknown flag: ",
"unknown shorthand",
"required flag(s) ",
"flag needs an argument",
"bad flag syntax:",
"no such flag ",
"invalid argument ",
"arg(s), ", // accepts / requires N arg(s), received / only received M
}
// isCobraUsageError reports whether err is a cobra / pflag usage mistake,
// identified by the stable error text of the pinned cobra version.
func isCobraUsageError(err error) bool {
msg := err.Error()
for _, m := range cobraUsageErrorMarkers {
if strings.Contains(msg, m) {
return true
}
}
return false
}
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
// group commands (no Run/RunE) with an unknown_subcommand error.
//
@@ -345,6 +302,10 @@ func isCobraUsageError(err error) bool {
// with reason_code=risk_not_annotated.
func installUnknownSubcommandGuard(cmd *cobra.Command) {
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
// Cobra's legacy Args fallback rejects an unknown top-level token before
// RunE can produce ranked suggestions. Explicitly accepting positional
// tokens lets every pure group, including root, reach the shared guard.
cmd.Args = cobra.ArbitraryArgs
cmd.RunE = unknownSubcommandRunE
// Route an unknown subcommand to unknownSubcommandRunE even when flags
// are also present (e.g. `sheets +cells-find --url ...`). A pure group
@@ -362,6 +323,132 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
}
}
// installCobraValidationGuards types errors at the stage where Cobra knows
// they are user input: positional argument validation, required flags and flag
// groups. This removes the need for final-boundary message matching.
func installCobraValidationGuards(cmd *cobra.Command) {
if cmd == nil {
return
}
if validateArgs := cmd.Args; validateArgs != nil {
cmd.Args = func(c *cobra.Command, args []string) error {
err := validateArgs(c, args)
if err == nil || errs.IsTyped(err) {
return err
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err)
}
}
previousPreRunE := cmd.PreRunE
previousPreRun := cmd.PreRun
cmd.PreRunE = func(c *cobra.Command, args []string) error {
if previousPreRunE != nil {
if err := previousPreRunE(c, args); err != nil {
return err
}
} else if previousPreRun != nil {
previousPreRun(c, args)
}
if err := c.ValidateRequiredFlags(); err != nil {
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err)
c.Flags().VisitAll(func(flag *pflag.Flag) {
if flag.Changed || len(flag.Annotations[cobra.BashCompOneRequiredFlag]) == 0 {
return
}
validationErr.WithParams(errs.InvalidParam{Name: "--" + flag.Name, Reason: "required flag is missing"})
})
return validationErr.WithHint("run `%s --help` to see required flags", c.CommandPath())
}
if err := c.ValidateFlagGroups(); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).
WithParams(invalidFlagGroupParams(err.Error())...).
WithHint("run `%s --help` to see valid flag combinations", c.CommandPath()).
WithCause(err)
}
return nil
}
cmd.PreRun = nil
for _, child := range cmd.Commands() {
installCobraValidationGuards(child)
}
}
type flagGroupConstraint int
const (
flagGroupRequiredTogether flagGroupConstraint = iota
flagGroupOneRequired
flagGroupMutuallyExclusive
)
// invalidFlagGroupParams extracts the offending flag group from Cobra's
// flag-group validation error. The message always names the group as
// "[flag-a flag-b ...]" and its wording identifies the constraint, so both are
// read straight from the message instead of re-deriving Cobra's private group
// state. Pinned to cobra v1.10.2's message format (see go.mod).
func invalidFlagGroupParams(message string) []errs.InvalidParam {
names := flagGroupNamesFromMessage(message)
if len(names) == 0 {
return nil
}
return buildFlagGroupParams(names, flagGroupConstraintFromMessage(message))
}
func buildFlagGroupParams(names []string, constraint flagGroupConstraint) []errs.InvalidParam {
flagNames := make([]string, 0, len(names))
for _, name := range names {
name = strings.TrimLeft(name, "-")
if name != "" {
flagNames = append(flagNames, "--"+name)
}
}
if len(flagNames) == 0 {
return nil
}
group := "[" + strings.Join(flagNames, " ") + "]"
reason := "invalid flag combination in " + group
switch constraint {
case flagGroupRequiredTogether:
reason = "all of " + group + " required together"
case flagGroupOneRequired:
reason = "one of " + group + " required"
case flagGroupMutuallyExclusive:
reason = "only one of " + group + " allowed"
}
params := make([]errs.InvalidParam, 0, len(flagNames))
for _, name := range flagNames {
params = append(params, errs.InvalidParam{Name: name, Reason: reason})
}
return params
}
func flagGroupNamesFromMessage(message string) []string {
start := strings.IndexByte(message, '[')
if start < 0 {
return nil
}
end := strings.IndexByte(message[start+1:], ']')
if end < 0 {
return nil
}
return strings.Fields(message[start+1 : start+1+end])
}
func flagGroupConstraintFromMessage(message string) flagGroupConstraint {
switch {
case strings.Contains(message, "at least one of the flags"):
return flagGroupOneRequired
case strings.Contains(message, "must all be set"):
return flagGroupRequiredTogether
case strings.Contains(message, "none of the others can be"):
return flagGroupMutuallyExclusive
default:
return flagGroupConstraint(-1)
}
}
// unknownSubcommandRunE replaces cobra's silent help fallback on group commands
// with a typed *errs.ValidationError: a flag that belongs to a missing
// subcommand, a misplaced subcommand-only flag, or an unknown subcommand name
@@ -592,17 +679,21 @@ func isLarkDomain(c *cobra.Command) bool {
return cmdmeta.Domain(c) != ""
}
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
// converts cobra's flag-parse errors into a typed validation envelope: an
// unknown flag gets a focused "did you mean" hint (so agents recover even when
// the typo is semantic, e.g. --query vs --find, where edit distance alone finds
// nothing) and the offending flag in `params`. Other flag errors stay typed
// but generic.
// flagDidYouMean is the single FlagErrorFunc inherited by all commands. It
// classifies pflag's typed parse errors and emits one stable validation shape.
func flagDidYouMean(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagName(ferr)
if !isUnknown {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
WithHint("run `%s --help` for valid flags", c.CommandPath())
if ferr == nil {
return nil
}
var notExist *pflag.NotExistError
if !errors.As(ferr, &notExist) {
return typedFlagParseError(c, ferr)
}
name := notExist.GetSpecifiedName()
rawName := "--" + name
if notExist.GetSpecifiedShortnames() != "" {
rawName = "-" + name
}
valid := visibleFlagNames(c)
suggestions := suggest.Closest(name, valid, 3)
@@ -614,36 +705,69 @@ func flagDidYouMean(c *cobra.Command, ferr error) error {
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
strings.Join(suggestions, ", "), c.CommandPath())
}
// The ranked candidates ride on the param as machine-readable Suggestions so
// an agent can retry without parsing the hint; the hint carries the same
// candidates as prose. The full valid-flag list stays recoverable via --help.
if cmdmeta.Domain(c) == "sheets" {
if list := inlineVisibleFlags(valid); list != "" {
if len(suggestions) > 0 {
hint = fmt.Sprintf("did you mean %s? valid flags: %s", strings.Join(suggestions, ", "), list)
} else {
hint = "valid flags: " + list
}
}
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %q for %q", "--"+name, c.CommandPath()).
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
WithHint("%s", hint)
"unknown flag %q for %q", rawName, c.CommandPath()).
WithParams(errs.InvalidParam{Name: rawName, Reason: "unknown flag", Suggestions: suggestions}).
WithHint("%s", hint).
WithCause(ferr)
}
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
// error text ("unknown flag: --query" → "query"). Returns ok=false for anything
// else (missing argument, invalid value, unknown shorthand) so the caller keeps
// those structured but generic — hallucinated flags are essentially always long.
//
// CONTRACT: this matches cobra's English wording "unknown flag: --" (go.mod
// pins github.com/spf13/cobra). If cobra rewords this or gains i18n the match
// silently fails and unknown flags degrade to a generic flag_error — re-verify
// this prefix when bumping cobra.
func unknownFlagName(err error) (string, bool) {
const p = "unknown flag: --"
msg := err.Error()
i := strings.Index(msg, p)
if i < 0 {
return "", false
func typedFlagParseError(c *cobra.Command, ferr error) error {
param := ""
reason := "flag parse error"
var valueRequired *pflag.ValueRequiredError
var invalidValue *pflag.InvalidValueError
var invalidSyntax *pflag.InvalidSyntaxError
switch {
case errors.As(ferr, &valueRequired):
param = "--" + valueRequired.GetSpecifiedName()
if valueRequired.GetSpecifiedShortnames() != "" {
param = "-" + valueRequired.GetSpecifiedName()
}
reason = "flag value is required"
case errors.As(ferr, &invalidValue):
if invalidValue.GetFlag() != nil {
param = "--" + invalidValue.GetFlag().Name
}
reason = "invalid flag value"
case errors.As(ferr, &invalidSyntax):
param = invalidSyntax.GetSpecifiedFlag()
reason = "invalid flag syntax"
}
rest := msg[i+len(p):]
if j := strings.IndexAny(rest, " \t"); j >= 0 {
rest = rest[:j]
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
WithHint("run `%s --help` for valid flags", c.CommandPath()).
WithCause(ferr)
if param != "" {
validationErr.WithParams(errs.InvalidParam{Name: param, Reason: reason})
}
return rest, true
return validationErr
}
func inlineVisibleFlags(names []string) string {
const limit = 25
if len(names) == 0 {
return ""
}
shown := names
suffix := ""
if len(shown) > limit {
shown = shown[:limit]
suffix = fmt.Sprintf(", ... (%d more; see --help)", len(names)-limit)
}
flags := make([]string, len(shown))
for i, name := range shown {
flags[i] = "--" + name
}
return strings.Join(flags, ", ") + suffix
}
// visibleFlagNames lists the non-hidden flag names of c (for suggestions and

View File

@@ -6,6 +6,7 @@ package cmd
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
@@ -284,9 +285,9 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
deprecation.SetPending(&deprecation.Notice{
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
})
// The bare error shape cobra's ValidateRequiredFlags produces: not a typed
// errs.* error, so it reaches the deprecation fallback.
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
err := errs.NewValidationError(errs.SubtypeInvalidArgument, `required flag(s) %q not set`, "values").
WithParam("--values")
exit := handleRootError(f, err)
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
@@ -381,10 +382,9 @@ func decodeErrorEnvelope(t *testing.T, raw []byte) map[string]any {
return errObj
}
// TestHandleRootError_NoDeprecationTypesUsageError pins that a residual cobra
// usage error (missing required flag) is typed as invalid_argument with exit 2
// even with no deprecation pending — never cobra's plain "Error:" line.
func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
// TestCobraValidationGuardTypesRequiredFlag pins that required-flag errors are
// typed at the Cobra validation stage, before the final dispatcher.
func TestCobraValidationGuardTypesRequiredFlag(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
@@ -393,7 +393,15 @@ func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
cmd := &cobra.Command{Use: "demo", RunE: func(*cobra.Command, []string) error { return nil }}
cmd.Flags().String("values", "", "")
cmd.MarkFlagRequired("values")
installCobraValidationGuards(cmd)
err := cmd.Execute()
if err == nil {
t.Fatal("expected missing required flag error")
}
exit := handleRootError(f, err)
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
@@ -411,6 +419,93 @@ func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
}
}
func TestCobraValidationGuardTypesFlagGroupErrorsWithParams(t *testing.T) {
tests := []struct {
name string
mark func(*cobra.Command)
args []string
wantNames []string
wantReason string
}{
{
name: "one required",
mark: func(cmd *cobra.Command) {
cmd.MarkFlagsOneRequired("start-cell", "range")
},
wantNames: []string{"--start-cell", "--range"},
wantReason: "one of [--start-cell --range] required",
},
{
name: "required together",
mark: func(cmd *cobra.Command) {
cmd.MarkFlagsRequiredTogether("start-cell", "range")
},
args: []string{"--start-cell", "A1"},
wantNames: []string{"--start-cell", "--range"},
wantReason: "all of [--start-cell --range] required together",
},
{
name: "mutually exclusive",
mark: func(cmd *cobra.Command) {
cmd.MarkFlagsMutuallyExclusive("start-cell", "range")
},
args: []string{"--start-cell", "A1", "--range", "B2"},
wantNames: []string{"--start-cell", "--range"},
wantReason: "only one of [--start-cell --range] allowed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "demo", RunE: func(*cobra.Command, []string) error { return nil }}
cmd.Flags().String("start-cell", "", "")
cmd.Flags().String("range", "", "")
tt.mark(cmd)
installCobraValidationGuards(cmd)
cmd.SetArgs(tt.args)
err := cmd.Execute()
if err == nil {
t.Fatal("expected flag group error")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if len(validationErr.Params) != len(tt.wantNames) {
t.Fatalf("params = %v, want %d entries", validationErr.Params, len(tt.wantNames))
}
for i, wantName := range tt.wantNames {
if validationErr.Params[i].Name != wantName || validationErr.Params[i].Reason != tt.wantReason {
t.Errorf("params[%d] = %+v, want name=%q reason=%q", i, validationErr.Params[i], wantName, tt.wantReason)
}
}
})
}
}
func TestInvalidFlagGroupParamsFromMessage(t *testing.T) {
got := invalidFlagGroupParams("at least one of the flags in the group [start-cell range] is required")
if len(got) != 2 {
t.Fatalf("params = %v, want two entries", got)
}
for i, want := range []string{"--start-cell", "--range"} {
if got[i].Name != want || got[i].Reason != "one of [--start-cell --range] required" {
t.Errorf("params[%d] = %+v", i, got[i])
}
}
}
// TestHandleRootError_LeakedUntypedErrorBecomesInternal pins that an untyped
// error that does NOT match a cobra usage shape (i.e. one that leaked past the
// typed boundary from a helper) is classified as an internal fault (exit 5),

View File

@@ -140,6 +140,7 @@ type ServiceMethodOptions struct {
PageLimit int
PageDelay int
Format string
JSON bool
JqExpr string
DryRun bool
File string // --file flag value
@@ -268,6 +269,11 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
opts.Cmd = cmd
opts.Ctx = cmd.Context()
opts.As = core.Identity(asStr)
format, err := output.StandardFormats.Resolve(opts.Format, cmd.Flags().Changed("format"), opts.JSON)
if err != nil {
return err
}
opts.Format = format
if runF != nil {
return runF(opts)
}
@@ -299,8 +305,8 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
_ = cmd.Flags().MarkHidden(name)
}
}
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVar(&opts.Format, "format", "json", output.StandardFormats.Usage())
cmd.Flags().BoolVar(&opts.JSON, "json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
if spec.risk == cmdutil.RiskHighRiskWrite {
@@ -311,7 +317,7 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
cmd.Flags().StringVar(&opts.File, "file", "", "File upload [field=]path. Supports - and stdin.")
}
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
return output.StandardFormats.Names(), cobra.ShellCompDirectiveNoFileComp
})
// Registered last so the collision guard sees the standard flags above.
@@ -403,9 +409,9 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
if opts.DryRun {
if fileMeta != nil {
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
return cmdutil.PrintDryRunWithFile(request, config, serviceDryRunOutputOptions(f, opts), *fileMeta)
}
return serviceDryRun(f, request, config, opts.Format)
return serviceDryRun(f, request, config, opts)
}
if opts.Method.Risk == cmdutil.RiskHighRiskWrite {
@@ -420,10 +426,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
}
out := f.IOStreams.Out
format, formatOK := output.ParseFormat(opts.Format)
if !formatOK {
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
}
format, _ := output.ParseFormat(opts.Format)
// Scope-insufficient (99991679) and all other Lark API codes route through
// errclass.BuildAPIError via ac.CheckResponse, producing *errs.PermissionError
@@ -667,8 +670,19 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
return request, nil, nil
}
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *ServiceMethodOptions) error {
return cmdutil.PrintDryRun(request, config, serviceDryRunOutputOptions(f, opts))
}
func serviceDryRunOutputOptions(f *cmdutil.Factory, opts *ServiceMethodOptions) cmdutil.DryRunOutputOptions {
return cmdutil.DryRunOutputOptions{
Format: opts.Format,
JqExpr: opts.JqExpr,
CommandPath: opts.Cmd.CommandPath(),
Identity: opts.As,
Out: f.IOStreams.Out,
ErrOut: f.IOStreams.ErrOut,
}
}
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
@@ -695,6 +709,24 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
}
switch format {
case output.FormatPretty:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return err
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatPretty)
return apiErr
}
scanResult := output.ScanForSafety(commandPath, result, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
output.FormatValue(out, result, output.FormatPretty)
return nil
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {

View File

@@ -201,6 +201,42 @@ func TestNewCmdServiceMethod_RunFCallback(t *testing.T) {
}
}
func TestServiceMethod_OutputFormatResolution(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "json shorthand", args: []string{"--json"}, want: "json"},
{name: "explicit format wins", args: []string{"--format", "table", "--json"}, want: "table"},
{name: "pretty format", args: []string{"--format", "PRETTY"}, want: "pretty"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil
})
args := []string{"--as", "bot"}
cmd.SetArgs(append(args, tt.args...))
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if captured == nil {
t.Fatal("expected options to be captured")
}
if captured.Format != tt.want {
t.Fatalf("format = %q, want %q", captured.Format, tt.want)
}
})
}
}
// ── dry-run / buildServiceRequest ──
func TestServiceMethod_DryRun_PathParam(t *testing.T) {
@@ -224,13 +260,39 @@ func TestServiceMethod_DryRun_PathParam(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), tt.wantInURL) {
t.Errorf("expected URL containing %q, got:\n%s", tt.wantInURL, stdout.String())
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, stdout.String())
}
if got["ok"] != true || got["dry_run"] != true {
t.Fatalf("unexpected dry-run envelope: %#v", got)
}
data := got["data"].(map[string]interface{})
api := data["api"].([]interface{})
call := api[0].(map[string]interface{})
if call["url"] != tt.wantInURL {
t.Errorf("url = %q, want %q\nstdout:\n%s", call["url"], tt.wantInURL, stdout.String())
}
})
}
}
func TestServiceMethod_DryRunWithJq(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("GET", nil), "get", "files", nil)
cmd.SetArgs([]string{
"--params", `{"file_token":"boxcn123abc"}`,
"--dry-run",
"--jq", ".data.api[0].url",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, want := strings.TrimSpace(stdout.String()), "/open-apis/drive/v1/files/boxcn123abc/copy"; got != want {
t.Fatalf("jq output = %q, want %q", got, want)
}
}
func TestServiceMethod_PathParamRejectsTraversal(t *testing.T) {
tests := []struct {
name string
@@ -318,8 +380,12 @@ func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) {
if err != nil {
t.Fatalf("expected no error with --page-all skipping page_size, got: %v", err)
}
if !strings.Contains(stdout.String(), "Dry Run") {
t.Error("expected dry-run output")
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, stdout.String())
}
if got["dry_run"] != true {
t.Fatalf("dry_run = %#v, want true", got["dry_run"])
}
}
@@ -432,6 +498,43 @@ func TestServiceMethod_BotMode_Success(t *testing.T) {
}
}
func TestServiceMethod_PrettyFormatsRealResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, testConfig)
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"name": "Alice"}},
},
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--format", "pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("pretty output should be valid JSON: %v\n%s", err, out)
}
data, ok := got["data"].(map[string]interface{})
if !ok {
t.Fatalf("pretty output data = %#v", got["data"])
}
items, ok := data["items"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("pretty output data.items = %#v", data["items"])
}
if !strings.Contains(out, "\n \"data\": {") || strings.Contains(out, "─") {
t.Fatalf("pretty output should be indented JSON rather than a table, got:\n%s", out)
}
}
func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-page", AppSecret: "test-secret-page", Brand: core.BrandFeishu,
@@ -473,6 +576,48 @@ func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
}
}
func TestServiceMethod_PageAll_PrettyAggregatesIndentedJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-pageall-pretty", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--format", "pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("page-all pretty output should be valid JSON: %v\n%s", err, out)
}
data, ok := got["data"].(map[string]interface{})
if !ok {
t.Fatalf("page-all pretty output data = %#v", got["data"])
}
items, ok := data["items"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("page-all pretty output data.items = %#v", data["items"])
}
if !strings.Contains(out, "\n \"data\": {") || strings.Contains(out, "─") {
t.Fatalf("page-all pretty output should be aggregated indented JSON, got:\n%s", out)
}
}
type serviceContentSafetyProvider struct {
called bool
path string
@@ -765,26 +910,23 @@ func TestServiceMethod_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T)
}
}
func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
func TestServiceMethod_UnknownFormat_ReturnsValidationError(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--format", "unknown"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
err := cmd.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if !strings.Contains(stderr.String(), "warning: unknown format") {
t.Errorf("expected format warning in stderr, got:\n%s", stderr.String())
if validationErr.Param != "--format" {
t.Errorf("param = %q, want --format", validationErr.Param)
}
}
@@ -1081,11 +1223,23 @@ func TestServiceMethod_FileUpload_DryRun(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "image") {
t.Errorf("expected dry-run output to mention file field, got: %s", out)
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, out)
}
if !strings.Contains(out, "Dry Run") {
t.Errorf("expected dry-run header, got: %s", out)
if env["dry_run"] != true {
t.Fatalf("dry_run = %#v, want true", env["dry_run"])
}
data := env["data"].(map[string]interface{})
api := data["api"].([]interface{})
call := api[0].(map[string]interface{})
body := call["body"].(map[string]interface{})
file := body["file"].(map[string]interface{})
if file["field"] != "image" || file["path"] != tmpFile {
t.Fatalf("unexpected file dry-run body: %#v", body)
}
if strings.Contains(out, "=== Dry Run ===") {
t.Fatalf("stdout should not contain dry-run banner: %s", out)
}
}

View File

@@ -45,11 +45,38 @@ func TestInstallUnknownSubcommandGuard_InstallsOnGroupsOnly(t *testing.T) {
if files.RunE == nil {
t.Error("files should have RunE installed")
}
if root.Args == nil {
t.Error("root should explicitly accept positional tokens so unknown commands reach RunE")
}
if err := leaf.RunE(leaf, []string{"unexpected-arg"}); err != nil {
t.Errorf("leaf +search RunE should be untouched, got error %v", err)
}
}
func TestTopLevelUnknownCommandReturnsStructuredSuggestion(t *testing.T) {
root, _, _ := newGroupTree()
installUnknownSubcommandGuard(root)
root.SetArgs([]string{"driv"})
err := root.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if len(validationErr.Params) != 1 || validationErr.Params[0].Name != "driv" {
t.Fatalf("params = %v, want one entry named driv", validationErr.Params)
}
found := false
for _, candidate := range validationErr.Params[0].Suggestions {
if candidate == "drive" {
found = true
}
}
if !found {
t.Fatalf("suggestions = %v, want drive", validationErr.Params[0].Suggestions)
}
}
func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
called := false

View File

@@ -13,17 +13,29 @@ import (
// ImMessageReceiveOutput is the flattened shape for im.message.receive_v1; `desc` tags drive the reflected schema.
type ImMessageReceiveOutput struct {
Type string `json:"type" desc:"Event type; always im.message.receive_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
ID string `json:"id,omitempty" desc:"Message ID (legacy alias of message_id, kept for compatibility)" kind:"message_id"`
MessageID string `json:"message_id,omitempty" desc:"Message ID; prefixed with om_" kind:"message_id"`
CreateTime string `json:"create_time,omitempty" desc:"Message creation time (ms timestamp string)" kind:"timestamp_ms"`
ChatID string `json:"chat_id,omitempty" desc:"Chat/conversation ID; prefixed with oc_" kind:"chat_id"`
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
MessageType string `json:"message_type,omitempty" desc:"Message type"`
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
Type string `json:"type" desc:"Event type; always im.message.receive_v1"`
EventID string `json:"event_id,omitempty" desc:"Event delivery ID. Do not use as the message deduplication key; use message_id instead."`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
ID string `json:"id,omitempty" desc:"Message ID (legacy alias of message_id, kept for compatibility)" kind:"message_id"`
MessageID string `json:"message_id,omitempty" desc:"Message ID; prefixed with om_. Recommended idempotency key for im.message.receive_v1 consumers." kind:"message_id"`
CreateTime string `json:"create_time,omitempty" desc:"Message creation time (ms timestamp string)" kind:"timestamp_ms"`
UpdateTime string `json:"update_time,omitempty" desc:"Message update time (ms timestamp string); emitted only when different from create_time" kind:"timestamp_ms"`
ChatID string `json:"chat_id,omitempty" desc:"Chat/conversation ID; prefixed with oc_" kind:"chat_id"`
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
MessageType string `json:"message_type,omitempty" desc:"Message type"`
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
SenderType string `json:"sender_type,omitempty" desc:"Sender type" enum:"user,bot"`
RootID string `json:"root_id,omitempty" desc:"Root message ID of the reply/thread context, when present" kind:"message_id"`
ThreadID string `json:"thread_id,omitempty" desc:"Thread ID, when present"`
ReplyTo string `json:"reply_to,omitempty" desc:"Parent message ID of the direct reply context, when present" kind:"message_id"`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
Mentions []MentionOutput `json:"mentions,omitempty" desc:"Compact mentions aligned with im +messages-mget"`
}
type MentionOutput struct {
Key string `json:"key,omitempty" desc:"Mention placeholder key, for example @_user_1"`
ID string `json:"id,omitempty" desc:"Mentioned user open_id; prefixed with ou_" kind:"open_id"`
Name string `json:"name,omitempty" desc:"Mentioned display name"`
}
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
@@ -36,15 +48,20 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
Event struct {
Message struct {
MessageID string `json:"message_id"`
RootID string `json:"root_id"`
ParentID string `json:"parent_id"`
ThreadID string `json:"thread_id"`
ChatID string `json:"chat_id"`
ChatType string `json:"chat_type"`
MessageType string `json:"message_type"`
Content string `json:"content"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
Mentions []interface{} `json:"mentions"`
} `json:"message"`
Sender struct {
SenderID struct {
SenderType string `json:"sender_type"`
SenderID struct {
OpenID string `json:"open_id"`
} `json:"sender_id"`
} `json:"sender"`
@@ -81,7 +98,54 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
ChatType: msg.ChatType,
MessageType: msg.MessageType,
SenderID: envelope.Event.Sender.SenderID.OpenID,
SenderType: envelope.Event.Sender.SenderType,
RootID: msg.RootID,
ThreadID: msg.ThreadID,
ReplyTo: msg.ParentID,
Content: content,
Mentions: compactMentions(msg.Mentions),
}
if msg.UpdateTime != "" && msg.UpdateTime != msg.CreateTime {
out.UpdateTime = msg.UpdateTime
}
return json.Marshal(out)
}
func compactMentions(mentions []interface{}) []MentionOutput {
if len(mentions) == 0 {
return nil
}
out := make([]MentionOutput, 0, len(mentions))
for _, raw := range mentions {
item, _ := raw.(map[string]interface{})
mention := MentionOutput{
Key: stringField(item, "key"),
ID: mentionOpenID(item["id"]),
Name: stringField(item, "name"),
}
if mention.Key != "" || mention.ID != "" || mention.Name != "" {
out = append(out, mention)
}
}
if len(out) == 0 {
return nil
}
return out
}
func stringField(m map[string]interface{}, key string) string {
v, _ := m[key].(string)
return v
}
func mentionOpenID(raw interface{}) string {
switch v := raw.(type) {
case map[string]interface{}:
openID, _ := v["open_id"].(string)
return openID
case string:
return v
default:
return ""
}
}

View File

@@ -84,19 +84,32 @@ func TestProcessImMessageReceive_Text(t *testing.T) {
},
"event": {
"sender": {
"sender_type": "user",
"sender_id": {"open_id": "ou_sender"}
},
"message": {
"message_id": "om_text_001",
"root_id": "om_root_001",
"parent_id": "om_parent_001",
"thread_id": "omt_thread_001",
"chat_id": "oc_chat",
"chat_type": "p2p",
"message_type": "text",
"create_time": "1776409468987",
"content": "{\"text\":\"hello there\"}"
"update_time": "1776409469999",
"content": "{\"text\":\"hello @_user_1\"}",
"mentions": [
{
"key": "@_user_1",
"id": {"open_id": "ou_mentioned"},
"name": "Alice"
}
]
}
}
}`
out := runReceive(t, payload)
outMap := runReceiveMap(t, payload)
if out.Type != "im.message.receive_v1" {
t.Errorf("Type = %q", out.Type)
@@ -110,12 +123,69 @@ func TestProcessImMessageReceive_Text(t *testing.T) {
if out.SenderID != "ou_sender" {
t.Errorf("SenderID = %q", out.SenderID)
}
if out.Content != "hello there" {
t.Errorf("Content = %q, want \"hello there\"", out.Content)
if out.Content != "hello @Alice" {
t.Errorf("Content = %q, want \"hello @Alice\"", out.Content)
}
if out.Timestamp != "1776409469273" {
t.Errorf("Timestamp = %q", out.Timestamp)
}
for field, want := range map[string]string{
"sender_type": "user",
"root_id": "om_root_001",
"thread_id": "omt_thread_001",
"reply_to": "om_parent_001",
"update_time": "1776409469999",
} {
if got, _ := outMap[field].(string); got != want {
t.Errorf("%s = %q, want %q", field, got, want)
}
}
mentions, _ := outMap["mentions"].([]interface{})
if len(mentions) != 1 {
t.Fatalf("mentions length = %d, want 1: %#v", len(mentions), outMap["mentions"])
}
mention, _ := mentions[0].(map[string]interface{})
for field, want := range map[string]string{
"key": "@_user_1",
"id": "ou_mentioned",
"name": "Alice",
} {
if got, _ := mention[field].(string); got != want {
t.Errorf("mentions[0].%s = %q, want %q", field, got, want)
}
}
}
func TestProcessImMessageReceive_OmitsUnchangedUpdateTime(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_test_text",
"event_type": "im.message.receive_v1",
"create_time": "1776409469273",
"app_id": "cli_test"
},
"event": {
"sender": {
"sender_type": "user",
"sender_id": {"open_id": "ou_sender"}
},
"message": {
"message_id": "om_text_001",
"chat_id": "oc_chat",
"chat_type": "p2p",
"message_type": "text",
"create_time": "1776409468987",
"update_time": "1776409468987",
"content": "{\"text\":\"hello there\"}"
}
}
}`
outMap := runReceiveMap(t, payload)
if _, ok := outMap["update_time"]; ok {
t.Errorf("update_time should be omitted when it equals create_time: %#v", outMap)
}
}
func TestProcessImMessageReceive_Interactive(t *testing.T) {
@@ -188,3 +258,22 @@ func runReceive(t *testing.T, payload string) ImMessageReceiveOutput {
}
return out
}
func runReceiveMap(t *testing.T, payload string) map[string]interface{} {
t.Helper()
raw := &event.RawEvent{
EventID: "ev_test",
EventType: "im.message.receive_v1",
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processImMessageReceive(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
var out map[string]interface{}
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
}
return out
}

2
go.mod
View File

@@ -14,7 +14,7 @@ require (
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
github.com/spf13/cobra v1.10.2 // flag-error-text contract: see cmd/root.go unknownFlagName
github.com/spf13/cobra v1.10.2 // typed flag errors are classified in cmd/root.go
github.com/spf13/pflag v1.0.9
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.18.0

View File

@@ -8,15 +8,29 @@ import (
"fmt"
"io"
"net/url"
"regexp"
"sort"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
)
var dryRunURLPlaceholderRE = regexp.MustCompile(`:([A-Za-z_][A-Za-z0-9_]*)`)
// DryRunOutputOptions controls dry-run stdout/stderr rendering.
type DryRunOutputOptions struct {
Format string
JqExpr string
CommandPath string
Identity core.Identity
Out io.Writer
ErrOut io.Writer
}
// DryRunAPICall describes a single API call in dry-run output.
type DryRunAPICall struct {
Desc string `json:"desc,omitempty"`
@@ -26,12 +40,21 @@ type DryRunAPICall struct {
Body interface{} `json:"body,omitempty"`
}
// DryRunContext is the execution context shared by every dry-run preview:
// which app would make the call and, when known, as which user. The identity
// itself lives at the envelope top level, not here.
type DryRunContext struct {
AppID string `json:"app_id,omitempty"`
UserOpenID string `json:"user_open_id,omitempty"`
}
// DryRunAPI is the builder and result type for dry-run output.
// URL templates use :param placeholders; Set stores actual values; MarshalJSON and Format resolve them.
type DryRunAPI struct {
desc string
calls []DryRunAPICall
extra map[string]interface{}
desc string
calls []DryRunAPICall
context *DryRunContext
extra map[string]interface{}
}
func NewDryRunAPI() *DryRunAPI {
@@ -40,30 +63,22 @@ func NewDryRunAPI() *DryRunAPI {
// --- HTTP method builders (add a call, return self for chaining) ---
func (d *DryRunAPI) GET(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "GET", URL: url})
// call appends a request with the method transcribed verbatim, so previews
// never misreport what the real client would send.
func (d *DryRunAPI) call(method, url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: method, URL: url})
return d
}
func (d *DryRunAPI) POST(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "POST", URL: url})
return d
}
func (d *DryRunAPI) GET(url string) *DryRunAPI { return d.call("GET", url) }
func (d *DryRunAPI) PUT(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "PUT", URL: url})
return d
}
func (d *DryRunAPI) POST(url string) *DryRunAPI { return d.call("POST", url) }
func (d *DryRunAPI) DELETE(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "DELETE", URL: url})
return d
}
func (d *DryRunAPI) PUT(url string) *DryRunAPI { return d.call("PUT", url) }
func (d *DryRunAPI) PATCH(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "PATCH", URL: url})
return d
}
func (d *DryRunAPI) DELETE(url string) *DryRunAPI { return d.call("DELETE", url) }
func (d *DryRunAPI) PATCH(url string) *DryRunAPI { return d.call("PATCH", url) }
// Body sets the request body on the last added call.
func (d *DryRunAPI) Body(body interface{}) *DryRunAPI {
@@ -98,12 +113,26 @@ func (d *DryRunAPI) Set(key string, value interface{}) *DryRunAPI {
return d
}
// Context records the calling app/user under data.context; empty values are
// omitted, and a fully empty context is not emitted at all.
func (d *DryRunAPI) Context(appID, userOpenID string) *DryRunAPI {
if appID == "" && userOpenID == "" {
return d
}
d.context = &DryRunContext{AppID: appID, UserOpenID: userOpenID}
return d
}
// resolveURL replaces :key placeholders in url with path-escaped values from extra.
func (d *DryRunAPI) resolveURL(rawURL string) string {
for k, v := range d.extra {
rawURL = strings.ReplaceAll(rawURL, ":"+k, url.PathEscape(fmt.Sprintf("%v", v)))
}
return rawURL
return dryRunURLPlaceholderRE.ReplaceAllStringFunc(rawURL, func(token string) string {
name := token[1:]
value, ok := d.extra[name]
if !ok {
return token
}
return url.PathEscape(fmt.Sprintf("%v", value))
})
}
// MarshalJSON serializes as {"description": "...", "api": [...calls with resolved URLs], ...extra}.
@@ -118,13 +147,17 @@ func (d *DryRunAPI) MarshalJSON() ([]byte, error) {
Body: c.Body,
}
}
m := make(map[string]interface{}, len(d.extra)+2)
m := make(map[string]interface{}, len(d.extra)+3)
for k, v := range d.extra {
m[k] = v
}
// Typed fields win over same-named extra keys.
if d.desc != "" {
m["description"] = d.desc
}
m["api"] = resolved
for k, v := range d.extra {
m[k] = v
if d.context != nil {
m["context"] = d.context
}
return json.Marshal(m)
}
@@ -154,11 +187,7 @@ func (d *DryRunAPI) Format() string {
u += "?" + encodeParams(c.Params)
}
method := c.Method
if method == "" {
method = "GET"
}
b.WriteString(method)
b.WriteString(c.Method)
b.WriteByte(' ')
b.WriteString(u)
b.WriteByte('\n')
@@ -215,83 +244,74 @@ func encodeParams(params map[string]interface{}) string {
return vals.Encode()
}
// PrintDryRunWithFile outputs a dry-run summary for file upload requests.
// Instead of serializing the Formdata body, it shows file metadata.
func PrintDryRunWithFile(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format, fileField, filePath string, formFields any) error {
dr := NewDryRunAPI()
switch request.Method {
case "POST":
dr.POST(request.URL)
case "PUT":
dr.PUT(request.URL)
case "PATCH":
dr.PATCH(request.URL)
case "DELETE":
dr.DELETE(request.URL)
default:
dr.GET(request.URL)
}
// buildDryRunPreview assembles the shared preview skeleton: HTTP method, URL,
// query params, and the app/user context common to every dry-run.
func buildDryRunPreview(request client.RawApiRequest, config *core.CliConfig) *DryRunAPI {
dr := NewDryRunAPI().call(request.Method, request.URL)
if len(request.Params) > 0 {
dr.Params(request.Params)
}
filePathDisplay := filePath
// Identity is reported at the envelope top level, not duplicated here.
dr.Context(config.AppID, config.UserOpenId)
return dr
}
// PrintDryRunWithFile outputs a dry-run summary for file upload requests.
// Instead of serializing the Formdata body, it shows file metadata.
func PrintDryRunWithFile(request client.RawApiRequest, config *core.CliConfig, opts DryRunOutputOptions, file FileUploadMeta) error {
dr := buildDryRunPreview(request, config)
filePathDisplay := file.FilePath
if filePathDisplay == "" {
filePathDisplay = "<stdin>"
}
fileInfo := map[string]any{
"file": map[string]string{"field": fileField, "path": filePathDisplay},
"file": map[string]string{"field": file.FieldName, "path": filePathDisplay},
}
if formFields != nil {
fileInfo["form_fields"] = formFields
if file.FormFields != nil {
fileInfo["form_fields"] = file.FormFields
}
fileInfo["options"] = []string{"WithFileUpload"}
dr.Body(fileInfo)
dr.Set("as", string(request.As))
dr.Set("appId", config.AppID)
if config.UserOpenId != "" {
dr.Set("userOpenId", config.UserOpenId)
}
fmt.Fprintln(w, "=== Dry Run ===")
if format == "pretty" {
fmt.Fprint(w, dr.Format())
} else {
output.PrintJson(w, dr)
}
return nil
return WriteDryRun(dr, opts)
}
// PrintDryRun outputs a standardised dry-run summary using DryRunAPI.
// When format is "pretty", outputs human-readable text; otherwise JSON.
func PrintDryRun(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format string) error {
dr := NewDryRunAPI()
switch request.Method {
case "POST":
dr.POST(request.URL)
case "PUT":
dr.PUT(request.URL)
case "PATCH":
dr.PATCH(request.URL)
case "DELETE":
dr.DELETE(request.URL)
default:
dr.GET(request.URL)
}
if len(request.Params) > 0 {
dr.Params(request.Params)
}
func PrintDryRun(request client.RawApiRequest, config *core.CliConfig, opts DryRunOutputOptions) error {
dr := buildDryRunPreview(request, config)
if !util.IsNil(request.Data) {
dr.Body(request.Data)
}
dr.Set("as", string(request.As))
dr.Set("appId", config.AppID)
if config.UserOpenId != "" {
dr.Set("userOpenId", config.UserOpenId)
}
fmt.Fprintln(w, "=== Dry Run ===")
if format == "pretty" {
fmt.Fprint(w, dr.Format())
} else {
output.PrintJson(w, dr)
}
return nil
return WriteDryRun(dr, opts)
}
// WriteDryRun emits a DryRunAPI using the shared dry-run output contract.
// Identity may be empty; the envelope omits it rather than guessing.
func WriteDryRun(dr *DryRunAPI, opts DryRunOutputOptions) error {
if dr == nil {
return errs.NewInternalError(errs.SubtypeUnknown, "dry-run produced no request preview")
}
// The JqExpr guard is defensive: every entry point already rejects --jq
// combined with --format pretty via output.ValidateJqFlags.
if opts.Format == "pretty" && opts.JqExpr == "" {
// A nil ErrOut only skips the banner decoration (mirroring
// WriteSuccessEnvelope's warning path); the payload write to Out
// must fail loudly rather than be silently discarded.
if opts.ErrOut != nil {
fmt.Fprintln(opts.ErrOut, "=== Dry Run ===")
}
// stdout carries its own marker so logs that drop stderr still show
// this was a preview, not an executed request.
fmt.Fprintln(opts.Out, "# dry-run: request not sent")
fmt.Fprint(opts.Out, dr.Format())
return nil
}
return output.WriteSuccessEnvelope(dr, output.SuccessEnvelopeOptions{
CommandPath: opts.CommandPath,
Identity: string(opts.Identity),
DryRun: true,
JqExpr: opts.JqExpr,
Out: opts.Out,
ErrOut: opts.ErrOut,
})
}

View File

@@ -6,9 +6,12 @@ package cmdutil
import (
"bytes"
"encoding/json"
"errors"
"io"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
)
@@ -66,11 +69,31 @@ func TestDryRunAPI_ResolveURL(t *testing.T) {
}
}
func TestDryRunAPI_ResolveURLMatchesFullPlaceholderOnly(t *testing.T) {
dr := NewDryRunAPI().
GET("/open-apis/task/v2/tasks/:assignee_id").
Set("assignee", "ou_bot")
text := dr.Format()
if strings.Contains(text, "ou_bot_id") {
t.Fatalf("prefix placeholder key corrupted longer token: %s", text)
}
if !strings.Contains(text, ":assignee_id") {
t.Fatalf("missing unresolved placeholder, got: %s", text)
}
dr.Set("assignee_id", "ou_abc/123")
text = dr.Format()
if !strings.Contains(text, "/open-apis/task/v2/tasks/ou_abc%2F123") {
t.Fatalf("expected full placeholder replacement with path escaping, got: %s", text)
}
}
func TestDryRunAPI_MarshalJSON(t *testing.T) {
dr := NewDryRunAPI().
Desc("test api").
GET("/open-apis/test").
Set("as", "user")
Set("note", "audit")
data, err := json.Marshal(dr)
if err != nil {
@@ -83,8 +106,8 @@ func TestDryRunAPI_MarshalJSON(t *testing.T) {
if m["description"] != "test api" {
t.Errorf("expected description, got: %v", m["description"])
}
if m["as"] != "user" {
t.Errorf("expected as=user, got: %v", m["as"])
if m["note"] != "audit" {
t.Errorf("expected note=audit, got: %v", m["note"])
}
api, ok := m["api"].([]interface{})
if !ok || len(api) != 1 {
@@ -123,31 +146,67 @@ func TestDryRunAPI_ExtraFieldsOnly(t *testing.T) {
func TestPrintDryRun_JSON(t *testing.T) {
var buf bytes.Buffer
err := PrintDryRun(&buf, client.RawApiRequest{
var errBuf bytes.Buffer
err := PrintDryRun(client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test",
As: "user",
}, &core.CliConfig{AppID: "app123"}, "json")
}, &core.CliConfig{AppID: "app123"}, DryRunOutputOptions{
Format: "json",
CommandPath: "lark-cli api",
Identity: core.AsUser,
Out: &buf,
ErrOut: &errBuf,
})
if err != nil {
t.Fatalf("PrintDryRun failed: %v", err)
}
out := buf.String()
if !strings.Contains(out, "=== Dry Run ===") {
t.Errorf("expected header, got: %s", out)
if strings.Contains(out, "=== Dry Run ===") {
t.Fatalf("JSON stdout must not contain banner, got: %s", out)
}
if !strings.Contains(out, "app123") {
t.Errorf("expected appId in output, got: %s", out)
var env map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, out)
}
if env["ok"] != true || env["identity"] != "user" || env["dry_run"] != true {
t.Fatalf("unexpected envelope: %#v", env)
}
data, ok := env["data"].(map[string]interface{})
if !ok {
t.Fatalf("unexpected data: %#v", env["data"])
}
dctx, ok := data["context"].(map[string]interface{})
if !ok || dctx["app_id"] != "app123" {
t.Fatalf("unexpected data.context: %#v", data["context"])
}
if _, exists := data["as"]; exists {
t.Fatalf("data.as must not appear; identity lives at the envelope top level: %#v", data)
}
api, ok := data["api"].([]interface{})
if !ok || len(api) != 1 {
t.Fatalf("api = %#v, want one call", data["api"])
}
call, ok := api[0].(map[string]interface{})
if !ok || call["url"] != "/open-apis/test" {
t.Fatalf("api[0] = %#v", api[0])
}
}
func TestPrintDryRun_Pretty(t *testing.T) {
var buf bytes.Buffer
err := PrintDryRun(&buf, client.RawApiRequest{
var errBuf bytes.Buffer
err := PrintDryRun(client.RawApiRequest{
Method: "POST",
URL: "/open-apis/test",
Data: map[string]interface{}{"key": "val"},
As: "bot",
}, &core.CliConfig{AppID: "app456"}, "pretty")
}, &core.CliConfig{AppID: "app456"}, DryRunOutputOptions{
Format: "pretty",
Identity: core.AsBot,
Out: &buf,
ErrOut: &errBuf,
})
if err != nil {
t.Fatalf("PrintDryRun failed: %v", err)
}
@@ -155,6 +214,136 @@ func TestPrintDryRun_Pretty(t *testing.T) {
if !strings.Contains(out, "POST /open-apis/test") {
t.Errorf("expected POST line in pretty output, got: %s", out)
}
if !strings.HasPrefix(out, "# dry-run: request not sent\n") {
t.Fatalf("pretty stdout should start with the dry-run marker, got: %s", out)
}
if strings.Contains(out, "=== Dry Run ===") {
t.Fatalf("pretty stdout must not contain banner, got: %s", out)
}
if !strings.Contains(errBuf.String(), "=== Dry Run ===") {
t.Fatalf("pretty stderr should contain banner, got: %s", errBuf.String())
}
}
func TestPrintDryRun_WithJqUsesEnvelope(t *testing.T) {
var buf bytes.Buffer
err := PrintDryRun(client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test",
As: "bot",
}, &core.CliConfig{AppID: "app123"}, DryRunOutputOptions{
Format: "json",
JqExpr: ".data.api[0].url",
Identity: core.AsBot,
Out: &buf,
ErrOut: io.Discard,
})
if err != nil {
t.Fatalf("PrintDryRun failed: %v", err)
}
if got := strings.TrimSpace(buf.String()); got != "/open-apis/test" {
t.Fatalf("jq output = %q, want /open-apis/test", got)
}
}
func TestPrintDryRunWithFile_JSONEnvelope(t *testing.T) {
var buf bytes.Buffer
err := PrintDryRunWithFile(client.RawApiRequest{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
As: "bot",
}, &core.CliConfig{AppID: "app123", UserOpenId: "ou_tester"}, DryRunOutputOptions{
Format: "json",
Identity: core.AsBot,
Out: &buf,
ErrOut: io.Discard,
}, FileUploadMeta{FieldName: "file", FilePath: "report.txt", FormFields: map[string]any{"parent": "fld"}})
if err != nil {
t.Fatalf("PrintDryRunWithFile failed: %v", err)
}
var env map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, buf.String())
}
if env["dry_run"] != true {
t.Fatalf("dry_run = %#v, want true", env["dry_run"])
}
data := env["data"].(map[string]interface{})
api := data["api"].([]interface{})
call := api[0].(map[string]interface{})
body := call["body"].(map[string]interface{})
file := body["file"].(map[string]interface{})
if file["path"] != "report.txt" {
t.Fatalf("file body = %#v", body)
}
dctx, ok := data["context"].(map[string]interface{})
if !ok || dctx["app_id"] != "app123" || dctx["user_open_id"] != "ou_tester" {
t.Fatalf("unexpected data.context: %#v", data["context"])
}
for _, legacy := range []string{"as", "appId", "userOpenId"} {
if _, exists := data[legacy]; exists {
t.Fatalf("legacy key %q must not appear in data: %#v", legacy, data)
}
}
}
func TestPrintDryRun_MethodTranscribedVerbatim(t *testing.T) {
var buf bytes.Buffer
err := PrintDryRun(client.RawApiRequest{
Method: "OPTIONS",
URL: "/open-apis/test",
As: "bot",
}, &core.CliConfig{AppID: "app123"}, DryRunOutputOptions{
Format: "json",
Identity: core.AsBot,
Out: &buf,
ErrOut: io.Discard,
})
if err != nil {
t.Fatalf("PrintDryRun failed: %v", err)
}
var env map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, buf.String())
}
call := env["data"].(map[string]interface{})["api"].([]interface{})[0].(map[string]interface{})
if call["method"] != "OPTIONS" {
t.Fatalf("method = %#v, want OPTIONS transcribed verbatim (not coerced to GET)", call["method"])
}
}
func TestPrintDryRun_EmptyConfigOmitsContext(t *testing.T) {
var buf bytes.Buffer
err := PrintDryRun(client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test",
}, &core.CliConfig{}, DryRunOutputOptions{
Format: "json",
Out: &buf,
ErrOut: io.Discard,
})
if err != nil {
t.Fatalf("PrintDryRun failed: %v", err)
}
var env map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, buf.String())
}
data := env["data"].(map[string]interface{})
if _, exists := data["context"]; exists {
t.Fatalf("empty app/user context must be omitted entirely, got: %#v", data["context"])
}
}
func TestWriteDryRun_NilPreviewIsInternalError(t *testing.T) {
err := WriteDryRun(nil, DryRunOutputOptions{Format: "json", Out: io.Discard})
if err == nil {
t.Fatal("WriteDryRun(nil) should fail instead of emitting an empty preview")
}
var internal *errs.InternalError
if !errors.As(err, &internal) {
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
}
}
func TestDryRunFormatValue(t *testing.T) {

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"slices"
"strings"
"github.com/larksuite/cli/errs"
)
// FormatCapabilities is the single description of the output formats a
// command supports. Help text, shell completion, shorthand normalization and
// runtime validation all consume the same value so they cannot drift apart.
type FormatCapabilities struct {
names []string
}
var (
// StandardFormats applies to API, service and ordinary shortcut commands.
StandardFormats = NewFormatCapabilities("json", "pretty", "table", "ndjson", "csv")
// JSONPrettyFormats applies to commands with a dedicated human renderer and
// no streaming/tabular output, such as auth scopes.
JSONPrettyFormats = NewFormatCapabilities("json", "pretty")
)
// NewFormatCapabilities constructs an immutable format capability set.
func NewFormatCapabilities(names ...string) FormatCapabilities {
return FormatCapabilities{names: append([]string(nil), names...)}
}
// Names returns a copy suitable for completion candidates.
func (c FormatCapabilities) Names() []string {
return append([]string(nil), c.names...)
}
// Usage returns the canonical help text for a --format flag.
func (c FormatCapabilities) Usage() string {
return "output format: " + strings.Join(c.names, "|")
}
// Supports reports whether name is part of this command's output contract.
func (c FormatCapabilities) Supports(name string) bool {
return slices.Contains(c.names, strings.ToLower(name))
}
// Resolve applies the --json shorthand and validates the selected format.
// An explicit --format always wins over --json.
func (c FormatCapabilities) Resolve(format string, formatExplicit, jsonShorthand bool) (string, error) {
if jsonShorthand && !formatExplicit {
format = "json"
}
format = strings.ToLower(format)
if c.Supports(format) {
return format, nil
}
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"unsupported output format %q; supported formats: %s", format, strings.Join(c.names, ", ")).
WithParam("--format")
}

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"errors"
"reflect"
"testing"
"github.com/larksuite/cli/errs"
)
func TestFormatCapabilitiesResolve(t *testing.T) {
tests := []struct {
name string
format string
formatExplicit bool
jsonShorthand bool
want string
wantErr bool
}{
{name: "default", format: "json", want: "json"},
{name: "json shorthand", format: "table", jsonShorthand: true, want: "json"},
{name: "explicit format wins", format: "table", formatExplicit: true, jsonShorthand: true, want: "table"},
{name: "case normalized", format: "PRETTY", formatExplicit: true, want: "pretty"},
{name: "unsupported", format: "xml", formatExplicit: true, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := StandardFormats.Resolve(tt.format, tt.formatExplicit, tt.jsonShorthand)
if tt.wantErr {
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--format" {
t.Fatalf("Resolve() error = %T %v, want invalid_argument --format validation error", err, err)
}
return
}
if err != nil || got != tt.want {
t.Fatalf("Resolve() = %q, %v; want %q, nil", got, err, tt.want)
}
})
}
}
func TestFormatCapabilitiesDriveHelpAndCompletion(t *testing.T) {
if got, want := StandardFormats.Usage(), "output format: json|pretty|table|ndjson|csv"; got != want {
t.Fatalf("Usage() = %q, want %q", got, want)
}
if got, want := StandardFormats.Names(), []string{"json", "pretty", "table", "ndjson", "csv"}; !reflect.DeepEqual(got, want) {
t.Fatalf("Names() = %v, want %v", got, want)
}
}

View File

@@ -7,6 +7,7 @@ package output
type Envelope struct {
OK bool `json:"ok"`
Identity string `json:"identity,omitempty"`
DryRun bool `json:"dry_run,omitempty"`
Data interface{} `json:"data,omitempty"`
Meta *Meta `json:"meta,omitempty"`
ContentSafetyAlert interface{} `json:"_content_safety_alert,omitempty"`

View File

@@ -9,6 +9,7 @@ import "io"
type SuccessEnvelopeOptions struct {
CommandPath string
Identity string
DryRun bool
JqExpr string
Out io.Writer
ErrOut io.Writer
@@ -41,6 +42,7 @@ func WriteSuccessEnvelope(data interface{}, opts SuccessEnvelopeOptions) error {
env := Envelope{
OK: true,
Identity: opts.Identity,
DryRun: opts.DryRun,
Data: data,
Notice: GetNotice(),
}

View File

@@ -104,6 +104,47 @@ func TestWriteSuccessEnvelope_JqUsesEnvelope(t *testing.T) {
}
}
func TestWriteSuccessEnvelope_DryRunMarker(t *testing.T) {
var out strings.Builder
err := WriteSuccessEnvelope(map[string]interface{}{"api": []interface{}{}}, SuccessEnvelopeOptions{
Identity: "bot",
DryRun: true,
Out: &out,
})
if err != nil {
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
}
var env map[string]interface{}
if err := json.Unmarshal([]byte(out.String()), &env); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, out.String())
}
if env["ok"] != true || env["identity"] != "bot" || env["dry_run"] != true {
t.Fatalf("unexpected dry-run envelope: %#v", env)
}
if _, ok := env["data"].(map[string]interface{}); !ok {
t.Fatalf("data = %#v, want object", env["data"])
}
}
func TestWriteSuccessEnvelope_DryRunJqUsesEnvelope(t *testing.T) {
var out strings.Builder
err := WriteSuccessEnvelope(map[string]interface{}{"api": []interface{}{}}, SuccessEnvelopeOptions{
Identity: "bot",
DryRun: true,
JqExpr: ".dry_run",
Out: &out,
})
if err != nil {
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
}
if strings.TrimSpace(out.String()) != "true" {
t.Fatalf("jq output = %q, want true", out.String())
}
}
func TestWriteSuccessEnvelope_JqWarnsWhenSafetyAlertFiltered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
extcs.Register(&mockProvider{

View File

@@ -103,6 +103,9 @@ func ExtractItems(data interface{}) []interface{} {
func FormatValue(w io.Writer, data interface{}, format Format) {
data = toGeneric(data)
switch format {
case FormatPretty:
PrintJson(w, data)
case FormatNDJSON:
items := ExtractItems(data)
if items != nil {
@@ -149,6 +152,9 @@ func NewPaginatedFormatter(w io.Writer, format Format) *PaginatedFormatter {
// FormatPage formats one page of items.
func (pf *PaginatedFormatter) FormatPage(data interface{}) {
switch pf.Format {
case FormatPretty:
PrintJson(pf.W, data)
case FormatJSON, FormatNDJSON:
if arr, ok := data.([]interface{}); ok {
PrintNdjson(pf.W, arr)

View File

@@ -73,6 +73,30 @@ func TestFormatValue_Table(t *testing.T) {
}
}
func TestFormatValue_Pretty(t *testing.T) {
data := map[string]interface{}{
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"name": "Alice"},
},
},
}
var buf bytes.Buffer
FormatValue(&buf, data, FormatPretty)
out := buf.String()
if !json.Valid([]byte(out)) {
t.Fatalf("pretty output should be valid JSON, got:\n%s", out)
}
if !strings.Contains(out, "\n \"data\": {") || !strings.Contains(out, `"name": "Alice"`) {
t.Fatalf("pretty output should be indented JSON, got:\n%s", out)
}
if strings.Contains(out, "─") {
t.Fatalf("pretty output should not render a table, got:\n%s", out)
}
}
func TestFormatValue_CSV(t *testing.T) {
data := map[string]interface{}{
"data": map[string]interface{}{
@@ -149,6 +173,20 @@ func TestPaginatedFormatter_Table(t *testing.T) {
}
}
func TestPaginatedFormatter_Pretty(t *testing.T) {
var buf bytes.Buffer
pf := NewPaginatedFormatter(&buf, FormatPretty)
pf.FormatPage([]interface{}{map[string]interface{}{"name": "Alice"}})
out := buf.String()
if !json.Valid([]byte(out)) {
t.Fatalf("paginated pretty output should be valid JSON, got:\n%s", out)
}
if !strings.Contains(out, "\n {") || !strings.Contains(out, `"name": "Alice"`) {
t.Fatalf("paginated pretty output should be indented JSON, got:\n%s", out)
}
}
func TestPaginatedFormatter_CSV(t *testing.T) {
var buf bytes.Buffer
pf := NewPaginatedFormatter(&buf, FormatCSV)

View File

@@ -10,6 +10,7 @@ type Format int
const (
FormatJSON Format = iota
FormatPretty
FormatNDJSON
FormatTable
FormatCSV
@@ -22,6 +23,8 @@ func ParseFormat(s string) (Format, bool) {
switch strings.ToLower(s) {
case "json", "":
return FormatJSON, true
case "pretty":
return FormatPretty, true
case "ndjson":
return FormatNDJSON, true
case "table":
@@ -36,6 +39,8 @@ func ParseFormat(s string) (Format, bool) {
// String returns the string representation of a Format.
func (f Format) String() string {
switch f {
case FormatPretty:
return "pretty"
case FormatNDJSON:
return "ndjson"
case FormatTable:

View File

@@ -14,6 +14,9 @@ func TestParseFormat(t *testing.T) {
{"json", FormatJSON, true},
{"JSON", FormatJSON, true},
{"Json", FormatJSON, true},
{"pretty", FormatPretty, true},
{"PRETTY", FormatPretty, true},
{"Pretty", FormatPretty, true},
{"ndjson", FormatNDJSON, true},
{"NDJSON", FormatNDJSON, true},
{"Ndjson", FormatNDJSON, true},
@@ -52,6 +55,7 @@ func TestFormatString(t *testing.T) {
want string
}{
{FormatJSON, "json"},
{FormatPretty, "pretty"},
{FormatNDJSON, "ndjson"},
{FormatTable, "table"},
{FormatCSV, "csv"},

View File

@@ -11,6 +11,7 @@ import (
rootcmd "github.com/larksuite/cli/cmd"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/registry"
@@ -94,7 +95,7 @@ func commandFromCobra(c *cobra.Command, defaultFields map[string][]string) manif
Short: c.Short,
Example: c.Example,
Hidden: c.Hidden,
Runnable: c.Runnable(),
Runnable: c.Runnable() && !cmdpolicy.IsPureGroup(c),
Source: source,
Generated: cmdmeta.Generated(c),
Identities: cmdmeta.Identities(c),

View File

@@ -90,6 +90,20 @@ func TestCollectContainsDocsFetchAndDryRunFlag(t *testing.T) {
}
}
func TestCollectMarksPureNavigationGroupsNonRunnable(t *testing.T) {
got, err := collectCommandIndex(context.Background())
if err != nil {
t.Fatalf("collectCommandIndex() error = %v", err)
}
cmd := findManifestCommand(&got, "approval")
if cmd == nil {
t.Fatalf("approval group not found")
}
if cmd.Runnable {
t.Fatalf("approval is a navigation group and must not be exported as runnable")
}
}
func TestCollectExcludesGeneratedServiceCommands(t *testing.T) {
got, err := collectHandAuthored(context.Background())
if err != nil {

View File

@@ -9,12 +9,15 @@ 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,
@@ -65,6 +68,8 @@ 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,
@@ -370,6 +375,13 @@ 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

@@ -878,16 +878,23 @@ func extractDryRunJSON(raw []byte) (facts.DryRunRequest, int, error) {
var firstErr error
for start >= 0 {
var preview struct {
API []facts.DryRunRequest `json:"api"`
API []facts.DryRunRequest `json:"api"`
Data struct {
API []facts.DryRunRequest `json:"api"`
} `json:"data"`
}
dec := json.NewDecoder(bytes.NewReader(raw[start:]))
if err := dec.Decode(&preview); err == nil {
if len(preview.API) == 0 {
api := preview.API
if len(api) == 0 {
api = preview.Data.API
}
if len(api) == 0 {
if firstErr == nil {
firstErr = errNoDryRunAPI
}
} else {
return preview.API[0], len(preview.API), nil
return api[0], len(api), nil
}
} else if firstErr == nil {
firstErr = err

View File

@@ -33,6 +33,17 @@ func TestExtractDryRunJSONSkipsBanner(t *testing.T) {
}
}
func TestExtractDryRunJSONReadsSuccessEnvelope(t *testing.T) {
raw := `{"ok":true,"dry_run":true,"data":{"api":[{"method":"GET","url":"/open-apis/test"}]}}`
got, apiCallCount, err := extractDryRunJSON([]byte(raw))
if err != nil {
t.Fatalf("extractDryRunJSON() error = %v", err)
}
if got.Method != "GET" || got.URL != "/open-apis/test" || apiCallCount != 1 {
t.Fatalf("got request=%#v apiCallCount=%d, want enveloped GET and count 1", got, apiCallCount)
}
}
func TestExtractDryRunJSONSkipsBannerWithBraces(t *testing.T) {
raw := "banner {not json}\n{\"api\":[{\"method\":\"GET\",\"url\":\"/open-apis/test\"}]}\n"
got, apiCallCount, err := extractDryRunJSON([]byte(raw))

View File

@@ -248,10 +248,18 @@ func TestLoadPlatformAutoApproveSet(t *testing.T) {
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
allowSet := LoadOverrideAutoApproveAllow()
// recommend.allow in scope_overrides.json is intentionally empty:
// no scopes are special-cased into the auto-approve set anymore.
if len(allowSet) != 0 {
t.Errorf("expected empty override allow set, got %d entries", len(allowSet))
// recommend.allow special-cases scopes absent from scope_priorities.json
// (application v7 is not in the platform catalog yet) so interactive
// login's "common scopes" tier still offers them. Only the read scope is
// admitted: write stays out of the recommended tier by design.
if !allowSet["application:app_slash_command:read"] {
t.Error("expected application:app_slash_command:read in override allow set")
}
if allowSet["application:app_slash_command:write"] {
t.Error("write scope must NOT be in the recommended tier")
}
if len(allowSet) != 1 {
t.Errorf("expected exactly 1 override allow entry, got %d", len(allowSet))
}
}

View File

@@ -12,7 +12,9 @@
"vc:meeting.meetingevent:read": 75
},
"recommend": {
"allow": [],
"allow": [
"application:app_slash_command:read"
],
"deny": [
"im:chat",
"im:message.send_as_user"

View File

@@ -3,6 +3,10 @@
"en": { "title": "Approval", "description": "Approval instance, and task management" },
"zh": { "title": "审批", "description": "审批实例、审批任务管理" }
},
"application": {
"en": { "title": "Application", "description": "Open Platform app self-management: slash commands for the currently bound app" },
"zh": { "title": "应用管理", "description": "开放平台应用自管理:当前绑定应用的斜杠指令管理" }
},
"apps": {
"en": { "title": "Apps", "description": "Develop, deploy HTML, web pages and applications" },
"zh": { "title": "应用", "description": "开发、部署 HTML、Web 页面和应用" }

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

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

View File

@@ -0,0 +1,18 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package application provides shortcuts for Open Platform app
// self-management (slash commands of the current bound app).
package application
import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all shortcuts of the application domain.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
SlashCommandList,
SlashCommandCreate,
SlashCommandUpdate,
SlashCommandDelete,
}
}

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
)
// slashCommandBasePath is the raw v7 endpoint (not in meta_data.json / SDK).
const slashCommandBasePath = "/open-apis/application/v7/app_slash_commands"
// clientCacheHint is printed to stderr after every successful write.
const clientCacheHint = "note: changes take ~5 minutes to appear in Feishu clients (client-side cache); the server state is already updated - list reflects it immediately."
// parseDescriptionI18n parses repeated --description-i18n values ("<lang>=<text>",
// split on the FIRST '='). Returns nil for empty input. Duplicate langs rejected.
func parseDescriptionI18n(values []string) (map[string]string, error) {
if len(values) == 0 {
return nil, nil
}
m := make(map[string]string, len(values))
for _, v := range values {
idx := strings.Index(v, "=")
if idx <= 0 || idx == len(v)-1 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid --description-i18n value %q: expected <lang>=<text> (e.g. zh_cn=你好)", v).
WithParam("--description-i18n")
}
lang := strings.TrimSpace(v[:idx])
text := v[idx+1:]
if lang == "" || strings.TrimSpace(text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid --description-i18n value %q: language and text must be non-empty", v).
WithParam("--description-i18n")
}
if _, dup := m[lang]; dup {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"duplicate language %q in --description-i18n", lang).
WithParam("--description-i18n")
}
m[lang] = text
}
return m, nil
}
// validateCommandName rejects empty and slash-prefixed command names.
func validateCommandName(name, flagName string) error {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s must not be empty", flagName).WithParam(flagName)
}
if strings.HasPrefix(trimmed, "/") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s must not start with \"/\" - the slash is implied (use %q)",
flagName, strings.TrimPrefix(trimmed, "/")).WithParam(flagName)
}
return nil
}
// encodeCommandIDPathSegment applies the same normalization and escaping to
// command IDs in dry-run output and real requests.
func encodeCommandIDPathSegment(id string) string {
return validate.EncodePathSegment(strings.TrimSpace(id))
}
// buildSlashCommandBody assembles a create/update request body. Only provided
// fields are included: PATCH is field-level partial (absent top-level fields
// are preserved server-side; a provided i18n map REPLACES the whole map).
// icon sits at the top level, sibling of description (verified live; the
// official create sample nesting icon inside description is a doc bug).
func buildSlashCommandBody(command, description string, i18n map[string]string, iconKey string) map[string]interface{} {
body := map[string]interface{}{}
if command != "" {
body["command"] = command
}
if description != "" || len(i18n) > 0 {
desc := map[string]interface{}{}
if description != "" {
desc["default_value"] = description
}
if len(i18n) > 0 {
desc["i18n"] = i18n
}
body["description"] = desc
}
if iconKey != "" {
body["icon"] = map[string]interface{}{"icon_key": iconKey}
}
return body
}
// isCommandExists reports whether err is the server-side name-collision error
// (code=40000000, message contains "command already exists"; verified live).
func isCommandExists(err error) bool {
p, ok := errs.ProblemOf(err)
if !ok {
return false
}
return p.Code == 40000000 && strings.Contains(p.Message, "command already exists")
}

View File

@@ -0,0 +1,197 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
func TestParseDescriptionI18n_OK(t *testing.T) {
m, err := parseDescriptionI18n([]string{"zh_cn=你好", "en_us=Hello=World"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if m["zh_cn"] != "你好" {
t.Errorf("zh_cn = %q", m["zh_cn"])
}
// 只按首个 = 分割:值内可含 =
if m["en_us"] != "Hello=World" {
t.Errorf("en_us = %q", m["en_us"])
}
}
func TestParseDescriptionI18n_Empty(t *testing.T) {
m, err := parseDescriptionI18n(nil)
if err != nil || m != nil {
t.Fatalf("nil input: m=%v err=%v", m, err)
}
}
func TestParseDescriptionI18n_BadFormat(t *testing.T) {
for _, bad := range []string{"zh_cn", "=text", "zh_cn=", " =x"} {
_, err := parseDescriptionI18n([]string{bad})
if err == nil {
t.Errorf("%q: expected error", bad)
continue
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("%q: expected validation problem, got %v", bad, err)
}
}
}
func TestParseDescriptionI18n_DuplicateLang(t *testing.T) {
_, err := parseDescriptionI18n([]string{"zh_cn=a", "zh_cn=b"})
if err == nil {
t.Fatal("expected duplicate language error")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected validation/invalid_argument, got %v", err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--description-i18n" {
t.Fatalf("expected param --description-i18n, got %#v", validationErr)
}
}
func TestValidateCommandName(t *testing.T) {
if err := validateCommandName("greet", "--command"); err != nil {
t.Fatalf("greet: %v", err)
}
for _, bad := range []string{"", " ", "/greet"} {
if err := validateCommandName(bad, "--command"); err == nil {
t.Errorf("%q: expected error", bad)
}
}
}
func TestBuildSlashCommandBody(t *testing.T) {
body := buildSlashCommandBody("greet", "hi", map[string]string{"zh_cn": "你好"}, "skill_outlined")
if body["command"] != "greet" {
t.Errorf("command = %v", body["command"])
}
desc := body["description"].(map[string]interface{})
if desc["default_value"] != "hi" {
t.Errorf("default_value = %v", desc["default_value"])
}
if desc["i18n"].(map[string]string)["zh_cn"] != "你好" {
t.Errorf("i18n = %v", desc["i18n"])
}
// icon 与 description 顶层平级(实测钉死,文档 create 示例是笔误)
if body["icon"].(map[string]interface{})["icon_key"] != "skill_outlined" {
t.Errorf("icon = %v", body["icon"])
}
// partial不提供的字段不出现PATCH 语义依赖)
partial := buildSlashCommandBody("", "", nil, "skill_outlined")
if _, has := partial["command"]; has {
t.Error("empty command must be omitted")
}
if _, has := partial["description"]; has {
t.Error("empty description must be omitted")
}
}
func TestIsCommandExists(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{
name: "matching code and message",
err: errs.NewAPIError(errs.SubtypeUnknown,
"Invalid Param 'command'. command already exists.").WithCode(40000000),
want: true,
},
{
name: "same message with different code",
err: errs.NewAPIError(errs.SubtypeUnknown,
"Invalid Param 'command'. command already exists.").WithCode(40000031),
},
{
name: "same code with different message",
err: errs.NewAPIError(errs.SubtypeUnknown,
"Invalid Param 'icon_key'. icon_key is invalid.").WithCode(40000000),
},
{name: "nil error"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isCommandExists(tt.err); got != tt.want {
t.Fatalf("isCommandExists() = %v, want %v", got, tt.want)
}
})
}
}
// TestSlashCommandShortcuts_SharedScopesAcrossIdentities locks in the
// reversal of the OAuth-isolation design: all four slash-command shortcuts
// declare identical scopes for the bot and user identities (plain Scopes /
// ConditionalScopes, no per-identity overrides), so a user-identity
// pre-flight sees the same scope set a bot identity would.
func TestSlashCommandShortcuts_SharedScopesAcrossIdentities(t *testing.T) {
cases := []struct {
name string
shortcut common.Shortcut
wantScope string
wantConditional string
hasConditional bool
}{
{
name: "list",
shortcut: SlashCommandList,
wantScope: "application:app_slash_command:read",
},
{
name: "create",
shortcut: SlashCommandCreate,
wantScope: "application:app_slash_command:write",
wantConditional: "application:app_slash_command:read",
hasConditional: true,
},
{
name: "update",
shortcut: SlashCommandUpdate,
wantScope: "application:app_slash_command:write",
wantConditional: "application:app_slash_command:read",
hasConditional: true,
},
{
name: "delete",
shortcut: SlashCommandDelete,
wantScope: "application:app_slash_command:write",
wantConditional: "application:app_slash_command:read",
hasConditional: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
for _, identity := range []string{"user", "bot"} {
declared := tc.shortcut.DeclaredScopesForIdentity(identity)
if !containsStr(declared, tc.wantScope) {
t.Errorf("%s: DeclaredScopesForIdentity(%q) = %v, want to contain %q", tc.name, identity, declared, tc.wantScope)
}
if tc.hasConditional && !containsStr(declared, tc.wantConditional) {
t.Errorf("%s: DeclaredScopesForIdentity(%q) = %v, want to contain conditional %q", tc.name, identity, declared, tc.wantConditional)
}
}
})
}
}
func containsStr(list []string, want string) bool {
for _, v := range list {
if v == want {
return true
}
}
return false
}

View File

@@ -0,0 +1,118 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// SlashCommandCreate registers a new slash command on the current bound app.
var SlashCommandCreate = common.Shortcut{
Service: "application",
Command: "+slash-command-create",
Description: "Register a slash command (/ command) on the current bound Open Platform app; --force converts a name collision into an update (idempotent re-run)",
Risk: "write",
Scopes: []string{"application:app_slash_command:write"},
ConditionalScopes: []string{
"application:app_slash_command:read", // only the --force collision path lists to resolve the id
},
AuthTypes: []string{"bot", "user"},
Flags: []common.Flag{
{Name: "command", Desc: "command name WITHOUT the leading slash (server enforces uniqueness per app; max 100 commands)", Required: true},
{Name: "description", Desc: "default description shown in the client command panel (description.default_value)", Required: true},
{Name: "description-i18n", Type: "string_array", Desc: "localized description, repeatable, format <lang>=<text> (e.g. zh_cn=发送问候); language codes are passed through to the server"},
{Name: "icon-key", Desc: "icon key (server default: skill_outlined; invalid keys are rejected server-side with code 40000031)"},
{Name: "force", Type: "bool", Desc: "on name collision, resolve the existing command by name and update it in place"},
},
Tips: []string{
`lark-cli application +slash-command-create --command greet --description "say hi" --description-i18n zh_cn=问候 --as bot`,
"changes take ~5 minutes to appear in clients (client-side cache); the server updates immediately",
"user identity needs explicit authorization first: lark-cli auth login --scope application:app_slash_command:write",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateCommandName(runtime.Str("command"), "--command"); err != nil {
return err
}
if len(strings.TrimSpace(runtime.Str("description"))) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--description must not be blank").WithParam("--description")
}
if _, err := parseDescriptionI18n(runtime.StrArray("description-i18n")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
if err != nil {
// The CLI validates first; keep this guard for direct DryRun callers.
return common.NewDryRunAPI().Set("error", err.Error())
}
name := strings.TrimSpace(runtime.Str("command"))
body := buildSlashCommandBody(name, runtime.Str("description"), i18n, runtime.Str("icon-key"))
d := common.NewDryRunAPI().
Desc("Create a slash command on the current bound app").
POST(slashCommandBasePath).
Body(body)
if runtime.Bool("force") {
d.Desc("--force: on 'command already exists' (code 40000000), GET list to resolve command_id then PATCH the same body")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
name := strings.TrimSpace(runtime.Str("command"))
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
if err != nil {
return err
}
body := buildSlashCommandBody(name, runtime.Str("description"), i18n, runtime.Str("icon-key"))
data, err := runtime.CallAPITyped("POST", slashCommandBasePath, nil, body)
action := "created"
if err != nil {
if !isCommandExists(err) {
return err
}
if !runtime.Bool("force") {
p, _ := errs.ProblemOf(err)
rewrapped := errs.NewAPIError(errs.SubtypeAlreadyExists, "slash command %q already exists", name).
WithHint("rerun with --force to update it, or use `lark-cli application +slash-command-update --command %q`", name).
WithCause(err)
if p.Code != 0 {
rewrapped = rewrapped.WithCode(p.Code)
}
if p.LogID != "" {
rewrapped = rewrapped.WithLogID(p.LogID)
}
return rewrapped
}
// --force: name collision -> resolve id -> PATCH (idempotent re-run).
id, rerr := resolveCommandID(runtime, name)
if rerr != nil {
return rerr
}
patchBody := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
data, err = runtime.CallAPITyped("PATCH", slashCommandBasePath+"/"+encodeCommandIDPathSegment(id), nil, patchBody)
if err != nil {
return err
}
action = "updated"
}
if data == nil {
data = map[string]interface{}{}
}
data["action"] = action
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
runtime.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "%s /%v (command_id: %v)\n", action, data["command"], data["command_id"])
})
return nil
},
}

View File

@@ -0,0 +1,229 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
func createOKStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/application/v7/app_slash_commands",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": sampleItem("greet", "id-new"),
},
}
}
func createConflictStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/application/v7/app_slash_commands",
Body: map[string]interface{}{
"code": 40000000, "msg": "Invalid Param 'command'. command already exists.",
},
}
}
func patchOKStub(id string) *httpmock.Stub {
return &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/application/v7/app_slash_commands/" + id,
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": sampleItem("greet", id),
},
}
}
func TestSlashCommandCreate_OK(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(createOKStub())
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", "greet", "--description", "hi",
"--description-i18n", "zh_cn=你好", "--description-i18n", "en_us=Hello",
"--icon-key", "skill_outlined", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v\n%s", err, stdout.String())
}
data := got["data"].(map[string]interface{})
if data["action"] != "created" {
t.Fatalf("action = %v", data["action"])
}
if data["command_id"] != "id-new" {
t.Fatalf("command_id = %v", data["command_id"])
}
}
func TestSlashCommandCreate_ValidateRejects(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
cases := [][]string{
{"+slash-command-create", "--command", "/greet", "--description", "hi", "--as", "bot"},
{"+slash-command-create", "--command", "greet", "--description", "hi", "--description-i18n", "bad", "--as", "bot"},
{"+slash-command-create", "--command", "greet", "--description", "hi", "--description-i18n", "zh_cn=a", "--description-i18n", "zh_cn=b", "--as", "bot"},
{"+slash-command-create", "--command", "greet", "--description", " ", "--as", "bot"},
}
for i, args := range cases {
err := mountAndRun(t, SlashCommandCreate, args, f, stdout)
if err == nil {
t.Errorf("case %d: expected validation error", i)
continue
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation {
t.Errorf("case %d: expected validation problem, got %v", i, err)
}
}
}
func TestSlashCommandCreate_ConflictNoForce(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(createConflictStub())
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", "greet", "--description", "hi", "--as", "bot"}, f, stdout)
if err == nil {
t.Fatal("expected conflict error")
}
p, _ := errs.ProblemOf(err)
if p == nil || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeAlreadyExists || p.Code != 40000000 {
t.Fatalf("expected api/already_exists code 40000000, got %#v", p)
}
if !strings.Contains(p.Hint, "--force") || !strings.Contains(p.Hint, "+slash-command-update") {
t.Fatalf("hint must offer --force and update, got %q", p.Hint)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("rewrapped error must be *errs.APIError, got %T", err)
}
if errors.Unwrap(apiErr) == nil {
t.Fatal("rewrapped conflict error must preserve the original cause via WithCause")
}
}
func TestSlashCommandCreate_ForceConvertsToUpdate(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(createConflictStub())
reg.Register(listStub([]interface{}{sampleItem("greet", "id-exist")}))
reg.Register(patchOKStub("id-exist"))
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", "greet", "--description", "hi2", "--force", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data := got["data"].(map[string]interface{})
if data["action"] != "updated" {
t.Fatalf("action = %v (force must convert to update)", data["action"])
}
}
func TestSlashCommandCreate_TrimsCommandBeforeCreateAndForceResolution(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
conflict := createConflictStub()
reg.Register(conflict)
reg.Register(listStub([]interface{}{sampleItem("greet", "id-exist")}))
reg.Register(patchOKStub("id-exist"))
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", " greet ", "--description", "hi", "--force", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(conflict.CapturedBody, &body); err != nil {
t.Fatalf("decode captured create body: %v", err)
}
if body["command"] != "greet" {
t.Fatalf("command = %q, want trimmed value %q", body["command"], "greet")
}
}
func createIconInvalidStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/application/v7/app_slash_commands",
Body: map[string]interface{}{
"code": 40000031, "msg": "Invalid Param 'icon_key'. icon_key is invalid.",
},
}
}
// TestSlashCommandCreate_ForceDoesNotConvertNonConflict guards against --force
// blindly treating ANY POST failure as a name collision: only the
// "command already exists" (40000000) shape may fall through to the
// GET+PATCH idempotent-update path. No PATCH stub is registered here, so if
// the code mistakenly attempted a PATCH, the httpmock registry would fail
// the unexpected request and surface a different (registry) error instead
// of the original icon_key failure asserted below.
func TestSlashCommandCreate_ForceDoesNotConvertNonConflict(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(createIconInvalidStub())
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", "greet", "--description", "hi", "--icon-key", "bogus", "--force", "--as", "bot"}, f, stdout)
if err == nil {
t.Fatal("expected the original icon_key error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryAPI || p.Subtype == errs.SubtypeAlreadyExists || p.Code != 40000031 {
t.Fatalf("expected original API error code 40000031 without collision reclassification, got %#v", p)
}
}
func TestSlashCommandCreate_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
if err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", "greet", "--description", "hi", "--icon-key", "skill_outlined", "--dry-run", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "POST") || !strings.Contains(out, slashCommandBasePath) {
t.Fatalf("dry-run must show POST path: %s", out)
}
// icon 顶层dry-run body 里 icon 不嵌套在 description 内
if !strings.Contains(out, "icon_key") {
t.Fatalf("dry-run must include body: %s", out)
}
}
func TestSlashCommandCreate_ForceHelpHasNoMetavar(t *testing.T) {
parent := &cobra.Command{Use: "application"}
SlashCommandCreate.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
forceFlag := cmd.Flags().Lookup("force")
if forceFlag == nil {
t.Fatal("missing --force flag")
}
placeholder, usage := pflag.UnquoteUsage(forceFlag)
if placeholder != "" {
t.Fatalf("boolean --force must not render a value placeholder, got %q", placeholder)
}
if !strings.Contains(usage, "update it in place") || strings.Contains(usage, "gh ") {
t.Fatalf("unexpected --force help: %q", usage)
}
if help := cmd.Flags().FlagUsages(); !strings.Contains(help, "--force") || !strings.Contains(help, "update it in place") {
t.Fatalf("rendered help missing --force description:\n%s", help)
}
}

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// SlashCommandDelete removes a slash command (irreversible; command_id is not
// reused - recreating the same name yields a NEW id).
var SlashCommandDelete = common.Shortcut{
Service: "application",
Command: "+slash-command-delete",
Description: "Delete a slash command from the current bound app (high-risk: irreversible; recreating the same name yields a new command_id)",
Risk: "high-risk-write",
Scopes: []string{"application:app_slash_command:write"},
ConditionalScopes: []string{
"application:app_slash_command:read", // only the --command by-name path
},
AuthTypes: []string{"bot", "user"},
Flags: []common.Flag{
{Name: "command-id", Desc: "target command_id; mutually exclusive with --command"},
{Name: "command", Desc: "target command name WITHOUT leading slash (resolved via live list, needs read scope); mutually exclusive with --command-id"},
},
Tips: []string{
"lark-cli application +slash-command-delete --command greet --yes --as bot",
"deleted commands may linger in clients for ~5 minutes (client cache)",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
id := strings.TrimSpace(runtime.Str("command-id"))
name := strings.TrimSpace(runtime.Str("command"))
if (id == "") == (name == "") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"provide exactly one of --command-id or --command").WithParam("--command-id")
}
if name != "" {
return validateCommandName(name, "--command")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI().Desc("HIGH-RISK: delete a slash command (irreversible; same-name recreate gets a NEW command_id)")
target := strings.TrimSpace(runtime.Str("command-id"))
if target == "" {
name := strings.TrimSpace(runtime.Str("command"))
d.GET(slashCommandBasePath).
Desc(fmt.Sprintf("resolve command_id by name %q via GET list first", name))
target = "<resolved_command_id>"
} else {
target = encodeCommandIDPathSegment(target)
}
return d.DELETE(slashCommandBasePath + "/" + target)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
id := strings.TrimSpace(runtime.Str("command-id"))
name := strings.TrimSpace(runtime.Str("command"))
if id == "" {
resolved, err := resolveCommandID(runtime, name)
if err != nil {
return err
}
id = resolved
}
if _, err := runtime.CallAPITyped("DELETE", slashCommandBasePath+"/"+encodeCommandIDPathSegment(id), nil, nil); err != nil {
return err
}
out := map[string]interface{}{"action": "deleted", "command_id": id}
if name != "" {
out["command"] = name
}
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
fmt.Fprintln(runtime.IO().ErrOut, "note: recreating the same command name will yield a NEW command_id.")
runtime.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "deleted command_id %s\n", id)
})
return nil
},
}

View File

@@ -0,0 +1,135 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func deleteOKStub(id string) *httpmock.Stub {
return &httpmock.Stub{
Method: "DELETE",
URL: slashCommandBasePath + "/" + id,
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
}
}
func TestSlashCommandDelete_RequiresYes(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
"--command-id", "id1", "--as", "bot"}, f, stdout)
if err == nil {
t.Fatal("expected confirmation_required without --yes")
}
if errs.CategoryOf(err) != errs.CategoryConfirmation {
t.Fatalf("expected confirmation category, got %v (%v)", errs.CategoryOf(err), err)
}
}
func TestSlashCommandDelete_ByIDWithYes(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(deleteOKStub("id1"))
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
"--command-id", "id1", "--yes", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data := got["data"].(map[string]interface{})
// 上游 DELETE 返回空对象CLI 必须补 action/command_id写操作返回资源 ID
if data["action"] != "deleted" || data["command_id"] != "id1" {
t.Fatalf("data = %v", data)
}
}
func TestSlashCommandDelete_ByNameWithYes(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(listStub([]interface{}{sampleItem("greet", "id7")}))
reg.Register(deleteOKStub("id7"))
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
"--command", "greet", "--yes", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data := got["data"].(map[string]interface{})
if data["command"] != "greet" || data["command_id"] != "id7" {
t.Fatalf("data = %v", data)
}
}
func TestSlashCommandDelete_ByNameDryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
"--command", "greet", "--dry-run", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var envlp struct {
Data struct {
Description string `json:"description"`
API []struct {
Desc string `json:"desc"`
Method string `json:"method"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envlp); err != nil {
t.Fatalf("json: %v", err)
}
got := envlp.Data
if !strings.Contains(got.Description, "HIGH-RISK") || strings.Contains(got.Description, "resolve command_id") {
t.Fatalf("top-level description must contain only the risk context: %q", got.Description)
}
if len(got.API) != 2 || got.API[0].Method != "GET" || !strings.Contains(got.API[0].Desc, "resolve command_id") {
t.Fatalf("first call must describe name resolution: %#v", got.API)
}
if got.API[1].Method != "DELETE" || strings.Contains(got.API[1].Desc, "resolve command_id") {
t.Fatalf("second call must be the delete without the resolve description: %#v", got.API)
}
}
func TestSlashCommandDelete_Validate(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
for _, args := range [][]string{
{"+slash-command-delete", "--yes", "--as", "bot"},
{"+slash-command-delete", "--command-id", "id1", "--command", "greet", "--yes", "--as", "bot"},
} {
err := mountAndRun(t, SlashCommandDelete, args, f, stdout)
if err == nil {
t.Errorf("%v: expected validation error", args)
continue
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("%v: expected validation problem, got %v", args, err)
}
}
}
func TestSlashCommandDelete_ByIDEncodesTrimmedPathSegment(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(deleteOKStub("id%2Fwith%20space%3Fx"))
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
"--command-id", " id/with space?x ", "--yes", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/shortcuts/common"
)
// SlashCommandList lists all slash commands of the current bound app.
var SlashCommandList = common.Shortcut{
Service: "application",
Command: "+slash-command-list",
Description: "List all slash commands (/ commands) registered on the currently bound Open Platform app; source of command_id for update/delete",
Risk: "read",
Scopes: []string{"application:app_slash_command:read"},
AuthTypes: []string{"bot", "user"},
Tips: []string{
"lark-cli application +slash-command-list --as bot",
"user identity needs explicit authorization first: lark-cli auth login --scope application:app_slash_command:read",
"the upstream API returns all commands at once (max 100 per app, no pagination)",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("List all slash commands of the current bound app (read-only)").
GET(slashCommandBasePath)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
data, err := runtime.CallAPITyped("GET", slashCommandBasePath, nil, nil)
if err != nil {
return err
}
items, _ := data["items"].([]interface{})
if items == nil {
items = []interface{}{}
}
out := map[string]interface{}{"items": items, "count": len(items)}
runtime.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "%d slash command(s)\n", len(items))
for _, it := range items {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
desc := ""
if d, ok := m["description"].(map[string]interface{}); ok {
desc, _ = d["default_value"].(string)
}
fmt.Fprintf(w, " /%v\t%v\t%s\n", m["command"], m["command_id"], desc)
}
})
return nil
},
}

View File

@@ -0,0 +1,115 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func appTestConfig() *core.CliConfig {
return &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
}
// mountAndRun mounts the shortcut under a parent cobra command and runs it.
// Mirrors shortcuts/contact tests.
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "application"}
s.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
func listStub(items []interface{}) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/application/v7/app_slash_commands",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{"items": items},
},
}
}
func sampleItem(name, id string) map[string]interface{} {
return map[string]interface{}{
"command": name, "command_id": id,
"create_time": "1783318553", "update_time": "1783318553",
"description": map[string]interface{}{"default_value": "desc of " + name},
"icon": map[string]interface{}{"icon_key": "skill_outlined"},
}
}
func TestSlashCommandList_JSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(listStub([]interface{}{sampleItem("greet", "id1"), sampleItem("weather", "id2")}))
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--format", "json", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v\n%s", err, stdout.String())
}
data := got["data"].(map[string]interface{})
items := data["items"].([]interface{})
if len(items) != 2 {
t.Fatalf("items = %d", len(items))
}
if data["count"] != float64(2) {
t.Fatalf("count = %v", data["count"])
}
first := items[0].(map[string]interface{})
for _, k := range []string{"command", "command_id", "description", "icon", "create_time", "update_time"} {
if _, ok := first[k]; !ok {
t.Errorf("missing item key %q", k)
}
}
}
func TestSlashCommandList_Empty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(listStub(nil))
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--format", "json", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data := got["data"].(map[string]interface{})
items, ok := data["items"].([]interface{})
if !ok || len(items) != 0 {
t.Fatalf("empty list must be [] not %v", data["items"])
}
if data["count"] != float64(0) {
t.Fatalf("count = %v", data["count"])
}
}
func TestSlashCommandList_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--dry-run", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/open-apis/application/v7/app_slash_commands") || !strings.Contains(out, "GET") {
t.Fatalf("dry-run must show GET path, got %s", out)
}
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// matchCommandID finds the command_id of the item whose "command" equals
// name (exact match - the server enforces name uniqueness, so first hit is the
// only hit).
func matchCommandID(items []interface{}, name string) string {
for _, it := range items {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
if m["command"] == name {
id, _ := m["command_id"].(string)
if id != "" {
return id
}
}
}
return ""
}
// commandNotFoundError reports a resolution miss against the live list as an
// API-category not-found error (the name is a valid argument shape; the
// resource simply does not exist server-side - this is not a validation
// failure of caller input).
func commandNotFoundError(name string) error {
return errs.NewAPIError(errs.SubtypeNotFound,
"slash command %q not found in the current bound app", name).
WithHint("run `lark-cli application +slash-command-list` to see registered commands")
}
// resolveCommandID resolves a command name to its command_id via the live
// list endpoint (in-memory only; never touches local files). Requires the
// read scope on the current identity.
func resolveCommandID(runtime *common.RuntimeContext, name string) (string, error) {
data, err := runtime.CallAPITyped("GET", slashCommandBasePath, nil, nil)
if err != nil {
return "", err
}
items, _ := data["items"].([]interface{})
id := matchCommandID(items, name)
if id == "" {
return "", commandNotFoundError(name)
}
return id, nil
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"testing"
"github.com/larksuite/cli/errs"
)
func TestMatchCommandID(t *testing.T) {
items := []interface{}{
sampleItem("greet", "id1"),
sampleItem("weather", "id2"),
}
id := matchCommandID(items, "weather")
if id != "id2" {
t.Fatalf("got id=%q", id)
}
id = matchCommandID(items, "nope")
if id != "" {
t.Fatalf("miss should return empty, got id=%q", id)
}
// 精确匹配:大小写与空白不做宽容
id = matchCommandID(items, "Greet")
if id != "" {
t.Fatalf("match must be exact, got %q", id)
}
}
func TestResolveNotFoundErrorShape(t *testing.T) {
err := commandNotFoundError("nope")
if err == nil {
t.Fatalf("err = %v", err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeNotFound {
t.Fatalf("expected api/not_found, got %#v", p)
}
}

View File

@@ -0,0 +1,124 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// validateUpdateTarget enforces: exactly one of --command-id/--command, and at
// least one editable field; --description-i18n requires --description (PATCH
// replaces the whole description object - sending i18n alone would drop
// default_value, so both values must be provided together).
func validateUpdateTarget(runtime *common.RuntimeContext) error {
id := strings.TrimSpace(runtime.Str("command-id"))
name := strings.TrimSpace(runtime.Str("command"))
if (id == "") == (name == "") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"provide exactly one of --command-id or --command").WithParam("--command-id")
}
if name != "" {
if err := validateCommandName(name, "--command"); err != nil {
return err
}
}
hasDesc := strings.TrimSpace(runtime.Str("description")) != ""
hasI18n := len(runtime.StrArray("description-i18n")) > 0
hasIcon := strings.TrimSpace(runtime.Str("icon-key")) != ""
if !hasDesc && !hasI18n && !hasIcon {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"provide at least one of --description / --description-i18n / --icon-key").WithParam("--description")
}
if hasI18n && !hasDesc {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--description-i18n requires --description: PATCH replaces the whole description object, so default_value must be provided together").WithParam("--description-i18n")
}
if _, err := parseDescriptionI18n(runtime.StrArray("description-i18n")); err != nil {
return err
}
return nil
}
// SlashCommandUpdate updates description/i18n/icon of an existing slash command.
var SlashCommandUpdate = common.Shortcut{
Service: "application",
Command: "+slash-command-update",
Description: "Update description / localized descriptions / icon of a slash command on the current bound app, addressed by --command-id or by name via --command",
Risk: "write",
Scopes: []string{"application:app_slash_command:write"},
ConditionalScopes: []string{
"application:app_slash_command:read", // only the --command by-name path lists to resolve the id
},
AuthTypes: []string{"bot", "user"},
Flags: []common.Flag{
{Name: "command-id", Desc: "target command_id (from +slash-command-list or create output); mutually exclusive with --command"},
{Name: "command", Desc: "target command name WITHOUT leading slash; resolved via live list (needs read scope); mutually exclusive with --command-id"},
{Name: "description", Desc: "new default description (description.default_value)"},
{Name: "description-i18n", Type: "string_array", Desc: "localized description, repeatable <lang>=<text>; REPLACES the whole i18n map (missing languages are dropped); requires --description"},
{Name: "icon-key", Desc: "new icon key (invalid keys rejected server-side with code 40000031)"},
},
Tips: []string{
`lark-cli application +slash-command-update --command greet --description "new text" --as bot`,
"PATCH is field-level partial: fields you do not pass are preserved server-side",
"the command NAME itself cannot be changed (API limitation): rename = delete + create (new command_id)",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateUpdateTarget(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
if err != nil {
// The CLI validates first; keep this guard for direct DryRun callers.
return common.NewDryRunAPI().Set("error", err.Error())
}
body := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
d := common.NewDryRunAPI()
target := strings.TrimSpace(runtime.Str("command-id"))
if target == "" {
name := strings.TrimSpace(runtime.Str("command"))
d.GET(slashCommandBasePath).
Desc(fmt.Sprintf("resolve command_id by name %q via GET list first", name))
target = "<resolved_command_id>"
} else {
target = encodeCommandIDPathSegment(target)
}
return d.PATCH(slashCommandBasePath + "/" + target).
Desc("Update a slash command by command_id").
Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
id := strings.TrimSpace(runtime.Str("command-id"))
if id == "" {
resolved, err := resolveCommandID(runtime, strings.TrimSpace(runtime.Str("command")))
if err != nil {
return err
}
id = resolved
}
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
if err != nil {
return err
}
body := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
data, err := runtime.CallAPITyped("PATCH", slashCommandBasePath+"/"+encodeCommandIDPathSegment(id), nil, body)
if err != nil {
return err
}
if data == nil {
data = map[string]interface{}{}
}
data["action"] = "updated"
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
runtime.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "updated /%v (command_id: %v)\n", data["command"], data["command_id"])
})
return nil
},
}

View File

@@ -0,0 +1,150 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
)
func TestSlashCommandUpdate_ByID(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(patchOKStub("id1"))
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
"--command-id", "id1", "--description", "new", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data := got["data"].(map[string]interface{})
if data["action"] != "updated" {
t.Fatalf("action = %v", data["action"])
}
}
func TestSlashCommandUpdate_ByName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(listStub([]interface{}{sampleItem("greet", "id9")}))
reg.Register(patchOKStub("id9"))
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
"--command", "greet", "--icon-key", "skill_outlined", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
}
func TestSlashCommandUpdate_ByNameNotFound(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(listStub(nil))
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
"--command", "nope", "--description", "x", "--as", "bot"}, f, stdout)
if err == nil {
t.Fatal("expected not-found error")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeNotFound {
t.Fatalf("expected api/not_found, got %#v", p)
}
}
func TestSlashCommandUpdate_Validate(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
cases := []struct {
name string
args []string
}{
{"both id and name", []string{"+slash-command-update", "--command-id", "id1", "--command", "greet", "--description", "x", "--as", "bot"}},
{"neither id nor name", []string{"+slash-command-update", "--description", "x", "--as", "bot"}},
{"no editable field", []string{"+slash-command-update", "--command-id", "id1", "--as", "bot"}},
{"i18n without description", []string{"+slash-command-update", "--command-id", "id1", "--description-i18n", "zh_cn=x", "--as", "bot"}},
}
for _, c := range cases {
err := mountAndRun(t, SlashCommandUpdate, c.args, f, stdout)
if err == nil {
t.Errorf("%s: expected validation error", c.name)
continue
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("%s: expected validation problem, got %v", c.name, err)
}
}
}
func TestSlashCommandUpdate_ByIDEncodesTrimmedPathSegment(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(patchOKStub("id%2Fwith%20space%3Fx"))
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
"--command-id", " id/with space?x ", "--description", "new", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
}
func TestSlashCommandUpdate_ByNameDryRunDescriptions(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
"--command", " greet ", "--description", "new", "--dry-run", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var envlp struct {
Data struct {
Description string `json:"description"`
API []struct {
Desc string `json:"desc"`
Method string `json:"method"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envlp); err != nil {
t.Fatalf("json: %v", err)
}
got := envlp.Data
if strings.Contains(got.Description, "resolve command_id") {
t.Fatalf("resolve description must be attached to GET, not top-level: %q", got.Description)
}
if len(got.API) != 2 || got.API[0].Method != "GET" || !strings.Contains(got.API[0].Desc, "resolve command_id") {
t.Fatalf("first call must describe name resolution: %#v", got.API)
}
if got.API[1].Method != "PATCH" || !strings.Contains(got.API[1].Desc, "Update a slash command") {
t.Fatalf("second call must describe update: %#v", got.API)
}
}
func TestSlashCommandUpdate_ByIDDryRunEncodesTrimmedPathSegment(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
"--command-id", " id/with space?x ", "--description", "new", "--dry-run", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var envlp struct {
Data struct {
API []struct {
Desc string `json:"desc"`
URL string `json:"url"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envlp); err != nil {
t.Fatalf("json: %v", err)
}
got := envlp.Data
wantURL := slashCommandBasePath + "/id%2Fwith%20space%3Fx"
if len(got.API) != 1 || got.API[0].URL != wantURL || got.API[0].Desc == "" {
t.Fatalf("dry-run call = %#v, want encoded URL %q with description", got.API, wantURL)
}
}

View File

@@ -23,19 +23,21 @@ func TestAppsAnalyticsList_DryRunUsesNanoseconds(t *testing.T) {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
Data struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/query_analytics_data" {
t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL)
if env.Data.API[0].Method != "POST" || env.Data.API[0].URL != "/open-apis/spark/v1/apps/app_x/query_analytics_data" {
t.Fatalf("method/url = %s %s", env.Data.API[0].Method, env.Data.API[0].URL)
}
body := env.API[0].Body
body := env.Data.API[0].Body
if _, ok := body["start_timestamp_ns"]; !ok {
t.Fatalf("analytics dry-run missing start_timestamp_ns: %#v", body)
}
@@ -92,14 +94,16 @@ func TestAppsAnalyticsList_PageViewDesktopSeriesSetsDeviceFilter(t *testing.T) {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
Data struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
filter := env.API[0].Body["filter"].(map[string]interface{})
filter := env.Data.API[0].Body["filter"].(map[string]interface{})
deviceTypes := filter["device_types"].([]interface{})
if len(deviceTypes) != 1 || deviceTypes[0] != "desktop" {
t.Fatalf("device_types = %#v", deviceTypes)

View File

@@ -9,6 +9,7 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -61,6 +62,7 @@ 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"),
@@ -71,5 +73,8 @@ 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,3 +273,93 @@ 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

@@ -101,14 +101,16 @@ func TestAppsDBAuditEnable_DryRunAndSuccess(t *testing.T) {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
Data struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
} `json:"data"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
a := env.Data.API[0]
if a.Method != "POST" || a.URL != dbAuditSetURL || a.Body["enabled"] != true || a.Body["retention"] != "30d" || a.Body["table"] != "orders" {
t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body)
}
@@ -136,13 +138,15 @@ func TestAppsDBAuditDisable_DryRunAndSuccess(t *testing.T) {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
Data struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
} `json:"data"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
if env.API[0].Body["enabled"] != false || env.API[0].Body["table"] != "orders" {
t.Fatalf("dry-run body=%v (want enabled:false)", env.API[0].Body)
if env.Data.API[0].Body["enabled"] != false || env.Data.API[0].Body["table"] != "orders" {
t.Fatalf("dry-run body=%v (want enabled:false)", env.Data.API[0].Body)
}
factory2, stdout2, reg := newAppsExecuteFactory(t)
@@ -178,14 +182,16 @@ func TestAppsDBAuditList_DryRunJoinsTables(t *testing.T) {
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"`
Data struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
} `json:"data"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
a := env.Data.API[0]
if a.Method != "GET" || a.URL != dbAuditListURL || a.Params["tables"] != "orders,users" {
t.Fatalf("dry-run = %s %s tables=%v", a.Method, a.URL, a.Params["tables"])
}

View File

@@ -37,13 +37,7 @@ func TestAppsDBChangelogList_DryRunFiltersAndTimeNormalize(t *testing.T) {
"--change-id", "01J", "--since", "2026-01-01", "--page-size", "5", "--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"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "GET" || a.URL != dbChangelogURL {

View File

@@ -71,13 +71,7 @@ func TestAppsDBDataExport_DryRunFormatFromOutput(t *testing.T) {
if err := runAppsShortcut(t, AppsDBDataExport, args, 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"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "GET" || a.URL != dbDataExportURL {

View File

@@ -97,14 +97,7 @@ func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) {
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--environment", "dev", "--dry-run", "--yes", "--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"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "POST" || a.URL != dbDataImportURL {
@@ -131,12 +124,11 @@ func TestAppsDBDataImport_DryRunOmitsEnvWhenUnset(t *testing.T) {
[]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"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
if len(env.API) != 1 {
t.Fatalf("dry-run API calls = %d, want 1; stdout=%s", len(env.API), stdout.String())
}
p := env.API[0].Params
if _, ok := p["env"]; ok {
t.Fatalf("no --environment → env key must be omitted, got params=%v", p)
@@ -174,11 +166,7 @@ func TestAppsDBDataImport_TableDefaultsToFileBasename(t *testing.T) {
[]string{"+db-data-import", "--app-id", "app_x", "--file", "customers.json", "--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"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
if env.API[0].Params["table"] != "customers" {
t.Fatalf("expected table=customers (from file basename) in params, got %v", env.API[0].Params)

View File

@@ -30,13 +30,7 @@ func TestAppsDBEnvDiff_DryRunBody(t *testing.T) {
[]string{"+db-env-diff", "--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"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "POST" || a.URL != dbEnvMigrateURL || a.Body["dry_run"] != true {
@@ -91,11 +85,7 @@ func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) {
[]string{"+db-env-migrate", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
if env.API[0].Body["dry_run"] != false {
t.Fatalf("dry-run body=%v (want dry_run:false)", env.API[0].Body)
@@ -180,13 +170,7 @@ func TestAppsDBRecoveryDiff_DryRunNormalizesTarget(t *testing.T) {
[]string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2026-04-15", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "POST" || a.URL != dbRecoveryURL || a.Body["dry_run"] != true {
@@ -331,14 +315,11 @@ func TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset(t *testing.T) {
[]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"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
if len(env.API) != 1 {
t.Fatalf("dry-run API calls = %d, want 1; stdout=%s", len(env.API), stdout.String())
}
a := env.API[0]
if a.Method != "GET" || a.URL != dbQuotaURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)

View File

@@ -165,14 +165,7 @@ func TestAppsDBExecute_DryRunSendsTransactionalFalse(t *testing.T) {
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
var env dryRunAPIEnvelope
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
t.Fatalf("decode: %v\n%s", err, stdout.String())
}
@@ -254,11 +247,7 @@ func TestAppsDBExecute_FileReadsSQLIntoBody(t *testing.T) {
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
t.Fatalf("decode: %v\n%s", err, stdout.String())
}

View File

@@ -79,11 +79,7 @@ func TestAppsDBTableGet_NonPrettyFormatsOmitFormatQuery(t *testing.T) {
if err := runAppsShortcut(t, AppsDBTableGet, args, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
var env dryRunAPIEnvelope
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
t.Fatalf("decode: %v", err)
}

View File

@@ -165,13 +165,7 @@ func TestAppsDBTableList_DryRunSendsPaginationAndEnv(t *testing.T) {
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"`
}
var env dryRunAPIEnvelope
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
@@ -196,11 +190,7 @@ func TestAppsDBTableList_DoesNotSendIncludeStatsQuery(t *testing.T) {
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
var env dryRunAPIEnvelope
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
t.Fatalf("decode: %v", err)
}

View File

@@ -149,11 +149,7 @@ func TestAppsEnvVarList_DryRunIncludesScene(t *testing.T) {
}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var dryRun struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var dryRun dryRunAPIEnvelope
if err := json.Unmarshal(stdout.Bytes(), &dryRun); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
@@ -228,11 +224,7 @@ func TestAppsEnvVarSet_OnlineDryRunDoesNotRequireYes(t *testing.T) {
t.Fatalf("dry-run missing %q: %s", want, got)
}
}
var dryRun struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var dryRun dryRunAPIEnvelope
if err := json.Unmarshal([]byte(got), &dryRun); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, got)
}
@@ -353,13 +345,7 @@ func TestAppsEnvVarDelete_OnlineDryRunDoesNotRequireYes(t *testing.T) {
t.Fatalf("dry-run err=%v", err)
}
var dryRun struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var dryRun dryRunAPIEnvelope
got := stdout.String()
if err := json.Unmarshal([]byte(got), &dryRun); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, got)

View File

@@ -44,8 +44,9 @@ func appsExternalToolError(err error, format string, args ...any) *errs.Internal
return errs.NewInternalError(errs.SubtypeExternalTool, format, args...).WithCause(err)
}
// appsSubprocessEnvelopeError classifies a malformed or failed envelope from a
// lark-cli subprocess (+git-credential-init / +env-pull) as internal/invalid_response.
// 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).
func appsSubprocessEnvelopeError(format string, args ...any) *errs.InternalError {
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...)
}

View File

@@ -48,13 +48,7 @@ func TestAppsFileDelete_DryRunSendsPaths(t *testing.T) {
[]string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/b.png", "--yes", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "POST" || a.URL != fileDeleteURL {

View File

@@ -41,12 +41,7 @@ func TestAppsFileDownload_DryRunSignsFirst(t *testing.T) {
[]string{"+file-download", "--app-id", "app_x", "--path", "/x.png", "--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"`
} `json:"api"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
if env.API[0].Method != "POST" || env.API[0].URL != fileSignURLForDownload {
t.Fatalf("dry-run = %s %s (want POST sign)", env.API[0].Method, env.API[0].URL)

View File

@@ -46,13 +46,7 @@ func TestAppsFileGet_DryRunSendsPathQuery(t *testing.T) {
[]string{"+file-get", "--app-id", "app_x", "--path", "/x.png", "--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"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
if env.API[0].Method != "GET" || env.API[0].URL != fileGetURL || env.API[0].Params["path"] != "/x.png" {
t.Fatalf("dry-run = %s %s params=%v", env.API[0].Method, env.API[0].URL, env.API[0].Params)

View File

@@ -95,13 +95,7 @@ func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) {
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"`
}
var env dryRunAPIEnvelope
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
@@ -139,11 +133,7 @@ func TestAppsFileList_DryRunOmitsEmptyFilters(t *testing.T) {
[]string{"+file-list", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
for _, banned := range []string{"name", "path", "type", "size_gt", "size_lt", "uploaded_since", "uploaded_until", "page_token"} {
if _, ok := env.API[0].Params[banned]; ok {

View File

@@ -22,13 +22,7 @@ func TestAppsFileSign_DryRunBody(t *testing.T) {
[]string{"+file-sign", "--app-id", "app_x", "--path", "/x.png", "--expires-in", "3600", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "POST" || a.URL != fileSignURL || a.Body["path"] != "/x.png" {

View File

@@ -76,13 +76,7 @@ func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "POST" || a.URL != "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload" {

View File

@@ -0,0 +1,67 @@
// 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

@@ -0,0 +1,109 @@
// 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,29 +4,32 @@
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 uploads + publishes via one multipart POST.
// AppsHTMLPublish packs --path as tar.gz and publishes an HTML app.
var AppsHTMLPublish = common.Shortcut{
Service: appsService,
Command: "+html-publish",
Description: "Publish HTML to an app (single multipart POST returns the access URL)",
Description: "Publish HTML to an app (returns url or release_id depending on app type)",
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"},
Scopes: []string{"spark:app:write", "spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
@@ -70,7 +73,7 @@ var AppsHTMLPublish = common.Shortcut{
appID := strings.TrimSpace(rctx.Str("app-id"))
path := strings.TrimSpace(rctx.Str("path"))
dry := common.NewDryRunAPI()
dry.Desc("Upload tar.gz + publish HTML (multipart, returns url)")
dry.Desc("Pack tar.gz and publish HTML app (actual API path determined at runtime by app type; returns url or release_id)")
dry.POST(fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID))).
Set("content_type", "multipart/form-data")
@@ -119,8 +122,17 @@ var AppsHTMLPublish = common.Shortcut{
AppID: strings.TrimSpace(rctx.Str("app-id")),
Path: strings.TrimSpace(rctx.Str("path")),
}
client := appsHTMLPublishAPI{runtime: rctx}
out, err := runHTMLPublish(ctx, rctx.FileIO(), client, spec)
appType := queryAppType(ctx, rctx, spec.AppID)
var out map[string]interface{}
var err error
if appType == "modern_html" {
out, err = runHTMLPublishTOS(ctx, rctx, spec)
} else {
client := appsHTMLPublishAPI{runtime: rctx}
out, err = runHTMLPublish(ctx, rctx.FileIO(), client, spec)
}
if err != nil {
return err
}
@@ -128,6 +140,9 @@ 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
},
@@ -214,8 +229,11 @@ 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")
}
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
candidates, err := walkHTMLPublishCandidates(fio, spec.Path)
// 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)
if err != nil {
return nil, err
}
@@ -238,12 +256,19 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPu
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 {
@@ -256,3 +281,74 @@ 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,10 +6,21 @@ 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 {
@@ -53,7 +64,7 @@ func TestRunHTMLPublish_HappyPath(t *testing.T) {
}
func TestRunHTMLPublish_OnlyURLInEnvelope(t *testing.T) {
// Pin 概要设计 §5.3 不变量 4 "同步语义不会变成异步":
// Pin 概要设计 §5.3 不变量 4 "同步语义不会变成异步" (legacy html path only):
// envelope 只含 url未来若有人加 status / release_id 字段会被这个测试拦截。
site := writeAppsSampleSite(t)
fake := &fakeAppsHTMLPublishClient{
@@ -582,3 +593,226 @@ 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,6 +11,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"unicode"
@@ -39,16 +40,57 @@ const (
const (
miaodaCLIPkg = "@lark-apaas/miaoda-cli@latest"
defaultTemplate = "nestjs-react-fullstack"
npmRegistry = "https://registry.npmmirror.com"
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,
@@ -59,13 +101,15 @@ 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 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,
// +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,
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
@@ -75,24 +119,28 @@ 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: "template", Desc: "code-init template for an empty repo; optional — if omitted, derived from the app's tech stack"},
{Name: "source-path", Desc: "path to existing source files (e.g. HTML output from an agent) to incorporate into the initialized project"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
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 --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("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("commit_push", "conditional: git add -A + commit + push origin "+defaultInitBranch+" when the working tree has changes").
Set("template", template).
Set("template", "derived from queryAppType (fallback: full_stack)").
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 {
@@ -122,20 +170,6 @@ 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,
@@ -294,6 +328,34 @@ 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.
@@ -326,34 +388,54 @@ 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, template string) (string, error) {
func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (string, error) {
empty, err := isEmptyRepo(ctx, dir)
if err != nil {
return "", err
}
if empty {
// isEmptyRepo treats a repo with no tracked files — or only the backend's
// seed README.md — as empty. If other seed files (e.g. .gitignore) can
// appear, extend isEmptyRepo's allow-list accordingly.
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", template, "--app-id", appID); err != nil {
args := scaffoldInitArgs(appType, appID, sourcePath)
if _, stderr, err := initRunner.Run(ctx, dir, "npx", args...); 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", miaodaCLIPkg, "app", "sync"); err != nil {
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "sync"); err != nil {
return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err))
}
if err := ensureMetaAppID(dir, appID); err != nil {
return "", err
}
if !hasSteeringSkills(dir) {
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
if !policyForAppType(appType).skipSkillsSync && !hasSteeringSkills(dir) {
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
return "", appsExternalToolError(err, "npx skills sync failed: %s", gitErr(stderr, err))
}
}
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`.
@@ -445,6 +527,9 @@ 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.
@@ -457,6 +542,19 @@ 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 == ""
@@ -514,8 +612,21 @@ 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)...")
scaffold, err := runScaffold(ctx, dir, appID, resolveTemplate(rctx, appID))
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)
if err != nil {
return err
}
@@ -530,15 +641,6 @@ 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),
@@ -547,21 +649,38 @@ 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 envPulled {
out["env_file"] = envFile
} else {
out["env_pull_error"] = envPullErr
out["message"] = fmt.Sprintf("Repository initialized. Could not pull local env vars automatically — run `lark-cli apps +env-pull --app-id %s` to retry.", appID)
if appType != "" {
out["app_type"] = appType
}
if policy.skipEnvPull {
out["env_pulled"] = false
out["env_pull_skipped"] = true
} 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)
}
}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ Repository initialized at %s\n", dir)
fmt.Fprintf(w, " branch: %s\n scaffold: %s\n", defaultInitBranch, scaffold)
if envPulled {
fmt.Fprintf(w, "✓ Local environment written to %s\n", envFile)
} else {
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 {
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)
}

View File

@@ -20,46 +20,20 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// testRuntimeWithDir builds a *common.RuntimeContext whose backing cobra command
// has string flags "dir" (=dirFlag) and "template" (=defaultTemplate) registered,
// mirroring how +init reads them at runtime via rctx.Str.
// has a string flag "dir" (=dirFlag) registered, mirroring how +init reads it
// at runtime via rctx.Str.
func testRuntimeWithDir(t *testing.T, dirFlag string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "init"}
cmd.Flags().String("dir", dirFlag, "")
cmd.Flags().String("template", defaultTemplate, "")
return common.TestNewRuntimeContext(cmd, nil)
}
// testRuntimeWithTemplate builds a *common.RuntimeContext with "dir" and
// "template" string flags registered, mirroring +init's runtime flag set. The
// template flag is registered with an empty default (matching the real flag,
// which no longer carries Default: defaultTemplate); pass tpl="" to model an
// omitted --template and a non-empty tpl to model an explicit one.
func testRuntimeWithTemplate(t *testing.T, dirFlag, tpl string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "init"}
cmd.Flags().String("dir", dirFlag, "")
cmd.Flags().String("template", tpl, "")
return common.TestNewRuntimeContext(cmd, nil)
}
func TestResolveTemplate(t *testing.T) {
if got := resolveTemplate(testRuntimeWithTemplate(t, "", "foo"), "app_x"); got != "foo" {
t.Errorf("explicit --template = %q, want foo", got)
}
if got := resolveTemplate(testRuntimeWithTemplate(t, "", ""), "app_x"); got != defaultTemplate {
t.Errorf("omitted --template = %q, want fallback %q", got, defaultTemplate)
}
// Whitespace-only --template is treated as omitted -> fallback.
if got := resolveTemplate(testRuntimeWithTemplate(t, "", " "), "app_x"); got != defaultTemplate {
t.Errorf("whitespace --template = %q, want fallback %q", got, defaultTemplate)
}
}
func TestResolveTargetPath(t *testing.T) {
got, err := resolveTargetPath(testRuntimeWithDir(t, ""), "app_x")
if err != nil {
@@ -261,12 +235,12 @@ func TestRunScaffold_EmptyRepo(t *testing.T) {
t.Run("ls="+ls, func(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ls}}}
withFakeRunner(t, f)
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack")
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", "")
if err != nil || kind != "init" {
t.Fatalf("ls=%q kind=%q err=%v, want init", ls, kind, err)
}
c := findCall(f.calls, "npx", "-y")
if c == nil || !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", "nestjs-react-fullstack", "--app-id", "app_x") {
if c == nil || !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--app-type", "full_stack", "--app-id", "app_x") {
t.Errorf("app init not invoked with expected args: %v", f.calls)
}
if c != nil && containsAll(c, "--local") {
@@ -280,7 +254,7 @@ func TestRunScaffold_NonEmpty_SyncsWhenNoSteering(t *testing.T) {
dir := t.TempDir() // no steering dir, no meta.json
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: "src/x.ts\n"}}}
withFakeRunner(t, f)
kind, err := runScaffold(context.Background(), dir, "app_x", "nestjs-react-fullstack")
kind, err := runScaffold(context.Background(), dir, "app_x", "", "")
if err != nil || kind != "upgrade" {
t.Fatalf("kind=%q err=%v, want upgrade", kind, err)
}
@@ -294,12 +268,24 @@ func TestRunScaffold_NonEmpty_SyncsWhenNoSteering(t *testing.T) {
}
}
func TestRunScaffold_NonEmpty_ModernHTML_SkipsSyncEvenWithoutSteering(t *testing.T) {
dir := t.TempDir() // no steering dir → sync would run for non-modern_html
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: "src/x.ts\n"}}}
withFakeRunner(t, f)
if _, err := runScaffold(context.Background(), dir, "app_x", "modern_html", ""); err != nil {
t.Fatal(err)
}
if findCallArg(f.calls, "npx", "skills", "sync") != nil {
t.Error("skills sync must be skipped for modern_html regardless of steering dir")
}
}
func TestRunScaffold_NonEmpty_SkipsSyncWhenSteeringExists(t *testing.T) {
dir := t.TempDir()
os.MkdirAll(filepath.Join(dir, steeringRelPath), 0o755)
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: "src/x.ts\n"}}}
withFakeRunner(t, f)
if _, err := runScaffold(context.Background(), dir, "app_x", "nestjs-react-fullstack"); err != nil {
if _, err := runScaffold(context.Background(), dir, "app_x", "", ""); err != nil {
t.Fatal(err)
}
if findCallArg(f.calls, "npx", "skills", "sync") != nil {
@@ -313,7 +299,7 @@ func TestRunScaffold_AppInitFailure(t *testing.T) {
"npx -y": {stderr: "boom", err: errors.New("exit 1")},
}}
withFakeRunner(t, f)
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack"); err == nil {
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", ""); err == nil {
t.Error("app init failure must propagate")
}
}
@@ -342,13 +328,13 @@ func TestAppsInit_EmptyRepo_EndToEnd(t *testing.T) {
if _, ok := data["npx_skipped"]; ok {
t.Error("npx_skipped must be removed")
}
// --template is omitted here, so resolveTemplate falls back to
// defaultTemplate and `app init` must still receive --template nestjs-react-fullstack.
// appType is empty, so scaffoldInitArgs falls back to "full_stack"
// and `app init` must still receive --app-type full_stack.
c := findCall(f.calls, "npx", "-y")
if c == nil {
t.Error("npx scaffold not invoked")
} else if !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", defaultTemplate, "--app-id", "app_x") {
t.Errorf("app init missing expected --template fallback args: %v", c)
} else if !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--app-type", "full_stack", "--app-id", "app_x") {
t.Errorf("app init missing expected --app-type fallback args: %v", c)
} else if containsAll(c, "--local") {
t.Errorf("app init must NOT carry --local: %v", c)
}
@@ -751,31 +737,12 @@ func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buf
}
func TestAppsInit_Req1_Wording(t *testing.T) {
var tmpl *common.Flag
for i := range AppsInit.Flags {
if AppsInit.Flags[i].Name == "template" {
tmpl = &AppsInit.Flags[i]
}
}
if tmpl == nil {
t.Fatal("--template flag missing")
}
if strings.Contains(strings.ToLower(tmpl.Desc), "scaffold") {
t.Errorf("--template Desc still mentions scaffold: %q", tmpl.Desc)
}
if !strings.Contains(strings.ToLower(tmpl.Desc), "code-init") {
t.Errorf("--template Desc should use code-init wording: %q", tmpl.Desc)
}
// The --dry-run output is a flat object (DryRunAPI marshals to top-level keys
// description/scaffold/api/...), NOT wrapped in {"data":...}, so parse stdout
// directly rather than via parseEnvelopeData.
factory, stdout, _ := newAppsExecuteFactoryWithStderr(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var data map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
data, err := decodeDryRunDataMap(stdout.Bytes())
if err != nil {
t.Fatalf("decode dry-run output: %v (raw=%q)", err, stdout.String())
}
desc, _ := data["description"].(string)
@@ -787,9 +754,8 @@ func TestAppsInit_Req1_Wording(t *testing.T) {
t.Error("dry-run must keep machine-contract key `scaffold`")
} else if !strings.Contains(scaffold, "skills sync --local") {
t.Errorf("dry-run scaffold string must show --local on skills sync: %q", scaffold)
} else if strings.Contains(scaffold, "app init --template nestjs-react-fullstack --app-id app_x --local") ||
strings.Contains(scaffold, "app sync --local") {
t.Errorf("dry-run scaffold string must NOT show --local on app init / app sync: %q", scaffold)
} else if strings.Contains(scaffold, "app sync --local") {
t.Errorf("dry-run scaffold string must NOT show --local on app sync: %q", scaffold)
}
f := &fakeCommandRunner{results: map[string]fakeCallResult{
@@ -1250,7 +1216,7 @@ func TestRunScaffold_NonEmpty_SyncFailure(t *testing.T) {
"git ls-files": {stdout: "src/x.ts\n"},
"npx -y": {err: errors.New("sync boom")},
}})
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "tpl"); err == nil {
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", ""); err == nil {
t.Error("npx app sync failure must surface as an error")
}
}
@@ -1478,8 +1444,8 @@ func TestAppsInit_DryRun_DescribesEnvPull(t *testing.T) {
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user", "--dry-run"}, factory, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var m map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &m); err != nil {
m, err := decodeDryRunDataMap(stdout.Bytes())
if err != nil {
t.Fatalf("decode dry-run: %v (raw=%q)", err, stdout.String())
}
ep, _ := m["env_pull"].(string)
@@ -1630,7 +1596,7 @@ func TestRunScaffold_SubprocessFailureIsExternalTool(t *testing.T) {
"git ls-files": {stderr: "fatal: not a git repository", err: cause},
}}
withFakeRunner(t, f)
_, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack")
_, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", "")
if err == nil {
t.Fatalf("expected error from failing git subprocess")
}
@@ -1645,3 +1611,368 @@ func TestRunScaffold_SubprocessFailureIsExternalTool(t *testing.T) {
t.Fatalf("cause chain not preserved: %v", err)
}
}
func TestRunScaffold_HtmlPassesTemplate(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
withFakeRunner(t, f)
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "html", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if kind != scaffoldKindInit {
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
}
c := findCall(f.calls, "npx", "-y")
if c == nil {
t.Fatal("npx not called")
}
if !containsAll(c, "--app-type", "html") {
t.Errorf("expected --app-type html in args: %v", c)
}
}
func TestRunScaffold_ModernHtmlPassesTemplate(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
withFakeRunner(t, f)
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "modern_html", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if kind != scaffoldKindInit {
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
}
c := findCall(f.calls, "npx", "-y")
if c == nil {
t.Fatal("npx not called")
}
if !containsAll(c, "--app-type", "modern_html") {
t.Errorf("expected --app-type modern_html in args: %v", c)
}
}
func TestRunScaffold_EmptyAppTypeFallback(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
withFakeRunner(t, f)
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if kind != scaffoldKindInit {
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
}
c := findCall(f.calls, "npx", "-y")
if c == nil {
t.Fatal("npx not called")
}
if !containsAll(c, "--app-type", "full_stack") {
t.Errorf("expected --app-type full_stack in args: %v", c)
}
}
func TestRunScaffold_FullStackPassesTemplate(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
withFakeRunner(t, f)
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "full_stack", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if kind != scaffoldKindInit {
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
}
c := findCall(f.calls, "npx", "-y")
if c == nil {
t.Fatal("npx not called")
}
if !containsAll(c, "--app-type", "full_stack") {
t.Errorf("expected --app-type full_stack in args: %v", c)
}
}
func TestScaffoldInitArgs_WithAppType(t *testing.T) {
args := scaffoldInitArgs("modern_html", "app_x", "")
if !containsAll(args, "--app-type", "modern_html", "--app-id", "app_x") {
t.Errorf("expected --app-type modern_html --app-id app_x, got %v", args)
}
// modern_html skips dependency install.
if !containsAll(args, "--skip-install") {
t.Errorf("expected --skip-install for modern_html, got %v", args)
}
for _, a := range args {
if a == "--source-path" {
t.Errorf("--source-path must not appear when sourcePath is empty: %v", args)
}
}
}
func TestPolicyForAppType(t *testing.T) {
// modern_html decouples all control points: skip install, env-pull, skills sync.
if p := policyForAppType("modern_html"); !p.skipInstall || !p.skipEnvPull || !p.skipSkillsSync {
t.Errorf("modern_html policy = %+v, want all skip flags set", p)
}
// Unlisted types (including "") get the zero-value policy: everything runs.
for _, at := range []string{"full_stack", "", "backend"} {
if p := policyForAppType(at); p.skipInstall || p.skipEnvPull || p.skipSkillsSync {
t.Errorf("policy for %q = %+v, want zero value", at, p)
}
}
}
func TestScaffoldInitArgs_SkipInstallOnlyForModernHTML(t *testing.T) {
// Non-modern_html types run the install step (no --skip-install).
for _, at := range []string{"full_stack", "", "backend"} {
args := scaffoldInitArgs(at, "app_x", "")
for _, a := range args {
if a == "--skip-install" {
t.Errorf("--skip-install must not appear for app-type %q: %v", at, args)
}
}
}
}
func TestScaffoldInitArgs_EmptyFallback(t *testing.T) {
args := scaffoldInitArgs("", "app_x", "")
if !containsAll(args, "--app-type", "full_stack", "--app-id", "app_x") {
t.Errorf("expected --app-type full_stack fallback, got %v", args)
}
}
func TestScaffoldInitArgs_WithSourcePath(t *testing.T) {
args := scaffoldInitArgs("modern_html", "app_x", "/path/to/src")
if !containsAll(args, "--app-type", "modern_html", "--app-id", "app_x", "--source-path", "/path/to/src") {
t.Errorf("expected --source-path /path/to/src, got %v", args)
}
}
// configSetValue finds a `git config <key> <value>` SET call (not a `--get`)
// in the recorded fake calls and returns its value.
func configSetValue(calls [][]string, key string) (string, bool) {
for _, c := range calls {
if len(c) >= 5 && c[1] == "git" && c[2] == "config" && c[3] == key {
return c[4], true
}
}
return "", false
}
func TestEnsureGitIdentity_SetsDefaultsWhenUnset(t *testing.T) {
f := &fakeCommandRunner{} // no "git config" result → `--get` returns empty stdout
withFakeRunner(t, f)
if err := ensureGitIdentity(context.Background(), "/repo"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if v, ok := configSetValue(f.calls, "user.name"); !ok || v != defaultGitUserName {
t.Errorf("user.name set = (%q,%v), want %q", v, ok, defaultGitUserName)
}
if v, ok := configSetValue(f.calls, "user.email"); !ok || v != defaultGitUserEmail {
t.Errorf("user.email set = (%q,%v), want %q", v, ok, defaultGitUserEmail)
}
}
func TestEnsureGitIdentity_RespectsExisting(t *testing.T) {
// `git config --get` returns a value → identity resolvable, nothing is set.
f := &fakeCommandRunner{results: map[string]fakeCallResult{
"git config": {stdout: "Existing Dev\n"},
}}
withFakeRunner(t, f)
if err := ensureGitIdentity(context.Background(), "/repo"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, ok := configSetValue(f.calls, "user.name"); ok {
t.Error("user.name must not be overwritten when already configured")
}
if _, ok := configSetValue(f.calls, "user.email"); ok {
t.Error("user.email must not be overwritten when already configured")
}
}
func TestEnsureGitIdentity_SetFailurePropagates(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{
"git config": {stderr: "boom", err: errors.New("exit 1")},
}}
withFakeRunner(t, f)
if err := ensureGitIdentity(context.Background(), "/repo"); err == nil {
t.Error("expected error when git config set fails")
}
}
func TestAppsInit_WithAppType_FreshClone(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{
"credential-init": credInitOK("http://u:t@h/app_typed.git"),
"git clone": {},
"git checkout": {},
"git ls-files": {stdout: ""},
"git status": {stdout: " A src/app.ts\n"},
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
// Register a meta mock so queryAppType returns "modern_html"
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_typed",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_typed",
"app_type": "MODERN_HTML",
},
},
},
})
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_typed", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := parseEnvelopeData(t, stdout)
if data["app_type"] != "modern_html" {
t.Errorf("app_type = %v, want modern_html", data["app_type"])
}
// Verify the scaffold used --app-type modern_html
c := findCall(f.calls, "npx", "-y")
if c == nil {
t.Fatal("npx not called")
}
if !containsAll(c, "--app-type", "modern_html") {
t.Errorf("expected --app-type modern_html, got %v", c)
}
}
func TestAppsInit_ModernHtml_SkipsEnvPull(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{
"credential-init": credInitOK("https://git.test/app_mh.git"),
"git clone": {},
"git checkout": {},
"git ls-files": {stdout: ""},
"npx -y": {},
"git status": {stdout: ""},
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_mh",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_mh",
"app_type": "MODERN_HTML",
},
},
},
})
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_mh", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := parseEnvelopeData(t, stdout)
if data["env_pull_skipped"] != true {
t.Errorf("env_pull_skipped = %v, want true", data["env_pull_skipped"])
}
if data["env_pulled"] != false {
t.Errorf("env_pulled = %v, want false", data["env_pulled"])
}
// Verify env-pull was NOT called
for _, c := range f.calls {
if len(c) >= 3 && c[2] == "apps" && len(c) >= 4 && c[3] == "+env-pull" {
t.Fatal("env-pull should not be called for modern_html")
}
}
}
func TestAppsInit_AlreadyInitialized_ModernHtml_SkipsEnvPull(t *testing.T) {
dir := relCloneDir(t)
abs, err := filepath.Abs(dir)
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(abs, ".spark"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(abs, metaRelPath), []byte(`{"app_id":"app_mh2"}`), 0o644); err != nil {
t.Fatal(err)
}
f := &fakeCommandRunner{}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_mh2",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_mh2",
"app_type": "MODERN_HTML",
},
},
},
})
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_mh2", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := parseEnvelopeData(t, stdout)
if data["scaffold"] != "already_initialized" {
t.Errorf("scaffold = %v, want already_initialized", data["scaffold"])
}
if data["env_pull_skipped"] != true {
t.Errorf("env_pull_skipped = %v, want true", data["env_pull_skipped"])
}
if len(f.calls) != 0 {
t.Errorf("no commands should be called for already-initialized modern_html, got %v", f.calls)
}
}
func TestAppsInit_WithAppType_AlreadyInitialized(t *testing.T) {
dir := relCloneDir(t)
abs, err := filepath.Abs(dir)
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(abs, ".spark"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(abs, metaRelPath), []byte(`{"app_id":"app_typed2"}`), 0o644); err != nil {
t.Fatal(err)
}
envFile := filepath.Join(abs, ".env.local")
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(envFile)}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
// Register meta mock so queryAppType returns "html"
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_typed2",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_typed2",
"app_type": "HTML",
},
},
},
})
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_typed2", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := parseEnvelopeData(t, stdout)
if data["scaffold"] != "already_initialized" {
t.Errorf("scaffold = %v, want already_initialized", data["scaffold"])
}
if data["app_type"] != "html" {
t.Errorf("app_type = %v, want html", data["app_type"])
}
}

View File

@@ -25,13 +25,7 @@ func TestAppsLogList_DryRunBuildsSearchLogsBody(t *testing.T) {
if err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// queryAppType fetches the app's type string from the server via
// GET /open-apis/spark/v1/apps/{appID}. The server returns uppercase
// values ("HTML", "FULL_STACK", "MODERN_HTML"); this function normalizes
// to lowercase. Returns "" when the API is unavailable or returns an
// error — callers fall back to legacy behavior.
func queryAppType(ctx context.Context, rctx *common.RuntimeContext, appID string) string {
path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))
data, err := rctx.CallAPITyped("GET", path, nil, nil)
if err != nil {
fmt.Fprintf(rctx.IO().ErrOut, "→ Could not query app type: %v\n", err)
return ""
}
appRaw, _ := data["app"].(map[string]interface{})
if appRaw == nil {
fmt.Fprintf(rctx.IO().ErrOut, "→ Could not query app type: response missing app object\n")
return ""
}
appType, _ := appRaw["app_type"].(string)
return strings.ToLower(appType)
}

View File

@@ -0,0 +1,148 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func newMetaTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
t.Helper()
cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_meta_test"}
f, _, _, reg := cmdutil.TestFactory(t, cfg)
rt := common.TestNewRuntimeContextForAPI(
context.Background(),
&cobra.Command{Use: "+meta-test"},
cfg, f, core.AsUser,
)
return rt, reg
}
func TestQueryAppType_Success(t *testing.T) {
rt, reg := newMetaTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_test",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_test",
"app_type": "MODERN_HTML",
},
},
},
})
result := queryAppType(context.Background(), rt, "app_test")
if result != "modern_html" {
t.Errorf("queryAppType = %q, want modern_html", result)
}
}
func TestQueryAppType_FullStack(t *testing.T) {
rt, reg := newMetaTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_fs",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_fs",
"app_type": "FULL_STACK",
},
},
},
})
result := queryAppType(context.Background(), rt, "app_fs")
if result != "full_stack" {
t.Errorf("queryAppType = %q, want full_stack", result)
}
}
func TestQueryAppType_Html(t *testing.T) {
rt, reg := newMetaTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_html",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_html",
"app_type": "HTML",
},
},
},
})
result := queryAppType(context.Background(), rt, "app_html")
if result != "html" {
t.Errorf("queryAppType = %q, want html", result)
}
}
func TestQueryAppType_APIError(t *testing.T) {
rt, reg := newMetaTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_bad",
Status: 500,
Body: map[string]interface{}{"code": float64(99999), "msg": "internal error"},
})
result := queryAppType(context.Background(), rt, "app_bad")
if result != "" {
t.Errorf("queryAppType = %q, want empty on error", result)
}
}
func TestQueryAppType_MissingAppObject(t *testing.T) {
rt, reg := newMetaTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_no",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{},
},
})
result := queryAppType(context.Background(), rt, "app_no")
if result != "" {
t.Errorf("queryAppType = %q, want empty when app object missing", result)
}
}
func TestQueryAppType_EmptyAppType(t *testing.T) {
rt, reg := newMetaTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_empty",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_empty",
"app_type": "",
},
},
},
})
result := queryAppType(context.Background(), rt, "app_empty")
if result != "" {
t.Errorf("queryAppType = %q, want empty when app_type is empty", result)
}
}

View File

@@ -39,13 +39,7 @@ func TestAppsMetricList_DryRunUsesSeconds(t *testing.T) {
if err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
@@ -90,11 +84,7 @@ func TestAppsMetricList_AutoDownSampleByRange(t *testing.T) {
if err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}

View File

@@ -56,9 +56,10 @@ var AppsReleaseCreate = common.Shortcut{
out := map[string]interface{}{
"release_id": common.GetString(data, "release_id"),
"status": common.GetString(data, "status"),
"sync": common.GetBool(data, "sync"),
}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "release_id: %s\nstatus: %s\n", out["release_id"], out["status"])
fmt.Fprintf(w, "release_id: %s\nstatus: %s\nsync: %v\n", out["release_id"], out["status"], out["sync"])
})
return nil
},

View File

@@ -105,3 +105,45 @@ func TestAppsReleaseCreateExecute_Success(t *testing.T) {
t.Errorf("status = %v, want publishing", env.Data["status"])
}
}
func TestAppsReleaseCreate_SyncField(t *testing.T) {
rctx, stdoutBuf, reg := newReleaseCreateRuntimeContext(t, "app_sync", "main")
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_sync/releases",
Body: map[string]interface{}{
"code": 0,
"msg": "",
"data": map[string]interface{}{
"release_id": "456",
"status": "publishing",
"sync": true,
},
},
})
err := AppsReleaseCreate.Execute(context.Background(), rctx)
if err != nil {
t.Fatalf("Execute() = %v", err)
}
var env struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
t.Fatalf("unmarshal output: %v\nraw: %s", err, stdoutBuf.String())
}
if !env.OK {
t.Fatalf("expected ok=true, got: %s", stdoutBuf.String())
}
if env.Data["release_id"] != "456" {
t.Errorf("release_id = %v, want 456", env.Data["release_id"])
}
if env.Data["status"] != "publishing" {
t.Errorf("status = %v, want publishing", env.Data["status"])
}
if env.Data["sync"] != true {
t.Errorf("sync = %v, want true", env.Data["sync"])
}
}

View File

@@ -24,13 +24,7 @@ func TestAppsTraceList_DryRunBuildsSearchTracesBody(t *testing.T) {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
@@ -73,13 +67,7 @@ func TestAppsTraceGet_DryRunBuildsGetTraceBody(t *testing.T) {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"fmt"
)
type dryRunAPICall struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
}
type dryRunAPIEnvelope struct {
API []dryRunAPICall
}
func (e *dryRunAPIEnvelope) UnmarshalJSON(data []byte) error {
var raw struct {
Data struct {
API []dryRunAPICall `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
e.API = raw.Data.API
return nil
}
func decodeDryRunDataMap(data []byte) (map[string]interface{}, error) {
var raw struct {
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
if raw.Data == nil {
return nil, fmt.Errorf("dry-run stdout is not a success envelope: %s", data)
}
return raw.Data, nil
}

View File

@@ -38,25 +38,27 @@ func TestAppsGitCredentialInitDryRunRequestShape(t *testing.T) {
t.Fatalf("dry-run err=%v", err)
}
var payload struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body interface{} `json:"body"`
} `json:"api"`
Mode string `json:"mode"`
Action string `json:"action"`
AppID string `json:"app_id"`
MetadataFile string `json:"metadata_file"`
LocalEffects []string `json:"local_effects"`
Data struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body interface{} `json:"body"`
} `json:"api"`
Mode string `json:"mode"`
Action string `json:"action"`
AppID string `json:"app_id"`
MetadataFile string `json:"metadata_file"`
LocalEffects []string `json:"local_effects"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
}
if len(payload.API) != 1 {
t.Fatalf("api len = %d, want 1", len(payload.API))
if len(payload.Data.API) != 1 {
t.Fatalf("api len = %d, want 1", len(payload.Data.API))
}
call := payload.API[0]
call := payload.Data.API[0]
if call.Method != "GET" {
t.Fatalf("method = %q, want GET", call.Method)
}
@@ -69,19 +71,19 @@ func TestAppsGitCredentialInitDryRunRequestShape(t *testing.T) {
if call.Body != nil {
t.Fatalf("body = %#v, want nil", call.Body)
}
if payload.Mode != "api-plus-local-setup" {
t.Fatalf("mode = %q", payload.Mode)
if payload.Data.Mode != "api-plus-local-setup" {
t.Fatalf("mode = %q", payload.Data.Mode)
}
if payload.Action != "initialize_local_git_credential" {
t.Fatalf("action = %q", payload.Action)
if payload.Data.Action != "initialize_local_git_credential" {
t.Fatalf("action = %q", payload.Data.Action)
}
if payload.AppID != "app_xxx" {
t.Fatalf("app_id = %q", payload.AppID)
if payload.Data.AppID != "app_xxx" {
t.Fatalf("app_id = %q", payload.Data.AppID)
}
if !strings.HasSuffix(payload.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) {
t.Fatalf("metadata_file = %q", payload.MetadataFile)
if !strings.HasSuffix(payload.Data.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) {
t.Fatalf("metadata_file = %q", payload.Data.MetadataFile)
}
assertStringSliceEqual(t, payload.LocalEffects, []string{
assertStringSliceEqual(t, payload.Data.LocalEffects, []string{
"save the issued PAT in the local system credential store",
"write app-scoped git credential metadata",
"configure a URL-scoped Git credential helper in global git config when possible",
@@ -96,32 +98,34 @@ func TestAppsGitCredentialListDryRunDescribesLocalReads(t *testing.T) {
t.Fatalf("dry-run err=%v", err)
}
var payload struct {
Description string `json:"description"`
API []interface{} `json:"api"`
Mode string `json:"mode"`
Action string `json:"action"`
StorageRoot string `json:"storage_root"`
Reads []string `json:"reads"`
Data struct {
Description string `json:"description"`
API []interface{} `json:"api"`
Mode string `json:"mode"`
Action string `json:"action"`
StorageRoot string `json:"storage_root"`
Reads []string `json:"reads"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
}
if payload.Description != "Preview local Git credential listing (no API call, read-only local state)." {
t.Fatalf("description = %q", payload.Description)
if payload.Data.Description != "Preview local Git credential listing (no API call, read-only local state)." {
t.Fatalf("description = %q", payload.Data.Description)
}
if len(payload.API) != 0 {
t.Fatalf("api len = %d, want 0", len(payload.API))
if len(payload.Data.API) != 0 {
t.Fatalf("api len = %d, want 0", len(payload.Data.API))
}
if payload.Mode != "local-read-only" {
t.Fatalf("mode = %q", payload.Mode)
if payload.Data.Mode != "local-read-only" {
t.Fatalf("mode = %q", payload.Data.Mode)
}
if payload.Action != "list_local_git_credentials" {
t.Fatalf("action = %q", payload.Action)
if payload.Data.Action != "list_local_git_credentials" {
t.Fatalf("action = %q", payload.Data.Action)
}
if !strings.HasSuffix(payload.StorageRoot, filepath.Join("spark")) {
t.Fatalf("storage_root = %q", payload.StorageRoot)
if !strings.HasSuffix(payload.Data.StorageRoot, filepath.Join("spark")) {
t.Fatalf("storage_root = %q", payload.Data.StorageRoot)
}
assertStringSliceEqual(t, payload.Reads, []string{
assertStringSliceEqual(t, payload.Data.Reads, []string{
"scan app-scoped git credential metadata under the CLI config directory",
"derive per-app repository URLs and local credential status from local metadata",
})
@@ -135,36 +139,38 @@ func TestAppsGitCredentialRemoveDryRunDescribesLocalCleanup(t *testing.T) {
t.Fatalf("dry-run err=%v", err)
}
var payload struct {
Description string `json:"description"`
API []interface{} `json:"api"`
Mode string `json:"mode"`
Action string `json:"action"`
AppID string `json:"app_id"`
MetadataFile string `json:"metadata_file"`
Effects []string `json:"effects"`
Data struct {
Description string `json:"description"`
API []interface{} `json:"api"`
Mode string `json:"mode"`
Action string `json:"action"`
AppID string `json:"app_id"`
MetadataFile string `json:"metadata_file"`
Effects []string `json:"effects"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
}
if payload.Description != "Preview local Git credential cleanup (no API call; would clean up local-only state)." {
t.Fatalf("description = %q", payload.Description)
if payload.Data.Description != "Preview local Git credential cleanup (no API call; would clean up local-only state)." {
t.Fatalf("description = %q", payload.Data.Description)
}
if len(payload.API) != 0 {
t.Fatalf("api len = %d, want 0", len(payload.API))
if len(payload.Data.API) != 0 {
t.Fatalf("api len = %d, want 0", len(payload.Data.API))
}
if payload.Mode != "local-cleanup-only" {
t.Fatalf("mode = %q", payload.Mode)
if payload.Data.Mode != "local-cleanup-only" {
t.Fatalf("mode = %q", payload.Data.Mode)
}
if payload.Action != "remove_local_git_credential" {
t.Fatalf("action = %q", payload.Action)
if payload.Data.Action != "remove_local_git_credential" {
t.Fatalf("action = %q", payload.Data.Action)
}
if payload.AppID != "app_xxx" {
t.Fatalf("app_id = %q", payload.AppID)
if payload.Data.AppID != "app_xxx" {
t.Fatalf("app_id = %q", payload.Data.AppID)
}
if !strings.HasSuffix(payload.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) {
t.Fatalf("metadata_file = %q", payload.MetadataFile)
if !strings.HasSuffix(payload.Data.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) {
t.Fatalf("metadata_file = %q", payload.Data.MetadataFile)
}
assertStringSliceEqual(t, payload.Effects, []string{
assertStringSliceEqual(t, payload.Data.Effects, []string{
"read app-scoped git credential metadata",
"remove the saved PAT from the local system credential store",
"remove the app-scoped Git helper from global git config when present",

View File

@@ -12,6 +12,7 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
AppsCreate,
AppsGet,
AppsUpdate,
AppsList,
AppsAccessScopeSet,

View File

@@ -21,10 +21,10 @@ import (
// - 5 sessioncreate/list/get/stop/chat+ 1 session-messages-list
// - 8 openapi-keylist/get/create/update/enable/disable/delete/reset
// - 3 plugininstall/uninstall/list= 63。
func TestAppsShortcuts_Returns63(t *testing.T) {
func TestAppsShortcuts_Returns64(t *testing.T) {
got := Shortcuts()
if len(got) != 63 {
t.Fatalf("Shortcuts() returned %d entries, want 63", len(got))
if len(got) != 64 {
t.Fatalf("Shortcuts() returned %d entries, want 64", len(got))
}
}

View File

@@ -1222,7 +1222,7 @@ func TestAgenda_Success(t *testing.T) {
"+agenda",
"--start", "2025-03-21",
"--end", "2025-03-21",
"--format", "prettry",
"--format", "pretty",
"--as", "bot",
}, f, stdout)

View File

@@ -752,12 +752,9 @@ func (ctx *RuntimeContext) OutFormatRaw(data interface{}, meta *output.Meta, pre
}
func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer), raw bool) {
outFn := ctx.Out
if raw {
outFn = ctx.OutRaw
}
emitJSON := func() { ctx.emit(data, meta, raw, true) }
if ctx.JqExpr != "" {
outFn(data, meta)
emitJSON()
return
}
switch ctx.Format {
@@ -773,10 +770,10 @@ func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, pretty
if prettyFn != nil {
prettyFn(ctx.IO().Out)
} else {
outFn(data, meta)
output.FormatValue(ctx.IO().Out, data, output.FormatPretty)
}
case "json", "":
outFn(data, meta)
emitJSON()
default:
// table, csv, ndjson — pass data directly; FormatValue handles both
// plain arrays and maps with array fields (e.g. {"members":[…]})
@@ -790,7 +787,11 @@ func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, pretty
}
format, formatOK := output.ParseFormat(ctx.Format)
if !formatOK {
fmt.Fprintf(ctx.IO().ErrOut, "warning: unknown format %q, falling back to json\n", ctx.Format)
ctx.outputErrOnce.Do(func() {
ctx.outputErr = errs.NewValidationError(errs.SubtypeInvalidArgument,
"unsupported output format %q", ctx.Format).WithParam("--format")
})
return
}
output.FormatValue(ctx.IO().Out, data, format)
}
@@ -928,6 +929,13 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
}
}
// Output format validation is local and must happen before identity,
// configuration or credential work. Invalid input therefore produces the
// same typed validation error even when no account is configured.
if _, err := normalizeShortcutOutputFormat(cmd, s); err != nil {
return err
}
as, err := resolveShortcutIdentity(cmd, f, s)
if err != nil {
return err
@@ -1027,8 +1035,11 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
}
rctx.larkSDK = sdk
applyJSONShorthand(cmd, s)
rctx.Format = rctx.Str("format")
format, err := normalizeShortcutOutputFormat(cmd, s)
if err != nil {
return nil, err
}
rctx.Format = format
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
return rctx, nil
}
@@ -1070,7 +1081,7 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
if rctx.stdinConsumed {
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
WithParam("--"+fl.Name).
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others as @file (e.g. --%s @/path/to/file)", fl.Name)
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others inline or as @file with a relative path under the current directory (e.g. --%s @./payload.json)", fl.Name)
}
rctx.stdinConsumed = true
data, err := io.ReadAll(rctx.IO().In)
@@ -1104,9 +1115,16 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
}
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
if err != nil {
return ValidationErrorf("--%s: %v", fl.Name, err).
verr := ValidationErrorf("--%s: %v", fl.Name, err).
WithParam("--" + fl.Name).
WithCause(err)
if slices.Contains(fl.Input, Stdin) {
// Rejected @file paths are usually absolute (temp files under
// /tmp). Steer toward stdin rather than cd / copying the file
// into the project tree.
verr = verr.WithHint("this flag also reads stdin: pipe the file contents into this command and pass --%s -", fl.Name)
}
return verr
}
// strip a leading UTF-8 BOM so it
// can't corrupt the first CSV cell or break JSON parsing downstream.
@@ -1146,14 +1164,19 @@ func handleShortcutDryRun(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut)
return ValidationErrorf("--dry-run is not supported for %s %s", s.Service, s.Command).
WithParam("--dry-run")
}
fmt.Fprintln(f.IOStreams.ErrOut, "=== Dry Run ===")
dryResult := s.DryRun(rctx.ctx, rctx)
if rctx.Format == "pretty" {
fmt.Fprint(f.IOStreams.Out, dryResult.Format())
} else {
output.PrintJson(f.IOStreams.Out, dryResult)
if dryResult != nil {
// Same data.context contract as the service/api dry-run paths.
dryResult.Context(rctx.Config.AppID, rctx.UserOpenId())
}
return nil
return cmdutil.WriteDryRun(dryResult, cmdutil.DryRunOutputOptions{
Format: rctx.Format,
JqExpr: rctx.JqExpr,
CommandPath: rctx.Cmd.CommandPath(),
Identity: rctx.As(),
Out: f.IOStreams.Out,
ErrOut: f.IOStreams.ErrOut,
})
}
// rejectPositionalArgs returns a cobra.PositionalArgs that rejects any
@@ -1188,15 +1211,23 @@ func shortcutDeclaresJSONFlag(s *Shortcut) bool {
}
// shortcutFormatSupportsJSON reports whether the command's format flag accepts
// "json": a self-declared format supports it only when its Enum lists "json";
// a framework-injected default format (no format entry in s.Flags) always does.
// "json". It derives the answer from the same capability set used everywhere
// else (shortcutFormatCapabilities), so a format flag that declares no Enum but
// defaults to "json" is correctly recognized as JSON-capable.
func shortcutFormatSupportsJSON(s *Shortcut) bool {
return shortcutFormatCapabilities(s).Supports("json")
}
func shortcutFormatCapabilities(s *Shortcut) output.FormatCapabilities {
for _, fl := range s.Flags {
if fl.Name == "format" {
return slices.Contains(fl.Enum, "json")
if len(fl.Enum) > 0 {
return output.NewFormatCapabilities(fl.Enum...)
}
return output.NewFormatCapabilities(fl.Default)
}
}
return true // framework-injected: json (default) | pretty | table | ndjson | csv
return output.StandardFormats
}
// ensureJSONShorthand registers --json as a shorthand for --format json when:
@@ -1231,15 +1262,32 @@ func ensureJSONShorthand(cmd *cobra.Command, s *Shortcut) {
// shorthand only fills in when the user did not choose a format). Shortcuts
// that declare their own "json" flag keep its custom semantics untouched.
func applyJSONShorthand(cmd *cobra.Command, s *Shortcut) {
if shortcutDeclaresJSONFlag(s) {
return
_, _ = normalizeShortcutOutputFormat(cmd, s)
}
func normalizeShortcutOutputFormat(cmd *cobra.Command, s *Shortcut) (string, error) {
format, err := resolveShortcutOutputFormat(cmd, s)
if err != nil {
return "", err
}
if cmd.Flags().Lookup("json") == nil || cmd.Flags().Changed("format") {
return
if cmd.Flags().Lookup("format") != nil && cmd.Flags().Lookup("format").Value.String() != format {
if err := cmd.Flags().Set("format", format); err != nil {
return "", errs.NewInternalError(errs.SubtypeUnknown, "failed to normalize --format: %v", err).WithCause(err)
}
}
if set, _ := cmd.Flags().GetBool("json"); set {
_ = cmd.Flags().Set("format", "json")
return format, nil
}
func resolveShortcutOutputFormat(cmd *cobra.Command, s *Shortcut) (string, error) {
format, err := cmd.Flags().GetString("format")
if err != nil {
return "", errs.NewInternalError(errs.SubtypeUnknown, "failed to read --format: %v", err).WithCause(err)
}
jsonShorthand := false
if !shortcutDeclaresJSONFlag(s) && cmd.Flags().Lookup("json") != nil {
jsonShorthand, _ = cmd.Flags().GetBool("json")
}
return shortcutFormatCapabilities(s).Resolve(format, cmd.Flags().Changed("format"), jsonShorthand)
}
func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) {
@@ -1301,9 +1349,9 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
cmd.Flags().Bool("dry-run", false, "print request without executing")
if cmd.Flags().Lookup("format") == nil {
cmd.Flags().String("format", "json", "output format: json (default) | pretty | table | ndjson | csv")
cmd.Flags().String("format", "json", output.StandardFormats.Usage())
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
return output.StandardFormats.Names(), cobra.ShellCompDirectiveNoFileComp
})
}
ensureJSONShorthand(cmd, s)

View File

@@ -5,9 +5,13 @@ package common
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/cobra"
)
@@ -37,3 +41,87 @@ func TestShortcutMount_FormatFlagAlwaysRegistered(t *testing.T) {
t.Errorf("--format default = %q, want %q", flag.DefValue, "json")
}
}
func TestRuntimeContextOutKeepsJSONEnvelopeForPrettyFormat(t *testing.T) {
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
rctx := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+read"}, cfg, f, core.AsBot)
rctx.Format = "pretty"
rctx.Out(map[string]interface{}{
"items": []interface{}{map[string]interface{}{"name": "Alice"}},
}, nil)
var envelope struct {
OK bool `json:"ok"`
Data struct {
Items []map[string]interface{} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("Out should emit a JSON envelope: %v\n%s", err, stdout.String())
}
if !envelope.OK || len(envelope.Data.Items) != 1 || envelope.Data.Items[0]["name"] != "Alice" {
t.Fatalf("unexpected envelope: %#v", envelope)
}
}
func TestRuntimeContextOutRawKeepsJSONEnvelopeForPrettyFormat(t *testing.T) {
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
rctx := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+read"}, cfg, f, core.AsBot)
rctx.Format = "pretty"
rctx.OutRaw(map[string]interface{}{"body": "<p>hello</p>"}, nil)
var envelope struct {
OK bool `json:"ok"`
Data struct {
Body string `json:"body"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("OutRaw should emit a JSON envelope: %v\n%s", err, stdout.String())
}
if !envelope.OK || envelope.Data.Body != "<p>hello</p>" {
t.Fatalf("unexpected envelope: %#v", envelope)
}
}
func TestShortcutMount_UnsupportedFormatFailsBeforeExecution(t *testing.T) {
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
parent := &cobra.Command{Use: "root"}
executed := false
shortcut := Shortcut{
Service: "test",
Command: "+read",
Description: "read data",
AuthTypes: []string{"bot"},
Execute: func(context.Context, *RuntimeContext) error {
executed = true
return nil
},
}
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+read"})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
if cmd == nil {
t.Fatal("expected mounted shortcut command")
}
parent.SetArgs([]string{"+read", "--format", "xml"})
err = parent.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Param != "--format" {
t.Fatalf("param = %q, want --format", validationErr.Param)
}
if executed {
t.Fatal("shortcut must not execute with an unsupported format")
}
}

View File

@@ -227,6 +227,35 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
}
}
// TestResolveInputFlags_FileErrorSuggestsStdin pins the recovery hint when
// an @file path is rejected (typically an absolute /tmp path): flags that
// also accept stdin must explain the portable `--flag -` form — never cd'ing
// into the target directory or copying the file into the project tree.
func TestResolveInputFlags_FileErrorSuggestsStdin(t *testing.T) {
rctx := newTestRuntimeWithStdin(map[string]string{"csv": "@/tmp/does-not-exist.csv"}, "")
flags := []Flag{{Name: "csv", Input: []string{File, Stdin}}}
err := resolveInputFlags(rctx, flags)
if err == nil {
t.Fatal("expected error for rejected @file path")
}
vErr := assertValidationParam(t, err, "--csv")
if !strings.Contains(vErr.Hint, "pipe the file contents") || !strings.Contains(vErr.Hint, "--csv -") {
t.Errorf("hint %q should explain the portable stdin form", vErr.Hint)
}
// A flag without stdin support must not get the stdin hint.
rctx = newTestRuntimeWithStdin(map[string]string{"file": "@/tmp/does-not-exist.xlsx"}, "")
err = resolveInputFlags(rctx, []Flag{{Name: "file", Input: []string{File}}})
if err == nil {
t.Fatal("expected error for rejected @file path")
}
vErr = assertValidationParam(t, err, "--file")
if strings.Contains(vErr.Hint, "stdin") {
t.Errorf("hint %q must not suggest stdin for a file-only flag", vErr.Hint)
}
}
func TestStripUTF8BOM(t *testing.T) {
cases := []struct{ name, in, want string }{
{"leading BOM removed", "\uFEFFhello", "hello"},

View File

@@ -6,6 +6,7 @@ package common
import (
"bytes"
"context"
"encoding/json"
"io"
"strings"
"testing"
@@ -229,6 +230,75 @@ func TestRunShortcut_JqRuntimeError_PropagatesError(t *testing.T) {
}
}
func TestRunShortcut_DryRunJSONUsesEnvelope(t *testing.T) {
s := &Shortcut{
Service: "test",
Command: "test-shortcut",
AuthTypes: []string{"bot"},
DryRun: func(ctx context.Context, rctx *RuntimeContext) *cmdutil.DryRunAPI {
return cmdutil.NewDryRunAPI().GET("/open-apis/test")
},
Execute: func(ctx context.Context, rctx *RuntimeContext) error {
t.Fatal("Execute should not run in dry-run")
return nil
},
}
f := newTestFactory()
cmd := newTestShortcutCmd(s, f)
cmd.Flags().Set("dry-run", "true")
cmd.Flags().Set("as", "bot")
if err := runShortcut(cmd, f, s, false); err != nil {
t.Fatalf("runShortcut() error = %v", err)
}
stdout := f.IOStreams.Out.(*bytes.Buffer)
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, stdout.String())
}
if env["ok"] != true || env["identity"] != "bot" || env["dry_run"] != true {
t.Fatalf("unexpected dry-run envelope: %#v", env)
}
data := env["data"].(map[string]interface{})
api := data["api"].([]interface{})
call := api[0].(map[string]interface{})
if call["url"] != "/open-apis/test" {
t.Fatalf("api[0] = %#v", call)
}
dctx, ok := data["context"].(map[string]interface{})
if !ok || dctx["app_id"] != "test" {
t.Fatalf("runner must inject data.context like the service/api paths, got: %#v", data["context"])
}
}
func TestRunShortcut_DryRunWithJq(t *testing.T) {
s := &Shortcut{
Service: "test",
Command: "test-shortcut",
AuthTypes: []string{"bot"},
DryRun: func(ctx context.Context, rctx *RuntimeContext) *cmdutil.DryRunAPI {
return cmdutil.NewDryRunAPI().GET("/open-apis/test")
},
Execute: func(ctx context.Context, rctx *RuntimeContext) error {
t.Fatal("Execute should not run in dry-run")
return nil
},
}
f := newTestFactory()
cmd := newTestShortcutCmd(s, f)
cmd.Flags().Set("dry-run", "true")
cmd.Flags().Set("jq", ".dry_run")
cmd.Flags().Set("as", "bot")
if err := runShortcut(cmd, f, s, false); err != nil {
t.Fatalf("runShortcut() error = %v", err)
}
stdout := f.IOStreams.Out.(*bytes.Buffer)
if got := strings.TrimSpace(stdout.String()); got != "true" {
t.Fatalf("jq output = %q, want true", got)
}
}
func TestRuntimeContext_Out_WithoutJq_NormalOutput(t *testing.T) {
rctx, stdout, _ := newJqTestContext("", "")

View File

@@ -45,6 +45,16 @@ func decodeJSONMap(t *testing.T, raw string) map[string]interface{} {
return data
}
func dryRunDataMap(t *testing.T, raw string) map[string]interface{} {
t.Helper()
out := decodeJSONMap(t, raw)
data, ok := out["data"].(map[string]interface{})
if !ok {
t.Fatalf("dry-run data is %T, want map[string]interface{}\nstdout:\n%s", out["data"], raw)
}
return data
}
func mustMapValue(t *testing.T, value interface{}, path string) map[string]interface{} {
t.Helper()
@@ -1628,8 +1638,8 @@ func TestDryRunSlidesDirectURL(t *testing.T) {
if !strings.Contains(stdout.String(), "slide block comment") {
t.Fatalf("dry-run output missing slide block comment: %s", stdout.String())
}
out := decodeJSONMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "api")
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
call := mustMapValue(t, api[0], "api[0]")
body := mustMapValue(t, call["body"], "api[0].body")
anchor := mustMapValue(t, body["anchor"], "api[0].body.anchor")
@@ -1656,8 +1666,8 @@ func TestDryRunBaseDirectURL(t *testing.T) {
if !strings.Contains(stdout.String(), "record-local comment") {
t.Fatalf("dry-run output missing record-local comment: %s", stdout.String())
}
out := decodeJSONMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "api")
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
call := mustMapValue(t, api[0], "api[0]")
body := mustMapValue(t, call["body"], "api[0].body")
anchor := mustMapValue(t, body["anchor"], "api[0].body.anchor")
@@ -1699,8 +1709,8 @@ func TestDryRunWikiResolvesToSlides(t *testing.T) {
if !strings.Contains(stdout.String(), "slide block comment") {
t.Fatalf("dry-run output missing slide block comment: %s", stdout.String())
}
out := decodeJSONMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "api")
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
call := mustMapValue(t, api[0], "api[0]")
body := mustMapValue(t, call["body"], "api[0].body")
anchor := mustMapValue(t, body["anchor"], "api[0].body.anchor")
@@ -1736,8 +1746,8 @@ func TestDryRunWikiSlidesInvalidBlockIDSurfaces(t *testing.T) {
if !strings.Contains(stdout.String(), "slide --block-id must be") || !strings.Contains(stdout.String(), "shape_2") {
t.Fatalf("dry-run output missing block-id format error: %s", stdout.String())
}
out := decodeJSONMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "api")
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 0 {
t.Fatalf("dry-run should not preview API calls with malformed block-id: %s", stdout.String())
}
@@ -1821,8 +1831,8 @@ func TestDryRunFileDirectURL(t *testing.T) {
if !strings.Contains(stdout.String(), "verify supported file metadata") {
t.Fatalf("dry-run output missing supported file metadata verification step: %s", stdout.String())
}
out := decodeJSONMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "api")
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 2 {
t.Fatalf("expected 2 dry-run api calls, got %d\nstdout:\n%s", len(api), stdout.String())
}

View File

@@ -39,7 +39,7 @@ func wrapExportContextErr(err error) error {
var DriveExport = common.Shortcut{
Service: "drive",
Command: "+export",
Description: "Export a doc/docx/sheet/bitable/slides to a local file with limited polling",
Description: "Export a doc/docx/sheet/bitable/slides or wiki document to a local file with limited polling",
Risk: "read",
Scopes: []string{
"docs:document.content:read",
@@ -47,10 +47,12 @@ var DriveExport = common.Shortcut{
"docx:document:readonly",
"drive:drive.metadata:readonly",
},
AuthTypes: []string{"user", "bot"},
ConditionalScopes: []string{"wiki:node:retrieve"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "token", Desc: "source document token", Required: true},
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable | slides", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable", "slides"}},
{Name: "url", Desc: "source document URL; doc type and token are inferred, and wiki URLs are resolved to the underlying document"},
{Name: "token", Desc: "source document token; bare tokens require --doc-type, and wiki tokens should use --doc-type wiki"},
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable | slides | wiki (required only when --token is a bare token)", Enum: []string{"doc", "docx", "sheet", "bitable", "slides", "wiki"}},
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only) | pptx (slides only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx"}},
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
{Name: "only-schema", Type: "bool", Desc: "export only bitable schema when --doc-type bitable --file-extension base"},
@@ -75,6 +77,7 @@ var DriveExport = common.Shortcut{
// task and poll, but do not download" — callers that only need the ready file
// token / status get it back without writing a local file.
type ExportParams struct {
URL string
Token string
DocType string
FileExtension string
@@ -87,6 +90,7 @@ type ExportParams struct {
func (p ExportParams) spec() driveExportSpec {
return driveExportSpec{
URL: p.URL,
Token: p.Token,
DocType: p.DocType,
FileExtension: p.FileExtension,
@@ -106,6 +110,7 @@ func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
outputDir = "."
}
return ExportParams{
URL: runtime.Str("url"),
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
@@ -127,60 +132,90 @@ func validateExport(p ExportParams) error {
// PlanExportDryRun builds the dry-run plan for an export without performing I/O.
func PlanExportDryRun(runtime *common.RuntimeContext, p ExportParams) *common.DryRunAPI {
spec := p.spec()
spec, source, err := normalizeDriveExportSpecInput(p.spec())
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if err := validateDriveExportNormalizedSpecForSource(spec, source); err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
dry := common.NewDryRunAPI()
if source.Type == "wiki" {
dry.GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[0] Resolve wiki node to underlying document token").
Params(map[string]interface{}{"token": source.Token})
spec.Token = "obj_token_from_step_0"
if spec.DocType == "" {
spec.DocType = "obj_type_from_step_0"
}
dry.Set("wiki_token", source.Token)
}
// Markdown export is a special case: docx markdown comes from the V2
// docs_ai fetch API directly instead of the Drive export task API.
if spec.FileExtension == "markdown" {
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
dr := common.NewDryRunAPI().
Desc("2-step orchestration: fetch docx markdown -> write local file").
desc := "2-step orchestration: fetch docx markdown -> write local file"
if source.Type == "wiki" {
desc = "3-step orchestration: resolve wiki -> fetch docx markdown -> write local file"
}
dry.Desc(desc).
POST(apiPath).
Body(map[string]interface{}{
"format": "markdown",
}).
Set("output_dir", p.OutputDir)
if name := strings.TrimSpace(p.FileName); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
dry.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
return dry
}
body := map[string]interface{}{
"token": spec.Token,
"type": spec.DocType,
"file_extension": spec.FileExtension,
desc := "3-step orchestration: create export task -> limited polling -> download file"
if source.Type == "wiki" {
desc = "4-step orchestration: resolve wiki -> create export task -> limited polling -> download file"
}
if strings.TrimSpace(spec.SubID) != "" {
body["sub_id"] = spec.SubID
}
if spec.OnlySchema {
body["only_schema"] = true
}
dr := common.NewDryRunAPI().
Desc("3-step orchestration: create export task -> limited polling -> download file").
dry.Desc(desc).
POST("/open-apis/drive/v1/export_tasks").
Body(body).
Body(buildDriveExportTaskBody(spec)).
Set("output_dir", p.OutputDir)
if name := strings.TrimSpace(p.FileName); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
dry.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
return dry
}
// RunExport drives create export task -> bounded poll -> optional download. It
// is the shared core behind both drive +export and sheets +workbook-export. An
// empty p.OutputDir skips the download step and returns the ready file token.
func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportParams) error {
spec := p.spec()
spec, source, err := normalizeDriveExportSpecInput(p.spec())
if err != nil {
return err
}
if err := validateDriveExportNormalizedSpecForSource(spec, source); err != nil {
return err
}
outputDir := p.OutputDir
preferredFileName := strings.TrimSpace(p.FileName)
overwrite := p.Overwrite
var wikiResolution driveExportWikiResolution
// Markdown export bypasses the async export task and writes the fetched
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
// higher-quality Lark-flavored Markdown output.
if spec.FileExtension == "markdown" {
if source.Type == "wiki" {
resolvedSpec, resolution, err := resolveDriveExportWikiSource(ctx, runtime, spec, source.Token)
if err != nil {
return err
}
spec = resolvedSpec
wikiResolution = resolution
}
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
data, err := runtime.CallAPITyped(
@@ -222,21 +257,23 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
return err
}
runtime.Out(map[string]interface{}{
runtime.Out(annotateDriveExportWikiOutput(map[string]interface{}{
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"file_name": filepath.Base(savedPath),
"saved_path": savedPath,
"size_bytes": len(content),
}, nil)
}, wikiResolution), nil)
return nil
}
ticket, err := createDriveExportTask(runtime, spec)
ticket, resolvedSpec, resolution, err := createDriveExportTaskResolvingWiki(ctx, runtime, spec, source)
if err != nil {
return err
}
spec = resolvedSpec
wikiResolution = resolution
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
var lastStatus driveExportStatus
@@ -274,7 +311,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
// no local download (e.g. sheets +workbook-export without an output
// path). Skip the download and return the status envelope.
if strings.TrimSpace(outputDir) == "" {
runtime.Out(map[string]interface{}{
runtime.Out(annotateDriveExportWikiOutput(map[string]interface{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
@@ -284,7 +321,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
"file_size": status.FileSize,
"ready": true,
"downloaded": false,
}, nil)
}, wikiResolution), nil)
return nil
}
@@ -307,7 +344,7 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
out["ticket"] = ticket
out["doc_type"] = spec.DocType
out["file_extension"] = spec.FileExtension
runtime.Out(out, nil)
runtime.Out(annotateDriveExportWikiOutput(out, wikiResolution), nil)
return nil
}
@@ -357,7 +394,19 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
if preferredFileName != "" {
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
}
runtime.Out(result, nil)
runtime.Out(annotateDriveExportWikiOutput(result, wikiResolution), nil)
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
return nil
}
func annotateDriveExportWikiOutput(out map[string]interface{}, resolution driveExportWikiResolution) map[string]interface{} {
if !resolution.Resolved {
return out
}
out["wiki_token"] = resolution.WikiToken
out["wiki_node"] = map[string]interface{}{
"obj_token": resolution.ObjToken,
"obj_type": resolution.ObjType,
}
return out
}

View File

@@ -27,9 +27,16 @@ var (
driveExportPollInterval = 5 * time.Second
)
const (
driveExportResolvedDocTypeValues = "doc, docx, sheet, bitable, slides"
driveExportInputDocTypeValues = driveExportResolvedDocTypeValues + ", wiki"
driveExportFileExtensionValues = "docx, pdf, xlsx, csv, markdown, base, pptx"
)
// driveExportSpec contains the normalized export request understood by the
// shortcut and the underlying export task APIs.
type driveExportSpec struct {
URL string
Token string
DocType string
FileExtension string
@@ -37,6 +44,19 @@ type driveExportSpec struct {
OnlySchema bool
}
type driveExportInputSource struct {
Type string
Token string
Param string
}
type driveExportWikiResolution struct {
Resolved bool
WikiToken string
ObjToken string
ObjType string
}
// driveExportTaskResultCommand prints the resume command shown when bounded
// export polling times out locally.
func driveExportTaskResultCommand(ticket, docToken string) string {
@@ -127,45 +147,49 @@ func (s driveExportStatus) StatusLabel() string {
// validateDriveExportSpec enforces shortcut-level export constraints before any
// backend request is sent.
func validateDriveExportSpec(spec driveExportSpec) error {
if err := validate.ResourceName(spec.Token, "--token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
normalized, source, err := normalizeDriveExportSpecInput(spec)
if err != nil {
return err
}
return validateDriveExportNormalizedSpecForSource(normalized, source)
}
func validateDriveExportNormalizedSpec(spec driveExportSpec) error {
switch spec.DocType {
case "doc", "docx", "sheet", "bitable", "slides":
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc-type %q: allowed values are doc, docx, sheet, bitable, slides", spec.DocType).WithParam("--doc-type")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc-type %q: allowed values are %s", spec.DocType, driveExportInputDocTypeValues).
WithParam("--doc-type").
WithHint("use --url when you have a document URL; use --doc-type wiki only with a bare Wiki node token so the CLI can resolve the underlying document type")
}
if err := validate.ResourceName(spec.Token, "--token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
switch spec.FileExtension {
case "docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx":
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base, pptx", spec.FileExtension).WithParam("--file-extension")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are %s", spec.FileExtension, driveExportFileExtensionValues).
WithParam("--file-extension").
WithHint("choose an export format supported by the source type; common choices are docx/pdf for docs, xlsx/csv for sheets, xlsx/csv/base for bitable, and pptx/pdf for slides")
}
if spec.FileExtension == "markdown" && spec.DocType != "docx" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension markdown only supports --doc-type docx")
}
if spec.FileExtension == "base" && spec.DocType != "bitable" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension base only supports --doc-type bitable")
if err := validateDriveExportFormatCompatibility(spec); err != nil {
return err
}
if spec.OnlySchema && (spec.DocType != "bitable" || spec.FileExtension != "base") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").WithParam("--only-schema")
}
if spec.FileExtension == "pptx" && spec.DocType != "slides" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension pptx only supports --doc-type slides")
}
if spec.DocType == "slides" && spec.FileExtension != "pptx" && spec.FileExtension != "pdf" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-type slides only supports --file-extension pptx or pdf")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").
WithParam("--only-schema").
WithHint("retry with --doc-type bitable --file-extension base, or remove --only-schema")
}
if strings.TrimSpace(spec.SubID) != "" {
if spec.FileExtension != "csv" || (spec.DocType != "sheet" && spec.DocType != "bitable") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").WithParam("--sub-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").
WithParam("--sub-id").
WithHint("remove --sub-id, or retry with --doc-type sheet|bitable --file-extension csv")
}
if err := validate.ResourceName(spec.SubID, "--sub-id"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--sub-id")
@@ -173,15 +197,212 @@ func validateDriveExportSpec(spec driveExportSpec) error {
}
if spec.FileExtension == "csv" && (spec.DocType == "sheet" || spec.DocType == "bitable") && strings.TrimSpace(spec.SubID) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is required when exporting sheet/bitable as csv").WithParam("--sub-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is required when exporting sheet/bitable as csv").
WithParam("--sub-id").
WithHint("retry with --sub-id <sheet_id_or_table_id>; if you need the whole workbook, use --file-extension xlsx instead")
}
return nil
}
// createDriveExportTask starts the asynchronous export job and returns its
// ticket for subsequent polling.
func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec) (string, error) {
func validateDriveExportFormatCompatibility(spec driveExportSpec) error {
if driveExportFileExtensionAllowedForDocType(spec.DocType, spec.FileExtension) {
return nil
}
allowed := strings.Join(driveExportAllowedFileExtensions(spec.DocType), ", ")
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported export format: --doc-type %s cannot be exported as %s",
spec.DocType,
spec.FileExtension,
).
WithParam("--file-extension").
WithHint("retry with --file-extension %s. If the token came from a URL, prefer --url so the CLI infers the correct source type before validating the export format", allowed)
}
func driveExportFileExtensionAllowedForDocType(docType, fileExtension string) bool {
for _, allowed := range driveExportAllowedFileExtensions(docType) {
if fileExtension == allowed {
return true
}
}
return false
}
func driveExportAllowedFileExtensions(docType string) []string {
switch normalizeDriveExportDocType(docType) {
case "doc":
return []string{"docx", "pdf"}
case "docx":
return []string{"docx", "pdf", "markdown"}
case "sheet":
return []string{"xlsx", "csv"}
case "bitable":
return []string{"xlsx", "csv", "base"}
case "slides":
return []string{"pptx", "pdf"}
default:
return []string{"docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx"}
}
}
func validateDriveExportNormalizedSpecForSource(spec driveExportSpec, source driveExportInputSource) error {
if source.Type == "wiki" && spec.DocType == "" {
return validateDriveExportPendingWikiSpec(spec, source)
}
return validateDriveExportNormalizedSpec(spec)
}
func validateDriveExportPendingWikiSpec(spec driveExportSpec, source driveExportInputSource) error {
param := source.Param
if param == "" {
param = "--token"
}
if err := validate.ResourceName(spec.Token, param); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(param)
}
switch spec.FileExtension {
case "docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx":
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are %s", spec.FileExtension, driveExportFileExtensionValues).
WithParam("--file-extension").
WithHint("Wiki export format is validated after resolving the Wiki node; choose a format normally supported by the underlying document type")
}
if spec.OnlySchema && spec.FileExtension != "base" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").
WithParam("--only-schema").
WithHint("retry with --file-extension base, or remove --only-schema")
}
if strings.TrimSpace(spec.SubID) != "" && spec.FileExtension != "csv" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").
WithParam("--sub-id").
WithHint("remove --sub-id, or retry with --file-extension csv if the Wiki node resolves to a sheet/bitable")
}
if strings.TrimSpace(spec.SubID) != "" {
if err := validate.ResourceName(spec.SubID, "--sub-id"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--sub-id")
}
}
return nil
}
func normalizeDriveExportSpecInput(spec driveExportSpec) (driveExportSpec, driveExportInputSource, error) {
spec.URL = strings.TrimSpace(spec.URL)
spec.Token = strings.TrimSpace(spec.Token)
spec.DocType = strings.ToLower(strings.TrimSpace(spec.DocType))
spec.FileExtension = strings.ToLower(strings.TrimSpace(spec.FileExtension))
if spec.Token == "" && spec.URL == "" {
return spec, driveExportInputSource{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "either --url or --token is required").WithParam("--url")
}
if spec.Token != "" && spec.URL != "" {
return spec, driveExportInputSource{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url and --token are mutually exclusive").WithParam("--url")
}
source := driveExportInputSource{
Type: spec.DocType,
Token: spec.Token,
Param: "--token",
}
rawInput := spec.Token
inputParam := "--token"
if spec.URL != "" {
rawInput = spec.URL
inputParam = "--url"
}
if ref, ok := common.ParseResourceURL(rawInput); ok {
refType := normalizeDriveExportDocType(ref.Type)
source = driveExportInputSource{
Type: refType,
Token: ref.Token,
Param: inputParam,
}
spec.Token = ref.Token
if refType != "wiki" {
if !isDriveExportDocType(refType) {
return spec, source, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"%s URL type %q is not supported by drive +export; use a doc/docx/sheet/base/slides/wiki URL or token",
inputParam,
ref.Type,
).WithParam(inputParam)
}
if spec.DocType == "wiki" {
return spec, source, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--doc-type wiki conflicts with %s URL type %q",
inputParam,
refType,
).
WithParam("--doc-type").
WithHint("remove --doc-type when passing --url; the CLI will infer %q from the URL", refType)
}
if spec.DocType != "" && spec.DocType != refType {
return spec, source, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--doc-type %q conflicts with %s URL type %q",
spec.DocType,
inputParam,
refType,
).WithParam("--doc-type")
}
spec.DocType = refType
} else if spec.DocType == "wiki" {
spec.DocType = ""
}
return spec, source, nil
}
if strings.Contains(rawInput, "://") {
return spec, source, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported %s URL %q: use a recognized Lark document URL",
inputParam,
rawInput,
).WithParam(inputParam)
}
if spec.URL != "" {
return spec, source, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported --url %q: use a recognized Lark document URL",
spec.URL,
).WithParam("--url")
}
if spec.DocType == "" {
return spec, source, errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-type is required when --token is a bare token (allowed: %s)", driveExportInputDocTypeValues).
WithParam("--doc-type").
WithHint("if you have the original document link, prefer --url <document_url>; if this is a Wiki node token, use --doc-type wiki")
}
if spec.DocType == "wiki" {
source.Type = "wiki"
source.Token = spec.Token
spec.DocType = ""
}
return spec, source, nil
}
func normalizeDriveExportDocType(docType string) string {
switch strings.ToLower(strings.TrimSpace(docType)) {
case "base":
return "bitable"
default:
return strings.ToLower(strings.TrimSpace(docType))
}
}
func isDriveExportDocType(docType string) bool {
switch normalizeDriveExportDocType(docType) {
case "doc", "docx", "sheet", "bitable", "slides":
return true
default:
return false
}
}
func buildDriveExportTaskBody(spec driveExportSpec) map[string]interface{} {
body := map[string]interface{}{
"token": spec.Token,
"type": spec.DocType,
@@ -193,8 +414,13 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec)
if spec.OnlySchema {
body["only_schema"] = true
}
return body
}
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, body)
// createDriveExportTask starts the asynchronous export job and returns its
// ticket for subsequent polling.
func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec) (string, error) {
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, buildDriveExportTaskBody(spec))
if err != nil {
return "", err
}
@@ -206,6 +432,79 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec)
return ticket, nil
}
func resolveDriveExportWikiSource(ctx context.Context, runtime *common.RuntimeContext, spec driveExportSpec, wikiToken string) (driveExportSpec, driveExportWikiResolution, error) {
wikiToken = strings.TrimSpace(wikiToken)
if err := validate.ResourceName(wikiToken, "--token"); err != nil {
return spec, driveExportWikiResolution{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node for export: %s\n", common.MaskToken(wikiToken))
data, err := driveInspectCallWithRetry(ctx, func() (map[string]interface{}, error) {
return runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": wikiToken},
nil,
)
})
if err != nil {
return spec, driveExportWikiResolution{}, err
}
node := common.GetMap(data, "node")
objType := normalizeDriveExportDocType(common.GetString(node, "obj_type"))
objToken := common.GetString(node, "obj_token")
if objType == "" || objToken == "" {
return spec, driveExportWikiResolution{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data (obj_type=%q, obj_token=%q)", objType, objToken)
}
if !isDriveExportDocType(objType) {
return spec, driveExportWikiResolution{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"wiki resolved to %q, but drive +export only supports doc, docx, sheet, bitable, and slides",
objType,
).WithParam("--token")
}
if spec.DocType != "" && spec.DocType != objType {
return spec, driveExportWikiResolution{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"wiki resolved to %q, but --doc-type is %q; use --doc-type %s",
objType,
spec.DocType,
objType,
).WithParam("--doc-type")
}
spec.Token = objToken
spec.DocType = objType
if err := validateDriveExportNormalizedSpec(spec); err != nil {
return spec, driveExportWikiResolution{}, err
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
return spec, driveExportWikiResolution{
Resolved: true,
WikiToken: wikiToken,
ObjToken: objToken,
ObjType: objType,
}, nil
}
func createDriveExportTaskResolvingWiki(ctx context.Context, runtime *common.RuntimeContext, spec driveExportSpec, source driveExportInputSource) (string, driveExportSpec, driveExportWikiResolution, error) {
if source.Type == "wiki" {
resolvedSpec, resolution, err := resolveDriveExportWikiSource(ctx, runtime, spec, source.Token)
if err != nil {
return "", spec, resolution, err
}
ticket, err := createDriveExportTask(runtime, resolvedSpec)
return ticket, resolvedSpec, resolution, err
}
ticket, err := createDriveExportTask(runtime, spec)
if err != nil {
return "", spec, driveExportWikiResolution{}, err
}
return ticket, spec, driveExportWikiResolution{}, nil
}
// getDriveExportStatus fetches the current backend state for a previously
// created export task.
func getDriveExportStatus(runtime *common.RuntimeContext, token, ticket string) (driveExportStatus, error) {

View File

@@ -33,10 +33,36 @@ func TestValidateDriveExportSpec(t *testing.T) {
name: "markdown docx ok",
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "markdown"},
},
{
name: "docx url infers doc type",
spec: driveExportSpec{URL: "https://example.feishu.cn/docx/docxURL123", FileExtension: "pdf"},
},
{
name: "wiki url can defer doc type until resolution",
spec: driveExportSpec{URL: "https://example.feishu.cn/wiki/wikiURL123", FileExtension: "pdf"},
},
{
name: "wiki url with doc-type wiki can defer doc type until resolution",
spec: driveExportSpec{URL: "https://example.feishu.cn/wiki/wikiURL123", DocType: "wiki", FileExtension: "pdf"},
},
{
name: "wiki token with doc-type wiki can defer doc type until resolution",
spec: driveExportSpec{Token: "wiki123", DocType: "wiki", FileExtension: "pdf"},
},
{
name: "bare token requires doc type",
spec: driveExportSpec{Token: "docx123", FileExtension: "pdf"},
wantErr: "--doc-type is required",
},
{
name: "markdown non docx rejected",
spec: driveExportSpec{Token: "doc123", DocType: "doc", FileExtension: "markdown"},
wantErr: "only supports --doc-type docx",
wantErr: "cannot be exported as markdown",
},
{
name: "docx csv rejected",
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "csv"},
wantErr: "cannot be exported as csv",
},
{
name: "csv without sub id rejected",
@@ -72,17 +98,27 @@ func TestValidateDriveExportSpec(t *testing.T) {
{
name: "base non bitable rejected",
spec: driveExportSpec{Token: "sheet123", DocType: "sheet", FileExtension: "base"},
wantErr: "only supports --doc-type bitable",
wantErr: "cannot be exported as base",
},
{
name: "sheet pdf rejected",
spec: driveExportSpec{Token: "sheet123", DocType: "sheet", FileExtension: "pdf"},
wantErr: "cannot be exported as pdf",
},
{
name: "bitable pdf rejected",
spec: driveExportSpec{Token: "base123", DocType: "bitable", FileExtension: "pdf"},
wantErr: "cannot be exported as pdf",
},
{
name: "pptx non slides rejected",
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "pptx"},
wantErr: "only supports --doc-type slides",
wantErr: "cannot be exported as pptx",
},
{
name: "slides csv rejected",
spec: driveExportSpec{Token: "slides123", DocType: "slides", FileExtension: "csv"},
wantErr: "slides only supports",
wantErr: "cannot be exported as csv",
},
{
name: "unknown doc type rejected",
@@ -113,6 +149,29 @@ func TestValidateDriveExportSpec(t *testing.T) {
}
}
func TestValidateDriveExportUnsupportedFormatHasHint(t *testing.T) {
t.Parallel()
err := validateDriveExportSpec(driveExportSpec{
Token: "docx123",
DocType: "docx",
FileExtension: "csv",
})
if err == nil {
t.Fatal("expected unsupported format error, got nil")
}
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if valErr.Param != "--file-extension" {
t.Fatalf("param = %q, want --file-extension", valErr.Param)
}
if !strings.Contains(valErr.Hint, "docx, pdf, markdown") || !strings.Contains(valErr.Hint, "--url") {
t.Fatalf("hint = %q, want allowed formats and URL retry guidance", valErr.Hint)
}
}
func TestDriveExportMarkdownWritesFile(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
fetchStub := &httpmock.Stub{
@@ -440,6 +499,76 @@ func TestDriveExportMarkdownRejectsMissingDocumentContent(t *testing.T) {
}
}
func TestDriveExportURLInfersDocType(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"ticket": "tk_url"},
},
}
reg.Register(createStub)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_url",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": map[string]interface{}{
"job_status": 0,
"file_token": "box_url",
"file_name": "url-report",
"file_extension": "pdf",
"type": "docx",
"file_size": 3,
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/file/box_url/download",
Status: 200,
RawBody: []byte("pdf"),
Headers: http.Header{
"Content-Type": []string{"application/pdf"},
"Content-Disposition": []string{`attachment; filename="url-report.pdf"`},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
driveExportPollAttempts, driveExportPollInterval = 1, 0
t.Cleanup(func() {
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--url", "https://example.feishu.cn/docx/docxURL123",
"--file-extension", "pdf",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var createBody map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
t.Fatalf("unmarshal export_tasks body: %v", err)
}
if createBody["token"] != "docxURL123" {
t.Fatalf("export_tasks body token = %v, want token from URL", createBody["token"])
}
if createBody["type"] != "docx" {
t.Fatalf("export_tasks body type = %v, want inferred docx", createBody["type"])
}
}
func TestDriveExportAsyncSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
@@ -510,6 +639,266 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
}
}
func TestDriveExportWikiURLResolvesBeforeAsyncTask(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "docx",
"obj_token": "docxResolved",
},
},
},
})
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"ticket": "tk_wiki"},
},
}
reg.Register(createStub)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_wiki",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": map[string]interface{}{
"job_status": 0,
"file_token": "box_wiki",
"file_name": "wiki-report",
"file_extension": "pdf",
"type": "docx",
"file_size": 3,
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/file/box_wiki/download",
Status: 200,
RawBody: []byte("pdf"),
Headers: http.Header{
"Content-Type": []string{"application/pdf"},
"Content-Disposition": []string{`attachment; filename="wiki-report.pdf"`},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
driveExportPollAttempts, driveExportPollInterval = 1, 0
t.Cleanup(func() {
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--url", "https://example.feishu.cn/wiki/wikiNode123",
"--file-extension", "pdf",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var createBody map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
t.Fatalf("unmarshal export_tasks body: %v", err)
}
if createBody["token"] != "docxResolved" {
t.Fatalf("export_tasks body token = %v, want resolved docx token", createBody["token"])
}
if createBody["type"] != "docx" {
t.Fatalf("export_tasks body type = %v, want docx", createBody["type"])
}
if !strings.Contains(stdout.String(), `"wiki_token": "wikiNode123"`) {
t.Fatalf("stdout missing wiki token context: %s", stdout.String())
}
}
func TestDriveExportBareWikiTypeResolvesBeforeAsyncTask(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "docx",
"obj_token": "docxResolved",
},
},
},
})
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"ticket": "tk_wiki_token"},
},
}
reg.Register(createStub)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_wiki_token",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": map[string]interface{}{
"job_status": 0,
"file_token": "box_wiki_token",
"file_name": "wiki-token-report",
"file_extension": "pdf",
"type": "docx",
"file_size": 3,
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/file/box_wiki_token/download",
Status: 200,
RawBody: []byte("pdf"),
Headers: http.Header{
"Content-Type": []string{"application/pdf"},
"Content-Disposition": []string{`attachment; filename="wiki-token-report.pdf"`},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
driveExportPollAttempts, driveExportPollInterval = 1, 0
t.Cleanup(func() {
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "wikiNodeBare",
"--doc-type", "wiki",
"--file-extension", "pdf",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var createBody map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
t.Fatalf("unmarshal export_tasks body: %v", err)
}
if createBody["token"] != "docxResolved" {
t.Fatalf("export_tasks body token = %v, want resolved docx token", createBody["token"])
}
if createBody["type"] != "docx" {
t.Fatalf("export_tasks body type = %v, want resolved docx type", createBody["type"])
}
if !strings.Contains(stdout.String(), `"wiki_token": "wikiNodeBare"`) {
t.Fatalf("stdout missing wiki token context: %s", stdout.String())
}
}
func TestDriveExportBareWikiTokenFileTokenInvalidDoesNotFallback(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig())
firstCreate := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Status: 404,
Body: map[string]interface{}{
"code": 1069914,
"msg": "file token invalid",
"log_id": "20260708000000TEST",
},
BodyFilter: func(body []byte) bool {
return strings.Contains(string(body), `"token":"wikiNodeBare"`)
},
}
reg.Register(firstCreate)
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
driveExportPollAttempts, driveExportPollInterval = 1, 0
t.Cleanup(func() {
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "wikiNodeBare",
"--doc-type", "docx",
"--file-extension", "pdf",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("expected file token invalid error, got nil")
}
if len(firstCreate.CapturedBody) == 0 {
t.Fatal("first export task request was not sent with the original token")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed API error, got %T: %v", err, err)
}
if problem.Code != 1069914 {
t.Fatalf("error code = %d, want 1069914", problem.Code)
}
if strings.Contains(stderr.String(), "Resolving wiki node for export") {
t.Fatalf("stderr unexpectedly contains wiki resolution log: %s", stderr.String())
}
}
func TestDriveExportWikiResolvedTypeMismatch(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "sheet",
"obj_token": "shtResolved",
},
},
},
})
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "https://example.feishu.cn/wiki/wikiSheet123",
"--doc-type", "docx",
"--file-extension", "pdf",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected type mismatch error, got nil")
}
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if !strings.Contains(valErr.Message, `wiki resolved to "sheet"`) {
t.Fatalf("error message = %q, want resolved type", valErr.Message)
}
}
// TestDriveExportEmptyOutputDirDownloadsToCwd guards the export refactor: an
// explicit empty --output-dir must still download to the current directory
// (normalized to "."), not trigger the export-only no-download path that the

View File

@@ -54,15 +54,21 @@ type ImportParams struct {
FolderToken string
Name string
TargetToken string
// FileExtension optionally overrides the extension inferred from File's
// name. Leave empty to infer from File (the default). Callers that have
// sniffed the file's real container use this to correct a mislabeled name
// so the backend receives the true format.
FileExtension string
}
func (p ImportParams) spec() driveImportSpec {
return driveImportSpec{
FilePath: p.File,
DocType: strings.ToLower(p.DocType),
FolderToken: p.FolderToken,
Name: p.Name,
TargetToken: p.TargetToken,
FilePath: p.File,
DocType: strings.ToLower(p.DocType),
FolderToken: p.FolderToken,
Name: p.Name,
TargetToken: p.TargetToken,
EffectiveExt: strings.TrimPrefix(strings.ToLower(p.FileExtension), "."),
}
}
@@ -127,7 +133,7 @@ func RunImport(ctx context.Context, runtime *common.RuntimeContext, p ImportPara
}
// Step 1: Upload file as media
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec)
if uploadErr != nil {
return uploadErr
}
@@ -203,14 +209,14 @@ func preflightDriveImportFile(fio fileio.FileIO, spec *driveImportSpec) (int64,
if !info.Mode().IsRegular() {
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", spec.FilePath).WithParam("--file")
}
if err = validateDriveImportFileSize(spec.FilePath, spec.DocType, info.Size()); err != nil {
if err = validateDriveImportFileSize(spec.FileExtension(), spec.DocType, info.Size()); err != nil {
return 0, err
}
return info.Size(), nil
}
func appendDriveImportUploadDryRun(dry *common.DryRunAPI, spec driveImportSpec, fileSize int64) {
extra, err := buildImportMediaExtra(spec.FilePath, spec.DocType)
extra, err := buildImportMediaExtra(spec.FileExtension(), spec.DocType)
if err != nil {
extra = fmt.Sprintf(`{"obj_type":"%s","file_extension":"%s"}`, spec.DocType, spec.FileExtension())
}

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