Compare commits

...

61 Commits

Author SHA1 Message Date
wenzhuozhen
b70ca02067 docs: align formula verify refs with file names 2026-07-03 15:09:13 +08:00
wenzhuozhen
f2f0fe2be7 docs: tighten formula verify workflow guidance 2026-07-03 14:16:32 +08:00
xiongyuanwen-byted
a814c8cb43 fix(sheets): satisfy errs-no-bare-wrap forbidigo and errorlint rules from main
main introduced the errs-no-bare-wrap forbidigo rule and errorlint
coverage that flag 27 issues in existing sheets code after the merge:

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

Also reword two doc comments in lark_sheet_history_revert.go that used
'' for an empty shell string: gofmt (Go 1.19+) rewrites '' in doc
comments to a curly quote, leaving the file permanently unformatted.
2026-07-03 11:14:30 +08:00
xiongyuanwen-byted
fa7e4ffede feat(sheets): accept local_office_ token prefix for image parent_type
The synthetic token prefix for imported office spreadsheets is being
renamed from fake_office_ to local_office_. Accept either prefix when
mapping a spreadsheet token to the drive media parent_type so image
uploads keep working across the rename (main package and backward
compat copy).
2026-07-03 11:14:21 +08:00
zhanglei-1103
7db899db01 docs: add mindnote guidance to lark-doc (#1581) 2026-07-03 10:50:25 +08:00
xiongyuanwen-byted
df1e7c01dc docs(sheets): dedupe +changeset-get flag def and skill reference entry 2026-07-03 10:22:55 +08:00
liangshuo-1
c2d6038aae chore: release v1.0.64 (#1725) 2026-07-02 21:58:37 +08:00
Lekko
efa3439e01 fix(update): increase npm registry fetch timeout from 5s to 15s (#1724)
The update checker fetches https://registry.npmjs.org/@larksuite/cli/latest
with a 5-second HTTP client timeout. Under high-latency network conditions
(TUN-mode proxies, VPNs, transcontinental routes), TLS handshake alone can
take 4-6 seconds, causing the check to fail with:

  context deadline exceeded (Client.Timeout exceeded while awaiting headers)

Measured example behind a Clash TUN proxy (US node from China):
  DNS resolve:    ~0ms (fake-ip)
  TCP connect:    ~0ms (local TUN)
  TLS handshake:  4.3-5.9s  <-- bottleneck
  Total:          4.7-6.3s

curl succeeds because it has no default connect timeout, but the Go HTTP
client with Timeout=5s is too tight. The registry endpoint returns a tiny
JSON payload (<1KB), so 15s is more than enough headroom while still
failing fast on genuinely unreachable networks.

Co-authored-by: 王伟达 <weida.wang@m.com>
2026-07-02 21:36:44 +08:00
zhengzhijiej-tech
6857876d85 Feat/lark sheets develop wzz (#1719)
* feat(sheets): add +changeset-get shortcut for changeset review

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

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

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

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

* feat: 同步 spec 修改

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

* feat: 移除 ppe 环境请求头

---------

Co-authored-by: wenzhuozhen <wenzhuozhen@bytedance.com>
2026-07-02 20:03:28 +08:00
91-enjoy
9f150670f3 feat: upgrade card send (#1688) 2026-07-02 19:46:11 +08:00
liangshuo-1
578e2db4e0 fix: point permission-apply link at official /page/scope-apply entry (#1722) 2026-07-02 19:26:00 +08:00
Paulazaaza-dev
94139751d3 Add detailed command-to-reference mapping for the approval skill. (#1630) 2026-07-02 19:08:19 +08:00
xiongyuanwen-byted
7cca3f39cd docs(sheets): tighten number-vs-text guidance and dedupe write-cells reference 2026-07-02 18:40:13 +08:00
shifengjuan-dev
8c3ed5d224 feat(im): add +chat-members-list shortcut for member listing (#1398)
Add a dedicated +chat-members-list shortcut that lists chat members,
returning users and bots in separate users[] / bots[] buckets. It owns its
pagination loop (mirroring the paginateLoop conventions: per-page log line,
--page-limit cap, non-advancing-token guard) because the list_members
response is multi-bucket: the generic --page-all merger is built for
single-array responses and would silently drop the bots[] bucket and the
final-page truncations[] signal.

Highlights:
- merges users[] and bots[] across pages; takes truncations[] / has_more /
  page_token from the last page so a server-side cap is never hidden
- surfaces truncations[] with a loud stderr warning when the server caps a
  bucket due to security config (the list is incomplete)
- --member-types filter (user/bot), --member-id-type, and the standard
  --page-all / --page-limit / --page-token flags
- with --page-all and no explicit --page-size, uses the max page size to
  minimize round-trips
- docs: SKILL.md Shortcuts table + references/lark-im-chat-members-list.md
2026-07-02 18:09:51 +08:00
zhanghuanxu
c982df4cf0 test: inline slide lint XML fixtures 2026-07-02 18:02:04 +08:00
zhanghuanxu
fb5ae41bca docs: remove lark slides template toolchain 2026-07-02 18:02:04 +08:00
zhanghuanxu
87e872a4c1 docs: add lark slides generation constraints 2026-07-02 18:02:04 +08:00
syh-cpdsss
ddc0f2a521 feat(okr): semi-plain text format with mention position preservation + patch shortcut (#1671)
Add semi-plain text (simple) format for OKR content I/O, and a new `+patch`
shortcut for incremental updates to objectives and key results.
2026-07-02 17:45:00 +08:00
HanShaoshuai-k
440867f1b4 fix: reduce public content token false positives 2026-07-02 17:39:49 +08:00
fangshuyu-768
d0cde9a414 Improve secure label error handling (#1707)
* Improve secure label error handling

* Address secure label review feedback
2026-07-02 15:41:02 +08:00
xiongyuanwen-byted
f41e6c4d74 docs(sheets): type by data nature, add pre-write reference column and chart/cond-format/filter rows
- SKILL.md quick-reference: add a "read before acting" column pointing each
  intent at its reference doc; add chart / cond-format / filter rows.
- Reframe number-vs-text decision to follow the data's nature (measure vs
  identifier), not whether the current task happens to sort/sum; a
  leaderboard/report "display only" use does not make a percentage text.
- write-cells reference: mirror the same rule and the +cells-set fallback
  for layouts +table-put cannot express.
2026-07-02 13:41:09 +08:00
SunPeiYang996
075b34f9a3 chore: lark-cli docs support reference_map (#1690)
* chore:lark-cli docs support reference_map

* fix: address docs reference map review feedback

* test: harden docs reference map CI assertions
2026-07-02 13:07:42 +08:00
fangshuyu-768
3788405256 fix(doc): align word statistics compound tokens (#1706) 2026-07-02 11:43:22 +08:00
liangshuo-1
462358a746 install: warn instead of failing when checksums.txt is missing (#1712) 2026-07-01 22:50:56 +08:00
liangshuo-1
ad4d3cb874 chore: release v1.0.62 (#1710) 2026-07-01 21:41:14 +08:00
zhicong666-bytedance
171778951d feat(vc): add meeting message send shortcut (#1643)
* feat(vc): add meeting message send shortcut

* docs: refine vc meeting emoji guidance

* fix(vc): validate meeting message send conflicts

* test: add vc meeting message dry-run e2e

* fix(vc): validate meeting message send limits
2026-07-01 20:51:59 +08:00
fangshuyu-768
a6797ac2e4 Improve drive batch failure handling (#1703) 2026-07-01 18:15:14 +08:00
fangshuyu-768
d852ab311b feat(doc): add document word statistics helper (#1697) 2026-07-01 18:03:28 +08:00
HanShaoshuai-k
e8bfbab4a5 fix: reduce public content credential false positives (#1700) 2026-07-01 17:46:33 +08:00
xiongyuanwen-byted
77dda3ddaa docs(sheets): clarify number-vs-text typing and copy-to-range template guidance in references 2026-07-01 16:22:33 +08:00
zgz2048
3bda9e17de fix: support field create json array input (#1661) 2026-07-01 16:08:55 +08:00
ILUO
e753b15d84 fix: expose completion state in my tasks output (#1641)
* fix: expose completion state in my tasks output

* test: cover my tasks pretty completion state
2026-07-01 15:41:57 +08:00
dc-bytedance
bdffffb368 feat: interactive upgrade prompt for bare lark-cli (#1498) 2026-07-01 15:07:18 +08:00
dc-bytedance
ec6fdc9b30 feat: fail closed when checksums.txt is missing during install (#1503) 2026-07-01 13:23:23 +08:00
xiongyuanwen-byted
4318f57c12 docs(sheets): bump lark-sheets skill version to 3.0.1 2026-07-01 13:01:14 +08:00
xiongyuanwen-byted
082625d2f1 docs(sheets): clarify workbook-import over read-then-recreate in skill 2026-07-01 10:42:32 +08:00
xiongyuanwen-byted
906826d4a1 fix(sheets): lower cells-set --max-cells default to 50000 2026-07-01 10:42:31 +08:00
liangshuo-1
775ee5a501 chore: release v1.0.61 (#1695) 2026-06-30 22:18:39 +08:00
liujinkun2025
214318aa02 fix: support bot identity for drive search (#1670) 2026-06-30 21:59:51 +08:00
liangshuo-1
6f2cddfce1 fix(identity): correct identity diagnosis under external credential providers (#1693) 2026-06-30 21:56:03 +08:00
wenzhuozhen
aa1a065802 Merge pull request #1653 from larksuite/feat/sheet-history-revert
feat(sheets): add +history-list / +history-revert / +history-revert-status shortcuts
2026-06-30 15:22:44 +08:00
wenzhuozhen
017d752ed9 docs(sheets): sync history skill reference required badges from spec
Companion to commit 9fa73312 (transaction-id) and 6ca35b06
(history-version-id): the two flag tables in
skills/lark-sheets/references/lark-sheets-history.md still showed
'optional' even though the canonical contract — and shortcuts/sheets/data/
flag-defs.json — already moved to 'required'. The earlier syncs only
picked up the data file from spec; the skill markdown drift slipped
through. Pull in the spec-side regenerated reference (ee/sheet-skill-spec
@9ca814d) so the human-readable doc matches the wire contract.
2026-06-30 14:42:43 +08:00
wenzhuozhen
909f78ed58 fix(sheets): make +history-revert-status --transaction-id cobra-required (match +history-revert)
Companion to commit 6ca35b06: same gating model now applies to both history
receipts.
- shortcuts/sheets/lark_sheet_history_revert.go: transactionIDFlag.Required=true.
  Validate keeps a trim/empty-string guard for '--transaction-id ""'.
- shortcuts/sheets/data/flag-defs.json: +history-revert-status --transaction-id
  required: optional -> required (synced from sheet-skill-spec @9ca814d).
- shortcuts/sheets/flag_defs_gen.go: regenerated.
- shortcuts/sheets/lark_sheet_history_test.go:
  TestHistoryRevert_MissingRequiredFlag/+history-revert-status moved to the
  cobra "required flag(s)" text contract (the test rig invokes the shortcut
  via cmd.Execute, which sees the raw cobra error directly without the
  dispatcher's typed wrap). Drop now-unused `errors` and `errs` imports.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Adds benchmarks + a boundary test as regression guards.

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

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

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

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

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

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

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

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

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

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

View File

@@ -2,6 +2,62 @@
All notable changes to this project will be documented in this file.
## [v1.0.64] - 2026-07-02
### Features
- **im**: Upgrade card send to Card 2.0 with full component reference (#1688)
- **im**: Add `+chat-members-list` shortcut for member listing (#1398)
- **okr**: Semi-plain text format with mention position preservation and `patch` shortcut (#1671)
### Bug Fixes
- **cli**: Point permission-apply link at official `/page/scope-apply` entry (#1722)
- **cli**: Improve secure label error handling (#1707)
- **cli**: Reduce public content token false positives
- **cli**: Increase npm registry fetch timeout to 15s during update check (#1724)
- **doc**: Align word statistics compound tokens (#1706)
### Documentation
- **approval**: Add detailed command-to-reference mapping for the approval skill (#1630)
- **doc**: Support `reference_map` in docs (#1690)
- **slides**: Refresh generation guidance — add constraints, drop template toolchain, and inline lint XML fixtures
## [v1.0.62] - 2026-07-01
### Features
- **vc**: Add meeting message send shortcut (#1643)
- **doc**: Add document word statistics helper (#1697)
- **cli**: Interactive upgrade prompt for bare `lark-cli` invocation (#1498)
- **install**: Fail closed when `checksums.txt` is missing during install (#1503)
### Bug Fixes
- **drive**: Improve batch failure handling for push/pull/sync (#1703)
- **base**: Support JSON array input for field create (#1661)
- **task**: Expose completion state in `my tasks` output (#1641)
- **cli**: Reduce public content credential false positives (#1700)
## [v1.0.61] - 2026-06-30
### Features
- **apps**: Add `db`, `file`, `openapi-key` and observability shortcuts (#1596)
- **identity**: Add `whoami` command showing effective identity (#1666)
- **docs**: Add reference map flags (#1547)
### Bug Fixes
- **identity**: Correct identity diagnosis under external credential providers (#1693)
- **cli**: Harden git credential error handling (#1676)
### Documentation
- **doc**: Guide document copy skill usage (#1673)
- **doc**: Fix lark-doc media token examples (#1662)
## [v1.0.60] - 2026-06-29
### Features
@@ -1299,6 +1355,9 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
[v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58

View File

@@ -214,6 +214,9 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
groupRootCommands(rootCmd)
installUnknownSubcommandGuard(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)
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
pruneForStrictMode(rootCmd, mode)

View File

@@ -129,7 +129,10 @@ func doctorRun(opts *DoctorOptions) error {
if diagnostics.Bot.Available || diagnostics.User.Available {
checks = append(checks, pass("identity_ready", "at least one identity is available"))
} else {
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
// No hint: this only summarizes the two checks above, which already carry
// the source-appropriate remediation. A command here would be redundant,
// or wrong (`auth status` is blocked under an external provider).
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
}
// ── 4 & 5. Endpoint reachability ──

View File

@@ -4,14 +4,19 @@
package doctor
import (
"bytes"
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/spf13/cobra"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
@@ -140,14 +145,84 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
}
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
t.Helper()
if got := findCheck(t, checks, name); got.Status != status {
t.Fatalf("%s status = %q, want %q", name, got.Status, status)
}
}
func findCheck(t *testing.T, checks []checkResult, name string) checkResult {
t.Helper()
for _, check := range checks {
if check.Name == name {
if check.Status != status {
t.Fatalf("%s status = %q, want %q", name, check.Status, status)
}
return
return check
}
}
t.Fatalf("check %q not found in %#v", name, checks)
return checkResult{}
}
type fakeExtProvider struct {
name string
account *extcred.Account
}
func (p *fakeExtProvider) Name() string { return p.name }
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return p.account, nil
}
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}
// Under an external credential provider with no usable identity, the
// identity_ready hint must not point at `auth status` (blocked there); the
// per-identity checks already carry the source-appropriate escalation.
func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{{Name: "default", AppId: "cli_x", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
// Provider serves neither identity: bot unsupported, user supported but not
// signed in → both unavailable → identity_ready fails.
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser)}
cred := credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}},
nil, nil,
func() (*http.Client, error) { return nil, nil },
)
out := &bytes.Buffer{}
f := &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
t.Fatalf("doctorRun() = nil, want failure when no identity is available")
}
var got struct {
Checks []checkResult `json:"checks"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v\n%s", err, out.String())
}
ready := findCheck(t, got.Checks, "identity_ready")
if ready.Status != "fail" {
t.Fatalf("identity_ready status = %q, want fail", ready.Status)
}
// The summary defers to the per-identity checks; it carries no hint of its
// own (a command here would be wrong under an external provider).
if ready.Hint != "" {
t.Fatalf("identity_ready should carry no hint, got %q", ready.Hint)
}
user := findCheck(t, got.Checks, "user_identity")
if !strings.Contains(user.Hint, "external") || strings.Contains(user.Hint, "auth login") {
t.Fatalf("user_identity hint not external-appropriate: %q", user.Hint)
}
}

90
cmd/root_upgrade.go Normal file
View File

@@ -0,0 +1,90 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bufio"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra"
)
// runRootUpgrade locates the registered `update` subcommand and runs it, so the
// interactive root-command upgrade reuses exactly `lark-cli update` behavior
// (install-method detection, output, error handling). Package-level var so
// tests can stub it and avoid real network / self-update.
var runRootUpgrade = func(cmd *cobra.Command) {
for _, c := range cmd.Root().Commands() {
if c.Name() == "update" && c.RunE != nil {
_ = c.RunE(c, nil) // update prints its own output/errors; swallow here
return
}
}
}
// isBareRootInvocation reports whether this is a bare `lark-cli` (no subcommand,
// no flags) — the only invocation that triggers the interactive upgrade prompt.
// Mirrors unknownSubcommandRunE's "bare group prints help" branch: args empty
// AND no flag tokens in the raw invocation.
func isBareRootInvocation(args []string) bool {
return len(args) == 0 && len(flagTokensInArgs(rawInvocationArgs)) == 0
}
// readYes reads one line and reports whether it is an affirmative y/yes.
// EOF / empty / anything else → false (default No, matching the [y/N] prompt).
func readYes(r io.Reader) bool {
line, _ := bufio.NewReader(r).ReadString('\n')
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes":
return true
default:
return false
}
}
// offerRootUpgrade prompts for an interactive upgrade when running bare
// `lark-cli` in an interactive terminal with a cached newer version. Every
// failure is swallowed — it must never affect help output or the exit code.
func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
ios := f.IOStreams
// Gates 1/2/3: need to read stdin AND show the prompt on stderr, and require
// stdout TTY too so this only fires in a pure foreground terminal session.
if !ios.IsTerminal || !ios.OutIsTerminal || !ios.StderrIsTerminal {
return
}
// Gate 4: cached newer version. CheckCached applies opt-out (shouldSkip)
// and the IsNewer/semver validation chain; it reads the on-disk cache that
// the 24h-throttled RefreshCache maintains (CheckCached itself has no TTL).
info := update.CheckCached(build.Version)
if info == nil {
return
}
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
if !readYes(ios.In) {
return
}
runRootUpgrade(cmd)
}
// installRootUpgradePrompt wraps the root command's RunE (set to
// unknownSubcommandRunE by installUnknownSubcommandGuard) so a bare `lark-cli`
// invocation offers an interactive upgrade before printing help. Non-bare
// invocations are passed straight through, unchanged.
func installRootUpgradePrompt(f *cmdutil.Factory, root *cobra.Command) {
inner := root.RunE
if inner == nil {
return
}
root.RunE = func(cmd *cobra.Command, args []string) error {
if isBareRootInvocation(args) {
offerRootUpgrade(f, cmd)
}
return inner(cmd, args)
}
}

191
cmd/root_upgrade_test.go Normal file
View File

@@ -0,0 +1,191 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/cobra"
)
func writeUpdateState(t *testing.T, dir, latest string) {
t.Helper()
data := fmt.Sprintf(`{"latest_version":%q,"checked_at":%d}`, latest, time.Now().Unix())
if err := os.WriteFile(filepath.Join(dir, "update-state.json"), []byte(data), 0o644); err != nil {
t.Fatal(err)
}
}
func TestReadYes(t *testing.T) {
cases := map[string]bool{
"y\n": true, "Y\n": true, "yes\n": true, "YES\n": true, " y \n": true,
"n\n": false, "\n": false, "": false, "nope\n": false, "yeah\n": false,
}
for in, want := range cases {
if got := readYes(strings.NewReader(in)); got != want {
t.Errorf("readYes(%q) = %v, want %v", in, got, want)
}
}
}
func TestIsBareRootInvocation(t *testing.T) {
orig := rawInvocationArgs
t.Cleanup(func() { rawInvocationArgs = orig })
rawInvocationArgs = nil
if !isBareRootInvocation([]string{}) {
t.Error("empty args + no raw flag tokens should be bare")
}
rawInvocationArgs = []string{"--profile", "x"}
if isBareRootInvocation([]string{}) {
t.Error("flag token present → not bare")
}
rawInvocationArgs = nil
if isBareRootInvocation([]string{"im"}) {
t.Error("positional arg → not bare")
}
}
func TestOfferRootUpgrade(t *testing.T) {
origV := build.Version
build.Version = "1.0.0" // release version so shouldSkip()==false
t.Cleanup(func() { build.Version = origV })
origRun := runRootUpgrade
t.Cleanup(func() { runRootUpgrade = origRun })
// This test builds a Factory literal (no NewDefault), so it never runs
// workspace detection; pin the process-global workspace to Local so
// statePath() resolves under LARKSUITE_CLI_CONFIG_DIR rather than a stale
// subdir inherited from a prior test in the package.
origWS := core.CurrentWorkspace()
t.Cleanup(func() { core.SetCurrentWorkspace(origWS) })
core.SetCurrentWorkspace(core.WorkspaceLocal)
cases := []struct {
name string
in, out, err bool
input string
latest string // "" → no state file (CheckCached nil)
optOut bool
wantPrompt, wantRun bool
}{
{"all-tty+y", true, true, true, "y\n", "2.0.0", false, true, true},
{"all-tty+yes", true, true, true, "yes\n", "2.0.0", false, true, true},
{"all-tty+n", true, true, true, "n\n", "2.0.0", false, true, false},
{"all-tty+empty", true, true, true, "\n", "2.0.0", false, true, false},
{"all-tty+eof", true, true, true, "", "2.0.0", false, true, false},
{"stdin-not-tty", false, true, true, "y\n", "2.0.0", false, false, false},
{"stdout-not-tty", true, false, true, "y\n", "2.0.0", false, false, false},
{"stderr-not-tty", true, true, false, "y\n", "2.0.0", false, false, false},
{"no-newer-version", true, true, true, "y\n", "", false, false, false},
{"already-latest", true, true, true, "y\n", "1.0.0", false, false, false}, // post-upgrade: current == cached latest → no prompt
{"cache-older-than-current", true, true, true, "y\n", "0.9.0", false, false, false},
{"opt-out", true, true, true, "y\n", "2.0.0", true, false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
// Clear env that update.shouldSkip treats as "suppress" so the
// test is deterministic regardless of host (GitHub Actions sets
// CI=true, which would otherwise suppress the prompt).
t.Setenv("CI", "")
t.Setenv("BUILD_NUMBER", "")
t.Setenv("RUN_ID", "")
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "")
if tc.latest != "" {
writeUpdateState(t, dir, tc.latest)
}
if tc.optOut {
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
}
called := false
runRootUpgrade = func(*cobra.Command) { called = true }
var errBuf bytes.Buffer
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader(tc.input),
Out: &bytes.Buffer{},
ErrOut: &errBuf,
IsTerminal: tc.in,
OutIsTerminal: tc.out,
StderrIsTerminal: tc.err,
}}
offerRootUpgrade(f, &cobra.Command{})
gotPrompt := strings.Contains(errBuf.String(), "available")
if gotPrompt != tc.wantPrompt {
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
}
if called != tc.wantRun {
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
}
})
}
}
func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
orig := rawInvocationArgs
t.Cleanup(func() { rawInvocationArgs = orig })
rawInvocationArgs = nil
innerCalls := 0
root := &cobra.Command{Use: "lark-cli"}
root.RunE = func(cmd *cobra.Command, args []string) error { innerCalls++; return nil }
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
}}
installRootUpgradePrompt(f, root)
if err := root.RunE(root, []string{}); err != nil {
t.Fatalf("bare RunE err = %v", err)
}
if err := root.RunE(root, []string{"im"}); err != nil {
t.Fatalf("non-bare RunE err = %v", err)
}
if innerCalls != 2 {
t.Errorf("inner RunE should run for both bare and non-bare, got %d", innerCalls)
}
}
// TestRunRootUpgradeDispatchesToUpdate covers the real runRootUpgrade dispatch
// path (not the stub used elsewhere): from any command it must locate the
// registered "update" subcommand via cmd.Root() and invoke its RunE.
func TestRunRootUpgradeDispatchesToUpdate(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
ran := 0
root.AddCommand(&cobra.Command{Use: "update", RunE: func(*cobra.Command, []string) error { ran++; return nil }})
child := &cobra.Command{Use: "im"}
root.AddCommand(child)
runRootUpgrade(child) // child.Root() resolves to root, which has "update"
if ran != 1 {
t.Errorf("runRootUpgrade should locate and run update's RunE once, got %d", ran)
}
}
// TestInstallRootUpgradePromptNilInnerNoop covers the inner == nil guard:
// when root has no RunE, installRootUpgradePrompt must not wrap it.
func TestInstallRootUpgradePromptNilInnerNoop(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"} // RunE is nil
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
}}
installRootUpgradePrompt(f, root)
if root.RunE != nil {
t.Error("installRootUpgradePrompt must not wrap a nil RunE (inner==nil guard)")
}
}

View File

@@ -5,8 +5,6 @@ package whoami
import (
"context"
"fmt"
"io"
"github.com/spf13/cobra"
@@ -17,6 +15,13 @@ import (
)
// whoamiResult is the structured output of `lark-cli whoami`.
//
// The self-vs-delegated distinction is carried by `identity`: a bot identity is
// the app acting as itself; a user identity is the app acting *on behalf of* a
// person (calls are attributed to that user, who is not necessarily present).
// onBehalfOf only *names* that person and so appears only once a user is
// resolved — a user identity that is not signed in still has identity "user"
// but no onBehalfOf yet. Do not read "no onBehalfOf" as "self"; read `identity`.
type whoamiResult struct {
Profile string `json:"profile"`
AppID string `json:"appId"`
@@ -26,34 +31,44 @@ type whoamiResult struct {
IdentitySource string `json:"identitySource"`
Available bool `json:"available"`
TokenStatus string `json:"tokenStatus"`
OpenID string `json:"openId,omitempty"`
UserName string `json:"userName,omitempty"`
OnBehalfOf *delegatedUser `json:"onBehalfOf,omitempty"`
Hint string `json:"hint,omitempty"`
}
// delegatedUser is the user a user-identity acts on behalf of.
type delegatedUser struct {
UserName string `json:"userName,omitempty"`
OpenID string `json:"openId,omitempty"`
}
// Options holds inputs for the whoami command.
type Options struct {
Factory *cmdutil.Factory
As string
JSON bool
}
// NewCmdWhoami creates the top-level whoami command. It reports the identity
// that the next API call would actually use (resolved via Factory.ResolveAs),
// together with the active profile, app, and token status. It is local-only:
// no network calls are made.
// together with the active profile, app, and token status. Output is always
// JSON — whoami is consumed by agents. With the built-in credential path it is
// local-only; when an external credential provider manages tokens, resolving
// the identity may contact that provider.
func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
opts := &Options{Factory: f}
cmd := &cobra.Command{
Use: "whoami",
Short: "Show the current effective identity, app, profile, and token status",
Short: "Show the current effective identity, app, profile, and token status (JSON)",
RunE: func(cmd *cobra.Command, args []string) error {
return whoamiRun(cmd, opts)
},
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.AddAPIIdentityFlag(context.Background(), cmd, f, &opts.As)
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
// Output is always JSON. Accept (and ignore) --json so existing
// `whoami --json` callers don't break; hide it to avoid implying a non-JSON
// mode exists.
cmd.Flags().Bool("json", true, "deprecated: output is always JSON")
_ = cmd.Flags().MarkHidden("json")
cmdutil.SetRisk(cmd, "read")
return cmd
}
@@ -67,10 +82,11 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
ctx := cmd.Context()
flagAs := core.Identity(opts.As)
as := f.ResolveAs(ctx, cmd, flagAs)
// Reject an explicit --as that does not resolve to a usable identity, so a
// typo like `--as admin` fails clearly instead of echoing back a bogus
// identity. Keeps the §5.1 invariant (identity is always user or bot) and
// matches how api/service/shortcut commands validate the resolved identity.
// Validate as a real API call does (strict mode, then identity) so whoami
// can't preview an identity the next call would refuse.
if err := f.CheckStrictMode(ctx, as); err != nil {
return err
}
if err := f.CheckIdentity(as, []string{"user", "bot"}); err != nil {
return err
}
@@ -82,11 +98,7 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
)
diag := identitydiag.Diagnose(ctx, f, cfg, false)
res := buildResult(cfg, as, source, diag)
if opts.JSON {
output.PrintJson(f.IOStreams.Out, res)
return nil
}
formatPretty(f.IOStreams.Out, res)
output.PrintJson(f.IOStreams.Out, res)
return nil
}
@@ -94,17 +106,18 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
// Mirrors Factory.ResolveAs precedence: explicit flag wins; otherwise an
// auto-detected result means auto-detect; otherwise a strict-mode forced
// identity means strict-mode; otherwise it came from configured default-as.
// Values are snake_case to match the other enum fields (e.g. tokenStatus).
func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, strictForced core.Identity) string {
if changedAs && (flagAs == core.AsUser || flagAs == core.AsBot) {
return "flag"
}
if autoDetected {
return "auto-detect"
return "auto_detect"
}
if strictForced != "" {
return "strict-mode"
return "strict_mode"
}
return "default-as"
return "default_as"
}
// buildResult maps the resolved identity and local diagnostics into the output.
@@ -122,46 +135,29 @@ func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag iden
Identity: string(as),
IdentitySource: source,
}
// Use the diagnosed hint as-is: it is tailored to the credential source, so
// it never says "auth login" when that is blocked under an external provider.
switch as {
case core.AsBot:
res.Available = diag.Bot.Available
res.TokenStatus = diag.Bot.Status
if !diag.Bot.Available {
res.Hint = "Bot identity not configured. Set app secret or bot token (see `lark-cli config --help`)."
res.Hint = diag.Bot.Hint
}
default: // user
res.Available = diag.User.Available
res.OpenID = diag.User.OpenID
res.UserName = diag.User.UserName
res.TokenStatus = diag.User.TokenStatus
if res.TokenStatus == "" {
res.TokenStatus = "missing"
// Use Status (not the raw TokenStatus) so the vocab matches the bot
// branch: "ready" means usable for both. available stays the canonical
// usable signal; tokenStatus is the readable state behind it.
res.TokenStatus = diag.User.Status
// Set onBehalfOf only when a user is actually resolved; an unresolved
// user identity (not signed in) has no one to act on behalf of yet.
if diag.User.UserName != "" || diag.User.OpenID != "" {
res.OnBehalfOf = &delegatedUser{UserName: diag.User.UserName, OpenID: diag.User.OpenID}
}
if !diag.User.Available {
res.Hint = "No usable user token. Run `lark-cli auth login`."
res.Hint = diag.User.Hint
}
}
return res
}
// formatPretty writes the human-readable one-glance summary.
func formatPretty(w io.Writer, r *whoamiResult) {
fmt.Fprintf(w, "Profile: %s (%s, %s)\n", r.Profile, r.AppID, r.Brand)
fmt.Fprintf(w, "Identity: %s (%s)\n", r.Identity, r.IdentitySource)
if r.Identity == string(core.AsUser) && r.UserName != "" {
if r.OpenID != "" {
fmt.Fprintf(w, "User: %s (%s)\n", r.UserName, r.OpenID)
} else {
fmt.Fprintf(w, "User: %s\n", r.UserName)
}
}
token := r.TokenStatus
if !r.Available && r.Hint != "" {
token = r.TokenStatus + " — " + r.Hint
}
// Write the label and value as separate %s args rather than one combined
// literal. A single label-colon-value literal trips the public-content
// credential scanner as a false-positive credential assignment; splitting
// the args avoids it while producing identical output.
fmt.Fprintf(w, "%s%s\n", "Token: ", token)
}

View File

@@ -5,15 +5,19 @@ package whoami
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/identitydiag"
)
@@ -28,10 +32,10 @@ func TestResolveSource(t *testing.T) {
}{
{"explicit flag user", true, core.AsUser, false, "", "flag"},
{"explicit flag bot", true, core.AsBot, false, "", "flag"},
{"flag auto falls through to auto-detect", true, core.AsAuto, true, "", "auto-detect"},
{"auto detected", false, "", true, "", "auto-detect"},
{"strict mode", false, "", false, core.AsBot, "strict-mode"},
{"default-as", false, "", false, "", "default-as"},
{"flag auto falls through to auto-detect", true, core.AsAuto, true, "", "auto_detect"},
{"auto detected", false, "", true, "", "auto_detect"},
{"strict mode", false, "", false, core.AsBot, "strict_mode"},
{"default_as", false, "", false, "", "default_as"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -46,18 +50,19 @@ func TestResolveSource(t *testing.T) {
func TestBuildResult_UserValid(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "my-app", AppID: "cli_x", Brand: core.BrandLark, DefaultAs: core.AsAuto}
diag := identitydiag.Result{
User: identitydiag.Identity{Available: true, TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
User: identitydiag.Identity{Available: true, Status: "ready", TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
}
r := buildResult(cfg, core.AsUser, "auto-detect", diag)
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
if r.Identity != "user" || r.IdentitySource != "auto-detect" {
if r.Identity != "user" || r.IdentitySource != "auto_detect" {
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
}
if !r.Available || r.TokenStatus != "valid" {
// tokenStatus mirrors the unified Status vocab ("ready"), not the raw "valid".
if !r.Available || r.TokenStatus != "ready" {
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
}
if r.OpenID != "ou_x" || r.UserName != "Alice" {
t.Fatalf("openId/userName = %q/%q", r.OpenID, r.UserName)
if r.OnBehalfOf == nil || r.OnBehalfOf.OpenID != "ou_x" || r.OnBehalfOf.UserName != "Alice" {
t.Fatalf("onBehalfOf = %#v, want Alice/ou_x", r.OnBehalfOf)
}
if r.Hint != "" {
t.Fatalf("hint = %q, want empty", r.Hint)
@@ -70,9 +75,9 @@ func TestBuildResult_UserValid(t *testing.T) {
func TestBuildResult_UserMissingToken(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandLark}
diag := identitydiag.Result{
User: identitydiag.Identity{Available: false, TokenStatus: ""}, // never logged in
User: identitydiag.Identity{Available: false, Status: "missing", Hint: "run: lark-cli auth login --help"}, // never logged in
}
r := buildResult(cfg, core.AsUser, "auto-detect", diag)
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
if r.Available {
t.Fatalf("available = true, want false")
@@ -80,8 +85,10 @@ func TestBuildResult_UserMissingToken(t *testing.T) {
if r.TokenStatus != "missing" {
t.Fatalf("tokenStatus = %q, want missing", r.TokenStatus)
}
if r.Hint == "" {
t.Fatalf("hint empty, want guidance")
// whoami renders the diagnosed hint verbatim (single source of truth) so it
// stays correct for the external-provider path without whoami knowing about it.
if r.Hint != diag.User.Hint {
t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.User.Hint)
}
if r.DefaultAs != "auto" {
t.Fatalf("defaultAs = %q, want auto (empty normalized)", r.DefaultAs)
@@ -93,16 +100,16 @@ func TestBuildResult_BotReady(t *testing.T) {
diag := identitydiag.Result{
Bot: identitydiag.Identity{Available: true, Status: "ready"},
}
r := buildResult(cfg, core.AsBot, "default-as", diag)
r := buildResult(cfg, core.AsBot, "default_as", diag)
if r.Identity != "bot" || r.IdentitySource != "default-as" {
if r.Identity != "bot" || r.IdentitySource != "default_as" {
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
}
if !r.Available || r.TokenStatus != "ready" {
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
}
if r.OpenID != "" || r.UserName != "" {
t.Fatalf("bot must not carry openId/userName: %#v", r)
if r.OnBehalfOf != nil {
t.Fatalf("bot must not carry onBehalfOf: %#v", r.OnBehalfOf)
}
if r.Hint != "" {
t.Fatalf("hint = %q, want empty", r.Hint)
@@ -112,9 +119,9 @@ func TestBuildResult_BotReady(t *testing.T) {
func TestBuildResult_BotNotConfigured(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu}
diag := identitydiag.Result{
Bot: identitydiag.Identity{Available: false, Status: "not_configured"},
Bot: identitydiag.Identity{Available: false, Status: "not_configured", Hint: "run: lark-cli config --help"},
}
r := buildResult(cfg, core.AsBot, "auto-detect", diag)
r := buildResult(cfg, core.AsBot, "auto_detect", diag)
if r.Available {
t.Fatalf("available = true, want false")
@@ -122,58 +129,8 @@ func TestBuildResult_BotNotConfigured(t *testing.T) {
if r.TokenStatus != "not_configured" {
t.Fatalf("tokenStatus = %q, want not_configured", r.TokenStatus)
}
if r.Hint == "" {
t.Fatalf("hint empty, want guidance")
}
}
func TestFormatPretty_User(t *testing.T) {
var buf bytes.Buffer
formatPretty(&buf, &whoamiResult{
Profile: "my-app", AppID: "cli_x", Brand: core.BrandLark,
Identity: "user", IdentitySource: "auto-detect",
Available: true, TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice",
})
out := buf.String()
for _, want := range []string{
"Profile: my-app (cli_x, lark)",
"Identity: user (auto-detect)",
"User: Alice (ou_x)",
"Token: valid",
} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q\n--- got ---\n%s", want, out)
}
}
}
func TestFormatPretty_BotNoUserLine(t *testing.T) {
var buf bytes.Buffer
formatPretty(&buf, &whoamiResult{
Profile: "p", AppID: "cli_x", Brand: core.BrandFeishu,
Identity: "bot", IdentitySource: "default-as",
Available: true, TokenStatus: "ready",
})
out := buf.String()
if strings.Contains(out, "User:") {
t.Errorf("bot output must not contain User: line\n%s", out)
}
if !strings.Contains(out, "Identity: bot (default-as)") || !strings.Contains(out, "Token: ready") {
t.Errorf("unexpected bot output:\n%s", out)
}
}
func TestFormatPretty_UnavailableShowsHint(t *testing.T) {
var buf bytes.Buffer
formatPretty(&buf, &whoamiResult{
Profile: "p", AppID: "cli_x", Brand: core.BrandLark,
Identity: "user", IdentitySource: "auto-detect",
Available: false, TokenStatus: "missing",
Hint: "No usable user token. Run `lark-cli auth login`.",
})
out := buf.String()
if !strings.Contains(out, "Token: missing — No usable user token.") {
t.Errorf("expected token line with hint, got:\n%s", out)
if r.Hint != diag.Bot.Hint {
t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.Bot.Hint)
}
}
@@ -183,7 +140,7 @@ func TestWhoami_BotJSON(t *testing.T) {
})
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--json"})
cmd.SetArgs([]string{}) // bare whoami: output is always JSON, no flag needed
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
@@ -204,8 +161,8 @@ func TestWhoami_BotJSON(t *testing.T) {
if got.IdentitySource == "" {
t.Fatalf("identitySource empty")
}
if got.OpenID != "" {
t.Fatalf("bot must not carry openId: %q", got.OpenID)
if got.OnBehalfOf != nil {
t.Fatalf("bot (self) must not carry onBehalfOf: %#v", got.OnBehalfOf)
}
}
@@ -256,3 +213,108 @@ func TestWhoami_ConfigErrorPropagates(t *testing.T) {
t.Fatalf("Execute() error = %v, want it to wrap %v", err, wantErr)
}
}
func TestWhoami_StrictModeRejectsCrossIdentity(t *testing.T) {
// Bot-only account → strict mode bot. A real `--as user` call would be
// rejected by CheckStrictMode; whoami must reject it identically rather than
// previewing a user identity the next call would refuse.
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
SupportedIdentities: 2, // bot only
})
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--as", "user", "--json"})
err := cmd.Execute()
if err == nil {
t.Fatalf("Execute() with --as user under strict bot = nil, want strict-mode rejection")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError: %v", err, err)
}
}
type fakeExtProvider struct {
name string
account *extcred.Account
}
func (p *fakeExtProvider) Name() string { return p.name }
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return p.account, nil
}
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil // no UAT served locally; whoami runs with verify=false
}
func externalWhoamiFactory(cfg *core.CliConfig) (*cmdutil.Factory, *bytes.Buffer) {
cred := credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: cfg.AppID}}},
nil, nil,
func() (*http.Client, error) { return nil, nil },
)
out := &bytes.Buffer{}
f := &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}
return f, out
}
// Regression for the external-provider blind spot: with credentials managed by
// an extension provider, a signed-in user must read as available, and an
// unavailable identity must not be told to "auth login" (which is blocked).
func TestWhoami_ExternalProvider_UserReady(t *testing.T) {
cfg := &core.CliConfig{
ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu,
SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice",
}
f, out := externalWhoamiFactory(cfg)
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--as", "user", "--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
var got whoamiResult
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("Unmarshal: %v\n%s", err, out.String())
}
if got.Identity != "user" || !got.Available || got.TokenStatus != "ready" {
t.Fatalf("got %#v, want user/available/ready", got)
}
if got.OnBehalfOf == nil || got.OnBehalfOf.UserName != "Alice" || got.OnBehalfOf.OpenID != "ou_x" {
t.Fatalf("onBehalfOf = %#v, want Alice/ou_x (delegated)", got.OnBehalfOf)
}
if got.Hint != "" {
t.Fatalf("hint = %q, want empty when available", got.Hint)
}
}
func TestWhoami_ExternalProvider_UserHintNotKeychain(t *testing.T) {
cfg := &core.CliConfig{
ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu,
SupportedIdentities: uint8(extcred.SupportsUser), // user supported but not signed in
}
f, out := externalWhoamiFactory(cfg)
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--as", "user", "--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
var got whoamiResult
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("Unmarshal: %v\n%s", err, out.String())
}
if got.Identity != "user" || got.Available {
t.Fatalf("got identity=%q available=%v, want user/false", got.Identity, got.Available)
}
if strings.Contains(got.Hint, "auth login") {
t.Fatalf("hint must not point at auth login under external provider: %q", got.Hint)
}
if !strings.Contains(got.Hint, "external") {
t.Fatalf("hint should explain external management: %q", got.Hint)
}
}

View File

@@ -319,7 +319,7 @@ func TestPermissionError_FullChain(t *testing.T) {
WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send").
WithMissingScopes("mail:user_mailbox.message:send").
WithIdentity("user").
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=mail:user_mailbox.message:send")
if got.Category != errs.CategoryAuthorization {
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
@@ -419,7 +419,7 @@ func TestBuilder_WireFormat(t *testing.T) {
WithHint("run lark-cli auth login --scope calendar:event:create").
WithMissingScopes("calendar:event:create").
WithIdentity("user").
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create")
buf, err := json.Marshal(e)
if err != nil {
@@ -439,7 +439,7 @@ func TestBuilder_WireFormat(t *testing.T) {
"hint": "run lark-cli auth login --scope calendar:event:create",
"log_id": "20260520-0a1b2c3d",
"identity": "user",
"console_url": "https://open.feishu.cn/app/cli_xxx/auth",
"console_url": "https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create",
"missing_scopes": []any{"calendar:event:create"},
}
for k, want := range wantFields {

View File

@@ -18,6 +18,9 @@ type IOStreams struct {
Out io.Writer
ErrOut io.Writer
IsTerminal bool
// OutIsTerminal reports whether Out is an interactive terminal. Mirrors
// IsTerminal; computed once in NewIOStreams and assignable directly in tests.
OutIsTerminal bool
// StderrIsTerminal reports whether ErrOut is an interactive terminal.
// Advisory warnings written to stderr (e.g. the proxy notice) gate on this
// so they stay out of non-interactive output (pipes, CI, agent runs).
@@ -27,19 +30,24 @@ type IOStreams struct {
}
// NewIOStreams builds an IOStreams from arbitrary readers/writers.
// IsTerminal / StderrIsTerminal are derived from in's / errOut's underlying
// *os.File, if any; non-file streams (bytes.Buffer, strings.Reader, …) yield
// false.
// IsTerminal / OutIsTerminal / StderrIsTerminal are each derived from the
// underlying *os.File of in / out / errOut respectively; non-file
// readers/writers (bytes.Buffer, strings.Reader, …) yield false.
func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
isTerminal := false
if f, ok := in.(*os.File); ok {
isTerminal = term.IsTerminal(int(f.Fd()))
fileIsTerminal := func(v any) bool {
if f, ok := v.(*os.File); ok {
return term.IsTerminal(int(f.Fd()))
}
return false
}
stderrIsTerminal := false
if f, ok := errOut.(*os.File); ok {
stderrIsTerminal = term.IsTerminal(int(f.Fd()))
return &IOStreams{
In: in,
Out: out,
ErrOut: errOut,
IsTerminal: fileIsTerminal(in),
OutIsTerminal: fileIsTerminal(out),
StderrIsTerminal: fileIsTerminal(errOut),
}
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal, StderrIsTerminal: stderrIsTerminal}
}
// SystemIO creates an IOStreams wired to the process's standard file descriptors.

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"bytes"
"os"
"testing"
)
func TestNewIOStreamsTerminalFlagsNonFile(t *testing.T) {
s := NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
if s.IsTerminal || s.OutIsTerminal || s.StderrIsTerminal {
t.Errorf("non-file streams must not be terminals: in=%v out=%v err=%v",
s.IsTerminal, s.OutIsTerminal, s.StderrIsTerminal)
}
}
func TestNewIOStreamsTerminalFlagsPipe(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
defer w.Close()
s := NewIOStreams(r, w, w)
if s.OutIsTerminal || s.StderrIsTerminal {
t.Errorf("os.Pipe must not be a terminal: out=%v err=%v", s.OutIsTerminal, s.StderrIsTerminal)
}
}

View File

@@ -10,12 +10,14 @@ import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
// ClassifyContext is the contextual data BuildAPIError uses to populate
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
// Identity is a plain string ("user" / "bot" / "") so this package does not
// depend on internal/core (which would create an import cycle).
// Brand and Identity are plain strings at this boundary; ConsoleURL normalizes
// Brand through core.ParseBrand, so callers can pass a raw brand string without
// coupling this contract to core's brand enum.
type ClassifyContext struct {
Brand string // "feishu" | "lark" — drives console_url host
AppID string // placed in console_url
@@ -444,28 +446,27 @@ func extractMissingScopes(resp map[string]any) []string {
return out
}
// ConsoleURL composes the Feishu/Lark open-platform scope-grant console URL,
// suitable for PermissionError.ConsoleURL. Empty appID → empty string. Empty
// scopes list returns the bare /auth landing page; scopes are joined with
// commas in the `q` query parameter so the console can pre-select them.
// ConsoleURL composes the Feishu/Lark open-platform application-scope apply
// page URL (the official open-pages `/page/scope-apply` entry), suitable for
// PermissionError.ConsoleURL. Empty appID → empty string. Empty scopes list
// returns the page carrying only clientID; otherwise scopes are joined with
// commas in the `scopes` query parameter so the console can pre-select them.
//
// brand is "feishu" or "lark"; unknown values default to feishu.
func ConsoleURL(brand, appID string, scopes []string) string {
if appID == "" {
return ""
}
host := "open.feishu.cn"
if brand == "lark" {
host = "open.larksuite.com"
}
// PathEscape on appID — it sits in the URL path. QueryEscape on the
// comma-joined scopes — they sit in the `?q=` value, and untrusted scope
// content must not be able to inject extra query parameters via `&`/`#`.
pathID := url.PathEscape(appID)
// QueryEscape both values — clientID and scopes both sit in the query
// string, and untrusted content must not be able to inject extra query
// parameters via `&`/`#`. The brand→host mapping is owned by core so the
// open-platform base URL stays a single source of truth.
base := fmt.Sprintf("%s/page/scope-apply?clientID=%s",
core.ResolveOpenBaseURL(core.ParseBrand(brand)), url.QueryEscape(appID))
if len(scopes) == 0 {
return fmt.Sprintf("https://%s/app/%s/auth", host, pathID)
return base
}
return fmt.Sprintf("https://%s/app/%s/auth?q=%s", host, pathID, url.QueryEscape(strings.Join(scopes, ",")))
return base + "&scopes=" + url.QueryEscape(strings.Join(scopes, ","))
}
func intFromAny(v any) int {

View File

@@ -422,8 +422,8 @@ func TestConsoleURL_FeishuBrand(t *testing.T) {
if !ok {
t.Fatalf("expected *errs.PermissionError, got %T", err)
}
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/app/cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.feishu.cn prefix", pe.ConsoleURL)
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/page/scope-apply?clientID=cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.feishu.cn scope-apply page", pe.ConsoleURL)
}
}
@@ -434,8 +434,8 @@ func TestConsoleURL_LarkBrand(t *testing.T) {
if !ok {
t.Fatalf("expected *errs.PermissionError, got %T", err)
}
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.larksuite.com prefix", pe.ConsoleURL)
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.larksuite.com scope-apply page", pe.ConsoleURL)
}
}
@@ -485,35 +485,35 @@ func TestConsoleURL_EscapesDangerousChars(t *testing.T) {
name: "ampersand in scope smuggles extra param",
appID: "cli_good",
scopes: []string{"scope&evil=injected"},
wantInURL: []string{"q=scope%26evil%3Dinjected"},
denyInURL: []string{"q=scope&evil=injected"},
wantInURL: []string{"scopes=scope%26evil%3Dinjected"},
denyInURL: []string{"scopes=scope&evil=injected"},
},
{
name: "hash in scope splits fragment",
appID: "cli_good",
scopes: []string{"scope#fragment"},
wantInURL: []string{"q=scope%23fragment"},
denyInURL: []string{"q=scope#fragment"},
wantInURL: []string{"scopes=scope%23fragment"},
denyInURL: []string{"scopes=scope#fragment"},
},
{
name: "question mark in appID prematurely opens query",
appID: "good?q=injected",
scopes: []string{"docx:document"},
wantInURL: []string{"/app/good%3Fq=injected/auth"},
denyInURL: []string{"/app/good?q=injected/auth"},
wantInURL: []string{"clientID=good%3Fq%3Dinjected"},
denyInURL: []string{"clientID=good?q=injected"},
},
{
name: "hash in appID truncates URL",
appID: "good#fragment",
scopes: []string{"docx:document"},
wantInURL: []string{"/app/good%23fragment/auth"},
denyInURL: []string{"/app/good#fragment/auth"},
wantInURL: []string{"clientID=good%23fragment"},
denyInURL: []string{"clientID=good#fragment"},
},
{
name: "slash in appID escapes path segment",
name: "slash in appID does not open a new path segment",
appID: "good/extra/segment",
scopes: []string{"docx:document"},
wantInURL: []string{"/app/good%2Fextra%2Fsegment/auth"},
wantInURL: []string{"clientID=good%2Fextra%2Fsegment"},
},
}
@@ -553,8 +553,8 @@ func TestPermissionError_NoViolations(t *testing.T) {
if pe.MissingScopes != nil {
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
}
if !strings.HasSuffix(pe.ConsoleURL, "/app/cli_a123/auth") {
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /app/cli_a123/auth", pe.ConsoleURL)
if !strings.HasSuffix(pe.ConsoleURL, "/page/scope-apply?clientID=cli_a123") {
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /page/scope-apply?clientID=cli_a123", pe.ConsoleURL)
}
}
@@ -758,7 +758,7 @@ func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) {
// at the app level — re-authenticating cannot fix it. The hint must
// point to the developer console regardless of caller identity, or
// agents will loop on `auth login` forever.
consoleURL := "https://open.feishu.cn/app/cli_x/auth?q=contact%3Acontact"
consoleURL := "https://open.feishu.cn/page/scope-apply?clientID=cli_x&scopes=contact%3Acontact"
for _, identity := range []string{"user", "bot", ""} {
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL)
if !strings.Contains(got, "developer console") {

View File

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

View File

@@ -27,6 +27,13 @@ func TestLookupCodeMeta_DriveCodes(t *testing.T) {
// 1069302: comment endpoint's opaque "Invalid or missing parameters"
// (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection.
{1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
// Secure label endpoint codes observed from drive +secure-label-update
// failure telemetry.
{1063001, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{1063002, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
{1063013, errs.CategoryValidation, errs.SubtypeFailedPrecondition, false},
{99992402, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{9499, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {

View File

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

View File

@@ -13,6 +13,7 @@ import (
"strings"
"time"
extcred "github.com/larksuite/cli/extension/credential"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -61,12 +62,131 @@ func Diagnose(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, veri
if ctx == nil {
ctx = context.Background()
}
// An external provider mints tokens on demand and blocks interactive auth,
// so the built-in keychain heuristics and "auth login" hints don't apply.
if provider := activeExternalProvider(ctx, f); provider != "" {
return diagnoseExternal(ctx, f, cfg, provider, verify)
}
return Result{
Bot: diagnoseBot(ctx, f, cfg, verify),
User: diagnoseUser(ctx, f, cfg, verify),
}
}
// activeExternalProvider returns the active extension provider name, or "".
// An error degrades to the built-in path: an unreachable provider would already
// have failed the f.Config() that produced cfg.
func activeExternalProvider(ctx context.Context, f *cmdutil.Factory) string {
if f == nil || f.Credential == nil {
return ""
}
name, err := f.Credential.ActiveExtensionProviderName(ctx)
if err != nil {
return ""
}
return name
}
func diagnoseExternal(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, verify bool) Result {
if cfg == nil || cfg.AppID == "" {
notConfigured := Identity{
Status: StatusNotConfigured,
Message: "not configured (missing app config)",
Hint: externalCredentialHint(provider),
}
return Result{Bot: notConfigured, User: notConfigured}
}
// SupportedIdentities == 0 is "unspecified" — treat as both, per CanBot.
ids := extcred.IdentitySupport(cfg.SupportedIdentities)
supportsBot := cfg.SupportedIdentities == 0 || ids.Has(extcred.SupportsBot)
supportsUser := cfg.SupportedIdentities == 0 || ids.Has(extcred.SupportsUser)
return Result{
Bot: diagnoseExternalBot(ctx, f, cfg, provider, supportsBot, verify),
User: diagnoseExternalUser(ctx, f, cfg, provider, supportsUser, verify),
}
}
func diagnoseExternalBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, supported, verify bool) Identity {
if !supported {
return notProvidedExternally("Bot", provider)
}
id := Identity{Status: StatusReady, Available: true, Message: "Bot identity: ready (provided by " + provider + ")"}
if !verify {
return id
}
token, err := resolveBotToken(ctx, f, cfg)
if err != nil {
return externalVerifyFailed(id, "Bot", provider, err)
}
info, err := fetchBotInfo(ctx, f, cfg, token)
if err != nil {
return externalVerifyFailed(id, "Bot", provider, err)
}
id.Verified = boolPtr(true)
id.OpenID = info.OpenID
id.AppName = info.AppName
return id
}
func diagnoseExternalUser(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, supported, verify bool) Identity {
if !supported {
return notProvidedExternally("User", provider)
}
// enrichUserInfo populates UserOpenId only after the provider returns and
// verifies a UAT (and clears it on failure), so a resolved open id is the
// external analogue of a keychain token being present.
if cfg.UserOpenId == "" {
return Identity{
Status: StatusMissing,
Message: "User identity: not signed in via credential source " + provider,
Hint: externalCredentialHint(provider),
}
}
id := Identity{
Status: StatusReady,
Available: true,
TokenStatus: StatusReady,
UserName: cfg.UserName,
OpenID: cfg.UserOpenId,
Message: "User identity: ready (provided by " + provider + ")",
}
if !verify {
return id
}
if _, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsUser, cfg.AppID)); err != nil {
return externalVerifyFailed(id, "User", provider, err)
}
id.Verified = boolPtr(true)
return id
}
func notProvidedExternally(label, provider string) Identity {
return Identity{
Status: StatusNotConfigured,
Message: label + " identity: not provided by credential source " + provider,
Hint: externalCredentialHint(provider),
}
}
// externalVerifyFailed flips id to verify-failed, keeping any identity fields
// (open id, user name) already resolved before the probe.
func externalVerifyFailed(id Identity, label, provider string, err error) Identity {
id.Available = false
id.Verified = boolPtr(false)
id.Status = StatusVerifyFailed
id.TokenStatus = ""
id.Message = label + " identity: verify failed: " + err.Error()
id.Hint = externalCredentialHint(provider)
return id
}
// externalCredentialHint reports the constraint, not a remediation: the
// identity is the provider's to manage, not lark-cli's to fix. What to do about
// it is the caller's call — there may be no user to ask.
func externalCredentialHint(provider string) string {
return fmt.Sprintf("managed by the external credential provider %q and cannot be configured via lark-cli", provider)
}
func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity {
if cfg == nil || cfg.AppID == "" {
return Identity{

View File

@@ -10,9 +10,11 @@ import (
"testing"
"time"
extcred "github.com/larksuite/cli/extension/credential"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/zalando/go-keyring"
)
@@ -348,3 +350,136 @@ func TestDiagnose_UserIdentityNeedsRefresh(t *testing.T) {
t.Fatalf("token status = %q, want needs_refresh", got.User.TokenStatus)
}
}
// fakeExtProvider is a minimal credential.extcred.Provider for exercising the
// external-credential diagnosis path. account makes the provider "active";
// token (when set) satisfies ResolveToken during verify.
type fakeExtProvider struct {
name string
account *extcred.Account
token *extcred.Token
}
func (p *fakeExtProvider) Name() string { return p.name }
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return p.account, nil
}
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return p.token, nil
}
func externalFactory(prov *fakeExtProvider, cfg *core.CliConfig) *cmdutil.Factory {
cred := credential.NewCredentialProvider(
[]extcred.Provider{prov}, nil, nil,
func() (*http.Client, error) { return nil, nil },
)
return &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{},
}
}
// assertExternalHint locks the contract that an external-provider hint never
// points at interactive commands blocked under an external provider.
func assertExternalHint(t *testing.T, hint string) {
t.Helper()
if hint == "" {
t.Fatalf("hint empty, want external guidance")
}
for _, blocked := range []string{"auth login", "config --help"} {
if strings.Contains(hint, blocked) {
t.Fatalf("hint %q must not point at %q (blocked under external provider)", hint, blocked)
}
}
if !strings.Contains(hint, "external") {
t.Fatalf("hint %q should explain credentials are external", hint)
}
}
func TestDiagnose_External_UserReady(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, false)
// The bug this guards: the built-in path read the keychain (empty under an
// external provider) and reported the user as missing. Now availability
// follows the resolved account, so a signed-in user reads as ready.
if !got.User.Available || got.User.Status != StatusReady || got.User.TokenStatus != StatusReady {
t.Fatalf("user = %#v, want ready/available", got.User)
}
if got.User.OpenID != "ou_x" || got.User.UserName != "Alice" {
t.Fatalf("user identity = %#v", got.User)
}
if got.User.Hint != "" {
t.Fatalf("hint = %q, want empty when available", got.User.Hint)
}
if !got.Bot.Available || got.Bot.Status != StatusReady {
t.Fatalf("bot = %#v, want ready/available", got.Bot)
}
}
func TestDiagnose_External_UserNotSignedIn(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsAll)}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, false)
if got.User.Available || got.User.Status != StatusMissing {
t.Fatalf("user = %#v, want missing/unavailable", got.User)
}
assertExternalHint(t, got.User.Hint)
}
func TestDiagnose_External_BotOnly(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsBot), UserOpenId: "ou_x"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, false)
if !got.Bot.Available || got.Bot.Status != StatusReady {
t.Fatalf("bot = %#v, want ready/available", got.Bot)
}
// Provider declares bot-only: user is unavailable even though an open id is
// present, and the hint is external (not "auth login").
if got.User.Available || got.User.Status != StatusNotConfigured {
t.Fatalf("user = %#v, want not_configured/unavailable", got.User)
}
assertExternalHint(t, got.User.Hint)
}
func TestDiagnose_External_UserOnly(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandLark, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Bob"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, false)
if !got.User.Available || got.User.Status != StatusReady {
t.Fatalf("user = %#v, want ready/available", got.User)
}
if got.Bot.Available || got.Bot.Status != StatusNotConfigured {
t.Fatalf("bot = %#v, want not_configured/unavailable", got.Bot)
}
assertExternalHint(t, got.Bot.Hint)
}
func TestDiagnose_External_VerifyUserResolvesToken(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Alice"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}, token: &extcred.Token{Value: "ext-uat"}}, cfg)
got := Diagnose(context.Background(), f, cfg, true)
if !got.User.Available || got.User.Verified == nil || !*got.User.Verified {
t.Fatalf("user = %#v, want available and verified", got.User)
}
}
func TestDiagnose_External_VerifyUserTokenUnavailable(t *testing.T) {
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x"}
f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg)
got := Diagnose(context.Background(), f, cfg, true)
if got.User.Available || got.User.Status != StatusVerifyFailed {
t.Fatalf("user = %#v, want verify_failed/unavailable", got.User)
}
if got.User.Verified == nil || *got.User.Verified {
t.Fatalf("verified = %v, want false", got.User.Verified)
}
assertExternalHint(t, got.User.Hint)
}

View File

@@ -52,6 +52,9 @@ func isPlaceholderValue(value string) bool {
normalized := strings.ToLower(trimmed)
if normalized == "" ||
normalized == "=" ||
printfPlaceholderValue(normalized) ||
htmlEntityAnglePlaceholder(normalized) ||
starMaskedPlaceholder(normalized) ||
percentWrappedPlaceholder(normalized) ||
angleWrappedPlaceholder(normalized) ||
urlWithAnglePlaceholder(normalized) ||
@@ -61,9 +64,28 @@ func isPlaceholderValue(value string) bool {
return namedPlaceholderValue(normalized)
}
func htmlEntityAnglePlaceholder(value string) bool {
if !strings.HasPrefix(value, "&lt;") || !strings.HasSuffix(value, "&gt;") {
return false
}
return anglePlaceholderIdentifier(strings.TrimSuffix(strings.TrimPrefix(value, "&lt;"), "&gt;"))
}
func starMaskedPlaceholder(value string) bool {
var stars int
for _, r := range value {
if r == '*' {
stars++
continue
}
return false
}
return stars >= 3
}
func namedPlaceholderValue(value string) bool {
switch value {
case "...", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret":
case "...", "***", "****", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret", "test-token", "dry-run", "dry_run":
return true
}
return strings.Contains(value, "cli_example") ||
@@ -71,6 +93,15 @@ func namedPlaceholderValue(value string) bool {
conventionalNamedPlaceholderValue(value)
}
func printfPlaceholderValue(value string) bool {
switch value {
case "%d", "%s", "%q", "%v", "%w", "%x", "%T":
return true
default:
return false
}
}
func allXPlaceholder(value string) bool {
if len(value) < 4 {
return false

View File

@@ -7,6 +7,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"math"
"path/filepath"
"sort"
"strings"
@@ -54,8 +55,9 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
keyName, _ := normalizedCredentialAssignmentKey(match[0])
if value == "" ||
isNonSecretLiteralValue(value) ||
isBenignCodeCredentialExpression(file, value) ||
isBenignCodeCredentialExpression(file, line, match[0], value) ||
isPlaceholderValue(value) ||
isPermissionScopeIdentifierAssignment(keyName, value) ||
isResourceTokenPlaceholderAssignment(keyName, value) {
continue
}
@@ -77,12 +79,15 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
}
for _, match := range credentialURLRE.FindAllString(line, -1) {
if isPlaceholderCredentialURL(match) {
if isPlaceholderCredentialURL(file, match) {
continue
}
out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
}
for _, match := range privateIPv4RE.FindAllString(line, -1) {
if !warnForPrivateIPv4(file) {
continue
}
out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
}
if source == "branch" && automationBranchRE.MatchString(line) {
@@ -129,6 +134,9 @@ func isCredentialAssignmentMatch(match string) bool {
if isBenignTokenField(name) && !credentialShapedValue(value) {
return false
}
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
return false
}
return isExplicitCredentialKey(name)
}
@@ -266,7 +274,7 @@ func isResourceTokenPlaceholderAssignment(key, value string) bool {
case key == "retry_without_token" && numericStringPlaceholderValue(value):
return true
case tokenLikePlaceholderKey(key):
return tokenLikePlaceholderValue(value)
return tokenLikePlaceholderValue(key, value)
default:
return false
}
@@ -278,12 +286,16 @@ func tokenLikePlaceholderKey(key string) bool {
strings.HasSuffix(key, "-token")
}
func tokenLikePlaceholderValue(value string) bool {
func tokenLikePlaceholderValue(key, value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
if normalized == "" || credentialShapedIdentifier(normalized) {
return false
}
if authCredentialTokenKey(key) {
return false
}
return resourceTokenPlaceholderValue(value) ||
maskedTokenFixturePlaceholderValue(key, normalized) ||
isPlaceholderValue(value) ||
normalized == "token" ||
strings.Contains(normalized, "...") ||
@@ -293,6 +305,149 @@ func tokenLikePlaceholderValue(value string) bool {
strings.HasPrefix(normalized, ".")
}
func maskedTokenFixturePlaceholderValue(key, value string) bool {
if authCredentialTokenKey(key) {
return false
}
var stars, alnum int
for _, r := range value {
switch {
case r == '*':
stars++
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
alnum++
default:
return false
}
}
return stars >= 6 && alnum > 0
}
func isWeakTokenCredentialKey(key string) bool {
if authCredentialTokenKey(key) || isStrongTokenCredentialKey(key) {
return false
}
return key == "token" ||
strings.HasSuffix(key, "_token") ||
strings.HasSuffix(key, "-token")
}
func isStrongTokenCredentialKey(key string) bool {
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
for _, phrase := range [][2]string{
{"access", "token"},
{"refresh", "token"},
{"auth", "token"},
{"bearer", "token"},
{"session", "token"},
{"service", "token"},
{"bot", "token"},
{"api", "token"},
{"secret", "token"},
} {
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
return true
}
}
return false
}
func weakTokenValueLooksCredentialLike(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
if normalized == "" ||
isNonSecretLiteralValue(value) ||
isPlaceholderValue(value) {
return false
}
candidate := unwrapCredentialValue(normalized)
return credentialShapedIdentifier(candidate) ||
highEntropyCredentialValue(candidate) ||
commandSubstitutionLooksCredentialLike(normalized) ||
(strings.Contains(normalized, "://") &&
urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)))
}
func unwrapCredentialValue(value string) string {
value = strings.TrimSpace(strings.Trim(value, `"'<>`))
if strings.HasPrefix(value, "${{") && strings.HasSuffix(value, "}}") {
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
}
value = strings.TrimPrefix(value, "$")
value = strings.Trim(value, "%")
return strings.TrimSpace(value)
}
func highEntropyCredentialValue(value string) bool {
if len(value) < 32 {
return false
}
var hasLetter, hasDigit bool
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
hasLetter = true
case r >= '0' && r <= '9':
hasDigit = true
case r == '_' || r == '-' || r == '.' || r == '=':
default:
return false
}
}
return hasLetter && hasDigit && shannonEntropy(value) >= 3.5
}
func shannonEntropy(value string) float64 {
if value == "" {
return 0
}
counts := map[rune]int{}
for _, r := range value {
counts[r]++
}
var entropy float64
length := float64(len([]rune(value)))
for _, count := range counts {
p := float64(count) / length
entropy -= p * log2(p)
}
return entropy
}
func log2(value float64) float64 {
return math.Log(value) / math.Ln2
}
func authCredentialTokenKey(key string) bool {
switch strings.ReplaceAll(strings.ToLower(key), "-", "_") {
case "access_token",
"api_token",
"bot_token",
"refresh_token",
"secret_token",
"session_token",
"service_token",
"bearer_token",
"auth_token",
"authorization_token",
"id_token":
return true
default:
return false
}
}
func isPermissionScopeIdentifierAssignment(key, value string) bool {
if !strings.HasSuffix(key, "_token") {
return false
}
switch strings.ToLower(strings.Trim(value, `"',;`)) {
case "read", "write", "modify", "readonly", "get_as_user":
return true
default:
return false
}
}
func idempotencyTokenPlaceholderValue(value string) bool {
return numericStringPlaceholderValue(value) || uuidStringPlaceholderValue(value)
}
@@ -333,20 +488,87 @@ func numericStringPlaceholderValue(value string) bool {
return true
}
func isBenignCodeCredentialExpression(file, value string) bool {
func isBenignCodeCredentialExpression(file, line, match, value string) bool {
normalized := strings.TrimSpace(value)
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
return true
}
if !sourceCodeFile(file) || quotedLiteral(value) || credentialShapedValue(value) {
if !sourceCodeFile(file) || credentialShapedValue(value) {
return false
}
if rhs, ok := sourceCodeTypedCredentialRHS(line, match); ok {
return isBenignTypedCredentialRHS(rhs)
}
rawValueQuoted := credentialAssignmentRawValueQuoted(match)
if sourceCodeLiteralLooksNonSecret(normalized, !rawValueQuoted) {
return true
}
if sourceCodeFormatStringLiteral(normalized) && sourceCodeFormatArgumentContext(line, match) {
return true
}
if strings.Contains(match, "+") {
return true
}
if rawValueQuoted {
return false
}
if quotedLiteral(value) {
return sourceCodeLiteralLooksNonSecret(value, false)
}
return codeReferenceExpression(normalized)
}
func sourceCodeTypedCredentialRHS(line, match string) (string, bool) {
idx := strings.Index(line, match)
if idx < 0 {
return "", false
}
key, ok := credentialAssignmentKey(match)
if !ok {
return "", false
}
rest := strings.TrimSpace(line[idx+len(key):])
if !strings.HasPrefix(rest, ":") {
return "", false
}
typeAndRHS := strings.TrimSpace(strings.TrimPrefix(rest, ":"))
assignmentIdx := strings.Index(typeAndRHS, "=")
if assignmentIdx < 0 {
return "", false
}
return strings.TrimSpace(typeAndRHS[assignmentIdx+1:]), true
}
func isBenignTypedCredentialRHS(value string) bool {
value = strings.TrimRight(strings.TrimSpace(value), ",;")
if value == "" || isNonSecretLiteralValue(value) || isPlaceholderValue(value) {
return true
}
if credentialShapedValue(value) {
return false
}
if sourceCodeLiteralLooksNonSecret(value, !quotedLiteral(value)) {
return true
}
if quotedLiteral(value) {
return false
}
return codeReferenceExpression(value)
}
func credentialAssignmentRawValueQuoted(match string) bool {
key, ok := credentialAssignmentKey(match)
if !ok {
return false
}
rest := strings.TrimSpace(strings.TrimPrefix(match[len(key):], ":"))
rest = strings.TrimSpace(strings.TrimPrefix(rest, "="))
return strings.HasPrefix(rest, `"`) || strings.HasPrefix(rest, `'`)
}
func sourceCodeFile(file string) bool {
switch filepath.Ext(file) {
case ".go", ".py":
case ".go", ".js", ".jsx", ".py", ".ts", ".tsx":
return true
default:
return false
@@ -360,7 +582,147 @@ func quotedLiteral(value string) bool {
(strings.HasPrefix(normalized, `'`) && strings.HasSuffix(normalized, `'`)))
}
func sourceCodeLiteralLooksNonSecret(value string, allowNumeric bool) bool {
literal := strings.Trim(strings.TrimSpace(value), `"'`)
if strings.HasPrefix(literal, "/") {
return true
}
return (allowNumeric && numericStringPlaceholderValue(literal)) ||
sourceCodeEnvVarNameLiteral(literal) ||
sourceCodeAttributeNameLiteral(literal) ||
sourceCodeFakeOrPlaceholderLiteral(literal) ||
sourceCodeCredentialTermLiteral(literal) ||
sourceCodeCredentialPrefixLiteral(literal) ||
sourceCodeVocabularyLiteral(literal) ||
sourceCodeSchemaTypeLiteral(literal) ||
benignCredentialStatusLiteral(literal)
}
func sourceCodeFormatArgumentContext(line, match string) bool {
idx := strings.Index(line, match)
if idx < 0 {
return false
}
prefix := line[:idx]
if semicolon := strings.LastIndex(prefix, ";"); semicolon >= 0 {
prefix = prefix[semicolon+1:]
}
return strings.Contains(prefix, "fmt.") ||
strings.Contains(prefix, "log.") ||
strings.Contains(prefix, "printf(") ||
strings.Contains(prefix, "Printf(") ||
strings.Contains(prefix, "Errorf(") ||
strings.Contains(prefix, "Fprintf(")
}
func sourceCodeFormatStringLiteral(value string) bool {
for i := 0; i < len(value)-1; i++ {
if value[i] != '%' {
continue
}
if value[i+1] == '%' {
i++
continue
}
j := i + 1
for j < len(value) && strings.ContainsRune("#+- 0.0123456789", rune(value[j])) {
j++
}
if j < len(value) && strings.ContainsRune("vTtbcdoOqxXUeEfFgGspw", rune(value[j])) {
return true
}
}
return false
}
func sourceCodeEnvVarNameLiteral(value string) bool {
if value == "" || !strings.Contains(value, "_") {
return false
}
var hasCredentialMarker bool
for _, r := range value {
switch {
case r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
case r == '_':
default:
return false
}
}
for _, marker := range []string{"TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"} {
if strings.Contains(value, marker) {
hasCredentialMarker = true
break
}
}
return hasCredentialMarker
}
func sourceCodeAttributeNameLiteral(value string) bool {
normalized := strings.ToLower(value)
return strings.HasPrefix(normalized, "data-") && delimitedPlaceholderIdentifier(normalized)
}
func sourceCodeFakeOrPlaceholderLiteral(value string) bool {
normalized := strings.ToLower(value)
return strings.HasPrefix(normalized, "fake_") ||
strings.HasPrefix(normalized, "fake-") ||
strings.Contains(normalized, "placeholder") ||
(strings.Contains(normalized, "<") && strings.Contains(normalized, ">"))
}
func sourceCodeCredentialTermLiteral(value string) bool {
normalized := strings.ToLower(strings.ReplaceAll(value, "-", "_"))
return conventionalCredentialPlaceholderName(normalized)
}
func sourceCodeCredentialPrefixLiteral(value string) bool {
switch strings.ToLower(value) {
case "appsecret:":
return true
default:
return false
}
}
func sourceCodeVocabularyLiteral(value string) bool {
switch strings.ToLower(value) {
case "bot", "tenant", "user":
return true
default:
return false
}
}
func sourceCodeSchemaTypeLiteral(value string) bool {
normalized := strings.ToLower(value)
return normalized == "string" || strings.HasPrefix(normalized, "string(")
}
func benignCredentialStatusLiteral(value string) bool {
normalized := strings.ToLower(strings.ReplaceAll(value, "-", "_"))
if !delimitedPlaceholderIdentifier(normalized) {
return false
}
for _, marker := range []string{
"bad_fmt",
"expired",
"format",
"invalid",
"missing",
"permission",
"status",
"type",
} {
if strings.Contains(normalized, marker) {
return true
}
}
return false
}
func codeReferenceExpression(value string) bool {
value = strings.TrimRight(strings.TrimSpace(value), ";")
if value == "" {
return false
}
@@ -369,7 +731,10 @@ func codeReferenceExpression(value string) bool {
return true
}
}
return codeIdentifier(value) && !credentialNameFragment(value)
if !codeIdentifier(value) {
return false
}
return codeIdentifier(value)
}
func codeIdentifier(value string) bool {
@@ -386,16 +751,6 @@ func codeIdentifier(value string) bool {
return true
}
func credentialNameFragment(value string) bool {
normalized := strings.ToLower(value)
for _, marker := range []string{"secret", "token", "password", "passwd", "key"} {
if strings.Contains(normalized, marker) {
return true
}
}
return false
}
func isNonSecretLiteralValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
case "true", "false", "null", "nil", "{", "[":
@@ -597,7 +952,7 @@ func looksLikeEqualityComparison(value string) bool {
return strings.HasPrefix(strings.TrimSpace(value), "=")
}
func isPlaceholderCredentialURL(raw string) bool {
func isPlaceholderCredentialURL(file, raw string) bool {
userInfo, ok := credentialURLUserInfo(raw)
if !ok {
return false
@@ -606,7 +961,8 @@ func isPlaceholderCredentialURL(raw string) bool {
if !ok {
return false
}
return credentialURLPasswordPlaceholder(password)
return credentialURLPasswordPlaceholder(password) ||
(sourceOrTestFixtureFile(file) && credentialURLPasswordFixture(password))
}
func credentialURLPasswordPlaceholder(password string) bool {
@@ -620,6 +976,46 @@ func credentialURLPasswordPlaceholder(password string) bool {
return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded)
}
func credentialURLPasswordFixture(password string) bool {
normalized := strings.ToLower(strings.Trim(password, `"'`))
switch normalized {
case "p",
"pass",
"password",
"pat_abc",
"pw",
"s3cret",
"secret",
"t":
return true
default:
return false
}
}
func sourceOrTestFixtureFile(file string) bool {
normalized := filepath.ToSlash(file)
return sourceCodeFile(normalized) ||
strings.HasPrefix(normalized, "testdata/") ||
strings.HasPrefix(normalized, "fixtures/") ||
strings.Contains(normalized, "/testdata/") ||
strings.Contains(normalized, "/fixtures/")
}
func warnForPrivateIPv4(file string) bool {
normalized := filepath.ToSlash(file)
if sourceOrTestFixtureFile(normalized) {
return false
}
switch filepath.Ext(normalized) {
case ".md", ".mdx", ".txt", ".json", ".yaml", ".yml", ".toml", ".env":
return true
default:
return strings.HasPrefix(normalized, "docs/") ||
strings.HasPrefix(normalized, "skills/")
}
}
func credentialURLUserInfo(raw string) (string, bool) {
schemeIdx := strings.Index(raw, "://")
if schemeIdx < 0 {

View File

@@ -61,6 +61,19 @@ func TestScanFileWarnsForPrivateIPv4Examples(t *testing.T) {
}
}
func TestScanFileAllowsPrivateIPv4SourceFixtures(t *testing.T) {
got := ScanFile("internal/transport/warn_test.go", []byte(strings.Join([]string{
`proxy := "http://user:pass@10.0.0.1:3128"`,
`target := "socks5://admin:secret@172.16.0.1:1080"`,
`host := "192.168.0.10"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_private_ipv4" {
t.Fatalf("private IPv4 source fixtures should not be public content findings: %#v", got)
}
}
}
func TestSemanticCandidateRequiresSpecificRiskSignals(t *testing.T) {
benign := semanticCandidate("docs/network.md", "file", "For a local lab, use RFC1918 example host 192.168."+"0.10 only.", 1)
if len(benign) != 0 {
@@ -632,6 +645,45 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
}
}
func TestScanFileAllowsCredentialURLFixtures(t *testing.T) {
got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{
`proxy := "http://user:pass@proxy:8080"`,
`repo := "https://u:t@h/r.git"`,
`target := "https://attacker:pw@open.feishu.cn"`,
`proxy := "http://admin:s3cret@127.0.0.1:3128"`,
`repo := "http://x-token:PAT_abc@git.host/app_x.git"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_credential_url" {
t.Fatalf("credential URL fixtures should not be credential URL findings: %#v", got)
}
}
}
func TestScanFileAllowsRootCredentialURLFixtures(t *testing.T) {
got := ScanFile("fixtures/network.md", []byte(strings.Join([]string{
`proxy: http://user:pass@proxy:8080`,
`repo: https://u:t@h/r.git`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_credential_url" {
t.Fatalf("root credential URL fixtures should not be credential URL findings: %#v", got)
}
}
}
func TestScanFileAllowsRootPrivateIPv4Fixtures(t *testing.T) {
got := ScanFile("testdata/network.md", []byte(strings.Join([]string{
`endpoint: http://10.0.0.1:8080`,
`redis: 192.168.1.10:6379`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_private_ipv4" {
t.Fatalf("root private IPv4 fixtures should not be private IPv4 findings: %#v", got)
}
}
}
func TestScanFileDetectsCredentialURLsWithRedactedSubstringPasswords(t *testing.T) {
got := ScanFile("docs/config.yaml", []byte("DATABASE_URL=postgres://user:notredactedreal@example.invalid/db\n"))
for _, item := range got {
@@ -648,6 +700,7 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
"DATABASE_URL=postgres://<user>:real-secret@example.invalid/db",
"DATABASE_URL=postgres://<user>:" + stripeLike + "@example.invalid/db",
"URL=https://<user>:real-secret@example.invalid/path",
"REPO=https://x-token:" + stripeLike + "@git.host/app.git",
}, "\n")+"\n"))
var count int
for _, item := range got {
@@ -661,8 +714,8 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
}
}
}
if count != 3 {
t.Fatalf("placeholder-user credential URL findings = %d, want 3: %#v", count, got)
if count != 4 {
t.Fatalf("placeholder-user credential URL findings = %d, want 4: %#v", count, got)
}
}
@@ -724,6 +777,68 @@ func TestScanFileAllowsBenignJSONTokenFields(t *testing.T) {
}
}
func TestScanFileAllowsWeakTokenFieldsWithoutCredentialEvidence(t *testing.T) {
got := ScanFile("docs/resource-tokens.md", []byte(strings.Join([]string{
`{"token":"img_abc123"}`,
`{"token":"img_live_secret"}`,
`{"token":"img_prod_key"}`,
`token=ab********cd`,
`{"image_token":"img_live_secret"}`,
`{"data_mail_token":"mail_abc123"}`,
`{"whiteboard_token":"board_v3_example"}`,
`{"want_token":"token from callback"}`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("weak token fields without credential evidence should not be credential findings: %#v", got)
}
}
}
func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *testing.T) {
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
stripeToken := "sk_" + "live_1234567890abcdef"
randomToken := strings.Join([]string{
"a1b2c3d4",
"e5f6g7h8",
"i9j0k1l2",
"m3n4p5q6",
}, "")
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
`{"token":"` + githubToken + `"}`,
`token=` + stripeToken,
`{"image_token":"` + githubToken + `"}`,
`{"token":"` + randomToken + `"}`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 4 {
t.Fatalf("high-confidence weak token credential findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
`{"access_token":"img_abc123"}`,
`{"api_token":"img_live_secret"}`,
`{"service_token":"ab********cd"}`,
`{"bot_token":"board_v3_example"}`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 4 {
t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
for _, item := range got {
@@ -770,6 +885,172 @@ func TestScanFileAllowsPythonArgumentTokens(t *testing.T) {
}
}
func TestScanFileAllowsPythonCredentialTypeAnnotations(t *testing.T) {
got := ScanFile("fixtures/doc_word_stat.py", []byte(strings.Join([]string{
"class Counter:",
" def __init__(self) -> None:",
" self._token_kind: TokenKind | None = None",
" self.access_token: AccessToken | None = None",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("python credential-shaped type annotations should not be credential findings: %#v", got)
}
}
}
func TestScanFileAllowsSourceCodeCredentialNonSecretLiterals(t *testing.T) {
got := ScanFile("fixtures/auth_paths.go", []byte(strings.Join([]string{
`const PathOAuthTokenV2 = "/open-apis/authen/v2/oauth/token"`,
`return fmt.Errorf("failed to remove token: %v", err)`,
`const LarkErrTokenMissing = "token_missing"`,
`const LarkErrTokenExpired = 99991677`,
`const CliAppSecret = "LARKSUITE_CLI_APP_SECRET"`,
`const LargeAttachmentTokenAttr = "data-mail-token"`,
`const fakeOfficeTokenPrefix = "fake_office_"`,
`fmt.Fprintf(w, " - token=%s filename=%s\n", att.Token, att.FileName)`,
`tokenTypeHint := "access_token"`,
`const TokenTenant Token = "tenant"`,
`const secretKeyPrefix = "appsecret:"`,
`output.PrintJson(out, map[string]interface{}{"appSecret": "****"})`,
`return &credential.TokenResult{Token: "test-token"}, nil`,
`fmt.Fprintf(w, "password=%s\n", pat)`,
`text += "(img_token:" + imgToken + ")"`,
`map[string]interface{}{"token": "string(optional, from inspect)"}`,
`this.token = token;`,
`// AppSecret: "appsecret:<appId>"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("source code non-secret literals should not be credential findings: %#v", got)
}
}
}
func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
`app_secret=***`,
`{"token":"&lt;wiki_token&gt;"}`,
`{"token":"Pgrrwvr***********UnRb"}`,
`"scope_name": "auth:user_access_token:read"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("public placeholders and scope identifiers should not be credential findings: %#v", got)
}
}
}
func TestScanFileDetectsPartiallyMaskedCredentialValues(t *testing.T) {
got := ScanFile("fixtures/config.md", []byte(strings.Join([]string{
"client_secret=realprefix***realsuffix",
"client_secret=ab********cd",
"access_token=ab********cd",
"refresh_token=realprefix********realsuffix",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 4 {
t.Fatalf("partially masked credential findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
got := ScanFile("fixtures/ci.yml", []byte(strings.Join([]string{
"LARKSUITE_CLI_APP_SECRET=dry-run",
"client_secret: dry_run",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("dry-run credential placeholders should not be credential findings: %#v", got)
}
}
}
func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
cases := []struct {
name string
file string
text string
}{
{
name: "typescript simple secret",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string = "real-client-secret-value"`,
},
{
name: "typescript numeric password",
file: "fixtures/source_secret.ts",
text: `const password: string = "12345678901234567890"`,
},
{
name: "typescript union secret",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string | undefined = "real-client-secret-value"`,
},
{
name: "python simple secret",
file: "fixtures/source_secret.py",
text: `self.client_secret: str = "real-client-secret-value"`,
},
{
name: "python union secret",
file: "fixtures/source_secret.py",
text: `self.client_secret: str | None = "real-client-secret-value"`,
},
{
name: "python optional secret",
file: "fixtures/source_secret.py",
text: `self.client_secret: Optional[str] = "real-client-secret-value"`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := ScanFile(tc.file, []byte(tc.text+"\n"))
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("typed credential assignment should be reported: %#v", got)
}
})
}
}
func TestScanFileDetectsCredentialShapedSourceCodeLiterals(t *testing.T) {
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("fixtures/source_secret.go", []byte(strings.Join([]string{
`const ClientSecret = "real-client-secret-value"`,
`const GithubToken = "` + githubToken + `"`,
`const Password = "12345678901234567890"`,
`const ClientSecretNumber = "12345678901234567890"`,
`const ClientSecretFormat = "abc%sdefreal"`,
`fmt.Println("done"); const ClientSecret = "abc%sdefreal"`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 6 {
t.Fatalf("source code credential-shaped literal findings = %d, want 6: %#v", count, got)
}
}
func TestScanFileAllowsPrintfCredentialPlaceholders(t *testing.T) {
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
"client_secret=%s",
"access_token=%v",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("printf placeholders should not be credential findings: %#v", got)
}
}
}
func TestScanFileAllowsEllipsisCredentialPlaceholders(t *testing.T) {
got := ScanFile("fixtures/lark-doc-fetch.md", []byte(strings.Join([]string{
`<img token="..." url="https://..." width="..." height="..."/>`,
@@ -886,10 +1167,12 @@ func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T)
}
}
func TestScanFileDetectsNonFixtureMinuteTokenValues(t *testing.T) {
func TestScanFileAllowsNonFixtureResourceTokenValues(t *testing.T) {
got := ScanFile("fixtures/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("non-fixture minute token should be credential finding: %#v", got)
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("resource-like bare token value should not be credential finding: %#v", got)
}
}
}

View File

@@ -59,13 +59,9 @@ func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string {
if appID == "" || scope == "" {
return ""
}
host := "open.feishu.cn"
if brand == core.BrandLark {
host = "open.larksuite.com"
}
return fmt.Sprintf(
"https://%s/page/scope-apply?clientID=%s&scopes=%s",
host,
"%s/page/scope-apply?clientID=%s&scopes=%s",
core.ResolveOpenBaseURL(brand),
url.QueryEscape(appID),
url.QueryEscape(scope),
)

View File

@@ -25,7 +25,7 @@ import (
const (
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
cacheTTL = 24 * time.Hour
fetchTimeout = 5 * time.Second
fetchTimeout = 15 * time.Second
stateFile = "update-state.json"
maxBody = 256 << 10 // 256 KB

View File

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

View File

@@ -89,6 +89,18 @@ func TestDryRunFieldOps(t *testing.T) {
)
assertDryRunContains(t, dryRunFieldGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
assertDryRunContains(t, dryRunFieldCreate(ctx, rt), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/fields")
arrayRT := newBaseTestRuntime(
map[string]string{
"base-token": "app_x",
"table-id": "tbl_1",
"json": `[{"name":"A","type":"text"},{"name":"B","type":"text"}]`,
},
nil,
nil,
)
assertDryRunContains(t, dryRunFieldCreate(ctx, arrayRT), `"name":"A"`, `"name":"B"`)
assertDryRunContains(t, dryRunFieldUpdate(ctx, rt), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
assertDryRunContains(t, dryRunFieldDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
assertDryRunContains(t, dryRunFieldSearchOptions(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1/options", "offset=3", "limit=30", "query=open")

View File

@@ -830,11 +830,6 @@ func TestBaseObjectJSONShortcutsRejectArrayInDryRun(t *testing.T) {
shortcut common.Shortcut
args []string
}{
{
name: "field create",
shortcut: BaseFieldCreate,
args: []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
},
{
name: "field update",
shortcut: BaseFieldUpdate,
@@ -1102,6 +1097,54 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
}
})
t.Run("create array sequentially", func(t *testing.T) {
oldDelay := fieldCreateBatchDelay
fieldCreateBatchDelay = 0
t.Cleanup(func() { fieldCreateBatchDelay = oldDelay })
factory, stdout, reg := newExecuteFactory(t)
firstStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
BodyFilter: func(body []byte) bool {
return strings.Contains(string(body), `"name":"A"`)
},
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"id": "fld_a", "name": "A", "type": "text"},
},
}
secondStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
BodyFilter: func(body []byte) bool {
return strings.Contains(string(body), `"name":"B"`)
},
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"id": "fld_b", "name": "B", "type": "text"},
},
}
reg.Register(firstStub)
reg.Register(secondStub)
err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[{"name":"A","type":"text"},{"name":"B","type":"text"}]`}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["created"] != true || data["total"] != float64(2) {
t.Fatalf("unexpected output: %#v", data)
}
fields, _ := data["fields"].([]interface{})
if len(fields) != 2 {
t.Fatalf("fields len=%d output=%#v", len(fields), data)
}
if !strings.Contains(string(firstStub.CapturedBody), `"name":"A"`) || !strings.Contains(string(secondStub.CapturedBody), `"name":"B"`) {
t.Fatalf("unexpected request bodies: %s / %s", firstStub.CapturedBody, secondStub.CapturedBody)
}
})
t.Run("delete", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{

View File

@@ -1060,6 +1060,15 @@ func TestBaseFieldValidate(t *testing.T) {
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
t.Fatalf("err=%v", err)
}
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `[{"name":"a","type":"text"},{"name":"b","type":"text"}]`}, nil, nil)); err != nil {
t.Fatalf("array create validate err=%v", err)
}
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `[{"name":"a","type":"text"},1]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json item 2 must be an object") {
t.Fatalf("err=%v", err)
}
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `[{"name":"a","type":"formula"}]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") {
t.Fatalf("err=%v", err)
}
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `{"name":"f1","type":"formula"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") {
t.Fatalf("err=%v", err)
}

View File

@@ -6,10 +6,13 @@ package base
import (
"context"
"strings"
"time"
"github.com/larksuite/cli/shortcuts/common"
)
var fieldCreateBatchDelay = time.Second
func dryRunFieldList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
offset := runtime.Int("offset")
if offset < 0 {
@@ -33,12 +36,14 @@ func dryRunFieldGet(_ context.Context, runtime *common.RuntimeContext) *common.D
func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields").
Body(body).
bodies, _ := parseFieldCreateBodies(pc, runtime.Str("json"))
dr := common.NewDryRunAPI().
Set("base_token", runtime.Str("base-token")).
Set("table_id", baseTableID(runtime))
for _, body := range bodies {
dr.POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields").Body(body)
}
return dr
}
func dryRunFieldUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -95,11 +100,16 @@ func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command strin
}
func validateFieldCreate(runtime *common.RuntimeContext) error {
body, err := validateFieldJSON(runtime)
bodies, err := parseFieldCreateBodies(newParseCtx(runtime), runtime.Str("json"))
if err != nil {
return err
}
return validateFormulaLookupGuideAck(runtime, "+field-create", body)
for _, body := range bodies {
if err := validateFormulaLookupGuideAck(runtime, "+field-create", body); err != nil {
return err
}
}
return nil
}
func validateFieldUpdate(runtime *common.RuntimeContext) error {
@@ -140,19 +150,40 @@ func executeFieldGet(runtime *common.RuntimeContext) error {
}
func executeFieldCreate(runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
bodies, err := parseFieldCreateBodies(newParseCtx(runtime), runtime.Str("json"))
if err != nil {
return err
}
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "fields"), nil, body)
if err != nil {
return err
fields := make([]interface{}, 0, len(bodies))
for idx, body := range bodies {
if idx > 0 && fieldCreateBatchDelay > 0 {
time.Sleep(fieldCreateBatchDelay)
}
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "fields"), nil, body)
if err != nil {
return err
}
fields = append(fields, data)
}
runtime.Out(map[string]interface{}{"field": data, "created": true}, nil)
if len(fields) == 1 {
runtime.Out(map[string]interface{}{"field": fields[0], "created": true}, nil)
return nil
}
runtime.Out(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, nil)
return nil
}
func parseFieldCreateBodies(pc *parseCtx, raw string) ([]map[string]interface{}, error) {
bodies, err := parseObjectList(pc, raw, "json")
if err != nil {
return nil, err
}
if len(bodies) == 0 {
return nil, baseFlagErrorf("--json must contain at least one field JSON object")
}
return bodies, nil
}
func executeFieldUpdate(runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
baseToken := runtime.Str("base-token")

View File

@@ -23,8 +23,8 @@ var DocMediaUpload = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard", Required: true},
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard)", Required: true},
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard | mindnote_image", Required: true},
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard, mindnote token for mindnote)", Required: true},
{Name: "doc-id", Desc: "document ID (for drive_route_token)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {

View File

@@ -18,6 +18,7 @@ func v2CreateFlags() []common.Flag {
return []common.Flag{
{Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as <title>...</title> so the title wins over later content titles"},
{Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
{Name: "reference-map", Desc: docsReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
{Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "parent-token", Desc: "parent folder token or wiki node token; mutually exclusive with --parent-position"},
{Name: "parent-position", Desc: "parent position such as my_library; mutually exclusive with --parent-token"},
@@ -32,8 +33,8 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
if runtime.Changed("title") && title == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--title must not be empty").WithParam("--title")
}
if runtime.Str("content") == "" && title == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content")
if err := validateDocsV2ReferenceMapFlags(runtime); err != nil {
return err
}
if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parent-token and --parent-position are mutually exclusive").WithParams(
@@ -41,11 +42,21 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
errs.InvalidParam{Name: "--parent-position", Reason: "mutually exclusive with --parent-token"},
)
}
if runtime.Str("content") == "" && title == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content")
}
if runtime.Str("content") != "" {
_, err := resolveDocsV2ContentReferenceMap(runtime)
return err
}
return nil
}
func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := buildCreateBody(runtime)
body, err := buildCreateBodyWithHTML5ReferenceMap(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
desc := "OpenAPI: create document"
if runtime.IsBot() {
desc += ". After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document."
@@ -57,7 +68,10 @@ func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.D
}
func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
body := buildCreateBody(runtime)
body, err := buildCreateBodyWithHTML5ReferenceMap(runtime)
if err != nil {
return err
}
data, err := doDocAPI(runtime, "POST", "/open-apis/docs_ai/v1/documents", body)
if err != nil {
@@ -86,7 +100,10 @@ func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
}
func buildCreateContent(runtime *common.RuntimeContext) string {
content := runtime.Str("content")
return buildCreateContentWithBody(runtime, runtime.Str("content"))
}
func buildCreateContentWithBody(runtime *common.RuntimeContext, content string) string {
title := strings.TrimSpace(runtime.Str("title"))
if title == "" {
return content

View File

@@ -14,7 +14,7 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
const docsFetchExtraParam = `{"enable_user_cite_reference_map":true}`
const docsFetchExtraParam = `{"enable_user_cite_reference_map":true,"return_html5_block_data":true}`
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
func v2FetchFlags() []common.Flag {
@@ -71,6 +71,9 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
if err != nil {
return err
}
if err := processHTML5BlockReferenceMapForFetch(runtime, effectiveFetchFormat(runtime), ref.Token, data); err != nil {
return err
}
if warning := addFetchDetailDowngradeWarning(runtime, data); warning != "" && runtime.Format == "pretty" {
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning)
}

View File

@@ -505,14 +505,14 @@ func TestBuildFetchBodyIncludesFetchExtraParamByDefault(t *testing.T) {
if got["enable_user_cite_reference_map"] != true {
t.Fatalf("enable_user_cite_reference_map = %#v, want true in %#v", got["enable_user_cite_reference_map"], got)
}
if _, ok := got["return_html5_block_data"]; ok {
t.Fatalf("extra_param should not request html5 block data: %#v", got)
if got["return_html5_block_data"] != true {
t.Fatalf("return_html5_block_data = %#v, want true in %#v", got["return_html5_block_data"], got)
}
if _, ok := got["reference_map_mode"]; ok {
t.Fatalf("extra_param should not use legacy reference_map_mode: %#v", got)
}
if len(got) != 1 {
t.Fatalf("extra_param should only contain fetch reference_map toggle: %#v", got)
if len(got) != 2 {
t.Fatalf("extra_param should only contain fetch reference_map and html5 data toggles: %#v", got)
}
}
@@ -579,6 +579,46 @@ func TestDocsFetchIMMarkdownRequestsMarkdownFromAPI(t *testing.T) {
}
}
func TestDocsFetchIMMarkdownIgnoresHTML5BlockInsideCodeFence(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-im-markdown-code-fence"))
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcnFetchIMMarkdownFence/fetch", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcnFetchIMMarkdownFence",
"revision_id": float64(1),
"content": "```xml\n<html5-block data-ref=\"html5_1\"></html5-block>\n```\n",
},
})
err := mountAndRunDocs(t, DocsFetch, []string{
"+fetch",
"--doc", "doxcnFetchIMMarkdownFence",
"--doc-format", "im-markdown",
"--format", "json",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode output: %v\nraw=%s", err, stdout.String())
}
if errField, ok := envelope["error"]; ok {
t.Fatalf("fetch output should not contain error: %#v", errField)
}
data, _ := envelope["data"].(map[string]interface{})
doc, _ := data["document"].(map[string]interface{})
content, _ := doc["content"].(string)
if !strings.Contains(content, "```xml\n<html5-block data-ref=\"html5_1\"></html5-block>\n```") {
t.Fatalf("fenced html5-block should stay in content, got:\n%s", content)
}
if _, ok := doc["reference_map"]; ok {
t.Fatalf("fenced html5-block should not create reference_map side effects: %#v", doc["reference_map"])
}
}
func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) {
t.Parallel()

View File

@@ -5,6 +5,7 @@ package doc
import (
"context"
"errors"
"os"
"strings"
"testing"
@@ -63,6 +64,39 @@ func TestDocsUpdateDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
}
}
func TestBuildUpdateBodyWithHTML5ReferenceMapReportsPathError(t *testing.T) {
t.Parallel()
runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
"content": `<html5-block path="@missing.html"></html5-block>`,
})
_, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
if err == nil {
t.Fatal("buildUpdateBodyWithHTML5ReferenceMap() succeeded, want error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf() ok = false for %T (%v)", err, err)
}
if p.Category != errs.CategoryValidation {
t.Fatalf("category = %q, want %q", p.Category, errs.CategoryValidation)
}
if p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error type = %T, want *errs.ValidationError", err)
}
if validationErr.Param != "path" {
t.Fatalf("param = %q, want path", validationErr.Param)
}
if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("error should preserve os.ErrNotExist cause, got: %v", err)
}
}
func TestDocsUpdateV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
t.Parallel()

View File

@@ -24,7 +24,9 @@ var validCommandsV2 = map[string]bool{
"append": true,
}
const docsUpdateReferenceMapFlagDesc = "结构化 `reference_map` JSON object `--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`。"
const docsReferenceMapFlagDesc = "结构化 `reference_map` JSON object必须与 `--content` 一起使用。普通写入优先把结构写在正文里;`--reference-map` 主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。"
const docsUpdateReferenceMapFlagDesc = docsReferenceMapFlagDesc
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
func v2UpdateFlags() []common.Flag {
@@ -115,13 +117,20 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command append requires --content").WithParam("--content")
}
}
if content != "" {
_, err := resolveDocsV2ContentReferenceMap(runtime)
return err
}
return nil
}
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
ref, _ := parseDocumentRef(runtime.Str("doc"))
body, _ := buildUpdateBodyWithReferenceMap(runtime)
body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
return common.NewDryRunAPI().
PUT(apiPath).
@@ -134,7 +143,7 @@ func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocumentRef(runtime.Str("doc"))
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
body, err := buildUpdateBodyWithReferenceMap(runtime)
body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
if err != nil {
return err
}

View File

@@ -0,0 +1,696 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"bytes"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"path/filepath"
"regexp"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
const (
html5BlockTag = "html5-block"
html5BlockPathAttr = "path"
html5BlockDataRefAttr = "data-ref"
html5BlockDataAttr = "data"
html5BlockReferenceRoot = "doc-fetch-resources"
html5BlockReferenceMaxRaw = 1024
)
var (
html5BlockStartTagPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>`)
html5BlockElementPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>(.*?)</html5-block>`)
html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
)
type html5BlockReferenceEntry struct {
Data string `json:"data,omitempty"`
Path string `json:"path,omitempty"`
UserID string `json:"user_id,omitempty"`
}
type html5BlockReferenceMap map[string]map[string]html5BlockReferenceEntry
type docsV2WriteInput struct {
Content string
ReferenceMap map[string]interface{}
}
type html5BlockAttr struct {
Name string
Value string
}
type html5BlockStartTag struct {
Attrs []html5BlockAttr
SelfClosing bool
}
func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := buildCreateBody(runtime)
if runtime.Str("content") == "" && !runtime.Changed("reference-map") {
return body, nil
}
input, err := resolveDocsV2ContentReferenceMap(runtime)
if err != nil {
return nil, err
}
body["content"] = buildCreateContentWithBody(runtime, input.Content)
if len(input.ReferenceMap) > 0 {
body["reference_map"] = input.ReferenceMap
}
return body, nil
}
func buildUpdateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := buildUpdateBody(runtime)
input, err := resolveDocsV2ContentReferenceMap(runtime)
if err != nil {
return nil, err
}
if input.Content != "" {
body["content"] = input.Content
}
if len(input.ReferenceMap) > 0 {
body["reference_map"] = input.ReferenceMap
}
return body, nil
}
func validateDocsV2ReferenceMapFlags(runtime *common.RuntimeContext) error {
if runtime.Changed("reference-map") && runtime.Str("content") == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map requires --content").WithParam("--reference-map")
}
return nil
}
func resolveDocsV2ContentReferenceMap(runtime *common.RuntimeContext) (docsV2WriteInput, error) {
input := docsV2WriteInput{Content: runtime.Str("content")}
if raw := runtime.Str("reference-map"); strings.TrimSpace(raw) != "" {
refMap, err := parseReferenceMapObject(raw, "--reference-map")
if err != nil {
return docsV2WriteInput{}, err
}
input.ReferenceMap = refMap
}
return prepareDocsV2WriteInput(runtime, input)
}
func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteInput) (docsV2WriteInput, error) {
refMap := cloneReferenceMapObject(input.ReferenceMap)
html5RefMap, err := html5ReferenceMapFromObject(refMap)
if err != nil {
return docsV2WriteInput{}, err
}
content, html5RefMap, err := prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), input.Content, html5RefMap)
if err != nil {
return docsV2WriteInput{}, err
}
if err := resolveReferenceMapPaths(runtime, html5RefMap); err != nil {
return docsV2WriteInput{}, err
}
refMap = mergeHTML5ReferenceMap(refMap, html5RefMap)
return docsV2WriteInput{
Content: content,
ReferenceMap: refMap,
}, nil
}
func parseReferenceMapObject(raw string, label string) (map[string]interface{}, error) {
if len(bytes.TrimSpace([]byte(raw))) == 0 || string(bytes.TrimSpace([]byte(raw))) == "null" {
return nil, nil
}
var refMap map[string]interface{}
if err := json.Unmarshal([]byte(raw), &refMap); err != nil {
return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam(label).WithCause(err)
}
return refMap, nil
}
func parseHTML5BlockReferenceMapBytes(raw []byte, label string) (html5BlockReferenceMap, error) {
if len(bytes.TrimSpace(raw)) == 0 || string(bytes.TrimSpace(raw)) == "null" {
return nil, nil
}
var refMap html5BlockReferenceMap
if err := json.Unmarshal(raw, &refMap); err != nil {
return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam(label).WithCause(err)
}
return compactReferenceMap(refMap), nil
}
func prepareHTML5BlockWriteContent(runtime *common.RuntimeContext, format string, content string, refMap html5BlockReferenceMap) (string, html5BlockReferenceMap, error) {
if !strings.Contains(content, "<html5-block") {
return content, compactReferenceMap(refMap), nil
}
if err := validateHTML5BlockWriteElementBodies(format, content); err != nil {
return "", nil, err
}
refMap = cloneReferenceMap(refMap)
if refMap == nil {
refMap = html5BlockReferenceMap{}
}
ensureReferenceGroup(refMap, html5BlockTag)
nextRef := nextHTML5BlockRef(refMap)
rewrite := func(segment string) (string, error) {
return rewriteHTML5BlockStartTags(segment, func(raw string) (string, error) {
tag, err := parseHTML5BlockStartTag(raw)
if err != nil {
return "", common.ValidationErrorf("invalid html5-block tag: %v", err).WithParam("html5-block")
}
if tag.hasAttr(html5BlockDataAttr) {
return "", common.ValidationErrorf("html5-block data is reserved for SDK internals; use data-ref with reference_map or path=\"@relative.html\"").WithParam("html5-block")
}
pathValue, hasPath := tag.attr(html5BlockPathAttr)
dataRef, hasDataRef := tag.attr(html5BlockDataRefAttr)
if hasPath && hasDataRef {
return "", common.ValidationErrorf("html5-block cannot contain both path and data-ref").WithParam("html5-block")
}
if hasDataRef {
ref := strings.TrimSpace(dataRef)
if ref == "" {
return "", common.ValidationErrorf("html5-block data-ref cannot be empty").WithParam("data-ref")
}
if _, ok := refMap[html5BlockTag][ref]; !ok {
return "", common.ValidationErrorf("reference_map.%s.%s is required for html5-block data-ref", html5BlockTag, ref).WithParam("reference_map")
}
return tag.render(false), nil
}
if !hasPath {
return "", common.ValidationErrorf("html5-block requires path=\"@relative.html\" or data-ref with reference_map").WithParam("html5-block")
}
data, err := readHTML5BlockPath(runtime, pathValue, "html5-block path")
if err != nil {
return "", err
}
ref := nextRef()
refMap[html5BlockTag][ref] = html5BlockReferenceEntry{Data: data}
tag.removeAttrs(html5BlockPathAttr, html5BlockDataRefAttr, html5BlockDataAttr)
tag.Attrs = append(tag.Attrs, html5BlockAttr{Name: html5BlockDataRefAttr, Value: ref})
return tag.render(false), nil
})
}
var (
out string
err error
)
if strings.TrimSpace(format) == "markdown" {
out = applyOutsideCodeFences(content, func(segment string) string {
if err != nil {
return segment
}
outSegment, rewriteErr := rewrite(segment)
if rewriteErr != nil {
err = rewriteErr
return segment
}
return outSegment
})
} else {
out, err = rewrite(content)
}
if err != nil {
return "", nil, err
}
return out, compactReferenceMap(refMap), nil
}
func validateHTML5BlockWriteElementBodies(format string, content string) error {
validateSegment := func(segment string) error {
matches := html5BlockElementPattern.FindAllStringSubmatchIndex(segment, -1)
for _, match := range matches {
if len(match) < 4 || match[2] < 0 || match[3] < 0 {
continue
}
if strings.TrimSpace(segment[match[2]:match[3]]) != "" {
return common.ValidationErrorf("html5-block content must be loaded from path=\"@relative.html\" or reference_map; remove content between <html5-block> and </html5-block>").WithParam("html5-block")
}
}
return nil
}
if strings.TrimSpace(format) != "markdown" {
return validateSegment(content)
}
var validateErr error
_ = applyOutsideCodeFences(content, func(segment string) string {
if validateErr != nil {
return segment
}
validateErr = validateSegment(segment)
return segment
})
return validateErr
}
func processHTML5BlockReferenceMapForFetch(runtime *common.RuntimeContext, format string, docToken string, data map[string]interface{}) error {
doc, _ := data["document"].(map[string]interface{})
if doc == nil {
return nil
}
content, _ := doc["content"].(string)
if !hasProcessableHTML5Block(format, content) {
return nil
}
refMap, err := referenceMapFromDocument(doc)
if err != nil {
return err
}
group := refMap[html5BlockTag]
if group == nil {
return common.ValidationErrorf("document.reference_map.%s is required for fetched html5-block content", html5BlockTag).WithParam("reference_map")
}
if err := validateFetchedHTML5BlockRefs(format, content, refMap); err != nil {
return err
}
changed := false
for ref, entry := range group {
if entry.Data == "" || len([]byte(entry.Data)) <= html5BlockReferenceMaxRaw {
continue
}
relPath, err := writeHTML5BlockReferenceFile(runtime, docToken, ref, entry.Data)
if err != nil {
return err
}
entry.Data = ""
entry.Path = "@" + filepath.ToSlash(relPath)
group[ref] = entry
changed = true
}
if changed {
doc["reference_map"] = refMap
}
return nil
}
func referenceMapFromDocument(doc map[string]interface{}) (html5BlockReferenceMap, error) {
raw, ok := doc["reference_map"]
if !ok || raw == nil {
return nil, common.ValidationErrorf("document.reference_map is required for fetched html5-block content").WithParam("reference_map")
}
refMap, err := referenceMapFromValue(raw, "document.reference_map")
if err != nil {
return nil, err
}
if len(refMap) == 0 {
return nil, common.ValidationErrorf("document.reference_map is required for fetched html5-block content").WithParam("reference_map")
}
return refMap, nil
}
func referenceMapFromValue(value interface{}, label string) (html5BlockReferenceMap, error) {
if typed, ok := value.(html5BlockReferenceMap); ok {
return compactReferenceMap(typed), nil
}
raw, err := json.Marshal(value)
if err != nil {
return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam("reference_map").WithCause(err)
}
return parseHTML5BlockReferenceMapBytes(raw, label)
}
func validateFetchedHTML5BlockRefs(format string, content string, refMap html5BlockReferenceMap) error {
validateSegment := func(segment string) error {
_, err := rewriteHTML5BlockStartTags(segment, func(raw string) (string, error) {
tag, parseErr := parseHTML5BlockStartTag(raw)
if parseErr != nil {
return raw, common.ValidationErrorf("invalid html5-block tag in fetched content: %v", parseErr).WithParam("html5-block")
}
ref, ok := tag.attr(html5BlockDataRefAttr)
if !ok || strings.TrimSpace(ref) == "" {
return raw, common.ValidationErrorf("fetched html5-block is missing data-ref; cannot resolve HTML reference").WithParam("html5-block")
}
ref = strings.TrimSpace(ref)
if _, ok := refMap[html5BlockTag][ref]; !ok {
return raw, common.ValidationErrorf("document.reference_map.%s.%s is missing; cannot resolve html5-block. Re-run fetch or check that the upstream document.reference_map field includes this ref.", html5BlockTag, ref).WithParam("reference_map")
}
return raw, nil
})
return err
}
if strings.TrimSpace(format) != "markdown" {
return validateSegment(content)
}
var validateErr error
_ = applyOutsideCodeFences(content, func(segment string) string {
if validateErr != nil {
return segment
}
validateErr = validateSegment(segment)
return segment
})
return validateErr
}
func resolveReferenceMapPaths(runtime *common.RuntimeContext, refMap html5BlockReferenceMap) error {
for typ, group := range refMap {
for ref, entry := range group {
if strings.TrimSpace(entry.Path) == "" {
continue
}
if entry.Data != "" {
return common.ValidationErrorf("reference_map.%s.%s must use either data or path, not both", typ, ref).WithParam("reference_map")
}
data, err := readHTML5BlockPath(runtime, entry.Path, fmt.Sprintf("reference_map.%s.%s.path", typ, ref))
if err != nil {
return err
}
entry.Data = data
entry.Path = ""
group[ref] = entry
}
}
return nil
}
func readHTML5BlockPath(runtime *common.RuntimeContext, pathValue string, label string) (string, error) {
pathRaw := strings.TrimSpace(pathValue)
if !strings.HasPrefix(pathRaw, "@") {
return "", common.ValidationErrorf("%s %q must start with @, for example @widget.html", label, pathValue).WithParam("path")
}
relPath := strings.TrimSpace(strings.TrimPrefix(pathRaw, "@"))
if relPath == "" {
return "", common.ValidationErrorf("%s cannot be empty after @", label).WithParam("path")
}
clean := filepath.Clean(relPath)
if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", common.ValidationErrorf("%s %q must be a relative path within the current working directory", label, pathValue).WithParam("path")
}
if strings.ToLower(filepath.Ext(clean)) != ".html" {
return "", common.ValidationErrorf("%s %q must point to a .html file", label, pathValue).WithParam("path")
}
data, err := cmdutil.ReadInputFile(runtime.FileIO(), clean)
if err != nil {
return "", common.ValidationErrorf("%s %q cannot be read from the current working directory; check that the file exists relative to where lark-cli is running: %v", label, clean, err).WithParam("path").WithCause(err)
}
return string(data), nil
}
func hasProcessableHTML5Block(format string, content string) bool {
if !strings.Contains(content, "<html5-block") {
return false
}
if strings.TrimSpace(format) != "markdown" {
return true
}
found := false
_ = applyOutsideCodeFences(content, func(segment string) string {
if strings.Contains(segment, "<html5-block") {
found = true
}
return segment
})
return found
}
func applyOutsideCodeFences(content string, fn func(segment string) string) string {
var out strings.Builder
var segment strings.Builder
inFence := false
flush := func() {
if segment.Len() == 0 {
return
}
out.WriteString(fn(segment.String()))
segment.Reset()
}
for _, line := range strings.SplitAfter(content, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
if !inFence {
flush()
inFence = true
} else {
inFence = false
}
out.WriteString(line)
continue
}
if inFence {
out.WriteString(line)
} else {
segment.WriteString(line)
}
}
flush()
return out.String()
}
func cloneReferenceMap(refMap html5BlockReferenceMap) html5BlockReferenceMap {
if len(refMap) == 0 {
return nil
}
out := make(html5BlockReferenceMap, len(refMap))
for typ, group := range refMap {
if len(group) == 0 {
continue
}
outGroup := make(map[string]html5BlockReferenceEntry, len(group))
for ref, entry := range group {
outGroup[ref] = entry
}
out[typ] = outGroup
}
return out
}
func cloneReferenceMapObject(refMap map[string]interface{}) map[string]interface{} {
if len(refMap) == 0 {
return nil
}
out := make(map[string]interface{}, len(refMap))
for key, value := range refMap {
out[key] = value
}
return out
}
func html5ReferenceMapFromObject(refMap map[string]interface{}) (html5BlockReferenceMap, error) {
if len(refMap) == 0 {
return nil, nil
}
group, ok := refMap[html5BlockTag]
if !ok || group == nil {
return nil, nil
}
return referenceMapFromValue(map[string]interface{}{html5BlockTag: group}, "reference_map."+html5BlockTag)
}
func mergeHTML5ReferenceMap(refMap map[string]interface{}, html5RefMap html5BlockReferenceMap) map[string]interface{} {
group := html5RefMap[html5BlockTag]
if len(group) == 0 {
return refMap
}
if refMap == nil {
refMap = map[string]interface{}{}
}
refMap[html5BlockTag] = group
return refMap
}
func compactReferenceMap(refMap html5BlockReferenceMap) html5BlockReferenceMap {
if len(refMap) == 0 {
return nil
}
out := make(html5BlockReferenceMap, len(refMap))
for typ, group := range refMap {
if len(group) == 0 {
continue
}
out[typ] = group
}
if len(out) == 0 {
return nil
}
return out
}
func ensureReferenceGroup(refMap html5BlockReferenceMap, typ string) {
if refMap[typ] == nil {
refMap[typ] = map[string]html5BlockReferenceEntry{}
}
}
func nextHTML5BlockRef(refMap html5BlockReferenceMap) func() string {
next := 1
return func() string {
for {
ref := fmt.Sprintf("html5_%d", next)
next++
if _, exists := refMap[html5BlockTag][ref]; !exists {
return ref
}
}
}
}
func writeHTML5BlockReferenceFile(runtime *common.RuntimeContext, docToken string, ref string, html string) (string, error) {
if !isSafeHTML5BlockResourceName(docToken) {
return "", common.ValidationErrorf("document_id %q cannot be used as a resource directory name", docToken).WithParam("document_id")
}
if !isSafeHTML5BlockResourceName(ref) {
return "", common.ValidationErrorf("html5-block data-ref %q cannot be used as a file name", ref).WithParam("data-ref")
}
relPath := filepath.Join(html5BlockReferenceRoot, docToken, ref+".html")
data := []byte(html)
_, err := runtime.FileIO().Save(relPath, fileio.SaveOptions{
ContentType: "text/html; charset=utf-8",
ContentLength: int64(len(data)),
}, bytes.NewReader(data))
if err != nil {
if errors.Is(err, fileio.ErrPathValidation) {
return "", common.ValidationErrorf("cannot write html5-block reference file %q: %v", relPath, err).WithParam("reference_map").WithCause(err)
}
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot write html5-block reference file %q: %v", relPath, err).WithCause(err)
}
return relPath, nil
}
func isSafeHTML5BlockResourceName(name string) bool {
return name != "." && name != ".." && html5BlockSafeNamePattern.MatchString(name)
}
func rewriteHTML5BlockStartTags(content string, fn func(raw string) (string, error)) (string, error) {
var rewriteErr error
out := html5BlockStartTagPattern.ReplaceAllStringFunc(content, func(raw string) string {
if rewriteErr != nil {
return raw
}
rewritten, err := fn(raw)
if err != nil {
rewriteErr = err
return raw
}
return rewritten
})
if rewriteErr != nil {
return "", rewriteErr
}
return out, nil
}
func parseHTML5BlockStartTag(raw string) (html5BlockStartTag, error) {
trimmed := strings.TrimSpace(raw)
selfClosing := strings.HasSuffix(trimmed, "/>")
decoder := xml.NewDecoder(strings.NewReader(raw))
for {
tok, err := decoder.Token()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return html5BlockStartTag{}, err
}
start, ok := tok.(xml.StartElement)
if !ok {
continue
}
if start.Name.Local != html5BlockTag {
return html5BlockStartTag{}, fmt.Errorf("expected <%s>, got <%s>", html5BlockTag, start.Name.Local) //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
}
attrs := make([]html5BlockAttr, 0, len(start.Attr))
for _, attr := range start.Attr {
attrs = append(attrs, html5BlockAttr{Name: attr.Name.Local, Value: attr.Value})
}
return html5BlockStartTag{Attrs: attrs, SelfClosing: selfClosing}, nil
}
return html5BlockStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
}
func (t html5BlockStartTag) attr(name string) (string, bool) {
for _, attr := range t.Attrs {
if attr.Name == name {
return attr.Value, true
}
}
return "", false
}
func (t html5BlockStartTag) hasAttr(name string) bool {
_, ok := t.attr(name)
return ok
}
func (t *html5BlockStartTag) removeAttrs(names ...string) {
remove := make(map[string]struct{}, len(names))
for _, name := range names {
remove[name] = struct{}{}
}
attrs := t.Attrs[:0]
for _, attr := range t.Attrs {
if _, ok := remove[attr.Name]; ok {
continue
}
attrs = append(attrs, attr)
}
t.Attrs = attrs
}
func (t html5BlockStartTag) render(selfClosing bool) string {
var b strings.Builder
b.WriteByte('<')
b.WriteString(html5BlockTag)
for _, attr := range t.Attrs {
b.WriteByte(' ')
b.WriteString(attr.Name)
b.WriteString(`="`)
b.WriteString(escapeXMLAttr(attr.Value))
b.WriteByte('"')
}
if selfClosing {
b.WriteString("/>")
} else {
b.WriteByte('>')
}
if t.SelfClosing && !selfClosing {
b.WriteString("</")
b.WriteString(html5BlockTag)
b.WriteByte('>')
}
return b.String()
}
func escapeXMLAttr(value string) string {
var b strings.Builder
for _, r := range value {
switch r {
case '&':
b.WriteString("&amp;")
case '<':
b.WriteString("&lt;")
case '>':
b.WriteString("&gt;")
case '"':
b.WriteString("&quot;")
case '\'':
b.WriteString("&apos;")
default:
b.WriteRune(r)
}
}
return b.String()
}

View File

@@ -0,0 +1,563 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestDocsV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
for name, flags := range map[string][]common.Flag{
"create": v2CreateFlags(),
"update": v2UpdateFlags(),
} {
t.Run(name, func(t *testing.T) {
flag := findDocsTestFlag(flags, "reference-map")
if flag.Name == "" {
t.Fatal("reference-map flag not found")
}
if flag.Hidden {
t.Fatal("reference-map flag should be public")
}
if !hasDocsTestInput(flag, common.File) || !hasDocsTestInput(flag, common.Stdin) {
t.Fatalf("reference-map Input = %#v, want file and stdin", flag.Input)
}
if !strings.Contains(flag.Desc, "@reference-map.json") {
t.Fatalf("reference-map help should mention @file support, got %q", flag.Desc)
}
})
}
}
func TestDocsV2InputFlagIsNotAvailable(t *testing.T) {
for name, flags := range map[string][]common.Flag{
"create": v2CreateFlags(),
"update": v2UpdateFlags(),
} {
t.Run(name, func(t *testing.T) {
for _, flag := range flags {
if flag.Name == "input" {
t.Fatalf("%s should not expose input flag", name)
}
}
})
}
}
func TestDocsUpdateV2ReferenceMapPreservesGenericGroups(t *testing.T) {
t.Parallel()
runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
"command": "append",
"content": `<p><widget data-ref="r1"></widget></p>`,
"reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
})
body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
if err != nil {
t.Fatalf("buildUpdateBodyWithHTML5ReferenceMap: %v", err)
}
refMap, ok := body["reference_map"].(map[string]interface{})
if !ok {
t.Fatalf("reference_map = %#v, want object", body["reference_map"])
}
widget, _ := refMap["widget"].(map[string]interface{})
r1, _ := widget["r1"].(map[string]interface{})
if got := r1["label"]; got != "widget-ref-value" {
t.Fatalf("reference_map.widget.r1.label = %#v, want widget-ref-value; body=%#v", got, body)
}
}
func TestDocsCreateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("widget.html", []byte("<html><body>hello</body></html>"), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<title>demo</title><html5-block path="@widget.html"></html5-block>`,
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeRequestBody(t, stub.CapturedBody)
if got := body["content"].(string); !strings.Contains(got, `<html5-block data-ref="html5_1"></html5-block>`) {
t.Fatalf("content was not rewritten with data-ref: %s", got)
}
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html><body>hello</body></html>" {
t.Fatalf("reference_map html data = %q", got)
}
if _, ok := body["resources"]; ok {
t.Fatalf("request body must not use resources: %#v", body)
}
}
func findDocsTestFlag(flags []common.Flag, name string) common.Flag {
for _, flag := range flags {
if flag.Name == name {
return flag
}
}
return common.Flag{}
}
func hasDocsTestInput(flag common.Flag, input string) bool {
for _, item := range flag.Input {
if item == input {
return true
}
}
return false
}
func TestDocsUpdateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("widget.html", []byte("<section>updated</section>"), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-update"))
stub := registerDocsAIStub(reg, "PUT", "/open-apis/docs_ai/v1/documents/doxcn_doc", map[string]interface{}{
"document": map[string]interface{}{
"revision_id": float64(2),
"new_blocks": []interface{}{
map[string]interface{}{
"block_type": "html5-block",
"block_id": "blk_html5",
"block_token": "boardXXXX",
},
},
},
"result": "success",
})
err := mountAndRunDocs(t, DocsUpdate, []string{
"+update",
"--api-version", "v2",
"--doc", "doxcn_doc",
"--command", "append",
"--content", `<html5-block path="@widget.html"></html5-block>`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeRequestBody(t, stub.CapturedBody)
if got := body["content"].(string); got != `<html5-block data-ref="html5_1"></html5-block>` {
t.Fatalf("content = %q", got)
}
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<section>updated</section>" {
t.Fatalf("reference_map html data = %q", got)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode stdout: %v\n%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
doc, _ := data["document"].(map[string]interface{})
if blocks, _ := doc["new_blocks"].([]interface{}); len(blocks) != 1 {
t.Fatalf("new_blocks not preserved in stdout: %#v", doc)
}
}
func TestDocsFetchV2HTML5BlockKeepsSmallReferenceMapInline(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch"))
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_fetch",
"revision_id": float64(3),
"content": `<docx><html5-block data-ref="html5_1"></html5-block></docx>`,
"reference_map": map[string]interface{}{
"html5-block": map[string]interface{}{
"html5_1": map[string]interface{}{"data": "<html><main>fetched</main></html>"},
},
},
},
"tips": "must_read_html_code",
})
err := mountAndRunDocs(t, DocsFetch, []string{
"+fetch",
"--api-version", "v2",
"--doc", "doxcn_fetch",
"--format", "json",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
written := filepath.Join(dir, html5BlockReferenceRoot, "doxcn_fetch", "html5_1.html")
if _, err := os.Stat(written); err == nil {
t.Fatalf("small html should stay inline, got file %s", written)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode stdout: %v\n%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
doc, _ := data["document"].(map[string]interface{})
if got := doc["content"].(string); !strings.Contains(got, `<html5-block data-ref="html5_1"></html5-block>`) {
t.Fatalf("content should keep data-ref: %s", got)
}
refMap := decodeHTML5ReferenceMap(t, doc["reference_map"])
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html><main>fetched</main></html>" {
t.Fatalf("reference_map html data = %q", got)
}
if _, ok := doc["resources"]; ok {
t.Fatalf("fetch output must not use resources: %#v", doc)
}
if _, ok := data["suggestions"]; ok {
t.Fatalf("CLI must not add suggestions; service tips is enough: %#v", data["suggestions"])
}
if got := data["tips"]; got != "must_read_html_code" {
t.Fatalf("tips should be preserved from service response, got %#v", got)
}
}
func TestDocsFetchV2HTML5BlockLargeReferenceMapUsesPath(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
largeHTML := "<html><main>" + strings.Repeat("x", html5BlockReferenceMaxRaw+1) + "</main></html>"
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch-large"))
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_fetch",
"revision_id": float64(3),
"content": `<docx><html5-block data-ref="html5_1"></html5-block></docx>`,
"reference_map": map[string]interface{}{
"html5-block": map[string]interface{}{
"html5_1": map[string]interface{}{"data": largeHTML},
},
},
},
})
err := mountAndRunDocs(t, DocsFetch, []string{
"+fetch",
"--api-version", "v2",
"--doc", "doxcn_fetch",
"--format", "json",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
written := filepath.Join(dir, html5BlockReferenceRoot, "doxcn_fetch", "html5_1.html")
raw, err := os.ReadFile(written)
if err != nil {
t.Fatalf("ReadFile(%s) error: %v", written, err)
}
if string(raw) != largeHTML {
t.Fatalf("materialized html = %q", raw)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode stdout: %v\n%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
doc, _ := data["document"].(map[string]interface{})
if got := doc["content"].(string); strings.Contains(got, `path="@`) || !strings.Contains(got, `data-ref="html5_1"`) {
t.Fatalf("content should keep data-ref and not path: %s", got)
}
refMap := decodeHTML5ReferenceMap(t, doc["reference_map"])
entry := refMap[html5BlockTag]["html5_1"]
if entry.Data != "" || entry.Path != "@doc-fetch-resources/doxcn_fetch/html5_1.html" {
t.Fatalf("large html should be represented as path, got %#v", entry)
}
}
func TestDocsCreateV2HTML5BlockReferenceMapAdvancedInput(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<html5-block data-ref="html5_1"></html5-block>`,
"--reference-map", `{"html5-block":{"html5_1":{"data":"<html></html>"}}}`,
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeRequestBody(t, stub.CapturedBody)
if got := body["content"].(string); got != `<html5-block data-ref="html5_1"></html5-block>` {
t.Fatalf("content = %q", got)
}
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html></html>" {
t.Fatalf("reference_map html data = %q", got)
}
}
func TestDocsCreateV2HTML5BlockReferenceMapFromFile(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("reference-map.json", []byte(`{"html5-block":{"html5_1":{"data":"<html>from file</html>"}}}`), 0o600); err != nil {
t.Fatalf("WriteFile(reference-map.json) error: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<html5-block data-ref="html5_1"></html5-block>`,
"--reference-map", "@reference-map.json",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeRequestBody(t, stub.CapturedBody)
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html>from file</html>" {
t.Fatalf("reference_map html data = %q", got)
}
}
func TestDocsCreateV2HTML5BlockRejectsMissingReferenceMap(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<html5-block data-ref="html5_1"></html5-block>`,
"--as", "user",
})
if err == nil || !strings.Contains(err.Error(), `reference_map.html5-block.html5_1 is required`) {
t.Fatalf("expected missing reference_map error, got: %v", err)
}
}
func TestDocsCreateV2HTML5BlockRejectsInternalDataAttr(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<html5-block data="PGh0bWw+PC9odG1sPg=="></html5-block>`,
"--as", "user",
})
if err == nil || !strings.Contains(err.Error(), `html5-block data is reserved for SDK internals`) {
t.Fatalf("expected internal data attr error, got: %v", err)
}
}
func TestDocsCreateV2HTML5BlockPathReadFailure(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<html5-block path="@missing.html"></html5-block>`,
"--as", "user",
})
if err == nil || !strings.Contains(err.Error(), `html5-block path "missing.html" cannot be read from the current working directory`) {
t.Fatalf("expected path read error, got: %v", err)
}
}
func TestDocsCreateV2HTML5BlockRejectsInlineContent(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("widget.html", []byte("<section>from file</section>"), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<html5-block path="@widget.html"><section>inline</section></html5-block>`,
"--as", "user",
})
if err == nil || !strings.Contains(err.Error(), `html5-block content must be loaded from path="@relative.html"`) {
t.Fatalf("expected inline content error, got: %v", err)
}
}
func TestDocsFetchV2MissingHTML5BlockReferenceFails(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch-missing"))
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_fetch",
"revision_id": float64(3),
"content": `<docx><html5-block data-ref="html5_missing"></html5-block></docx>`,
"reference_map": map[string]interface{}{
"html5-block": map[string]interface{}{
"html5_1": map[string]interface{}{"data": "<html></html>"},
},
},
},
})
err := mountAndRunDocs(t, DocsFetch, []string{
"+fetch",
"--api-version", "v2",
"--doc", "doxcn_fetch",
"--format", "json",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "Re-run fetch or check that the upstream document.reference_map field includes this ref") {
t.Fatalf("expected missing reference_map error, got: %v", err)
}
}
func TestHTML5BlockMarkdownCodeFenceIsIgnored(t *testing.T) {
for _, fence := range []string{"```", "~~~"} {
t.Run(fence, func(t *testing.T) {
content := fence + "xml\n<html5-block data-ref=\"html5_1\"></html5-block>\n" + fence + "\n"
if hasProcessableHTML5Block("markdown", content) {
t.Fatalf("html5-block inside markdown code fence should be ignored")
}
})
}
}
func TestWriteHTML5BlockReferenceFileRejectsDotNames(t *testing.T) {
runtime := newFetchShortcutTestRuntime(t, "", nil)
tests := []struct {
name string
docToken string
ref string
want string
}{
{name: "dot doc token", docToken: ".", ref: "html5_1", want: "document_id"},
{name: "dotdot doc token", docToken: "..", ref: "html5_1", want: "document_id"},
{name: "dot ref", docToken: "doxcn_fetch", ref: ".", want: "data-ref"},
{name: "dotdot ref", docToken: "doxcn_fetch", ref: "..", want: "data-ref"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := writeHTML5BlockReferenceFile(runtime, tt.docToken, tt.ref, "<html></html>")
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("writeHTML5BlockReferenceFile() error = %v, want %q", err, tt.want)
}
})
}
}
func TestPrepareHTML5BlockWriteContentMarkdownRaw(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("widget.html", []byte("<html><body>markdown</body></html>"), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--doc-format", "markdown",
"--content", "before\n<html5-block path=\"@widget.html\"></html5-block>\nafter",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeRequestBody(t, stub.CapturedBody)
if got := body["content"].(string); !strings.Contains(got, `<html5-block data-ref="html5_1"></html5-block>`) {
t.Fatalf("content was not rewritten: %s", got)
}
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html><body>markdown</body></html>" {
t.Fatalf("reference_map html data = %q", got)
}
}
func registerDocsAIStub(reg *httpmock.Registry, method string, url string, data map[string]interface{}) *httpmock.Stub {
stub := &httpmock.Stub{
Method: method,
URL: url,
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
}
reg.Register(stub)
return stub
}
func decodeRequestBody(t *testing.T, raw []byte) map[string]interface{} {
t.Helper()
var body map[string]interface{}
if err := json.Unmarshal(bytes.TrimSpace(raw), &body); err != nil {
t.Fatalf("decode request body: %v\n%s", err, raw)
}
return body
}
func decodeHTML5ReferenceMap(t *testing.T, raw interface{}) html5BlockReferenceMap {
t.Helper()
data, err := json.Marshal(raw)
if err != nil {
t.Fatalf("marshal reference_map: %v\n%#v", err, raw)
}
var refMap html5BlockReferenceMap
if err := json.Unmarshal(data, &refMap); err != nil {
t.Fatalf("decode reference_map: %v\n%s", err, data)
}
return refMap
}

View File

@@ -37,11 +37,16 @@ const (
)
type drivePullItem struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
SourceID string `json:"source_id,omitempty"`
Action string `json:"action"`
Error string `json:"error,omitempty"`
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
SourceID string `json:"source_id,omitempty"`
Action string `json:"action"`
Error string `json:"error,omitempty"`
Phase string `json:"phase,omitempty"`
ErrorClass string `json:"error_class,omitempty"`
Code int `json:"code,omitempty"`
Subtype string `json:"subtype,omitempty"`
Retryable *bool `json:"retryable,omitempty"`
}
type drivePullTarget struct {
@@ -189,6 +194,9 @@ var DrivePull = common.Shortcut{
sort.Strings(downloadablePaths)
for _, rel := range downloadablePaths {
if drivePullHasTerminalFailure(items) {
break
}
targetFile := remoteFiles[rel]
downloadToken := targetFile.DownloadToken
itemFileToken := targetFile.ItemFileToken
@@ -204,13 +212,9 @@ var DrivePull = common.Shortcut{
// pre-existing file under --if-exists=skip silently
// hides the conflict. Surface as a failure.
if info.IsDir() {
items = append(items, drivePullItem{
RelPath: rel,
FileToken: itemFileToken,
SourceID: itemSourceID,
Action: "failed",
Error: fmt.Sprintf("local path is a directory, remote is a regular file: %s", target),
})
conflictErr := errs.NewValidationError(errs.SubtypeFailedPrecondition, "local path is a directory, remote is a regular file: %s", target)
item, _ := drivePullFailedItem(rel, itemFileToken, itemSourceID, "failed", "local", conflictErr)
items = append(items, item)
failed++
downloadFailed++
continue
@@ -223,9 +227,14 @@ var DrivePull = common.Shortcut{
}
if err := drivePullDownload(ctx, runtime, downloadToken, target, targetFile.ModifiedTime); err != nil {
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "failed", Error: err.Error()})
item, terminal := drivePullFailedItem(rel, itemFileToken, itemSourceID, "failed", "download", err)
items = append(items, item)
failed++
downloadFailed++
if terminal {
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +pull after terminal %s failure: %v\n", item.Phase, err)
break
}
continue
}
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "downloaded"})
@@ -251,7 +260,8 @@ var DrivePull = common.Shortcut{
for _, absPath := range localAbsPaths {
rel, relErr := filepath.Rel(safeRoot, absPath)
if relErr != nil {
items = append(items, drivePullItem{RelPath: absPath, Action: "delete_failed", Error: relErr.Error()})
item, _ := drivePullFailedItem(absPath, "", "", "delete_failed", "delete_local", relErr)
items = append(items, item)
failed++
continue
}
@@ -271,7 +281,9 @@ var DrivePull = common.Shortcut{
// acceptable here. Shortcuts cannot import internal/vfs
// directly (depguard rule shortcuts-no-vfs).
if err := os.Remove(absPath); err != nil { //nolint:forbidigo // see comment above
items = append(items, drivePullItem{RelPath: rel, Action: "delete_failed", Error: err.Error()})
deleteErr := errs.NewInternalError(errs.SubtypeFileIO, "delete local %q: %s", rel, err).WithCause(err)
item, _ := drivePullFailedItem(rel, "", "", "delete_failed", "delete_local", deleteErr)
items = append(items, item)
failed++
continue
}
@@ -286,6 +298,7 @@ var DrivePull = common.Shortcut{
"skipped": skipped,
"failed": failed,
"deleted_local": deletedLocal,
"aborted": drivePullHasTerminalFailure(items),
},
"items": items,
}
@@ -317,6 +330,32 @@ var DrivePull = common.Shortcut{
},
}
func drivePullFailedItem(relPath, fileToken, sourceID, action, phase string, err error) (drivePullItem, bool) {
decision := driveClassifyBatchFailure(err)
item := drivePullItem{
RelPath: relPath,
FileToken: fileToken,
SourceID: sourceID,
Action: action,
Error: err.Error(),
Phase: phase,
ErrorClass: decision.Class,
Code: decision.Code,
Subtype: decision.Subtype,
Retryable: driveBoolPtr(decision.Retryable),
}
return item, decision.Terminal
}
func drivePullHasTerminalFailure(items []drivePullItem) bool {
for _, item := range items {
if driveTerminalBatchErrorClass(item.ErrorClass) {
return true
}
}
return false
}
// drivePullDownload streams one Drive file into the local mirror target and
// then best-effort aligns the local mtime to Drive's modified_time.
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target, remoteModifiedTime string) error {

View File

@@ -1032,6 +1032,66 @@ func TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero(t *testing.T) {
}
}
func TestDrivePullAbortsAfterDownloadForbidden(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"},
map[string]interface{}{"token": "tok_b", "name": "b.txt", "type": "file"},
},
"has_more": false,
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/tok_a/download",
Status: http.StatusForbidden,
RawBody: []byte("forbidden"),
})
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
assertDrivePullPartialFailure(t, err)
summary, items := splitDrivePullStdout(t, stdout.Bytes())
if got := summary["aborted"]; got != true {
t.Fatalf("summary.aborted = %v, want true", got)
}
if got := summary["failed"]; got != float64(1) {
t.Fatalf("summary.failed = %v, want 1", got)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
item := items[0]
if item["rel_path"] != "a.txt" || item["phase"] != "download" || item["error_class"] != "permission_denied" {
t.Fatalf("unexpected failed item: %#v", item)
}
if item["code"] != float64(http.StatusForbidden) || item["retryable"] != false {
t.Fatalf("unexpected failure classification: %#v", item)
}
if _, statErr := os.Stat(filepath.Join("local", "b.txt")); !os.IsNotExist(statErr) {
t.Fatalf("b.txt should not be downloaded after terminal permission failure; stat err=%v", statErr)
}
}
// TestDrivePullDeleteLocalDoesNotEscapeViaSymlinkParentRef is the
// regression for the "link/.." escape applied to --delete-local — the
// most dangerous variant, since the bug would otherwise let the kernel

View File

@@ -29,12 +29,25 @@ const (
)
type drivePushItem struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
Action string `json:"action"`
Version string `json:"version,omitempty"`
SizeBytes int64 `json:"size_bytes,omitempty"`
Error string `json:"error,omitempty"`
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
Action string `json:"action"`
Version string `json:"version,omitempty"`
SizeBytes int64 `json:"size_bytes,omitempty"`
Error string `json:"error,omitempty"`
Phase string `json:"phase,omitempty"`
ErrorClass string `json:"error_class,omitempty"`
Code int `json:"code,omitempty"`
Subtype string `json:"subtype,omitempty"`
Retryable *bool `json:"retryable,omitempty"`
}
type driveBatchFailureDecision struct {
Class string
Code int
Subtype string
Retryable bool
Terminal bool
}
// DrivePush is a one-way, file-level mirror from a local directory onto a
@@ -248,9 +261,14 @@ var DrivePush = common.Shortcut{
continue
}
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
items = append(items, drivePushItem{RelPath: relDir, Action: "failed", Error: ensureErr.Error()})
item, terminal := drivePushFailedItem(relDir, "", "failed", "create_folder", 0, ensureErr)
items = append(items, item)
failed++
uploadFailed = true
if terminal {
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
break
}
continue
}
items = append(items, drivePushItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created"})
@@ -266,6 +284,9 @@ var DrivePush = common.Shortcut{
for _, rel := range localPaths {
localFile := localFiles[rel]
if uploadFailed && drivePushHasTerminalFailure(items) {
break
}
if entry, ok := remoteFiles[rel]; ok {
if drivePushShouldSkipExisting(localFile, entry, ifExists) {
@@ -275,9 +296,14 @@ var DrivePush = common.Shortcut{
}
parentToken, parentErr := drivePushEnsureParentToken(ctx, runtime, folderToken, rel, folderCache)
if parentErr != nil {
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "failed", SizeBytes: localFile.Size, Error: parentErr.Error()})
item, terminal := drivePushFailedItem(rel, entry.FileToken, "failed", "create_folder", localFile.Size, parentErr)
items = append(items, item)
failed++
uploadFailed = true
if terminal {
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, parentErr)
break
}
continue
}
token, version, upErr := drivePushUploadFile(ctx, runtime, localFile, entry.FileToken, parentToken)
@@ -301,9 +327,14 @@ var DrivePush = common.Shortcut{
if failedToken == "" {
failedToken = entry.FileToken
}
items = append(items, drivePushItem{RelPath: rel, FileToken: failedToken, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()})
item, terminal := drivePushFailedItem(rel, failedToken, "failed", "upload", localFile.Size, upErr)
items = append(items, item)
failed++
uploadFailed = true
if terminal {
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
break
}
continue
}
items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "overwritten", Version: version, SizeBytes: localFile.Size})
@@ -314,16 +345,26 @@ var DrivePush = common.Shortcut{
parentRel := drivePushParentRel(rel)
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
if ensureErr != nil {
items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: ensureErr.Error()})
item, terminal := drivePushFailedItem(rel, "", "failed", "create_folder", localFile.Size, ensureErr)
items = append(items, item)
failed++
uploadFailed = true
if terminal {
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
break
}
continue
}
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
if upErr != nil {
items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()})
item, terminal := drivePushFailedItem(rel, "", "failed", "upload", localFile.Size, upErr)
items = append(items, item)
failed++
uploadFailed = true
if terminal {
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
break
}
continue
}
items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "uploaded", SizeBytes: localFile.Size})
@@ -350,7 +391,11 @@ var DrivePush = common.Shortcut{
}
sort.Strings(remoteRelPaths)
abortDelete := false
for _, rel := range remoteRelPaths {
if abortDelete {
break
}
keepToken := ""
if _, ok := localFiles[rel]; ok {
if chosen, ok := remoteFiles[rel]; ok {
@@ -362,8 +407,14 @@ var DrivePush = common.Shortcut{
continue
}
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "delete_failed", Error: err.Error()})
item, terminal := drivePushFailedItem(rel, entry.FileToken, "delete_failed", "delete", 0, err)
items = append(items, item)
failed++
if terminal {
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, err)
abortDelete = true
break
}
continue
}
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "deleted_remote"})
@@ -378,6 +429,7 @@ var DrivePush = common.Shortcut{
"skipped": skipped,
"failed": failed,
"deleted_remote": deletedRemote,
"aborted": drivePushHasTerminalFailure(items),
},
"items": items,
}
@@ -507,6 +559,91 @@ func drivePushShouldSkipSmart(localFile drivePushLocalFile, remoteFile driveRemo
return cmp >= 0
}
func drivePushFailedItem(relPath, fileToken, action, phase string, sizeBytes int64, err error) (drivePushItem, bool) {
decision := driveClassifyBatchFailure(err)
item := drivePushItem{
RelPath: relPath,
FileToken: fileToken,
Action: action,
SizeBytes: sizeBytes,
Error: err.Error(),
Phase: phase,
ErrorClass: decision.Class,
Code: decision.Code,
Subtype: decision.Subtype,
Retryable: driveBoolPtr(decision.Retryable),
}
return item, decision.Terminal
}
func driveBoolPtr(v bool) *bool {
return &v
}
func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
decision := driveBatchFailureDecision{Class: "unknown", Retryable: errs.IsRetryable(err)}
problem, ok := errs.ProblemOf(err)
if !ok {
return decision
}
decision.Code = problem.Code
decision.Subtype = string(problem.Subtype)
decision.Retryable = problem.Retryable
switch {
case problem.Category == errs.CategoryAuthorization && problem.Code == 99991672:
decision.Class = "app_scope_missing"
decision.Terminal = true
case problem.Category == errs.CategoryAuthorization && problem.Code == 99991679:
decision.Class = "user_scope_missing"
decision.Terminal = true
case problem.Category == errs.CategoryAuthorization && problem.Subtype == errs.SubtypePermissionDenied:
decision.Class = "permission_denied"
decision.Terminal = true
case problem.Category == errs.CategoryNetwork && problem.Code == http.StatusForbidden:
decision.Class = "permission_denied"
decision.Terminal = true
case problem.Subtype == errs.SubtypeInvalidParameters || problem.Code == 1061002:
decision.Class = "invalid_api_parameters"
decision.Terminal = true
case problem.Subtype == errs.SubtypeRateLimit || problem.Code == 99991400:
decision.Class = "rate_limited"
decision.Terminal = true
case problem.Subtype == errs.SubtypeQuotaExceeded || problem.Code == 1061043:
decision.Class = "file_size_limit"
case problem.Code == 1062009:
decision.Class = "upload_size_mismatch"
case problem.Subtype == errs.SubtypeNotFound || problem.Code == 1061007:
decision.Class = "remote_not_found"
case problem.Subtype == errs.SubtypeServerError || problem.Code == 1061001 || problem.Code == 2200:
decision.Class = "server_error"
decision.Terminal = true
case problem.Subtype == errs.SubtypeFailedPrecondition:
decision.Class = "local_file_changed"
default:
decision.Class = string(problem.Subtype)
}
return decision
}
func drivePushHasTerminalFailure(items []drivePushItem) bool {
for _, item := range items {
if driveTerminalBatchErrorClass(item.ErrorClass) {
return true
}
}
return false
}
func driveTerminalBatchErrorClass(errorClass string) bool {
switch errorClass {
case "app_scope_missing", "user_scope_missing", "permission_denied", "invalid_api_parameters", "rate_limited", "server_error":
return true
default:
return false
}
}
func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) {
remoteFiles := make(map[string]driveRemoteEntry, len(entries))
remoteFolders := make(map[string]driveRemoteEntry, len(entries))
@@ -600,6 +737,12 @@ func drivePushEnsureParentToken(ctx context.Context, runtime *common.RuntimeCont
// the three-step prepare/part/finish flow, which mirrors drive +upload's
// existing multipart logic.
func drivePushUploadFile(ctx context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) {
if err := drivePushValidateUploadRequest(file, existingToken, parentToken); err != nil {
return "", "", err
}
if err := drivePushVerifyLocalSnapshot(runtime, file); err != nil {
return "", "", err
}
if file.Size > common.MaxDriveMediaUploadSinglePartSize {
token, err := drivePushUploadMultipart(ctx, runtime, file, existingToken, parentToken)
// Multipart finish does not return version on the existing
@@ -612,6 +755,44 @@ func drivePushUploadFile(ctx context.Context, runtime *common.RuntimeContext, fi
return drivePushUploadAll(ctx, runtime, file, existingToken, parentToken)
}
func drivePushValidateUploadRequest(file drivePushLocalFile, existingToken, parentToken string) error {
if strings.TrimSpace(file.FileName) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot upload %q: file name is empty", file.RelPath)
}
if file.Size < 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot upload %q: file size is negative", file.RelPath)
}
if strings.TrimSpace(parentToken) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot upload %q: parent folder token is empty", file.RelPath)
}
if err := validate.ResourceName(parentToken, "parent_node"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot upload %q: %s", file.RelPath, err)
}
if existingToken != "" {
if err := validate.ResourceName(existingToken, "file_token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot overwrite %q: %s", file.RelPath, err)
}
}
return nil
}
func drivePushVerifyLocalSnapshot(runtime *common.RuntimeContext, file drivePushLocalFile) error {
info, err := runtime.FileIO().Stat(file.OpenPath)
if err != nil {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "local file changed during push: %s is no longer readable: %v", file.RelPath, err).WithCause(err)
}
if !info.Mode().IsRegular() {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "local file changed during push: %s is no longer a regular file", file.RelPath)
}
if info.Size() != file.Size {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "local file changed during push: %s snapshot size no longer matches", file.RelPath)
}
if modTimer, ok := info.(interface{ ModTime() time.Time }); ok && !modTimer.ModTime().Equal(file.ModTime) {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "local file changed during push: %s snapshot modtime no longer matches", file.RelPath)
}
return nil
}
func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) {
f, err := runtime.FileIO().Open(file.OpenPath)
if err != nil {

View File

@@ -5,8 +5,10 @@ package drive
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strings"
@@ -14,12 +16,14 @@ import (
"testing"
"time"
"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/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// countingOpenProvider wraps a fileio.Provider and counts FileIO.Open
@@ -652,6 +656,82 @@ func TestDrivePushDeleteRemoteSkipsOnlineDocs(t *testing.T) {
}
}
func TestDrivePushDeleteRemoteAbortsAfterTerminalFailure(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"},
map[string]interface{}{"token": "tok_b", "name": "b.txt", "type": "file"},
},
"has_more": false,
},
},
})
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/tok_a",
Body: map[string]interface{}{
"code": 1061004,
"msg": "forbidden",
},
})
// No DELETE stub for tok_b: terminal delete failure must stop before it.
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--delete-remote",
"--yes",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) || pfErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI *output.PartialFailureError, got %T %v", err, err)
}
summary, items := splitDrivePushStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Fatalf("summary.failed = %v, want 1", got)
}
if got := summary["aborted"]; got != true {
t.Fatalf("summary.aborted = %v, want true", got)
}
if got := summary["deleted_remote"]; got != float64(0) {
t.Fatalf("summary.deleted_remote = %v, want 0", got)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
item := items[0]
if item["action"] != "delete_failed" || item["phase"] != "delete" || item["error_class"] != "permission_denied" {
t.Fatalf("unexpected failed item: %#v", item)
}
if item["code"] != float64(1061004) || item["retryable"] != false {
t.Fatalf("unexpected failure metadata: %#v", item)
}
for _, item := range items {
if item["file_token"] == "tok_b" {
t.Fatalf("terminal delete failure must abort before tok_b, got items=%#v", items)
}
}
}
func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
@@ -886,21 +966,22 @@ func TestDrivePushOverwriteWithoutVersionFails(t *testing.T) {
t.Errorf("expected ExitAPI (%d), got code=%d", output.ExitAPI, pfErr.Code)
}
out := stdout.String()
// summary.failed should reflect the missing version; summary.uploaded
// should not pretend the overwrite succeeded.
if !strings.Contains(out, `"failed": 1`) {
t.Errorf("expected failed=1, got: %s", out)
summary, items := splitDrivePushStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Errorf("summary.failed = %v, want 1", got)
}
if !strings.Contains(out, "no version") {
t.Errorf("expected error about missing version in items[].error, got: %s", out)
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
if got, _ := items[0]["error"].(string); !strings.Contains(got, "no version") {
t.Errorf("items[0].error = %q, want missing-version message", got)
}
// Pin the token-stability contract: the failed item must surface the
// token returned by upload_all (tok_keep_new), NOT the fallback
// entry.FileToken (tok_keep). Without this, a regression that always
// uses entry.FileToken on failure would slip through.
if !strings.Contains(out, `"file_token": "tok_keep_new"`) {
t.Errorf("expected failed item to surface upload_all's returned file_token (tok_keep_new), got: %s", out)
if got := items[0]["file_token"]; got != "tok_keep_new" {
t.Errorf("items[0].file_token = %v, want tok_keep_new", got)
}
}
@@ -962,24 +1043,313 @@ func TestDrivePushOverwritePartialSuccessSurfacesReturnedToken(t *testing.T) {
t.Fatalf("expected ExitAPI from *output.PartialFailureError, got %T %v", err, err)
}
out := stdout.String()
// Partial failure reports an ok:false result envelope on stdout (not a
// misleading ok:true) while still carrying BOTH the succeeded and failed
// items — consistent with the pre-change payload. The failed side is
// asserted via "failed": 1 and the succeeded side via tok_keep_partial.
if !strings.Contains(out, `"ok": false`) {
t.Errorf("partial failure must emit an ok:false result envelope, got: %s", out)
envelope := decodeDrivePushStdout(t, stdout.Bytes())
if envelope.OK {
t.Fatalf("partial failure must emit ok=false; stdout=%s", stdout.String())
}
if !strings.Contains(out, `"failed": 1`) {
t.Errorf("expected failed=1, got: %s", out)
summary, items := splitDrivePushStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Errorf("summary.failed = %v, want 1", got)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
// The freshly returned token must be the one in items[].file_token,
// not the stale entry.FileToken (tok_keep_old).
if !strings.Contains(out, `"file_token": "tok_keep_partial"`) {
t.Errorf("expected items[].file_token to surface upload_all's returned token (tok_keep_partial), got: %s", out)
if got := items[0]["file_token"]; got != "tok_keep_partial" {
t.Errorf("items[0].file_token = %v, want tok_keep_partial", got)
}
if strings.Contains(out, `"file_token": "tok_keep_old"`) {
t.Errorf("must NOT fall back to entry.FileToken when upload_all returned a token; got: %s", out)
if got := items[0]["file_token"]; got == "tok_keep_old" {
t.Errorf("must NOT fall back to entry.FileToken when upload_all returned a token; item=%#v", items[0])
}
}
func TestDrivePushAbortsAfterUploadParamsError(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "a.txt"), []byte("A"), 0o644); err != nil {
t.Fatalf("WriteFile a: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "b.txt"), []byte("B"), 0o644); err != nil {
t.Fatalf("WriteFile b: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 1061002,
"msg": "params error.",
},
})
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
summary, items := splitDrivePushStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Fatalf("summary.failed = %v, want 1", got)
}
if got := summary["aborted"]; got != true {
t.Fatalf("summary.aborted = %v, want true", got)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
item := items[0]
if item["rel_path"] != "a.txt" || item["phase"] != "upload" || item["error_class"] != "invalid_api_parameters" {
t.Fatalf("unexpected failed item: %#v", item)
}
if item["code"] != float64(1061002) || item["subtype"] != "invalid_parameters" || item["retryable"] != false {
t.Fatalf("unexpected failure metadata: %#v", item)
}
for _, item := range items {
if item["rel_path"] == "b.txt" {
t.Fatalf("terminal upload params error must abort before b.txt, got items=%#v", items)
}
}
}
func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll(filepath.Join("local", "a"), 0o755); err != nil {
t.Fatalf("MkdirAll a: %v", err)
}
if err := os.MkdirAll(filepath.Join("local", "b"), 0o755); err != nil {
t.Fatalf("MkdirAll b: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/create_folder",
Body: map[string]interface{}{
"code": 99991672,
"msg": "Access denied. One of the following scopes is required: [drive:drive, space:folder:create].",
},
})
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
summary, items := splitDrivePushStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Fatalf("summary.failed = %v, want 1", got)
}
if got := summary["aborted"]; got != true {
t.Fatalf("summary.aborted = %v, want true", got)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
item := items[0]
if item["rel_path"] != "a" || item["phase"] != "create_folder" || item["error_class"] != "app_scope_missing" {
t.Fatalf("unexpected failed item: %#v", item)
}
if item["code"] != float64(99991672) || item["retryable"] != false {
t.Fatalf("unexpected failure metadata: %#v", item)
}
for _, item := range items {
if item["rel_path"] == "b" {
t.Fatalf("missing folder-create scope must abort before b, got items=%#v", items)
}
}
}
func TestDrivePushDetectsLocalFileChangedBeforeUpload(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
localPath := filepath.Join("local", "changing.txt")
if err := os.WriteFile(localPath, []byte("before"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
OnMatch: func(req *http.Request) {
if err := os.WriteFile(localPath, []byte("after-change"), 0o644); err != nil {
t.Fatalf("mutate local file: %v", err)
}
},
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
},
})
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
summary, items := splitDrivePushStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Fatalf("summary.failed = %v, want 1", got)
}
if got := summary["aborted"]; got != false {
t.Fatalf("summary.aborted = %v, want false", got)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
item := items[0]
if item["error_class"] != "local_file_changed" || item["subtype"] != "failed_precondition" || item["retryable"] != false {
t.Fatalf("unexpected failure metadata: %#v", item)
}
if got, _ := item["error"].(string); !strings.Contains(got, "local file changed during push") {
t.Fatalf("items[0].error = %q, want local-change message", got)
}
if strings.Contains(stdout.String(), "httpmock: no stub") {
t.Fatalf("upload_all was called after local snapshot changed:\n%s", stdout.String())
}
problemErr := drivePushVerifyLocalSnapshot(common.TestNewRuntimeContext(&cobra.Command{Use: "drive +push"}, &core.CliConfig{}), drivePushLocalFile{
RelPath: "missing.txt",
OpenPath: filepath.Join("local", "missing.txt"),
FileName: "missing.txt",
Size: 1,
ModTime: time.Now(),
})
problem, ok := errs.ProblemOf(problemErr)
if !ok {
t.Fatalf("ProblemOf(snapshot error) ok=false, err=%T %v", problemErr, problemErr)
}
if problem.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("snapshot error subtype = %q, want %q", problem.Subtype, errs.SubtypeFailedPrecondition)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("snapshot error category = %q, want %q", problem.Category, errs.CategoryValidation)
}
if errors.Unwrap(problemErr) == nil {
t.Fatalf("snapshot error cause was not preserved")
}
}
func TestDrivePushDetectsSameSizeLocalFileChangedBeforeUpload(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
localPath := filepath.Join("local", "changing.txt")
if err := os.WriteFile(localPath, []byte("before"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
originalModTime := time.Unix(100, 0)
changedModTime := time.Unix(200, 0)
if err := os.Chtimes(localPath, originalModTime, originalModTime); err != nil {
t.Fatalf("Chtimes original: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
OnMatch: func(req *http.Request) {
if err := os.WriteFile(localPath, []byte("AFTER!"), 0o644); err != nil {
t.Fatalf("mutate local file: %v", err)
}
if err := os.Chtimes(localPath, changedModTime, changedModTime); err != nil {
t.Fatalf("Chtimes changed: %v", err)
}
},
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
},
})
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
summary, items := splitDrivePushStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Fatalf("summary.failed = %v, want 1", got)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
item := items[0]
if item["rel_path"] != "changing.txt" || item["error_class"] != "local_file_changed" || item["subtype"] != "failed_precondition" {
t.Fatalf("unexpected failure metadata: %#v", item)
}
if got, _ := item["error"].(string); !strings.Contains(got, "snapshot modtime no longer matches") {
t.Fatalf("items[0].error = %q, want modtime mismatch", got)
}
if strings.Contains(stdout.String(), "httpmock: no stub") {
t.Fatalf("upload_all was called after local snapshot changed:\n%s", stdout.String())
}
}
@@ -1113,6 +1483,32 @@ func TestDrivePushExitsZeroOnCleanRun(t *testing.T) {
}
}
type drivePushStdoutEnvelope struct {
OK bool `json:"ok"`
Data struct {
Summary map[string]interface{} `json:"summary"`
Items []map[string]interface{} `json:"items"`
} `json:"data"`
}
func decodeDrivePushStdout(t *testing.T, stdout []byte) drivePushStdoutEnvelope {
t.Helper()
var envelope drivePushStdoutEnvelope
if err := json.Unmarshal(stdout, &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout))
}
return envelope
}
func splitDrivePushStdout(t *testing.T, stdout []byte) (map[string]interface{}, []map[string]interface{}) {
t.Helper()
envelope := decodeDrivePushStdout(t, stdout)
if envelope.Data.Summary == nil {
t.Fatalf("stdout missing data.summary; raw=%s", string(stdout))
}
return envelope.Data.Summary, envelope.Data.Items
}
// TestDrivePushUploadsSiblingWhenRemoteSameNameIsNativeDoc pins the
// behavior when a local regular file shares its rel_path with a Lark
// native cloud document on Drive (sheet/docx/bitable/...).

View File

@@ -72,7 +72,7 @@ var DriveSearch = common.Shortcut{
Description: "Search Lark docs, Wiki, and spreadsheet files with flat filters (Search v2: doc_wiki/search)",
Risk: "read",
Scopes: []string{"search:docs:read"},
AuthTypes: []string{"user"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "query", Desc: "search keyword (may be empty to browse by filter only)"},

View File

@@ -6,6 +6,7 @@ package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
@@ -17,6 +18,13 @@ const (
secureLabelUpdateScope = "docs:secure_label:write_only"
)
type secureLabelOperation string
const (
secureLabelOperationList secureLabelOperation = "list"
secureLabelOperationUpdate secureLabelOperation = "update"
)
var secureLabelTypes = permApplyTypes
// DriveSecureLabelList lists secure labels available to the current user.
@@ -28,6 +36,9 @@ var DriveSecureLabelList = common.Shortcut{
Scopes: []string{secureLabelReadScope},
AuthTypes: []string{"user"},
HasFormat: true,
Tips: []string{
"Use the `id` field from this command as --label-id for +secure-label-update; do not use the display name.",
},
Flags: []common.Flag{
{Name: "page-size", Type: "int", Default: "10", Desc: "page size, 1-10"},
{Name: "page-token", Desc: "pagination token from previous response"},
@@ -53,7 +64,7 @@ var DriveSecureLabelList = common.Shortcut{
nil,
)
if err != nil {
return err
return decorateSecureLabelError(err, secureLabelOperationList)
}
runtime.OutFormat(data, nil, nil)
return nil
@@ -68,13 +79,21 @@ var DriveSecureLabelUpdate = common.Shortcut{
Risk: "write",
Scopes: []string{secureLabelUpdateScope},
AuthTypes: []string{"user"},
Tips: []string{
"Pass the numeric label id returned by +secure-label-list; display names like Public(D) are rejected.",
"Downgrading a secure label may require approval; retrying the same request will not bypass approval.",
"When updating many files, serialize requests and back off on rate_limit errors.",
},
Flags: []common.Flag{
{Name: "token", Desc: "target file token or document URL (docx/sheets/base/file/wiki/doc/mindnote/slides)", Required: true},
{Name: "type", Desc: "target type; auto-inferred from URL when omitted", Enum: secureLabelTypes},
{Name: "label-id", Desc: "secure label ID to set", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, _, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type"))
if _, _, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type")); err != nil {
return err
}
_, err := normalizeSecureLabelID(runtime.Str("label-id"))
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -82,11 +101,15 @@ var DriveSecureLabelUpdate = common.Shortcut{
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
labelID, err := normalizeSecureLabelID(runtime.Str("label-id"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
Desc("Update Drive secure label").
PATCH("/open-apis/drive/v2/files/:file_token/secure_label").
Params(map[string]interface{}{"type": docType}).
Body(map[string]interface{}{"id": runtime.Str("label-id")}).
Body(map[string]interface{}{"id": labelID}).
Set("file_token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -94,14 +117,18 @@ var DriveSecureLabelUpdate = common.Shortcut{
if err != nil {
return err
}
body := map[string]interface{}{"id": runtime.Str("label-id")}
labelID, err := normalizeSecureLabelID(runtime.Str("label-id"))
if err != nil {
return err
}
body := map[string]interface{}{"id": labelID}
data, err := runtime.CallAPITyped("PATCH",
fmt.Sprintf("/open-apis/drive/v2/files/%s/secure_label", validate.EncodePathSegment(token)),
map[string]interface{}{"type": docType},
body,
)
if err != nil {
return err
return decorateSecureLabelError(err, secureLabelOperationUpdate)
}
runtime.Out(data, nil)
return nil
@@ -122,3 +149,70 @@ func buildSecureLabelListParams(runtime *common.RuntimeContext) map[string]inter
func resolveSecureLabelTarget(raw, explicitType string) (token, docType string, err error) {
return resolvePermApplyTarget(raw, explicitType)
}
// normalizeSecureLabelID trims a label id and rejects display names before the
// request reaches Drive, where they otherwise surface as opaque JSON errors.
func normalizeSecureLabelID(raw string) (string, error) {
labelID := strings.TrimSpace(raw)
if labelID == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--label-id is required").
WithParam("--label-id")
}
for _, r := range labelID {
if r < '0' || r > '9' {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--label-id must be a numeric secure label ID, not a display name: %q", raw).
WithParam("--label-id").
WithHint("run `lark-cli drive +secure-label-list` and pass the numeric `id` value; do not pass label names like `Public(D)`")
}
}
return labelID, nil
}
// decorateSecureLabelError appends command-aware recovery guidance while
// preserving upstream/classifier hints already attached to the typed error.
func decorateSecureLabelError(err error, operation secureLabelOperation) error {
if err == nil {
return nil
}
p, ok := errs.ProblemOf(err)
if !ok {
return err
}
guidance := secureLabelErrorGuidance(p.Code, operation)
if guidance == "" {
return err
}
if p.Hint == "" {
p.Hint = guidance
} else if !strings.Contains(p.Hint, guidance) {
p.Hint = p.Hint + "; " + guidance
}
return err
}
// secureLabelErrorGuidance returns recovery guidance for secure-label API
// failures whose generic code-level classification needs command context.
func secureLabelErrorGuidance(code int, operation secureLabelOperation) string {
switch code {
case 99991400:
if operation == secureLabelOperationUpdate {
return "secure label updates are rate limited; retry later with exponential backoff and serialize bulk updates"
}
return "secure label listing is rate limited; retry later with exponential backoff"
case 1063013:
if operation == secureLabelOperationUpdate {
return "secure label downgrade requires approval; request approval or choose a non-downgrade label before retrying"
}
case 1063002:
if operation == secureLabelOperationUpdate {
return "the current user lacks permission to update this file's secure label; use a user with file and security-label permission"
}
return "the current user lacks permission to list secure labels; use a user with security-label read permission"
case 1063001, 99992402, 9499:
if operation == secureLabelOperationUpdate {
return "check --token/--type and pass a secure label ID from `lark-cli drive +secure-label-list`, not the display name"
}
return "check secure label list parameters such as --page-size, --page-token, and --lang"
}
return ""
}

View File

@@ -5,9 +5,11 @@ package drive
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
@@ -90,13 +92,54 @@ func TestDriveSecureLabelList_ExecuteSuccess(t *testing.T) {
}
}
func TestDriveSecureLabelList_RateLimitPreservesUpstreamHint(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v2/my_secure_labels?page_size=10",
Status: 429,
Body: map[string]interface{}{
"code": 99991400,
"msg": "rate limit exceeded",
"error": map[string]interface{}{
"details": []interface{}{
map[string]interface{}{"value": "server says slow down"},
},
},
},
})
err := mountAndRunDrive(t, DriveSecureLabelList, []string{
"+secure-label-list",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected rate limit error")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError, got %T: %v", err, err)
}
if apiErr.Subtype != errs.SubtypeRateLimit || apiErr.Code != 99991400 || !apiErr.Retryable {
t.Fatalf("problem = %+v, want code=99991400 subtype=rate_limit retryable=true", apiErr.Problem)
}
for _, want := range []string{"server says slow down", "secure label listing is rate limited"} {
if !strings.Contains(apiErr.Hint, want) {
t.Fatalf("hint missing %q: %q", want, apiErr.Hint)
}
}
if strings.Contains(apiErr.Hint, "updates are rate limited") {
t.Fatalf("list hint should not use update-specific wording: %q", apiErr.Hint)
}
}
func TestDriveSecureLabelUpdate_DryRunInfersTypeFromURL(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
"+secure-label-update",
"--token", "https://example.feishu.cn/docx/doxTok123?from=share",
"--label-id", "7217780879644737539",
"--label-id", " 7217780879644737539 ",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
@@ -132,7 +175,7 @@ func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) {
"+secure-label-update",
"--token", "doxTok123",
"--type", "docx",
"--label-id", "7217780879644737539",
"--label-id", " 7217780879644737539 ",
"--as", "user",
}, f, stdout)
if err != nil {
@@ -148,7 +191,32 @@ func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) {
}
}
func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsAPIError(t *testing.T) {
func TestDriveSecureLabelUpdate_RejectsDisplayNameAsLabelID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
"+secure-label-update",
"--token", "doxTok123",
"--type", "docx",
"--label-id", "Public(D)",
"--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected label id validation error")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T: %v", err, err)
}
if validationErr.Param != "--label-id" {
t.Fatalf("Param = %q, want --label-id", validationErr.Param)
}
if !strings.Contains(validationErr.Hint, "+secure-label-list") {
t.Fatalf("hint missing list guidance: %q", validationErr.Hint)
}
}
func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsFailedPrecondition(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH",
@@ -169,7 +237,78 @@ func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsAPIError(t *testing.T) {
if err == nil {
t.Fatal("expected 1063013 error")
}
if !strings.Contains(err.Error(), "Security label downgrade requires approval") {
t.Fatalf("expected raw API error message, got: %v", err)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T: %v", err, err)
}
if validationErr.Subtype != errs.SubtypeFailedPrecondition || validationErr.Code != 1063013 {
t.Fatalf("problem = %+v, want code=1063013 subtype=failed_precondition", validationErr.Problem)
}
if !strings.Contains(validationErr.Hint, "approval") {
t.Fatalf("hint missing approval guidance: %q", validationErr.Hint)
}
}
func TestDriveSecureLabelUpdate_InvalidJSONTypeGetsLabelHint(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v2/files/doxTok123/secure_label",
Status: 400,
Body: map[string]interface{}{
"code": 9499, "msg": "Invalid parameter type in json: id",
},
})
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
"+secure-label-update",
"--token", "https://example.feishu.cn/docx/doxTok123",
"--label-id", "7217780879644737539",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected 9499 error")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError, got %T: %v", err, err)
}
if apiErr.Subtype != errs.SubtypeInvalidParameters || apiErr.Code != 9499 {
t.Fatalf("problem = %+v, want code=9499 subtype=invalid_parameters", apiErr.Problem)
}
if !strings.Contains(apiErr.Hint, "+secure-label-list") {
t.Fatalf("hint missing secure label list guidance: %q", apiErr.Hint)
}
}
func TestDriveSecureLabelUpdate_RateLimitIsRetryableWithBackoffHint(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v2/files/doxTok123/secure_label",
Status: 429,
Body: map[string]interface{}{
"code": 99991400, "msg": "rate limit exceeded",
},
})
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
"+secure-label-update",
"--token", "https://example.feishu.cn/docx/doxTok123",
"--label-id", "7217780879644737539",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected rate limit error")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError, got %T: %v", err, err)
}
if apiErr.Subtype != errs.SubtypeRateLimit || apiErr.Code != 99991400 || !apiErr.Retryable {
t.Fatalf("problem = %+v, want code=99991400 subtype=rate_limit retryable=true", apiErr.Problem)
}
if !strings.Contains(apiErr.Hint, "backoff") {
t.Fatalf("hint missing backoff guidance: %q", apiErr.Hint)
}
}

View File

@@ -25,12 +25,21 @@ const (
driveSyncOnConflictAsk = "ask"
)
func driveSyncActionScopes() []string {
return []string{"drive:file:download", "drive:file:upload", "space:folder:create"}
}
type driveSyncItem struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
Action string `json:"action"`
Direction string `json:"direction,omitempty"` // "pull" or "push"
Error string `json:"error,omitempty"`
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
Action string `json:"action"`
Direction string `json:"direction,omitempty"` // "pull" or "push"
Error string `json:"error,omitempty"`
Phase string `json:"phase,omitempty"`
ErrorClass string `json:"error_class,omitempty"`
Code int `json:"code,omitempty"`
Subtype string `json:"subtype,omitempty"`
Retryable *bool `json:"retryable,omitempty"`
}
// DriveSync performs a two-way sync between a local directory and a Drive
@@ -66,6 +75,7 @@ var DriveSync = common.Shortcut{
"Default --on-conflict=remote-wins pulls the remote version when both sides changed a file. Use local-wins to push instead, keep-both to rename and keep both copies, or ask for interactive resolution.",
"Pass --quick for faster best-effort diff detection using modified_time instead of SHA-256 hash (no remote file downloads needed during diffing).",
"Because +sync acts on the diff, --quick can still pull, overwrite, or rename files when timestamps differ even if file contents are actually unchanged.",
"Actual sync execution pre-flights download, upload, and folder-create scopes before listing or walking, so missing grants fail before any partial sync can start.",
"Only entries with type=file are synced; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -110,10 +120,8 @@ var DriveSync = common.Shortcut{
duplicateRemote = driveDuplicateRemoteFail
}
quick := runtime.Bool("quick")
if !quick {
if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil {
return err
}
if err := runtime.EnsureScopes(driveSyncActionScopes()); err != nil {
return err
}
safeRoot, err := validate.SafeInputPath(localDir)
@@ -262,18 +270,6 @@ var DriveSync = common.Shortcut{
var pulled, pushed, skipped, failed int
items := make([]driveSyncItem, 0)
if quick && driveSyncNeedsDownloadScope(newRemote, modified, conflictResolutions) {
if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil {
return err
}
}
plannedUploads := driveSyncPlannedUploadPaths(newLocal, modified, conflictResolutions)
if len(plannedUploads) > 0 {
if err := runtime.EnsureScopes([]string{"drive:file:upload"}); err != nil {
return err
}
}
// Build push infrastructure: local walk for push + remote views + folder cache.
folderCache := map[string]string{"": folderToken}
for relDir, entry := range remoteFolders {
@@ -287,20 +283,18 @@ var DriveSync = common.Shortcut{
return err
}
if driveSyncNeedsCreateScope(plannedUploads, localDirs, folderCache) {
if err := runtime.EnsureScopes([]string{"space:folder:create"}); err != nil {
return err
}
}
// Mirror local directory structure first (same as +push), so
// empty local directories are not silently dropped.
for _, relDir := range localDirs {
if driveSyncHasTerminalFailure(items) {
break
}
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
continue
}
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
items = append(items, driveSyncItem{RelPath: relDir, Action: "failed", Direction: "push", Error: ensureErr.Error()})
item, _ := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
items = append(items, item)
failed++
continue
}
@@ -310,6 +304,9 @@ var DriveSync = common.Shortcut{
// 2a. Pull new_remote files.
for _, entry := range newRemote {
if driveSyncHasTerminalFailure(items) {
break
}
targetFile, ok := pullRemoteFiles[entry.RelPath]
if !ok {
// Non-file type (doc, shortcut, etc.) — skip.
@@ -317,8 +314,13 @@ var DriveSync = common.Shortcut{
}
target := filepath.Join(rootRelToCwd, entry.RelPath)
if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: err.Error()})
item, terminal := driveSyncFailedItem(entry.RelPath, entry.FileToken, "failed", "pull", "download", err)
items = append(items, item)
failed++
if terminal {
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
break
}
continue
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"})
@@ -327,6 +329,9 @@ var DriveSync = common.Shortcut{
// 2b. Push new_local files.
for _, entry := range newLocal {
if driveSyncHasTerminalFailure(items) {
break
}
localFile, ok := pushLocalFiles[entry.RelPath]
if !ok {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "push", Error: "local file disappeared during sync"})
@@ -336,14 +341,20 @@ var DriveSync = common.Shortcut{
parentRel := drivePushParentRel(entry.RelPath)
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
if ensureErr != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "push", Error: ensureErr.Error()})
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
items = append(items, item)
failed++
continue
}
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
if upErr != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "push", Error: upErr.Error()})
item, terminal := driveSyncFailedItem(entry.RelPath, token, "failed", "push", "upload", upErr)
items = append(items, item)
failed++
if terminal {
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
break
}
continue
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: token, Action: "uploaded", Direction: "push"})
@@ -352,6 +363,9 @@ var DriveSync = common.Shortcut{
// 2c. Resolve modified files by --on-conflict strategy.
for _, entry := range modified {
if driveSyncHasTerminalFailure(items) {
break
}
remoteFile := remoteFiles[entry.RelPath]
localFile, hasLocal := pushLocalFiles[entry.RelPath]
if !hasLocal {
@@ -379,8 +393,13 @@ var DriveSync = common.Shortcut{
}
target := filepath.Join(rootRelToCwd, entry.RelPath)
if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: err.Error()})
item, terminal := driveSyncFailedItem(entry.RelPath, entry.FileToken, "failed", "pull", "download", err)
items = append(items, item)
failed++
if terminal {
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
break
}
continue
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"})
@@ -396,7 +415,8 @@ var DriveSync = common.Shortcut{
}
parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache)
if parentErr != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: existingToken, Action: "failed", Direction: "push", Error: parentErr.Error()})
item, _ := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
items = append(items, item)
failed++
continue
}
@@ -411,8 +431,13 @@ var DriveSync = common.Shortcut{
if failedToken == "" {
failedToken = existingToken
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: failedToken, Action: "failed", Direction: "push", Error: upErr.Error()})
item, terminal := driveSyncFailedItem(entry.RelPath, failedToken, "failed", "push", "upload", upErr)
items = append(items, item)
failed++
if terminal {
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
break
}
continue
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: token, Action: "overwritten", Direction: "push"})
@@ -433,7 +458,8 @@ var DriveSync = common.Shortcut{
}
suffixedRel, err := relPathWithUniqueFileTokenSuffix(entry.RelPath, remoteFile.FileToken, occupied)
if err != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "conflict", Error: err.Error()})
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "conflict", "conflict", err)
items = append(items, item)
failed++
continue
}
@@ -441,7 +467,9 @@ var DriveSync = common.Shortcut{
oldAbsPath := filepath.Join(safeRoot, filepath.FromSlash(entry.RelPath))
newAbsPath := filepath.Join(safeRoot, filepath.FromSlash(suffixedRel))
if err := os.Rename(oldAbsPath, newAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "conflict", Error: fmt.Sprintf("rename local: %s", err)})
renameErr := errs.NewInternalError(errs.SubtypeFileIO, "rename local: %s", err).WithCause(err)
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "conflict", "conflict", renameErr)
items = append(items, item)
failed++
continue
}
@@ -454,19 +482,30 @@ var DriveSync = common.Shortcut{
if rollbackErr != nil {
errMsg += "; rollback failed: " + rollbackErr.Error()
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "pull", Error: errMsg})
notFoundErr := errs.NewAPIError(errs.SubtypeNotFound, "%s", errMsg)
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "pull", "download", notFoundErr)
items = append(items, item)
failed++
continue
}
target := filepath.Join(rootRelToCwd, entry.RelPath)
if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil {
downloadErr := err
rollbackErr := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath)
errMsg := err.Error()
if rollbackErr != nil {
errMsg += "; rollback failed: " + rollbackErr.Error()
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: errMsg})
item, terminal := driveSyncFailedItem(entry.RelPath, entry.FileToken, "failed", "pull", "download", downloadErr)
if rollbackErr != nil {
item.Error = errMsg
}
items = append(items, item)
failed++
if terminal {
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, downloadErr)
break
}
continue
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "renamed_local", Direction: "conflict"})
@@ -492,6 +531,7 @@ var DriveSync = common.Shortcut{
"pushed": pushed,
"skipped": skipped,
"failed": failed,
"aborted": driveSyncHasTerminalFailure(items),
},
"items": items,
}
@@ -520,6 +560,32 @@ func driveSyncStatusRemoteFiles(pullRemoteFiles map[string]drivePullTarget) map[
return remoteFiles
}
func driveSyncFailedItem(relPath, fileToken, action, direction, phase string, err error) (driveSyncItem, bool) {
decision := driveClassifyBatchFailure(err)
item := driveSyncItem{
RelPath: relPath,
FileToken: fileToken,
Action: action,
Direction: direction,
Error: err.Error(),
Phase: phase,
ErrorClass: decision.Class,
Code: decision.Code,
Subtype: decision.Subtype,
Retryable: driveBoolPtr(decision.Retryable),
}
return item, decision.Terminal
}
func driveSyncHasTerminalFailure(items []driveSyncItem) bool {
for _, item := range items {
if driveTerminalBatchErrorClass(item.ErrorClass) {
return true
}
}
return false
}
// driveSyncAskConflict prompts the user for a conflict resolution strategy
// for a single file. Returns the strategy string, or empty string if the
// user chose to skip.
@@ -558,51 +624,6 @@ func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (strin
}
}
func driveSyncNeedsDownloadScope(newRemote, modified []driveStatusEntry, conflictResolutions map[string]string) bool {
if len(newRemote) > 0 {
return true
}
for _, entry := range modified {
switch conflictResolutions[entry.RelPath] {
case driveSyncOnConflictRemoteWins, driveSyncOnConflictKeepBoth:
return true
}
}
return false
}
func driveSyncPlannedUploadPaths(newLocal, modified []driveStatusEntry, conflictResolutions map[string]string) []string {
planned := make([]string, 0, len(newLocal)+len(modified))
for _, entry := range newLocal {
planned = append(planned, entry.RelPath)
}
for _, entry := range modified {
if conflictResolutions[entry.RelPath] == driveSyncOnConflictLocalWins {
planned = append(planned, entry.RelPath)
}
}
return planned
}
func driveSyncNeedsCreateScope(uploadPaths []string, localDirs []string, folderCache map[string]string) bool {
for _, relPath := range uploadPaths {
parentRel := drivePushParentRel(relPath)
if parentRel == "" {
continue
}
if _, ok := folderCache[parentRel]; !ok {
return true
}
}
// Empty local directories also need create_folder if not already on Drive.
for _, relDir := range localDirs {
if _, ok := folderCache[relDir]; !ok {
return true
}
}
return false
}
func driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath string) error {
if info, err := os.Stat(oldAbsPath); err == nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
if info.IsDir() {

View File

@@ -311,6 +311,71 @@ func TestDriveSyncRemoteWinsPullsNewRemoteAndPushesNewLocal(t *testing.T) {
}
}
func TestDriveSyncAbortsAfterNewRemoteDownloadForbidden(t *testing.T) {
syncTestConfig := &core.CliConfig{
AppID: "drive-sync-forbidden", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig)
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file", "modified_time": "100"},
map[string]interface{}{"token": "tok_b", "name": "b.txt", "type": "file", "modified_time": "100"},
},
"has_more": false,
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/tok_a/download",
Status: http.StatusForbidden,
RawBody: []byte("forbidden"),
})
err := mountAndRunDrive(t, DriveSync, []string{
"+sync",
"--local-dir", "local",
"--folder-token", "folder_root",
"--quick",
"--as", "bot",
}, f, stdout)
assertDriveSyncPartialFailure(t, err)
summary := driveSyncStdoutSummary(t, stdout.Bytes())
if got := summary["aborted"]; got != true {
t.Fatalf("summary.aborted = %v, want true", got)
}
if got := summary["failed"]; got != float64(1) {
t.Fatalf("summary.failed = %v, want 1", got)
}
items := driveSyncStdoutItems(t, stdout.Bytes())
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
item := items[0]
if item.RelPath != "a.txt" || item.Direction != "pull" || item.Phase != "download" || item.ErrorClass != "permission_denied" {
t.Fatalf("unexpected failed item: %#v", item)
}
if item.Code != http.StatusForbidden || item.Retryable == nil || *item.Retryable {
t.Fatalf("unexpected failure classification: %#v", item)
}
if _, statErr := os.Stat(filepath.Join("local", "b.txt")); !os.IsNotExist(statErr) {
t.Fatalf("b.txt should not be downloaded after terminal permission failure; stat err=%v", statErr)
}
}
// TestDriveSyncLocalWinsPushesOverRemote verifies that --on-conflict=local-wins
// pushes the local version over the remote file.
func TestDriveSyncLocalWinsPushesOverRemote(t *testing.T) {
@@ -1552,11 +1617,11 @@ func TestDriveSyncDryRunQuickAcceptsMetadataOnlyScope(t *testing.T) {
}
}
func TestDriveSyncExactRemoteWinsAcceptsDownloadOnlyScope(t *testing.T) {
func TestDriveSyncPreflightsActionScopesBeforeListing(t *testing.T) {
syncTestConfig := &core.CliConfig{
AppID: "drive-sync-download-scope-only", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig)
f, stdout, _, _ := cmdutil.TestFactory(t, syncTestConfig)
f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly drive:file:download"}, nil)
tmpDir := t.TempDir()
@@ -1568,34 +1633,6 @@ func TestDriveSyncExactRemoteWinsAcceptsDownloadOnlyScope(t *testing.T) {
t.Fatalf("WriteFile a.txt: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"},
},
"has_more": false,
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/tok_a/download",
Status: 200,
Body: []byte("remote-a"),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/tok_a/download",
Status: 200,
Body: []byte("remote-a"),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})
err := mountAndRunDrive(t, DriveSync, []string{
"+sync",
"--local-dir", "local",
@@ -1603,11 +1640,30 @@ func TestDriveSyncExactRemoteWinsAcceptsDownloadOnlyScope(t *testing.T) {
"--on-conflict", "remote-wins",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("expected exact remote-wins to succeed with download-only scope, got: %v\nstdout: %s", err, stdout.String())
if err == nil {
t.Fatalf("expected action-scope preflight to reject download-only scope\nstdout: %s", stdout.String())
}
if strings.Contains(strings.ToLower(stdout.String()), "missing_scope") {
t.Fatalf("should not surface missing_scope, got: %s", stdout.String())
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
}
if permErr.Subtype != errs.SubtypeMissingScope {
t.Fatalf("Subtype = %q, want %q", permErr.Subtype, errs.SubtypeMissingScope)
}
for _, scope := range []string{"drive:file:upload", "space:folder:create"} {
found := false
for _, missing := range permErr.MissingScopes {
if missing == scope {
found = true
break
}
}
if !found {
t.Fatalf("MissingScopes = %v, want %s", permErr.MissingScopes, scope)
}
}
if strings.Contains(stdout.String(), "folder_root") {
t.Fatalf("preflight should fail before remote listing, got stdout: %s", stdout.String())
}
}
@@ -2552,30 +2608,6 @@ func TestDriveSyncAskConflictRemoteShortForms(t *testing.T) {
}
}
// TestDriveSyncNeedsDownloadScopeReturnsFalseForLocalWinsOnly verifies
// that driveSyncNeedsDownloadScope returns false when there are no
// new_remote entries and all modified entries resolve to local-wins.
func TestDriveSyncNeedsDownloadScopeReturnsFalseForLocalWinsOnly(t *testing.T) {
modified := []driveStatusEntry{{RelPath: "a.txt"}, {RelPath: "b.txt"}}
resolutions := map[string]string{"a.txt": driveSyncOnConflictLocalWins, "b.txt": driveSyncOnConflictLocalWins}
if driveSyncNeedsDownloadScope(nil, modified, resolutions) {
t.Fatal("expected false when no new_remote and all conflicts are local-wins")
}
}
// TestDriveSyncNeedsDownloadScopeReturnsTrueForKeepBoth verifies that
// driveSyncNeedsDownloadScope returns true when a modified entry resolves
// to keep-both (which requires pulling the remote version).
func TestDriveSyncNeedsDownloadScopeReturnsTrueForKeepBoth(t *testing.T) {
modified := []driveStatusEntry{{RelPath: "a.txt"}}
resolutions := map[string]string{"a.txt": driveSyncOnConflictKeepBoth}
if !driveSyncNeedsDownloadScope(nil, modified, resolutions) {
t.Fatal("expected true when a conflict resolves to keep-both")
}
}
// TestDriveSyncRemoteWinsReportsMissingPullView verifies that when a
// modified file's rel_path is not in pullRemoteFiles during the
// remote-wins branch, a failed item is reported instead of a panic.
@@ -3083,3 +3115,19 @@ func driveSyncStdoutItems(t *testing.T, stdout []byte) []driveSyncItem {
}
return envelope.Data.Items
}
func driveSyncStdoutSummary(t *testing.T, stdout []byte) map[string]interface{} {
t.Helper()
var envelope struct {
Data struct {
Summary map[string]interface{} `json:"summary"`
} `json:"data"`
}
if err := json.Unmarshal(stdout, &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout))
}
if envelope.Data.Summary == nil {
t.Fatalf("stdout missing data.summary; raw=%s", string(stdout))
}
return envelope.Data.Summary
}

View File

@@ -3,7 +3,10 @@
package drive
import "testing"
import (
"reflect"
"testing"
)
// TestShortcutsIncludesExpectedCommands verifies the drive shortcut registry contains the expected commands.
func TestShortcutsIncludesExpectedCommands(t *testing.T) {
@@ -58,3 +61,12 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
}
}
}
func TestDriveSearchSupportsUserAndBotIdentity(t *testing.T) {
t.Parallel()
want := []string{"user", "bot"}
if !reflect.DeepEqual(DriveSearch.AuthTypes, want) {
t.Fatalf("DriveSearch.AuthTypes = %v, want %v", DriveSearch.AuthTypes, want)
}
}

View File

@@ -651,6 +651,7 @@ func TestShortcuts(t *testing.T) {
want := []string{
"+chat-create",
"+chat-list",
"+chat-members-list",
"+chat-messages-list",
"+chat-search",
"+chat-update",

View File

@@ -0,0 +1,420 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
imChatMembersListPathFmt = "/open-apis/im/v1/chats/%s/members/list"
chatMembersListDefaultPageSize = 20
chatMembersListMaxPageSize = 100
// chatMembersListDefaultPageDelay throttles --page-all the same way the
// generic paginateLoop does (200ms). It matters for tenants WITHOUT the
// server-side member cap, where a large group drains many pages back to
// back and could otherwise trip rate limits.
chatMembersListDefaultPageDelay = 200
)
// ImChatMembersList is the +chat-members-list shortcut: it lists chat members,
// returning users and bots in separate buckets (users[]/bots[]). It owns its
// pagination loop (mirroring the generic paginateLoop conventions: a per-page
// log line, a --page-limit cap, a non-advancing-token guard) precisely because
// the response is multi-bucket — the generic --page-all merger is built for
// single-array responses and would drop the bots[] bucket and the final-page
// truncations[] signal. See mergeChatMemberPages for the merge semantics.
var ImChatMembersList = common.Shortcut{
Service: "im",
Command: "+chat-members-list",
Description: "List members of a chat; returns separate users[] / bots[] buckets; callable as user or bot; --member-types filters which kinds to return; --page-all pagination; surfaces truncations[] when the server caps a bucket",
Risk: "read",
// Declare the narrowest scope the API accepts so tokens carrying only
// im:chat.members:read are honored (same rationale as +chat-list).
Scopes: []string{"im:chat.members:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "chat-id", Required: true, Desc: "chat ID (oc_xxx)"},
{Name: "member-types", Type: "string_slice", Desc: "member types to return (user, bot); omit = all"},
{Name: "member-id-type", Default: "open_id", Desc: "ID type for member_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
{Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageSize), Desc: fmt.Sprintf("page size, 1-%d", chatMembersListMaxPageSize)},
{Name: "page-token", Desc: "page token; implies single-page fetch (no auto-pagination)"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (capped by --page-limit)"},
{Name: "page-limit", Type: "int", Default: "10", Desc: "max pages to fetch with --page-all (default 10, 0 = unlimited)"},
{Name: "page-delay", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageDelay), Desc: "delay in ms between pages when --page-all (0 = no delay)"},
},
Tips: []string{
"Default fetches a single page; pass --page-all to walk every page.",
"With --page-all and no explicit --page-size, the max page size is used to minimize round-trips.",
"truncations[] in the result means the server capped a bucket due to security config — the member list is incomplete.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
chatID := strings.TrimSpace(runtime.Str("chat-id"))
if chatID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-id is required (oc_xxx)").WithParam("--chat-id")
}
if !strings.HasPrefix(chatID, "oc_") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --chat-id %q: must be an open_chat_id starting with oc_", chatID).WithParam("--chat-id")
}
if n := runtime.Int("page-size"); n < 1 || n > chatMembersListMaxPageSize {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and %d", chatMembersListMaxPageSize).WithParam("--page-size")
}
if n := runtime.Int("page-limit"); n < 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be a non-negative integer").WithParam("--page-limit")
}
if n := runtime.Int("page-delay"); n < 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-delay must be a non-negative integer").WithParam("--page-delay")
}
_, err := normalizeMemberTypes(runtime.StrSlice("member-types"))
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
chatID := strings.TrimSpace(runtime.Str("chat-id"))
dry := common.NewDryRunAPI()
if chatMembersShouldAutoPaginate(runtime) {
dry.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
}
params, _ := buildChatMembersParams(runtime, strings.TrimSpace(runtime.Str("page-token")))
return dry.
GET(fmt.Sprintf(imChatMembersListPathFmt, validate.EncodePathSegment(chatID))).
Params(params)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
warnIfConflictingPagingFlags(runtime)
chatID := strings.TrimSpace(runtime.Str("chat-id"))
res, err := fetchChatMembers(ctx, runtime, chatID)
if err != nil {
return err
}
// The truncation signal is the whole reason this is a dedicated shortcut:
// surface it loudly so an agent never mistakes a capped list for a
// complete one.
if len(res.truncations) > 0 {
writeChatMembersTruncationWarning(runtime.IO().ErrOut, res.truncations)
}
fmt.Fprintf(runtime.IO().ErrOut, "Found %d user(s) and %d bot(s)\n", len(res.users), len(res.bots))
outData := map[string]interface{}{
"chat_id": chatID,
"users": res.users,
"bots": res.bots,
"truncations": res.truncations,
"has_more": res.hasMore,
"page_token": res.pageToken,
}
if res.userTotal != nil {
outData["user_total"] = res.userTotal
}
if res.botTotal != nil {
outData["bot_total"] = res.botTotal
}
runtime.OutFormat(outData, &output.Meta{Count: len(res.users) + len(res.bots)}, func(w io.Writer) {
renderChatMembersPretty(w, chatID, res)
})
return nil
},
}
// chatMembersResult is the aggregated view across one or more pages.
type chatMembersResult struct {
users []interface{}
bots []interface{}
truncations []interface{}
userTotal interface{}
botTotal interface{}
hasMore bool
pageToken string
}
// effectiveChatMembersPageSize resolves the page_size to request. When draining
// every page (--page-all) and the caller did NOT explicitly set --page-size, it
// uses the maximum so a full walk takes the fewest round-trips. An explicit
// --page-size is always honored; without --page-all the smaller default is kept
// as a sensible single-page preview size.
func effectiveChatMembersPageSize(runtime *common.RuntimeContext) int {
if chatMembersShouldAutoPaginate(runtime) && !runtime.Changed("page-size") {
return chatMembersListMaxPageSize
}
if n := runtime.Int("page-size"); n > 0 {
return n
}
return chatMembersListDefaultPageSize
}
// chatMembersShouldAutoPaginate reports whether the fetch loop should walk
// every page. An explicit --page-token disables the auto loop because the
// caller supplied a specific cursor (single-page fetch).
func chatMembersShouldAutoPaginate(runtime *common.RuntimeContext) bool {
if strings.TrimSpace(runtime.Str("page-token")) != "" {
return false
}
return runtime.Bool("page-all")
}
// buildChatMembersParams builds the query params for one page request. The
// startToken (when non-empty) seeds the page_token; the loop overrides it per
// page. Returns the params and the normalized member-types CSV (already
// validated by Validate, so the error is only a defensive guard).
func buildChatMembersParams(runtime *common.RuntimeContext, startToken string) (map[string]interface{}, error) {
memberTypes, err := normalizeMemberTypes(runtime.StrSlice("member-types"))
if err != nil {
return nil, err
}
params := map[string]interface{}{
"member_id_type": runtime.Str("member-id-type"),
"page_size": effectiveChatMembersPageSize(runtime),
}
if memberTypes != "" {
params["member_types"] = memberTypes
}
if startToken != "" {
params["page_token"] = startToken
}
return params, nil
}
// fetchChatMembers walks the list_members endpoint, honoring the four
// pagination flags the same way the generic --page-all path does. It merges
// each page into the aggregate as it arrives (rather than buffering every raw
// page), so peak memory is just the aggregated members plus the single most
// recent page — important for large groups under --page-limit 0.
func fetchChatMembers(ctx context.Context, runtime *common.RuntimeContext, chatID string) (*chatMembersResult, error) {
auto := chatMembersShouldAutoPaginate(runtime)
pageLimit := runtime.Int("page-limit")
pageDelay := runtime.Int("page-delay")
apiPath := fmt.Sprintf(imChatMembersListPathFmt, validate.EncodePathSegment(chatID))
params, err := buildChatMembersParams(runtime, strings.TrimSpace(runtime.Str("page-token")))
if err != nil {
return nil, err
}
res := newChatMembersResult()
var lastData map[string]interface{}
pageToken := strings.TrimSpace(runtime.Str("page-token"))
for page := 0; ; page++ {
if pageToken != "" {
params["page_token"] = pageToken
}
fmt.Fprintf(runtime.IO().ErrOut, "[page %d] fetching...\n", page+1)
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
if err != nil {
return nil, err
}
addMemberBuckets(res, data)
lastData = data
hasMore, nextToken := common.PaginationMeta(data)
if !auto {
break
}
if !hasMore || nextToken == "" {
break
}
if nextToken == pageToken {
// Guard against a buggy server echoing the same cursor with
// has_more=true: without --page-limit we would loop forever.
fmt.Fprintln(runtime.IO().ErrOut, "Stopping pagination: server returned a non-advancing page_token.")
break
}
if pageLimit > 0 && page+1 >= pageLimit {
fmt.Fprintf(runtime.IO().ErrOut, "[pagination] reached page limit (%d), stopping. Use --page-all --page-limit 0 to fetch all pages.\n", pageLimit)
break
}
pageToken = nextToken
// Throttle between pages (only reached when another page follows), so
// draining a large untruncated list doesn't hammer the API.
if pageDelay > 0 {
time.Sleep(time.Duration(pageDelay) * time.Millisecond)
}
}
if lastData != nil {
applyLastPageSignals(res, lastData)
}
return res, nil
}
// newChatMembersResult returns an empty aggregate with non-nil buckets so the
// JSON output always carries arrays (never null).
func newChatMembersResult() *chatMembersResult {
return &chatMembersResult{
users: []interface{}{},
bots: []interface{}{},
truncations: []interface{}{},
}
}
// addMemberBuckets appends one page's users[] and bots[] into the aggregate.
// Concatenating every bucket is what avoids dropping bots[] — the bug the
// generic single-array --page-all merger would hit on this multi-bucket shape.
func addMemberBuckets(res *chatMembersResult, data map[string]interface{}) {
if u, ok := data["users"].([]interface{}); ok {
res.users = append(res.users, u...)
}
if b, ok := data["bots"].([]interface{}); ok {
res.bots = append(res.bots, b...)
}
}
// applyLastPageSignals copies the per-request signals from the FINAL page:
// has_more / page_token / truncations / totals. These must come from the last
// page, not page 1: truncations[] is emitted only on the final page (empty
// earlier), so reading it sooner would hide a server-side cap; user_total /
// bot_total are server-wide counts, and taking the final page's value keeps a
// single, consistent source rather than a possibly-stale earlier count.
func applyLastPageSignals(res *chatMembersResult, data map[string]interface{}) {
res.hasMore, res.pageToken = common.PaginationMeta(data)
if t, ok := data["truncations"].([]interface{}); ok {
res.truncations = t
}
res.userTotal = data["user_total"]
res.botTotal = data["bot_total"]
}
// mergeChatMemberPages folds a slice of page payloads into one aggregate. It is
// the same logic fetchChatMembers applies incrementally, kept as a pure
// function so the multi-bucket merge + last-page-signal semantics are unit
// tested in one place.
func mergeChatMemberPages(pages []map[string]interface{}) *chatMembersResult {
res := newChatMembersResult()
if len(pages) == 0 {
return res
}
for _, data := range pages {
addMemberBuckets(res, data)
}
applyLastPageSignals(res, pages[len(pages)-1])
return res
}
// normalizeMemberTypes validates the --member-types slice (already CSV-split by
// cobra) into a lowercased, deduped CSV string. Empty input is a no-op (return
// the API's default of all types). Any element outside {user, bot} is rejected.
func normalizeMemberTypes(raw []string) (string, error) {
if len(raw) == 0 {
return "", nil
}
seen := make(map[string]struct{}, len(raw))
out := make([]string, 0, len(raw))
for _, p := range raw {
p = strings.TrimSpace(strings.ToLower(p))
if p != "user" && p != "bot" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --member-types value %q: expected one of user, bot", p).WithParam("--member-types")
}
if _, dup := seen[p]; dup {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
return strings.Join(out, ","), nil
}
// warnIfConflictingPagingFlags mirrors the wiki list shortcuts: --page-token
// wins (single-page fetch from the supplied cursor) and --page-all is ignored.
func warnIfConflictingPagingFlags(runtime *common.RuntimeContext) {
if strings.TrimSpace(runtime.Str("page-token")) != "" && runtime.Bool("page-all") {
fmt.Fprintln(runtime.IO().ErrOut,
"warning: --page-token is set, so --page-all is ignored (single-page fetch from the supplied cursor)")
}
}
// writeChatMembersTruncationWarning emits a stderr warning for every
// server-side bucket cap reported in truncations[]. It uses the repo's plain
// "warning: <code>: <message>" convention (see shortcuts/common/runner.go and
// +chat-list's bot_strip_p2p) — no emoji, so it stays legible in CI logs and
// pipes regardless of terminal encoding.
func writeChatMembersTruncationWarning(w io.Writer, truncations []interface{}) {
for _, t := range truncations {
tm, ok := t.(map[string]interface{})
if !ok {
continue
}
memberType := valueOrAll(tm["member_type"])
limit := tm["limit"]
fmt.Fprintf(w, "warning: members_truncated: %s bucket capped at %v by server security config; the member list is INCOMPLETE\n", memberType, limit)
}
}
func valueOrAll(v interface{}) string {
if s, ok := v.(string); ok && s != "" {
return s
}
return "member"
}
func renderChatMembersPretty(w io.Writer, chatID string, res *chatMembersResult) {
fmt.Fprintf(w, "Chat: %s\n", chatID)
// Show the server-wide total next to the fetched count: when truncated or
// paged, total can far exceed len(users)/len(bots), and that gap is exactly
// what tells the reader how incomplete the list is.
fmt.Fprintf(w, "Users (%d%s):\n", len(res.users), totalSuffix(res.userTotal, len(res.users)))
for i, u := range res.users {
m, _ := u.(map[string]interface{})
fmt.Fprintf(w, " [%d] %s %s\n", i+1, valueOrDash(m["member_id"]), valueOrDash(m["name"]))
}
fmt.Fprintf(w, "Bots (%d%s):\n", len(res.bots), totalSuffix(res.botTotal, len(res.bots)))
for i, b := range res.bots {
m, _ := b.(map[string]interface{})
fmt.Fprintf(w, " [%d] %s %s\n", i+1, valueOrDash(m["member_id"]), valueOrDash(m["name"]))
}
if len(res.truncations) > 0 {
fmt.Fprintln(w, "warning: result truncated by server security config (see truncations[]); the list is INCOMPLETE")
}
if res.hasMore {
fmt.Fprint(w, "More pages available; pass --page-all (and --page-limit 0 for everything)")
if res.pageToken != "" {
fmt.Fprintf(w, ", or --page-token %s to resume", res.pageToken)
}
fmt.Fprintln(w)
}
}
func valueOrDash(v interface{}) string {
if s, ok := v.(string); ok && s != "" {
return s
}
return "-"
}
// totalSuffix renders " of <total>" when the server-reported total exceeds the
// number actually fetched (so a truncated/partial bucket is obvious), and ""
// when the total is absent or already matches the fetched count.
func totalSuffix(total interface{}, fetched int) string {
n, ok := toInt(total)
if !ok || n <= fetched {
return ""
}
return fmt.Sprintf(" of %d", n)
}
// toInt coerces a JSON-decoded number (float64 / json.Number / int) to int.
func toInt(v interface{}) (int, bool) {
switch n := v.(type) {
case float64:
return int(n), true
case int:
return n, true
case int64:
return int(n), true
case json.Number:
if i, err := n.Int64(); err == nil {
return int(i), true
}
}
return 0, false
}

View File

@@ -0,0 +1,325 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// page builds one list_members page payload shaped like the data object the
// server returns (users[]/bots[]/truncations[] plus paging + totals).
func cmlPage(users, bots, truncations []interface{}, hasMore bool, pageToken string) map[string]interface{} {
return map[string]interface{}{
"users": users,
"bots": bots,
"truncations": truncations,
"has_more": hasMore,
"page_token": pageToken,
"user_total": 324,
"bot_total": 2,
}
}
func us(ids ...string) []interface{} {
out := make([]interface{}, 0, len(ids))
for _, id := range ids {
out = append(out, map[string]interface{}{"member_id": id})
}
return out
}
// TestMergeChatMemberPages_MergesUsersAndBots covers Bug 1: every list bucket
// (users AND bots) must be concatenated across pages, not just one of them.
func TestMergeChatMemberPages_MergesUsersAndBots(t *testing.T) {
pages := []map[string]interface{}{
cmlPage(us("u1", "u2"), us("b1"), []interface{}{}, true, "p2"),
cmlPage(us("u3"), us("b2", "b3"), []interface{}{}, false, ""),
}
res := mergeChatMemberPages(pages)
if len(res.users) != 3 {
t.Errorf("users: want 3 merged, got %d", len(res.users))
}
if len(res.bots) != 3 {
t.Errorf("bots: want 3 merged, got %d", len(res.bots))
}
}
// TestMergeChatMemberPages_TruncationsFromLastPage covers Bug 2: truncations[]
// is emitted only on the final page, so the merged view must take it from the
// last page rather than inherit page 1's empty slice.
func TestMergeChatMemberPages_TruncationsFromLastPage(t *testing.T) {
limit := []interface{}{map[string]interface{}{"limit": 100, "member_type": "user"}}
pages := []map[string]interface{}{
cmlPage(us("u1"), us("b1"), []interface{}{}, true, "p2"),
cmlPage(us("u2"), nil, limit, false, ""),
}
res := mergeChatMemberPages(pages)
if len(res.truncations) != 1 {
t.Fatalf("truncations: want last page's 1 entry, got %d (%v)", len(res.truncations), res.truncations)
}
}
// TestMergeChatMemberPages_HasMoreAndTokenFromLastPage guards that paging
// signals come from the final page (so a --page-limit cutoff is visible).
func TestMergeChatMemberPages_HasMoreAndTokenFromLastPage(t *testing.T) {
pages := []map[string]interface{}{
cmlPage(us("u1"), nil, nil, true, "p2"),
cmlPage(us("u2"), nil, nil, true, "p3"), // loop stopped early; server still has more
}
res := mergeChatMemberPages(pages)
if !res.hasMore {
t.Error("has_more: want true from last page")
}
if res.pageToken != "p3" {
t.Errorf("page_token: want last page's p3, got %q", res.pageToken)
}
}
// TestMergeChatMemberPages_TotalsFromLastPage verifies user_total / bot_total
// are taken from the final page (not an earlier, possibly-different value).
func TestMergeChatMemberPages_TotalsFromLastPage(t *testing.T) {
pages := []map[string]interface{}{
{"users": us("u1"), "user_total": 999, "bot_total": 7, "has_more": true, "page_token": "p2"},
{"users": us("u2"), "user_total": 324, "bot_total": 2, "has_more": false, "page_token": ""},
}
res := mergeChatMemberPages(pages)
if n, _ := toInt(res.userTotal); n != 324 {
t.Errorf("user_total: want last page's 324, got %v", res.userTotal)
}
if n, _ := toInt(res.botTotal); n != 2 {
t.Errorf("bot_total: want last page's 2, got %v", res.botTotal)
}
}
// TestChatMembersValidate covers --chat-id presence + oc_ prefix enforcement.
func TestChatMembersValidate(t *testing.T) {
noop := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": cmlPage(nil, nil, nil, false, "")}), nil
})
cases := []struct {
name string
chatID string
wantErr bool
}{
{"valid oc_", "oc_abc", false},
{"empty", "", true},
{"missing oc_ prefix", "abc123", true},
}
for _, c := range cases {
rt := newChatMembersTestRuntime(t, noop, map[string]string{"chat-id": c.chatID}, nil, nil)
err := ImChatMembersList.Validate(context.Background(), rt)
if c.wantErr {
assertValidationError(t, c.name, err, "--chat-id")
continue
}
if err != nil {
t.Errorf("%s: unexpected error %v", c.name, err)
}
}
}
// assertValidationError checks err satisfies the repo's typed-error contract for
// a validation failure: a *errs.ValidationError carrying the expected Param, and
// problem metadata of category validation / subtype invalid_argument.
func assertValidationError(t *testing.T, ctx string, err error, wantParam string) {
t.Helper()
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Errorf("%s: want *errs.ValidationError, got %T (%v)", ctx, err, err)
return
}
if ve.Param != wantParam {
t.Errorf("%s: Param = %q, want %q", ctx, ve.Param, wantParam)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("%s: problem = %+v (ok=%v), want category=%s subtype=%s", ctx, p, ok, errs.CategoryValidation, errs.SubtypeInvalidArgument)
}
}
func TestNormalizeMemberTypes(t *testing.T) {
cases := []struct {
in []string
want string
wantErr bool
}{
{nil, "", false},
{[]string{"user", "bot"}, "user,bot", false},
{[]string{"USER", "user"}, "user", false}, // lowercased + deduped
{[]string{"admin"}, "", true},
{[]string{""}, "", true},
}
for _, c := range cases {
got, err := normalizeMemberTypes(c.in)
if c.wantErr {
assertValidationError(t, fmt.Sprintf("normalizeMemberTypes(%v)", c.in), err, "--member-types")
continue
}
if err != nil {
t.Errorf("normalizeMemberTypes(%v): unexpected error %v", c.in, err)
}
if got != c.want {
t.Errorf("normalizeMemberTypes(%v) = %q, want %q", c.in, got, c.want)
}
}
}
// TestEffectiveChatMembersPageSize covers the --page-all max-page-size behavior:
// drain with no explicit size → max; explicit size → honored; single page → default.
func TestEffectiveChatMembersPageSize(t *testing.T) {
noop := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": cmlPage(nil, nil, nil, false, "")}), nil
})
cases := []struct {
name string
b map[string]bool
ints map[string]int
want int
}{
{"page-all, size unset -> max", map[string]bool{"page-all": true}, nil, chatMembersListMaxPageSize},
{"page-all, size explicit -> honored", map[string]bool{"page-all": true}, map[string]int{"page-size": 15}, 15},
{"single page, size unset -> default", nil, nil, chatMembersListDefaultPageSize},
}
for _, c := range cases {
rt := newChatMembersTestRuntime(t, noop, map[string]string{"chat-id": "oc_x"}, c.b, c.ints)
if got := effectiveChatMembersPageSize(rt); got != c.want {
t.Errorf("%s: want %d, got %d", c.name, c.want, got)
}
}
}
// newChatMembersTestRuntime registers the shortcut's flags and returns a
// user-identity runtime wired to the given RoundTripper for multi-page mocking.
func newChatMembersTestRuntime(t *testing.T, rt http.RoundTripper, str map[string]string, b map[string]bool, ints map[string]int) *common.RuntimeContext {
t.Helper()
runtime := newUserShortcutRuntime(t, rt)
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("chat-id", "", "")
cmd.Flags().String("member-id-type", "open_id", "")
cmd.Flags().StringSlice("member-types", nil, "")
cmd.Flags().String("page-token", "", "")
cmd.Flags().Bool("page-all", false, "")
cmd.Flags().Int("page-size", 20, "")
cmd.Flags().Int("page-limit", 10, "")
cmd.Flags().Int("page-delay", 200, "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags: %v", err)
}
for k, v := range str {
if err := cmd.Flags().Set(k, v); err != nil {
t.Fatalf("set %s: %v", k, err)
}
}
for k, v := range b {
if err := cmd.Flags().Set(k, strconv.FormatBool(v)); err != nil {
t.Fatalf("set %s: %v", k, err)
}
}
for k, v := range ints {
if err := cmd.Flags().Set(k, strconv.Itoa(v)); err != nil {
t.Fatalf("set %s: %v", k, err)
}
}
runtime.Cmd = cmd
return runtime
}
// TestFetchChatMembers_PageAllMergesBucketsAndTruncations exercises the full
// fetch loop over mocked pages: users/bots merge across pages and the final
// page's truncations[] survives.
func TestFetchChatMembers_PageAllMergesBucketsAndTruncations(t *testing.T) {
calls := 0
rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/oc_test/members/list") {
return shortcutJSONResponse(404, map[string]interface{}{"code": 1}), nil
}
calls++
token := req.URL.Query().Get("page_token")
if token == "" {
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": cmlPage(us("u1", "u2"), us("b1"), []interface{}{}, true, "p2"),
}), nil
}
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": cmlPage(us("u3"), us("b2"), []interface{}{map[string]interface{}{"limit": 100, "member_type": "user"}}, false, ""),
}), nil
})
runtime := newChatMembersTestRuntime(t, rt,
map[string]string{"chat-id": "oc_test"},
map[string]bool{"page-all": true},
map[string]int{"page-size": 2, "page-limit": 0, "page-delay": 0})
res, err := fetchChatMembers(context.Background(), runtime, "oc_test")
if err != nil {
t.Fatalf("fetchChatMembers: %v", err)
}
if calls != 2 {
t.Errorf("want 2 page calls, got %d", calls)
}
if len(res.users) != 3 {
t.Errorf("users: want 3, got %d", len(res.users))
}
if len(res.bots) != 2 {
t.Errorf("bots: want 2, got %d", len(res.bots))
}
if len(res.truncations) != 1 {
t.Errorf("truncations: want 1 from last page, got %d", len(res.truncations))
}
if res.hasMore {
t.Error("has_more: want false after draining all pages")
}
}
// TestFetchChatMembers_PageLimitStops verifies --page-limit caps the loop and
// leaves has_more=true so the caller knows the result is incomplete.
func TestFetchChatMembers_PageLimitStops(t *testing.T) {
seq := 0
rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
// Every page reports more pages available, with an advancing token so the
// loop is stopped by --page-limit, not the non-advancing-token guard.
seq++
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": cmlPage(us("u"), nil, nil, true, fmt.Sprintf("p%d", seq)),
}), nil
})
runtime := newChatMembersTestRuntime(t, rt,
map[string]string{"chat-id": "oc_test"},
map[string]bool{"page-all": true},
map[string]int{"page-size": 1, "page-limit": 3, "page-delay": 0})
res, err := fetchChatMembers(context.Background(), runtime, "oc_test")
if err != nil {
t.Fatalf("fetchChatMembers: %v", err)
}
if len(res.users) != 3 {
t.Errorf("users: want 3 (capped at page-limit), got %d", len(res.users))
}
if !res.hasMore {
t.Error("has_more: want true (loop cut short by page-limit)")
}
errOut := runtime.IO().ErrOut.(*bytes.Buffer)
if !strings.Contains(errOut.String(), "reached page limit (3)") {
t.Errorf("want page-limit notice on stderr, got: %s", errOut.String())
}
}

View File

@@ -10,6 +10,7 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
ImChatCreate,
ImChatList,
ImChatMembersList,
ImChatMessageList,
ImChatSearch,
ImChatUpdate,

View File

@@ -58,45 +58,9 @@ func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
return objectives, nil
}
// buildContentBlock converts text and mentions to a ContentBlock.
func buildContentBlock(text string, mentions []string) *ContentBlock {
elements := make([]ContentParagraphElement, 0, len(mentions)+1)
// Add text element
textElem := ContentParagraphElement{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: &text,
},
}
elements = append(elements, textElem)
// Add mention elements
for _, mention := range mentions {
mentionElem := ContentParagraphElement{
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
Mention: &ContentMention{
UserID: &mention,
},
}
elements = append(elements, mentionElem)
}
return &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: elements,
},
},
},
}
}
// createObjective calls the API to create an objective.
func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType string, obj batchCreateObjective) (string, error) {
content := buildContentBlock(obj.Text, obj.Mention)
content := BuildContentBlock(obj.Text, obj.Mention)
body := map[string]interface{}{
"content": content,
}
@@ -120,7 +84,7 @@ func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleI
// createKR calls the API to create a key result.
func createKR(ctx context.Context, runtime *common.RuntimeContext, objectiveID, userIDType string, kr batchCreateKR) (string, error) {
content := buildContentBlock(kr.Text, kr.Mention)
content := BuildContentBlock(kr.Text, kr.Mention)
body := map[string]interface{}{
"content": content,
}
@@ -224,7 +188,7 @@ var OKRBatchCreate = common.Shortcut{
for i, obj := range objectives {
// Objective creation
objContent := buildContentBlock(obj.Text, obj.Mention)
objContent := BuildContentBlock(obj.Text, obj.Mention)
objBody := map[string]interface{}{
"content": objContent,
}
@@ -241,7 +205,7 @@ var OKRBatchCreate = common.Shortcut{
// KR creations
for j, kr := range obj.KRs {
krContent := buildContentBlock(kr.Text, kr.Mention)
krContent := BuildContentBlock(kr.Text, kr.Mention)
krBody := map[string]interface{}{
"content": krContent,
}

View File

@@ -557,7 +557,7 @@ func TestParseBatchCreateInput_Valid(t *testing.T) {
func TestBuildContentBlock(t *testing.T) {
t.Parallel()
cb := buildContentBlock("Test text", []string{"ou_123", "ou_456"})
cb := BuildContentBlock("Test text", []string{"ou_123", "ou_456"})
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}

View File

@@ -29,15 +29,10 @@ type RespCategory struct {
// RespCycle 周期
type RespCycle struct {
ID string `json:"id"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
TenantCycleID string `json:"tenant_cycle_id"`
Owner RespOwner `json:"owner"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
CycleStatus *string `json:"cycle_status,omitempty"`
Score *float64 `json:"score,omitempty"`
ID string `json:"id"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
CycleStatus *string `json:"cycle_status,omitempty"`
}
// RespIndicator 指标
@@ -152,3 +147,145 @@ type RespProgress struct {
Content *string `json:"content,omitempty"`
ProgressRate *RespProgressRate `json:"progress_rate,omitempty"`
}
// ========== Simple-style response types (semi-plain text format) ==========
// RespKeyResultSimple is KeyResult response with SemiPlainContent instead of ContentBlock JSON string.
type RespKeyResultSimple struct {
ID string `json:"id"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
Owner RespOwner `json:"owner"`
ObjectiveID string `json:"objective_id"`
Position *int32 `json:"position,omitempty"`
Content *SemiPlainContent `json:"content,omitempty"`
Score *float64 `json:"score,omitempty"`
Weight *float64 `json:"weight,omitempty"`
Deadline *string `json:"deadline,omitempty"`
}
// RespObjectiveSimple is Objective response with SemiPlainContent instead of ContentBlock JSON string.
type RespObjectiveSimple struct {
ID string `json:"id"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
Owner RespOwner `json:"owner"`
CycleID string `json:"cycle_id"`
Position *int32 `json:"position,omitempty"`
Content *SemiPlainContent `json:"content,omitempty"`
Score *float64 `json:"score,omitempty"`
Notes *SemiPlainContent `json:"notes,omitempty"`
Weight *float64 `json:"weight,omitempty"`
Deadline *string `json:"deadline,omitempty"`
CategoryID *string `json:"category_id,omitempty"`
KeyResults []RespKeyResultSimple `json:"key_results,omitempty"`
}
// RespProgressSimple is Progress response with SemiPlainContent instead of ContentBlock JSON string.
type RespProgressSimple struct {
ID string `json:"progress_id"`
ModifyTime string `json:"modify_time"`
CreateTime *string `json:"create_time,omitempty"`
Content *SemiPlainContent `json:"content,omitempty"`
ProgressRate *RespProgressRate `json:"progress_rate,omitempty"`
}
// ToSimple converts KeyResult to RespKeyResultSimple.
func (k *KeyResult) ToSimple() *RespKeyResultSimple {
if k == nil {
return nil
}
result := &RespKeyResultSimple{
ID: k.ID,
CreateTime: formatTimestamp(k.CreateTime),
UpdateTime: formatTimestamp(k.UpdateTime),
Owner: *k.Owner.ToResp(),
ObjectiveID: k.ObjectiveID,
Position: k.Position,
Score: k.Score,
Weight: k.Weight,
}
if k.Deadline != nil {
d := formatTimestamp(*k.Deadline)
result.Deadline = &d
}
result.Content = k.Content.ToSemiPlain()
return result
}
// ToSimple converts Objective to RespObjectiveSimple.
func (o *Objective) ToSimple() *RespObjectiveSimple {
if o == nil {
return nil
}
result := &RespObjectiveSimple{
ID: o.ID,
CreateTime: formatTimestamp(o.CreateTime),
UpdateTime: formatTimestamp(o.UpdateTime),
Owner: *o.Owner.ToResp(),
CycleID: o.CycleID,
Position: o.Position,
Score: o.Score,
Weight: o.Weight,
CategoryID: o.CategoryID,
}
if o.Deadline != nil {
d := formatTimestamp(*o.Deadline)
result.Deadline = &d
}
result.Content = o.Content.ToSemiPlain()
result.Notes = o.Notes.ToSemiPlain()
return result
}
// ToSimple converts ProgressV1 to RespProgressSimple.
func (p *ProgressV1) ToSimple() *RespProgressSimple {
if p == nil {
return nil
}
resp := &RespProgressSimple{
ID: p.ID,
ModifyTime: formatTimestamp(p.ModifyTime),
}
if p.ProgressRate != nil {
resp.ProgressRate = &RespProgressRate{
Percent: p.ProgressRate.Percent,
}
if p.ProgressRate.Status != nil {
s := ProgressStatus(*p.ProgressRate.Status).String()
if s != "" {
resp.ProgressRate.Status = &s
}
}
}
if p.Content != nil {
resp.Content = p.Content.ToV2().ToSemiPlain()
}
return resp
}
// ToSimple converts Progress to RespProgressSimple.
func (p *Progress) ToSimple() *RespProgressSimple {
if p == nil {
return nil
}
createTime := formatTimestamp(p.CreateTime)
resp := &RespProgressSimple{
ID: p.ID,
ModifyTime: formatTimestamp(p.UpdateTime),
CreateTime: &createTime,
}
if p.ProgressRate != nil {
resp.ProgressRate = &RespProgressRate{
Percent: p.ProgressRate.ProgressPercent,
}
if p.ProgressRate.ProgressStatus != nil {
s := ProgressStatus(*p.ProgressRate.ProgressStatus).String()
if s != "" {
resp.ProgressRate.Status = &s
}
}
}
resp.Content = p.Content.ToSemiPlain()
return resp
}

View File

@@ -26,6 +26,7 @@ var OKRCycleDetail = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
{Name: "cycle-id", Desc: "OKR cycle id (int64)", Required: true},
{Name: "style", Default: "simple", Desc: "output style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
cycleID := runtime.Str("cycle-id")
@@ -35,6 +36,10 @@ var OKRCycleDetail = common.Shortcut{
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
}
style := runtime.Str("style")
if style != "simple" && style != "richtext" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -50,6 +55,7 @@ var OKRCycleDetail = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
cycleID := runtime.Str("cycle-id")
style := runtime.Str("style")
// Paginate objectives under the cycle.
queryParams := map[string]interface{}{"page_size": "100"}
@@ -96,85 +102,106 @@ var OKRCycleDetail = common.Shortcut{
}
// For each objective, paginate key results and convert to response format.
respObjectives := make([]*RespObjective, 0, len(objectives))
for i := range objectives {
if err := ctx.Err(); err != nil {
return err
}
obj := &objectives[i]
krQuery := map[string]interface{}{"page_size": "100"}
var keyResults []KeyResult
krPage := 0
for {
if style == "simple" {
respObjectives := make([]*RespObjectiveSimple, 0, len(objectives))
for i := range objectives {
if err := ctx.Err(); err != nil {
return err
}
if krPage > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
krPage++
obj := &objectives[i]
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", obj.ID)
data, err := runtime.CallAPITyped("GET", path, krQuery, nil)
keyResults, err := fetchKeyResults(ctx, runtime, obj.ID)
if err != nil {
return err
}
itemsRaw, _ := data["items"].([]interface{})
for _, item := range itemsRaw {
raw, err := json.Marshal(item)
if err != nil {
continue
respObj := obj.ToSimple()
if respObj == nil {
continue
}
respKRs := make([]RespKeyResultSimple, 0, len(keyResults))
for j := range keyResults {
if r := keyResults[j].ToSimple(); r != nil {
respKRs = append(respKRs, *r)
}
var kr KeyResult
if err := json.Unmarshal(raw, &kr); err != nil {
continue
}
respObj.KeyResults = respKRs
respObjectives = append(respObjectives, respObj)
}
result := map[string]interface{}{
"cycle_id": cycleID,
"objectives": respObjectives,
"total": len(respObjectives),
"style": style,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Cycle %s: %d objective(s) (style: %s)\n", cycleID, len(respObjectives), style)
for _, o := range respObjectives {
contentText := ""
if o.Content != nil {
contentText = o.Content.Text
}
keyResults = append(keyResults, kr)
notesText := ""
if o.Notes != nil {
notesText = o.Notes.Text
}
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, contentText, notesText, ptrFloat64(o.Score), ptrFloat64(o.Weight))
for _, kr := range o.KeyResults {
krText := ""
if kr.Content != nil {
krText = kr.Content.Text
}
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, krText, ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
}
}
})
} else {
// richtext mode
respObjectives := make([]*RespObjective, 0, len(objectives))
for i := range objectives {
if err := ctx.Err(); err != nil {
return err
}
obj := &objectives[i]
keyResults, err := fetchKeyResults(ctx, runtime, obj.ID)
if err != nil {
return err
}
hasMore, pageToken := common.PaginationMeta(data)
if !hasMore || pageToken == "" {
break
respObj := obj.ToResp()
if respObj == nil {
continue
}
krQuery["page_token"] = pageToken
respKRs := make([]RespKeyResult, 0, len(keyResults))
for j := range keyResults {
if r := keyResults[j].ToResp(); r != nil {
respKRs = append(respKRs, *r)
}
}
respObj.KeyResults = respKRs
respObjectives = append(respObjectives, respObj)
}
respObj := obj.ToResp()
if respObj == nil {
continue
result := map[string]interface{}{
"cycle_id": cycleID,
"objectives": respObjectives,
"total": len(respObjectives),
"style": style,
}
respKRs := make([]RespKeyResult, 0, len(keyResults))
for j := range keyResults {
if r := keyResults[j].ToResp(); r != nil {
respKRs = append(respKRs, *r)
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Cycle %s: %d objective(s) (style: %s)\n", cycleID, len(respObjectives), style)
for _, o := range respObjectives {
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight))
for _, kr := range o.KeyResults {
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
}
}
}
respObj.KeyResults = respKRs
respObjectives = append(respObjectives, respObj)
})
}
result := map[string]interface{}{
"cycle_id": cycleID,
"objectives": respObjectives,
"total": len(respObjectives),
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Cycle %s: %d objective(s)\n", cycleID, len(respObjectives))
for _, o := range respObjectives {
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight))
for _, kr := range o.KeyResults {
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
}
}
})
return nil
},
}

View File

@@ -46,12 +46,38 @@ func cycleOverlaps(cycle *Cycle, rangeStart, rangeEnd time.Time) bool {
if err1 != nil || err2 != nil {
return false
}
cycleStart := time.UnixMilli(startMs)
cycleEnd := time.UnixMilli(endMs)
cycleStart := time.UnixMilli(startMs).UTC()
cycleEnd := time.UnixMilli(endMs).UTC()
// Two ranges overlap iff one starts before the other ends
return !cycleStart.After(rangeEnd) && !cycleEnd.Before(rangeStart)
}
// isCurrentActiveCycle checks whether a cycle is currently active:
// - current time is within the cycle's start and end time
// - cycle status is default (0) or normal (1)
func isCurrentActiveCycle(cycle *Cycle, now time.Time) bool {
startMs, err1 := strconv.ParseInt(cycle.StartTime, 10, 64)
endMs, err2 := strconv.ParseInt(cycle.EndTime, 10, 64)
if err1 != nil || err2 != nil {
return false
}
cycleStart := time.UnixMilli(startMs).UTC()
cycleEnd := time.UnixMilli(endMs).UTC()
nowUTC := now.UTC()
// Check time range: now must be >= start and <= end
if nowUTC.Before(cycleStart) || nowUTC.After(cycleEnd) {
return false
}
// Check status: must be default or normal
if cycle.CycleStatus == nil {
return false
}
status := *cycle.CycleStatus
return status == CycleStatusDefault || status == CycleStatusNormal
}
var OKRListCycles = common.Shortcut{
Service: "okr",
Command: "+cycle-list",
@@ -175,14 +201,30 @@ var OKRListCycles = common.Shortcut{
respCycles = append(respCycles, filtered[i].ToResp())
}
// Filter current active cycles
now := time.Now()
currentActiveCycles := make([]*RespCycle, 0)
for i := range filtered {
if isCurrentActiveCycle(&filtered[i], now) {
currentActiveCycles = append(currentActiveCycles, filtered[i].ToResp())
}
}
runtime.OutFormat(map[string]interface{}{
"cycles": respCycles,
"total": len(respCycles),
"cycles": respCycles,
"total": len(respCycles),
"current_active_cycles": currentActiveCycles,
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Found %d cycle(s)\n", len(respCycles))
for _, c := range respCycles {
fmt.Fprintf(w, " [%s] %s ~ %s (status: %s)\n", c.ID, c.StartTime, c.EndTime, ptrStr(c.CycleStatus))
}
if len(currentActiveCycles) > 0 {
fmt.Fprintf(w, "\nCurrent active cycle(s):\n")
for _, c := range currentActiveCycles {
fmt.Fprintf(w, " [%s] %s ~ %s\n", c.ID, c.StartTime, c.EndTime)
}
}
})
return nil
},

View File

@@ -5,8 +5,10 @@ package okr
import (
"bytes"
"strconv"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
@@ -260,11 +262,156 @@ func TestCycleListExecute_NoCycles(t *testing.T) {
if len(cycles) != 0 {
t.Fatalf("cycles = %v, want empty", cycles)
}
// Assert current_active_cycles field exists and is a slice
rawCurrentActive, ok := data["current_active_cycles"]
if !ok {
t.Fatal("current_active_cycles field is missing from response")
}
currentActive, ok := rawCurrentActive.([]interface{})
if !ok {
t.Fatalf("current_active_cycles is not a slice, got %T", rawCurrentActive)
}
if len(currentActive) != 0 {
t.Fatalf("current_active_cycles = %v, want empty", currentActive)
}
}
// --- isCurrentActiveCycle unit tests ---
func TestIsCurrentActiveCycle(t *testing.T) {
t.Parallel()
now := time.Date(2026, 6, 29, 12, 0, 0, 0, time.UTC)
tests := []struct {
name string
cycle *Cycle
expected bool
}{
{
name: "active cycle with normal status",
cycle: &Cycle{
ID: "c1",
StartTime: "1767225600000", // 2026-01-01
EndTime: "1798761599999", // 2026-12-31 23:59:59
CycleStatus: CycleStatusNormal.Ptr(),
},
expected: true,
},
{
name: "active cycle with default status",
cycle: &Cycle{
ID: "c2",
StartTime: "1767225600000", // 2026-01-01
EndTime: "1798761599999", // 2026-12-31
CycleStatus: CycleStatusDefault.Ptr(),
},
expected: true,
},
{
name: "cycle with invalid status",
cycle: &Cycle{
ID: "c3",
StartTime: "1767225600000", // 2026-01-01
EndTime: "1798761599999", // 2026-12-31
CycleStatus: CycleStatusInvalid.Ptr(),
},
expected: false,
},
{
name: "cycle with hidden status",
cycle: &Cycle{
ID: "c4",
StartTime: "1767225600000", // 2026-01-01
EndTime: "1798761599999", // 2026-12-31
CycleStatus: CycleStatusHidden.Ptr(),
},
expected: false,
},
{
name: "past cycle",
cycle: &Cycle{
ID: "c5",
StartTime: "1704067200000", // 2024-01-01
EndTime: "1719791999999", // 2024-06-30
CycleStatus: CycleStatusNormal.Ptr(),
},
expected: false,
},
{
name: "future cycle",
cycle: &Cycle{
ID: "c6",
StartTime: "1830297600000", // 2028-01-01
EndTime: "1861833599999", // 2028-12-31
CycleStatus: CycleStatusNormal.Ptr(),
},
expected: false,
},
{
name: "nil cycle status",
cycle: &Cycle{
ID: "c7",
StartTime: "1767225600000", // 2026-01-01
EndTime: "1798761599999", // 2026-12-31
CycleStatus: nil,
},
expected: false,
},
{
name: "invalid start time",
cycle: &Cycle{
ID: "c8",
StartTime: "invalid",
EndTime: "1798761599999", // 2026-12-31
CycleStatus: CycleStatusNormal.Ptr(),
},
expected: false,
},
{
name: "exact start time boundary",
cycle: &Cycle{
ID: "c9",
StartTime: "1782734400000", // 2026-06-29 12:00:00 UTC
EndTime: "1798761599000", // 2026-12-31 23:59:59 UTC
CycleStatus: CycleStatusNormal.Ptr(),
},
expected: true,
},
{
name: "exact end time boundary",
cycle: &Cycle{
ID: "c10",
StartTime: "1767225600000", // 2026-01-01 00:00:00 UTC
EndTime: "1782734400000", // 2026-06-29 12:00:00 UTC
CycleStatus: CycleStatusNormal.Ptr(),
},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isCurrentActiveCycle(tt.cycle, now)
if result != tt.expected {
t.Fatalf("isCurrentActiveCycle() = %v, want %v", result, tt.expected)
}
})
}
}
func TestCycleListExecute_WithCycles(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
// Calculate timestamps relative to now to avoid test expiration
now := time.Now().UTC()
// Active cycle: 6 months before to 6 months after now
activeStartMs := now.AddDate(0, -6, 0).UnixMilli()
activeEndMs := now.AddDate(0, 6, 0).UnixMilli()
// Past cycle: 2 years before to 1.5 years before now
pastStartMs := now.AddDate(-2, 0, 0).UnixMilli()
pastEndMs := now.AddDate(-1, -6, 0).UnixMilli()
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles",
@@ -274,19 +421,19 @@ func TestCycleListExecute_WithCycles(t *testing.T) {
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"id": "cycle-1",
"start_time": "1735689600000",
"end_time": "1751318400000",
"cycle_status": 1,
"id": "cycle-active",
"start_time": strconv.FormatInt(activeStartMs, 10),
"end_time": strconv.FormatInt(activeEndMs, 10),
"cycle_status": 1, // normal
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
"tenant_cycle_id": "tc-1",
"score": 0.75,
},
map[string]interface{}{
"id": "cycle-2",
"start_time": "1704067200000",
"end_time": "1719792000000",
"cycle_status": 2,
"id": "cycle-past",
"start_time": strconv.FormatInt(pastStartMs, 10),
"end_time": strconv.FormatInt(pastEndMs, 10),
"cycle_status": 2, // invalid
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
"tenant_cycle_id": "tc-2",
"score": 0.5,
@@ -311,6 +458,46 @@ func TestCycleListExecute_WithCycles(t *testing.T) {
if int(total) != 2 {
t.Fatalf("total = %v, want 2", total)
}
// Check current_active_cycles - should only contain cycle-active
rawCurrentActive, ok := data["current_active_cycles"]
if !ok {
t.Fatal("current_active_cycles field is missing from response")
}
currentActive, ok := rawCurrentActive.([]interface{})
if !ok {
t.Fatalf("current_active_cycles is not a slice, got %T", rawCurrentActive)
}
if len(currentActive) != 1 {
t.Fatalf("current_active_cycles count = %d, want 1", len(currentActive))
}
activeCycle, ok := currentActive[0].(map[string]interface{})
if !ok {
t.Fatalf("current_active_cycles[0] is not a map, got %T", currentActive[0])
}
if activeCycle["id"] != "cycle-active" {
t.Fatalf("current_active_cycles[0].id = %v, want cycle-active", activeCycle["id"])
}
// Verify removed fields are not present in the response
for _, c := range cycles {
cycleMap, _ := c.(map[string]interface{})
if _, ok := cycleMap["create_time"]; ok {
t.Fatal("create_time should not be present in response")
}
if _, ok := cycleMap["update_time"]; ok {
t.Fatal("update_time should not be present in response")
}
if _, ok := cycleMap["tenant_cycle_id"]; ok {
t.Fatal("tenant_cycle_id should not be present in response")
}
if _, ok := cycleMap["owner"]; ok {
t.Fatal("owner should not be present in response")
}
if _, ok := cycleMap["score"]; ok {
t.Fatal("score should not be present in response")
}
}
}
func TestCycleListExecute_WithTimeRangeFilter(t *testing.T) {

View File

@@ -5,7 +5,9 @@ package okr
import (
"encoding/json"
"regexp"
"strconv"
"strings"
"time"
)
@@ -261,14 +263,9 @@ func (c *Cycle) ToResp() *RespCycle {
return nil
}
resp := &RespCycle{
ID: c.ID,
CreateTime: formatTimestamp(c.CreateTime),
UpdateTime: formatTimestamp(c.UpdateTime),
TenantCycleID: c.TenantCycleID,
Owner: *c.Owner.ToResp(),
StartTime: formatTimestamp(c.StartTime),
EndTime: formatTimestamp(c.EndTime),
Score: c.Score,
ID: c.ID,
StartTime: formatTimestamp(c.StartTime),
EndTime: formatTimestamp(c.EndTime),
}
if c.CycleStatus != nil {
s := c.CycleStatus.ToString()
@@ -733,6 +730,131 @@ func (p *ContentPersonV1) ToV2() *ContentMention {
}
}
// ========== SemiPlainContent (半纯文本格式) ==========
// Regex patterns for semi-plain text processing (pre-compiled for performance).
var (
placeholderRE = regexp.MustCompile(`\s*@\{[^}]+\}\s*`)
multiSpaceRE = regexp.MustCompile(`\s+`)
)
// SemiPlainDoc represents a document link in semi-plain content.
type SemiPlainDoc struct {
Title string `json:"title"`
URL string `json:"url"`
}
// SemiPlainContent is a simplified, lossy representation of ContentBlock.
// It contains plain text, mentions, docs, and images without rich formatting or position info.
type SemiPlainContent struct {
Text string `json:"text"`
Mention []string `json:"mention,omitempty"`
Docs []SemiPlainDoc `json:"docs,omitempty"`
Images []string `json:"images,omitempty"`
}
// ToSemiPlain converts ContentBlock to SemiPlainContent (lossy conversion).
// Position information and formatting are discarded; only text, mentions, docs, and images are extracted.
func (c *ContentBlock) ToSemiPlain() *SemiPlainContent {
if c == nil {
return nil
}
result := &SemiPlainContent{}
var textParts []string
for _, block := range c.Blocks {
if block.Paragraph != nil {
for _, elem := range block.Paragraph.Elements {
switch {
case elem.TextRun != nil && elem.TextRun.Text != nil:
textParts = append(textParts, *elem.TextRun.Text)
case elem.Mention != nil && elem.Mention.UserID != nil:
textParts = append(textParts, " @{"+*elem.Mention.UserID+"} ")
result.Mention = append(result.Mention, *elem.Mention.UserID)
case elem.DocsLink != nil:
doc := SemiPlainDoc{}
if elem.DocsLink.Title != nil {
doc.Title = *elem.DocsLink.Title
}
if elem.DocsLink.URL != nil {
doc.URL = *elem.DocsLink.URL
}
result.Docs = append(result.Docs, doc)
}
}
}
if block.Gallery != nil {
for _, img := range block.Gallery.Images {
if img.Src != nil {
result.Images = append(result.Images, *img.Src)
}
}
}
}
result.Text = strings.Join(textParts, "")
return result
}
// ToContentBlock converts SemiPlainContent to ContentBlock.
// Text and mentions are placed in a single paragraph (text first, then mentions).
// Docs and images are NOT converted (input semi-plain format only supports text+mention).
func (s *SemiPlainContent) ToContentBlock() *ContentBlock {
if s == nil {
return nil
}
elements := make([]ContentParagraphElement, 0, len(s.Mention)+1)
// Strip @{userID} placeholders from text to avoid duplicate mentions
// (these placeholders are only for readability in the output format)
strippedText := placeholderRE.ReplaceAllString(s.Text, " ")
// Collapse multiple spaces and trim
strippedText = multiSpaceRE.ReplaceAllString(strippedText, " ")
strippedText = strings.TrimSpace(strippedText)
// Add text element if stripped text is not empty
if strippedText != "" {
text := strippedText
elements = append(elements, ContentParagraphElement{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: &text,
},
})
}
// Add mention elements
for _, mention := range s.Mention {
m := mention
elements = append(elements, ContentParagraphElement{
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
Mention: &ContentMention{
UserID: &m,
},
})
}
return &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: elements,
},
},
},
}
}
// BuildContentBlock converts text and mentions to a ContentBlock.
// This is a convenience wrapper around SemiPlainContent.ToContentBlock().
func BuildContentBlock(text string, mentions []string) *ContentBlock {
return (&SemiPlainContent{
Text: text,
Mention: mentions,
}).ToContentBlock()
}
// ProgressRateV1 进度率
type ProgressRateV1 struct {
Percent *float64 `json:"percent,omitempty"`

View File

@@ -57,7 +57,9 @@ func TestToRespMethods(t *testing.T) {
convey.So(resp, convey.ShouldNotBeNil)
convey.So(resp.ID, convey.ShouldEqual, "cycle-id")
convey.So(*resp.CycleStatus, convey.ShouldEqual, "normal")
convey.So(*resp.Score, convey.ShouldEqual, 0.75)
// Verify removed fields are not present in RespCycle
convey.So(resp.StartTime, convey.ShouldNotBeEmpty)
convey.So(resp.EndTime, convey.ShouldNotBeEmpty)
})
convey.Convey("Objective", func() {
@@ -518,5 +520,449 @@ func float64Ptr(v float64) *float64 { return &v }
// boolPtr returns a pointer to the given bool value.
func boolPtr(v bool) *bool { return &v }
// ========== SemiPlainContent Conversion Tests ==========
func TestContentBlockToSemiPlain_TextOnly(t *testing.T) {
t.Parallel()
cb := &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: []ContentParagraphElement{
{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: strPtr("Hello world"),
},
},
},
},
},
},
}
sp := cb.ToSemiPlain()
if sp == nil {
t.Fatal("expected non-nil SemiPlainContent")
}
if sp.Text != "Hello world" {
t.Fatalf("expected text 'Hello world', got '%s'", sp.Text)
}
if len(sp.Mention) != 0 {
t.Fatalf("expected 0 mentions, got %d", len(sp.Mention))
}
if len(sp.Docs) != 0 {
t.Fatalf("expected 0 docs, got %d", len(sp.Docs))
}
if len(sp.Images) != 0 {
t.Fatalf("expected 0 images, got %d", len(sp.Images))
}
}
func TestContentBlockToSemiPlain_WithMention(t *testing.T) {
t.Parallel()
cb := &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: []ContentParagraphElement{
{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: strPtr("Hello "),
},
},
{
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
Mention: &ContentMention{
UserID: strPtr("ou_123"),
},
},
{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: strPtr(", how are you?"),
},
},
},
},
},
},
}
sp := cb.ToSemiPlain()
if sp == nil {
t.Fatal("expected non-nil SemiPlainContent")
}
// Text includes @{userID} placeholder to preserve positional context
if sp.Text != "Hello @{ou_123} , how are you?" {
t.Fatalf("expected text 'Hello @{ou_123} , how are you?', got '%s'", sp.Text)
}
if len(sp.Mention) != 1 || sp.Mention[0] != "ou_123" {
t.Fatalf("expected mention [ou_123], got %v", sp.Mention)
}
}
func TestContentBlockToSemiPlain_WithDocsAndImages(t *testing.T) {
t.Parallel()
cb := &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: []ContentParagraphElement{
{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: strPtr("Check out this doc: "),
},
},
{
ParagraphElementType: ParagraphElementTypeDocsLink.Ptr(),
DocsLink: &ContentDocsLink{
Title: strPtr("Design Doc"),
URL: strPtr("https://example.feishu.cn/docx/xxx"),
},
},
},
},
},
{
BlockElementType: BlockElementTypeGallery.Ptr(),
Gallery: &ContentGallery{
Images: []ContentImageItem{
{
Src: strPtr("https://example.com/img1.png"),
},
{
Src: strPtr("https://example.com/img2.png"),
},
},
},
},
},
}
sp := cb.ToSemiPlain()
if sp == nil {
t.Fatal("expected non-nil SemiPlainContent")
}
if sp.Text != "Check out this doc: " {
t.Fatalf("unexpected text: '%s'", sp.Text)
}
if len(sp.Docs) != 1 {
t.Fatalf("expected 1 doc, got %d", len(sp.Docs))
}
if sp.Docs[0].Title != "Design Doc" || sp.Docs[0].URL != "https://example.feishu.cn/docx/xxx" {
t.Fatalf("unexpected doc: %+v", sp.Docs[0])
}
if len(sp.Images) != 2 {
t.Fatalf("expected 2 images, got %d", len(sp.Images))
}
if sp.Images[0] != "https://example.com/img1.png" || sp.Images[1] != "https://example.com/img2.png" {
t.Fatalf("unexpected images: %v", sp.Images)
}
}
func TestContentBlockToSemiPlain_Nil(t *testing.T) {
t.Parallel()
var cb *ContentBlock
sp := cb.ToSemiPlain()
if sp != nil {
t.Fatal("expected nil SemiPlainContent for nil ContentBlock")
}
}
func TestSemiPlainContentToContentBlock_TextOnly(t *testing.T) {
t.Parallel()
sp := &SemiPlainContent{
Text: "Hello world",
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
if len(cb.Blocks) != 1 {
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
}
block := cb.Blocks[0]
if block.BlockElementType == nil || *block.BlockElementType != BlockElementTypeParagraph {
t.Fatal("expected paragraph block")
}
if block.Paragraph == nil || len(block.Paragraph.Elements) != 1 {
t.Fatalf("expected 1 paragraph element, got %d", len(block.Paragraph.Elements))
}
elem := block.Paragraph.Elements[0]
if elem.ParagraphElementType == nil || *elem.ParagraphElementType != ParagraphElementTypeTextRun {
t.Fatal("expected textRun element")
}
if elem.TextRun == nil || elem.TextRun.Text == nil || *elem.TextRun.Text != "Hello world" {
t.Fatalf("unexpected text: %v", elem.TextRun)
}
}
func TestSemiPlainContentToContentBlock_WithMentions(t *testing.T) {
t.Parallel()
sp := &SemiPlainContent{
Text: "Please review",
Mention: []string{"ou_123", "ou_456"},
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
if len(cb.Blocks) != 1 {
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
}
elems := cb.Blocks[0].Paragraph.Elements
if len(elems) != 3 {
t.Fatalf("expected 3 elements (1 text + 2 mentions), got %d", len(elems))
}
if *elems[0].ParagraphElementType != ParagraphElementTypeTextRun || *elems[0].TextRun.Text != "Please review" {
t.Fatal("unexpected first element")
}
if *elems[1].ParagraphElementType != ParagraphElementTypeMention || *elems[1].Mention.UserID != "ou_123" {
t.Fatal("unexpected second element")
}
if *elems[2].ParagraphElementType != ParagraphElementTypeMention || *elems[2].Mention.UserID != "ou_456" {
t.Fatal("unexpected third element")
}
}
func TestSemiPlainContentToContentBlock_EmptyText(t *testing.T) {
t.Parallel()
sp := &SemiPlainContent{
Text: " ",
Mention: []string{"ou_123"},
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
elems := cb.Blocks[0].Paragraph.Elements
// Empty text should be skipped, only mention remains
if len(elems) != 1 {
t.Fatalf("expected 1 element (mention only), got %d", len(elems))
}
if *elems[0].ParagraphElementType != ParagraphElementTypeMention {
t.Fatal("expected mention element")
}
}
func TestSemiPlainContentToContentBlock_DocsImagesIgnored(t *testing.T) {
t.Parallel()
sp := &SemiPlainContent{
Text: "Test",
Mention: []string{"ou_123"},
Docs: []SemiPlainDoc{{Title: "Doc", URL: "https://..."}},
Images: []string{"https://img.png"},
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
elems := cb.Blocks[0].Paragraph.Elements
// Docs and images are ignored in input conversion
if len(elems) != 2 {
t.Fatalf("expected 2 elements (text + mention), got %d", len(elems))
}
}
func TestSemiPlainContentToContentBlock_PlaceholderStripping(t *testing.T) {
t.Parallel()
// Simulate round-trip: output format has @{userID} in text,
// input conversion should strip them to avoid duplicate mentions
sp := &SemiPlainContent{
Text: "任务一 @{ou_zhangsan} ,任务二 @{ou_lisi} ",
Mention: []string{"ou_zhangsan", "ou_lisi"},
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
elems := cb.Blocks[0].Paragraph.Elements
// Should have 3 elements: 1 text (stripped) + 2 mentions
if len(elems) != 3 {
t.Fatalf("expected 3 elements (1 text + 2 mentions), got %d", len(elems))
}
// Text should have placeholders stripped
if *elems[0].ParagraphElementType != ParagraphElementTypeTextRun {
t.Fatal("expected first element to be textRun")
}
// Note: space before comma is preserved from the placeholder's trailing space
expectedText := "任务一 ,任务二"
if *elems[0].TextRun.Text != expectedText {
t.Fatalf("expected stripped text '%s', got '%s'", expectedText, *elems[0].TextRun.Text)
}
// Mentions should be preserved as separate elements
if *elems[1].ParagraphElementType != ParagraphElementTypeMention || *elems[1].Mention.UserID != "ou_zhangsan" {
t.Fatal("unexpected second element")
}
if *elems[2].ParagraphElementType != ParagraphElementTypeMention || *elems[2].Mention.UserID != "ou_lisi" {
t.Fatal("unexpected third element")
}
}
func TestSemiPlainContentToContentBlock_OnlyPlaceholders(t *testing.T) {
t.Parallel()
// Text that is only placeholders should result in no text element
sp := &SemiPlainContent{
Text: " @{ou_123} @{ou_456} ",
Mention: []string{"ou_123", "ou_456"},
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
elems := cb.Blocks[0].Paragraph.Elements
// Should have only 2 mention elements, no text element
if len(elems) != 2 {
t.Fatalf("expected 2 elements (mentions only), got %d", len(elems))
}
if *elems[0].ParagraphElementType != ParagraphElementTypeMention {
t.Fatal("expected first element to be mention")
}
if *elems[1].ParagraphElementType != ParagraphElementTypeMention {
t.Fatal("expected second element to be mention")
}
}
func TestSemiPlainContentToContentBlock_Nil(t *testing.T) {
t.Parallel()
var sp *SemiPlainContent
cb := sp.ToContentBlock()
if cb != nil {
t.Fatal("expected nil ContentBlock for nil SemiPlainContent")
}
}
func TestBuildContentBlock_Conversion(t *testing.T) {
t.Parallel()
cb := BuildContentBlock("Test text", []string{"ou_123", "ou_456"})
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
elems := cb.Blocks[0].Paragraph.Elements
if len(elems) != 3 {
t.Fatalf("expected 3 elements, got %d", len(elems))
}
if *elems[0].TextRun.Text != "Test text" {
t.Fatalf("unexpected text: %s", *elems[0].TextRun.Text)
}
if *elems[1].Mention.UserID != "ou_123" {
t.Fatalf("unexpected mention: %s", *elems[1].Mention.UserID)
}
if *elems[2].Mention.UserID != "ou_456" {
t.Fatalf("unexpected mention: %s", *elems[2].Mention.UserID)
}
}
func TestToSimpleMethods(t *testing.T) {
t.Parallel()
// Test Objective.ToSimple()
text := "Objective text"
obj := &Objective{
ID: "obj-1",
Content: BuildContentBlock(text, []string{"ou_123"}),
Notes: BuildContentBlock("Note text", nil),
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou_owner")},
CycleID: "cycle-1",
Score: float64Ptr(0.7),
Weight: float64Ptr(0.5),
Deadline: strPtr("1735776000000"),
}
simpleObj := obj.ToSimple()
if simpleObj == nil {
t.Fatal("expected non-nil RespObjectiveSimple")
}
if simpleObj.ID != "obj-1" {
t.Fatalf("expected ID obj-1, got %s", simpleObj.ID)
}
// Text includes @{userID} placeholder for positional context
expectedContentText := "Objective text @{ou_123} "
if simpleObj.Content == nil || simpleObj.Content.Text != expectedContentText {
t.Fatalf("unexpected content text: expected '%s', got '%s'", expectedContentText, simpleObj.Content.Text)
}
if simpleObj.Notes == nil || simpleObj.Notes.Text != "Note text" {
t.Fatalf("unexpected notes: %+v", simpleObj.Notes)
}
if simpleObj.Score == nil || *simpleObj.Score != 0.7 {
t.Fatalf("unexpected score: %v", simpleObj.Score)
}
if len(simpleObj.Content.Mention) != 1 || simpleObj.Content.Mention[0] != "ou_123" {
t.Fatalf("unexpected mentions: %v", simpleObj.Content.Mention)
}
// Test KeyResult.ToSimple()
kr := &KeyResult{
ID: "kr-1",
ObjectiveID: "obj-1",
Content: BuildContentBlock("KR text", nil),
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou_kr_owner")},
Score: float64Ptr(0.5),
}
simpleKR := kr.ToSimple()
if simpleKR == nil {
t.Fatal("expected non-nil RespKeyResultSimple")
}
if simpleKR.Content == nil || simpleKR.Content.Text != "KR text" {
t.Fatalf("unexpected KR content: %+v", simpleKR.Content)
}
// Test ProgressV1.ToSimple()
progress := &ProgressV1{
ID: "prog-1",
ModifyTime: "1735776000000",
Content: BuildContentBlock("Progress text", []string{"ou_mention"}).ToV1(),
}
simpleProgress := progress.ToSimple()
if simpleProgress == nil {
t.Fatal("expected non-nil RespProgressSimple")
}
// Text includes @{userID} placeholder for positional context
expectedProgressText := "Progress text @{ou_mention} "
if simpleProgress.Content == nil || simpleProgress.Content.Text != expectedProgressText {
t.Fatalf("unexpected progress text: expected '%s', got '%s'", expectedProgressText, simpleProgress.Content.Text)
}
if len(simpleProgress.Content.Mention) != 1 || simpleProgress.Content.Mention[0] != "ou_mention" {
t.Fatalf("unexpected progress mentions: %v", simpleProgress.Content.Mention)
}
// Test Progress.ToSimple() (V2 progress record)
progressV2 := &Progress{
ID: "prog-v2-1",
CreateTime: "1735689600000",
UpdateTime: "1735776000000",
Content: BuildContentBlock("V2 progress text", []string{"ou_v2_mention"}),
ProgressRate: &ProgressRate{
ProgressPercent: float64Ptr(80.0),
ProgressStatus: int32Ptr(int32(ProgressStatusDone)),
},
}
simpleProgressV2 := progressV2.ToSimple()
if simpleProgressV2 == nil {
t.Fatal("expected non-nil RespProgressSimple for Progress V2")
}
if simpleProgressV2.ID != "prog-v2-1" {
t.Fatalf("expected ID prog-v2-1, got %s", simpleProgressV2.ID)
}
if simpleProgressV2.CreateTime == nil || *simpleProgressV2.CreateTime == "" {
t.Fatal("expected non-empty CreateTime for Progress V2")
}
expectedV2Text := "V2 progress text @{ou_v2_mention} "
if simpleProgressV2.Content == nil || simpleProgressV2.Content.Text != expectedV2Text {
t.Fatalf("unexpected V2 progress text: expected '%s', got '%s'", expectedV2Text, simpleProgressV2.Content.Text)
}
if simpleProgressV2.ProgressRate == nil || simpleProgressV2.ProgressRate.Status == nil || *simpleProgressV2.ProgressRate.Status != "done" {
t.Fatalf("expected progress status 'done', got %+v", simpleProgressV2.ProgressRate)
}
if simpleProgressV2.ProgressRate.Percent == nil || *simpleProgressV2.ProgressRate.Percent != 80.0 {
t.Fatalf("expected progress percent 80.0, got %v", simpleProgressV2.ProgressRate.Percent)
}
if len(simpleProgressV2.Content.Mention) != 1 || simpleProgressV2.Content.Mention[0] != "ou_v2_mention" {
t.Fatalf("unexpected V2 progress mentions: %v", simpleProgressV2.Content.Mention)
}
}
// listTypePtr returns a pointer to the given ListType value.
func listTypePtr(v ListType) *ListType { return &v }

311
shortcuts/okr/okr_patch.go Normal file
View File

@@ -0,0 +1,311 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"context"
"encoding/json"
"fmt"
"io"
"math"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// patchParams holds the parsed parameters for the patch operation.
type patchParams struct {
Level string
TargetID string
Style string
Content *ContentBlock
Notes *ContentBlock
Score *float64
Deadline *string
UserIDType string
}
// parsePatchParams parses and validates flags from runtime into request-ready parameters.
func parsePatchParams(runtime *common.RuntimeContext) (*patchParams, error) {
p := &patchParams{
Level: runtime.Str("level"),
TargetID: runtime.Str("target-id"),
Style: runtime.Str("style"),
UserIDType: runtime.Str("user-id-type"),
}
hasField := false
// Parse content if provided
if contentStr := runtime.Str("content"); contentStr != "" {
hasField = true
if err := common.RejectDangerousCharsTyped("--content", contentStr); err != nil {
return nil, err
}
if p.Style == "simple" {
var sp SemiPlainContent
if err := json.Unmarshal([]byte(contentStr), &sp); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
}
if strings.TrimSpace(sp.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
}
for i, m := range sp.Mention {
if strings.TrimSpace(m) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
}
}
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
}
p.Content = sp.ToContentBlock()
} else {
var cb ContentBlock
if err := json.Unmarshal([]byte(contentStr), &cb); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
p.Content = &cb
}
}
// Parse notes if provided (only for objective)
if notesStr := runtime.Str("notes"); notesStr != "" {
hasField = true
if err := common.RejectDangerousCharsTyped("--notes", notesStr); err != nil {
return nil, err
}
if p.Level != "objective" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes is only supported for level=objective").WithParam("--notes")
}
if p.Style == "simple" {
var sp SemiPlainContent
if err := json.Unmarshal([]byte(notesStr), &sp); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--notes").WithCause(err)
}
if strings.TrimSpace(sp.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes text is required and cannot be empty").WithParam("--notes")
}
for i, m := range sp.Mention {
if strings.TrimSpace(m) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes mention[%d] cannot be empty", i).WithParam("--notes")
}
}
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--notes")
}
p.Notes = sp.ToContentBlock()
} else {
var cb ContentBlock
if err := json.Unmarshal([]byte(notesStr), &cb); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes must be valid ContentBlock JSON: %s", err).WithParam("--notes").WithCause(err)
}
p.Notes = &cb
}
}
// Parse score if provided
if scoreStr := runtime.Str("score"); scoreStr != "" {
hasField = true
score, err := strconv.ParseFloat(scoreStr, 64)
if err != nil || math.IsNaN(score) || math.IsInf(score, 0) {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must be a valid number").WithParam("--score")
}
if score < 0 || score > 1 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must be between 0 and 1").WithParam("--score")
}
// Check for exactly one decimal place
scoreStrTrimmed := strings.TrimRight(strings.TrimRight(scoreStr, "0"), ".")
parts := strings.Split(scoreStrTrimmed, ".")
if len(parts) == 2 && len(parts[1]) > 1 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must have at most one decimal place (e.g., 0.5, not 0.51)").WithParam("--score")
}
// Validation ensures at most one decimal place, so score is already correctly formatted
p.Score = &score
}
// Parse deadline if provided
if deadlineStr := runtime.Str("deadline"); deadlineStr != "" {
hasField = true
deadlineMs, err := strconv.ParseInt(deadlineStr, 10, 64)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a valid millisecond timestamp (integer)").WithParam("--deadline")
}
if deadlineMs <= 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a positive millisecond timestamp").WithParam("--deadline")
}
// Reject non-millisecond timestamps: year 2000 in ms is ~946e9, year 2100 in ms is ~4.1e12
// Anything less than 1e12 is likely seconds or a wrong unit
if deadlineMs < 1000000000000 { // 1e12 ms = year ~33658, so use 1e12 as lower bound for reasonable ms timestamps
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a millisecond timestamp (13 digits), not seconds").WithParam("--deadline")
}
p.Deadline = &deadlineStr
}
// At least one field must be provided
if !hasField {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "at least one of --content, --notes, --score, or --deadline must be provided")
}
return p, nil
}
// OKRPatch patches an objective or key result.
var OKRPatch = common.Shortcut{
Service: "okr",
Command: "+patch",
Description: "Patch an OKR objective or key result (content, notes, score, deadline)",
Risk: "write",
Scopes: []string{"okr:okr.content:writeonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "level", Desc: "patch level: objective | key-result", Required: true, Enum: []string{"objective", "key-result"}},
{Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true},
{Name: "style", Default: "simple", Desc: "input style for content/notes: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
{Name: "content", Desc: "content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}},
{Name: "notes", Desc: "notes (objective only): semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}},
{Name: "score", Desc: "score value between 0 and 1, with at most one decimal place (e.g., 0.5)"},
{Name: "deadline", Desc: "deadline as millisecond timestamp"},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
if level != "objective" && level != "key-result" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
}
targetID := runtime.Str("target-id")
if targetID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id is required").WithParam("--target-id")
}
if err := common.RejectDangerousCharsTyped("--target-id", targetID); err != nil {
return err
}
if id, err := strconv.ParseInt(targetID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id must be a positive int64").WithParam("--target-id")
}
style := runtime.Str("style")
if style != "simple" && style != "richtext" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
}
idType := runtime.Str("user-id-type")
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
}
// Delegate content/notes/score/deadline validation to parsePatchParams
if _, err := parsePatchParams(runtime); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
p, err := parsePatchParams(runtime)
if err != nil {
return common.NewDryRunAPI().
PATCH("").
Desc(fmt.Sprintf("Dry-run skipped: %s", err.Error()))
}
body := make(map[string]interface{})
if p.Content != nil {
body["content"] = p.Content
}
if p.Notes != nil {
body["notes"] = p.Notes
}
if p.Score != nil {
body["score"] = *p.Score
}
if p.Deadline != nil {
body["deadline"] = *p.Deadline
}
params := map[string]interface{}{
"user_id_type": p.UserIDType,
}
api := common.NewDryRunAPI()
if p.Level == "objective" {
api = api.PATCH("/open-apis/okr/v2/objectives/:objective_id").
Set("objective_id", p.TargetID)
} else {
api = api.PATCH("/open-apis/okr/v2/key_results/:key_result_id").
Set("key_result_id", p.TargetID)
}
return api.Params(params).Body(body).
Desc(fmt.Sprintf("Patch OKR %s: content=%v, notes=%v, score=%v, deadline=%v",
p.Level, p.Content != nil, p.Notes != nil, p.Score != nil, p.Deadline != nil))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
p, err := parsePatchParams(runtime)
if err != nil {
return err
}
body := make(map[string]interface{})
if p.Content != nil {
body["content"] = p.Content
}
if p.Notes != nil {
body["notes"] = p.Notes
}
if p.Score != nil {
body["score"] = *p.Score
}
if p.Deadline != nil {
body["deadline"] = *p.Deadline
}
queryParams := map[string]interface{}{
"user_id_type": p.UserIDType,
}
var path string
if p.Level == "objective" {
path = fmt.Sprintf("/open-apis/okr/v2/objectives/%s", p.TargetID)
} else {
path = fmt.Sprintf("/open-apis/okr/v2/key_results/%s", p.TargetID)
}
_, err = runtime.CallAPITyped("PATCH", path, queryParams, body)
if err != nil {
return wrapOkrNetworkErr(err, "failed to patch OKR %s", p.Level)
}
result := map[string]interface{}{
"level": p.Level,
"target_id": p.TargetID,
"patched": map[string]bool{
"content": p.Content != nil,
"notes": p.Notes != nil,
"score": p.Score != nil,
"deadline": p.Deadline != nil,
},
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Patched OKR %s [%s]\n", p.Level, p.TargetID)
if p.Content != nil {
fmt.Fprintf(w, " - content: updated\n")
}
if p.Notes != nil {
fmt.Fprintf(w, " - notes: updated\n")
}
if p.Score != nil {
fmt.Fprintf(w, " - score: %.1f\n", *p.Score)
}
if p.Deadline != nil {
fmt.Fprintf(w, " - deadline: %s\n", formatTimestamp(*p.Deadline))
}
})
return nil
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ import (
"io"
"math"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
@@ -35,12 +36,37 @@ type createProgressRecordParams struct {
// parseCreateProgressRecordParams parses and validates flags from runtime into request-ready parameters.
func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createProgressRecordParams, error) {
style := runtime.Str("style")
content := runtime.Str("content")
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
var contentV1 *ContentBlockV1
if style == "simple" {
var sp SemiPlainContent
if err := json.Unmarshal([]byte(content), &sp); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
}
if strings.TrimSpace(sp.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
}
// Validate mention IDs are non-empty
for i, m := range sp.Mention {
if strings.TrimSpace(m) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
}
}
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
}
// Build ContentBlock from semi-plain content (text + mentions)
contentV1 = sp.ToContentBlock().ToV1()
} else {
// richtext mode
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
contentV1 = cb.ToV1()
}
contentV1 := cb.ToV1()
targetType := runtime.Str("target-type")
targetTypeVal := targetTypeAllowed[targetType]
@@ -92,7 +118,7 @@ var OKRCreateProgressRecord = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "content", Desc: "progress content in ContentBlock JSON format", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "content", Desc: "progress content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple style) or ContentBlock JSON (richtext style)", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true},
{Name: "target-type", Desc: "target type: objective | key_result", Required: true, Enum: []string{"objective", "key_result"}},
{Name: "progress-percent", Desc: "progress percentage"},
@@ -100,6 +126,7 @@ var OKRCreateProgressRecord = common.Shortcut{
{Name: "source-title", Default: "created by lark-cli", Desc: "source title for display"},
{Name: "source-url", Desc: "source URL for display (defaults to open platform URL based on brand)"},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
{Name: "style", Default: "simple", Desc: "input style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
content := runtime.Str("content")
@@ -109,10 +136,36 @@ var OKRCreateProgressRecord = common.Shortcut{
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
return err
}
// Validate content is valid JSON and can be parsed as ContentBlock
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
style := runtime.Str("style")
if style != "simple" && style != "richtext" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
}
// Validate content based on style
if style == "simple" {
var sp SemiPlainContent
if err := json.Unmarshal([]byte(content), &sp); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
}
if strings.TrimSpace(sp.Text) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
}
for i, m := range sp.Mention {
if strings.TrimSpace(m) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
}
}
// If user provided docs or images in simple mode, warn that they are ignored
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
}
} else {
// richtext mode
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
}
targetID := runtime.Str("target-id")
@@ -213,21 +266,43 @@ var OKRCreateProgressRecord = common.Shortcut{
return err
}
resp := record.ToResp()
result := map[string]interface{}{
"progress": resp,
}
style := runtime.Str("style")
var result map[string]interface{}
if style == "simple" {
resp := record.ToSimple()
result = map[string]interface{}{
"progress": resp,
"style": style,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Created Progress [%s]\n", resp.ID)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Created Progress [%s] (style: %s)\n", resp.ID, style)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", resp.Content.Text)
}
})
} else {
resp := record.ToResp()
result = map[string]interface{}{
"progress": resp,
"style": style,
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
}
})
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Created Progress [%s] (style: %s)\n", resp.ID, style)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
}
})
}
return nil
},
}

View File

@@ -5,11 +5,13 @@ package okr
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -38,6 +40,7 @@ func runProgressCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.B
}
const validContentBlockJSON = `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test content"}}]}}]}`
const validSemiPlainJSON = `{"text":"test content","mention":["ou_123"]}`
// --- Validate tests ---
@@ -60,6 +63,7 @@ func TestProgressCreateValidate_InvalidContentJSON(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", "not-json",
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
})
@@ -77,6 +81,7 @@ func TestProgressCreateValidate_MissingTargetID(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-type", "objective",
})
if err == nil {
@@ -90,6 +95,7 @@ func TestProgressCreateValidate_InvalidTargetID_NonNumeric(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "abc",
"--target-type", "objective",
})
@@ -107,6 +113,7 @@ func TestProgressCreateValidate_InvalidTargetType(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123",
"--target-type", "invalid",
})
@@ -124,6 +131,7 @@ func TestProgressCreateValidate_ControlCharsInContent(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", "{\"blocks\":[{\"block_element_type\":\"para\tgraph\"}]}",
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
})
@@ -138,6 +146,7 @@ func TestProgressCreateValidate_InvalidUserIDType(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
"--user-id-type", "invalid",
@@ -153,6 +162,7 @@ func TestProgressCreateValidate_InvalidProgressPercent_OutOfRange(t *testing.T)
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
"--progress-percent", "999999999999",
@@ -171,6 +181,7 @@ func TestProgressCreateValidate_InvalidProgressPercent_NonNumeric(t *testing.T)
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
"--progress-percent", "abc",
@@ -189,6 +200,7 @@ func TestProgressCreateValidate_InvalidProgressStatus(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
"--progress-status", "invalid_status",
@@ -219,6 +231,7 @@ func TestProgressCreateValidate_Valid(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
})
@@ -235,6 +248,7 @@ func TestProgressCreateDryRun(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
"--dry-run",
@@ -264,6 +278,7 @@ func TestProgressCreateDryRun_WithProgressRate(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123",
"--target-type", "objective",
"--progress-percent", "75",
@@ -299,6 +314,7 @@ func TestProgressCreateExecute_Success(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "456",
"--target-type", "key_result",
})
@@ -330,6 +346,7 @@ func TestProgressCreateExecute_APIError(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "789",
"--target-type", "objective",
})
@@ -337,3 +354,200 @@ func TestProgressCreateExecute_APIError(t *testing.T) {
t.Fatal("expected error for API failure")
}
}
// --- Simple mode tests ---
func TestProgressCreateExecute_SimpleMode_DefaultStyle(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, progressCreateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v1/progress_records/",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"progress_id": "300",
"modify_time": "1735776000000",
},
},
})
// Use default style (simple) without specifying --style
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validSemiPlainJSON,
"--target-id", "123",
"--target-type", "objective",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
pr, _ := data["progress"].(map[string]interface{})
if pr == nil {
t.Fatal("expected progress in output")
}
if pr["progress_id"] != "300" {
t.Fatalf("progress_id = %v, want 300", pr["progress_id"])
}
}
func TestProgressCreateExecute_SimpleMode_ExplicitStyle(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, progressCreateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v1/progress_records/",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"progress_id": "400",
"modify_time": "1735776000000",
},
},
})
// Explicitly specify --style simple with mentions
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", `{"text":"simple progress with mention","mention":["ou_abc","ou_def"]}`,
"--style", "simple",
"--target-id", "456",
"--target-type", "key_result",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
pr, _ := data["progress"].(map[string]interface{})
if pr == nil {
t.Fatal("expected progress in output")
}
if pr["progress_id"] != "400" {
t.Fatalf("progress_id = %v, want 400", pr["progress_id"])
}
}
func TestProgressCreateValidate_SimpleMode_InvalidSemiPlainJSON(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", `{"text":"missing closing brace`,
"--target-id", "123",
"--target-type", "objective",
})
if err == nil {
t.Fatal("expected error for invalid semi-plain JSON")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got: %T", err)
}
if validationErr.Param != "--content" {
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
}
if !strings.Contains(err.Error(), "--content must be valid semi-plain JSON") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProgressCreateValidate_SimpleMode_EmptyText(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", `{"text":" ","mention":[]}`,
"--target-id", "123",
"--target-type", "objective",
})
if err == nil {
t.Fatal("expected error for empty text in simple mode")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got: %T", err)
}
if validationErr.Param != "--content" {
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
}
if !strings.Contains(err.Error(), "--content text is required and cannot be empty") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProgressCreateValidate_SimpleMode_DocsImagesNotSupported(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", `{"text":"has docs","mention":[],"docs":[{"title":"doc","url":"https://example.com"}]}`,
"--target-id", "123",
"--target-type", "objective",
})
if err == nil {
t.Fatal("expected error for docs in simple mode")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got: %T", err)
}
if validationErr.Param != "--content" {
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
}
if !strings.Contains(err.Error(), "docs and images are not supported in simple style input") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProgressCreateDryRun_SimpleMode(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validSemiPlainJSON,
"--target-id", "123",
"--target-type", "objective",
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v1/progress_records/") {
t.Fatalf("dry-run output should contain API path, got: %s", output)
}
if !strings.Contains(output, "POST") {
t.Fatalf("dry-run output should contain POST method, got: %s", output)
}
}

View File

@@ -26,6 +26,7 @@ var OKRGetProgressRecord = common.Shortcut{
Flags: []common.Flag{
{Name: "progress-id", Desc: "progress ID (int64)", Required: true},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
{Name: "style", Default: "simple", Desc: "output style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
progressID := runtime.Str("progress-id")
@@ -39,6 +40,10 @@ var OKRGetProgressRecord = common.Shortcut{
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
}
style := runtime.Str("style")
if style != "simple" && style != "richtext" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -55,6 +60,7 @@ var OKRGetProgressRecord = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
progressID := runtime.Str("progress-id")
userIDType := runtime.Str("user-id-type")
style := runtime.Str("style")
queryParams := map[string]interface{}{"user_id_type": userIDType}
@@ -69,21 +75,45 @@ var OKRGetProgressRecord = common.Shortcut{
return err
}
resp := record.ToResp()
result := map[string]interface{}{
"progress": resp,
}
var result map[string]interface{}
if style == "simple" {
resp := record.ToSimple()
result = map[string]interface{}{
"progress": resp,
"style": style,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Progress [%s]\n", resp.ID)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Progress [%s] (style: %s)\n", resp.ID, style)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", resp.Content.Text)
if len(resp.Content.Mention) > 0 {
fmt.Fprintf(w, " Mentions: %v\n", resp.Content.Mention)
}
}
})
} else {
resp := record.ToResp()
result = map[string]interface{}{
"progress": resp,
"style": style,
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
}
})
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Progress [%s] (style: %s)\n", resp.ID, style)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
}
})
}
return nil
},
}

View File

@@ -10,6 +10,7 @@ import (
"io"
"math"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
@@ -25,12 +26,35 @@ type updateProgressRecordParams struct {
// parseUpdateProgressRecordParams parses and validates flags from runtime into request-ready parameters.
func parseUpdateProgressRecordParams(runtime *common.RuntimeContext) (*updateProgressRecordParams, error) {
style := runtime.Str("style")
content := runtime.Str("content")
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
var contentV1 *ContentBlockV1
if style == "simple" {
var sp SemiPlainContent
if err := json.Unmarshal([]byte(content), &sp); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
}
if strings.TrimSpace(sp.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
}
for i, m := range sp.Mention {
if strings.TrimSpace(m) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
}
}
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
}
contentV1 = sp.ToContentBlock().ToV1()
} else {
// richtext mode
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
contentV1 = cb.ToV1()
}
contentV1 := cb.ToV1()
var progressRate *ProgressRateV1
if v := runtime.Str("progress-percent"); v != "" {
@@ -67,10 +91,11 @@ var OKRUpdateProgressRecord = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
{Name: "progress-id", Desc: "progress ID (int64)", Required: true},
{Name: "content", Desc: "progress content in ContentBlock JSON format", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "content", Desc: "progress content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple style) or ContentBlock JSON (richtext style)", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "progress-percent", Desc: "progress percentage"},
{Name: "progress-status", Desc: "progress status: normal | overdue | done", Enum: []string{"normal", "overdue", "done"}},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
{Name: "style", Default: "simple", Desc: "input style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
progressID := runtime.Str("progress-id")
@@ -88,9 +113,35 @@ var OKRUpdateProgressRecord = common.Shortcut{
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
return err
}
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
style := runtime.Str("style")
if style != "simple" && style != "richtext" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
}
// Validate content based on style
if style == "simple" {
var sp SemiPlainContent
if err := json.Unmarshal([]byte(content), &sp); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
}
if strings.TrimSpace(sp.Text) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
}
for i, m := range sp.Mention {
if strings.TrimSpace(m) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
}
}
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
}
} else {
// richtext mode
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
}
if v := runtime.Str("progress-percent"); v != "" {
@@ -158,21 +209,43 @@ var OKRUpdateProgressRecord = common.Shortcut{
return err
}
resp := record.ToResp()
result := map[string]interface{}{
"progress": resp,
}
style := runtime.Str("style")
var result map[string]interface{}
if style == "simple" {
resp := record.ToSimple()
result = map[string]interface{}{
"progress": resp,
"style": style,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Updated Progress [%s]\n", resp.ID)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent)
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Updated Progress [%s] (style: %s)\n", resp.ID, style)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", resp.Content.Text)
}
})
} else {
resp := record.ToResp()
result = map[string]interface{}{
"progress": resp,
"style": style,
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
}
})
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Updated Progress [%s] (style: %s)\n", resp.ID, style)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
}
})
}
return nil
},
}

View File

@@ -5,11 +5,13 @@ package okr
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -45,6 +47,7 @@ func TestProgressUpdateValidate_MissingProgressID(t *testing.T) {
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--content", validContentBlockJSON,
"--style", "richtext",
})
if err == nil {
t.Fatal("expected error for missing --progress-id")
@@ -58,6 +61,7 @@ func TestProgressUpdateValidate_InvalidProgressID(t *testing.T) {
"+progress-update",
"--progress-id", "abc",
"--content", validContentBlockJSON,
"--style", "richtext",
})
if err == nil {
t.Fatal("expected error for invalid --progress-id")
@@ -86,6 +90,7 @@ func TestProgressUpdateValidate_InvalidContentJSON(t *testing.T) {
"+progress-update",
"--progress-id", "123",
"--content", "not-json",
"--style", "richtext",
})
if err == nil {
t.Fatal("expected error for invalid --content JSON")
@@ -102,6 +107,7 @@ func TestProgressUpdateValidate_InvalidUserIDType(t *testing.T) {
"+progress-update",
"--progress-id", "123",
"--content", validContentBlockJSON,
"--style", "richtext",
"--user-id-type", "invalid",
})
if err == nil {
@@ -116,6 +122,7 @@ func TestProgressUpdateValidate_InvalidProgressPercent_OutOfRange(t *testing.T)
"+progress-update",
"--progress-id", "123",
"--content", validContentBlockJSON,
"--style", "richtext",
"--progress-percent", "-999999999999",
})
if err == nil {
@@ -133,6 +140,7 @@ func TestProgressUpdateValidate_InvalidProgressStatus(t *testing.T) {
"+progress-update",
"--progress-id", "123",
"--content", validContentBlockJSON,
"--style", "richtext",
"--progress-status", "invalid_status",
})
if err == nil {
@@ -162,6 +170,7 @@ func TestProgressUpdateValidate_Valid(t *testing.T) {
"+progress-update",
"--progress-id", "123",
"--content", validContentBlockJSON,
"--style", "richtext",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -177,6 +186,7 @@ func TestProgressUpdateDryRun(t *testing.T) {
"+progress-update",
"--progress-id", "456",
"--content", validContentBlockJSON,
"--style", "richtext",
"--dry-run",
})
if err != nil {
@@ -201,6 +211,7 @@ func TestProgressUpdateDryRun_WithProgressRate(t *testing.T) {
"+progress-update",
"--progress-id", "456",
"--content", validContentBlockJSON,
"--style", "richtext",
"--progress-percent", "50",
"--progress-status", "overdue",
"--dry-run",
@@ -235,6 +246,7 @@ func TestProgressUpdateExecute_Success(t *testing.T) {
"+progress-update",
"--progress-id", "789",
"--content", validContentBlockJSON,
"--style", "richtext",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -265,8 +277,202 @@ func TestProgressUpdateExecute_APIError(t *testing.T) {
"+progress-update",
"--progress-id", "999",
"--content", validContentBlockJSON,
"--style", "richtext",
})
if err == nil {
t.Fatal("expected error for API failure")
}
}
// --- Simple mode tests ---
func TestProgressUpdateExecute_SimpleMode_DefaultStyle(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v1/progress_records/500",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"progress_id": "500",
"modify_time": "1735776000000",
},
},
})
// Use default style (simple) without specifying --style
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--progress-id", "500",
"--content", validSemiPlainJSON,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
pr, _ := data["progress"].(map[string]interface{})
if pr == nil {
t.Fatal("expected progress in output")
}
if pr["progress_id"] != "500" {
t.Fatalf("progress_id = %v, want 500", pr["progress_id"])
}
}
func TestProgressUpdateExecute_SimpleMode_ExplicitStyle(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v1/progress_records/600",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"progress_id": "600",
"modify_time": "1735776000000",
},
},
})
// Explicitly specify --style simple with mentions and progress rate
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--progress-id", "600",
"--content", `{"text":"updated progress","mention":["ou_abc"]}`,
"--style", "simple",
"--progress-percent", "80",
"--progress-status", "normal",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
pr, _ := data["progress"].(map[string]interface{})
if pr == nil {
t.Fatal("expected progress in output")
}
if pr["progress_id"] != "600" {
t.Fatalf("progress_id = %v, want 600", pr["progress_id"])
}
}
func TestProgressUpdateValidate_SimpleMode_InvalidSemiPlainJSON(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--progress-id", "123",
"--content", `{"text":"invalid json`,
})
if err == nil {
t.Fatal("expected error for invalid semi-plain JSON")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got: %T", err)
}
if validationErr.Param != "--content" {
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
}
if !strings.Contains(err.Error(), "--content must be valid semi-plain JSON") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProgressUpdateValidate_SimpleMode_EmptyMention(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--progress-id", "123",
"--content", `{"text":"has empty mention","mention":["ou_abc",""]}`,
})
if err == nil {
t.Fatal("expected error for empty mention in simple mode")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got: %T", err)
}
if validationErr.Param != "--content" {
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
}
if !strings.Contains(err.Error(), "--content mention[1] cannot be empty") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProgressUpdateValidate_SimpleMode_ImagesNotSupported(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--progress-id", "123",
"--content", `{"text":"has images","mention":[],"images":["img_token"]}`,
})
if err == nil {
t.Fatal("expected error for images in simple mode")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got: %T", err)
}
if validationErr.Param != "--content" {
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
}
if !strings.Contains(err.Error(), "docs and images are not supported in simple style input") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProgressUpdateDryRun_SimpleMode(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--progress-id", "700",
"--content", validSemiPlainJSON,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v1/progress_records/700") {
t.Fatalf("dry-run output should contain API path, got: %s", output)
}
if !strings.Contains(output, "PUT") {
t.Fatalf("dry-run output should contain PUT method, got: %s", output)
}
}

View File

@@ -22,5 +22,6 @@ func Shortcuts() []common.Shortcut {
OKRReorder,
OKRWeight,
OKRIndicatorUpdate,
OKRPatch,
}
}

View File

@@ -5,6 +5,7 @@ package backward
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
@@ -17,20 +18,30 @@ import (
// Drive media parent_type values for uploading an image into a spreadsheet.
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
// synthetic token prefixed with "fake_office_" and the backend requires
// "office_sheet_file" instead.
// synthetic token prefixed with "fake_office_" (being renamed to
// "local_office_") and the backend requires "office_sheet_file" instead.
const (
sheetImageParentType = "sheet_image"
officeSheetFileParentType = "office_sheet_file"
fakeOfficeTokenPrefix = "fake_office_"
localOfficeTokenPrefix = "local_office_"
)
// officeTokenPrefixes are the synthetic token prefixes an imported "office"
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
// "local_office_"; accept either so image uploads keep working across the
// rename.
var officeTokenPrefixes = []string{fakeOfficeTokenPrefix, localOfficeTokenPrefix}
// sheetMediaParentType returns the drive media parent_type to use when
// uploading an image whose parent_node is spreadsheetToken, mapping the
// "fake_office_" imported-spreadsheet token prefix to "office_sheet_file".
// uploading an image whose parent_node is spreadsheetToken, mapping either the
// "fake_office_" or "local_office_" imported-spreadsheet token prefix to
// "office_sheet_file".
func sheetMediaParentType(spreadsheetToken string) string {
if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {
return officeSheetFileParentType
for _, prefix := range officeTokenPrefixes {
if strings.HasPrefix(spreadsheetToken, prefix) {
return officeSheetFileParentType
}
}
return sheetImageParentType
}
@@ -135,7 +146,8 @@ func validateSheetMediaUploadFile(runtime *common.RuntimeContext, filePath strin
stat, err := runtime.FileIO().Stat(filePath)
if err != nil {
wrapped := common.WrapInputStatErrorTyped(err, "file not found")
if v, ok := wrapped.(*errs.ValidationError); ok {
var v *errs.ValidationError
if errors.As(wrapped, &v) {
return "", nil, v.WithParam("--file")
}
return "", nil, wrapped

View File

@@ -332,11 +332,21 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
}, nil
}
// maxBatchOperations caps how many sub-operations a single +batch-update may
// carry. Every translated op (with its own cells/properties payload) is held in
// the out slice at once before the whole batch is marshaled, so an unbounded
// operation count is the same unbounded-materialization hazard as the fan-out
// matrix, on the operations axis.
const maxBatchOperations = 100
// translateBatchOperations 翻译整个 ops 数组fail-fast遇错立即返回。
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
if len(rawOps) == 0 {
return nil, sheetsValidationForFlag("operations", "--operations must be a non-empty JSON array")
}
if len(rawOps) > maxBatchOperations {
return nil, sheetsValidationForFlag("operations", "--operations accepts at most %d entries; got %d", maxBatchOperations, len(rawOps))
}
out := make([]interface{}, 0, len(rawOps))
for i, raw := range rawOps {
translated, err := translateBatchOp(raw, token, i)

View File

@@ -1,4 +1,59 @@
{
"+formula-verify": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "sheet-id",
"kind": "public",
"type": "string_slice",
"required": "optional",
"desc": "Sheet reference_id(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."
},
{
"name": "sheet-name",
"kind": "public",
"type": "string_slice",
"required": "optional",
"desc": "Sheet name(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."
},
{
"name": "range",
"kind": "own",
"type": "string_slice",
"required": "optional",
"desc": "Optional A1 ranges (e.g. `A1:Z200`); repeat or comma-separate for multiple ranges. Omit to scan each sheet's current_region."
},
{
"name": "max-locations",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max locations / samples per error type; default 20.",
"default": "20"
},
{
"name": "exit-on-error",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "When status=errors_found, exit non-zero. Useful for CI gate after batch formula writes."
}
]
},
"+workbook-info": {
"risk": "read",
"flags": [
@@ -25,6 +80,32 @@
}
]
},
"+revision-get": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+sheet-create": {
"risk": "write",
"flags": [
@@ -73,6 +154,14 @@
"desc": "Initial column count (default 20, max 200)",
"default": "20"
},
{
"name": "type",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "New sub-sheet type: sheet (spreadsheet) | bitable; default sheet. bitable creates an empty table only — edit its content via lark-base commands",
"default": "sheet"
},
{
"name": "dry-run",
"kind": "system",
@@ -219,7 +308,7 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`",
"desc": "Source position (0-based); optional for standalone calls — if omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`. Inside `+batch-update` it must be passed explicitly, since batch cannot issue a structure query mid-run to derive it",
"default": "-1"
},
{
@@ -515,7 +604,7 @@
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected (dates / numbers land as text — use --sheets to preserve types), through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
"input": [
"file",
"stdin"
@@ -1069,7 +1158,7 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Group nesting level to ungroup; default 1 (outermost)",
"desc": "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)",
"default": "1"
},
{
@@ -1711,6 +1800,13 @@
"required": "optional",
"desc": "Font color (hex, e.g. `#000000`)"
},
{
"name": "font-family",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Font family name (e.g. `Arial`, `Microsoft YaHei`)"
},
{
"name": "font-size",
"kind": "own",
@@ -2739,7 +2835,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range",
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range",
"input": [
"file",
"stdin"
@@ -2759,6 +2855,13 @@
"required": "optional",
"desc": "Font color (hex, e.g. `#000000`)"
},
{
"name": "font-family",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Font family name (e.g. `Arial`, `Microsoft YaHei`)"
},
{
"name": "font-size",
"kind": "own",
@@ -2885,7 +2988,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id",
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id",
"input": [
"file",
"stdin"
@@ -2965,7 +3068,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id",
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!E2:E6\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id",
"input": [
"file",
"stdin"
@@ -3009,7 +3112,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range",
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range",
"input": [
"file",
"stdin"
@@ -3127,7 +3230,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.",
"desc": "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`); must include at least one of `snapshot.data.dim1.serie.index` or `dim2.series[].index`, otherwise the server rejects it. Deeply nested — run `--print-schema --flag-name properties` for the full structure.",
"input": [
"file",
"stdin"
@@ -4066,7 +4169,7 @@
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"
"desc": "Filter-view name; auto-assigned by the server when omitted; takes precedence over the same-named field inside `--properties`"
},
{
"name": "dry-run",
@@ -4747,5 +4850,138 @@
"desc": ""
}
]
},
"+history-list": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "end-version",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max version to query (descending pagination). Omit on the first call; pass next_end_version from the previous response."
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+history-revert": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "history-version-id",
"kind": "own",
"type": "string",
"required": "required",
"desc": "History version to revert to (from +history-list)."
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+history-revert-status": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "transaction-id",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Async revert transaction id (from +history-revert)."
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+changeset-get": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "start-revision",
"kind": "own",
"type": "int",
"required": "required",
"desc": "Start version (CS revision); the before baseline for review (must be >= 1)"
},
{
"name": "end-revision",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20",
"default": "-1"
}
]
}
}

View File

@@ -241,6 +241,10 @@
"description": "字体颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"font_family": {
"description": "字体名称/字族(例如 \"Arial\"、\"微软雅黑\"、\"宋体\"",
"type": "string"
},
"font_size": {
"description": "字体大小单位px/像素,例如 10、12、14",
"type": "number"
@@ -6498,6 +6502,9 @@
"font_color": {
"type": "string"
},
"font_family": {
"type": "string"
},
"font_line": {
"enum": [
"none",
@@ -6867,6 +6874,9 @@
"font_color": {
"type": "string"
},
"font_family": {
"type": "string"
},
"font_line": {
"enum": [
"none",

View File

@@ -27,7 +27,7 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}},
{Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); batch clear is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -38,9 +38,10 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}},
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
{Name: "font-family", Kind: "own", Type: "string", Required: "optional", Desc: "Font family name (e.g. `Arial`, `Microsoft YaHei`)"},
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
@@ -165,6 +166,7 @@ var flagDefs = map[string]commandDef{
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A1:B2`)"},
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
{Name: "font-family", Kind: "own", Type: "string", Required: "optional", Desc: "Font family name (e.g. `Arial`, `Microsoft YaHei`)"},
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
@@ -188,6 +190,15 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+changeset-get": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "start-revision", Kind: "own", Type: "int", Required: "required", Desc: "Start version (CS revision); the before baseline for review (must be >= 1)"},
{Name: "end-revision", Kind: "own", Type: "int", Required: "optional", Desc: "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20", Default: "-1"},
},
},
"+chart-create": {
Risk: "write",
Flags: []flagDef{
@@ -195,7 +206,7 @@ var flagDefs = map[string]commandDef{
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`); must include at least one of `snapshot.data.dim1.serie.index` or `dim2.series[].index`, otherwise the server rejects it. Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
},
},
@@ -405,7 +416,7 @@ var flagDefs = map[string]commandDef{
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (outermost)", Default: "1"},
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)", Default: "1"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -426,7 +437,7 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!E2:E6\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", Input: []string{"file", "stdin"}},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -463,7 +474,7 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", Input: []string{"file", "stdin"}},
{Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}},
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
{Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select"},
@@ -526,7 +537,7 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; required on create and must cover the header row"},
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"},
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted; takes precedence over the same-named field inside `--properties`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -632,6 +643,45 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+formula-verify": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string_slice", Required: "optional", Desc: "Sheet reference_id(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."},
{Name: "sheet-name", Kind: "public", Type: "string_slice", Required: "optional", Desc: "Sheet name(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."},
{Name: "range", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Optional A1 ranges (e.g. `A1:Z200`); repeat or comma-separate for multiple ranges. Omit to scan each sheet's current_region."},
{Name: "max-locations", Kind: "own", Type: "int", Required: "optional", Desc: "Max locations / samples per error type; default 20.", Default: "20"},
{Name: "exit-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "When status=errors_found, exit non-zero. Useful for CI gate after batch formula writes."},
},
},
"+history-list": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "end-version", Kind: "own", Type: "int", Required: "optional", Desc: "Max version to query (descending pagination). Omit on the first call; pass next_end_version from the previous response."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+history-revert": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "history-version-id", Kind: "own", Type: "string", Required: "required", Desc: "History version to revert to (from +history-list)."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+history-revert-status": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "transaction-id", Kind: "own", Type: "string", Required: "required", Desc: "Async revert transaction id (from +history-revert)."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+pivot-create": {
Risk: "write",
Flags: []flagDef{
@@ -734,6 +784,14 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+revision-get": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+rows-resize": {
Risk: "write",
Flags: []flagDef{
@@ -768,6 +826,7 @@ var flagDefs = map[string]commandDef{
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position (0-based); appended to the end when omitted", Default: "-1"},
{Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"},
{Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"},
{Name: "type", Kind: "own", Type: "string", Required: "optional", Desc: "New sub-sheet type: sheet (spreadsheet) | bitable; default sheet. bitable creates an empty table only — edit its content via lark-base commands", Default: "sheet"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -822,7 +881,7 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "index", Kind: "own", Type: "int", Required: "required", Desc: "Target position (0-based)"},
{Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`", Default: "-1"},
{Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional for standalone calls — if omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`. Inside `+batch-update` it must be passed explicitly, since batch cannot issue a structure query mid-run to derive it", Default: "-1"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -941,7 +1000,7 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}},
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected (dates / numbers land as text — use --sheets to preserve types), through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}},
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): top-level `{\"sheets\":[...]}`, with each array item a sub-sheet `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` — `name` and the outer `sheets` envelope are both required. Agents typically use `df_to_sheet(df, name)` from `scripts/sheets_df.py` to pack each DataFrame into one item, then wrap the list in `{\"sheets\":[...]}`. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},

View File

@@ -6,7 +6,6 @@ package sheets
import (
_ "embed"
"encoding/json"
"fmt"
"sort"
"sync"
@@ -54,7 +53,7 @@ func loadFlagSchemas() (*flagSchemaIndex, error) {
flagSchemasOnce.Do(func() {
var idx flagSchemaIndex
if err := json.Unmarshal(flagSchemasJSON, &idx); err != nil {
parseFlagErr = fmt.Errorf("flag-schemas.json: %w", err)
parseFlagErr = errs.NewInternalError(errs.SubtypeUnknown, "flag-schemas.json: %v", err).WithCause(err)
return
}
if idx.Flags == nil {

View File

@@ -243,7 +243,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
if schema.Type != "" {
if !matchesJSONType(value, schema.Type) {
return fmt.Errorf("%sexpected type %q, got %q", pathPrefix(path), schema.Type, jsType(value))
return fmt.Errorf("%sexpected type %q, got %q", pathPrefix(path), schema.Type, jsType(value)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
}
@@ -251,20 +251,20 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
// already reported above). Apply to both `number` and `integer` types.
if num, ok := value.(float64); ok {
if schema.Minimum != nil && num < *schema.Minimum {
return fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum)
return fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
if schema.Maximum != nil && num > *schema.Maximum {
return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum)
return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
}
// Array length bounds — only checked when value is an array.
if arr, ok := value.([]interface{}); ok {
if schema.MinItems != nil && len(arr) < *schema.MinItems {
return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems)
return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
if schema.MaxItems != nil && len(arr) > *schema.MaxItems {
return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems)
return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
}
@@ -282,7 +282,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
if hint := suggestEnumMatch(value, schema.Enum); hint != "" {
msg += fmt.Sprintf(` (did you mean %q?)`, hint)
}
return fmt.Errorf("%s", msg)
return fmt.Errorf("%s", msg) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
}
@@ -295,7 +295,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
}
}
if !matched {
return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path))
return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
}
@@ -305,7 +305,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
if obj, ok := value.(map[string]interface{}); ok {
for _, key := range schema.Required {
if _, present := obj[key]; !present {
return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path))
return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
}
if schema.Properties != nil {
@@ -357,7 +357,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
sort.Strings(extras)
for _, key := range extras {
if schema.AdditionalProperties.Strict {
return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key)
return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
if schema.AdditionalProperties.Schema != nil {
child := key

View File

@@ -281,18 +281,18 @@ func (m mapFlagView) validateRawTypes() error {
// parse time; reject here too to keep batch/standalone parity.
f, isNum := val.(float64)
if !isNum {
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val))
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
}
if math.Trunc(f) != f {
return fmt.Errorf("--%s must be an integer, got %s", name, strconv.FormatFloat(f, 'g', -1, 64))
return fmt.Errorf("--%s must be an integer, got %s", name, strconv.FormatFloat(f, 'g', -1, 64)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
}
case "float64":
if _, isNum := val.(float64); !isNum {
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val))
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
}
case "bool":
if _, isBool := val.(bool); !isBool {
return fmt.Errorf("--%s must be a boolean, got %s", name, jsonTypeName(val))
return fmt.Errorf("--%s must be a boolean, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
}
}
}

View File

@@ -10,6 +10,7 @@ package sheets
import (
"context"
"encoding/json"
"errors"
"fmt"
neturl "net/url"
"strings"
@@ -44,7 +45,8 @@ func sheetsValidationCauseForFlag(name string, cause error) *errs.ValidationErro
// classification and only adds the domain's flag param.
func sheetsInputStatError(flag string, err error) error {
wrapped := common.WrapInputStatErrorTyped(err)
if v, ok := wrapped.(*errs.ValidationError); ok {
var v *errs.ValidationError
if errors.As(wrapped, &v) {
return v.WithParam(sheetsFlagParam(flag))
}
return wrapped
@@ -52,21 +54,30 @@ func sheetsInputStatError(flag string, err error) error {
// Drive media parent_type values for uploading an image into a spreadsheet.
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
// synthetic token prefixed with "fake_office_" and the backend requires
// "office_sheet_file" instead.
// synthetic token prefixed with "fake_office_" (being renamed to
// "local_office_") and the backend requires "office_sheet_file" instead.
const (
sheetImageParentType = "sheet_image"
officeSheetFileParentType = "office_sheet_file"
fakeOfficeTokenPrefix = "fake_office_"
localOfficeTokenPrefix = "local_office_"
)
// officeTokenPrefixes are the synthetic token prefixes an imported "office"
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
// "local_office_"; accept either so image uploads keep working across the
// rename.
var officeTokenPrefixes = []string{fakeOfficeTokenPrefix, localOfficeTokenPrefix}
// sheetMediaParentType returns the drive media parent_type to use when
// uploading an image whose parent_node is spreadsheetToken. It is the single
// place that maps a spreadsheet token to its parent_type so every image-upload
// entry point (and its dry-run preview) stays consistent.
func sheetMediaParentType(spreadsheetToken string) string {
if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {
return officeSheetFileParentType
for _, prefix := range officeTokenPrefixes {
if strings.HasPrefix(spreadsheetToken, prefix) {
return officeSheetFileParentType
}
}
return sheetImageParentType
}
@@ -440,7 +451,7 @@ func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
// ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─
// buildCellStyleFromFlags reads the 11 flat style flags and returns the
// buildCellStyleFromFlags reads the 12 flat style flags and returns the
// cell_styles map expected by set_cell_range. Skips any flag the user
// didn't set so partial styles work.
func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
@@ -451,6 +462,9 @@ func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
if v := runtime.Str("font-color"); v != "" {
style["font_color"] = v
}
if v := runtime.Str("font-family"); v != "" {
style["font_family"] = v
}
if runtime.Changed("font-size") && runtime.Float64("font-size") > 0 {
style["font_size"] = runtime.Float64("font-size")
}

View File

@@ -215,7 +215,8 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[
if borderStyles != nil {
prototype["border_styles"] = borderStyles
}
var ops []interface{}
ops := make([]interface{}, 0, len(ranges))
var totalCells int64
for _, rng := range ranges {
sheet, sub, err := splitSheetPrefixedRange(rng)
if err != nil {
@@ -225,6 +226,13 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[
if err != nil {
return nil, sheetsValidationForFlag("range", "range %q: %v", rng, err)
}
if err := checkStampMatrixBudget("ranges", rng, rows, cols); err != nil {
return nil, err
}
totalCells += int64(rows) * int64(cols)
if err := checkBatchStampBudget(totalCells); err != nil {
return nil, err
}
cells := fillCellsMatrix(rows, cols, prototype)
ops = append(ops, map[string]interface{}{
"tool_name": "set_cell_range",
@@ -299,7 +307,7 @@ func cellsBatchClearInput(runtime *common.RuntimeContext, token string) (map[str
return nil, err
}
clearType := normalizeClearType(runtime.Str("scope"))
var ops []interface{}
ops := make([]interface{}, 0, len(ranges))
for _, rng := range ranges {
sheet, sub, err := splitSheetPrefixedRange(rng)
if err != nil {
@@ -382,13 +390,10 @@ var DropdownDelete = common.Shortcut{
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
ranges, err := validateDropdownRanges(runtime)
if err != nil {
// validateDropdownRanges enforces the shared maxBatchRanges cap.
if _, err := validateDropdownRanges(runtime); err != nil {
return err
}
if len(ranges) > 100 {
return sheetsValidationForFlag("ranges", "--ranges accepts at most 100 entries; got %d", len(ranges))
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -432,7 +437,8 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
}
prototype = map[string]interface{}{"data_validation": validation}
}
var ops []interface{}
ops := make([]interface{}, 0, len(ranges))
var totalCells int64
for _, rng := range ranges {
sheet, sub, err := splitSheetPrefixedRange(rng)
if err != nil {
@@ -442,6 +448,13 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
if err != nil {
return nil, sheetsValidationForFlag("range", "range %q: %v", rng, err)
}
if err := checkStampMatrixBudget("ranges", rng, rows, cols); err != nil {
return nil, err
}
totalCells += int64(rows) * int64(cols)
if err := checkBatchStampBudget(totalCells); err != nil {
return nil, err
}
cells := fillCellsMatrix(rows, cols, prototype)
ops = append(ops, map[string]interface{}{
"tool_name": "set_cell_range",
@@ -461,6 +474,25 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
// ─── helpers resurrected from B3 (used here + future skills) ──────────
// maxBatchRanges caps how many ranges a fan-out batch (+cells-batch-set-style /
// +cells-batch-clear / +dropdown-update / +dropdown-delete) may carry, bounding
// the number of ops materialized into one batch_update.
const maxBatchRanges = 100
// checkBatchStampBudget rejects a fan-out batch whose ranges materialize more
// than maxStampMatrixCells cells in aggregate. A batch builds every range's
// cells matrix up front, so the SUM across ranges is the real peak-memory bound
// — the per-range checkStampMatrixBudget alone can't stop many ranges from
// summing past it. totalCells is int64 to stay overflow-safe.
func checkBatchStampBudget(totalCells int64) error {
if totalCells > maxStampMatrixCells {
return sheetsValidationForFlag("ranges",
"ranges expand to %d cells total, over the %d-cell safety cap; reduce the number or size of ranges",
totalCells, maxStampMatrixCells)
}
return nil
}
// validateDropdownRanges parses --ranges, requires every entry to carry a
// sheet prefix, and returns the parsed list.
func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) {
@@ -490,6 +522,9 @@ func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) {
}
out = append(out, s)
}
if len(out) > maxBatchRanges {
return nil, sheetsValidationForFlag("ranges", "--ranges accepts at most %d entries; got %d", maxBatchRanges, len(out))
}
return out, nil
}

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_changeset ─────────────────────────────────────────────
//
// +changeset-get wraps the get_changeset read tool: fetch the raw changeset
// (the list of edit actions) between two CS revisions of a spreadsheet, so a
// human or reviewing agent can verify whether an AI edit actually fulfilled
// the user's request.
//
// - --start-revision is the "before" baseline (required, >= 1).
// - --end-revision is optional; when omitted it defaults to the latest
// revision, returning every changeset from start up to now.
// - The version gap is capped at 20 (end - start + 1 <= 20); the same cap
// is enforced server-side (sheet-facade-agg maxChangesetRevGap).
const changesetMaxRevGap = 20
// ChangesetGet fetches the raw changesets between two spreadsheet versions.
var ChangesetGet = common.Shortcut{
Service: "sheets",
Command: "+changeset-get",
Description: "Fetch the raw changeset (edit actions) between two versions, to review whether an AI edit fulfilled the request.",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+changeset-get"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
_, _, err := changesetRevisions(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
input, _ := changesetInput(runtime)
return invokeToolDryRun(token, ToolKindRead, "get_changeset", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
input, err := changesetInput(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_changeset", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
Tips: []string{
"Pass only --start-revision to diff against the latest version; add --end-revision to bound the range.",
"The version gap is capped at 20 revisions (end - start + 1 <= 20).",
},
}
// changesetRevisions reads and validates the start / end revision flags.
// end <= 0 means "not provided" (default to latest, resolved server-side); a
// provided end must be >= start and within the 20-revision gap.
func changesetRevisions(runtime flagView) (start int, end int, err error) {
start = runtime.Int("start-revision")
end = runtime.Int("end-revision")
if start < 1 {
return 0, 0, sheetsValidationForFlag("start-revision", "--start-revision must be >= 1")
}
if end > 0 {
if end < start {
return 0, 0, sheetsValidationForFlag("end-revision", "--end-revision (%d) must be >= --start-revision (%d)", end, start)
}
if end-start+1 > changesetMaxRevGap {
return 0, 0, sheetsValidationForFlag("end-revision", "version gap exceeds limit %d (start=%d, end=%d)", changesetMaxRevGap, start, end)
}
}
return start, end, nil
}
// changesetInput builds the get_changeset tool input. end_revision is only
// sent when explicitly provided; otherwise the server defaults to latest.
func changesetInput(runtime flagView) (map[string]interface{}, error) {
start, end, err := changesetRevisions(runtime)
if err != nil {
return nil, err
}
input := map[string]interface{}{
"start_revision": start,
}
if end > 0 {
input["end_revision"] = end
}
return input, nil
}

View File

@@ -0,0 +1,86 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
)
// TestChangesetGet_DryRun locks the get_changeset tool input: --end-revision
// is only sent when explicitly provided, otherwise the server defaults to the
// latest revision.
func TestChangesetGet_DryRun(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args []string
wantInput map[string]interface{}
}{
{
name: "start + end bounded range",
args: []string{"--url", testURL, "--start-revision", "120", "--end-revision", "135"},
wantInput: map[string]interface{}{
"start_revision": float64(120),
"end_revision": float64(135),
},
},
{
name: "start only → end omitted (server defaults to latest)",
args: []string{"--url", testURL, "--start-revision", "120"},
wantInput: map[string]interface{}{
"start_revision": float64(120),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChangesetGet, tt.args)
got := decodeToolInput(t, body, "get_changeset")
assertInputEquals(t, got, tt.wantInput)
})
}
}
// TestChangesetGet_Validation covers the client-side revision guards, which
// mirror the server cap (sheet-facade-agg maxChangesetRevGap = 20).
func TestChangesetGet_Validation(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
wantSub string
}{
{
name: "start-revision must be >= 1",
args: []string{"--url", testURL, "--start-revision", "0"},
wantSub: "start-revision must be >= 1",
},
{
name: "end before start rejected",
args: []string{"--url", testURL, "--start-revision", "100", "--end-revision", "50"},
wantSub: "end-revision",
},
{
name: "gap over 20 rejected",
args: []string{"--url", testURL, "--start-revision", "1", "--end-revision", "30"},
wantSub: "version gap exceeds limit",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, ChangesetGet, append(c.args, "--dry-run"))
if err == nil {
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), c.wantSub) {
t.Errorf("expected %q; got=%s|%s|%v", c.wantSub, stdout, stderr, err)
}
})
}
}

View File

@@ -0,0 +1,167 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_formula_verify ───────────────────────────────────────
//
// Wraps verify_formula (read): scan formulas + cell error states across one
// or more sub-sheets and aggregate Excel errors (#REF! / #DIV/0! / #VALUE! /
// #NAME? / #NULL! / #NUM! / #N/A) plus compile failures (formula_errors)
// into a recalc.py-shaped JSON status report. The contract is the single
// AI self-check entry point for the R10 "write → verify zero-error"
// invariant — see canonical-spec/references/lark_sheet_formula_verify/.
// FormulaVerify wraps verify_formula. Sheet selection is optional (both
// --sheet-id and --sheet-name are repeatable); when omitted, the tool scans
// every visible sub-sheet's current_region.
var FormulaVerify = common.Shortcut{
Service: "sheets",
Command: "+formula-verify",
Description: "Scan formulas / cell errors and return a recalc.py-shaped status report (success / errors_found / partial).",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+formula-verify"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
if err := validateFormulaVerifySheetSelector(runtime); err != nil {
return err
}
return validateFormulaVerifyLimits(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
return invokeToolDryRun(token, ToolKindRead, "verify_formula", formulaVerifyInput(runtime, token))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "verify_formula", formulaVerifyInput(runtime, token))
if err != nil {
return err
}
runtime.Out(out, nil)
if runtime.Bool("exit-on-error") {
return formulaVerifyExitOnError(out)
}
return nil
},
}
// validateFormulaVerifySheetSelector enforces XOR-like guarantees on the
// two multi-value selectors: at most one of --sheet-id / --sheet-name may be
// non-empty (passing both is the high-frequency reflex confusion when the
// caller cargo-cults the single-sheet shortcut signature). Both empty is the
// documented "scan every visible sub-sheet" path. Control-char checks reuse
// requireSheetSelector's logic on each item.
func validateFormulaVerifySheetSelector(runtime *common.RuntimeContext) error {
ids := nonEmptySliceItems(runtime.StrSlice("sheet-id"))
names := nonEmptySliceItems(runtime.StrSlice("sheet-name"))
if len(ids) > 0 && len(names) > 0 {
return common.ValidationErrorf("--sheet-id and --sheet-name are mutually exclusive; pick one selector to identify sub-sheets").
WithParams(
sheetsInvalidParam("sheet-id", "mutually exclusive"),
sheetsInvalidParam("sheet-name", "mutually exclusive"),
)
}
for _, id := range ids {
if err := requireSheetSelector(id, ""); err != nil {
return err
}
}
for _, name := range names {
if err := requireSheetSelector("", name); err != nil {
return err
}
}
return nil
}
// validateFormulaVerifyLimits rejects non-positive caps so a misplaced 0 or
// negative flag value can't silently degrade the scan (the server-side
// default would otherwise mask the typo).
func validateFormulaVerifyLimits(runtime *common.RuntimeContext) error {
if runtime.Changed("max-locations") && runtime.Int("max-locations") <= 0 {
return sheetsValidationForFlag("max-locations", "--max-locations must be > 0")
}
return nil
}
// nonEmptySliceItems trims and drops blanks from a repeated-flag value so
// `--sheet-id ""` doesn't masquerade as a real entry.
func nonEmptySliceItems(in []string) []string {
out := make([]string, 0, len(in))
for _, v := range in {
if trimmed := strings.TrimSpace(v); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
// formulaVerifyInput builds the verify_formula tool input map from CLI flags.
// excel_id is required; everything else is optional per the schema.
func formulaVerifyInput(runtime *common.RuntimeContext, token string) map[string]interface{} {
input := map[string]interface{}{
"excel_id": token,
}
if ids := nonEmptySliceItems(runtime.StrSlice("sheet-id")); len(ids) > 0 {
input["sheet_ids"] = ids
} else if names := nonEmptySliceItems(runtime.StrSlice("sheet-name")); len(names) > 0 {
// The verify_formula schema only declares sheet_ids; the facade
// accepts sheet_names as a parallel optional field so name-based
// selection works without forcing the caller to pre-resolve. Mirrors
// how the other read shortcuts pack both fields via
// sheetSelectorForToolInput.
input["sheet_names"] = names
}
if ranges := nonEmptySliceItems(runtime.StrSlice("range")); len(ranges) > 0 {
input["ranges"] = ranges
}
if runtime.Changed("max-locations") {
input["max_locations_per_error"] = runtime.Int("max-locations")
}
return input
}
// formulaVerifyExitOnError converts a verify_formula status into a non-zero
// CLI exit when the caller passed --exit-on-error. status="errors_found"
// is the only failure mode for this flag: "partial" means truncated but the
// scanned slice is clean, and "success" is obviously clean. A missing /
// unknown status is treated as a typed internal error because the tool's
// schema guarantees the field and we don't want a silent zero-exit.
func formulaVerifyExitOnError(out interface{}) error {
m, ok := out.(map[string]interface{})
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"verify_formula: missing status field in tool output")
}
status, _ := m["status"].(string)
switch status {
case "success", "partial":
return nil
case "errors_found":
total, _ := util.ToFloat64(m["total_errors"])
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"verify_formula: %d formula error(s) detected; resolve and re-run", int(total)).
WithHint("inspect error_summary[*] / compile_errors[*] in the JSON output, fix or wrap with IFERROR, then re-run +formula-verify until status=success")
default:
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"verify_formula: unexpected status %q", status)
}
}

View File

@@ -0,0 +1,213 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
// TestFormulaVerify_DryRun pins the wire shape verify_formula sends for the
// common input combinations: no selector (workbook-wide scan), explicit
// sheet_ids, explicit ranges, and the optional max_locations_per_error
// field. The test exercises the One-OpenAPI body
// directly so the schema field names stay locked to the canonical
// tool-schemas.json verify_formula node.
func TestFormulaVerify_DryRun(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args []string
wantInput map[string]interface{}
}{
{
name: "no selector — workbook-wide scan defaults",
args: []string{"--url", testURL},
wantInput: map[string]interface{}{
"excel_id": testToken,
},
},
{
name: "sheet_ids multi via repeat",
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--sheet-id", testSheetID2},
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_ids": []interface{}{testSheetID, testSheetID2},
},
},
{
name: "sheet_names multi via comma",
args: []string{"--url", testURL, "--sheet-name", "Sheet1,Sheet2"},
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_names": []interface{}{"Sheet1", "Sheet2"},
},
},
{
name: "ranges + max_locations",
args: []string{
"--url", testURL,
"--range", "A1:Z200",
"--range", "AA1:AZ100",
"--max-locations", "5",
},
wantInput: map[string]interface{}{
"excel_id": testToken,
"ranges": []interface{}{"A1:Z200", "AA1:AZ100"},
"max_locations_per_error": float64(5),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, FormulaVerify, tt.args)
got := decodeToolInput(t, body, "verify_formula")
assertInputEquals(t, got, tt.wantInput)
})
}
}
// TestFormulaVerify_DryRunInvokeReadPath confirms the request hits
// invoke_read (read scope) and not invoke_write — a scope mismatch here would
// surface as a 403 from the gateway.
func TestFormulaVerify_DryRunInvokeReadPath(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, FormulaVerify, []string{"--url", testURL})
if len(calls) == 0 {
t.Fatalf("dry-run produced no api calls")
}
call, _ := calls[0].(map[string]interface{})
url, _ := call["url"].(string)
if !strings.HasSuffix(url, "/tools/invoke_read") {
t.Errorf("verify_formula must hit invoke_read; got url=%q", url)
}
if want := "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_read"; url != want {
t.Errorf("url = %q, want %q", url, want)
}
}
// TestFormulaVerify_RejectsBothSelectors locks the "at most one selector"
// rule on the two multi-value flags. Both empty is the documented
// workbook-wide scan path, so we only reject the both-supplied case.
func TestFormulaVerify_RejectsBothSelectors(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, FormulaVerify, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--sheet-name", "Sheet1",
"--dry-run",
})
ve := requireValidation(t, err, "mutually exclusive")
gotParams := map[string]bool{}
for _, p := range ve.Params {
gotParams[p.Name] = true
}
if !gotParams["--sheet-id"] || !gotParams["--sheet-name"] {
t.Errorf("params = %#v, want both --sheet-id and --sheet-name flagged", ve.Params)
}
}
// TestFormulaVerify_RejectsNonPositiveLimits guards against typos like
// `--max-locations 0`, which would otherwise be silently swallowed by the
// "explicit value but unset" comparison in the input builder.
func TestFormulaVerify_RejectsNonPositiveLimits(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
want string
}{
{
name: "max-locations=0",
args: []string{"--url", testURL, "--max-locations", "0"},
want: "--max-locations must be > 0",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, FormulaVerify, append(c.args, "--dry-run"))
requireValidation(t, err, c.want)
})
}
}
// TestFormulaVerifyExitOnError_StatusMatrix locks the --exit-on-error
// contract: success/partial → no error; errors_found → typed validation
// error with SubtypeFailedPrecondition; missing or unknown status →
// typed internal error so a silent zero-exit can never happen.
func TestFormulaVerifyExitOnError_StatusMatrix(t *testing.T) {
t.Parallel()
t.Run("success returns no error", func(t *testing.T) {
t.Parallel()
if err := formulaVerifyExitOnError(map[string]interface{}{"status": "success"}); err != nil {
t.Fatalf("success path returned err: %v", err)
}
})
t.Run("partial returns no error", func(t *testing.T) {
t.Parallel()
if err := formulaVerifyExitOnError(map[string]interface{}{"status": "partial", "has_more": true}); err != nil {
t.Fatalf("partial path returned err: %v", err)
}
})
t.Run("errors_found yields failed_precondition with count", func(t *testing.T) {
t.Parallel()
err := formulaVerifyExitOnError(map[string]interface{}{
"status": "errors_found",
"total_errors": float64(7),
})
if err == nil {
t.Fatal("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if ve.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition)
}
if !strings.Contains(ve.Message, "7 formula error") {
t.Errorf("message %q must surface the error count", ve.Message)
}
if ve.Hint == "" {
t.Errorf("hint must be set so AI agents know to re-run after fixes")
}
})
t.Run("unknown status maps to internal/invalid_response", func(t *testing.T) {
t.Parallel()
err := formulaVerifyExitOnError(map[string]interface{}{"status": "weird"})
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T %v", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("category/subtype = %q/%q, want internal/invalid_response", p.Category, p.Subtype)
}
})
t.Run("non-object output maps to internal/invalid_response", func(t *testing.T) {
t.Parallel()
err := formulaVerifyExitOnError("oops")
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T %v", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("category/subtype = %q/%q, want internal/invalid_response", p.Category, p.Subtype)
}
})
}

View File

@@ -0,0 +1,97 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_history (BE-1: +history-list) ─────────────────────────
//
// Wraps the facade-agg `history_list` tool (read) behind the One-OpenAPI
// invoke_read endpoint. The tool returns a sheet's version history. The
// facade-agg tool already performs the response transform (minor_histories
// trim / id → history_version_id / 4-field projection / RFC3339 create_time),
// so the CLI passes the tool output straight through and does NOT re-implement
// the transform client-side.
//
// History is workbook-level (no sheet selector), mirroring +workbook-info:
// the only locator is --url / --spreadsheet-token (XOR), with --token accepted
// as a parse-time alias for --spreadsheet-token via the shared PostMount hook.
//
// Flags are declared inline here rather than via flagsFor(): the generated
// flag_defs_gen.go / data/flag-defs.json are synced from sheet-skill-spec
// (BE-3) and must not be hand-edited, so this hand-written shortcut owns its
// own flag set. The two locator flags match +workbook-info's shape exactly.
// historyLocatorFlags is the --url / --spreadsheet-token XOR locator pair
// shared by the three history shortcuts. Mirrors +workbook-info's flag-defs
// entry; XOR is enforced in Validate via parseSpreadsheetRef, not by Required.
func historyLocatorFlags() []common.Flag {
return []common.Flag{
{Name: "url", Type: "string", Desc: "Spreadsheet locator (a /sheets/ or /wiki/ URL)."},
{Name: "spreadsheet-token", Type: "string", Desc: "Spreadsheet locator (raw spreadsheet token)."},
}
}
// HistoryList wraps the history_list tool: list a spreadsheet's history
// versions. Each item carries history_version_id / create_time / action /
// all_block_revision (projected server-side). An empty sheet yields an empty
// list and exit 0.
//
// Backward pagination: --end-version (optional int) maps to the tool's
// `end_version` parameter. Omit on the first call to fetch the latest page.
// On subsequent pages pass the previous response's next_end_version as
// --end-version. The tool returns next_end_version + has_more only when
// more history exists; both fields are absent at the earliest page.
var HistoryList = common.Shortcut{
Service: "sheets",
Command: "+history-list",
Description: "List a spreadsheet's edit history versions (history_version_id, create_time, action, all_block_revision).",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: append(historyLocatorFlags(),
common.Flag{Name: "end-version", Type: "int", Desc: "Max version to query (descending pagination). Omit on the first call; pass the previous response's next_end_version on subsequent pages."},
),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := resolveSpreadsheetToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
return invokeToolDryRun(token, ToolKindRead, "history_list", historyListInput(runtime, token))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "history_list", historyListInput(runtime, token))
if err != nil {
return err
}
// Pass the tool output through verbatim — facade-agg already shaped it.
runtime.Out(out, nil)
return nil
},
Tips: []string{
"Capture a history_version_id from the result to feed +history-revert.",
"For older history, capture next_end_version from the response and pass it as --end-version on the next call (omitted by the server when the earliest page is reached).",
},
}
// historyListInput composes the history_list tool input. --end-version is
// optional: include it only when explicitly set so the server treats absence
// as "first page (latest)".
func historyListInput(runtime *common.RuntimeContext, token string) map[string]interface{} {
in := map[string]interface{}{"excel_id": token}
if runtime.Changed("end-version") {
in["end_version"] = runtime.Int("end-version")
}
return in
}

View File

@@ -0,0 +1,197 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_history (BE-2: +history-revert / +history-revert-status) ──
//
// Two thin callTool wrappers over the facade-agg history tools:
// - +history-revert → history_revert (write) — async revert
// - +history-revert-status → history_revert_status (read) — poll outcome
//
// Both target a single history version via --history-version-id (the id
// surfaced by +history-list). Revert is asynchronous: it returns a receipt /
// transaction id that +history-revert-status then polls, distinguishing
// in-progress / success / failure from the tool output (passed through
// verbatim — no client-side shaping).
//
// ⚠️ Backend state: the facade-agg history_revert / history_revert_status
// tools are registered but their downstream RPC wiring is a DEFERRED
// follow-up; today they return a "not wired yet" guard error from the gateway,
// which surfaces here as a normal tool error. These CLI shortcuts are correct
// thin wrappers and will work end-to-end once the backend follow-up lands —
// this is NOT a CLI blocker. See self_check.md.
//
// Flags are declared inline (historyLocatorFlags + history-version-id) rather
// than via flagsFor(), because flag_defs_gen.go / data/flag-defs.json are
// synced from sheet-skill-spec (BE-3) and must not be hand-edited.
// historyVersionIDFlag is the target-version selector shared by +history-revert.
// Required at the cli surface (cobra MarkFlagRequired): a missing value yields
// cobra's standard "required flag(s) \"history-version-id\" not set" message
// before Validate runs. We still trim + reject control-chars in Validate to
// reject empty strings ("--history-version-id "" "), which cobra accepts.
func historyVersionIDFlag() common.Flag {
return common.Flag{
Name: "history-version-id",
Type: "string",
Required: true,
Desc: "History version to act on (from +history-list).",
}
}
func historyRevertFlags() []common.Flag {
return append(historyLocatorFlags(), historyVersionIDFlag())
}
// validateHistoryVersionID enforces the required, control-char-clean
// --history-version-id. Returns the trimmed value so callers reuse it.
func validateHistoryVersionID(runtime *common.RuntimeContext) (string, error) {
id := strings.TrimSpace(runtime.Str("history-version-id"))
if id == "" {
return "", sheetsValidationForFlag("history-version-id", "--history-version-id is required")
}
return id, nil
}
func historyRevertInput(token, versionID string) map[string]interface{} {
return map[string]interface{}{
"excel_id": token,
"history_version_id": versionID,
}
}
// transactionIDFlag is the async-revert receipt selector used by
// +history-revert-status: the transaction_id returned by +history-revert (NOT a
// history version id — the facade-agg status tool keys on transaction_id).
// Required at the cli surface (cobra MarkFlagRequired) — same gating model as
// historyVersionIDFlag. Validate still trims + rejects empty/control-char
// values to catch the case where cobra accepts --transaction-id with an
// empty-string value.
func transactionIDFlag() common.Flag {
return common.Flag{
Name: "transaction-id",
Type: "string",
Required: true,
Desc: "Async revert transaction id (from +history-revert).",
}
}
func historyRevertStatusFlags() []common.Flag {
return append(historyLocatorFlags(), transactionIDFlag())
}
// validateTransactionID enforces the required, trimmed --transaction-id and
// returns it for reuse.
func validateTransactionID(runtime *common.RuntimeContext) (string, error) {
id := strings.TrimSpace(runtime.Str("transaction-id"))
if id == "" {
return "", sheetsValidationForFlag("transaction-id", "--transaction-id is required")
}
return id, nil
}
func historyRevertStatusInput(token, transactionID string) map[string]interface{} {
return map[string]interface{}{
"excel_id": token,
"transaction_id": transactionID,
}
}
// HistoryRevert wraps the history_revert tool (write): asynchronously revert a
// spreadsheet to the given history version. --history-version-id is required
// at the cli surface (cobra MarkFlagRequired); a missing flag fails before
// Validate runs with cobra's standard "required flag(s)" error (which the
// dispatcher classifies as a typed *errs.ValidationError, exit 2). We still
// trim + reject empty / control-char values in Validate to catch the
// case where cobra accepts --history-version-id with an empty-string value.
var HistoryRevert = common.Shortcut{
Service: "sheets",
Command: "+history-revert",
Description: "Revert a spreadsheet to a given history version (asynchronous; poll with +history-revert-status).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: historyRevertFlags(),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
_, err := validateHistoryVersionID(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
versionID := strings.TrimSpace(runtime.Str("history-version-id"))
return invokeToolDryRun(token, ToolKindWrite, "history_revert", historyRevertInput(token, versionID))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
versionID, err := validateHistoryVersionID(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "history_revert", historyRevertInput(token, versionID))
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
Tips: []string{
"Revert is asynchronous — pass the returned id to +history-revert-status to track in-progress / success / failure.",
},
}
// HistoryRevertStatus wraps the history_revert_status tool (read): poll the
// outcome of a prior +history-revert. The tool output distinguishes
// in-progress / success / failure and is passed through verbatim.
var HistoryRevertStatus = common.Shortcut{
Service: "sheets",
Command: "+history-revert-status",
Description: "Poll the status of a history revert (in-progress / success / failure).",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: historyRevertStatusFlags(),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
_, err := validateTransactionID(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
txnID := strings.TrimSpace(runtime.Str("transaction-id"))
return invokeToolDryRun(token, ToolKindRead, "history_revert_status", historyRevertStatusInput(token, txnID))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
txnID, err := validateTransactionID(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "history_revert_status", historyRevertStatusInput(token, txnID))
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}

View File

@@ -0,0 +1,167 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
// TestHistoryShortcuts_DryRun asserts each history shortcut targets the right
// facade-agg tool, routes through the correct read/write invoke endpoint, and
// builds the expected tool input (excel_id always; history_version_id for the
// revert pair).
func TestHistoryShortcuts_DryRun(t *testing.T) {
t.Parallel()
const versionID = "histVER123"
const txnID = "txn-abc-123"
tests := []struct {
name string
sc common.Shortcut
args []string
toolName string
wantPath string // invoke_read | invoke_write suffix
wantInput map[string]interface{}
}{
{
name: "+history-list via --url",
sc: HistoryList,
args: []string{"--url", testURL},
toolName: "history_list",
wantPath: "invoke_read",
wantInput: map[string]interface{}{
"excel_id": testToken,
},
},
{
name: "+history-list via --spreadsheet-token",
sc: HistoryList,
args: []string{"--spreadsheet-token", testToken},
toolName: "history_list",
wantPath: "invoke_read",
wantInput: map[string]interface{}{
"excel_id": testToken,
},
},
{
name: "+history-list paginates with --end-version",
sc: HistoryList,
args: []string{"--url", testURL, "--end-version", "12345"},
toolName: "history_list",
wantPath: "invoke_read",
wantInput: map[string]interface{}{
"excel_id": testToken,
"end_version": float64(12345), // post-JSON-unmarshal numeric type
},
},
{
name: "+history-revert routes to invoke_write with version id",
sc: HistoryRevert,
args: []string{"--url", testURL, "--history-version-id", versionID},
toolName: "history_revert",
wantPath: "invoke_write",
wantInput: map[string]interface{}{
"excel_id": testToken,
"history_version_id": versionID,
},
},
{
name: "+history-revert-status routes to invoke_read with transaction id",
sc: HistoryRevertStatus,
args: []string{"--url", testURL, "--transaction-id", txnID},
toolName: "history_revert_status",
wantPath: "invoke_read",
wantInput: map[string]interface{}{
"excel_id": testToken,
"transaction_id": txnID,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
callURL := dryRunFirstCallURL(t, tt.sc, tt.args)
if !containsSuffix(callURL, tt.wantPath) {
t.Errorf("invoke url = %q, want suffix %q", callURL, tt.wantPath)
}
body := parseDryRunBody(t, tt.sc, tt.args)
got := decodeToolInput(t, body, tt.toolName)
assertInputEquals(t, got, tt.wantInput)
})
}
}
// TestHistoryRevert_MissingRequiredFlag asserts each shortcut rejects a
// missing required selector before any request is sent, with two distinct
// gates by design:
//
// - +history-revert: --history-version-id is cobra-required (Required=true
// in the flag def → MarkFlagRequired). cobra refuses the call before
// Validate runs with a plain "required flag(s)" error; the cmd dispatcher
// classifies it as a typed *errs.ValidationError (invalid_argument, exit 2).
// The test rig invokes the shortcut via cmd.Execute and observes the raw
// cobra error directly (no dispatcher wrap), so we assert the cobra text
// contract instead of the typed envelope.
//
// - +history-revert-status: --transaction-id is cobra-optional;
// requiredness is enforced inside Validate so we still get a typed,
// flag-tagged *errs.ValidationError with Param="--transaction-id".
func TestHistoryRevert_MissingRequiredFlag(t *testing.T) {
t.Parallel()
t.Run(HistoryRevert.Command, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, HistoryRevert, []string{"--url", testURL})
if err == nil {
t.Fatalf("%s: expected error for missing --history-version-id", HistoryRevert.Command)
}
msg := err.Error()
if !strings.Contains(msg, "required flag(s)") || !strings.Contains(msg, "history-version-id") {
t.Fatalf("%s: cobra error = %q, want substrings 'required flag(s)' and 'history-version-id'", HistoryRevert.Command, msg)
}
})
t.Run(HistoryRevertStatus.Command, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, HistoryRevertStatus, []string{"--url", testURL})
if err == nil {
t.Fatalf("%s: expected error for missing --transaction-id", HistoryRevertStatus.Command)
}
msg := err.Error()
if !strings.Contains(msg, "required flag(s)") || !strings.Contains(msg, "transaction-id") {
t.Fatalf("%s: cobra error = %q, want substrings 'required flag(s)' and 'transaction-id'", HistoryRevertStatus.Command, msg)
}
})
}
// dryRunFirstCallURL runs the shortcut in --dry-run and returns the first
// api call's url, so tests can assert read vs. write endpoint routing.
func dryRunFirstCallURL(t *testing.T, sc common.Shortcut, args []string) string {
t.Helper()
out, err := runShortcut(t, sc, append(args, "--dry-run"))
if err != nil {
t.Fatalf("dry-run failed: %v\noutput=%s", err, out)
}
dryRun := decodeDryRunRaw(t, out)
calls, ok := dryRun["api"].([]interface{})
if !ok || len(calls) == 0 {
t.Fatalf("dry-run api array empty or wrong shape: %#v", dryRun)
}
call, _ := calls[0].(map[string]interface{})
url, _ := call["url"].(string)
return url
}
func containsSuffix(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_revision_get ───────────────────────────────────────────
//
// RevisionGet is a read-only derivative over get_workbook_structure that
// projects out only the document revision (version number). The backend
// surfaces `revision` on every read/write tool response, so this shortcut
// needs no dedicated backend tool — it issues the lightest existing read
// (no range, just the workbook token) and narrows the payload to the single
// field callers want.
//
// The revision is the anchor for recover / undo. Callers that have just run a
// write already have it in that write's response; +revision-get is the
// explicit, zero-side-effect way to fetch the current value on its own.
var RevisionGet = common.Shortcut{
Service: "sheets",
Command: "+revision-get",
Description: "Get the spreadsheet's current document revision (version number).",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+revision-get"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := resolveSpreadsheetToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
return invokeToolDryRun(token, ToolKindRead, "get_workbook_structure", map[string]interface{}{
"excel_id": token,
})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_workbook_structure", map[string]interface{}{
"excel_id": token,
})
if err != nil {
return err
}
rev, err := projectRevision(out)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"revision": rev}, nil)
return nil
},
Tips: []string{
"The revision is the version anchor for recover / undo; every read and write tool response already carries it.",
},
}
// projectRevision narrows a get_workbook_structure response to its `revision`
// field. An absent revision means the backend predates revision injection on
// read responses; surface that as an explicit error rather than emitting a
// silent null.
func projectRevision(out interface{}) (interface{}, error) {
obj, ok := out.(map[string]interface{})
if !ok {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"get_workbook_structure returned non-object output")
}
rev, ok := obj["revision"]
if !ok {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"get_workbook_structure did not return a revision (backend may not support it yet)")
}
return rev, nil
}

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import "testing"
func TestRevisionGetProjectRevision(t *testing.T) {
t.Parallel()
t.Run("extracts revision from a workbook-structure object", func(t *testing.T) {
out := map[string]interface{}{
"revision": float64(60),
"sheets": []interface{}{map[string]interface{}{"sheet_id": "Nh34WX"}},
}
got, err := projectRevision(out)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != float64(60) {
t.Errorf("revision = %v, want 60", got)
}
})
t.Run("errors when revision is absent", func(t *testing.T) {
out := map[string]interface{}{"sheets": []interface{}{}}
if _, err := projectRevision(out); err == nil {
t.Error("expected an error when revision is missing, got nil")
}
})
t.Run("errors on a non-object output", func(t *testing.T) {
if _, err := projectRevision("not-an-object"); err == nil {
t.Error("expected an error for non-object output, got nil")
}
})
}

View File

@@ -483,11 +483,11 @@ func dimGroupInput(runtime flagView, token, sheetID, sheetName, op string) (map[
func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error) {
s = strings.TrimSpace(s)
if s == "" {
return "", 0, 0, fmt.Errorf("range is empty")
return "", 0, 0, fmt.Errorf("range is empty") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
parts := strings.Split(s, ":")
if len(parts) > 2 {
return "", 0, 0, fmt.Errorf("expected \"start:end\" or single element")
return "", 0, 0, fmt.Errorf("expected \"start:end\" or single element") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
dim1, idx1, err := parseA1Position(parts[0])
if err != nil {
@@ -501,10 +501,10 @@ func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error)
return "", 0, 0, err
}
if dim1 != dim2 {
return "", 0, 0, fmt.Errorf("cannot mix row (digits) and column (letters) in one range")
return "", 0, 0, fmt.Errorf("cannot mix row (digits) and column (letters) in one range") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
if idx2 < idx1 {
return "", 0, 0, fmt.Errorf("end position is before start")
return "", 0, 0, fmt.Errorf("end position is before start") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
return dim1, idx1, idx2, nil
}
@@ -515,7 +515,7 @@ func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error)
func parseA1Position(s string) (dimension string, idx int, err error) {
s = strings.TrimSpace(s)
if s == "" {
return "", 0, fmt.Errorf("position is empty")
return "", 0, fmt.Errorf("position is empty") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
isDigits := true
isLetters := true
@@ -530,14 +530,14 @@ func parseA1Position(s string) (dimension string, idx int, err error) {
if isDigits {
n, _ := strconv.Atoi(s)
if n <= 0 {
return "", 0, fmt.Errorf("row number must be >= 1 (got %q)", s)
return "", 0, fmt.Errorf("row number must be >= 1 (got %q)", s) //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
return "row", n - 1, nil
}
if isLetters {
return "column", letterToColumnIndex(s), nil
}
return "", 0, fmt.Errorf("expected pure digits (row number) or letters (column letter), got %q", s)
return "", 0, fmt.Errorf("expected pure digits (row number) or letters (column letter), got %q", s) //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
// columnIndexToLetter converts a 0-based column index to the spreadsheet

View File

@@ -382,6 +382,32 @@ func (p *tablePayload) validate() error {
return common.ValidationErrorf("--sheets[%d] %q: mode %q is invalid (want \"overwrite\" or \"append\")", i, s.Name, s.Mode)
}
}
return p.checkCellBudget()
}
// maxTablePutCells bounds how many cells a single +table-put / +workbook-create
// write may materialize. Unlike the fan-out stamp cap (maxStampMatrixCells),
// these cells come from the caller's own --sheets/--values payload rather than a
// range blow-up, so this is a generous OOM guardrail, not a usability limit:
// buildSheetMatrix builds the whole rows×cols matrix of per-cell maps in memory
// before slicing it into tablePutMaxCellsPerWrite-sized writes, so an unbounded
// payload (2.6M cells ≈ 900MB heap, doubled again by json.Marshal) OOMs the
// process before the first write leaves.
const maxTablePutCells = 1_000_000
// checkCellBudget rejects a payload whose total materialized cell count across
// all sheets exceeds maxTablePutCells. Counted in int64 to stay overflow-safe on
// pathological row/column counts.
func (p *tablePayload) checkCellBudget() error {
var total int64
for i := range p.Sheets {
total += int64(len(p.Sheets[i].Rows)) * int64(len(p.Sheets[i].Columns))
}
if total > maxTablePutCells {
return common.ValidationErrorf(
"--sheets/--values cover %d cells total, over the %d-cell safety cap; split the write across smaller payloads",
total, maxTablePutCells)
}
return nil
}

View File

@@ -123,6 +123,26 @@ func sheetCreateInput(runtime flagView, token string) (map[string]interface{}, e
if strings.TrimSpace(runtime.Str("title")) == "" {
return nil, common.ValidationErrorf("--title is required")
}
// --type bitable 建一张空白多维表格子表operation=create_bitable默认 sheet 为普通
// 电子表格子表。bitable 子表内容编辑走 lark-base 命令row-count/col-count 不适用。
sheetType := strings.TrimSpace(runtime.Str("type"))
if sheetType == "" {
sheetType = "sheet"
}
if sheetType != "sheet" && sheetType != "bitable" {
return nil, common.ValidationErrorf("--type must be 'sheet' or 'bitable'")
}
if sheetType == "bitable" {
input := map[string]interface{}{
"excel_id": token,
"operation": "create_bitable",
"sheet_name": strings.TrimSpace(runtime.Str("title")),
}
if runtime.Changed("index") {
input["target_index"] = runtime.Int("index")
}
return input, nil
}
if n := runtime.Int("row-count"); n < 0 || n > 50000 {
return nil, common.ValidationErrorf("--row-count must be between 0 and 50000")
}
@@ -836,13 +856,19 @@ func buildValuesPayload(runtime flagView, sheetStyles *workbookCreateSheetStyles
cols[i] = tableColumnSpec{Name: fmt.Sprintf("col%d", i+1)} // type-less
}
noHeader := false
return &tablePayload{Sheets: []tableSheetSpec{{
payload := &tablePayload{Sheets: []tableSheetSpec{{
Name: valuesSheetName,
Mode: "overwrite",
Header: &noHeader,
Columns: cols,
Rows: rows,
}}}, nil
}}}
// --values bypasses tablePayload.validate(), so enforce the cell budget here
// too — otherwise a giant --values array materializes unbounded.
if err := payload.checkCellBudget(); err != nil {
return nil, err
}
return payload, nil
}
// parseValuesRows decodes --values (JSON 2D array, with @file/stdin already
@@ -1246,7 +1272,7 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
func workbookCreateCellStyleField(name string) bool {
switch name {
case "font_color", "font_size", "font_weight", "font_style", "font_line",
case "font_color", "font_family", "font_size", "font_weight", "font_style", "font_line",
"background_color", "horizontal_alignment", "vertical_alignment",
"number_format", "word_wrap":
return true

View File

@@ -111,10 +111,10 @@ func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[stri
// CellsSetStyle stamps a single style block across every cell in --range.
// Style is composed from a dozen flat flags (background-color, font-color,
// font-size, font-style, font-weight, font-line, horizontal-alignment,
// vertical-alignment, word-wrap, number-format) plus --border-styles for
// the only field that still needs a nested object. At least one flag must
// be set.
// font-family, font-size, font-style, font-weight, font-line,
// horizontal-alignment, vertical-alignment, word-wrap, number-format) plus
// --border-styles for the only field that still needs a nested object. At
// least one flag must be set.
var CellsSetStyle = common.Shortcut{
Service: "sheets",
Command: "+cells-set-style",
@@ -165,6 +165,9 @@ func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map
if err != nil {
return nil, sheetsValidationForFlag("range", "--range %q: %v", rangeStr, err)
}
if err := checkStampMatrixBudget("range", rangeStr, rows, cols); err != nil {
return nil, err
}
if err := requireAnyStyleFlag(runtime); err != nil {
return nil, err
}
@@ -450,6 +453,9 @@ func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[s
if err != nil {
return nil, sheetsValidationForFlag("range", "--range %q: %v", rangeStr, err)
}
if err := checkStampMatrixBudget("range", rangeStr, rows, cols); err != nil {
return nil, err
}
validation, err := buildDropdownValidation(runtime)
if err != nil {
return nil, err
@@ -625,23 +631,23 @@ func rangeDimensions(rangeStr string) (rows, cols int, err error) {
}
rangeStr = strings.TrimSpace(rangeStr)
if rangeStr == "" {
return 0, 0, fmt.Errorf("empty range")
return 0, 0, fmt.Errorf("empty range") //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
}
parts := strings.SplitN(rangeStr, ":", 2)
if len(parts) == 1 {
// single cell, e.g. "A1"
if _, _, ok := splitCellRef(parts[0]); !ok {
return 0, 0, fmt.Errorf("invalid cell ref %q", parts[0])
return 0, 0, fmt.Errorf("invalid cell ref %q", parts[0]) //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
}
return 1, 1, nil
}
startCol, startRow, ok1 := splitCellRef(parts[0])
endCol, endRow, ok2 := splitCellRef(parts[1])
if !ok1 || !ok2 {
return 0, 0, fmt.Errorf("unsupported range form %q (need rectangular A1:B2)", rangeStr)
return 0, 0, fmt.Errorf("unsupported range form %q (need rectangular A1:B2)", rangeStr) //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
}
if endRow < startRow || endCol < startCol {
return 0, 0, fmt.Errorf("end %q must be at or after start %q", parts[1], parts[0])
return 0, 0, fmt.Errorf("end %q must be at or after start %q", parts[1], parts[0]) //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
}
return endRow - startRow + 1, endCol - startCol + 1, nil
}
@@ -692,9 +698,30 @@ func letterToColumnIndex(letters string) int {
return n - 1
}
// maxStampMatrixCells bounds how many per-cell maps a fan-out / stamp shortcut
// will materialize from a single A1 range. The backing tools take an explicit
// cells matrix, so the CLI must expand a range like "A1:Z100000" into rows×cols
// maps before sending it — an unbounded blow-up (2.6M cells ≈ 900MB heap, then
// doubled again by json.Marshal) that OOMs the process before the request even
// leaves. 200000 matches the documented --max-cells safety cap.
const maxStampMatrixCells = 200000
// checkStampMatrixBudget rejects a range whose materialized cell count would
// exceed maxStampMatrixCells, before fillCellsMatrix allocates it. rows*cols is
// computed in int64 to stay safe against overflow on pathological ranges.
func checkStampMatrixBudget(flagName, rangeStr string, rows, cols int) error {
if total := int64(rows) * int64(cols); total > maxStampMatrixCells {
return sheetsValidationForFlag(flagName,
"range %q covers %d cells, over the %d-cell safety cap; narrow the range or split it across smaller ranges",
rangeStr, total, maxStampMatrixCells)
}
return nil
}
// fillCellsMatrix returns a rows×cols matrix where every cell is the same
// (shallow-copied) prototype map. Use for fan-out shortcuts that stamp a
// single attribute (style / data_validation) across an entire range.
// Callers MUST gate the dimensions through checkStampMatrixBudget first.
func fillCellsMatrix(rows, cols int, prototype map[string]interface{}) [][]interface{} {
cells := make([][]interface{}, rows)
for r := range cells {

View File

@@ -25,8 +25,8 @@ import (
// TestSheetMediaParentType pins the token→parent_type mapping that every
// sheets image-upload entry point funnels through. Native spreadsheet tokens
// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_"
// synthetic token and must upload with "office_sheet_file".
// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_" or
// "local_office_" synthetic token and must upload with "office_sheet_file".
func TestSheetMediaParentType(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -36,9 +36,12 @@ func TestSheetMediaParentType(t *testing.T) {
}{
{"native spreadsheet token", "shtcnABC123", sheetImageParentType},
{"empty token", "", sheetImageParentType},
{"office imported token", "fake_office_abc123", officeSheetFileParentType},
{"office token, only the prefix", fakeOfficeTokenPrefix, officeSheetFileParentType},
{"prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType},
{"fake_office imported token", "fake_office_abc123", officeSheetFileParentType},
{"fake_office token, only the prefix", fakeOfficeTokenPrefix, officeSheetFileParentType},
{"local_office imported token", "local_office_abc123", officeSheetFileParentType},
{"local_office token, only the prefix", localOfficeTokenPrefix, officeSheetFileParentType},
{"fake_office prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType},
{"local_office prefix mid-string is not matched", "shtlocal_office_abc", sheetImageParentType},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -62,7 +65,8 @@ func TestUploadSheetImage_ParentType(t *testing.T) {
wantParentType string
}{
{"native spreadsheet", "shtcnTOK123", sheetImageParentType},
{"office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
{"fake_office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
{"local_office imported spreadsheet", "local_office_abc123", officeSheetFileParentType},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {

View File

@@ -0,0 +1,272 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"bytes"
"encoding/json"
"io"
"runtime"
"strings"
"testing"
)
// These benchmarks back the memory review of the sheets fan-out / download
// paths. They measure two hot spots:
//
// 1. fillCellsMatrix — fan-out shortcuts (+cells-set-style, +dropdown-set,
// +cells-batch-set-style, +dropdown-update) expand one A1 range into a
// rows×cols matrix of per-cell maps. A tiny input string ("A1:Z100000")
// explodes into millions of heap maps with no upper bound.
//
// 2. the export-download reader — strings.NewReader(string(rawBody)) copies
// the whole downloaded file once more before saving it.
//
// Run: go test ./shortcuts/sheets -run XXX -bench 'FillCellsMatrix|DownloadReader' -benchmem
var styleProto = map[string]interface{}{
"cell_styles": map[string]interface{}{"bold": true, "fg_color": "#FF0000"},
"border_styles": map[string]interface{}{"top": map[string]interface{}{"style": "solid"}},
}
func benchFillCellsMatrix(b *testing.B, rows, cols int) {
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
m := fillCellsMatrix(rows, cols, styleProto)
if len(m) != rows {
b.Fatalf("bad matrix")
}
}
}
func BenchmarkFillCellsMatrix_100(b *testing.B) { benchFillCellsMatrix(b, 10, 10) } // A1:J10
func BenchmarkFillCellsMatrix_10K(b *testing.B) { benchFillCellsMatrix(b, 1000, 10) } // A1:J1000
func BenchmarkFillCellsMatrix_100K(b *testing.B) { benchFillCellsMatrix(b, 10000, 10) } // A1:J10000
func BenchmarkFillCellsMatrix_2600K(b *testing.B) { benchFillCellsMatrix(b, 100000, 26) } // A1:Z100000
// TestFanoutMatrixPeakMemory reports the concrete resident-heap delta of
// materializing a large fan-out matrix, so the review doc can quote real MB.
// Not an assertion — it prints numbers under `go test -v -run PeakMemory`.
func TestFanoutMatrixPeakMemory(t *testing.T) {
if testing.Short() {
t.Skip("skipping memory probe in -short")
}
cases := []struct {
name string
rows, cols int
}{
{"A1:Z10000 (260K cells)", 10000, 26},
{"A1:Z100000 (2.6M cells)", 100000, 26},
}
for _, c := range cases {
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
m := fillCellsMatrix(c.rows, c.cols, styleProto)
runtime.ReadMemStats(&after)
runtime.KeepAlive(m)
t.Logf("%-26s heap +%6.1f MB (%d total allocs)",
c.name,
float64(after.HeapAlloc-before.HeapAlloc)/(1024*1024),
after.Mallocs-before.Mallocs)
}
}
// --- +table-put / +workbook-create matrix materialization (sibling #1 path) ---
//
// buildSheetMatrix turns the caller's --sheets/--values into a rows×cols matrix
// of per-cell maps, the same unbounded blow-up as fillCellsMatrix but on the
// table-put ingress (tablePutMaxCellsPerWrite only slices the *write*, not this
// in-memory build). checkCellBudget rejects oversized payloads before this runs.
func makeTypelessSpec(rows, cols int) *tableSheetSpec {
c := make([]tableColumnSpec, cols)
r := make([][]interface{}, rows)
for i := range r {
row := make([]interface{}, cols)
for j := range row {
row[j] = "x"
}
r[i] = row
}
return &tableSheetSpec{Columns: c, Rows: r}
}
func benchBuildSheetMatrix(b *testing.B, rows, cols int) {
spec := makeTypelessSpec(rows, cols)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
m, err := buildSheetMatrix(spec, true)
if err != nil || len(m) != rows+1 {
b.Fatalf("bad matrix")
}
}
}
func BenchmarkBuildSheetMatrix_100K(b *testing.B) { benchBuildSheetMatrix(b, 10000, 10) } // 100K cells
func BenchmarkBuildSheetMatrix_2600K(b *testing.B) { benchBuildSheetMatrix(b, 100000, 26) } // 2.6M cells
// TestTablePutMatrixPeakMemory reports the resident-heap delta of materializing
// a large table-put matrix (the cost checkCellBudget now prevents), so the
// review doc can quote real MB. Not an assertion — prints under -v -run PeakMemory.
func TestTablePutMatrixPeakMemory(t *testing.T) {
if testing.Short() {
t.Skip("skipping memory probe in -short")
}
for _, c := range []struct {
name string
rows, cols int
}{
{"100000×26 (2.6M cells)", 100000, 26},
} {
spec := makeTypelessSpec(c.rows, c.cols)
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
m, _ := buildSheetMatrix(spec, true)
runtime.ReadMemStats(&after)
runtime.KeepAlive(m)
t.Logf("%-24s buildSheetMatrix heap +%6.1f MB (%d total allocs)",
c.name,
float64(after.HeapAlloc-before.HeapAlloc)/(1024*1024),
after.Mallocs-before.Mallocs)
}
}
// --- export-download reader copy ---
func benchDownloadReader(b *testing.B, size int, useStringCopy bool) {
raw := bytes.Repeat([]byte("x"), size)
sink := make([]byte, 32*1024)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var r io.Reader
if useStringCopy {
r = strings.NewReader(string(raw)) // current code: extra full-size copy
} else {
r = bytes.NewReader(raw) // fix: no copy
}
for {
if _, err := r.Read(sink); err != nil {
break
}
}
}
}
// --- fan-out cell-budget cap (fix for the unbounded matrix blow-up) ---
func TestStampMatrixBudgetCap(t *testing.T) {
// 199992 cells (7692×26) sits just under the 200000 cap → allowed.
if err := checkStampMatrixBudget("range", "A1:Z7692", 7692, 26); err != nil {
t.Fatalf("199992 cells should pass, got: %v", err)
}
// Exactly at the cap → allowed.
if err := checkStampMatrixBudget("range", "A1:A200000", 200000, 1); err != nil {
t.Fatalf("200000 cells (== cap) should pass, got: %v", err)
}
// Just over the cap → rejected.
if err := checkStampMatrixBudget("range", "A1:A200001", 200001, 1); err == nil {
t.Fatal("200001 cells should be rejected")
}
// The pathological case from the review (2.6M cells) → rejected.
if err := checkStampMatrixBudget("ranges", "Sheet1!A1:Z100000", 100000, 26); err == nil {
t.Fatal("2.6M-cell fan-out should be rejected")
}
}
// --- sibling cap gaps: +table-put/+workbook-create payload, batch aggregate,
// batch-update operation count (follow-up to the single fan-out cap) ---
// TestTablePutCellBudgetCap covers the --sheets/--values materialization cap:
// buildSheetMatrix builds the whole matrix in memory, so the total cell count is
// bounded before that allocation, summed across all sheets.
func TestTablePutCellBudgetCap(t *testing.T) {
// 1000×1000 = 1,000,000 == cap → allowed.
atCap := &tablePayload{Sheets: []tableSheetSpec{{
Columns: make([]tableColumnSpec, 1000),
Rows: make([][]interface{}, 1000),
}}}
if err := atCap.checkCellBudget(); err != nil {
t.Fatalf("1,000,000 cells (== cap) should pass, got: %v", err)
}
// 1000×1001 = 1,001,000 > cap → rejected.
over := &tablePayload{Sheets: []tableSheetSpec{{
Columns: make([]tableColumnSpec, 1000),
Rows: make([][]interface{}, 1001),
}}}
if err := over.checkCellBudget(); err == nil {
t.Fatal("1,001,000 cells should be rejected")
}
// Budget is summed across sheets, not per-sheet: 600k + 600k = 1.2M > cap.
twoSheets := &tablePayload{Sheets: []tableSheetSpec{
{Columns: make([]tableColumnSpec, 1000), Rows: make([][]interface{}, 600)},
{Columns: make([]tableColumnSpec, 1000), Rows: make([][]interface{}, 600)},
}}
if err := twoSheets.checkCellBudget(); err == nil {
t.Fatal("1.2M cells across two sheets should be rejected")
}
}
// TestBatchStampAggregateCap covers the batch fan-out aggregate budget — the
// per-range cap can't stop many ranges from summing past the matrix ceiling.
func TestBatchStampAggregateCap(t *testing.T) {
if err := checkBatchStampBudget(maxStampMatrixCells); err != nil {
t.Fatalf("aggregate == cap should pass, got: %v", err)
}
if err := checkBatchStampBudget(maxStampMatrixCells + 1); err == nil {
t.Fatal("aggregate over cap should be rejected")
}
}
// TestBatchFanoutRangeCountCap drives a fan-out shortcut with > maxBatchRanges
// ranges and expects the shared validateDropdownRanges cap to reject it.
func TestBatchFanoutRangeCountCap(t *testing.T) {
ranges := make([]string, maxBatchRanges+1)
for i := range ranges {
ranges[i] = "sheet1!A1"
}
rangesJSON, _ := json.Marshal(ranges)
_, _, err := runShortcutCapturingErr(t, CellsBatchSetStyle, []string{
"--url", testURL,
"--ranges", string(rangesJSON),
"--font-weight", "bold",
"--dry-run",
})
requireValidation(t, err, "at most")
}
// TestBatchOperationsCountCap covers the +batch-update sub-operation count cap.
func TestBatchOperationsCountCap(t *testing.T) {
ops := make([]interface{}, maxBatchOperations+1)
for i := range ops {
ops[i] = map[string]interface{}{"shortcut": "+cells-set", "input": map[string]interface{}{}}
}
_, err := translateBatchOperations(ops, testURL)
if err == nil || !strings.Contains(err.Error(), "at most") {
t.Fatalf("expected operations count cap error, got: %v", err)
}
}
// BenchmarkStampBudget_RejectsOversized is the "after" side of the fix: the same
// A1:Z100000 input that BenchmarkFillCellsMatrix_2600K shows costing ~917MB /
// 5.3M allocs is now rejected up front, allocating only the error string.
func BenchmarkStampBudget_RejectsOversized(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if err := checkStampMatrixBudget("range", "A1:Z100000", 100000, 26); err == nil {
b.Fatal("expected rejection")
}
}
}
func BenchmarkDownloadReader_StringCopy_1MB(b *testing.B) { benchDownloadReader(b, 1<<20, true) }
func BenchmarkDownloadReader_BytesNoCopy_1MB(b *testing.B) { benchDownloadReader(b, 1<<20, false) }
func BenchmarkDownloadReader_StringCopy_16MB(b *testing.B) { benchDownloadReader(b, 16<<20, true) }
func BenchmarkDownloadReader_BytesNoCopy_16MB(b *testing.B) {
benchDownloadReader(b, 16<<20, false)
}

View File

@@ -70,6 +70,7 @@ func shortcutList() []common.Shortcut {
return []common.Shortcut{
// lark_sheet_workbook
WorkbookInfo,
RevisionGet,
SheetCreate,
SheetDelete,
SheetRename,
@@ -95,6 +96,9 @@ func shortcutList() []common.Shortcut {
DimUngroup,
DimMove,
// lark_sheet_changeset
ChangesetGet,
// lark_sheet_read_data
CellsGet,
CsvGet,
@@ -105,6 +109,9 @@ func shortcutList() []common.Shortcut {
CellsSearch,
CellsReplace,
// lark_sheet_formula_verify
FormulaVerify,
// lark_sheet_write_cells
CellsSet,
CellsSetStyle,
@@ -148,5 +155,10 @@ func shortcutList() []common.Shortcut {
CellsBatchClear,
DropdownUpdate,
DropdownDelete,
// lark_sheet_history
HistoryList,
HistoryRevert,
HistoryRevertStatus,
}
}

View File

@@ -200,16 +200,21 @@ var GetMyTasks = common.Shortcut{
for _, item := range filteredItems {
urlVal, _ := item["url"].(string)
urlVal = truncateTaskURL(urlVal)
completed, completedAt := taskCompletionState(item)
outputItem := map[string]interface{}{
"guid": item["guid"],
"summary": item["summary"],
"url": urlVal,
"guid": item["guid"],
"summary": item["summary"],
"url": urlVal,
"completed": completed,
}
if createdAtStr, ok := item["created_at"].(string); ok {
if ts, err := strconv.ParseInt(createdAtStr, 10, 64); err == nil {
outputItem["created_at"] = time.UnixMilli(ts).Local().Format(time.RFC3339)
}
}
if !completedAt.IsZero() {
outputItem["completed_at"] = completedAt.Local().Format(time.RFC3339)
}
if dueObj, ok := item["due"].(map[string]interface{}); ok {
if tsStr, ok := dueObj["timestamp"].(string); ok {
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
@@ -237,6 +242,7 @@ var GetMyTasks = common.Shortcut{
summary, _ := item["summary"].(string)
urlVal, _ := item["url"].(string)
urlVal = truncateTaskURL(urlVal)
completed, completedAt := taskCompletionState(item)
var dueTimeStr string
if dueObj, ok := item["due"].(map[string]interface{}); ok {
@@ -259,6 +265,10 @@ var GetMyTasks = common.Shortcut{
if urlVal != "" {
fmt.Fprintf(w, " URL: %s\n", urlVal)
}
fmt.Fprintf(w, " Completed: %t\n", completed)
if !completedAt.IsZero() {
fmt.Fprintf(w, " Completed At: %s\n", completedAt.Local().Format("2006-01-02 15:04"))
}
if dueTimeStr != "" {
fmt.Fprintf(w, " Due: %s\n", dueTimeStr)
}
@@ -278,3 +288,15 @@ var GetMyTasks = common.Shortcut{
return nil
},
}
func taskCompletionState(item map[string]interface{}) (bool, time.Time) {
completedAtStr, _ := item["completed_at"].(string)
if completedAtStr == "" || completedAtStr == "0" {
return false, time.Time{}
}
ts, err := strconv.ParseInt(completedAtStr, 10, 64)
if err != nil {
return false, time.Time{}
}
return true, time.UnixMilli(ts)
}

View File

@@ -110,6 +110,118 @@ func TestGetMyTasks_LocalTimeFormatting(t *testing.T) {
}
}
func TestGetMyTasks_IncludesCompletionStateInJSON(t *testing.T) {
tsMs := int64(1775174400000)
tsStr := strconv.FormatInt(tsMs, 10)
expectedCompletedAt := time.UnixMilli(tsMs).Local().Format(time.RFC3339)
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"guid": "task-open",
"summary": "Open Task",
"completed_at": "0",
"url": "https://example.com/task-open",
},
map[string]interface{}{
"guid": "task-done",
"summary": "Done Task",
"completed_at": tsStr,
"url": "https://example.com/task-done",
},
},
"has_more": false,
"page_token": "",
},
},
})
s := GetMyTasks
s.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, s, []string{"+get-my-tasks", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
outNorm := strings.ReplaceAll(stdout.String(), `":"`, `": "`)
for _, expected := range []string{
`"guid": "task-open"`,
`"completed": false`,
`"guid": "task-done"`,
`"completed": true`,
`"completed_at": "` + expectedCompletedAt + `"`,
} {
if !strings.Contains(outNorm, expected) {
t.Fatalf("output missing expected string (%s), got: %s", expected, stdout.String())
}
}
}
func TestGetMyTasks_IncludesCompletionStateInPretty(t *testing.T) {
tsMs := int64(1775174400000)
tsStr := strconv.FormatInt(tsMs, 10)
expectedCompletedAt := time.UnixMilli(tsMs).Local().Format("2006-01-02 15:04")
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"guid": "task-open",
"summary": "Open Task",
"completed_at": "0",
"url": "https://example.com/task-open",
},
map[string]interface{}{
"guid": "task-done",
"summary": "Done Task",
"completed_at": tsStr,
"url": "https://example.com/task-done",
},
},
"has_more": false,
"page_token": "",
},
},
})
s := GetMyTasks
s.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, s, []string{"+get-my-tasks", "--format", "pretty", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
out := stdout.String()
for _, expected := range []string{
"[1] Open Task\n ID: task-open\n URL: https://example.com/task-open\n Completed: false\n",
"[2] Done Task\n ID: task-done\n URL: https://example.com/task-done\n Completed: true\n Completed At: " + expectedCompletedAt + "\n",
} {
if !strings.Contains(out, expected) {
t.Fatalf("output missing expected string (%s), got: %s", expected, out)
}
}
if count := strings.Count(out, "Completed At:"); count != 1 {
t.Fatalf("Completed At count = %d, want 1; output: %s", count, out)
}
}
// TestGetMyTasks_InvalidTimeFlags locks the three time-flag validation arms in
// Execute (--created_at / --due-start / --due-end). The parse runs before any
// API call, so a malformed value deterministically surfaces a typed

View File

@@ -159,7 +159,7 @@ func TestHandleTaskApiResultWithContext_PermissionConsoleURL(t *testing.T) {
if pe.Subtype != errs.SubtypeAppScopeNotApplied {
t.Errorf("subtype = %q, want %q", pe.Subtype, errs.SubtypeAppScopeNotApplied)
}
if pe.ConsoleURL == "" || !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123/auth") {
if pe.ConsoleURL == "" || !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
t.Errorf("ConsoleURL = %q, want Lark developer console URL", pe.ConsoleURL)
}
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "task:attachment:write" {

View File

@@ -16,5 +16,6 @@ func Shortcuts() []common.Shortcut {
VCMeetingLeave,
VCMeetingListActive,
VCMeetingEvents,
VCMeetingMessageSend,
}
}

View File

@@ -838,7 +838,7 @@ func TestVCShortcuts_RegistersMeetingAgentCommands(t *testing.T) {
for _, shortcut := range got {
commands = append(commands, shortcut.Command)
}
want := []string{"+search", "+notes", "+recording", "+detail", "+meeting-join", "+meeting-leave", "+meeting-list-active", "+meeting-events"}
want := []string{"+search", "+notes", "+recording", "+detail", "+meeting-join", "+meeting-leave", "+meeting-list-active", "+meeting-events", "+meeting-message-send"}
if !reflect.DeepEqual(commands, want) {
t.Fatalf("shortcut commands = %#v, want %#v", commands, want)
}

View File

@@ -0,0 +1,161 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
const (
meetingMessageTypeText = "text"
meetingMessageTypeReaction = "reaction"
// Keep the client-side cap below the server-side content limit.
meetingMessageMaxTextBytes = 48 * 1024
meetingMessageMaxUUIDBytes = 128
)
// VCMeetingMessageSend sends an in-meeting text message or reaction emoji.
var VCMeetingMessageSend = common.Shortcut{
Service: "vc",
Command: "+meeting-message-send",
Description: "Send an in-meeting text message or reaction emoji",
Risk: "write",
Scopes: []string{"vc:meeting.message:write"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-id", Required: true, Desc: "meeting ID to send into"},
{Name: "msg-type", Desc: "message type: text or reaction"},
{Name: "text", Desc: "text content when --msg-type text"},
{Name: "emoji-type", Desc: "emoji key when --msg-type reaction, for example LOVE, THUMBSUP, VC_NoSound"},
{Name: "uuid", Desc: "optional idempotency key"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateMeetingEventsMeetingID(runtime.Str("meeting-id")); err != nil {
return err
}
_, err := validateMeetingMessagePayload(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body, err := buildMeetingMessageSendBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST(buildMeetingMessageSendPath()).
Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildMeetingMessageSendBody(runtime)
if err != nil {
return err
}
data, err := runtime.CallAPITyped(http.MethodPost, buildMeetingMessageSendPath(), nil, body)
if err != nil {
return err
}
if data == nil {
data = map[string]interface{}{}
}
runtime.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintln(w, "Meeting message sent.")
if msgType := common.GetString(data, "msg_type"); msgType != "" {
fmt.Fprintf(w, " Type: %s\n", msgType)
} else if msgType, _ := body["msg_type"].(string); msgType != "" {
fmt.Fprintf(w, " Type: %s\n", msgType)
}
if uuid := common.GetString(data, "uuid"); uuid != "" {
fmt.Fprintf(w, " UUID: %s\n", uuid)
}
})
return nil
},
}
func buildMeetingMessageSendPath() string {
return "/open-apis/vc/v1/bots/message"
}
func buildMeetingMessageSendBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
msgType, err := validateMeetingMessagePayload(runtime)
if err != nil {
return nil, err
}
body := map[string]interface{}{
"meeting_id": strings.TrimSpace(runtime.Str("meeting-id")),
"msg_type": msgType,
}
switch msgType {
case meetingMessageTypeText:
body["content"] = strings.TrimSpace(runtime.Str("text"))
case meetingMessageTypeReaction:
body["content"] = strings.TrimSpace(runtime.Str("emoji-type"))
}
if uuid := strings.TrimSpace(runtime.Str("uuid")); uuid != "" {
body["uuid"] = uuid
}
return body, nil
}
func validateMeetingMessagePayload(runtime *common.RuntimeContext) (string, error) {
msgType, err := resolveMeetingMessageType(runtime)
if err != nil {
return "", err
}
if msgType == meetingMessageTypeText {
text := strings.TrimSpace(runtime.Str("text"))
if len(text) > meetingMessageMaxTextBytes {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, fmt.Sprintf("--text is too long; max %d bytes", meetingMessageMaxTextBytes)).WithParam("--text")
}
}
if uuid := strings.TrimSpace(runtime.Str("uuid")); len(uuid) > meetingMessageMaxUUIDBytes {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, fmt.Sprintf("--uuid is too long; max %d bytes", meetingMessageMaxUUIDBytes)).WithParam("--uuid")
}
return msgType, nil
}
func resolveMeetingMessageType(runtime *common.RuntimeContext) (string, error) {
msgType := strings.ToLower(strings.TrimSpace(runtime.Str("msg-type")))
text := strings.TrimSpace(runtime.Str("text"))
emojiType := strings.TrimSpace(runtime.Str("emoji-type"))
if msgType == "" {
switch {
case text != "" && emojiType == "":
msgType = meetingMessageTypeText
case text == "" && emojiType != "":
msgType = meetingMessageTypeReaction
default:
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--msg-type is required when both --text and --emoji-type are empty or both are set").WithParam("--msg-type")
}
}
switch msgType {
case meetingMessageTypeText:
if text == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--text is required when --msg-type text").WithParam("--text")
}
if emojiType != "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--emoji-type cannot be used when --msg-type text").WithParam("--emoji-type")
}
case meetingMessageTypeReaction:
if emojiType == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--emoji-type is required when --msg-type reaction").WithParam("--emoji-type")
}
if text != "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--text cannot be used when --msg-type reaction").WithParam("--text")
}
default:
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--msg-type must be text or reaction").WithParam("--msg-type")
}
return msgType, nil
}

View File

@@ -0,0 +1,312 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func newMeetingMessageSendRuntime() *common.RuntimeContext {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-id", "", "")
cmd.Flags().String("msg-type", "", "")
cmd.Flags().String("text", "", "")
cmd.Flags().String("emoji-type", "", "")
cmd.Flags().String("uuid", "", "")
return common.TestNewRuntimeContext(cmd, defaultConfig())
}
func mustSetMeetingMessageSendFlag(t *testing.T, runtime *common.RuntimeContext, name, value string) {
t.Helper()
if err := runtime.Cmd.Flags().Set(name, value); err != nil {
t.Fatalf("Flags().Set(%q, %q) error = %v", name, value, err)
}
}
func assertMeetingMessageSendValidationError(t *testing.T, err error, wantParam string) {
t.Helper()
if err == nil {
t.Fatal("expected validation error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if p.Category != errs.CategoryValidation {
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
}
if p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Param != wantParam {
t.Errorf("Param = %q, want %q", ve.Param, wantParam)
}
}
func TestMeetingMessageSendBuildBody_Text(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "text", " hello ")
mustSetMeetingMessageSendFlag(t, runtime, "uuid", " cid-1 ")
body, err := buildMeetingMessageSendBody(runtime)
if err != nil {
t.Fatalf("buildMeetingMessageSendBody() error = %v", err)
}
if body["msg_type"] != meetingMessageTypeText {
t.Fatalf("msg_type = %v, want text", body["msg_type"])
}
if body["content"] != "hello" {
t.Fatalf("content = %v, want hello", body["content"])
}
if body["uuid"] != "cid-1" {
t.Fatalf("uuid = %v, want cid-1", body["uuid"])
}
}
func TestMeetingMessageSendBuildBody_Reaction(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "reaction")
mustSetMeetingMessageSendFlag(t, runtime, "emoji-type", "LOVE")
body, err := buildMeetingMessageSendBody(runtime)
if err != nil {
t.Fatalf("buildMeetingMessageSendBody() error = %v", err)
}
if body["msg_type"] != meetingMessageTypeReaction {
t.Fatalf("msg_type = %v, want reaction", body["msg_type"])
}
if body["content"] != "LOVE" {
t.Fatalf("content = %v, want LOVE", body["content"])
}
if _, ok := body["text"]; ok {
t.Fatalf("text should be omitted for reaction, got %#v", body["text"])
}
if _, ok := body["emoji_type"]; ok {
t.Fatalf("emoji_type should be omitted for reaction, got %#v", body["emoji_type"])
}
}
func TestMeetingMessageSendBuildBody_ReactionVCFeedbackKey(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "reaction")
mustSetMeetingMessageSendFlag(t, runtime, "emoji-type", "VC_NoSound")
body, err := buildMeetingMessageSendBody(runtime)
if err != nil {
t.Fatalf("buildMeetingMessageSendBody() error = %v", err)
}
if body["content"] != "VC_NoSound" {
t.Fatalf("content = %v, want VC_NoSound", body["content"])
}
}
func TestMeetingMessageSendValidateRejectsMeetingNumber(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "123456789")
mustSetMeetingMessageSendFlag(t, runtime, "text", "hello")
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
assertMeetingMessageSendValidationError(t, err, "--meeting-id")
if !strings.Contains(err.Error(), "9-digit meeting number") {
t.Fatalf("error = %v, want 9-digit meeting number hint", err)
}
}
func TestMeetingMessageSendValidateRejectsMissingEmojiType(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "reaction")
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
assertMeetingMessageSendValidationError(t, err, "--emoji-type")
if !strings.Contains(err.Error(), "--emoji-type is required") {
t.Fatalf("error = %v, want --emoji-type required", err)
}
}
func TestMeetingMessageSendValidateRejectsTextMessageWithEmojiType(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "text")
mustSetMeetingMessageSendFlag(t, runtime, "text", "hello")
mustSetMeetingMessageSendFlag(t, runtime, "emoji-type", "LOVE")
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
assertMeetingMessageSendValidationError(t, err, "--emoji-type")
if !strings.Contains(err.Error(), "--emoji-type cannot be used") {
t.Fatalf("error = %v, want --emoji-type conflict", err)
}
}
func TestMeetingMessageSendValidateRejectsReactionMessageWithText(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "reaction")
mustSetMeetingMessageSendFlag(t, runtime, "emoji-type", "LOVE")
mustSetMeetingMessageSendFlag(t, runtime, "text", "hello")
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
assertMeetingMessageSendValidationError(t, err, "--text")
if !strings.Contains(err.Error(), "--text cannot be used") {
t.Fatalf("error = %v, want --text conflict", err)
}
}
func TestMeetingMessageSendValidateRejectsLongText(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
mustSetMeetingMessageSendFlag(t, runtime, "text", strings.Repeat("a", meetingMessageMaxTextBytes+1))
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
assertMeetingMessageSendValidationError(t, err, "--text")
if !strings.Contains(err.Error(), "--text is too long") {
t.Fatalf("error = %v, want --text too long", err)
}
}
func TestMeetingMessageSendValidateRejectsLongUUID(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
mustSetMeetingMessageSendFlag(t, runtime, "text", "hello")
mustSetMeetingMessageSendFlag(t, runtime, "uuid", strings.Repeat("u", meetingMessageMaxUUIDBytes+1))
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
assertMeetingMessageSendValidationError(t, err, "--uuid")
if !strings.Contains(err.Error(), "--uuid is too long") {
t.Fatalf("error = %v, want --uuid too long", err)
}
}
func TestMeetingMessageSendDryRun_Text(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingMessageSend, []string{
"+meeting-message-send", "--dry-run", "--as", "user",
"--meeting-id", "7651377260537433044",
"--text", "hello",
"--uuid", "cid-1",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{
"/open-apis/vc/v1/bots/message",
"\"meeting_id\": \"7651377260537433044\"",
"\"msg_type\": \"text\"",
"\"content\": \"hello\"",
"\"uuid\": \"cid-1\"",
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q: %s", want, out)
}
}
}
func TestMeetingMessageSendDryRun_ValidationErrorEnvelope(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
dryRun := VCMeetingMessageSend.DryRun(context.Background(), runtime)
if got := dryRun.Format(); !strings.Contains(got, "--msg-type is required") {
t.Fatalf("dry-run error = %v, want --msg-type required", got)
}
}
func TestMeetingMessageSendExecute_Text(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: buildMeetingMessageSendPath(),
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"msg_type": "text",
"uuid": "cid-1",
},
},
}
reg.Register(stub)
err := mountAndRun(t, VCMeetingMessageSend, []string{
"+meeting-message-send", "--as", "user",
"--format", "pretty",
"--meeting-id", "7651377260537433044",
"--text", "hello",
"--uuid", "cid-1",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
var req map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &req); err != nil {
t.Fatalf("failed to parse request body: %v", err)
}
for key, want := range map[string]string{
"meeting_id": "7651377260537433044",
"msg_type": "text",
"content": "hello",
"uuid": "cid-1",
} {
if req[key] != want {
t.Errorf("%s = %v, want %s", key, req[key], want)
}
}
out := stdout.String()
for _, want := range []string{
"Meeting message sent.",
"Type: text",
"UUID: cid-1",
} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
}
func TestMeetingMessageSendExecute_ReactionFallsBackToRequestType(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: buildMeetingMessageSendPath(),
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{},
},
})
err := mountAndRun(t, VCMeetingMessageSend, []string{
"+meeting-message-send", "--as", "user",
"--format", "pretty",
"--meeting-id", "7651377260537433044",
"--msg-type", "reaction",
"--emoji-type", "LOVE",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
if out := stdout.String(); !strings.Contains(out, "Type: reaction") {
t.Fatalf("output missing fallback type: %s", out)
}
}

View File

@@ -1,23 +1,77 @@
所有命令默认 `--as user`(审批是人的动作)。调用前先 `lark-cli schema approval.<resource>.<method>` 查参数结构,不要猜字段。
所有命令默认 `--as user`(审批是人的动作)。调用前先按需读取 references 下对应的文件,查参数结构,不要猜字段。
## 路由优先级(先判断是不是审批,再选命令)
审批待办不是飞书任务。**只要用户的核心对象是审批单据 / 审批待办 / 审批实例,就优先使用 `lark-approval`,不要让渡给 `lark-task`。**
### 明确归 `lark-approval` 的高优先级语义
出现以下任一语义时,优先走 `lark-approval`
- 审批待办 / 审批单据 / 审批实例 / 审批意见 / 审批定义
- 同意 / 拒绝 / 转交 / 退回 / 撤回 / 催办 / 加签 / 抄送
- 待办列表 / 待办单据 / 已发起审批 / 已办审批 / 审批详情 / 同意可编辑
**判定规则:** 只要最终动作是对审批单据做同意、拒绝、转交、退回、撤回、催办、加签、抄送、查详情、查已发起/已办/待办,就归 `lark-approval`。只有当用户处理的是**非审批类任务/待办**时,才走 [`lark-task`](../lark-task/SKILL.md)。
## 选哪个命令
| 想做什么 | 命令 |
|---|---|
| 搜可发起定义 | `approvals search` |
| 看审批定义详情/提单前确认表单与流程 | `approvals get` |
| 发起原生审批实例 | `instances create` |
| 查待办/已办 | `tasks query``topic`1待办 2已办 17未读 18已读|
| 看表单/进度/当前节点 | `instances get` |
| 同意/拒绝 | `tasks approve` / `tasks reject` |
| 转交/加签/退回 | `tasks transfer` / `tasks add_sign` / `tasks rollback` |
| 催办 | `tasks remind` |
| 撤回/抄送/按定义查已发起 | `instances cancel` / `instances cc` / `instances initiated` |
| 想做什么 | 命令 | 按需读取 reference |
|---|---|---------------------------------------------------------------------------------|
| 搜可发起定义 | `approvals search` | [`lark-approval-approvals-search.md`](references/lark-approval-approvals-search.md) |
| 看审批定义详情/提单前确认表单与流程 | `approvals get` | [`lark-approval-approvals-get.md`](references/lark-approval-approvals-get.md) |
| 发起原生审批实例/提交请假审批/提交报销审批/创建审批实例 | `instances create` | [`lark-approval-initiate.md`](references/lark-approval-initiate.md) |
| 查待办/已办 | `tasks query``topic`1待办 2已办 17未读 18已读 | [`lark-approval-tasks-query.md`](references/lark-approval-tasks-query.md) |
| 看表单/进度/当前节点 | `instances get` | [`lark-approval-instances-get.md`](references/lark-approval-instances-get.md) |
| 同意审批 | `tasks approve` | [`lark-approval-tasks-approve.md`](references/lark-approval-tasks-approve.md) |
| 拒绝审批 | `tasks reject` | [`lark-approval-tasks-reject.md`](references/lark-approval-tasks-reject.md) |
| 转交审批 | `tasks transfer` | [`lark-approval-tasks-transfer.md`](references/lark-approval-tasks-transfer.md) |
| 加签审批 | `tasks add_sign` | [`lark-approval-tasks-add-sign.md`](references/lark-approval-tasks-add-sign.md) |
| 退回审批 | `tasks rollback` | [`lark-approval-tasks-rollback.md`](references/lark-approval-tasks-rollback.md) |
| 催办审批 | `tasks remind` | [`lark-approval-tasks-remind.md`](references/lark-approval-tasks-remind.md) |
| 撤回已发起审批 | `instances cancel` | [`lark-approval-instances-cancel.md`](references/lark-approval-instances-cancel.md) |
| 给审批实例追加抄送 | `instances cc` | [`lark-approval-instances-cc.md`](references/lark-approval-instances-cc.md) |
| 按定义查已发起审批 | `instances initiated` | [`lark-approval-instances-initiated.md`](references/lark-approval-instances-initiated.md) |
处理链:
- 发起审批:`approvals search` -> `approvals get` -> `instances.create`
- 处理审批:`tasks query``instance_code` + `task_id`(操作必须成对带上)→ 需要细节`instances get` → 执行操作
- 发起审批:`approvals search` -> `approvals get` -> `instances create`
- 处理审批:`tasks query``instance_code` + `task_id`(操作必须成对带上)→ 只有用户明确需要查看详情、当前节点、表单内容、或流程进度时,`instances get` → 执行操作
## 执行原则(减少误路由、误重试和无效消耗)
### 1) 先拿最小必要信息,再执行
- 目标只是处理待办时,优先 `tasks query` 获取 `instance_code` + `task_id`
- **只有**用户明确要看详情、当前节点、表单内容、流程进度时,才调用 `instances get`
- 用户已经明确给出 `instance_code` / `task_id` 时,不要先查列表再过滤
### 2) 已知对象时直达动作
- 已拿到 `instance_code` + `task_id` 后,优先直接执行 `tasks approve/reject/transfer/add_sign/rollback/remind`
- 同一轮里如果已有足够的新鲜查询结果,不要重复 `tasks query`
- 不要默认走 `list -> filter -> detail -> write` 全链路;对象已明确时应压缩步骤
### 3) 错误码驱动,而不是盲目重试
- 写操作失败后,先看错误码和报错语义,再决定是否补查或结束
- **除非错误明确提示可恢复或需要补充参数,否则不要重复刷同一个写操作**
- 同一个失败原因不要连续多次重试,避免 token 和耗时失控最多重试1次
## 写操作失败处理1395001 决策树
当拒绝 / 转交 / 退回 / 撤回 / 同意等写操作返回 `1395001`(任务状态异常 / 写前置校验失败)时,按下面规则处理:
1. **先停止盲目重试**不要连续重复提交相同写操作最多重试1次
2. 优先从以下角度解释:
- 任务可能已被他人处理
- 单据状态已变化,当前动作已不再允许
- 当前用户已不具备该任务的操作资格
- 当前节点或单据状态不支持该操作
3. 如需确认,只补 **一次** 状态查询(`tasks query``instances get`),不要陷入 query/write 循环
4. 最终给用户明确结论和下一步建议,而不是继续无意义重试
**特别注意:** 对拒绝 / 转交 / 撤回场景更要严格执行上述规则;这些场景最容易因状态切换而失败。
```bash
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
@@ -27,14 +81,6 @@ lark-cli approval tasks query --params '{"topic":"1"}' --as user
lark-cli approval tasks approve --data '{"instance_code":"<ic>","task_id":"<tid>","comment":"同意"}' --as user
```
## 发起原生审批
## 不在本 skill 范围
发起审批属于高风险写操作,按下表处理:
| 规则 | 处理 |
|---|---|
| 用户意图是发起审批 / 提单 / 提交请假审批 / 提交报销审批 / 创建审批实例 | 先读 [`references/lark-approval-initiate.md`](references/lark-approval-initiate.md)、[`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md) 和 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create` |
| 编排顺序 | 固定走 `approvals.search` -> `approvals.get` -> `instances.create`;未拿到定义详情前不要猜 `form``node_approver_list``node_cc_list` |
| 三方定义 | `is_external=true` 时不要调用 `approval instances create`,返回 `create_link` 并说明需通过链接发起 |
| 表单与节点参数 | 控件 `value` 结构看 [`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md);值来源看 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md) |
| 真正执行前 | 让用户确认最终定义、表单值和节点参数;执行时显式传 `--yes`,成功后回报 `instance_code``instance_link` |
创建审批定义(走飞书客户端或审批管理后台);三方定义发起(返回 `create_link`,引导用户通过链接发起);非审批类待办 → [`lark-task`](../lark-task/SKILL.md)

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