Compare commits

...

108 Commits

Author SHA1 Message Date
liangshuo-1
f0e724cbd4 chore: cut v1.0.12 with reviewed release notes (#493)
This release prep captures the version bump and changelog entry for v1.0.12 without pulling unrelated workspace edits into the release branch.

Change-Id: Ib343337c4851b7cc15a52dd0068795a92092b781
Constraint: Keep the release PR scoped to package version and changelog only
Rejected: Include .gitignore and local workspace files | unrelated to this release PR
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep release notes aligned with shipped changes only; exclude reverted work from summaries
Tested: make unit-test
Tested: go mod tidy
Tested: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
Not-tested: Manual tag/release publishing flow
2026-04-15 21:29:44 +08:00
chanthuang
03ba542a60 Revert "feat: mail support scheduled send (#449)" (#492)
This reverts commit 44e7b5b477.

Change-Id: I0b0c6454cf5ea4c15169a3c683b91795ef880478
2026-04-15 21:04:27 +08:00
chanthuang
5fa68ccaa0 feat(mail): add email signature support (#485)
* feat(mail): add signature foundation, draft exports, and +signature shortcut

- Add signature data model, API provider, and template variable
  interpolation with tests (shortcuts/mail/signature/)
- Export signature-related symbols from draft package (SignatureWrapperClass,
  BuildSignatureHTML, FindMatchingCloseDiv, SplitAtQuote, RemoveSignatureHTML,
  SignatureSpacing, SignatureImage) for use by compose shortcuts
- Add +signature shortcut for listing and viewing email signatures
- Add signature reference documentation

Change-Id: I62525e7b475692ada9ec8590b6d0252cf5afcdbc
Co-Authored-By: AI

* feat(mail): add --signature-id to all compose shortcuts

- Add --signature-id flag to +draft-create, +send, +reply, +reply-all,
  +forward for inserting a signature into the email body
- Add signature image download with SSRF protection (https enforcement,
  no token leak, context timeout, size limit)
- Add signature HTML insertion with quote-aware placement
- Update compose shortcut reference docs

Change-Id: Ic5606bab7826a20364084898ad1714778e5a8bd0
Co-Authored-By: AI

* feat(mail): add signature insert/remove ops for +draft-edit

- Add insert_signature and remove_signature patch operations with
  old-signature MIME cleanup and case-insensitive CID matching
- Expose signature ops in supported_ops flat list
- Update SKILL.md and draft-edit reference docs

Change-Id: I74affbf555e32351520f610ef42195f399a265d9
Co-Authored-By: AI

* test(mail): add unit tests for signature patch operations

Test insert_signature and remove_signature ops:
- Insert into basic HTML body
- Insert before quote block (reply/forward)
- Replace existing signature
- Error on plain-text-only draft
- Remove existing signature
- Error when no signature present

Change-Id: Icd713552b130d6eb461ef1cabca61e82327f4f0b
Co-Authored-By: AI

* fix(mail): address reviewer findings on signature PR

- Remove --device flag and device field from docs (not exposed in CLI)
- Fix signature interpolation to match --from alias address in send_as
  list, instead of always using the primary mailbox address
- Update lark-mail-signature.md reference doc

Change-Id: I65f41a029cd33b17785e2355a99d042063962d23
Co-Authored-By: AI

* fix(mail): resolve lint issues — remove unused code, fix gofmt

- Remove unused cidSrcRe, collectSignatureCIDs, isCIDReferencedInHTML
  from signature_html.go (CID logic lives in draft/patch.go)
- Remove unused strings import
- Run gofmt on all affected files

Change-Id: Ie142744a7ab17acf440dc69a5a78cefb3ce6c341
Co-Authored-By: AI

* fix(mail): use draft From address for signature interpolation in +draft-edit

Moved signature resolution after draft fetch+parse so insert_signature
reads the From header from the existing draft. This ensures alias and
shared-mailbox senders get correct template variable values (B-NAME,
B-ENTERPRISE-EMAIL) instead of falling back to the primary address.

Change-Id: I917016b17176090124814f30e8e15c67f1604de0
Co-Authored-By: AI
2026-04-15 17:44:59 +08:00
mazhe-nerd
1583af7fc0 feat: 一键安装并配置 (#464) 2026-04-15 17:44:29 +08:00
feng zhi hao
44e7b5b477 feat: mail support scheduled send (#449)
feat: mail support scheduled send
2026-04-15 14:11:19 +08:00
chanthuang
66ec27f6e1 feat(mail): support recipient search (#437)
* feat(mail): add contact search workflow and multi_entity search API

- Add recipient search workflow to mail skill template (search by name,
  email keyword, or group name with rich result display)
- Regenerate SKILL.md with multi_entity.search command

Change-Id: Ie307af16a5ee38dac99c1d8d0df528730bf847d0
Co-Authored-By: AI

* fix: require user confirmation for all contact search results

multi_entity.search is a fuzzy keyword search — a single result does
not guarantee an exact match (e.g. searching "张三" may only return
"张三丰"). Always show candidates for user confirmation before using
the email address in compose parameters.

Change-Id: I447c54cd59b06a88c5d6806bfe76f0adfdceb1ce
Co-Authored-By: AI
2026-04-15 12:33:50 +08:00
chanthuang
162c25527b feat(mail): support recall sent email (#481)
- Add buildSendResult helper that includes recall_available/recall_tip
  when backend returns recall_status in send response
- Update +send, +reply, +reply-all, +forward to use buildSendResult
- Add "Recall Email" section to mail skill template with recall and
  get_recall_detail command examples
- Regenerate SKILL.md

Change-Id: I44317ead8f8a65db81e874cfc3529ffeb21e1384
Co-Authored-By: AI
2026-04-15 12:31:25 +08:00
calendar-assistant
0c7a930fc3 docs: route past meeting queries to lark-vc (#482)
Change-Id: Ia39721ba7b72e08f29422354eb2c82c89c5b81b0
2026-04-15 11:53:53 +08:00
ViperCai
ec9e67c21a feat(slides): add image upload via +media-upload and @path placeholders in +create (#450)
- New `slides +media-upload` shortcut: upload a local image to a slides
  presentation and return the file_token for use in <img src="...">.
- `slides +create --slides` now supports `@./path.png` placeholders that
  are auto-uploaded and replaced with file_tokens.
- Reject images >20 MB (multipart upload not supported for slide_file).
- Support wiki URL resolution for --presentation flag.
2026-04-15 11:44:11 +08:00
zhaoleibd
74e4a97f52 docs(lark-vc): clarify historical date search in skill description (#480)
Explicitly mention historical dates in the description of lark-vc skill to improve query matching for past meetings.

Change-Id: I796382793bb5d910924fac450e5315645ce543d4
2026-04-15 11:23:50 +08:00
liangshuo-1
fe4123436f chore: prepare v1.0.11 release metadata (#472)
Update the package version and changelog entry so the release branch matches the v1.0.11 changes already queued after v1.0.10.

This keeps the published package version and human-readable release notes aligned without pulling unrelated local workspace changes into the release PR.

Change-Id: Ia937651001e0057df4fe82bd11705c52d343f9a9
2026-04-14 20:08:57 +08:00
kongenpei
052e2112bf fix: validate base shortcut JSON object inputs (#458)
* fix: validate base shortcut JSON object inputs

* fix: reject null in base JSON object parser

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-14 19:30:23 +08:00
caojie0621
76a834e928 feat(sheets): add dropdown shortcuts and formula reference docs (#461)
Implement +set-dropdown, +update-dropdown, +get-dropdown, and
+delete-dropdown shortcuts wrapping the v2 dataValidation API.
This resolves the issue where multipleValue writes silently
became plain text because the prerequisite dropdown configuration
step was not exposed as a CLI command.

Also add lark-sheets-formula.md reference for Lark-specific formula
rules (ARRAYFORMULA, native array functions, date diff, etc.) and
update the dropdown limitation note in SKILL.md to link to the new
+set-dropdown shortcut.
2026-04-14 18:48:07 +08:00
ILUO
20761fa56a feat(task): add task shortcuts with skill docs and tests (#377)
* feat(task): add task shortcuts with skill docs and tests

* docs(task): document task event payload shape

* refactor(task): remove unused buildUserIDs helper

* fix(task): handle api error codes in set-ancestor

* docs(task): clarify get-related-tasks page-token unit

* feat(task): support bot identity for subscribe-event

* docs(task): clarify bot subscribe-event scope

* docs(task): clarify related-task pagination semantics

* docs(task): add BOE selftest report (boe_task_tasklist_oapi_support)

* docs(task): prefer related-task shortcuts over search for scoped queries

* docs(task): clarify tasklist search routing

* docs(task): route keywordless tasklist queries to list API

* docs(task): refine search routing heuristics

* feat(event): include task user-access updates in catch-all subscribe

* docs(task): remove auth status --json guidance
2026-04-14 17:24:38 +08:00
mazhe-nerd
2a301246f9 feat: skip auth check (#451)
The secondary confirmation step in the interactive login process has been removed (Phase 2: After the user selects the complete domain name, permission level, and scope, they no longer need to confirm "authorize" again and can directly proceed to the authorization process).
2026-04-14 11:38:39 +08:00
Schumi Lin
abc374f1a3 docs(readme): add Attendance to Features table (#460)
* docs(readme): add lark-attendance to Agent Skills table and update counts

- Add lark-attendance to Agent Skills table in both EN and ZH READMEs
- Add Attendance to ZH Features table
- Update skill count 21 → 22 and domain count 13 → 14
2026-04-14 10:55:33 +08:00
caojie0621
2910cde73a feat(sheets): add value format documentation for formula and special types (#456)
Document the correct object format for writing formulas, URLs with
text, mentions, and dropdown lists via --values parameter. Add
examples contrasting correct object format vs incorrect plain string.
2026-04-14 00:07:45 +08:00
liangshuo-1
7fdc162ff7 chore: bump version to v1.0.10 and update changelog (#457)
Change-Id: I6f8f6b474e2bcedec4646c69b35235c52906c74e
2026-04-13 22:58:20 +08:00
chenxingtong-bytedance
06e7ae267c (im) support im oapi range download large file (#283)
Add range download support for IM OAPI resources so lark-cli can reliably download large files. This improves stability for large payloads and network interruptions.

Change-Id: I38e6f6f9cf8b8711dc40650d19c77503f4e44989
2026-04-13 22:02:34 +08:00
caojie0621
74f7de386a feat(sheets): add filter view and condition shortcuts (#422)
Add 10 new sheet shortcuts for filter view management:

Filter views:
- +create-filter-view, +update-filter-view, +list-filter-views
- +get-filter-view, +delete-filter-view

Filter view conditions:
- +create-filter-view-condition, +update-filter-view-condition
- +list-filter-view-conditions, +get-filter-view-condition
- +delete-filter-view-condition

Includes unit tests (39 cases, 88-93% coverage) and skill reference docs.
2026-04-13 21:41:28 +08:00
yaozhen00
c2b132945e feat(test): optimize cli-e2e-testcase-writer skill (#447)
* feat(test): optimize cli-e2e-testcase-writer skill add coverage.md

* feat(test): test report show
2026-04-13 21:10:11 +08:00
liujinkun2025
88fd3bdab8 feat(wiki): add wiki move shortcut with async task polling (#436)
Change-Id: I58400054e6c3c3c8e7b0cf72b874602b22fa287d
2026-04-13 19:33:53 +08:00
kongenpei
c70c3fdce2 fix: support large base attachment uploads (#441)
Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-13 19:32:05 +08:00
MaxHuang22
c13f240b9b fix(config): clarify init copy for TTY, preserve original for AI (#448)
The interactive `config init` flow showed a QR code and verification
link without indicating their relationship, leaving users unsure
which to act on first and whether the link was still needed after
scanning.

Split the message strings on TTY vs non-TTY:
- TTY: header above QR ("使用飞书 / Lark 扫码配置应用"), "或打开链接"
  framing to mark the link as an alternative, and an active waiting
  indicator.
- Non-TTY (AI / piped callers via --new): keep the original copy
  verbatim so existing parsers and prompts are unaffected.

QR is still rendered in both branches.

Change-Id: I9b753f044ebefaedbb4b095cabf7beff4669eb2e
2026-04-13 18:51:38 +08:00
wittam-01
88bf7fc1cd feat: add drive files patch metaapi (#444)
Change-Id: Ieb5b11f004c6007813f48d4312a7d6e476bd6d79
2026-04-13 18:51:30 +08:00
haozhenghua-code
25534d72b5 fix(im): reject --user-id under bot identity for chat-messages-list (#340)
The chat_p2p/batch_query endpoint that resolves a user's p2p chat_id
requires user identity. Calling +chat-messages-list with --user-id
under bot identity previously failed silently or returned wrong
results.

- Validate: reject --user-id when runtime.IsBot(), with a hint to
  pass --as user or use --chat-id instead
- resolveP2PChatID: add defensive guard for the same condition in
  case the helper is reached via another path
- Update --user-id flag description and the lark-im skill reference
  to note the user-identity requirement
- Tests: add bot-rejection cases for Validate and resolveP2PChatID,
  switch p2p happy-path tests to a user-identity runtime helper
2026-04-13 17:54:10 +08:00
chenhuang
815db0c866 fix(mail): add missing scopes for mail +watch shortcut (#357)
* fix(mail): add missing event scope for mail watch

The mail +watch shortcut requires scope
mail:user_mailbox.event.mail_address:read to receive the mail_address
field in WebSocket event payloads, but this scope was neither declared
in the shortcut's Scopes list nor included in the auto-approve
(recommend.allow) set.

Without this scope, +watch events arrive without the mail_address field,
which breaks mailbox filtering and fetch-mailbox resolution.

- Add scope to mail +watch Scopes declaration
- Add scope to scope_overrides.json recommend.allow list so that
  auth login --recommend requests it automatically

* fix(mail): add missing mailbox profile scope for mail watch

The +watch shortcut calls fetchMailboxPrimaryEmail (GET
user_mailboxes/me/profile) to resolve the mailbox address for event
filtering, which requires scope mail:user_mailbox:readonly. All other
mail shortcuts that call this API (send, reply, forward, draft-create,
draft-edit) already declare this scope, but +watch did not.

* fix(mail): remove event scope from scope_overrides.json

The mail:user_mailbox.event.mail_address:read scope only needs to be
declared in the +watch shortcut's Scopes list, not in the global
recommend.allow set.
2026-04-13 17:22:28 +08:00
liujinkun2025
bb7957245b docs: add wiki member operations to lark-wiki skill (#417)
Change-Id: I5f8d930c25a650e26e7250269add2809b2b7f343
2026-04-13 14:33:14 +08:00
Tsai_Hui
3917b77e91 feat: add drive create-shortcut shortcut (#432) 2026-04-13 11:54:31 +08:00
wangzhengkui
dc0d92708b fix(mail): restrict --output-dir to current working directory (#376)
* fix(mail): restrict --output-dir to current working directory

Previously, mail +watch --output-dir accepted absolute paths (e.g.
/etc, /tmp) and home directory paths (~/), allowing writes to arbitrary
locations. Since mail content is sender-controlled, this posed a risk
of writing attacker-influenced data to sensitive system directories.

Now all --output-dir values go through validate.SafeOutputPath which:
- Rejects absolute paths and ~ expansion
- Resolves .. and symlinks
- Enforces the result stays under CWD

* fix(mail): reject tilde paths in --output-dir explicitly

SafeOutputPath treats ~/x as a literal relative path, silently creating
a directory named "~" under CWD. Reject ~ prefixed paths with a clear
error message instead.

* fix(mail): reject all tilde-prefixed paths and use ErrValidation

- Broaden ~ check from "~ || ~/" to "~" prefix, covering ~user/path forms
- Use output.ErrValidation for consistent error type (exit code 2)

* fix(mail): add post-mkdir EvalSymlinks + CWD re-verification (TOCTOU)

SafeOutputPath validates before MkdirAll, but an attacker could replace
the newly created directory with a symlink between mkdir and the first
write. Add EvalSymlinks after MkdirAll and re-verify the resolved path
is still under CWD.

Also broaden ~ rejection to all tilde-prefixed paths (~user/path) and
use output.ErrValidation for consistent error types.

* fix(mail): use validate.SafeOutputPath for post-mkdir TOCTOU check

Replace direct os.Getwd and filepath.EvalSymlinks calls with a second
SafeOutputPath call after MkdirAll. This satisfies the forbidigo lint
rule (no direct os/filepath calls in shortcuts/) while maintaining the
same TOCTOU protection.

* fix(mail): use original relative path for post-mkdir re-validation

SafeOutputPath rejects absolute paths, but after the first call
outputDir was already resolved to an absolute path. Pass the original
relative path to the second SafeOutputPath call so it can properly
re-validate after MkdirAll.

* fix(mail): remove redundant post-mkdir SafeOutputPath call

The second SafeOutputPath call after MkdirAll provided no real TOCTOU
protection: mail +watch is long-running, so the directory could be
replaced at any point during the session, not just between mkdir and
the check. The first SafeOutputPath already validates and resolves
the path; one call is sufficient.
2026-04-13 10:53:08 +08:00
Yuxuan Zhao
085ffd87f3 feat: add stable cli e2e tests (#401)
* feat: add stable bot-only cli e2e subset

Change-Id: I62edf59d179e407954f65f82e94cf5dcf4938080

* fix: address review comments on stable cli e2e tests

Change-Id: I4436100c30adf2694cd06953961f8d77f576fc1e

* fix: reduce flakiness in drive and im e2e helpers

Change-Id: I51e77d857f1fd9aec5ee34adf5045cc695239f21

* fix: document missing drive cleanup support

Change-Id: I3d4f034145bd69fb7640e707fcda05146b8754c7

* style: unify e2e cleanup comments

Change-Id: I40d906c9168754ad71ef9fb770ff4c340fc19beb

* test: update e2e assertions

Change-Id: I73c21b4b38d4ced7ea27cb327075957ec2b9a2a2

* test: stabilize cli e2e bot-only coverage

Change-Id: Ied897c37c4f42e446d55d110461aa34ae198195d
2026-04-12 16:52:41 +08:00
zero-my
f6b8091843 Feat/task section updates (#430)
* docs(task): document sections API resources and add URL parsing reminder

* feat(task): support --section-guid flag in tasklist-task-add shortcut

* docs(task): document sections API resources, permissions, and URL parsing
2026-04-12 16:12:16 +08:00
OwenYWT
0e7f507efb docs(lark-doc): clarify when markdown escaping is needed (#312)
* docs(lark-doc): clarify when markdown escaping is needed

* docs(lark-doc): fix escaped special character code span
2026-04-11 23:56:07 +08:00
liangshuo-1
1ff2dc578e chore: bump version to v1.0.9 and update changelog (#426)
Change-Id: I570d2f33d08c94d6df8daf78801be1bbcd252c3e
2026-04-11 22:18:31 +08:00
vanilla
69ae326d01 feat: add attendance user_task.query (#405)
Change-Id: Ie34b9b98859942ff368a9808fc2efab4d2bf27fa
2026-04-11 21:55:05 +08:00
ViperCai
e07842d3b5 feat(slides): return presentation URL in slides +create output (#425)
After creating the presentation, call drive batch_query (with_url=true)
to fetch the document URL and include it in the output. The fetch is
best-effort so it won't break creation if the API call fails.

Also update the skill reference doc to document the new optional url
return field.
2026-04-11 21:19:31 +08:00
ethan-zhx
a9c07cebb6 feat(slides): add slides +create shortcut with --slides one-step creation (#389)
Co-authored-by: caichengjie.viper <caichengjie.viper@bytedance.com>
2026-04-11 18:37:11 +08:00
caojie0621
f6a31e0853 feat(sheets): add dimension shortcuts for row/column operations (#413)
Add 5 new sheet shortcuts for row/column management:
- +add-dimension: append rows/columns at the end
- +insert-dimension: insert rows/columns at a position
- +update-dimension: update visibility and size
- +move-dimension: move rows/columns to a new position
- +delete-dimension: delete rows/columns

Includes unit tests (89-100% coverage) and skill reference docs.
2026-04-11 17:21:21 +08:00
liujinkun2025
bd5a33c0b7 feat(drive): add drive folder delete shortcut with async task polling (#415)
Change-Id: Ifb34f67296b800501a1b4960e02d5fed3382b84a
2026-04-11 16:47:03 +08:00
caojie0621
3242ca6f7f feat(sheets): add cell operation shortcuts for merge, replace, and style (#412)
Add 5 new sheet shortcuts for cell operations:
- +merge-cells: merge cells with MERGE_ALL/MERGE_ROWS/MERGE_COLUMNS
- +unmerge-cells: split merged cells
- +replace: find and replace cell values
- +set-style: set cell style (font, color, alignment, border)
- +batch-set-style: batch set styles for multiple ranges

Includes unit tests (81-89% coverage) and skill reference docs.
2026-04-11 16:45:14 +08:00
caojie0621
368ec7e753 docs(drive): add guide for granting document permission to current bot (#414) 2026-04-11 13:13:29 +08:00
liangshuo-1
9f81e7e567 feat: add RuntimeContext.BotInfo() for lazy bot identity retrieval (#409)
Add BotInfo() method on RuntimeContext that lazily fetches the current
app's bot open_id and display name from /bot/v3/info on first call,
cached via sync.OnceValues for the lifetime of the process.

- BotInfo struct (OpenID, AppName) in Identity section of runner.go
- fetchBotInfo() uses DoAPIAsBot for consistent header injection
- CanBot() on CliConfig gates the call when bot identity is unavailable
- Nil guard prevents panic in test contexts
- Full test coverage via httpmock.Registry + mounted shortcuts

Change-Id: I40ac710fb52d13939853f71827a5cbdbddd4f80f
2026-04-11 11:53:02 +08:00
zhicong666-bytedance
a00dfad56a feat: support minutes search (#359)
* feat: support minutes search by keyword and owner

* fix(minutes): align search output fields and clarify same-day queries

* fix(minutes): tighten search validation and output

* docs(vc): clarify recording usage examples

* test(minutes): remove redundant loop variable copies

* test(minutes): add docstrings for search tests

* refine minutes search params and skill routing

* minutes: refine search params payload and dry-run params feed

* skills: fix minutes search reference wording and vc link

* fix(minutes): align page-size cap to 30 and update tests

* skills: route meeting minutes lookup via vc first

* docs(skills): require shortcut reference reads
2026-04-11 06:31:10 +08:00
liangshuo-1
8c799d5a9f chore: release v1.0.8 (#408)
Change-Id: I3971cc32c35ce84b5ec5f1890a69e6fb02e0e022
2026-04-10 22:53:53 +08:00
dengfanxin
474cb30a48 docs(base): document Base attachment download via docs +media-download (#404)
* docs(base): document Base attachment download via docs +media-download

Base attachment files must be downloaded via 'lark-cli docs +media-download',
not 'lark-cli drive +download' (which returns HTTP 403). The existing
lark-doc reference already documents the command thoroughly, so this PR
just adds entries to the lark-base skill that reference it.

- SKILL.md: add download row to field classification, routing, and record
  commands tables, referencing lark-doc-media-download.md
- references/lark-base-record.md: add download entry to the command
  navigation table and notes, referencing lark-doc-media-download.md

* docs: add output flag to base attachment download examples

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-10 22:13:48 +08:00
huangxincola
e8e0c6fc5a Add +dashboard-arrange command for auto-arranging dashboard blocks layout and introduce text block type with Markdown support for dashboard visualization. (#388)
- Add `+dashboard-arrange` command that triggers server-side smart layout optimization via POST /open-apis/base/v3/bases/{token}/dashboards/{id}/arrange
- Add `text` block type support for dashboard blocks with Markdown syntax (headers, bold, italic, strikethrough, lists)
- Update `validateBlockDataConfig()` to handle text-specific validation rules
- Update documentation (SKILL.md, lark-base-dashboard.md, dashboard-block-data-config.md, lark-base-dashboard-arrange.md)
- Add comprehensive unit tests for new commands and block type
- [x] Unit tests pass (`go test ./shortcuts/base/...`)
- [x] All dashboard-related tests pass including new `TestBaseDashboardExecuteArrange`
- [x] Text block type validation tests pass
- None
2026-04-10 21:05:37 +08:00
calendar-assistant
b8f71d50d1 feat(calendar): add room find workflow (#403)
Fix room-find multi-slot verification.

Change-Id: I3ba4c8dbe30bbb1eb12c0996bb8bc5d54e6339ca
2026-04-10 21:01:00 +08:00
syh-cpdsss
46468a900c feat: Add whiteboard +query shortcut and enhance +update with Mermaid/PlantUML support (#382)
Change-Id: I719935bb8fee337908ec99d59f1dfaae0df74874
2026-04-10 19:40:29 +08:00
zhouyue-bytedance
f59f263138 docs: reorganize lark-base skill guidance (#374)
* docs: reorganize lark-base skill guidance

* docs: condense lark-base command tables

* docs: tighten lark-base shared guidance

* docs: refine lark-base routing guidance

* Merge origin/main into docs/lark-base-skill-structure
2026-04-10 18:32:03 +08:00
wittam-01
51d07be18a feat: support file comment reply reactions (#380)
Change-Id: Ib75a35c438dc1c1aac32077ccc04a0de2ffef145
2026-04-10 18:22:30 +08:00
MaxHuang22
344ff88701 feat: add --file flag for multipart/form-data file uploads (#395)
* feat(cmdutil): add shared file upload helpers

Add ParseFileFlag, ValidateFileFlag, and BuildFormdata to support
multipart file upload via --file flag across raw API and meta API commands.

Change-Id: Ib724cf8b055b0b314af11d8d830f38559dac60eb

* feat(api): add --file flag for multipart/form-data file uploads

Add --file flag to `lark-cli api` command enabling file upload via
multipart/form-data. The flag accepts [field=]path format and supports
stdin (-). Includes mutual exclusion validation with --output,
--page-all, and GET method. Dry-run mode shows file metadata instead
of building actual formdata.

Change-Id: Icf34aba5da3a558219a97a583e8f6aa951ded199

* feat(service): add --file flag with auto-detection from metadata

Add file upload support to meta API service method commands. The --file
flag is conditionally registered only for methods whose metadata declares
file-type fields (POST/PUT/PATCH/DELETE). The default field name is
auto-detected from metadata when exactly one file field exists.

Change-Id: Ibbf04eb42341ba11bb1fd9750e63bc1d0eacd08d

* feat(schema): show file upload indicators in method detail display

Add hasFileFields helper to detect file-type fields in requestBody
metadata. Modify printMethodDetail to display [file upload] tag on
--data line, --file flag description with default field name, and
--file <path> in CLI example for methods that accept file uploads.

Change-Id: Iae3bc14fe07e16a8b5f6a50a2b3592d6d8490ed9

* fix: address code review findings for file upload feature

- ParseFileFlag: change idx >= 0 to idx > 0 to prevent empty field name
  when input like "=photo.jpg" is passed
- BuildFormdata: read file into bytes.Reader with defer Close to prevent
  file handle leak on later errors
- BuildFormdata: remove unused ctx parameter from signature and callers
- Eliminate duplicated dry-run logic by having buildAPIRequest and
  buildServiceRequest return FileUploadMeta when in dry-run mode,
  removing ~60 lines of copy-pasted URL building and validation code

Change-Id: I27b9534fd0eaefce40390f6e723dd0c04a2cdf80

* fix: address PR review findings

- Remove opts.File=="" guard on dual-stdin check so --file photo.jpg
  --params - --data - correctly reports an error instead of silently
  dropping --data content (P1 bug in both api.go and service.go)
- Extract shared DetectFileFields into cmdutil, deduplicate
  detectFileFields (service.go) and hasFileFields (schema.go)
- Show "<stdin>" instead of empty path in dry-run output for --file -

Change-Id: Iccc5d879165ea6a3d04f0425ec6a5018a10e72e1

* fix: reject non-object --data with --file and improve multi-file schema

- --data with --file now requires a JSON object; arrays/strings/numbers
  are rejected with a clear error instead of being silently dropped
- Schema display for multi-file methods shows explicit field=path syntax
  and lists valid field names instead of advertising a false default

Change-Id: I0facdb3ad86f68cb125c7ea109a33714fd91dba0
2026-04-10 17:49:41 +08:00
liangshuo-1
78ff1e7968 feat: add update command with self-update, verification, and rollback (#391) 2026-04-10 17:47:42 +08:00
kongenpei
fa16fe1976 feat(base): add record batch add/set shortcuts (#277)
* feat(base): add record batch add/set shortcuts

* docs: clarify record batch add/set input guidance

* docs: mark base shortcut references as required before calling

* fix(base): remove stale token stub calls in batch record tests

* feat(base): rename record batch add/set to create/update

* refactor(base): remove noop record json validators

* test(base): align record validate test with nil hooks

* fix: align base record batch shortcuts with openapi routes

* fix(base): pass parse context for record batch JSON parsing

* docs: move base record batch JSON guidance to tips

* refactor: remove noop record validate

* docs: remove has_more from batch update guide

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-10 17:39:54 +08:00
kongenpei
d8b0865814 feat(base): add +record-search for keyword-based record search (#328)
* feat(base): add +record-search json passthrough shortcut

* docs(base): refine record-search wording and field constraints

* docs(base): prefer record-list unless keyword is explicit

* refactor(base): inline record-search parsing and align tests

* refactor(base): remove noop record validate hook

* docs(base): unify record example token placeholders

* fix: align record search JSON parsing with parse context

* feat: add help tips for base record search

* docs: refine base record search reference

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-10 17:18:41 +08:00
kongenpei
d026741532 feat(base): add view visible fields get/set shortcuts (#326)
* feat: add base view visible fields shortcuts and docs

* docs: add view-create guidance for visible fields read

* docs(base): refine visible fields reference wording

* refactor(base): remove noop validate hook from view-set-visible-fields

* docs: unify view-set-visible-fields example placeholders

* docs: update visible fields example field placeholder

* fix(base): pass parse context in view-set-visible-fields

* feat: add tips for view-set-visible-fields json usage

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-10 16:37:08 +08:00
kongenpei
cd7a2363e5 feat(base): add record field filters (#327)
* feat(base): add record field filters

* fix(base): align record field filter flags with OpenAPI params

* fix: scope record dry-run field filters and align docs

* docs(base): clarify record-list field_scope priority

* refactor(base): remove field-id from record-get

---------

Co-authored-by: zgz2048 <zhonggangzhi.tim@bytedance.com>
Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-10 16:30:54 +08:00
kongenpei
353c473e52 fix(base): return raw table list response and clarify sort help (#393)
Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-10 16:28:55 +08:00
MaxHuang22
76fac115ed feat(registry): update scope priorities from scope platform (#385)
Sync latest scope list from the scope platform:
- 10 scopes added, 3 removed, 1087 score changes
- Net +5 recommend=true scopes (286 -> 291)
- Update scope_overrides.json adjustments

Change-Id: I3304127f83d6b14d158b5f171b1aae2e9f4d1af9
2026-04-10 15:02:06 +08:00
JackZhao10086
d2a834051d fix: improve error hints for sandbox and initialization issues (#384)
* fix(keychain): improve error hint for keychain initialization

Clarify the error message for uninitialized keychain by combining both possible scenarios (sandbox/CI environment and normal usage) into a single hint to avoid confusion.

* docs(keychain): improve error message hints for sandbox environments

Add suggestion to try running outside sandbox when keychain access fails. Also update hint for uninitialized keychain case to include same suggestion.

* docs(keychain): fix grammar in error message hints

* docs(keychain): fix typo in error message hint
2026-04-10 14:54:29 +08:00
zhouyue-bytedance
d30a9472c3 Revert "Add +dashboard-arrange command for auto-arranging dashboard blocks …" (#386)
This reverts commit b8fa2b3f80.
2026-04-10 14:41:10 +08:00
huangxincola
b8fa2b3f80 Add +dashboard-arrange command for auto-arranging dashboard blocks layout and introduce text block type with Markdown support for dashboard visualization. (#341)
- Add `+dashboard-arrange` command that triggers server-side smart layout optimization via POST /open-apis/base/v3/bases/{token}/dashboards/{id}/arrange
- Add `text` block type support for dashboard blocks with Markdown syntax (headers, bold, italic, strikethrough, lists)
- Update `validateBlockDataConfig()` to handle text-specific validation rules
- Update documentation (SKILL.md, lark-base-dashboard.md, dashboard-block-data-config.md, lark-base-dashboard-arrange.md)
- Add comprehensive unit tests for new commands and block type
- [x] Unit tests pass (`go test ./shortcuts/base/...`)
- [x] All dashboard-related tests pass including new `TestBaseDashboardExecuteArrange`
- [x] Text block type validation tests pass
- None
2026-04-10 14:34:10 +08:00
calendar-assistant
6ec19cbc84 fix(calendar): add default video meeting to +create (#383)
Change-Id: Ib3ee2f393a7b81f37f5d736c009235f9acefe9f9
2026-04-10 12:34:37 +08:00
yballul-bytedance
d7363b0481 feat(base): optimize workflow skills (#345)
Change-Id: I70bce656feea6af54b3366db3e71eea8f1d5b47b
2026-04-10 12:29:14 +08:00
kongenpei
5f3915b25c fix: return raw base field and view responses (#378)
Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-10 11:09:15 +08:00
MaxHuang22
4e65ea808e feat: add scope snapshot test for minimum-privilege scope audit (#370)
Add cmd/diagnose_scope_test.go that exports a JSON snapshot of all API
methods and shortcuts with their minimum-privilege scopes, identity
support, auto-approve status, and scope_priorities coverage. Consumed
by scripts/scope_audit.py for diff and reporting.
2026-04-10 11:03:58 +08:00
91-enjoy
d7262b7dc5 feat: markdown support line breaks (#338)
Change-Id: Ie6b56b6302027f42e869d087d7ca4e94b99afda9
2026-04-10 11:00:29 +08:00
chenhuang
c16a021ac6 fix(mail): replace os.Exit with graceful shutdown in mail watch (#350)
* fix(mail): replace os.Exit with graceful shutdown in mail watch

The signal handler in mail +watch called os.Exit(0), which bypassed all
deferred cleanup functions, made the code path untestable, and did not
follow Go's idiomatic context cancellation pattern.

Key changes:
- Remove os.Exit(0) and use context.WithCancel to propagate shutdown
- Run cli.Start in a separate goroutine so the main goroutine can return
  immediately on signal receipt (the Lark WebSocket SDK does not return
  promptly after context cancellation)
- Extract handleMailWatchSignal as a testable standalone function
- Use sync.Once + defer for idempotent cleanup on all exit paths
- Fix eventCount data race with atomic.Int64
- Add signal.Reset to support forced termination via a second Ctrl+C

Closes #268

* docs: add docstrings to handleMailWatchSignal test functions

* fix(mail): cancel watch context on signal handler panic

If handleMailWatchSignal panics, the recover block now calls
cancelWatch() to unblock the main select. Without this, a panic
would leave shutdownBySignal unclosed and watchCtx uncancelled,
causing the process to hang.

* fix(mail): use triggerShutdown to unblock main select on signal handler panic

The previous panic recovery only called cancelWatch(), but since the
WebSocket SDK does not return promptly after context cancellation,
the main select could still hang waiting on startErrCh.

Introduce triggerShutdown() that closes shutdownBySignal (via
sync.Once) and cancels the watch context, used by both the normal
signal path and the panic recovery path. This ensures the main
select unblocks immediately regardless of how the signal goroutine
exits.

Add regression test that forces a panic and asserts shutdownBySignal
is closed promptly.
2026-04-09 21:57:02 +08:00
wangzhengkui
fd9ee6afd6 feat(mail): add --page-token and --page-size to mail +triage (#301)
* feat(mail): add --page-token and --page-size pagination support to mail +triage

Support external pagination for mail +triage with two new flags:
- --page-token: resume from a previous response's page token
- --page-size: alias for --max

Token carries a "search:" or "list:" prefix to identify the API path,
with strict validation: conflicting parameters (e.g. list: token with
--query) fail fast, and bare tokens without prefix are rejected.

JSON/data output now returns an object with messages, total, has_more,
and page_token fields. Table output shows next-page hint on stderr.

* fix(mail): address PR review — keep data format as array, fix whitespace query edge case

- --format data preserves backward-compatible flat array output
- --format json returns the new envelope object with pagination fields
- Align search: prefix guard with TrimSpace(query) to match usesTriageSearchPath

* fix(mail): simplify page-token format and fix page-size change data loss

- Remove page_size encoding from token (search:abc → not search:5:abc)
  The search API token is a session cursor; page_size only controls how
  many items to return, not the cursor position. Encoding page_size
  caused data loss when users changed --page-size between requests.
- Token format is now simply "search:<raw>" / "list:<raw>"
- Add parseTriagePageToken/encodeTriagePageToken helpers for clean
  token handling with proper validation
- next page hint in table output now includes --query and --filter
  for easy copy-paste continuation

* docs(mail): update triage skill doc for json/data format split and search pagination note

- Separate --format json (object with pagination) and --format data (array) examples
- Update table next-page hint example to show --query/--filter inclusion
- Add search pagination caveat about cross-session result ordering

* fix(mail): make --format data include pagination fields same as json

* fix(mail): address remaining PR review comments

- Reject empty prefixed tokens (search: / list:) in parseTriagePageToken
- Shell-escape query/filter in next-page hint to handle single quotes
- Fix doc caption mismatch (data → json/data) and add language tag to code block
- Fix test comment for TestResolveTriagePageSizeDefaultMax

* fix(mail): rename total to count in triage pagination output

total was misleading — it represented the current page count, not the
global total. Renamed to count to match len(messages) semantics.

* fix(mail): improve dry-run desc when using --page-token
2026-04-09 21:39:12 +08:00
liangshuo-1
69cf9f206e chore: release v1.0.7 (#375)
Change-Id: I0568fc87795a821802fe793802fc64ac55def6d6
2026-04-09 21:35:34 +08:00
wittam-01
99b8aaa556 feat: improve doc media extension inference (#364)
Change-Id: Ifc7c0e7844908b88e2d527e0933d080b140a50eb
2026-04-09 21:11:47 +08:00
kongenpei
b4a26b2cdc fix(base): unify --json help format with tips and agent hints (#372)
* fix(base): improve --json help examples and group guide

* fix(base): unify --json help tips format

* docs(base): fix view-set-group schema with group_config

* fix(base): remove array wording from view-set-group json help

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-09 21:06:56 +08:00
liangshuo-1
619ec8c2cb fix(api): support stdin and quoted JSON inputs on Windows (#367)
* fix(api): add stdin and single-quote support for --params/--data on Windows (#64)

Windows PowerShell 5.x mangles JSON double-quotes when passing arguments
to native executables, causing --params and --data to fail with
"invalid JSON format". This commit adds two mitigations at the framework
level:

- stdin piping: `echo '{"k":"v"}' | lark-cli --params -` bypasses
  shell argument parsing entirely and works on all platforms/shells.
- single-quote stripping: cmd.exe passes literal single quotes which
  are now transparently removed before JSON parsing.

Implementation:
- New `cmdutil.ResolveInput(raw, stdin)` handles `-` (stdin), strip
  surrounding `'...'`, and plain passthrough.
- `ParseJSONMap` and `ParseOptionalBody` now accept an `io.Reader` and
  delegate to `ResolveInput` before JSON unmarshalling.
- `cmd/api` and `cmd/service` pass `IOStreams.In` and guard against
  simultaneous stdin usage by --params and --data.
- Empty stdin is rejected with a clear error message.

Closes #64

Change-Id: If21e735d0aed5c6a2d6674c1e6c898186fca3aba

* test: add stdin e2e regression coverage

Change-Id: I4e00bf1c6b6f3259f503e3414cae10fa4b34ba75
2026-04-09 19:10:50 +08:00
liujinkun2025
eb3c643f0b route base import guidance to drive +import (#368)
Change-Id: I12f86a343d79b8fb480084305ed34b54aa92fb94
2026-04-09 18:22:05 +08:00
wittam-01
37747177fc feat: auto-grant current user access for bot-created docs, sheets, imports, and uploads (#360)
Change-Id: Idf5b35dbf77d72788895e0a3c34563281d658c88
2026-04-09 18:06:47 +08:00
河伯
9d48ef422b fix(doc): post-process docs +fetch output to improve round-trip fidelity (#214) 2026-04-09 17:57:41 +08:00
tuxedomm
e64d24580a refactor: migrate mail shortcuts to FileIO (#356)
* refactor: migrate mail shortcuts to FileIO

- DraftSnapshot.FIO: inject FileIO into draft snapshot for patch ops
  (addAttachment, loadAndAttachInline, replaceInline)
- emlbuilder.Builder.fio: inject via WithFileIO(), readFile uses FileIO.Open
- mail_draft_edit: loadPatchFile uses runtime.FileIO().Open
- helpers: checkAttachmentSizeLimit takes fio param, uses FileIO.Stat
- validateComposeInlineAndAttachments: pass fio through to size check
- All mail entry points (send/reply/reply_all/forward/draft_create):
  pass runtime.FileIO() to builder and size limit checks
2026-04-09 17:40:30 +08:00
sang-neo03
3db4f42ab8 fix(run): add missing binary check for lark-cli execution (#362) 2026-04-09 17:09:53 +08:00
tuxedomm
0bf4f80ef4 refactor: migrate drive/doc/sheets shortcuts to FileIO (#339)
* refactor: migrate drive/doc/sheets shortcuts to FileIO

- drive_download/upload/import/export: SafeInputPath/SafeOutputPath +
  vfs.Stat/Open/MkdirAll + AtomicWrite → FileIO.Stat/Open/Save
- doc_media_download/insert/upload: same migration pattern
- sheet_export: same migration pattern
- Add Mode() fs.FileMode to fileio.FileInfo for IsRegular() checks
- Add WrapInputStatError helper to preserve error message fidelity
- Add WrapSaveErrorByCategory for standardized save error mapping
2026-04-09 16:34:59 +08:00
feng zhi hao
284e5b6606 feat(mail): add send_as alias support, mailbox/sender discovery APIs, and mail rules API
New capabilities:

  1. Alias (send_as) sending for all compose shortcuts (+send, +reply, +reply-all,
     +forward, +draft-create, +draft-edit):
     - New --mailbox flag separates mailbox routing from sender identity, enabling
       alias sending where --mailbox specifies the owning mailbox and --from
       specifies the alias address in the From header.
     - Example: --mailbox me --from alias@example.com --to bob@example.com
     - --mailbox priority: --mailbox > --from > "me"
     - --from priority: --from > --mailbox > profile("me")

  2. Discovery APIs for available mailboxes and sender addresses:
     - accessible_mailboxes: lists all mailboxes the user can access (primary + shared)
     - send_as: lists available sender addresses for a mailbox (primary, aliases, mailing lists)

  3. Mail rules API:
     - user_mailbox.rules resource: create, delete, list, reorder, update

  4. Reply-all self-exclusion improvement:
     fetchSelfEmailSet now also excludes the --from alias address, preventing the
     sender from appearing in the recipient list when replying via an alias.

  No breaking changes — omitting --mailbox preserves existing behavior.
2026-04-09 14:52:20 +08:00
maochengwei1024-create
af83e5495b fix(config): validate appId and appSecret keychain key consistency (#295)
When config.json is hand-edited, the appId field can become out of sync
with the appSecret keychain reference (e.g. appId changed but
appSecret.id still points to the old app). This causes silent auth
failures at API call time. Add a pre-flight check in
ResolveConfigFromMulti that compares the two before any keychain lookup
or OAPI request, failing fast with actionable guidance.

Change-Id: I74b9ab640642dde3df1ad70890b93b91ee422022
2026-04-09 12:05:24 +08:00
tuxedomm
a3bced3ee5 refactor: migrate base shortcuts to FileIO (#347)
* refactor: migrate base shortcuts to FileIO

- loadJSONInput: SafeInputPath + vfs.ReadFile → fio.Open + io.ReadAll
- parseJSONObject/parseJSONArray/parseJSONValue/parseObjectList/
  parseStringListFlexible: add fio param, pass through to loadJSONInput
- parseStringList: inline comma-split (no longer depends on fio)
- record_upload_attachment: SafeInputPath + vfs.Stat → FileIO.Stat
  with ErrPathValidation check; vfs.Open → FileIO.Open
- All ops files pass runtime.FileIO() to parse helpers
2026-04-09 11:54:58 +08:00
max
35108e1798 feat(vc): extract note doc tokens from calendar event relation API (#333)
* feat(vc): extract meeting_notes and ai_meeting_notes from calendar event relation API

* test(vc): add tests for calendar-to-notes dedup and fallback logic

* fix(vc): address review findings for calendar-to-notes dedup and table output

* refactor(vc): remove ai_meeting_notes concept and simplify dedup logic
2026-04-09 11:20:58 +08:00
tuxedomm
30b97e1bdd chore: add depguard and forbidigo rules to guide FileIO adoption (#342)
- Add depguard linter to block shortcuts/ from importing internal/vfs
  directly (must use runtime.FileIO() instead)
- Add forbidigo rules for os.* filesystem ops, IO streams, os.Exit,
  and filepath.* functions that bypass vfs
- Split os.Remove / os.RemoveAll into separate patterns with accurate
  guidance (RemoveAll not yet in vfs)
- Use compact regex groups for maintainability, no duplicate or
  shadowed patterns

Change-Id: I9e45ab07ca58a61b86bdcea9f1f2cc6181c974bc
2026-04-09 10:56:17 +08:00
liujinkun2025
2715b560b7 add wiki node create shortcut (#320)
Change-Id: I4810fc541c31ae9e3e08539d4b1c91d01f53b7f5
2026-04-09 00:06:57 +08:00
caojie0621
15bd134f5c feat: add sheets +write-image shortcut (#343) 2026-04-08 23:59:39 +08:00
wittam-01
daa21731ad feat: add docs media-preview shortcut (#334)
Change-Id: I5db9e52008e175f975838c8a9c03254afa30f52b
2026-04-08 23:52:24 +08:00
wittam-01
9fab62bf00 feat: add support for additional search filters (#353)
Change-Id: Ib5b06e2df513a835a79a295c45ef1637413afa4e
2026-04-08 23:42:13 +08:00
liangshuo-1
cdd9f9ab49 chore: add missing license headers (#352)
Change-Id: Ic26bedcbb111331eb53d695fccdabd0907a6272f
2026-04-08 23:11:01 +08:00
ygxs
d5d31f0ee4 docs(lark-doc): document advanced boolean and intitle search syntax for AI agents (#210)
Change-Id: I647ffad4579c503711a7ea220c390dca760cd6de
2026-04-08 22:01:10 +08:00
yaozhen00
67cb0a961e ci: add license-header check (#250)
* ci: add license-header check
2026-04-08 21:57:59 +08:00
liangshuo-1
aa4076a7cc docs: add v1.0.6 changelog (#348)
Change-Id: Ic5a1d128c9ec903c0e1a9a673f7da8340e775dc0
2026-04-08 21:57:20 +08:00
JackZhao10086
db9ca5c2a4 feat: improve login scope validation and success output (#317)
* feat(auth): improve scope handling and output in login flow

- Add scope validation to check for missing requested scopes
- Implement detailed scope breakdown in login success output
- Add new message strings for scope-related output
- Refactor login success output to handle both JSON and text formats
- Add tests for scope validation and output scenarios

* feat(auth): add requested scope caching for device code login

Implement caching of requested scopes during device code login flow to ensure proper scope validation after authorization. The cache is stored in JSON files under config directory and automatically cleaned up after successful or failed authorization.

Add tests for scope caching functionality and verify proper integration with existing login flow.

* docs(auth): add function comments for login scope handling

Add detailed doc comments to all functions in login scope cache and result handling files to improve code documentation and maintainability.

* refactor(auth): remove pending scopes and improve json output stability

- Remove PendingScopes field and related logic as it's no longer needed
- Add emptyIfNil helper to ensure nil slices are normalized to empty slices in JSON output
- Update tests to verify JSON output stability and fix expected text outputs

* refactor(auth): extract device token polling function for testability

Move device token polling to a package-level variable to enable mocking in tests
Add test case for scope cleanup when token is nil

* fix(auth): return JSON write errors instead of ignoring them

Previously, JSON write errors were only logged to stderr but not returned, causing tests to pass when they should fail. Now properly propagate these errors to callers and update tests to verify error handling.

* refactor(auth): simplify scope handling and improve user messaging

remove redundant scope display and consolidate hint messages to focus on actionable guidance

* refactor(auth): improve scope handling and messaging in login flow

remove ShortHint field and simplify scope hint messages
always display missing scopes section with consistent formatting
add StatusHint for successful login with no missing scopes
update tests to reflect new message structure and content
2026-04-08 21:06:58 +08:00
ILUO
1f8d4b211d feat(task): support starting pagination from page token (#332) 2026-04-08 21:06:43 +08:00
tuxedomm
63ea52b2e6 refactor: migrate vc/minutes shortcuts to FileIO (#336)
* refactor: migrate vc/minutes shortcuts to FileIO

- vc_notes: replace vfs.Stat + validate.SafeOutputPath + validate.AtomicWrite
  with FileIO.Stat/Save for transcript download
- minutes_download: replace validate.SafeOutputPath + validate.AtomicWriteFromReader
  with FileIO.Save, use FileIO.Stat for overwrite checks
- Use WrapSaveError to preserve original error messages
2026-04-08 19:34:19 +08:00
liangshuo-1
555722ac8e fix: resolve concurrency races in RuntimeContext (#330)
* fix: resolve concurrency races in RuntimeContext

- getAPIClient: replace check-then-act with sync.OnceValues, matching
  the factory_default.go convention; use NewAPIClientWithConfig to avoid
  post-construction config override; fall back to direct construction
  for test contexts that bypass newRuntimeContext.

- outputErr: guard first-error capture with sync.Once to prevent data
  races if Out() is ever called from concurrent goroutines.

Change-Id: I99c94c3dcb7663fa61571c9720163e41a5fc0e36

* fix: use tenant token for auth scopes

Change-Id: I83bb677e9a33e906e207679b2ba8d0364bc20fe3
2026-04-08 19:14:45 +08:00
tuxedomm
f5a8fbf8f1 refactor: migrate common/client/im to FileIO and add localfileio tests (#322)
* refactor: migrate common/client/im to FileIO and add localfileio tests

- runner resolveInputFlags: replace validate.SafeInputPath + vfs.ReadFile
  with FileIO.Open + io.ReadAll
- SaveResponse: delegate to FileIO.Save + ResolvePath
- cmd/api, cmd/service: pass FileIO to ResponseOptions
- im: replace validate.SafeLocalFlagPath with RuntimeContext.ValidatePath,
  migrate download/upload to FileIO.Save/Open/Stat
- Add path_test.go and atomicwrite_test.go for localfileio
- Add validate_media_test.go for im media flag validation
- Adapt test mocks to fileio.FileInfo interface
2026-04-08 17:31:21 +08:00
OwenYWT
adef52ada5 fix(config): save empty config before clearing keychain entries (#291)
* fix(config): save empty config before clearing keychain entries
2026-04-08 16:34:50 +08:00
liujinkun2025
6ac5b4d566 support multipart doc media uploads (#294)
Change-Id: I9d9fb00079dacfc96b5781e12e6ce79945baa2ed
2026-04-08 15:43:15 +08:00
MaxHuang22
7158dc2f3c fix: reject positional arguments in shortcuts (#227)
* fix: reject positional arguments in shortcuts with clear error

Shortcuts silently ignored positional arguments (e.g. `lark-cli docs
+search "hello"`), causing empty results. Add Args validator to all
declarative shortcuts so cobra prints usage and a clear error message
telling users to pass values via flags instead.

Change-Id: I7579f9c871138cf91dd5f5d8c1d51bda3f77a1db

* fix: address PR review comments

- Remove unused *Shortcut parameter from rejectPositionalArgs
- Show all positional args in error message instead of only the first
- Add test case for multiple positional arguments

Change-Id: Ifea92d09ddabcd35fbf2db98d9888d18af59b894
2026-04-08 15:11:36 +08:00
feng zhi hao
c54a1354a0 feat(mail): auto-resolve local image paths in all draft entry points (#205)
All draft-related shortcuts now support <img src="./local.png"> in --body,automatically resolving relative paths into cid: inline MIME parts. Only relative paths are supported; absolute paths are rejected. Previously only +draft-edit supported this; now extended to +draft-create, +send, +reply, +reply-all, and +forward.
2026-04-08 14:36:01 +08:00
Vux
a73c9ae27e fix: improve raw API diagnostics for invalid or empty JSON responses (#257)
- Add internal/client/api_errors.go with WrapDoAPIError and WrapJSONResponseParseError to classify JSON decode issues vs generic network errors
- Route cmd/api DoAPI errors and HandleResponse JSON parse errors through the new helpers
- Add regression tests in cmd/api and internal/client

Related: https://github.com/larksuite/cli/issues/215
2026-04-08 14:28:02 +08:00
tuxedomm
900c12ce8d feat: add FileIO extension for file transfer abstraction (#314)
* feat: add FileIO extension for file transfer abstraction

Introduce extension/fileio package with Provider/FileIO/File interfaces
and a global registry, following the same pattern as extension/credential.

- Add LocalFileIO default implementation with path validation and atomic writes
- Wire FileIOProvider into Factory and resolve at runtime via RuntimeContext.FileIO()
- Factory holds Provider (not resolved instance), deferring resolution to execution time
2026-04-08 14:13:59 +08:00
JackZhao10086
f3c3a4c49f feat: support custom data dir and log directories (#302)
* feat: linux support custom data dir via environment variable

* feat(keychain): support custom log directory via LARKSUITE_CLI_LOG_DIR

* feat(security): validate env dir paths for security

Add validation for environment variable directory paths to ensure they are absolute and safe. This prevents potential security issues from malformed paths. Also add corresponding tests to verify the validation behavior.

* docs(validate): add function and test documentation comments

Add missing documentation comments for SafeEnvDirPath function and related test cases to improve code clarity and maintainability

* refactor(keychain): remove warning logs for invalid env vars
2026-04-08 11:06:58 +08:00
max
2e345a4fdd feat(vc): add +recording shortcut for meeting_id to minute_token conversion (#246)
* feat(vc): add +recording shortcut for meeting_id to minute_token conversion

* fix(vc): address PR review feedback for +recording shortcut

* docs(vc): merge Recording and Minutes in resource diagram as they share minute_token

* docs(vc): simplify resource diagram to use Minutes only

* test(vc): add integration eval for +recording execute paths

* docs(vc): fix +recording description to include both input modes

* fix(vc): address review findings for +recording docs and code consistency
2026-04-08 11:02:24 +08:00
eggyrooch-blip
78bc66ce14 fix(docs): normalize board_tokens in +create response for mermaid/whiteboard content (#10)
+update already calls normalizeDocsUpdateResult to surface board_tokens when
markdown contains mermaid/plantuml/whiteboard blocks. +create was missing the
same call, so callers could not know how many whiteboards were created or
retrieve their tokens. One-line fix: call normalizeDocsUpdateResult after
CallMCPTool in DocsCreate.Execute.
2026-04-08 11:01:45 +08:00
zero-my
fe8da8d924 Fix/task get my tasks complete flag help (#310)
* docs: clarify --complete flag behavior in get-my-tasks reference

* fix: clarify complete flag description in get-my-tasks command
2026-04-08 10:33:22 +08:00
zero-my
12bb01addf docs: clarify --complete flag behavior in get-my-tasks reference (#308) 2026-04-08 09:53:13 +08:00
OwenYWT
d6fada01f5 fix(help): point root help Agent Skills link to README section (#289) 2026-04-07 23:24:24 +08:00
497 changed files with 57923 additions and 9397 deletions

View File

@@ -25,6 +25,8 @@ on:
permissions:
contents: read
actions: read
checks: write
jobs:
cli-e2e:
@@ -65,71 +67,17 @@ jobs:
echo "No CLI E2E packages to test after exclusions."
exit 1
fi
go run gotest.tools/gotestsum@v1.12.3 --format testname --junitfile cli-e2e-report.xml -- -count=1 -v $packages
# gotestsum requires --packages when --rerun-fails is combined with go test args after --.
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
- name: Summarize CLI E2E test report
- name: Publish CLI E2E test report
if: ${{ !cancelled() }}
run: |
python3 - <<'PY'
import os
import xml.etree.ElementTree as ET
report_path = "cli-e2e-report.xml"
summary_path = os.environ["GITHUB_STEP_SUMMARY"]
root = ET.parse(report_path).getroot()
suites = [root] if root.tag == "testsuite" else root.findall("testsuite")
tests = failures = errors = skipped = 0
failed_cases = []
skipped_cases = []
for suite in suites:
tests += int(suite.attrib.get("tests", 0))
failures += int(suite.attrib.get("failures", 0))
errors += int(suite.attrib.get("errors", 0))
skipped += int(suite.attrib.get("skipped", 0))
for case in suite.findall("testcase"):
classname = case.attrib.get("classname", "")
name = case.attrib.get("name", "")
label = f"{classname}.{name}" if classname else name
failure = case.find("failure")
error = case.find("error")
skipped_node = case.find("skipped")
if failure is not None or error is not None:
message = ""
node = failure if failure is not None else error
if node is not None:
message = node.attrib.get("message", "") or (node.text or "").strip()
failed_cases.append((label, message))
elif skipped_node is not None:
message = skipped_node.attrib.get("message", "") or (skipped_node.text or "").strip()
skipped_cases.append((label, message))
passed = tests - failures - errors - skipped
with open(summary_path, "a", encoding="utf-8") as f:
f.write("## CLI E2E Test Report\n\n")
f.write(f"- Total: {tests}\n")
f.write(f"- Passed: {passed}\n")
f.write(f"- Failed: {failures}\n")
f.write(f"- Errors: {errors}\n")
f.write(f"- Skipped: {skipped}\n\n")
if failed_cases:
f.write("### Failed Tests\n\n")
for label, message in failed_cases:
detail = f" - {message}" if message else ""
f.write(f"- `{label}`{detail}\n")
f.write("\n")
if skipped_cases:
f.write("### Skipped Tests\n\n")
for label, message in skipped_cases:
detail = f" - {message}" if message else ""
f.write(f"- `{label}`{detail}\n")
f.write("\n")
PY
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: CLI E2E Tests
path: cli-e2e-report.xml
reporter: java-junit
use-actions-summary: true
list-suites: all
list-tests: all

26
.github/workflows/license-header.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: License Header
on:
pull_request:
branches: [main]
paths:
- "**/*.go"
- "**/*.js"
- "**/*.py"
- .licenserc.yaml
- .github/workflows/license-header.yml
permissions:
contents: read
pull-requests: write
jobs:
header-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Check license headers
uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad
with:
config: .licenserc.yaml

2
.gitignore vendored
View File

@@ -31,6 +31,8 @@ tests/mail/reports/
/log/
# Generated / test artifacts
internal/registry/meta_data.json
cmd/api/download.bin
app.log

View File

@@ -27,6 +27,7 @@ linters:
- reassign # checks that package variables are not reassigned
- unconvert # removes unnecessary type conversions
- unused # checks for unused constants, variables, functions and types
- depguard # blocks forbidden package imports
- forbidigo # forbids specific function calls
# To enable later after fixing existing issues:
@@ -45,6 +46,7 @@ linters:
linters:
- bodyclose
- gocritic
- depguard
- forbidigo
- path-except: (shortcuts/|internal/)
linters:
@@ -54,79 +56,56 @@ linters:
- forbidigo
settings:
depguard:
rules:
shortcuts-no-vfs:
files:
- "**/shortcuts/**"
deny:
- pkg: "github.com/larksuite/cli/internal/vfs"
desc: >-
shortcuts must not import internal/vfs directly.
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
- pkg: "github.com/larksuite/cli/internal/vfs/localfileio"
desc: >-
shortcuts must not import internal/vfs/localfileio directly.
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
forbidigo:
forbid:
# ── Filesystem operations: use internal/vfs instead ──
- pattern: os\.Stat\b
msg: "use vfs.Stat() from internal/vfs"
- pattern: os\.Lstat\b
msg: "use vfs.Lstat() from internal/vfs"
- pattern: os\.Open\b
msg: "use vfs.Open() from internal/vfs"
- pattern: os\.OpenFile\b
msg: "use vfs.OpenFile() from internal/vfs"
- pattern: os\.Create\b
msg: "use vfs.OpenFile() from internal/vfs"
- pattern: os\.CreateTemp\b
# ── os: already wrapped in internal/vfs ──
- pattern: os\.(Stat|Lstat|Open|OpenFile|Rename|ReadFile|WriteFile|Getwd|UserHomeDir|ReadDir)\b
msg: "use the corresponding vfs.Xxx() from internal/vfs"
- pattern: os\.(Create|CreateTemp|MkdirTemp)\b
msg: >-
internal/: use vfs.CreateTemp() from internal/vfs.
shortcuts/: avoid temp files entirely — use io.Reader streaming or in-memory buffers instead.
- pattern: os\.Mkdir\b
msg: "use vfs.MkdirAll() from internal/vfs"
- pattern: os\.MkdirAll\b
internal/: use vfs.CreateTemp() or vfs.OpenFile().
shortcuts/: avoid temp files — use io.Reader streaming or in-memory buffers.
- pattern: os\.Mkdir(All)?\b
msg: "use vfs.MkdirAll() from internal/vfs"
- pattern: os\.Remove\b
msg: >-
internal/: use vfs.Remove() from internal/vfs.
shortcuts/: avoid temp files entirely — use io.Reader streaming or in-memory buffers instead.
shortcuts/: avoid temp files — use io.Reader streaming or in-memory buffers.
- pattern: os\.RemoveAll\b
msg: >-
internal/: add RemoveAll to internal/vfs/fs.go first, then use vfs.RemoveAll().
shortcuts/: avoid temp files entirely — use io.Reader streaming or in-memory buffers instead.
- pattern: os\.Rename\b
msg: "use vfs.Rename() from internal/vfs"
- pattern: os\.ReadFile\b
msg: "use vfs.ReadFile() from internal/vfs"
- pattern: os\.WriteFile\b
msg: "use vfs.WriteFile() from internal/vfs"
- pattern: os\.ReadDir\b
msg: "add ReadDir to internal/vfs/fs.go first, then use vfs.ReadDir()"
- pattern: os\.Getwd\b
msg: "use vfs.Getwd() from internal/vfs"
- pattern: os\.Chdir\b
msg: "add Chdir to internal/vfs/fs.go first, then use vfs.Chdir()"
- pattern: os\.UserHomeDir\b
msg: "use vfs.UserHomeDir() from internal/vfs"
- pattern: os\.Chmod\b
msg: "add Chmod to internal/vfs/fs.go first, then use vfs.Chmod()"
- pattern: os\.Chown\b
msg: "add Chown to internal/vfs/fs.go first, then use vfs.Chown()"
- pattern: os\.Lchown\b
msg: "add Lchown to internal/vfs/fs.go first, then use vfs.Lchown()"
- pattern: os\.Link\b
msg: "add Link to internal/vfs/fs.go first, then use vfs.Link()"
- pattern: os\.Symlink\b
msg: "add Symlink to internal/vfs/fs.go first, then use vfs.Symlink()"
- pattern: os\.Readlink\b
msg: "add Readlink to internal/vfs/fs.go first, then use vfs.Readlink()"
- pattern: os\.Truncate\b
msg: "add Truncate to internal/vfs/fs.go first, then use vfs.Truncate()"
- pattern: os\.DirFS\b
msg: "add DirFS to internal/vfs/fs.go first, then use vfs.DirFS()"
- pattern: os\.SameFile\b
msg: "add SameFile to internal/vfs/fs.go first, then use vfs.SameFile()"
# ── IO streams: use IOStreams from cmdutil instead ──
- pattern: os\.Stdin\b
msg: "use IOStreams.In instead of os.Stdin"
- pattern: os\.Stdout\b
msg: "use IOStreams.Out instead of os.Stdout"
- pattern: os\.Stderr\b
msg: "use IOStreams.ErrOut instead of os.Stderr"
# ── Process-level rules ──
shortcuts/: avoid temp files — use io.Reader streaming or in-memory buffers.
# ── os: not yet in vfs — add to vfs/fs.go first ──
- pattern: os\.(Chdir|Chmod|Chown|Lchown|Chtimes|CopyFS|DirFS|Link|Symlink|Readlink|Truncate|SameFile)\b
msg: "add this function to internal/vfs/fs.go first, then use vfs.Xxx()"
# ── os: IO streams ──
- pattern: os\.Std(in|out|err)\b
msg: "use IOStreams (In/Out/ErrOut) instead of os.Stdin/Stdout/Stderr"
# ── os: process ──
- pattern: os\.Exit\b
msg: >-
Do not use os.Exit in shortcuts/. Return an error instead and let
the caller (cmd layer) decide how to terminate.
# ── filepath: functions that access the filesystem ──
- pattern: filepath\.(EvalSymlinks|Walk|WalkDir|Glob|Abs)\b
msg: >-
These filepath functions access the filesystem directly.
internal/: use vfs helpers or localfileio path validation.
shortcuts/: use runtime.ValidatePath() or runtime.FileIO().
analyze-types: true
gocritic:
disabled-checks:

16
.licenserc.yaml Normal file
View File

@@ -0,0 +1,16 @@
header:
license:
content: |
Copyright (c) [year] Lark Technologies Pte. Ltd.
SPDX-License-Identifier: MIT
copyright-year: "2026"
paths:
- '**/*.go'
- '**/*.js'
- '**/*.py'
paths-ignore:
- '**/testdata/**'
comment: on-failure

View File

@@ -2,6 +2,172 @@
All notable changes to this project will be documented in this file.
## [v1.0.12] - 2026-04-15
### Features
- Add guided npm install flow that installs or upgrades the CLI, installs AI skills, and walks through app config and auth login (#464)
- **mail**: Add email signature support with `+signature`, `--signature-id` compose flags, and draft signature edit operations (#485)
- **mail**: Return recall hints for sent emails when recall is available (#481)
- **slides**: Add `+media-upload` and support `@path` image placeholders in `+create --slides` (#450)
### Documentation
- **mail**: Add recipient search guidance to the mail skill workflow (#437)
- **calendar/vc**: Route past meeting queries to `lark-vc` and clarify historical date matching in skills (#482, #480)
## [v1.0.11] - 2026-04-14
### Features
- **sheets**: Add dropdown shortcuts for data validation management (`+set-dropdown`, `+update-dropdown`, `+get-dropdown`, `+delete-dropdown`) (#461)
- **task**: Add task search, tasklist search, related-task, set-ancestor, and subscribe-event shortcuts (#377)
- Streamline interactive login by removing the extra auth confirmation step (#451)
### Bug Fixes
- **base**: Validate JSON object inputs for base shortcuts and reject `null` objects (#458)
### Documentation
- **sheets**: Document value formats for formulas and special field types (#456)
- **readme**: Add Attendance to the features table (#460)
## [v1.0.10] - 2026-04-13
### Features
- **im**: Support im oapi range download for large files (#283)
- **sheets**: Add filter view and condition shortcuts (#422)
- **wiki**: Add wiki move shortcut with async task polling (#436)
- **drive**: Add drive `+create-shortcut` shortcut (#432)
- **drive**: Add drive files patch metadata API (#444)
- **task**: Support `--section-guid` flag in tasklist-task-add shortcut (#430)
### Bug Fixes
- **base**: Support large base attachment uploads (#441)
- **config**: Clarify init copy for TTY, preserve original for AI (#448)
- **im**: Reject `--user-id` under bot identity for chat-messages-list (#340)
- **mail**: Add missing scopes for mail `+watch` shortcut (#357)
- **mail**: Restrict `--output-dir` to current working directory (#376)
### Documentation
- **wiki**: Add wiki member operations to lark-wiki skill (#417)
- **task**: Document sections API resources, permissions, and URL parsing (#430)
- **doc**: Clarify when markdown escaping is needed (#312)
## [v1.0.9] - 2026-04-11
### Features
- Add attendance `user_task.query` (#405)
- Support minutes search (#359)
- **slides**: Add slides `+create` shortcut with `--slides` one-step creation (#389)
- **slides**: Return presentation URL in slides `+create` output (#425)
- **sheets**: Add dimension shortcuts for row/column operations (#413)
- **sheets**: Add cell operation shortcuts for merge, replace, and style (#412)
- **drive**: Add drive folder delete shortcut with async task polling (#415)
### Documentation
- **drive**: Add guide for granting document permission to current bot (#414)
## [v1.0.8] - 2026-04-10
### Features
- Add `update` command with self-update, verification, and rollback (#391)
- Add `--file` flag for multipart/form-data file uploads (#395)
- Support file comment reply reactions (#380)
- **base**: Add `+dashboard-arrange` command for auto-arranging dashboard blocks layout and `text` block type with Markdown support (#388)
- **base**: Add record batch `+add` / `+set` shortcuts (#277)
- **base**: Add `+record-search` for keyword-based record search (#328)
- **base**: Add view visible fields `+get` / `+set` shortcuts (#326)
- **base**: Add record field filters (#327)
- **base**: Optimize workflow skills (#345)
- **calendar**: Add room find workflow (#403)
- **mail**: Add `--page-token` and `--page-size` to mail `+triage` (#301)
- **whiteboard**: Add `+query` shortcut and enhance `+update` with Mermaid/PlantUML support (#382)
### Bug Fixes
- Improve error hints for sandbox and initialization issues (#384)
- Fix markdown line breaks support (#338)
- Return raw base field and view responses (#378)
- **base**: Return raw table list response and clarify sort help (#393)
- **calendar**: Add default video meeting to `+create` (#383)
- **mail**: Replace `os.Exit` with graceful shutdown in mail watch (#350)
### Documentation
- **base**: Document Base attachment download via docs `+media-download` (#404)
- Reorganize lark-base skill guidance (#374)
## [v1.0.7] - 2026-04-09
### Features
- Auto-grant current user access for bot-created docs, sheets, imports, and uploads (#360)
- **mail**: Add `send_as` alias support, mailbox/sender discovery APIs, and mail rules API
- **vc**: Extract note doc tokens from calendar event relation API (#333)
- **wiki**: Add wiki node create shortcut (#320)
- **sheets**: Add `+write-image` shortcut (#343)
- **docs**: Add media-preview shortcut (#334)
- **docs**: Add support for additional search filters (#353)
### Bug Fixes
- **api**: Support stdin and quoted JSON inputs on Windows (#367)
- **doc**: Post-process `docs +fetch` output to improve round-trip fidelity (#214)
- **run**: Add missing binary check for lark-cli execution (#362)
- **config**: Validate appId and appSecret keychain key consistency (#295)
### Refactor
- Route base import guidance to drive `+import` (#368)
- Migrate mail shortcuts to FileIO (#356)
- Migrate drive/doc/sheets shortcuts to FileIO (#339)
- Migrate base shortcuts to FileIO (#347)
### Documentation
- **lark-doc**: Document advanced boolean and intitle search syntax for AI agents (#210)
### Chore
- Add depguard and forbidigo rules to guide FileIO adoption (#342)
## [v1.0.6] - 2026-04-08
### Features
- Improve login scope validation and success output (#317)
- **task**: Support starting pagination from page token (#332)
- Support multipart doc media uploads (#294)
- **mail**: Auto-resolve local image paths in all draft entry points (#205)
- **vc**: Add `+recording` shortcut for `meeting_id` to `minute_token` conversion (#246)
### Bug Fixes
- Resolve concurrency races in RuntimeContext (#330)
- **config**: Save empty config before clearing keychain entries (#291)
- Reject positional arguments in shortcuts (#227)
- Improve raw API diagnostics for invalid or empty JSON responses (#257)
- **docs**: Normalize `board_tokens` in `+create` response for mermaid/whiteboard content (#10)
- **task**: Clarify `--complete` flag help for `get-my-tasks` (#310)
- **help**: Point root help Agent Skills link to README section (#289)
### Documentation
- Clarify `--complete` flag behavior in `get-my-tasks` reference (#308)
### Refactor
- Migrate VC/minutes shortcuts to FileIO (#336)
- Migrate common/client/IM to FileIO and add localfileio tests (#322)
## [v1.0.5] - 2026-04-07
### Features
@@ -193,6 +359,13 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.12]: https://github.com/larksuite/cli/releases/tag/v1.0.12
[v1.0.11]: https://github.com/larksuite/cli/releases/tag/v1.0.11
[v1.0.10]: https://github.com/larksuite/cli/releases/tag/v1.0.10
[v1.0.9]: https://github.com/larksuite/cli/releases/tag/v1.0.9
[v1.0.8]: https://github.com/larksuite/cli/releases/tag/v1.0.8
[v1.0.7]: https://github.com/larksuite/cli/releases/tag/v1.0.7
[v1.0.6]: https://github.com/larksuite/cli/releases/tag/v1.0.6
[v1.0.5]: https://github.com/larksuite/cli/releases/tag/v1.0.5
[v1.0.4]: https://github.com/larksuite/cli/releases/tag/v1.0.4
[v1.0.3]: https://github.com/larksuite/cli/releases/tag/v1.0.3

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 20 AI Agent [Skills](./skills/).
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 22 AI Agent [Skills](./skills/).
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
## Why lark-cli?
- **Agent-Native Design** — 20 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 12 business domains, 200+ curated commands, 20 AI Agent [Skills](./skills/)
- **Agent-Native Design** — 22 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 14 business domains, 200+ curated commands, 22 AI Agent [Skills](./skills/)
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
@@ -30,11 +30,13 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
| 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics |
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders |
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
| 👤 Contact | Search users by name/email/phone, get user profiles |
| 📧 Mail | Browse, search, read emails, send, reply, forward, manage drafts, watch new mail |
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
| 🕐 Attendance | Query personal attendance check-in records |
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
## Installation & Quick Start
@@ -136,6 +138,7 @@ lark-cli auth status
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
| `lark-drive` | Upload, download files, manage permissions & comments |
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
| `lark-slides` | Create and manage presentations, read presentation content, and add or remove slides |
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |
| `lark-task` | Tasks, task lists, subtasks, reminders, member assignment |
| `lark-mail` | Browse, search, read emails, send, reply, forward, draft management, watch new mail |
@@ -147,6 +150,7 @@ lark-cli auth status
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters) |
| `lark-openapi-explorer` | Explore underlying APIs from official docs |
| `lark-skill-maker` | Custom skill creation framework |
| `lark-attendance` | Query personal attendance check-in records |
| `lark-approval` | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report |
| `lark-workflow-standup-report` | Workflow: agenda & todo summary |

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 20 个 AI Agent [Skills](./skills/)。
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 22 个 AI Agent [Skills](./skills/)。
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
## 为什么选 lark-cli
- **为 Agent 原生设计** — [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 12 大业务域、200+ 精选命令、 20 个 AI Agent [Skills](./skills/)
- **为 Agent 原生设计** — 22 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 14 大业务域、200+ 精选命令、22 个 AI Agent [Skills](./skills/)
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
@@ -30,11 +30,13 @@
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
| ✅ 任务 | 创建、查询、更新和完成任务;管理任务清单、子任务、评论与提醒 |
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
## 安装与快速开始
@@ -137,6 +139,7 @@ lark-cli auth status
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown |
| `lark-drive` | 上传、下载文件,管理权限与评论 |
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
| `lark-task` | 任务、任务清单、子任务、提醒、成员分配 |
| `lark-mail` | 浏览、搜索、阅读邮件,发送、回复、转发,草稿管理,监听新邮件 |
@@ -148,6 +151,7 @@ lark-cli auth status
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) |
| `lark-openapi-explorer` | 从官方文档探索底层 API |
| `lark-skill-maker` | 自定义 skill 创建框架 |
| `lark-attendance` | 查询个人考勤打卡记录 |
| `lark-approval` | 审批任务查询、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |

View File

@@ -5,7 +5,6 @@ package api
import (
"context"
"encoding/json"
"fmt"
"io"
"regexp"
@@ -42,17 +41,7 @@ type APIOptions struct {
Format string
JqExpr string
DryRun bool
}
func parseJsonOpt(input, label string) (map[string]interface{}, error) {
if input == "" {
return nil, nil
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(input), &result); err != nil {
return nil, output.ErrValidation("%s invalid format, expected JSON object", label)
}
return result, nil
File string
}
var urlPrefixRe = regexp.MustCompile(`https?://[^/]+(/open-apis/.+)`)
@@ -88,8 +77,8 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
},
}
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON")
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
@@ -99,6 +88,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")
cmd.ValidArgsFunction = func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
@@ -117,20 +107,24 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
}
// buildAPIRequest validates flags and builds a RawApiRequest.
func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, error) {
params, err := parseJsonOpt(opts.Params, "--params")
// When dryRun is true and a file is provided, file reading is skipped and
// FileUploadMeta is returned instead so the caller can render dry-run output.
func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
stdin := opts.Factory.IOStreams.In
// Validate --file mutual exclusions first.
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
return client.RawApiRequest{}, nil, err
}
// stdin conflict: --params and --data cannot both read from stdin, regardless of --file.
if opts.Params == "-" && opts.Data == "-" {
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
if err != nil {
return client.RawApiRequest{}, err
}
if params == nil {
params = map[string]interface{}{}
}
var data interface{}
if opts.Data != "" {
data, err = parseJsonOpt(opts.Data, "--data")
if err != nil {
return client.RawApiRequest{}, err
}
return client.RawApiRequest{}, nil, err
}
if opts.PageSize > 0 {
params["page_size"] = opts.PageSize
@@ -140,14 +134,53 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, error) {
Method: opts.Method,
URL: normalisePath(opts.Path),
Params: params,
Data: data,
As: opts.As,
}
// WithFileDownload tells the SDK to skip CodeError parsing on 200 OK.
if opts.Output != "" {
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
if opts.File != "" {
// File upload path: build formdata.
fieldName, filePath, isStdin := cmdutil.ParseFileFlag(opts.File, "file")
// Parse --data as JSON map for form fields (not as body).
var dataFields any
if opts.Data != "" {
dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
if err != nil {
return client.RawApiRequest{}, nil, err
}
if _, ok := dataFields.(map[string]any); !ok {
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
}
}
if opts.DryRun {
return request, &cmdutil.FileUploadMeta{
FieldName: fieldName, FilePath: filePath, FormFields: dataFields,
}, nil
}
fd, err := cmdutil.BuildFormdata(
opts.Factory.ResolveFileIO(opts.Ctx),
fieldName, filePath, isStdin, stdin, dataFields,
)
if err != nil {
return client.RawApiRequest{}, nil, err
}
request.Data = fd
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
} else {
// Normal path: JSON body.
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
if err != nil {
return client.RawApiRequest{}, nil, err
}
request.Data = data
if opts.Output != "" {
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
}
}
return request, nil
return request, nil, nil
}
func apiRun(opts *APIOptions) error {
@@ -165,7 +198,7 @@ func apiRun(opts *APIOptions) error {
return err
}
request, err := buildAPIRequest(opts)
request, fileMeta, err := buildAPIRequest(opts)
if err != nil {
return err
}
@@ -176,6 +209,9 @@ func apiRun(opts *APIOptions) error {
}
if opts.DryRun {
if fileMeta != nil {
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
}
return apiDryRun(f, request, config, opts.Format)
}
// Identity info is now included in the JSON envelope; skip stderr printing.
@@ -199,7 +235,7 @@ func apiRun(opts *APIOptions) error {
resp, err := ac.DoAPI(opts.Ctx, request)
if err != nil {
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
return output.MarkRaw(client.WrapDoAPIError(err))
}
err = client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
@@ -207,6 +243,7 @@ func apiRun(opts *APIOptions) error {
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
FileIO: f.ResolveFileIO(opts.Ctx),
})
// MarkRaw tells root error handler to skip enrichPermissionError,
// preserving the original API error detail (log_id, troubleshooter, etc.).

View File

@@ -5,6 +5,7 @@ package api
import (
"errors"
"os"
"sort"
"strings"
"testing"
@@ -199,6 +200,22 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
}
}
func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error when both --params and --data use stdin")
}
if !strings.Contains(err.Error(), "cannot both read from stdin") {
t.Errorf("expected stdin conflict error, got: %v", err)
}
}
func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -446,6 +463,43 @@ func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) {
}
}
func TestApiCmd_InvalidJSONResponse_ShowsDiagnostic(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-invalidjson", AppSecret: "test-secret-invalidjson", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/invalidjson",
RawBody: []byte{},
ContentType: "application/json",
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/invalidjson", "--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
}
if exitErr.Detail == nil {
t.Fatal("expected detail on exit error")
}
if !strings.Contains(exitErr.Detail.Message, "invalid JSON response") &&
!strings.Contains(exitErr.Detail.Message, "empty JSON response body") {
t.Fatalf("expected JSON diagnostic, got %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "--output") {
t.Fatalf("expected hint to mention --output, got %q", exitErr.Detail.Hint)
}
}
func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-rawpage", AppSecret: "test-secret-rawpage", Brand: core.BrandFeishu,
@@ -653,3 +707,98 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
t.Errorf("expected method POST (uppercased), got %s", gotOpts.Method)
}
}
func TestApiCmd_FileFlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"POST", "/open-apis/test", "--file", "image=photo.jpg", "--data", `{"image_type":"message"}`})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.File != "image=photo.jpg" {
t.Errorf("expected File = %q, got %q", "image=photo.jpg", gotOpts.File)
}
}
func TestApiCmd_FileAndOutputConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for --file with --output")
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("expected mutual exclusion error, got: %v", err)
}
}
func TestApiCmd_FileWithGET(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for --file with GET")
}
if !strings.Contains(err.Error(), "requires POST") {
t.Errorf("expected method error, got: %v", err)
}
}
func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for --file stdin with --data stdin")
}
if !strings.Contains(err.Error(), "cannot both read from stdin") {
t.Errorf("expected stdin conflict error, got: %v", err)
}
}
func TestApiCmd_DryRunWithFile(t *testing.T) {
tmpDir := t.TempDir()
tmpFile := tmpDir + "/test.jpg"
if err := os.WriteFile(tmpFile, []byte("fake-image"), 0600); err != nil {
t.Fatal(err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "image") {
t.Errorf("expected dry-run output to mention file field, got: %s", out)
}
if !strings.Contains(out, "Dry Run") {
t.Errorf("expected dry-run header, got: %s", out)
}
}

View File

@@ -16,6 +16,7 @@ import (
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// NewCmdAuth creates the auth command with subcommands.
@@ -100,7 +101,7 @@ type appInfoResponse struct {
// getAppInfo queries app info from the Lark API.
func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
sdk, err := f.LarkClient()
ac, err := f.NewAPIClient()
if err != nil {
return nil, err
}
@@ -108,12 +109,11 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo
queryParams := make(larkcore.QueryParams)
queryParams.Set("lang", "zh_cn")
apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: larkauth.ApplicationInfoPath(appId),
QueryParams: queryParams,
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant},
})
apiResp, err := ac.DoSDKRequest(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: larkauth.ApplicationInfoPath(appId),
QueryParams: queryParams,
}, core.AsBot)
if err != nil {
return nil, err
}

View File

@@ -4,12 +4,16 @@
package auth
import (
"context"
"net/http"
"sort"
"strings"
"testing"
"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/larksuite/cli/internal/registry"
)
@@ -231,3 +235,71 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
t.Errorf("expected format json, got %s", gotOpts.Format)
}
}
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
})
tokenResolver := &authScopesTokenResolver{}
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
appInfoStub := &httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/application/v6/applications/test-app",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"app": map[string]interface{}{
"creator_id": "ou_creator",
"scopes": []map[string]interface{}{
{
"scope": "im:message",
"token_types": []string{"tenant"},
},
{
"scope": "im:message:send_as_user",
"token_types": []string{"user"},
},
},
},
},
},
}
reg.Register(appInfoStub)
err := authScopesRun(&ScopesOptions{
Factory: f,
Ctx: context.Background(),
Format: "json",
})
if err != nil {
t.Fatalf("authScopesRun() error = %v", err)
}
if len(tokenResolver.requests) != 1 {
t.Fatalf("resolved token requests = %v, want exactly one request", tokenResolver.requests)
}
if got := tokenResolver.requests[0].Type; got != credential.TokenTypeTAT {
t.Fatalf("resolved token type = %q, want %q", got, credential.TokenTypeTAT)
}
if got := appInfoStub.CapturedHeaders.Get("Authorization"); got != "Bearer tenant-token" {
t.Fatalf("Authorization header = %q, want %q", got, "Bearer tenant-token")
}
}
type authScopesTokenResolver struct {
requests []credential.TokenSpec
}
func (r *authScopesTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
r.requests = append(r.requests, req)
switch req.Type {
case credential.TokenTypeTAT:
return &credential.TokenResult{Token: "tenant-token"}, nil
case credential.TokenTypeUAT:
return &credential.TokenResult{Token: "user-token"}, nil
default:
return &credential.TokenResult{Token: "unexpected-token"}, nil
}
}

View File

@@ -34,6 +34,8 @@ type LoginOptions struct {
DeviceCode string
}
var pollDeviceToken = larkauth.PollDeviceToken
// NewCmdAuthLogin creates the auth login subcommand.
func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
opts := &LoginOptions{Factory: f}
@@ -132,18 +134,7 @@ func authLoginRun(opts *LoginOptions) error {
// Expand --domain all to all available domains (from_meta projects + shortcut services)
for _, d := range selectedDomains {
if strings.EqualFold(d, "all") {
domainSet := make(map[string]bool)
for _, p := range registry.ListFromMetaProjects() {
domainSet[p] = true
}
for _, sc := range shortcuts.AllShortcuts() {
domainSet[sc.Service] = true
}
selectedDomains = make([]string, 0, len(domainSet))
for d := range domainSet {
selectedDomains = append(selectedDomains, d)
}
sort.Strings(selectedDomains)
selectedDomains = sortedKnownDomains()
break
}
}
@@ -235,6 +226,9 @@ func authLoginRun(opts *LoginOptions) error {
// --no-wait: return immediately with device code and URL
if opts.NoWait {
if err := saveLoginRequestedScope(authResp.DeviceCode, finalScope); err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to cache requested scopes: %v\n", err)
}
data := map[string]interface{}{
"verification_url": authResp.VerificationUriComplete,
"device_code": authResp.DeviceCode,
@@ -244,7 +238,7 @@ func authLoginRun(opts *LoginOptions) error {
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(data); err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "error: failed to write JSON output: %v\n", err)
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
}
return nil
}
@@ -261,7 +255,7 @@ func authLoginRun(opts *LoginOptions) error {
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(data); err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "error: failed to write JSON output: %v\n", err)
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
}
} else {
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
@@ -270,20 +264,26 @@ func authLoginRun(opts *LoginOptions) error {
// Step 3: Poll for token
log(msg.WaitingAuth)
result := larkauth.PollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if !result.OK {
if opts.JSON {
b, _ := json.Marshal(map[string]interface{}{
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(map[string]interface{}{
"event": "authorization_failed",
"error": result.Message,
})
fmt.Fprintln(f.IOStreams.Out, string(b))
}); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
}
return output.ErrBare(output.ExitAuth)
}
return output.ErrAuth("authorization failed: %s", result.Message)
}
if result.Token == nil {
return output.ErrAuth("authorization succeeded but no token returned")
}
// Step 6: Get user info
log(msg.AuthSuccess)
@@ -296,6 +296,8 @@ func authLoginRun(opts *LoginOptions) error {
return output.ErrAuth("failed to get user info: %v", err)
}
scopeSummary := loadLoginScopeSummary(config.AppID, openId, finalScope, result.Token.Scope)
// Step 7: Store token
now := time.Now().UnixMilli()
storedToken := &larkauth.StoredUAToken{
@@ -318,21 +320,11 @@ func authLoginRun(opts *LoginOptions) error {
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
}
if opts.JSON {
b, _ := json.Marshal(map[string]interface{}{
"event": "authorization_complete",
"user_open_id": openId,
"user_name": userName,
"scope": result.Token.Scope,
})
fmt.Fprintln(f.IOStreams.Out, string(b))
} else {
fmt.Fprintln(f.IOStreams.ErrOut)
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
if result.Token.Scope != "" {
fmt.Fprintf(f.IOStreams.ErrOut, msg.GrantedScopes, result.Token.Scope)
}
if issue := ensureRequestedScopesGranted(finalScope, result.Token.Scope, msg, scopeSummary); issue != nil {
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName)
}
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary)
return nil
}
@@ -345,13 +337,26 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
if err != nil {
return err
}
requestedScope, err := loadLoginRequestedScope(opts.DeviceCode)
if err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to load cached requested scopes: %v\n", err)
}
cleanupRequestedScope := func() {
if err := removeLoginRequestedScope(opts.DeviceCode); err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
}
}
log(msg.WaitingAuth)
result := larkauth.PollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
opts.DeviceCode, 5, 180, f.IOStreams.ErrOut)
if !result.OK {
if shouldRemoveLoginRequestedScope(result) {
cleanupRequestedScope()
}
return output.ErrAuth("authorization failed: %s", result.Message)
}
defer cleanupRequestedScope()
if result.Token == nil {
return output.ErrAuth("authorization succeeded but no token returned")
}
@@ -367,6 +372,8 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
return output.ErrAuth("failed to get user info: %v", err)
}
scopeSummary := loadLoginScopeSummary(config.AppID, openId, requestedScope, result.Token.Scope)
// Store token
now := time.Now().UnixMilli()
storedToken := &larkauth.StoredUAToken{
@@ -389,7 +396,11 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
if issue := ensureRequestedScopesGranted(requestedScope, result.Token.Scope, msg, scopeSummary); issue != nil {
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName)
}
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary)
return nil
}
@@ -429,6 +440,8 @@ func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.App
// collectScopesForDomains collects API scopes (from from_meta projects) and
// shortcut scopes for the given domain names.
// Domains with auth_domain children are automatically expanded to include
// their children's scopes.
func collectScopesForDomains(domains []string, identity string) []string {
scopeSet := make(map[string]bool)
@@ -437,11 +450,16 @@ func collectScopesForDomains(domains []string, identity string) []string {
scopeSet[s] = true
}
// 2. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
// 2. Expand domains: include auth_domain children
domainSet := make(map[string]bool, len(domains))
for _, d := range domains {
domainSet[d] = true
for _, child := range registry.GetAuthChildren(d) {
domainSet[child] = true
}
}
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
for _, sc := range shortcuts.AllShortcuts() {
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
for _, s := range sc.ScopesForIdentity(identity) {
@@ -450,7 +468,7 @@ func collectScopesForDomains(domains []string, identity string) []string {
}
}
// 3. Deduplicate and sort
// 4. Deduplicate and sort
result := make([]string, 0, len(scopeSet))
for s := range scopeSet {
result = append(result, s)
@@ -459,14 +477,20 @@ func collectScopesForDomains(domains []string, identity string) []string {
return result
}
// allKnownDomains returns all valid domain names (from_meta projects + shortcut services).
// allKnownDomains returns all valid auth domain names (from_meta projects +
// shortcut services), excluding domains that have auth_domain set (they are
// folded into their parent domain).
func allKnownDomains() map[string]bool {
domains := make(map[string]bool)
for _, p := range registry.ListFromMetaProjects() {
domains[p] = true
if !registry.HasAuthDomain(p) {
domains[p] = true
}
}
for _, sc := range shortcuts.AllShortcuts() {
domains[sc.Service] = true
if !registry.HasAuthDomain(sc.Service) {
domains[sc.Service] = true
}
}
return domains
}

View File

@@ -34,8 +34,12 @@ func getDomainMetadata(lang string) []domainMeta {
seen := make(map[string]bool)
var domains []domainMeta
// 1. Domains from from_meta projects
// 1. Domains from from_meta projects (skip domains with auth_domain)
for _, project := range registry.ListFromMetaProjects() {
if registry.HasAuthDomain(project) {
seen[project] = true
continue
}
dm := buildDomainMeta(project, lang)
domains = append(domains, dm)
seen[project] = true
@@ -52,13 +56,14 @@ func getDomainMetadata(lang string) []domainMeta {
}
// 3. Auto-discover remaining shortcut services that are listed as shortcut-only domains
// (skip domains with auth_domain — they are folded into their parent)
shortcutOnlySet := make(map[string]bool)
for _, n := range shortcutOnlyNames {
shortcutOnlySet[n] = true
}
for _, sc := range shortcuts.AllShortcuts() {
if !seen[sc.Service] {
if shortcutOnlySet[sc.Service] {
if shortcutOnlySet[sc.Service] && !registry.HasAuthDomain(sc.Service) {
dm := buildDomainMeta(sc.Service, lang)
domains = append(domains, dm)
}
@@ -179,27 +184,6 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*i
}
fmt.Fprintf(ios.ErrOut, msg.SummaryScopes, len(scopes), scopePreview)
// Phase 2: confirmation
var confirmed bool
form2 := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title(msg.ConfirmAuth).
Value(&confirmed),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form2.Run(); err != nil {
if err == huh.ErrUserAborted {
return nil, output.ErrBare(1)
}
return nil, err
}
if !confirmed {
return nil, output.ErrBare(1)
}
return &interactiveResult{
Domains: selectedDomains,
ScopeLevel: permLevel,

View File

@@ -20,11 +20,17 @@ type loginMsg struct {
ConfirmAuth string
// Non-interactive prompts (login.go)
OpenURL string
WaitingAuth string
AuthSuccess string
LoginSuccess string
GrantedScopes string
OpenURL string
WaitingAuth string
AuthSuccess string
LoginSuccess string
ScopeMismatch string
ScopeHint string
RequestedScopes string
NewlyGrantedScopes string
MissingScopes string
NoScopes string
StatusHint string
// Non-interactive hint (no flags)
HintHeader string
@@ -50,11 +56,17 @@ var loginMsgZh = &loginMsg{
ErrNoDomain: "请至少选择一个业务域",
ConfirmAuth: "确认授权?",
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AuthSuccess: "授权成功,正在获取用户信息...",
LoginSuccess: "登录成功! 用户: %s (%s)",
GrantedScopes: " 已授权 scopes: %s\n",
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AuthSuccess: "授权成功,正在获取用户信息...",
LoginSuccess: "登录成功! 用户: %s (%s)",
ScopeMismatch: "授权完成,但以下请求 scopes 未被授予: %s",
ScopeHint: "以上结果是本次授权请求用户最终确认后的结果请勿持续重试Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
RequestedScopes: " 本次请求 scopes: %s\n",
NewlyGrantedScopes: " 本次新授予 scopes: %s\n",
MissingScopes: " 本次未授予 scopes: %s\n",
NoScopes: "(空)",
StatusHint: "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
HintHeader: "请指定要授权的权限:\n",
HintCommon1: " --recommend 授权推荐权限",
@@ -79,11 +91,17 @@ var loginMsgEn = &loginMsg{
ErrNoDomain: "please select at least one domain",
ConfirmAuth: "Confirm authorization?",
OpenURL: "Open this URL in your browser to authenticate:\n\n",
WaitingAuth: "Waiting for user authorization...",
AuthSuccess: "Authorization successful, fetching user info...",
LoginSuccess: "Login successful! User: %s (%s)",
GrantedScopes: " Granted scopes: %s\n",
OpenURL: "Open this URL in your browser to authenticate:\n\n",
WaitingAuth: "Waiting for user authorization...",
AuthSuccess: "Authorization successful, fetching user info...",
LoginSuccess: "Login successful! User: %s (%s)",
ScopeMismatch: "authorization completed, but these requested scopes were not granted: %s",
ScopeHint: "The result above is the user's final confirmation for this authorization request. Do not retry continuously. Scopes may be not granted for various reasons, such as a scope being disabled. The specific reason has already been shown to the user on the authorization page. Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
RequestedScopes: " Requested scopes: %s\n",
NewlyGrantedScopes: " Newly granted scopes: %s\n",
MissingScopes: " Not granted scopes: %s\n",
NoScopes: "(none)",
StatusHint: "Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
HintHeader: "Please specify the scopes to authorize:\n",
HintCommon1: " --recommend authorize recommended scopes",

View File

@@ -69,12 +69,6 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
t.Errorf("%s LoginSuccess has no format verb", lang)
}
// GrantedScopes should contain %s
got = fmt.Sprintf(msg.GrantedScopes, "scope1 scope2")
if got == msg.GrantedScopes {
t.Errorf("%s GrantedScopes has no format verb", lang)
}
// SummaryDomains should contain %s
got = fmt.Sprintf(msg.SummaryDomains, "calendar, task")
if got == msg.SummaryDomains {

235
cmd/auth/login_result.go Normal file
View File

@@ -0,0 +1,235 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"fmt"
"strings"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
type loginScopeSummary struct {
Requested []string
NewlyGranted []string
AlreadyGranted []string
Granted []string
Missing []string
}
type loginScopeIssue struct {
Message string
Hint string
Summary *loginScopeSummary
}
// ensureRequestedScopesGranted checks whether all requested scopes were granted
// and returns a structured issue when any requested scope is missing.
func ensureRequestedScopesGranted(requestedScope, grantedScope string, msg *loginMsg, summary *loginScopeSummary) *loginScopeIssue {
requested := uniqueScopeList(requestedScope)
if len(requested) == 0 {
return nil
}
missing := larkauth.MissingScopes(grantedScope, requested)
if len(missing) == 0 {
return nil
}
if summary == nil {
summary = &loginScopeSummary{
Requested: requested,
Granted: strings.Fields(grantedScope),
Missing: missing,
}
}
return &loginScopeIssue{
Message: fmt.Sprintf(msg.ScopeMismatch, strings.Join(missing, " ")),
Hint: msg.ScopeHint,
Summary: summary,
}
}
// loadLoginScopeSummary builds a scope summary by comparing the requested scopes,
// previously stored scopes, and the newly granted scopes from the current login.
func loadLoginScopeSummary(appID, openId, requestedScope, grantedScope string) *loginScopeSummary {
previousScope := ""
if previous := larkauth.GetStoredToken(appID, openId); previous != nil {
previousScope = previous.Scope
}
return buildLoginScopeSummary(requestedScope, previousScope, grantedScope)
}
// buildLoginScopeSummary classifies requested scopes into newly granted,
// already granted, and missing buckets while preserving the final granted list.
func buildLoginScopeSummary(requestedScope, previousScope, grantedScope string) *loginScopeSummary {
requested := uniqueScopeList(requestedScope)
previous := uniqueScopeList(previousScope)
granted := uniqueScopeList(grantedScope)
previousSet := make(map[string]bool, len(previous))
for _, scope := range previous {
previousSet[scope] = true
}
grantedSet := make(map[string]bool, len(granted))
for _, scope := range granted {
grantedSet[scope] = true
}
summary := &loginScopeSummary{
Requested: requested,
Granted: granted,
}
for _, scope := range requested {
if !grantedSet[scope] {
summary.Missing = append(summary.Missing, scope)
continue
}
if previousSet[scope] {
summary.AlreadyGranted = append(summary.AlreadyGranted, scope)
continue
}
summary.NewlyGranted = append(summary.NewlyGranted, scope)
}
return summary
}
// uniqueScopeList splits a scope string into a de-duplicated ordered slice.
func uniqueScopeList(scope string) []string {
seen := make(map[string]bool)
var result []string
for _, item := range strings.Fields(scope) {
if seen[item] {
continue
}
seen[item] = true
result = append(result, item)
}
return result
}
// formatScopeList joins scopes for display and falls back to the provided empty
// label when the input slice is empty.
func formatScopeList(scopes []string, empty string) string {
if len(scopes) == 0 {
return empty
}
return strings.Join(scopes, " ")
}
// emptyIfNil normalizes nil slices to empty slices for stable JSON output.
func emptyIfNil(s []string) []string {
if s == nil {
return []string{}
}
return s
}
// writeLoginScopeBreakdown renders the requested/newly granted/missing scope
// breakdown to stderr.
func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary *loginScopeSummary) {
if summary == nil {
summary = &loginScopeSummary{}
}
fmt.Fprintf(errOut.ErrOut, msg.RequestedScopes, formatScopeList(summary.Requested, msg.NoScopes))
fmt.Fprintf(errOut.ErrOut, msg.NewlyGrantedScopes, formatScopeList(summary.NewlyGranted, msg.NoScopes))
fmt.Fprintf(errOut.ErrOut, msg.MissingScopes, formatScopeList(summary.Missing, msg.NoScopes))
}
// writeLoginSuccess emits the successful login payload in either JSON or text
// format together with the computed scope breakdown.
func writeLoginSuccess(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, openId, userName string, summary *loginScopeSummary) {
if summary == nil {
summary = &loginScopeSummary{}
}
if opts.JSON {
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, summary, nil))
fmt.Fprintln(f.IOStreams.Out, string(b))
return
}
fmt.Fprintln(f.IOStreams.ErrOut)
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
writeLoginScopeBreakdown(f.IOStreams, msg, summary)
if len(summary.Missing) == 0 && msg.StatusHint != "" {
fmt.Fprintln(f.IOStreams.ErrOut, msg.StatusHint)
}
}
// handleLoginScopeIssue prints or returns a structured missing-scope result
// while preserving a successful login outcome when authorization completed.
func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, issue *loginScopeIssue, openId, userName string) error {
if issue == nil {
return nil
}
loginSucceeded := openId != ""
if opts.JSON {
if loginSucceeded {
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, issue.Summary, issue))
fmt.Fprintln(f.IOStreams.Out, string(b))
return nil
}
detail := map[string]interface{}{
"requested": issue.Summary.Requested,
"granted": issue.Summary.Granted,
"missing": issue.Summary.Missing,
}
return &output.ExitError{
Code: output.ExitAuth,
Detail: &output.ErrDetail{
Type: "missing_scope",
Message: issue.Message,
Hint: issue.Hint,
Detail: detail,
},
}
}
fmt.Fprintln(f.IOStreams.ErrOut)
if loginSucceeded {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
} else {
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
}
if loginSucceeded {
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
}
writeLoginScopeBreakdown(f.IOStreams, msg, issue.Summary)
if issue.Hint != "" {
fmt.Fprintln(f.IOStreams.ErrOut, issue.Hint)
}
if loginSucceeded {
return nil
}
return output.ErrBare(output.ExitAuth)
}
// authorizationCompletePayload builds the JSON payload for a completed login,
// optionally attaching a warning when requested scopes are missing.
func authorizationCompletePayload(openId, userName string, summary *loginScopeSummary, issue *loginScopeIssue) map[string]interface{} {
if summary == nil {
summary = &loginScopeSummary{}
}
payload := map[string]interface{}{
"event": "authorization_complete",
"user_open_id": openId,
"user_name": userName,
"scope": strings.Join(summary.Granted, " "),
"requested": emptyIfNil(summary.Requested),
"newly_granted": emptyIfNil(summary.NewlyGranted),
"already_granted": emptyIfNil(summary.AlreadyGranted),
"missing": emptyIfNil(summary.Missing),
"granted": emptyIfNil(summary.Granted),
}
if issue != nil {
payload["warning"] = map[string]interface{}{
"type": "missing_scope",
"message": issue.Message,
"hint": issue.Hint,
}
}
return payload
}

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"regexp"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
var loginScopeCacheSafeChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
type loginScopeCacheRecord struct {
RequestedScope string `json:"requested_scope"`
}
// loginScopeCacheDir returns the directory used to persist auth login --no-wait
// requested scopes keyed by device_code.
func loginScopeCacheDir() string {
return filepath.Join(core.GetConfigDir(), "cache", "auth_login_scopes")
}
// loginScopeCachePath returns the cache file path for a given device_code.
func loginScopeCachePath(deviceCode string) string {
return filepath.Join(loginScopeCacheDir(), sanitizeLoginScopeCacheKey(deviceCode)+".json")
}
// sanitizeLoginScopeCacheKey converts a device_code into a safe filename token.
func sanitizeLoginScopeCacheKey(deviceCode string) string {
sanitized := loginScopeCacheSafeChars.ReplaceAllString(deviceCode, "_")
if sanitized == "" {
return "default"
}
return sanitized
}
// saveLoginRequestedScope persists the requested scope string for a device_code.
func saveLoginRequestedScope(deviceCode, requestedScope string) error {
if err := vfs.MkdirAll(loginScopeCacheDir(), 0700); err != nil {
return err
}
data, err := json.Marshal(loginScopeCacheRecord{RequestedScope: requestedScope})
if err != nil {
return err
}
return validate.AtomicWrite(loginScopeCachePath(deviceCode), data, 0600)
}
// loadLoginRequestedScope loads the cached requested scope string for a device_code.
// It returns an empty string if no cache entry exists.
func loadLoginRequestedScope(deviceCode string) (string, error) {
data, err := vfs.ReadFile(loginScopeCachePath(deviceCode))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
return "", err
}
var record loginScopeCacheRecord
if err := json.Unmarshal(data, &record); err != nil {
_ = vfs.Remove(loginScopeCachePath(deviceCode))
return "", err
}
return record.RequestedScope, nil
}
// removeLoginRequestedScope deletes the cache entry for a device_code.
func removeLoginRequestedScope(deviceCode string) error {
err := vfs.Remove(loginScopeCachePath(deviceCode))
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
// shouldRemoveLoginRequestedScope indicates whether the requested-scope cache
// should be removed after polling finishes.
func shouldRemoveLoginRequestedScope(result *larkauth.DeviceFlowResult) bool {
if result == nil {
return false
}
if result.OK || result.Error == "access_denied" {
return true
}
return result.Error == "expired_token" && result.Message != "Polling was cancelled"
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"os"
"testing"
"github.com/larksuite/cli/internal/vfs"
)
func TestLoginRequestedScopeCache_RoundTrip(t *testing.T) {
setupLoginConfigDir(t)
deviceCode := "device/code:123"
requestedScope := "im:message:send im:message:reply"
if err := saveLoginRequestedScope(deviceCode, requestedScope); err != nil {
t.Fatalf("saveLoginRequestedScope() error = %v", err)
}
got, err := loadLoginRequestedScope(deviceCode)
if err != nil {
t.Fatalf("loadLoginRequestedScope() error = %v", err)
}
if got != requestedScope {
t.Fatalf("requestedScope = %q, want %q", got, requestedScope)
}
if _, err := vfs.Stat(loginScopeCachePath(deviceCode)); err != nil {
t.Fatalf("Stat(cachePath) error = %v", err)
}
if err := removeLoginRequestedScope(deviceCode); err != nil {
t.Fatalf("removeLoginRequestedScope() error = %v", err)
}
if _, err := vfs.Stat(loginScopeCachePath(deviceCode)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("Stat(cachePath) error = %v, want not exist", err)
}
}
func TestLoadLoginRequestedScope_MissingReturnsEmpty(t *testing.T) {
setupLoginConfigDir(t)
got, err := loadLoginRequestedScope("missing-device-code")
if err != nil {
t.Fatalf("loadLoginRequestedScope() error = %v", err)
}
if got != "" {
t.Fatalf("requestedScope = %q, want empty", got)
}
}

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (

View File

@@ -5,16 +5,29 @@ package auth
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"sort"
"strings"
"testing"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts/common"
"github.com/zalando/go-keyring"
)
type failWriter struct{}
func (failWriter) Write([]byte) (int, error) {
return 0, errors.New("write failed")
}
func TestSuggestDomain_PrefixMatch(t *testing.T) {
known := map[string]bool{
"calendar": true,
@@ -282,6 +295,606 @@ func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
}
}
func TestEnsureRequestedScopesGranted(t *testing.T) {
issue := ensureRequestedScopesGranted("im:message:send im:message:reply", "im:message:reply", getLoginMsg("en"), nil)
if issue == nil {
t.Fatal("expected missing scope issue")
}
if !strings.Contains(issue.Message, "im:message:send") {
t.Fatalf("message %q missing requested scope", issue.Message)
}
for _, want := range []string{"Do not retry continuously", "scope being disabled", "lark-cli auth status"} {
if !strings.Contains(issue.Hint, want) {
t.Fatalf("hint %q missing %q", issue.Hint, want)
}
}
if got := strings.Join(issue.Summary.Missing, " "); got != "im:message:send" {
t.Fatalf("Missing = %q", got)
}
}
func TestBuildLoginScopeSummary(t *testing.T) {
summary := buildLoginScopeSummary("im:message:send im:message:reply im:message:send", "im:message:reply", "im:message:send im:message:reply im:chat:read")
if got := strings.Join(summary.Requested, " "); got != "im:message:send im:message:reply" {
t.Fatalf("Requested = %q", got)
}
if got := strings.Join(summary.NewlyGranted, " "); got != "im:message:send" {
t.Fatalf("NewlyGranted = %q", got)
}
if got := strings.Join(summary.AlreadyGranted, " "); got != "im:message:reply" {
t.Fatalf("AlreadyGranted = %q", got)
}
if len(summary.Missing) != 0 {
t.Fatalf("Missing = %v, want empty", summary.Missing)
}
if got := strings.Join(summary.Granted, " "); got != "im:message:send im:message:reply im:chat:read" {
t.Fatalf("Granted = %q", got)
}
}
func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
Requested: []string{"im:message:send", "im:message:reply"},
NewlyGranted: []string{"im:message:send"},
AlreadyGranted: []string{"im:message:reply"},
Granted: []string{"im:message:send", "im:message:reply"},
})
var data map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String())
}
if data["event"] != "authorization_complete" {
t.Fatalf("event = %v", data["event"])
}
if data["scope"] != "im:message:send im:message:reply" {
t.Fatalf("scope = %v", data["scope"])
}
if len(data["newly_granted"].([]interface{})) != 1 {
t.Fatalf("newly_granted = %#v", data["newly_granted"])
}
if len(data["already_granted"].([]interface{})) != 1 {
t.Fatalf("already_granted = %#v", data["already_granted"])
}
}
func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
err := handleLoginScopeIssue(&LoginOptions{}, getLoginMsg("zh"), f, &loginScopeIssue{
Message: "授权完成,但以下请求 scopes 未被授予: im:message:send",
Hint: "以上结果是本次授权请求用户最终确认后的结果请勿持续重试Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
Summary: &loginScopeSummary{
Requested: []string{"im:message:send"},
Missing: []string{"im:message:send"},
Granted: []string{"base:app:copy"},
},
}, "ou_user", "tester")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
got := stderr.String()
for _, want := range []string{
"OK: 登录成功! 用户: tester (ou_user)",
"授权完成,但以下请求 scopes 未被授予: im:message:send",
"本次请求 scopes: im:message:send",
"本次新授予 scopes: (空)",
"本次未授予 scopes: im:message:send",
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
"scope 被禁用",
"lark-cli auth status",
} {
if !strings.Contains(got, want) {
t.Fatalf("stderr missing %q, got:\n%s", want, got)
}
}
if strings.Contains(got, "最终已授权 scopes:") {
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
}
if strings.Contains(got, "ERROR:") {
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
}
}
func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
err := handleLoginScopeIssue(&LoginOptions{JSON: true}, getLoginMsg("en"), f, &loginScopeIssue{
Message: "authorization completed, but these requested scopes were not granted: im:message:send",
Hint: "Granted scopes: base:app:copy. Check app scopes.",
Summary: &loginScopeSummary{
Requested: []string{"im:message:send"},
Missing: []string{"im:message:send"},
Granted: []string{"base:app:copy"},
},
}, "ou_user", "tester")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
var data map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String())
}
if data["event"] != "authorization_complete" {
t.Fatalf("event = %v", data["event"])
}
if data["user_open_id"] != "ou_user" {
t.Fatalf("user_open_id = %v", data["user_open_id"])
}
warning, ok := data["warning"].(map[string]interface{})
if !ok {
t.Fatalf("warning = %#v", data["warning"])
}
if warning["type"] != "missing_scope" {
t.Fatalf("warning.type = %v", warning["type"])
}
}
func TestWriteLoginSuccess_JSONEmptySlicesNotNull(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
Granted: []string{"offline_access"},
})
var data map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String())
}
for _, k := range []string{"requested", "newly_granted", "already_granted", "missing", "granted"} {
v, ok := data[k]
if !ok {
t.Fatalf("missing key %q in payload: %v", k, data)
}
if _, ok := v.([]interface{}); !ok {
t.Fatalf("%s = %#v, want JSON array", k, v)
}
}
}
func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
tests := []struct {
name string
summary *loginScopeSummary
expectedPresent []string
expectedAbsent []string
}{
{
name: "mixed newly granted and already granted",
summary: &loginScopeSummary{
Requested: []string{"im:message:send", "im:message:reply"},
NewlyGranted: []string{"im:message:send"},
AlreadyGranted: []string{"im:message:reply"},
Granted: []string{"im:message:send", "im:message:reply"},
},
expectedPresent: []string{
"登录成功! 用户: tester (ou_user)",
"本次请求 scopes: im:message:send im:message:reply",
"本次新授予 scopes: im:message:send",
"本次未授予 scopes: (空)",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
},
expectedAbsent: []string{
"最终已授权 scopes:",
"已有 scopes:",
},
},
{
name: "all already granted",
summary: &loginScopeSummary{
Requested: []string{"im:message:send"},
AlreadyGranted: []string{"im:message:send"},
Granted: []string{"im:message:send", "contact:user.base:readonly"},
},
expectedPresent: []string{
"本次请求 scopes: im:message:send",
"本次新授予 scopes: (空)",
"本次未授予 scopes: (空)",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
},
expectedAbsent: []string{
"最终已授权 scopes:",
"已有 scopes:",
},
},
{
name: "missing scopes are shown",
summary: &loginScopeSummary{
Requested: []string{"im:message:send", "im:message:reply"},
Missing: []string{"im:message:send"},
Granted: []string{"im:message:reply"},
},
expectedPresent: []string{
"本次请求 scopes: im:message:send im:message:reply",
"本次新授予 scopes: (空)",
"本次未授予 scopes: im:message:send",
},
expectedAbsent: []string{
"已有 scopes:",
"最终已授权 scopes:",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
writeLoginSuccess(&LoginOptions{}, getLoginMsg("zh"), f, "ou_user", "tester", tt.summary)
got := stderr.String()
for _, want := range tt.expectedPresent {
if !strings.Contains(got, want) {
t.Fatalf("stderr missing %q, got:\n%s", want, got)
}
}
for _, unwanted := range tt.expectedAbsent {
if strings.Contains(got, unwanted) {
t.Fatalf("stderr should not contain %q, got:\n%s", unwanted, got)
}
}
})
}
}
func TestBuildLoginScopeSummary_WithMissingScopes(t *testing.T) {
summary := buildLoginScopeSummary("im:message:send im:message:reply", "im:message:reply", "im:message:reply")
if got := strings.Join(summary.NewlyGranted, " "); got != "" {
t.Fatalf("NewlyGranted = %q, want empty", got)
}
if got := strings.Join(summary.AlreadyGranted, " "); got != "im:message:reply" {
t.Fatalf("AlreadyGranted = %q", got)
}
if got := strings.Join(summary.Missing, " "); got != "im:message:send" {
t.Fatalf("Missing = %q", got)
}
}
func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{Name: "default", AppId: "cli_test"},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathDeviceAuthorization,
Body: map[string]interface{}{
"device_code": "device-code",
"user_code": "user-code",
"verification_uri": "https://example.com/verify",
"verification_uri_complete": "https://example.com/verify?code=123",
"expires_in": 240,
"interval": 0,
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthTokenV2,
Body: map[string]interface{}{
"access_token": "user-access-token",
"refresh_token": "refresh-token",
"expires_in": 7200,
"refresh_token_expires_in": 604800,
"scope": "offline_access",
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: larkauth.PathUserInfoV1,
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"open_id": "ou_user",
"name": "tester",
},
},
})
err := authLoginRun(&LoginOptions{
Factory: f,
Ctx: context.Background(),
Scope: "im:message:send",
})
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
got := stderr.String()
for _, want := range []string{
"OK: 登录成功! 用户: tester (ou_user)",
"授权完成,但以下请求 scopes 未被授予: im:message:send",
"本次请求 scopes: im:message:send",
"本次未授予 scopes: im:message:send",
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
"scope 被禁用",
"lark-cli auth status",
} {
if !strings.Contains(got, want) {
t.Fatalf("stderr missing %q, got:\n%s", want, got)
}
}
if strings.Contains(got, "最终已授权 scopes:") {
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
}
if strings.Contains(got, "ERROR:") {
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
}
stored := larkauth.GetStoredToken("cli_test", "ou_user")
if stored == nil {
t.Fatal("expected token to be stored when authorization succeeds with missing scopes")
}
if stored.Scope != "offline_access" {
t.Fatalf("stored scope = %q", stored.Scope)
}
cfg, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(cfg.Apps) != 1 || len(cfg.Apps[0].Users) != 1 {
t.Fatalf("unexpected users in config: %#v", cfg.Apps)
}
if cfg.Apps[0].Users[0].UserOpenId != "ou_user" {
t.Fatalf("stored user open id = %q", cfg.Apps[0].Users[0].UserOpenId)
}
if cfg.Apps[0].Users[0].UserName != "tester" {
t.Fatalf("stored user name = %q", cfg.Apps[0].Users[0].UserName)
}
}
func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{Name: "default", AppId: "cli_test"},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathDeviceAuthorization,
Body: map[string]interface{}{
"device_code": "device-code",
"user_code": "user-code",
"verification_uri": "https://example.com/verify",
"verification_uri_complete": "https://example.com/verify?code=123",
"expires_in": 240,
"interval": 0,
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthTokenV2,
Body: map[string]interface{}{
"access_token": "user-access-token",
"refresh_token": "refresh-token",
"expires_in": 7200,
"refresh_token_expires_in": 604800,
"scope": "im:message:send offline_access",
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: larkauth.PathUserInfoV1,
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"open_id": "ou_user",
"name": "tester",
},
},
})
err := authLoginRun(&LoginOptions{
Factory: f,
Ctx: context.Background(),
Scope: "im:message:send",
NoWait: true,
})
if err != nil {
t.Fatalf("no-wait authLoginRun() error = %v", err)
}
if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "im:message:send" {
t.Fatalf("loadLoginRequestedScope() = (%q, %v), want requested scope", got, err)
}
stdout.Reset()
stderr.Reset()
err = authLoginRun(&LoginOptions{
Factory: f,
Ctx: context.Background(),
DeviceCode: "device-code",
})
if err != nil {
t.Fatalf("device-code authLoginRun() error = %v", err)
}
got := stderr.String()
for _, want := range []string{
"OK: 登录成功! 用户: tester (ou_user)",
"本次请求 scopes: im:message:send",
"本次新授予 scopes: im:message:send",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
} {
if !strings.Contains(got, want) {
t.Fatalf("stderr missing %q, got:\n%s", want, got)
}
}
if strings.Contains(got, "最终已授权 scopes:") {
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
}
if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "" {
t.Fatalf("loadLoginRequestedScope() after cleanup = (%q, %v), want empty", got, err)
}
}
func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScopes(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
writeLoginSuccess(&LoginOptions{}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
Requested: []string{"im:message:send"},
NewlyGranted: []string{"im:message:send"},
Granted: []string{"im:message:send"},
})
got := stderr.String()
for _, want := range []string{
"Login successful! User: tester (ou_user)",
"Requested scopes: im:message:send",
"Newly granted scopes: im:message:send",
"Not granted scopes: (none)",
"Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
} {
if !strings.Contains(got, want) {
t.Fatalf("stderr missing %q, got:\n%s", want, got)
}
}
}
func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
if err := saveLoginRequestedScope("device-code", "im:message:send"); err != nil {
t.Fatalf("saveLoginRequestedScope() error = %v", err)
}
original := pollDeviceToken
t.Cleanup(func() { pollDeviceToken = original })
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
return &larkauth.DeviceFlowResult{OK: true, Token: nil}
}
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
err := authLoginRun(&LoginOptions{
Factory: f,
Ctx: context.Background(),
DeviceCode: "device-code",
})
if err == nil {
t.Fatal("expected error for nil token")
}
if !strings.Contains(err.Error(), "authorization succeeded but no token returned") {
t.Fatalf("error = %v, want nil token error", err)
}
if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "" {
t.Fatalf("loadLoginRequestedScope() after nil token = (%q, %v), want empty", got, err)
}
}
func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
f.IOStreams.Out = failWriter{}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathDeviceAuthorization,
Body: map[string]interface{}{
"device_code": "device-code",
"user_code": "user-code",
"verification_uri": "https://example.com/verify",
"verification_uri_complete": "https://example.com/verify?code=123",
"expires_in": 240,
"interval": 5,
},
})
err := authLoginRun(&LoginOptions{
Factory: f,
Ctx: context.Background(),
Scope: "im:message:send",
NoWait: true,
JSON: true,
})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "failed to write JSON output") {
t.Fatalf("error = %v, want JSON write failure", err)
}
}
func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
f.IOStreams.Out = failWriter{}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathDeviceAuthorization,
Body: map[string]interface{}{
"device_code": "device-code",
"user_code": "user-code",
"verification_uri": "https://example.com/verify",
"verification_uri_complete": "https://example.com/verify?code=123",
"expires_in": 240,
"interval": 5,
},
})
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := authLoginRun(&LoginOptions{
Factory: f,
Ctx: ctx,
Scope: "im:message:send",
JSON: true,
})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "failed to write JSON output") {
t.Fatalf("error = %v, want JSON write failure", err)
}
}
func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
domains := getDomainMetadata("zh")
for _, dm := range domains {
@@ -290,3 +903,37 @@ func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
}
}
}
func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
domains := allKnownDomains()
if domains["whiteboard"] {
t.Error("whiteboard should not appear in known auth domains (it has auth_domain=docs)")
}
if !domains["docs"] {
t.Error("docs should still be a known auth domain")
}
}
func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) {
scopes := collectScopesForDomains([]string{"docs"}, "user")
// docs domain should include whiteboard shortcut scopes (board:whiteboard:*)
found := false
for _, s := range scopes {
if strings.HasPrefix(s, "board:whiteboard:") {
found = true
break
}
}
if !found {
t.Error("collectScopesForDomains([docs]) should include whiteboard scopes (board:whiteboard:*)")
}
}
func TestGetDomainMetadata_ExcludesAuthDomainChildren(t *testing.T) {
domains := getDomainMetadata("zh")
for _, dm := range domains {
if dm.Name == "whiteboard" {
t.Error("whiteboard should not appear in interactive domain list (has auth_domain=docs)")
}
}
}

View File

@@ -6,6 +6,8 @@ package config
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
@@ -21,6 +23,17 @@ func (n *noopConfigKeychain) Get(service, account string) (string, error) { retu
func (n *noopConfigKeychain) Set(service, account, value string) error { return nil }
func (n *noopConfigKeychain) Remove(service, account string) error { return nil }
type recordingConfigKeychain struct {
removed []string
}
func (r *recordingConfigKeychain) Get(service, account string) (string, error) { return "", nil }
func (r *recordingConfigKeychain) Set(service, account, value string) error { return nil }
func (r *recordingConfigKeychain) Remove(service, account string) error {
r.removed = append(r.removed, service+":"+account)
return nil
}
func TestConfigInitCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret123\n")
@@ -221,6 +234,66 @@ func TestConfigRemoveCmd_FlagParsing(t *testing.T) {
}
}
func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
multi := &core.MultiAppConfig{
Apps: []core.AppConfig{{
AppId: "app-test",
AppSecret: core.SecretInput{
Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-test"},
},
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "Tester"}},
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
kc := &recordingConfigKeychain{}
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.Keychain = kc
// Make subsequent config saves fail while keeping the existing config readable.
if err := os.Chmod(configDir, 0500); err != nil {
t.Fatalf("Chmod(%s) error = %v", configDir, err)
}
defer os.Chmod(configDir, 0700)
err := configRemoveRun(&ConfigRemoveOptions{Factory: f})
if err == nil {
t.Fatal("expected save failure")
}
if !strings.Contains(err.Error(), "failed to save config") {
t.Fatalf("error = %v, want failed to save config", err)
}
if len(kc.removed) != 0 {
t.Fatalf("expected no keychain cleanup before successful save, got removals: %v", kc.removed)
}
// Restore permissions and confirm the original config is still intact.
if err := os.Chmod(configDir, 0700); err != nil {
t.Fatalf("restore Chmod(%s) error = %v", configDir, err)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if saved == nil || len(saved.Apps) != 1 || saved.Apps[0].AppId != "app-test" {
t.Fatalf("saved config = %#v, want original single app preserved", saved)
}
if got := saved.Apps[0].AppSecret.Ref; got == nil || got.ID != "appsecret:app-test" {
t.Fatalf("saved app secret ref = %#v, want preserved keychain ref", got)
}
configPath := filepath.Join(configDir, "config.json")
if _, err := os.Stat(configPath); err != nil {
t.Fatalf("expected existing config file to remain, stat error = %v", err)
}
}
func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())

View File

@@ -177,17 +177,26 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
// Step 2: Build and display verification URL + QR code
verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version)
// Show QR code in terminal
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
if qrErr == nil {
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
// Branch on TTY: human-friendly copy in interactive terminals,
// preserve original copy for AI / non-interactive callers.
if f.IOStreams.IsTerminal {
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanQRCode)
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
if qrErr == nil {
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
}
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanOrOpenLink)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScan)
} else {
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
if qrErr == nil {
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
}
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.OpenLinkNonTTY)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScanNonTTY)
}
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanOrOpenLink)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
// Step 3: Poll for result
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScan)
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if err != nil {
return nil, output.ErrAuth("%v", err)

View File

@@ -16,11 +16,16 @@ type initMsg struct {
Platform string
SelectPlatform string
Feishu string
ScanOrOpenLink string
WaitingForScan string
DetectedLarkTenant string
AppCreated string
ConfigSaved string
// TTY (interactive) variants
ScanQRCode string // header shown above QR code
ScanOrOpenLink string // post-QR alt link prompt ("or open...")
WaitingForScan string // active polling indicator
// Non-TTY (AI / non-interactive) variants — preserve original copy
OpenLinkNonTTY string // primary link prompt
WaitingForScanNonTTY string // passive waiting indicator
DetectedLarkTenant string
AppCreated string
ConfigSaved string
}
var initMsgZh = &initMsg{
@@ -29,12 +34,15 @@ var initMsgZh = &initMsg{
ConfigExistingApp: "手动输入应用凭证",
Platform: "平台",
SelectPlatform: "选择平台",
Feishu: "飞书",
ScanOrOpenLink: "\n打开以下链接配置应用:\n\n",
WaitingForScan: "等待配置应用...",
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
AppCreated: "应用配置成功! App ID: %s",
ConfigSaved: "应用配置成功! App ID: %s",
Feishu: "飞书",
ScanQRCode: "\n使用飞书 / Lark 扫码配置应用\n\n",
ScanOrOpenLink: "\n或打开以下链接完成配置\n",
WaitingForScan: "正在获取你的应用配置结果...",
OpenLinkNonTTY: "\n打开以下链接配置应用:\n\n",
WaitingForScanNonTTY: "等待配置应用...",
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
AppCreated: "应用配置成功! App ID: %s",
ConfigSaved: "应用配置成功! App ID: %s",
}
var initMsgEn = &initMsg{
@@ -43,12 +51,15 @@ var initMsgEn = &initMsg{
ConfigExistingApp: "Enter app credentials yourself",
Platform: "Platform",
SelectPlatform: "Select platform",
Feishu: "Feishu",
ScanOrOpenLink: "\nOpen the link below to configure app:\n\n",
WaitingForScan: "Waiting for app configuration...",
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
AppCreated: "App configured! App ID: %s",
ConfigSaved: "App configured! App ID: %s",
Feishu: "Feishu",
ScanQRCode: "\nScan the QR code with Feishu/Lark:\n\n",
ScanOrOpenLink: "\nOr open the link below in your browser:\n",
WaitingForScan: "Fetching configuration results...",
OpenLinkNonTTY: "\nOpen the link below to configure app:\n\n",
WaitingForScanNonTTY: "Waiting for app configuration...",
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
AppCreated: "App configured! App ID: %s",
ConfigSaved: "App configured! App ID: %s",
}
func getInitMsg(lang string) *initMsg {

View File

@@ -54,11 +54,14 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
"Platform": msg.Platform,
"SelectPlatform": msg.SelectPlatform,
"Feishu": msg.Feishu,
"ScanOrOpenLink": msg.ScanOrOpenLink,
"WaitingForScan": msg.WaitingForScan,
"DetectedLarkTenant": msg.DetectedLarkTenant,
"AppCreated": msg.AppCreated,
"ConfigSaved": msg.ConfigSaved,
"ScanQRCode": msg.ScanQRCode,
"ScanOrOpenLink": msg.ScanOrOpenLink,
"WaitingForScan": msg.WaitingForScan,
"OpenLinkNonTTY": msg.OpenLinkNonTTY,
"WaitingForScanNonTTY": msg.WaitingForScanNonTTY,
"DetectedLarkTenant": msg.DetectedLarkTenant,
"AppCreated": msg.AppCreated,
"ConfigSaved": msg.ConfigSaved,
}
for name, val := range fields {
if val == "" {

View File

@@ -44,19 +44,21 @@ func configRemoveRun(opts *ConfigRemoveOptions) error {
return output.ErrValidation("not configured yet")
}
// Clean up keychain entries for all apps
for _, app := range config.Apps {
core.RemoveSecretStore(app.AppSecret, f.Keychain)
for _, user := range app.Users {
auth.RemoveStoredToken(app.AppId, user.UserOpenId)
}
}
// Save empty config
// Save empty config first. If this fails, keep secrets and tokens intact so the
// existing config can still be retried instead of ending up half-removed.
empty := &core.MultiAppConfig{Apps: []core.AppConfig{}}
if err := core.SaveMultiAppConfig(empty); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
// Clean up keychain entries for all apps after config is cleared.
for _, app := range config.Apps {
core.RemoveSecretStore(app.AppSecret, f.Keychain)
for _, user := range app.Users {
_ = auth.RemoveStoredToken(app.AppId, user.UserOpenId)
}
}
output.PrintSuccess(f.IOStreams.ErrOut, "Configuration removed")
userCount := 0
for _, app := range config.Apps {

203
cmd/diagnose_scope_test.go Normal file
View File

@@ -0,0 +1,203 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
shortcutTypes "github.com/larksuite/cli/shortcuts/common"
)
// ── Data types ────────────────────────────────────────────────────────
type diagMethodEntry struct {
Domain string `json:"domain"`
Type string `json:"type"` // "api" or "shortcut"
Method string `json:"method"` // "calendar.calendars.search" or "+agenda"
Scope string `json:"scope"` // minimum-privilege scope
Identity []string `json:"identity"` // ["user"], ["bot"], or ["user","bot"]
}
type diagScopeInfo struct {
Scope string `json:"scope"`
Recommend bool `json:"recommend"`
InPriority bool `json:"in_priority"`
}
type diagOutput struct {
Methods []diagMethodEntry `json:"methods"`
Scopes []diagScopeInfo `json:"scopes"`
}
// ── Core logic ────────────────────────────────────────────────────────
// diagAllKnownDomains returns sorted, deduplicated domain names from both
// from_meta projects and shortcuts.
func diagAllKnownDomains() []string {
seen := make(map[string]bool)
for _, p := range registry.ListFromMetaProjects() {
seen[p] = true
}
for _, s := range shortcuts.AllShortcuts() {
if s.Service != "" {
seen[s.Service] = true
}
}
result := make([]string, 0, len(seen))
for d := range seen {
result = append(result, d)
}
sort.Strings(result)
return result
}
// methodKey uniquely identifies a method+scope pair for merging identities.
type methodKey struct {
domain string
typ string
method string
scope string
}
// diagBuild builds the full output: flat methods list (merged identities) + scopes.
func diagBuild(domains []string) diagOutput {
recommend := registry.LoadAutoApproveSet()
identities := []string{"user", "bot"}
merged := make(map[methodKey]*diagMethodEntry)
allSC := shortcuts.AllShortcuts()
for _, domain := range domains {
for _, identity := range identities {
for _, ce := range registry.CollectCommandScopes([]string{domain}, identity) {
for _, scope := range ce.Scopes {
method := domain + "." + strings.ReplaceAll(ce.Command, " ", ".")
k := methodKey{domain, "api", method, scope}
if e, ok := merged[k]; ok {
e.Identity = appendUniq(e.Identity, identity)
} else {
merged[k] = &diagMethodEntry{
Domain: domain, Type: "api",
Method: method,
Scope: scope, Identity: []string{identity},
}
}
}
}
for _, sc := range allSC {
if sc.Service != domain || !diagShortcutSupportsIdentity(&sc, identity) {
continue
}
for _, scope := range sc.ScopesForIdentity(identity) {
k := methodKey{domain, "shortcut", sc.Command, scope}
if e, ok := merged[k]; ok {
e.Identity = appendUniq(e.Identity, identity)
} else {
merged[k] = &diagMethodEntry{
Domain: domain, Type: "shortcut",
Method: sc.Command,
Scope: scope, Identity: []string{identity},
}
}
}
}
}
}
methods := make([]diagMethodEntry, 0, len(merged))
scopeSet := make(map[string]bool)
for _, e := range merged {
methods = append(methods, *e)
scopeSet[e.Scope] = true
}
sort.Slice(methods, func(i, j int) bool {
if methods[i].Domain != methods[j].Domain {
return methods[i].Domain < methods[j].Domain
}
if methods[i].Type != methods[j].Type {
return methods[i].Type < methods[j].Type
}
if methods[i].Method != methods[j].Method {
return methods[i].Method < methods[j].Method
}
return methods[i].Scope < methods[j].Scope
})
scopeList := make([]string, 0, len(scopeSet))
for s := range scopeSet {
scopeList = append(scopeList, s)
}
sort.Strings(scopeList)
priorities := registry.LoadScopePriorities()
scopes := make([]diagScopeInfo, len(scopeList))
for i, s := range scopeList {
_, inPri := priorities[s]
scopes[i] = diagScopeInfo{Scope: s, Recommend: recommend[s], InPriority: inPri}
}
return diagOutput{Methods: methods, Scopes: scopes}
}
func diagShortcutSupportsIdentity(sc *shortcutTypes.Shortcut, identity string) bool {
if len(sc.AuthTypes) == 0 {
return identity == "user"
}
for _, a := range sc.AuthTypes {
if a == identity {
return true
}
}
return false
}
func appendUniq(ss []string, s string) []string {
for _, existing := range ss {
if existing == s {
return ss
}
}
return append(ss, s)
}
// ── Snapshot generation ───────────────────────────────────────────────
//
// Generates a JSON snapshot of all API methods and shortcuts with their
// minimum-privilege scopes. Consumed by scripts/scope_audit.py.
//
// Usage:
//
// SCOPE_SNAPSHOT_DIR=/tmp/scope-audit go test ./cmd/ -run TestScopeSnapshot -v
func TestScopeSnapshot(t *testing.T) {
dir := os.Getenv("SCOPE_SNAPSHOT_DIR")
if dir == "" {
t.Skip("set SCOPE_SNAPSHOT_DIR to enable snapshot generation")
}
registry.Init()
result := diagBuild(diagAllKnownDomains())
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(dir, "snapshot.json")
data, err := json.MarshalIndent(result, "", " ")
if err != nil {
t.Fatalf("marshal: %v", err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatalf("write: %v", err)
}
t.Logf("Wrote %s (%d methods, %d scopes)", path, len(result.Methods), len(result.Scopes))
}

View File

@@ -238,7 +238,7 @@ func checkCLIUpdate() []checkResult {
if update.IsNewer(latest, current) {
return []checkResult{warn("cli_update",
fmt.Sprintf("%s → %s available", current, latest),
"run: npm update -g @larksuite/cli")}
"run: lark-cli update (or: npm install -g @larksuite/cli)")}
}
return []checkResult{pass("cli_update", latest+" (up to date)")}
}

View File

@@ -22,6 +22,7 @@ import (
"github.com/larksuite/cli/cmd/profile"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/cmd/service"
cmdupdate "github.com/larksuite/cli/cmd/update"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
@@ -78,7 +79,7 @@ AI AGENT SKILLS:
npx skills add larksuite/cli -s lark-calendar -y
npx skills add larksuite/cli -s lark-im -y
Learn more: https://github.com/larksuite/cli#install-ai-agent-skills
Learn more: https://github.com/larksuite/cli#agent-skills
COMMUNITY:
GitHub: https://github.com/larksuite/cli
@@ -118,6 +119,7 @@ func Execute() int {
rootCmd.AddCommand(api.NewCmdApi(f, nil))
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
service.RegisterServiceCommands(rootCmd, f)
shortcuts.RegisterShortcuts(rootCmd, f)

View File

@@ -187,3 +187,12 @@ func TestEnrichPermissionError_SpecialCharsEscaped(t *testing.T) {
})
}
}
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
}
if strings.Contains(rootLong, "https://github.com/larksuite/cli#install-ai-agent-skills") {
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
}
}

View File

@@ -73,6 +73,12 @@ func printResourceList(w io.Writer, spec map[string]interface{}) {
fmt.Fprintf(w, "%sUsage: lark-cli schema %s.<resource>.<method>%s\n", output.Dim, name, output.Reset)
}
// hasFileFields returns true if any requestBody field has type "file".
func hasFileFields(method map[string]interface{}) (bool, []string) {
names := cmdutil.DetectFileFields(method)
return len(names) > 0, names
}
func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, methodName string, method map[string]interface{}) {
servicePath := registry.GetStrFromMap(spec, "servicePath")
specName := registry.GetStrFromMap(spec, "name")
@@ -80,6 +86,7 @@ func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, method
fullPath := servicePath + "/" + methodPath
httpMethod := registry.GetStrFromMap(method, "httpMethod")
desc := registry.GetStrFromMap(method, "description")
isFileUpload, fileFieldNames := hasFileFields(method)
fmt.Fprintf(w, "%s%s.%s.%s%s\n\n", output.Bold, specName, resName, methodName, output.Reset)
@@ -138,11 +145,25 @@ func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, method
if len(params) == 0 {
fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset)
}
fmt.Fprintf(w, " %s--data%s <json> %soptional%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
fileUploadTag := ""
if isFileUpload {
fileUploadTag = fmt.Sprintf(" %s[file upload]%s", output.Yellow, output.Reset)
}
fmt.Fprintf(w, " %s--data%s <json> %soptional%s%s\n", output.Cyan, output.Reset, output.Dim, output.Reset, fileUploadTag)
requestBody, _ := method["requestBody"].(map[string]interface{})
if len(requestBody) > 0 {
printNestedFields(w, requestBody, " ", "")
}
if isFileUpload {
if len(fileFieldNames) == 1 {
fmt.Fprintf(w, "\n %s--file%s <[field=]path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
fmt.Fprintf(w, " Upload file as multipart/form-data. Default field: %q\n", fileFieldNames[0])
} else {
fmt.Fprintf(w, "\n %s--file%s <field=path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
fmt.Fprintf(w, " Upload file as multipart/form-data. Fields: %s\n", strings.Join(fileFieldNames, ", "))
}
}
fmt.Fprintln(w)
}
@@ -184,7 +205,13 @@ func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, method
}
// CLI example
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s\n", output.Bold, output.Reset, specName, resName, methodName)
if isFileUpload && len(fileFieldNames) == 1 {
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <path>\n", output.Bold, output.Reset, specName, resName, methodName)
} else if isFileUpload {
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <field=path>\n", output.Bold, output.Reset, specName, resName, methodName)
} else {
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s\n", output.Bold, output.Reset, specName, resName, methodName)
}
// Docs
if docUrl := registry.GetStrFromMap(method, "docUrl"); docUrl != "" {

View File

@@ -4,6 +4,7 @@
package schema
import (
"bytes"
"strings"
"testing"
@@ -61,3 +62,123 @@ func TestSchemaCmd_UnknownService(t *testing.T) {
t.Errorf("expected 'Unknown service' error, got: %v", err)
}
}
func TestPrintMethodDetail_FileUpload(t *testing.T) {
spec := map[string]interface{}{
"name": "im",
"servicePath": "/open-apis/im/v1",
}
method := map[string]interface{}{
"path": "images",
"httpMethod": "POST",
"description": "Upload an image",
"requestBody": map[string]interface{}{
"image_type": map[string]interface{}{
"type": "string",
"required": true,
},
"image": map[string]interface{}{
"type": "file",
"required": true,
},
},
"accessTokens": []interface{}{"user", "tenant"},
}
var buf bytes.Buffer
printMethodDetail(&buf, spec, "images", "create", method)
out := buf.String()
if !strings.Contains(out, "file upload") {
t.Errorf("expected 'file upload' marker in output, got:\n%s", out)
}
if !strings.Contains(out, "--file") {
t.Errorf("expected '--file' in output, got:\n%s", out)
}
if !strings.Contains(out, `"image"`) {
t.Errorf("expected default field name 'image' in output, got:\n%s", out)
}
if !strings.Contains(out, "--file <path>") {
t.Errorf("expected CLI example with --file <path>, got:\n%s", out)
}
}
func TestPrintMethodDetail_NoFileUpload(t *testing.T) {
spec := map[string]interface{}{
"name": "calendar",
"servicePath": "/open-apis/calendar/v4",
}
method := map[string]interface{}{
"path": "events",
"httpMethod": "POST",
"description": "Create an event",
"requestBody": map[string]interface{}{
"summary": map[string]interface{}{
"type": "string",
"required": true,
},
},
}
var buf bytes.Buffer
printMethodDetail(&buf, spec, "events", "create", method)
out := buf.String()
if strings.Contains(out, "file upload") {
t.Errorf("did not expect 'file upload' marker for non-file method, got:\n%s", out)
}
if strings.Contains(out, "--file") {
t.Errorf("did not expect '--file' for non-file method, got:\n%s", out)
}
}
func TestHasFileFields(t *testing.T) {
tests := []struct {
name string
method map[string]interface{}
wantBool bool
wantFields []string
}{
{
name: "has file field",
method: map[string]interface{}{
"requestBody": map[string]interface{}{
"image": map[string]interface{}{"type": "file"},
"name": map[string]interface{}{"type": "string"},
},
},
wantBool: true,
wantFields: []string{"image"},
},
{
name: "no file field",
method: map[string]interface{}{
"requestBody": map[string]interface{}{
"name": map[string]interface{}{"type": "string"},
},
},
wantBool: false,
wantFields: nil,
},
{
name: "no requestBody",
method: map[string]interface{}{},
wantBool: false,
wantFields: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, names := hasFileFields(tt.method)
if got != tt.wantBool {
t.Errorf("hasFileFields() = %v, want %v", got, tt.wantBool)
}
if tt.wantFields == nil && names != nil {
t.Errorf("expected nil names, got %v", names)
}
if tt.wantFields != nil && len(names) != len(tt.wantFields) {
t.Errorf("expected %d field names, got %d", len(tt.wantFields), len(names))
}
})
}
}

View File

@@ -5,7 +5,6 @@ package service
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
@@ -112,6 +111,13 @@ type ServiceMethodOptions struct {
Format string
JqExpr string
DryRun bool
File string // --file flag value
FileFields []string // auto-detected file field names from metadata
}
// detectFileFields delegates to the shared cmdutil.DetectFileFields helper.
func detectFileFields(method map[string]interface{}) []string {
return cmdutil.DetectFileFields(method)
}
func registerMethod(parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
@@ -148,10 +154,10 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
},
}
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON")
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin)")
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
}
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
@@ -162,6 +168,16 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
// Conditionally register --file for methods with file-type fields.
fileFields := detectFileFields(method)
opts.FileFields = fileFields
if len(fileFields) > 0 {
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)")
}
}
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
})
@@ -213,12 +229,15 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
}
}
request, err := buildServiceRequest(opts)
request, fileMeta, err := buildServiceRequest(opts)
if err != nil {
return err
}
if opts.DryRun {
if fileMeta != nil {
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
}
return serviceDryRun(f, request, config, opts.Format)
}
@@ -250,6 +269,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
FileIO: f.ResolveFileIO(opts.Ctx),
CheckError: checkErr,
})
}
@@ -303,19 +323,28 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
}
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, error) {
// When dryRun is true and a file is provided, file reading is skipped and
// FileUploadMeta is returned instead so the caller can render dry-run output.
func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
spec := opts.Spec
method := opts.Method
schemaPath := opts.SchemaPath
httpMethod := registry.GetStrFromMap(method, "httpMethod")
var params map[string]interface{}
if opts.Params != "" {
if err := json.Unmarshal([]byte(opts.Params), &params); err != nil {
return client.RawApiRequest{}, output.ErrValidation("--params invalid JSON format")
}
} else {
params = map[string]interface{}{}
// stdin is an io.Reader consumed at most once. Only one of --params/--data
// may use "-" (stdin); the conflict check below prevents silent data loss.
stdin := opts.Factory.IOStreams.In
// Validate --file mutual exclusions.
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, httpMethod); err != nil {
return client.RawApiRequest{}, nil, err
}
if opts.Params == "-" && opts.Data == "-" {
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
if err != nil {
return client.RawApiRequest{}, nil, err
}
url := registry.GetStrFromMap(spec, "servicePath") + "/" + registry.GetStrFromMap(method, "path")
@@ -328,13 +357,13 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
}
val, ok := params[name]
if !ok || util.IsEmptyValue(val) {
return client.RawApiRequest{}, output.ErrWithHint(output.ExitValidation, "validation",
return client.RawApiRequest{}, nil, output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("missing required path parameter: %s", name),
fmt.Sprintf("lark-cli schema %s", schemaPath))
}
valStr := fmt.Sprintf("%v", val)
if err := validate.ResourceName(valStr, name); err != nil {
return client.RawApiRequest{}, output.ErrValidation("%s", err)
return client.RawApiRequest{}, nil, output.ErrValidation("%s", err)
}
url = strings.Replace(url, "{"+name+"}", validate.EncodePathSegment(valStr), 1)
delete(params, name)
@@ -350,7 +379,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
required, _ := p["required"].(bool)
isPaginationParam := opts.PageAll && (name == "page_token" || name == "page_size")
if required && !isPaginationParam && (!exists || util.IsEmptyValue(value)) {
return client.RawApiRequest{}, output.ErrWithHint(output.ExitValidation, "validation",
return client.RawApiRequest{}, nil, output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("missing required query parameter: %s", name),
fmt.Sprintf("lark-cli schema %s", schemaPath))
}
@@ -364,22 +393,60 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
}
}
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data)
if err != nil {
return client.RawApiRequest{}, err
}
request := client.RawApiRequest{
Method: httpMethod,
URL: url,
Params: queryParams,
Data: data,
As: opts.As,
}
if opts.Output != "" {
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
if opts.File != "" {
// File upload: determine default field name from metadata.
defaultField := "file"
if len(opts.FileFields) == 1 {
defaultField = opts.FileFields[0]
}
fieldName, filePath, isStdin := cmdutil.ParseFileFlag(opts.File, defaultField)
// Parse --data as form fields.
var dataFields any
if opts.Data != "" {
dataFields, err = cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
if err != nil {
return client.RawApiRequest{}, nil, err
}
if _, ok := dataFields.(map[string]any); !ok {
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
}
}
if opts.DryRun {
return request, &cmdutil.FileUploadMeta{
FieldName: fieldName, FilePath: filePath, FormFields: dataFields,
}, nil
}
fd, err := cmdutil.BuildFormdata(
opts.Factory.ResolveFileIO(opts.Ctx),
fieldName, filePath, isStdin, stdin, dataFields,
)
if err != nil {
return client.RawApiRequest{}, nil, err
}
request.Data = fd
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
} else {
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
if err != nil {
return client.RawApiRequest{}, nil, err
}
request.Data = data
if opts.Output != "" {
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
}
}
return request, nil
return request, nil, nil
}
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {

View File

@@ -4,6 +4,7 @@
package service
import (
"os"
"strings"
"testing"
@@ -308,7 +309,7 @@ func TestServiceMethod_InvalidParamsJSON(t *testing.T) {
if err == nil {
t.Fatal("expected error for invalid JSON")
}
if !strings.Contains(err.Error(), "--params invalid JSON format") {
if !strings.Contains(err.Error(), "--params invalid format") {
t.Errorf("unexpected error: %v", err)
}
}
@@ -331,6 +332,24 @@ func TestServiceMethod_InvalidDataJSON(t *testing.T) {
}
}
func TestServiceMethod_ParamsAndDataBothStdinConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}}
cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil)
cmd.SetArgs([]string{"--params", "-", "--data", "-", "--dry-run"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error when both --params and --data use stdin")
}
if !strings.Contains(err.Error(), "cannot both read from stdin") {
t.Errorf("expected stdin conflict error, got: %v", err)
}
}
func TestServiceMethod_OutputAndPageAllConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
@@ -692,6 +711,144 @@ func TestScopeAwareChecker_ScopeError_BotMode(t *testing.T) {
}
}
// ── file upload ──
func imImageMethod() map[string]interface{} {
return map[string]interface{}{
"path": "images",
"httpMethod": "POST",
"requestBody": map[string]interface{}{
"image_type": map[string]interface{}{
"type": "string",
"required": true,
},
"image": map[string]interface{}{
"type": "file",
"required": true,
},
},
"accessTokens": []interface{}{"user", "tenant"},
}
}
func imSpec() map[string]interface{} {
return map[string]interface{}{
"name": "im",
"servicePath": "/open-apis/im/v1",
}
}
func TestServiceMethod_FileFlagRegistered(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imImageMethod(), "create", "images", nil)
flag := cmd.Flags().Lookup("file")
if flag == nil {
t.Fatal("expected --file flag to be registered for file upload method")
}
}
func TestServiceMethod_FileFlagNotRegistered(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("POST", nil), "copy", "files", nil)
flag := cmd.Flags().Lookup("file")
if flag != nil {
t.Fatal("expected --file flag NOT to be registered for non-file method")
}
}
func TestServiceMethod_FileFlagNotRegisteredForGET(t *testing.T) {
getMethod := map[string]interface{}{
"path": "images",
"httpMethod": "GET",
"requestBody": map[string]interface{}{
"image": map[string]interface{}{
"type": "file",
},
},
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), getMethod, "get", "images", nil)
flag := cmd.Flags().Lookup("file")
if flag != nil {
t.Fatal("expected --file flag NOT to be registered for GET method")
}
}
func TestServiceMethod_FileUpload_DryRun(t *testing.T) {
tmpDir := t.TempDir()
tmpFile := tmpDir + "/test.jpg"
if err := os.WriteFile(tmpFile, []byte("fake-image"), 0600); err != nil {
t.Fatal(err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imImageMethod(), "create", "images", nil)
cmd.SetArgs([]string{
"--file", "image=" + tmpFile,
"--data", `{"image_type":"message"}`,
"--dry-run",
"--as", "bot",
})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "image") {
t.Errorf("expected dry-run output to mention file field, got: %s", out)
}
if !strings.Contains(out, "Dry Run") {
t.Errorf("expected dry-run header, got: %s", out)
}
}
func TestDetectFileFields(t *testing.T) {
tests := []struct {
name string
method map[string]interface{}
want []string
}{
{
name: "single file field",
method: map[string]interface{}{
"requestBody": map[string]interface{}{
"image": map[string]interface{}{"type": "file"},
"name": map[string]interface{}{"type": "string"},
},
},
want: []string{"image"},
},
{
name: "no file fields",
method: map[string]interface{}{
"requestBody": map[string]interface{}{
"name": map[string]interface{}{"type": "string"},
},
},
want: nil,
},
{
name: "no requestBody",
method: map[string]interface{}{},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := detectFileFields(tt.method)
if len(got) != len(tt.want) {
t.Errorf("detectFileFields() = %v, want %v", got, tt.want)
return
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("detectFileFields()[%d] = %q, want %q", i, got[i], tt.want[i])
}
}
})
}
}
// ── helpers ──
func isExitError(err error, target **output.ExitError) bool {

314
cmd/update/update.go Normal file
View File

@@ -0,0 +1,314 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdupdate
import (
"fmt"
"runtime"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/selfupdate"
"github.com/larksuite/cli/internal/update"
)
const (
repoURL = "https://github.com/larksuite/cli"
maxNpmOutput = 2000
osWindows = "windows"
)
// Overridable for testing.
var (
fetchLatest = func() (string, error) { return update.FetchLatest() }
currentVersion = func() string { return build.Version }
currentOS = runtime.GOOS
newUpdater = func() *selfupdate.Updater { return selfupdate.New() }
)
func isWindows() bool { return currentOS == osWindows }
func releaseURL(version string) string {
return repoURL + "/releases/tag/v" + strings.TrimPrefix(version, "v")
}
func changelogURL() string { return repoURL + "/blob/main/CHANGELOG.md" }
// --- Terminal symbols (ASCII fallback on Windows) ---
func symOK() string {
if isWindows() {
return "[OK]"
}
return "✓"
}
func symFail() string {
if isWindows() {
return "[FAIL]"
}
return "✗"
}
func symWarn() string {
if isWindows() {
return "[WARN]"
}
return "⚠"
}
func symArrow() string {
if isWindows() {
return "->"
}
return "→"
}
// --- Command ---
// UpdateOptions holds inputs for the update command.
type UpdateOptions struct {
Factory *cmdutil.Factory
JSON bool
Force bool
Check bool
}
// NewCmdUpdate creates the update command.
func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
opts := &UpdateOptions{Factory: f}
cmd := &cobra.Command{
Use: "update",
Short: "Update lark-cli to the latest version",
Long: `Update lark-cli to the latest version.
Detects the installation method automatically:
- npm install: runs npm install -g @larksuite/cli@<version>
- manual/other: shows GitHub Releases download URL
Use --json for structured output (for AI agents and scripts).
Use --check to only check for updates without installing.`,
RunE: func(cmd *cobra.Command, args []string) error {
return updateRun(opts)
},
}
cmdutil.DisableAuthCheck(cmd)
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmd.Flags().BoolVar(&opts.Force, "force", false, "force reinstall even if already up to date")
cmd.Flags().BoolVar(&opts.Check, "check", false, "only check for updates, do not install")
return cmd
}
func updateRun(opts *UpdateOptions) error {
io := opts.Factory.IOStreams
cur := currentVersion()
updater := newUpdater()
updater.CleanupStaleFiles()
output.PendingNotice = nil
// 1. Fetch latest version
latest, err := fetchLatest()
if err != nil {
return reportError(opts, io, output.ExitNetwork, "network", "failed to check latest version: %s", err)
}
// 2. Validate version format
if update.ParseVersion(latest) == nil {
return reportError(opts, io, output.ExitInternal, "update_error", "invalid version from registry: %s", latest)
}
// 3. Compare versions
if !opts.Force && !update.IsNewer(latest, cur) {
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": true, "previous_version": cur, "current_version": cur,
"latest_version": latest, "action": "already_up_to_date",
"message": fmt.Sprintf("lark-cli %s is already up to date", cur),
})
return nil
}
fmt.Fprintf(io.ErrOut, "%s lark-cli %s is already up to date\n", symOK(), cur)
return nil
}
// 4. Detect installation method
detect := updater.DetectInstallMethod()
// 5. --check
if opts.Check {
return reportCheckResult(opts, io, cur, latest, detect.CanAutoUpdate())
}
// 6. Execute update
if !detect.CanAutoUpdate() {
return doManualUpdate(opts, io, cur, latest, detect)
}
return doNpmUpdate(opts, io, cur, latest, updater)
}
// --- Output helpers ---
func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, exitCode int, errType, format string, args ...interface{}) error {
msg := fmt.Sprintf(format, args...)
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "error": map[string]interface{}{"type": errType, "message": msg},
})
return output.ErrBare(exitCode)
}
return output.Errorf(exitCode, errType, "%s", msg)
}
func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, canAutoUpdate bool) error {
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": true, "previous_version": cur, "current_version": cur,
"latest_version": latest, "action": "update_available",
"auto_update": canAutoUpdate,
"message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest),
"url": releaseURL(latest), "changelog": changelogURL(),
})
return nil
}
fmt.Fprintf(io.ErrOut, "Update available: %s %s %s\n", cur, symArrow(), latest)
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
if canAutoUpdate {
fmt.Fprintf(io.ErrOut, "\nRun `lark-cli update` to install.\n")
} else {
fmt.Fprintf(io.ErrOut, "\nDownload the release above to update manually.\n")
}
return nil
}
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult) error {
reason := detect.ManualReason()
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": true, "previous_version": cur, "latest_version": latest,
"action": "manual_required",
"message": fmt.Sprintf("Automatic update unavailable: %s (path: %s)", reason, detect.ResolvedPath),
"url": releaseURL(latest), "changelog": changelogURL(),
})
return nil
}
fmt.Fprintf(io.ErrOut, "Automatic update unavailable: %s (path: %s).\n\n", reason, detect.ResolvedPath)
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
fmt.Fprintf(io.ErrOut, "\nOr install via npm:\n npm install -g %s@%s\n", selfupdate.NpmPackage, latest)
fmt.Fprintf(io.ErrOut, "\nAfter updating, also update skills:\n npx -y skills add larksuite/cli -g -y\n")
return nil
}
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
restore, err := updater.PrepareSelfReplace()
if err != nil {
return reportError(opts, io, output.ExitAPI, "update_error", "failed to prepare update: %s", err)
}
if !opts.JSON {
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
}
npmResult := updater.RunNpmInstall(latest)
if npmResult.Err != nil {
restore()
combined := npmResult.CombinedOutput()
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "error": map[string]interface{}{
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
"detail": selfupdate.Truncate(combined, maxNpmOutput),
"hint": permissionHint(combined),
},
})
return output.ErrBare(output.ExitAPI)
}
if npmResult.Stdout.Len() > 0 {
fmt.Fprint(io.ErrOut, npmResult.Stdout.String())
}
if npmResult.Stderr.Len() > 0 {
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
}
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
if hint := permissionHint(combined); hint != "" {
fmt.Fprintf(io.ErrOut, " %s\n", hint)
}
return output.ErrBare(output.ExitAPI)
}
// Verify the new binary is functional before proceeding.
// If corrupt, restore the previous version from .old.
if err := updater.VerifyBinary(latest); err != nil {
restore()
msg := fmt.Sprintf("new binary verification failed: %s", err)
hint := verificationFailureHint(updater, latest)
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false,
"error": map[string]interface{}{"type": "update_error", "message": msg, "hint": hint},
})
return output.ErrBare(output.ExitAPI)
}
fmt.Fprintf(io.ErrOut, "\n%s %s\n", symFail(), msg)
fmt.Fprintf(io.ErrOut, " %s\n", hint)
return output.ErrBare(output.ExitAPI)
}
// Skills update (best-effort).
skillsResult := updater.RunSkillsUpdate()
if opts.JSON {
result := map[string]interface{}{
"ok": true, "previous_version": cur, "current_version": latest,
"latest_version": latest, "action": "updated",
"message": fmt.Sprintf("lark-cli updated from %s to %s", cur, latest),
"url": releaseURL(latest), "changelog": changelogURL(),
}
if skillsResult.Err != nil {
result["skills_warning"] = fmt.Sprintf("skills update failed: %s", skillsResult.Err)
if detail := strings.TrimSpace(skillsResult.Stderr.String()); detail != "" {
result["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput)
}
}
output.PrintJson(io.Out, result)
return nil
}
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
if skillsResult.Err != nil {
fmt.Fprintf(io.ErrOut, "%s Skills update failed: %s\n", symWarn(), skillsResult.Err)
if detail := strings.TrimSpace(skillsResult.Stderr.String()); detail != "" {
fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, 500))
}
fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n")
} else {
fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK())
}
return nil
}
func permissionHint(npmOutput string) string {
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
}
return ""
}
func verificationFailureHint(updater *selfupdate.Updater, latest string) string {
if updater.CanRestorePreviousVersion() {
return "the previous version has been restored"
}
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually: npm install -g %s@%s, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
}

851
cmd/update/update_test.go Normal file
View File

@@ -0,0 +1,851 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdupdate
import (
"bytes"
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/selfupdate"
)
// newTestFactory creates a test factory with minimal config.
func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{})
return f, stdout, stderr
}
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
// It preserves any existing NpmInstallOverride/SkillsUpdateOverride that may be set later.
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult { return result }
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
// mockDetectAndNpm sets up newUpdater with detect, npm install, and skills overrides all at once.
func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult,
npmFn func(string) *selfupdate.NpmResult,
skillsFn func() *selfupdate.NpmResult) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.NpmInstallOverride = npmFn
u.SkillsUpdateOverride = skillsFn
u.VerifyOverride = func(string) error { return nil }
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "1.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"action": "already_up_to_date"`) {
t.Errorf("expected already_up_to_date in JSON output, got: %s", out)
}
if !strings.Contains(out, `"ok": true`) {
t.Errorf("expected ok:true in JSON output, got: %s", out)
}
}
func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "1.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "already up to date") {
t.Errorf("expected 'already up to date' in stderr, got: %s", out)
}
}
func TestUpdateManual_JSON(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
cmd.SilenceErrors = true
cmd.SilenceUsage = true
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"action": "manual_required"`) {
t.Errorf("expected manual_required in output, got: %s", out)
}
if !strings.Contains(out, "not installed via npm") {
t.Errorf("expected accurate reason in output, got: %s", out)
}
if !strings.Contains(out, "releases/tag/v2.0.0") {
t.Errorf("expected version-pinned URL in output, got: %s", out)
}
}
func TestUpdateManual_Human(t *testing.T) {
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "not installed via npm") {
t.Errorf("expected 'not installed via npm' in stderr, got: %s", out)
}
if !strings.Contains(out, "releases/tag/v2.0.0") {
t.Errorf("expected version-pinned URL in stderr, got: %s", out)
}
}
func TestUpdateNpm_JSON(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"action": "updated"`) {
t.Errorf("expected updated in output, got: %s", out)
}
}
func TestUpdateNpm_Human(t *testing.T) {
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "Successfully updated") {
t.Errorf("expected success message in stderr, got: %s", out)
}
}
func TestUpdateForce_JSON(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--force", "--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "1.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"action": "updated"`) {
t.Errorf("expected updated in JSON output, got: %s", out)
}
}
func TestUpdateFetchError_JSON(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "", errors.New("network timeout") }
defer func() { fetchLatest = origFetch }()
err := cmd.Execute()
// cobra silences errors when RunE returns; we just check stdout
_ = err
out := stdout.String()
if !strings.Contains(out, `"ok": false`) {
t.Errorf("expected ok:false in JSON output, got: %s", out)
}
if !strings.Contains(out, "network timeout") {
t.Errorf("expected 'network timeout' in JSON output, got: %s", out)
}
}
func TestUpdateFetchError_Human(t *testing.T) {
f, _, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "", errors.New("network timeout") }
defer func() { fetchLatest = origFetch }()
// Suppress cobra's default error printing.
cmd.SilenceErrors = true
cmd.SilenceUsage = true
err := cmd.Execute()
if err == nil {
t.Fatal("expected non-nil error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitNetwork {
t.Errorf("expected ExitNetwork (%d), got %d", output.ExitNetwork, exitErr.Code)
}
}
func TestUpdateInvalidVersion_JSON(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "not-a-version", nil }
defer func() { fetchLatest = origFetch }()
_ = cmd.Execute()
out := stdout.String()
if !strings.Contains(out, "invalid version") {
t.Errorf("expected 'invalid version' in JSON output, got: %s", out)
}
}
func TestUpdateDevVersion_JSON(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "1.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "DEV" }
defer func() { currentVersion = origVersion }()
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"action": "updated"`) {
t.Errorf("expected updated in JSON output, got: %s", out)
}
}
func TestUpdateNpmFail_JSON(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
fmt.Fprint(&r.Stderr, "EACCES: permission denied")
r.Err = errors.New("npm install failed")
return r
}
return u
}
defer func() { newUpdater = origNew }()
_ = cmd.Execute()
out := stdout.String()
if !strings.Contains(out, "permission denied") {
t.Errorf("expected 'permission denied' in JSON output, got: %s", out)
}
if !strings.Contains(out, `"hint"`) {
t.Errorf("expected 'hint' field in JSON output, got: %s", out)
}
}
func TestUpdateNpmFail_Human(t *testing.T) {
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
fmt.Fprint(&r.Stderr, "EACCES: permission denied")
r.Err = errors.New("npm install failed")
return r
}
return u
}
defer func() { newUpdater = origNew }()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
_ = cmd.Execute()
out := stderr.String()
if !strings.Contains(out, "Update failed") {
t.Errorf("expected 'Update failed' in stderr, got: %s", out)
}
if !strings.Contains(out, "Permission denied") {
t.Errorf("expected permission hint in stderr, got: %s", out)
}
}
func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
u.RestoreAvailableOverride = func() bool { return false }
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
t.Fatal("skills update should not run when binary verification fails")
return nil
}
return u
}
defer func() { newUpdater = origNew }()
err := cmd.Execute()
if err == nil {
t.Fatal("expected verification failure")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI (%d), got %d", output.ExitAPI, exitErr.Code)
}
out := stdout.String()
if !strings.Contains(out, "automatic rollback is unavailable") {
t.Errorf("expected unavailable rollback hint, got: %s", out)
}
if strings.Contains(out, "previous version has been restored") {
t.Errorf("should not claim restore when no backup is available, got: %s", out)
}
if !strings.Contains(out, "npm install -g @larksuite/cli@2.0.0") {
t.Errorf("expected manual reinstall command in hint, got: %s", out)
}
}
func TestUpdateCheck_JSON_Npm(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json", "--check"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"action": "update_available"`) {
t.Errorf("expected update_available action, got: %s", out)
}
if !strings.Contains(out, `"auto_update": true`) {
t.Errorf("expected auto_update:true for npm, got: %s", out)
}
if !strings.Contains(out, "releases/tag/v2.0.0") {
t.Errorf("expected version-pinned release URL, got: %s", out)
}
if !strings.Contains(out, "CHANGELOG") {
t.Errorf("expected changelog URL, got: %s", out)
}
}
func TestUpdateCheck_Human_Npm(t *testing.T) {
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--check"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "Update available") {
t.Errorf("expected 'Update available' in stderr, got: %s", out)
}
if !strings.Contains(out, "lark-cli update") {
t.Errorf("expected 'lark-cli update' instruction for npm, got: %s", out)
}
}
func TestUpdateCheck_Human_Manual(t *testing.T) {
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--check"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "Update available") {
t.Errorf("expected 'Update available' in stderr, got: %s", out)
}
if !strings.Contains(out, "manually") {
t.Errorf("expected manual download instruction for non-npm, got: %s", out)
}
if strings.Contains(out, "lark-cli update` to install") {
t.Errorf("should NOT suggest 'lark-cli update' for manual install, got: %s", out)
}
}
func TestUpdateNpmNotFound_FallsBackToManual(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
// npm detected (node_modules in path) but npm binary not available
mockDetect(t, selfupdate.DetectResult{
Method: selfupdate.InstallNpm,
ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli",
NpmAvailable: false,
})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"action": "manual_required"`) {
t.Errorf("expected manual_required when npm not found, got: %s", out)
}
// Must say "npm is not available", not generic "not installed via npm"
if !strings.Contains(out, "npm is not available") {
t.Errorf("expected 'npm is not available' reason when npm detected but missing, got: %s", out)
}
}
func TestReleaseURL(t *testing.T) {
got := releaseURL("2.0.0")
if got != "https://github.com/larksuite/cli/releases/tag/v2.0.0" {
t.Errorf("expected version-pinned URL, got: %s", got)
}
got2 := releaseURL("v1.5.0")
if got2 != "https://github.com/larksuite/cli/releases/tag/v1.5.0" {
t.Errorf("expected no double v prefix, got: %s", got2)
}
}
func TestPermissionHint(t *testing.T) {
origOS := currentOS
defer func() { currentOS = origOS }()
// Linux: EACCES should produce a hint with npm prefix guidance.
currentOS = "linux"
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'")
if !strings.Contains(hint, "npm global prefix") {
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
}
if strings.Contains(hint, "sudo npm install -g") {
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
}
// Windows: EACCES hint is suppressed (no EACCES on Windows).
currentOS = "windows"
hint = permissionHint("EACCES: permission denied")
if hint != "" {
t.Errorf("expected empty hint on Windows, got: %s", hint)
}
// Non-EACCES error: always empty.
currentOS = "linux"
if got := permissionHint("some other error"); got != "" {
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
}
}
func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
// With the rename trick, Windows npm installs can now auto-update.
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
origOS := currentOS
currentOS = osWindows
defer func() { currentOS = origOS }()
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\npm\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"action": "updated"`) {
t.Errorf("expected updated on Windows with rename trick, got: %s", out)
}
}
func TestUpdateWindows_Check_JSON(t *testing.T) {
// --check on Windows npm should report auto_update: true (rename trick available).
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json", "--check"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
origOS := currentOS
currentOS = osWindows
defer func() { currentOS = origOS }()
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"auto_update": true`) {
t.Errorf("expected auto_update:true on Windows (rename trick), got: %s", out)
}
}
func TestUpdateWindows_Symbols(t *testing.T) {
origOS := currentOS
defer func() { currentOS = origOS }()
currentOS = "windows"
if symOK() != "[OK]" {
t.Errorf("expected [OK] on Windows, got: %s", symOK())
}
if symFail() != "[FAIL]" {
t.Errorf("expected [FAIL] on Windows, got: %s", symFail())
}
if symWarn() != "[WARN]" {
t.Errorf("expected [WARN] on Windows, got: %s", symWarn())
}
if symArrow() != "->" {
t.Errorf("expected -> on Windows, got: %s", symArrow())
}
currentOS = "darwin"
if symOK() != "\u2713" {
t.Errorf("expected \u2713 on darwin, got: %s", symOK())
}
if symArrow() != "\u2192" {
t.Errorf("expected \u2192 on darwin, got: %s", symArrow())
}
}
func TestUpdateNpm_SkillsSuccess_JSON(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// Should NOT have skills_warning when skills succeed
if strings.Contains(out, "skills_warning") {
t.Errorf("expected no skills_warning on success, got: %s", out)
}
}
func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil }
// Skills update fails
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found")
r.Err = fmt.Errorf("exit status 127")
return r
}
return u
}
defer func() { newUpdater = origNew }()
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// CLI update should still succeed (ok:true)
if !strings.Contains(out, `"ok": true`) {
t.Errorf("expected ok:true despite skills failure, got: %s", out)
}
if !strings.Contains(out, `"action": "updated"`) {
t.Errorf("expected action:updated despite skills failure, got: %s", out)
}
// Should have skills_warning with detail
if !strings.Contains(out, "skills_warning") {
t.Errorf("expected skills_warning in output, got: %s", out)
}
if !strings.Contains(out, "skills_detail") {
t.Errorf("expected skills_detail in output, got: %s", out)
}
}
func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil }
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found")
r.Err = fmt.Errorf("exit status 127")
return r
}
return u
}
defer func() { newUpdater = origNew }()
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
// CLI update should still show success
if !strings.Contains(out, "Successfully updated") {
t.Errorf("expected CLI success message, got: %s", out)
}
// Skills warning should be shown
if !strings.Contains(out, "Skills update failed") {
t.Errorf("expected skills failure warning, got: %s", out)
}
if !strings.Contains(out, "npx -y skills add") {
t.Errorf("expected manual skills command hint, got: %s", out)
}
}
func TestTruncate(t *testing.T) {
long := strings.Repeat("x", 3000)
got := selfupdate.Truncate(long, 2000)
if len(got) != 2000 {
t.Errorf("expected truncated length 2000, got %d", len(got))
}
short := "hello"
got2 := selfupdate.Truncate(short, 2000)
if got2 != "hello" {
t.Errorf("expected 'hello', got %q", got2)
}
}

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package env
import (

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package fileio
import "errors"
// ErrPathValidation indicates the path failed security validation
// (traversal, absolute, control chars, symlink escape, etc.).
var ErrPathValidation = errors.New("path validation failed")
// PathValidationError wraps a path validation error.
// errors.Is(err, ErrPathValidation) returns true.
// errors.Is(err, <original OS error>) also works via the chain.
type PathValidationError struct {
Err error // original error
}
func (e *PathValidationError) Error() string { return e.Err.Error() }
func (e *PathValidationError) Unwrap() []error {
return []error{ErrPathValidation, e.Err}
}
// MkdirError indicates parent directory creation failed.
// Use errors.As(err, &fileio.MkdirError{}) to match.
type MkdirError struct {
Err error
}
func (e *MkdirError) Error() string { return e.Err.Error() }
func (e *MkdirError) Unwrap() error { return e.Err }
// WriteError indicates file write failed.
// Use errors.As(err, &fileio.WriteError{}) to match.
type WriteError struct {
Err error
}
func (e *WriteError) Error() string { return e.Err.Error() }
func (e *WriteError) Unwrap() error { return e.Err }

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package fileio
import "sync"
var (
mu sync.Mutex
provider Provider
)
// Register registers a FileIO Provider.
// Later registrations override earlier ones (last-write-wins).
// Unlike credential.Register which appends to a chain (multiple credential
// sources are tried in order), FileIO uses a single active provider because
// only one file I/O backend is active at a time (local vs server mode).
// Typically called from init() via blank import.
func Register(p Provider) {
mu.Lock()
defer mu.Unlock()
provider = p
}
// GetProvider returns the currently registered Provider.
// Returns nil if no provider has been registered.
func GetProvider() Provider {
mu.Lock()
defer mu.Unlock()
return provider
}

73
extension/fileio/types.go Normal file
View File

@@ -0,0 +1,73 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package fileio
import (
"context"
"io"
"io/fs"
)
// Provider creates FileIO instances.
// Follows the same API style as extension/credential.Provider.
type Provider interface {
Name() string
ResolveFileIO(ctx context.Context) FileIO
}
// FileIO abstracts file transfer operations for CLI commands.
// The default implementation operates on the local filesystem with
// path validation, directory creation, and atomic writes.
// Inject a custom implementation via Factory.FileIOProvider to replace
// file transfer behavior (e.g. streaming in server mode).
type FileIO interface {
// Open opens a file for reading (upload, attachment, template scenarios).
// The default implementation validates the path via SafeInputPath.
Open(name string) (File, error)
// Stat returns file metadata (size validation, existence checks).
// The default implementation validates the path via SafeInputPath.
// Use os.IsNotExist(err) to distinguish "file not found" from "invalid path".
Stat(name string) (FileInfo, error)
// ResolvePath returns the validated, absolute path for the given output path.
// The default implementation delegates to SafeOutputPath.
// Use this to obtain the canonical saved path for user-facing output.
ResolvePath(path string) (string, error)
// Save writes content to the target path and returns a SaveResult.
// The default implementation validates via SafeOutputPath, creates
// parent directories, and writes atomically.
Save(path string, opts SaveOptions, body io.Reader) (SaveResult, error)
}
// FileInfo is a minimal subset of os.FileInfo covering actual CLI usage.
// os.FileInfo satisfies this interface.
type FileInfo interface {
Size() int64
IsDir() bool
Mode() fs.FileMode
}
// File is the interface returned by FileIO.Open.
// It covers the subset of *os.File methods actually used by CLI commands.
// *os.File satisfies this interface without adaptation.
type File interface {
io.Reader
io.ReaderAt
io.Closer
}
// SaveResult holds the outcome of a Save operation.
type SaveResult interface {
Size() int64 // actual bytes written
}
// SaveOptions carries metadata for Save.
// The default (local) implementation ignores these fields;
// server-mode implementations use them to construct streaming response frames.
type SaveOptions struct {
ContentType string // MIME type
ContentLength int64 // content length; -1 if unknown
}

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package charcheck provides character-level security checks shared across
// path validation (localfileio) and input validation (validate) packages.
// Keeping these checks in one place ensures consistent detection of dangerous
// Unicode and control characters throughout the codebase.
package charcheck
import "fmt"
// RejectControlChars rejects C0 control characters (except \t and \n) and
// dangerous Unicode characters (Bidi overrides, zero-width, line/paragraph
// separators) that enable visual spoofing attacks.
func RejectControlChars(value, flagName string) error {
for _, r := range value {
if r != '\t' && r != '\n' && (r < 0x20 || r == 0x7f) {
return fmt.Errorf("%s contains invalid control characters", flagName)
}
if IsDangerousUnicode(r) {
return fmt.Errorf("%s contains dangerous Unicode characters", flagName)
}
}
return nil
}
// IsDangerousUnicode identifies Unicode code points used for visual spoofing
// attacks. These characters are invisible or alter text direction, allowing
// attackers to make "report.exe" display as "report.txt" (Bidi override) or
// insert hidden content (zero-width characters).
func IsDangerousUnicode(r rune) bool {
switch {
case r >= 0x200B && r <= 0x200D: // zero-width space/non-joiner/joiner
return true
case r == 0xFEFF: // BOM / ZWNBSP
return true
case r >= 0x202A && r <= 0x202E: // Bidi: LRE/RLE/PDF/LRO/RLO
return true
case r >= 0x2028 && r <= 0x2029: // line/paragraph separator
return true
case r >= 0x2066 && r <= 0x2069: // Bidi isolates: LRI/RLI/FSI/PDI
return true
}
return false
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
)
const rawAPIJSONHint = "The endpoint may have returned an empty or non-standard JSON body. If it returns a file, rerun with --output."
// WrapDoAPIError upgrades malformed JSON decode errors from the SDK into
// actionable API errors for raw `lark-cli api` calls. All other failures
// remain network errors.
func WrapDoAPIError(err error) error {
if err == nil {
return nil
}
if isJSONDecodeError(err, false) {
return output.ErrWithHint(output.ExitAPI, "api_error",
fmt.Sprintf("API returned an invalid JSON response: %v", err), rawAPIJSONHint)
}
return output.ErrNetwork("API call failed: %v", err)
}
// WrapJSONResponseParseError upgrades empty or malformed JSON response bodies
// into API errors with hints instead of generic parse failures.
func WrapJSONResponseParseError(err error, body []byte) error {
if err == nil {
return nil
}
if len(bytes.TrimSpace(body)) == 0 {
return output.ErrWithHint(output.ExitAPI, "api_error",
"API returned an empty JSON response body", rawAPIJSONHint)
}
if isJSONDecodeError(err, true) {
return output.ErrWithHint(output.ExitAPI, "api_error",
fmt.Sprintf("API returned an invalid JSON response: %v", err), rawAPIJSONHint)
}
return output.ErrNetwork("API call failed: %v", err)
}
func isJSONDecodeError(err error, allowEOF bool) bool {
var syntaxErr *json.SyntaxError
var unmarshalTypeErr *json.UnmarshalTypeError
if errors.As(err, &syntaxErr) || errors.As(err, &unmarshalTypeErr) {
return true
}
if allowEOF && (errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) {
return true
}
msg := err.Error()
if allowEOF && strings.Contains(msg, "unexpected EOF") {
return true
}
return strings.Contains(msg, "unexpected end of JSON input") ||
strings.Contains(msg, "invalid character") ||
strings.Contains(msg, "cannot unmarshal")
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"encoding/json"
"errors"
"io"
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
)
func TestWrapDoAPIError_BareEOFIsNetworkError(t *testing.T) {
err := WrapDoAPIError(io.EOF)
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T", err)
}
if exitErr.Code != output.ExitNetwork {
t.Fatalf("expected ExitNetwork, got %d", exitErr.Code)
}
if strings.Contains(exitErr.Error(), "invalid JSON response") {
t.Fatalf("unexpected JSON diagnostic for bare EOF: %q", exitErr.Error())
}
}
func TestWrapDoAPIError_SyntaxErrorIsAPIDiagnostic(t *testing.T) {
err := WrapDoAPIError(&json.SyntaxError{Offset: 1})
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
}
if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "invalid JSON response") {
t.Fatalf("expected JSON diagnostic message, got %#v", exitErr.Detail)
}
}
func TestWrapJSONResponseParseError_UnexpectedEOFIsAPIDiagnostic(t *testing.T) {
err := WrapJSONResponseParseError(io.ErrUnexpectedEOF, []byte("{"))
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
}
if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "invalid JSON response") {
t.Fatalf("expected invalid JSON diagnostic, got %#v", exitErr.Detail)
}
}

View File

@@ -6,18 +6,17 @@ package client
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"path/filepath"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// ── Response routing ──
@@ -29,6 +28,7 @@ type ResponseOptions struct {
JqExpr string // if set, apply jq filter instead of Format
Out io.Writer // stdout
ErrOut io.Writer // stderr
FileIO fileio.FileIO // file transfer abstraction; required when saving files (--output or binary response)
// CheckError is called on parsed JSON results. Nil defaults to CheckLarkResponse.
CheckError func(interface{}) error
}
@@ -55,13 +55,13 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
if IsJSONContentType(ct) || ct == "" {
result, err := ParseJSONResponse(resp)
if err != nil {
return output.ErrNetwork("API call failed: %v", err)
return WrapJSONResponseParseError(err, resp.RawBody)
}
if apiErr := check(result); apiErr != nil {
return apiErr
}
if opts.OutputPath != "" {
return saveAndPrint(resp, opts.OutputPath, opts.Out)
return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out)
}
if opts.JqExpr != "" {
return output.JqFilter(opts.Out, result, opts.JqExpr)
@@ -75,11 +75,11 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
return output.ErrValidation("--jq requires a JSON response (got Content-Type: %s)", ct)
}
if opts.OutputPath != "" {
return saveAndPrint(resp, opts.OutputPath, opts.Out)
return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out)
}
// No --output: auto-save with derived filename.
meta, err := SaveResponse(resp, ResolveFilename(resp))
meta, err := SaveResponse(opts.FileIO, resp, ResolveFilename(resp))
if err != nil {
return output.Errorf(output.ExitInternal, "file_error", "%s", err)
}
@@ -88,8 +88,8 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
return nil
}
func saveAndPrint(resp *larkcore.ApiResp, path string, w io.Writer) error {
meta, err := SaveResponse(resp, path)
func saveAndPrint(fio fileio.FileIO, resp *larkcore.ApiResp, path string, w io.Writer) error {
meta, err := SaveResponse(fio, resp, path)
if err != nil {
return output.Errorf(output.ExitInternal, "file_error", "%s", err)
}
@@ -111,7 +111,7 @@ func ParseJSONResponse(resp *larkcore.ApiResp) (interface{}, error) {
dec := json.NewDecoder(bytes.NewReader(resp.RawBody))
dec.UseNumber()
if err := dec.Decode(&result); err != nil {
return nil, fmt.Errorf("response parse error: %v (body: %s)", err, util.TruncateStr(string(resp.RawBody), 500))
return nil, fmt.Errorf("response parse error: %w (body: %s)", err, util.TruncateStr(string(resp.RawBody), 500))
}
return result, nil
}
@@ -119,23 +119,34 @@ func ParseJSONResponse(resp *larkcore.ApiResp) (interface{}, error) {
// ── File saving ──
// SaveResponse writes an API response body to the given outputPath and returns metadata.
func SaveResponse(resp *larkcore.ApiResp, outputPath string) (map[string]interface{}, error) {
safePath, err := validate.SafeOutputPath(outputPath)
// It delegates to FileIO.Save for path validation and atomic write; fio must not be nil.
func SaveResponse(fio fileio.FileIO, resp *larkcore.ApiResp, outputPath string) (map[string]interface{}, error) {
result, err := fio.Save(outputPath, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: int64(len(resp.RawBody)),
}, bytes.NewReader(resp.RawBody))
if err != nil {
return nil, fmt.Errorf("unsafe output path: %s", err)
var me *fileio.MkdirError
var we *fileio.WriteError
switch {
case errors.Is(err, fileio.ErrPathValidation):
return nil, fmt.Errorf("unsafe output path: %s", err)
case errors.As(err, &me):
return nil, fmt.Errorf("create directory: %s", err)
case errors.As(err, &we):
return nil, fmt.Errorf("cannot write file: %s", err)
default:
return nil, fmt.Errorf("cannot write file: %s", err)
}
}
if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
return nil, fmt.Errorf("create directory: %s", err)
resolvedPath, err := fio.ResolvePath(outputPath)
if err != nil || resolvedPath == "" {
resolvedPath = outputPath
}
if err := validate.AtomicWrite(safePath, resp.RawBody, 0644); err != nil {
return nil, fmt.Errorf("cannot write file: %s", err)
}
return map[string]interface{}{
"saved_path": safePath,
"size_bytes": len(resp.RawBody),
"saved_path": resolvedPath,
"size_bytes": result.Size(),
"content_type": resp.Header.Get("Content-Type"),
}, nil
}

View File

@@ -6,6 +6,7 @@ package client
import (
"bytes"
"errors"
"io"
"net/http"
"os"
"path/filepath"
@@ -15,6 +16,7 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
func newApiResp(body []byte, headers map[string]string) *larkcore.ApiResp {
@@ -75,6 +77,17 @@ func TestParseJSONResponse_Invalid(t *testing.T) {
}
}
func TestParseJSONResponse_EmptyBody_WrapsEOF(t *testing.T) {
resp := newApiResp([]byte{}, map[string]string{"Content-Type": "application/json"})
_, err := ParseJSONResponse(resp)
if err == nil {
t.Fatal("expected error for empty body")
}
if !errors.Is(err, io.EOF) {
t.Fatalf("expected wrapped io.EOF, got %v", err)
}
}
func TestResolveFilename(t *testing.T) {
tests := []struct {
name string
@@ -150,11 +163,11 @@ func TestSaveResponse(t *testing.T) {
body := []byte("hello binary data")
resp := newApiResp(body, map[string]string{"Content-Type": "application/octet-stream"})
meta, err := SaveResponse(resp, "test_output.bin")
meta, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "test_output.bin")
if err != nil {
t.Fatalf("SaveResponse failed: %v", err)
}
if meta["size_bytes"] != len(body) {
if meta["size_bytes"] != int64(len(body)) {
t.Errorf("expected size_bytes=%d, got %v", len(body), meta["size_bytes"])
}
@@ -176,7 +189,7 @@ func TestSaveResponse_CreatesDir(t *testing.T) {
resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"})
meta, err := SaveResponse(resp, filepath.Join("sub", "deep", "out.bin"))
meta, err := SaveResponse(&localfileio.LocalFileIO{}, resp, filepath.Join("sub", "deep", "out.bin"))
if err != nil {
t.Fatalf("SaveResponse with nested dir failed: %v", err)
}
@@ -195,6 +208,7 @@ func TestHandleResponse_JSON(t *testing.T) {
err := HandleResponse(resp, ResponseOptions{
Out: &out,
ErrOut: &errOut,
FileIO: &localfileio.LocalFileIO{},
})
if err != nil {
t.Fatalf("HandleResponse failed: %v", err)
@@ -213,12 +227,44 @@ func TestHandleResponse_JSONWithError(t *testing.T) {
err := HandleResponse(resp, ResponseOptions{
Out: &out,
ErrOut: &errOut,
FileIO: &localfileio.LocalFileIO{},
})
if err == nil {
t.Error("expected error for non-zero code")
}
}
func TestHandleResponse_EmptyJSONBody_ShowsDiagnostic(t *testing.T) {
resp := newApiResp([]byte{}, map[string]string{"Content-Type": "application/json"})
var out bytes.Buffer
var errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{
Out: &out,
ErrOut: &errOut,
})
if err == nil {
t.Fatal("expected error for empty JSON body")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
}
if exitErr.Detail == nil {
t.Fatal("expected detail on exit error")
}
if exitErr.Detail.Message != "API returned an empty JSON response body" {
t.Fatalf("unexpected message: %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "--output") {
t.Fatalf("expected hint to mention --output, got %q", exitErr.Detail.Hint)
}
}
func TestHandleResponse_BinaryAutoSave(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()
@@ -232,6 +278,7 @@ func TestHandleResponse_BinaryAutoSave(t *testing.T) {
err := HandleResponse(resp, ResponseOptions{
Out: &out,
ErrOut: &errOut,
FileIO: &localfileio.LocalFileIO{},
})
if err != nil {
t.Fatalf("HandleResponse binary failed: %v", err)
@@ -255,6 +302,7 @@ func TestHandleResponse_BinaryWithOutput(t *testing.T) {
OutputPath: "out.png",
Out: &out,
ErrOut: &errOut,
FileIO: &localfileio.LocalFileIO{},
})
if err != nil {
t.Fatalf("HandleResponse with output path failed: %v", err)
@@ -269,7 +317,7 @@ func TestHandleResponse_NonJSONError_404(t *testing.T) {
resp := newApiRespWithStatus(404, []byte("404 page not found"), map[string]string{"Content-Type": "text/plain"})
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut})
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
if err == nil {
t.Fatal("expected error for 404 text/plain")
}
@@ -287,7 +335,7 @@ func TestHandleResponse_NonJSONError_502(t *testing.T) {
resp := newApiRespWithStatus(502, []byte("<html>Bad Gateway</html>"), map[string]string{"Content-Type": "text/html"})
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut})
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
if err == nil {
t.Fatal("expected error for 502 text/html")
}
@@ -310,7 +358,7 @@ func TestHandleResponse_200TextPlain_SavesFile(t *testing.T) {
resp := newApiRespWithStatus(200, []byte("plain text file content"), map[string]string{"Content-Type": "text/plain"})
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut})
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
if err != nil {
t.Fatalf("expected no error for 200 text/plain, got: %v", err)
}
@@ -336,12 +384,53 @@ func TestHandleResponse_BinaryWithJq_RejectsNonJSON(t *testing.T) {
}
}
func TestSaveResponse_RejectsPathTraversal(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()
os.Chdir(dir)
defer os.Chdir(origWd)
resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"})
_, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "../../evil.txt")
if err == nil {
t.Fatal("expected error for path traversal")
}
if !strings.Contains(err.Error(), "unsafe output path") {
t.Errorf("expected 'unsafe output path' wrapper, got: %v", err)
}
}
func TestSaveResponse_RejectsAbsolutePath(t *testing.T) {
resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"})
_, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "/tmp/evil.txt")
if err == nil {
t.Fatal("expected error for absolute path")
}
}
func TestSaveResponse_MetadataContainsAbsolutePath(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()
os.Chdir(dir)
defer os.Chdir(origWd)
resp := newApiResp([]byte("x"), map[string]string{"Content-Type": "text/plain"})
meta, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "rel.txt")
if err != nil {
t.Fatalf("SaveResponse failed: %v", err)
}
savedPath, _ := meta["saved_path"].(string)
if !filepath.IsAbs(savedPath) {
t.Errorf("saved_path should be absolute, got %q", savedPath)
}
}
func TestHandleResponse_403JSON_CheckLarkResponse(t *testing.T) {
body := []byte(`{"code":99991400,"msg":"invalid token"}`)
resp := newApiRespWithStatus(403, body, map[string]string{"Content-Type": "application/json"})
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut})
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
if err == nil {
t.Fatal("expected error for 403 JSON with non-zero code")
}

View File

@@ -215,6 +215,51 @@ func encodeParams(params map[string]interface{}) string {
return vals.Encode()
}
// PrintDryRunWithFile outputs a dry-run summary for file upload requests.
// Instead of serializing the Formdata body, it shows file metadata.
func PrintDryRunWithFile(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format, fileField, filePath string, formFields any) error {
dr := NewDryRunAPI()
switch request.Method {
case "POST":
dr.POST(request.URL)
case "PUT":
dr.PUT(request.URL)
case "PATCH":
dr.PATCH(request.URL)
case "DELETE":
dr.DELETE(request.URL)
default:
dr.GET(request.URL)
}
if len(request.Params) > 0 {
dr.Params(request.Params)
}
filePathDisplay := filePath
if filePathDisplay == "" {
filePathDisplay = "<stdin>"
}
fileInfo := map[string]any{
"file": map[string]string{"field": fileField, "path": filePathDisplay},
}
if formFields != nil {
fileInfo["form_fields"] = formFields
}
fileInfo["options"] = []string{"WithFileUpload"}
dr.Body(fileInfo)
dr.Set("as", string(request.As))
dr.Set("appId", config.AppID)
if config.UserOpenId != "" {
dr.Set("userOpenId", config.UserOpenId)
}
fmt.Fprintln(w, "=== Dry Run ===")
if format == "pretty" {
fmt.Fprint(w, dr.Format())
} else {
output.PrintJson(w, dr)
}
return nil
}
// PrintDryRun outputs a standardised dry-run summary using DryRunAPI.
// When format is "pretty", outputs human-readable text; otherwise JSON.
func PrintDryRun(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format string) error {

View File

@@ -14,6 +14,7 @@ import (
"github.com/spf13/cobra"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
@@ -40,6 +41,17 @@ type Factory struct {
ResolvedIdentity core.Identity // identity resolved by the last ResolveAs call
Credential *credential.CredentialProvider
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
}
// ResolveFileIO resolves a FileIO instance using the current execution context.
// The provider controls whether the returned instance is fresh or cached.
func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO {
if f == nil || f.FileIOProvider == nil {
return nil
}
return f.FileIOProvider.ResolveFileIO(ctx)
}
// ResolveAs returns the effective identity type.

View File

@@ -17,12 +17,14 @@ import (
"golang.org/x/term"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/util"
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
)
// NewDefault creates a production Factory with cached closures.
@@ -44,6 +46,9 @@ func NewDefault(inv InvocationContext) *Factory {
IsTerminal: term.IsTerminal(int(os.Stdin.Fd())),
}
// Phase 0: FileIO provider (no dependency)
f.FileIOProvider = fileio.GetProvider()
// Phase 1: HttpClient (no credential dependency)
f.HttpClient = cachedHttpClientFunc()

View File

@@ -11,13 +11,26 @@ import (
"testing"
_ "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/extension/fileio"
exttransport "github.com/larksuite/cli/extension/transport"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
type countingFileIOProvider struct {
resolveCalls int
}
func (p *countingFileIOProvider) Name() string { return "counting" }
func (p *countingFileIOProvider) ResolveFileIO(context.Context) fileio.FileIO {
p.resolveCalls++
return &localfileio.LocalFileIO{}
}
func TestNewDefault_InvocationProfileUsedByStrictModeAndConfig(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
@@ -198,6 +211,28 @@ func TestNewDefault_ConfigUsesRuntimePlaceholderForTokenOnlyEnvAccount(t *testin
}
}
func TestNewDefault_FileIOProviderDoesNotResolveDuringInitialization(t *testing.T) {
prev := fileio.GetProvider()
provider := &countingFileIOProvider{}
fileio.Register(provider)
t.Cleanup(func() { fileio.Register(prev) })
f := NewDefault(InvocationContext{})
if f.FileIOProvider != provider {
t.Fatalf("NewDefault() provider = %T, want %T", f.FileIOProvider, provider)
}
if provider.resolveCalls != 0 {
t.Fatalf("ResolveFileIO() calls after NewDefault() = %d, want 0", provider.resolveCalls)
}
if got := f.ResolveFileIO(context.Background()); got == nil {
t.Fatal("ResolveFileIO() = nil, want non-nil")
}
if provider.resolveCalls != 1 {
t.Fatalf("ResolveFileIO() calls after explicit resolve = %d, want 1", provider.resolveCalls)
}
}
type stubTransportProvider struct {
interceptor exttransport.Interceptor
}

View File

@@ -0,0 +1,130 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"bytes"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// DetectFileFields returns field names with type "file" in the method's requestBody.
func DetectFileFields(method map[string]interface{}) []string {
rb, _ := method["requestBody"].(map[string]interface{})
var fields []string
for name, field := range rb {
f, _ := field.(map[string]interface{})
if registry.GetStrFromMap(f, "type") == "file" {
fields = append(fields, name)
}
}
return fields
}
// ParseFileFlag parses a --file flag value into its components.
// The format is either "path" or "field=path". When no explicit "field="
// prefix is present, defaultField is used as the field name.
// A path of "-" indicates stdin; in that case filePath is empty and isStdin is true.
func ParseFileFlag(raw, defaultField string) (fieldName, filePath string, isStdin bool) {
if idx := strings.IndexByte(raw, '='); idx > 0 {
fieldName = raw[:idx]
filePath = raw[idx+1:]
} else {
fieldName = defaultField
filePath = raw
}
if filePath == "-" {
return fieldName, "", true
}
return fieldName, filePath, false
}
// ValidateFileFlag checks mutual exclusion rules for the --file flag.
// Returns nil if file is empty (flag not provided).
func ValidateFileFlag(file, params, data, outputPath string, pageAll bool, httpMethod string) error {
if file == "" {
return nil
}
_, filePath, isStdin := ParseFileFlag(file, "file")
if !isStdin && filePath == "" {
return output.ErrValidation("--file: empty file path")
}
if outputPath != "" {
return output.ErrValidation("--file and --output are mutually exclusive")
}
if pageAll {
return output.ErrValidation("--file and --page-all are mutually exclusive")
}
if isStdin && data == "-" {
return output.ErrValidation("--file and --data cannot both read from stdin")
}
if isStdin && params == "-" {
return output.ErrValidation("--file and --params cannot both read from stdin")
}
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
default:
return output.ErrValidation("--file requires POST, PUT, PATCH, or DELETE method")
}
return nil
}
// FileUploadMeta holds file upload metadata for dry-run display.
// Returned by request builders when dry-run mode skips actual file reading.
type FileUploadMeta struct {
FieldName string
FilePath string
FormFields any
}
// BuildFormdata constructs a multipart form data payload for file upload.
// If isStdin is true, the file content is read from stdin.
// Top-level keys from dataJSON are added as text form fields.
func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin bool, stdin io.Reader, dataJSON any) (*larkcore.Formdata, error) {
fd := larkcore.NewFormdata()
if isStdin {
if stdin == nil {
return nil, output.ErrValidation("--file: stdin is not available")
}
data, err := io.ReadAll(stdin)
if err != nil {
return nil, output.ErrValidation("--file: failed to read stdin: %v", err)
}
if len(data) == 0 {
return nil, output.ErrValidation("--file: stdin is empty")
}
fd.AddFile(fieldName, bytes.NewReader(data))
} else {
f, err := fileIO.Open(filePath)
if err != nil {
return nil, output.ErrValidation("cannot open file: %s", filePath)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, output.ErrValidation("--file: failed to read %s: %v", filePath, err)
}
fd.AddFile(fieldName, bytes.NewReader(data))
}
// Add top-level JSON keys as text form fields.
if m, ok := dataJSON.(map[string]any); ok {
for k, v := range m {
fd.AddField(k, fmt.Sprintf("%v", v))
}
}
return fd, nil
}

View File

@@ -0,0 +1,338 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
func TestParseFileFlag(t *testing.T) {
tests := []struct {
name string
raw string
defaultField string
wantField string
wantPath string
wantStdin bool
}{
{
name: "simple filename uses default field",
raw: "photo.jpg",
defaultField: "file",
wantField: "file",
wantPath: "photo.jpg",
wantStdin: false,
},
{
name: "simple filename with custom default",
raw: "photo.jpg",
defaultField: "image",
wantField: "image",
wantPath: "photo.jpg",
wantStdin: false,
},
{
name: "explicit field prefix",
raw: "image=photo.jpg",
defaultField: "file",
wantField: "image",
wantPath: "photo.jpg",
wantStdin: false,
},
{
name: "stdin bare",
raw: "-",
defaultField: "file",
wantField: "file",
wantPath: "",
wantStdin: true,
},
{
name: "stdin with field prefix",
raw: "image=-",
defaultField: "file",
wantField: "image",
wantPath: "",
wantStdin: true,
},
{
name: "path with equals sign (only first equals splits)",
raw: "field=path/to/file=1.jpg",
defaultField: "file",
wantField: "field",
wantPath: "path/to/file=1.jpg",
wantStdin: false,
},
{
name: "absolute path no prefix",
raw: "/tmp/photo.jpg",
defaultField: "file",
wantField: "file",
wantPath: "/tmp/photo.jpg",
wantStdin: false,
},
{
name: "absolute path with field prefix",
raw: "image=/tmp/photo.jpg",
defaultField: "file",
wantField: "image",
wantPath: "/tmp/photo.jpg",
wantStdin: false,
},
{
name: "empty field prefix falls through to default",
raw: "=photo.jpg",
defaultField: "file",
wantField: "file",
wantPath: "=photo.jpg",
wantStdin: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
field, path, isStdin := ParseFileFlag(tt.raw, tt.defaultField)
if field != tt.wantField {
t.Errorf("field = %q, want %q", field, tt.wantField)
}
if path != tt.wantPath {
t.Errorf("path = %q, want %q", path, tt.wantPath)
}
if isStdin != tt.wantStdin {
t.Errorf("isStdin = %v, want %v", isStdin, tt.wantStdin)
}
})
}
}
func TestValidateFileFlag(t *testing.T) {
tests := []struct {
name string
file string
params string
data string
outputPath string
pageAll bool
httpMethod string
wantErr string // empty means no error
}{
{
name: "empty file is valid",
file: "",
httpMethod: "GET",
wantErr: "",
},
{
name: "empty file path",
file: "field=",
httpMethod: "POST",
wantErr: "--file: empty file path",
},
{
name: "file with output",
file: "photo.jpg",
outputPath: "out.json",
httpMethod: "POST",
wantErr: "--file and --output are mutually exclusive",
},
{
name: "file with page-all",
file: "photo.jpg",
pageAll: true,
httpMethod: "POST",
wantErr: "--file and --page-all are mutually exclusive",
},
{
name: "stdin file with stdin data",
file: "-",
data: "-",
httpMethod: "POST",
wantErr: "--file and --data cannot both read from stdin",
},
{
name: "stdin file with stdin params",
file: "-",
params: "-",
httpMethod: "POST",
wantErr: "--file and --params cannot both read from stdin",
},
{
name: "file with GET method",
file: "photo.jpg",
httpMethod: "GET",
wantErr: "--file requires POST, PUT, PATCH, or DELETE method",
},
{
name: "file with POST method",
file: "photo.jpg",
httpMethod: "POST",
wantErr: "",
},
{
name: "file with PUT method",
file: "photo.jpg",
httpMethod: "PUT",
wantErr: "",
},
{
name: "file with PATCH method",
file: "photo.jpg",
httpMethod: "PATCH",
wantErr: "",
},
{
name: "file with DELETE method",
file: "photo.jpg",
httpMethod: "DELETE",
wantErr: "",
},
{
name: "stdin with field prefix and data stdin",
file: "image=-",
data: "-",
httpMethod: "POST",
wantErr: "--file and --data cannot both read from stdin",
},
{
name: "stdin with field prefix and params stdin",
file: "image=-",
params: "-",
httpMethod: "POST",
wantErr: "--file and --params cannot both read from stdin",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateFileFlag(tt.file, tt.params, tt.data, tt.outputPath, tt.pageAll, tt.httpMethod)
if tt.wantErr == "" {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
return
}
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("error = %q, want containing %q", err.Error(), tt.wantErr)
}
})
}
}
func TestBuildFormdata(t *testing.T) {
fio := &localfileio.LocalFileIO{}
t.Run("stdin success", func(t *testing.T) {
stdin := bytes.NewReader([]byte("file-content-here"))
fd, err := BuildFormdata(fio, "file", "", true, stdin, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if fd == nil {
t.Fatal("expected non-nil Formdata")
}
})
t.Run("stdin nil reader", func(t *testing.T) {
_, err := BuildFormdata(fio, "file", "", true, nil, nil)
if err == nil {
t.Fatal("expected error for nil stdin")
}
if !strings.Contains(err.Error(), "stdin is not available") {
t.Errorf("error = %q, want containing %q", err.Error(), "stdin is not available")
}
})
t.Run("stdin empty", func(t *testing.T) {
stdin := bytes.NewReader([]byte{})
_, err := BuildFormdata(fio, "file", "", true, stdin, nil)
if err == nil {
t.Fatal("expected error for empty stdin")
}
if !strings.Contains(err.Error(), "stdin is empty") {
t.Errorf("error = %q, want containing %q", err.Error(), "stdin is empty")
}
})
t.Run("file open success", func(t *testing.T) {
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile(filepath.Join(dir, "test.txt"), []byte("hello"), 0600); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
fd, err := BuildFormdata(fio, "photo", "test.txt", false, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if fd == nil {
t.Fatal("expected non-nil Formdata")
}
})
t.Run("file not found", func(t *testing.T) {
dir := t.TempDir()
TestChdir(t, dir)
_, err := BuildFormdata(fio, "file", "nonexistent.txt", false, nil, nil)
if err == nil {
t.Fatal("expected error for missing file")
}
if !strings.Contains(err.Error(), "cannot open file:") {
t.Errorf("error = %q, want containing %q", err.Error(), "cannot open file:")
}
})
t.Run("dataJSON fields added", func(t *testing.T) {
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile(filepath.Join(dir, "upload.bin"), []byte("data"), 0600); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
dataJSON := map[string]any{
"file_name": "report.pdf",
"parent_type": "doc_image",
"size": 1024,
}
fd, err := BuildFormdata(fio, "file", "upload.bin", false, nil, dataJSON)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if fd == nil {
t.Fatal("expected non-nil Formdata")
}
})
t.Run("dataJSON nil is fine", func(t *testing.T) {
stdin := bytes.NewReader([]byte("content"))
fd, err := BuildFormdata(fio, "file", "", true, stdin, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if fd == nil {
t.Fatal("expected non-nil Formdata")
}
})
t.Run("dataJSON non-map is ignored", func(t *testing.T) {
stdin := bytes.NewReader([]byte("content"))
fd, err := BuildFormdata(fio, "file", "", true, stdin, "not-a-map")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if fd == nil {
t.Fatal("expected non-nil Formdata")
}
})
}

View File

@@ -5,35 +5,46 @@ package cmdutil
import (
"encoding/json"
"io"
"github.com/larksuite/cli/internal/output"
)
// ParseOptionalBody parses --data JSON for methods that accept a request body.
// Supports stdin (-) and single-quote stripping via ResolveInput.
// Returns (nil, nil) if the method has no body or data is empty.
func ParseOptionalBody(httpMethod, data string) (interface{}, error) {
func ParseOptionalBody(httpMethod, data string, stdin io.Reader) (interface{}, error) {
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
default:
return nil, nil
}
if data == "" {
resolved, err := ResolveInput(data, stdin)
if err != nil {
return nil, output.ErrValidation("--data: %s", err)
}
if resolved == "" {
return nil, nil
}
var body interface{}
if err := json.Unmarshal([]byte(data), &body); err != nil {
if err := json.Unmarshal([]byte(resolved), &body); err != nil {
return nil, output.ErrValidation("--data invalid JSON format")
}
return body, nil
}
// ParseJSONMap parses a JSON string into a map. Returns an empty map if input is empty.
func ParseJSONMap(input, label string) (map[string]any, error) {
if input == "" {
// Supports stdin (-) and single-quote stripping via ResolveInput.
func ParseJSONMap(input, label string, stdin io.Reader) (map[string]any, error) {
resolved, err := ResolveInput(input, stdin)
if err != nil {
return nil, output.ErrValidation("%s: %s", label, err)
}
if resolved == "" {
return map[string]any{}, nil
}
var result map[string]any
if err := json.Unmarshal([]byte(input), &result); err != nil {
if err := json.Unmarshal([]byte(resolved), &result); err != nil {
return nil, output.ErrValidation("%s invalid format, expected JSON object", label)
}
return result, nil

View File

@@ -23,7 +23,7 @@ func TestParseOptionalBody(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseOptionalBody(tt.method, tt.data)
got, err := ParseOptionalBody(tt.method, tt.data, nil)
if (err != nil) != tt.wantErr {
t.Errorf("ParseOptionalBody() error = %v, wantErr %v", err, tt.wantErr)
return
@@ -53,7 +53,7 @@ func TestParseJSONMap(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseJSONMap(tt.input, tt.label)
got, err := ParseJSONMap(tt.input, tt.label, nil)
if (err != nil) != tt.wantErr {
t.Errorf("ParseJSONMap() error = %v, wantErr %v", err, tt.wantErr)
return

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"fmt"
"io"
"strings"
)
// ResolveInput resolves special input conventions for a raw flag value:
// - "-" → read all bytes from stdin
// - "'...'" → strip surrounding single quotes (Windows cmd.exe compatibility)
// - other → return as-is
//
// This allows callers to bypass shell quoting issues (especially on Windows
// PowerShell) by piping JSON via stdin instead of command-line arguments.
func ResolveInput(raw string, stdin io.Reader) (string, error) {
if raw == "" {
return "", nil
}
// stdin
if raw == "-" {
if stdin == nil {
return "", fmt.Errorf("stdin is not available")
}
data, err := io.ReadAll(stdin)
if err != nil {
return "", fmt.Errorf("failed to read stdin: %w", err)
}
s := strings.TrimSpace(string(data))
if s == "" {
return "", fmt.Errorf("stdin is empty (did you forget to pipe input?)")
}
return s, nil
}
// strip surrounding single quotes (Windows cmd.exe passes them literally)
if len(raw) >= 2 && raw[0] == '\'' && raw[len(raw)-1] == '\'' {
raw = raw[1 : len(raw)-1]
}
return raw, nil
}

View File

@@ -0,0 +1,189 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"fmt"
"strings"
"testing"
)
func TestResolveInput_Stdin(t *testing.T) {
got, err := ResolveInput("-", strings.NewReader(`{"key":"value"}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != `{"key":"value"}` {
t.Errorf("got %q, want %q", got, `{"key":"value"}`)
}
}
func TestResolveInput_Stdin_TrimNewline(t *testing.T) {
got, err := ResolveInput("-", strings.NewReader("{\"k\":\"v\"}\n"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != `{"k":"v"}` {
t.Errorf("got %q, want %q", got, `{"k":"v"}`)
}
}
func TestResolveInput_Stdin_Empty(t *testing.T) {
_, err := ResolveInput("-", strings.NewReader(""))
if err == nil {
t.Error("expected error for empty stdin")
}
if !strings.Contains(err.Error(), "stdin is empty") {
t.Errorf("expected 'stdin is empty' error, got: %v", err)
}
}
type errorReader struct{}
func (errorReader) Read([]byte) (int, error) { return 0, fmt.Errorf("disk failure") }
func TestResolveInput_Stdin_ReadError(t *testing.T) {
_, err := ResolveInput("-", errorReader{})
if err == nil || !strings.Contains(err.Error(), "failed to read stdin") {
t.Errorf("expected read error, got: %v", err)
}
}
func TestResolveInput_Stdin_WhitespaceOnly(t *testing.T) {
_, err := ResolveInput("-", strings.NewReader(" \n\t\n "))
if err == nil {
t.Error("expected error for whitespace-only stdin")
}
}
func TestResolveInput_Stdin_Nil(t *testing.T) {
_, err := ResolveInput("-", nil)
if err == nil {
t.Error("expected error for nil stdin")
}
}
func TestResolveInput_StripSingleQuotes(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"cmd.exe JSON", `'{"key":"value"}'`, `{"key":"value"}`},
{"cmd.exe empty", `'{}'`, `{}`},
{"no quotes", `{"key":"value"}`, `{"key":"value"}`},
{"just quotes", `''`, ``},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ResolveInput(tt.in, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
func TestResolveInput_Empty(t *testing.T) {
got, err := ResolveInput("", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "" {
t.Errorf("got %q, want empty", got)
}
}
func TestResolveInput_PlainValue(t *testing.T) {
got, err := ResolveInput(`{"already":"valid"}`, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != `{"already":"valid"}` {
t.Errorf("got %q, want %q", got, `{"already":"valid"}`)
}
}
func TestResolveInput_AtPrefixPassedThrough(t *testing.T) {
// Without @file support, @-prefixed values are passed as-is
got, err := ResolveInput("@something", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "@something" {
t.Errorf("got %q, want %q", got, "@something")
}
}
// Integration: ResolveInput flows through ParseJSONMap correctly.
func TestParseJSONMap_WithStdin(t *testing.T) {
stdin := strings.NewReader(`{"message_id":"om_xxx","user_id_type":"open_id"}`)
got, err := ParseJSONMap("-", "--params", stdin)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 2 {
t.Errorf("got %d keys, want 2", len(got))
}
}
func TestParseJSONMap_StripSingleQuotes_CmdExe(t *testing.T) {
got, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got["key"] != "value" {
t.Errorf("got %v, want key=value", got)
}
}
func TestParseOptionalBody_WithStdin(t *testing.T) {
stdin := strings.NewReader(`{"text":"hello"}`)
got, err := ParseOptionalBody("POST", "-", stdin)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == nil {
t.Fatal("expected non-nil body")
}
m, ok := got.(map[string]interface{})
if !ok {
t.Fatalf("expected map, got %T", got)
}
if m["text"] != "hello" {
t.Errorf("got %v, want text=hello", m)
}
}
// Simulates exact strings Go receives on different Windows shells.
func TestParseJSONMap_WindowsShellScenarios(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
wantErr bool
}{
{"bash: normal JSON", `{"a":"1","b":"2"}`, 2, false},
{"cmd.exe: single-quoted", `'{"a":"1","b":"2"}'`, 2, false}, // strip ' fix
{"PS 5.x: mangled", `{a:1,b:2}`, 0, true}, // unrecoverable
{"PS 5.x: empty JSON OK", `{}`, 0, false}, // no inner "
{"PS 7.3+: normal JSON", `{"a":"1"}`, 1, false}, // already fixed
{"PS escaped: correct", `{"a":"1"}`, 1, false}, // after CommandLineToArgvW
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseJSONMap(tt.input, "--params", nil)
if (err != nil) != tt.wantErr {
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && len(got) != tt.wantLen {
t.Errorf("got %d keys, want %d", len(got), tt.wantLen)
}
})
}
}

View File

@@ -7,14 +7,17 @@ import (
"bytes"
"context"
"net/http"
"os"
"testing"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/vfs"
)
// noopKeychain is a no-op KeychainAccess for tests that don't need keychain.
@@ -62,12 +65,13 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer,
)
f := &Factory{
Config: func() (*core.CliConfig, error) { return config, nil },
HttpClient: func() (*http.Client, error) { return mockClient, nil },
LarkClient: func() (*lark.Client, error) { return testLarkClient, nil },
IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf},
Keychain: &noopKeychain{},
Credential: testCred,
Config: func() (*core.CliConfig, error) { return config, nil },
HttpClient: func() (*http.Client, error) { return mockClient, nil },
LarkClient: func() (*lark.Client, error) { return testLarkClient, nil },
IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf},
Keychain: &noopKeychain{},
Credential: testCred,
FileIOProvider: fileio.GetProvider(),
}
return f, stdoutBuf, stderrBuf, reg
}
@@ -83,6 +87,23 @@ func (a *testDefaultAcct) ResolveAccount(ctx context.Context) (*credential.Accou
return credential.AccountFromCliConfig(a.config), nil
}
// TestChdir changes the working directory to dir for the duration of the test.
// The original directory is restored via t.Cleanup.
// This enables tests to use LocalFileIO (which resolves relative paths under cwd)
// with temporary directories, keeping test artifacts out of the source tree.
// Not compatible with t.Parallel() — os.Chdir is process-wide.
func TestChdir(t *testing.T, dir string) {
t.Helper()
orig, err := vfs.Getwd()
if err != nil {
t.Fatalf("Getwd: %v", err)
}
if err := os.Chdir(dir); err != nil { //nolint:forbidigo // no vfs.Chdir yet; test-only, process-wide chdir
t.Fatalf("Chdir(%s): %v", dir, err)
}
t.Cleanup(func() { os.Chdir(orig) }) //nolint:forbidigo // matching restore
}
type testDefaultToken struct{}
func (t *testDefaultToken) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {

View File

@@ -163,6 +163,16 @@ type CliConfig struct {
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
}
// identityBotBit is the bit flag for bot identity in SupportedIdentities.
// Must match extension/credential.SupportsBot.
const identityBotBit uint8 = 1 << 1
// CanBot reports whether the current credential context supports bot identity.
// Returns true when SupportedIdentities is unset (0, unknown) or includes the bot bit.
func (c *CliConfig) CanBot() bool {
return c.SupportedIdentities == 0 || c.SupportedIdentities&identityBotBit != 0
}
// GetConfigDir returns the config directory path.
// If the home directory cannot be determined, it falls back to a relative path
// and prints a warning to stderr.
@@ -240,6 +250,12 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
}
}
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
return nil, &ConfigError{Code: 2, Type: "config",
Message: "appId and appSecret keychain key are out of sync",
Hint: err.Error()}
}
secret, err := ResolveSecretInput(app.AppSecret, kc)
if err != nil {
// If the error comes from the keychain, it will already be wrapped as an ExitError.

View File

@@ -5,9 +5,21 @@ package core
import (
"encoding/json"
"errors"
"testing"
"github.com/larksuite/cli/internal/keychain"
)
// stubKeychain is a minimal KeychainAccess that always returns ErrNotFound.
type stubKeychain struct{}
func (stubKeychain) Get(service, account string) (string, error) {
return "", keychain.ErrNotFound
}
func (stubKeychain) Set(service, account, value string) error { return nil }
func (stubKeychain) Remove(service, account string) error { return nil }
func TestAppConfig_LangSerialization(t *testing.T) {
app := AppConfig{
AppId: "cli_test", AppSecret: PlainSecret("secret"),
@@ -73,6 +85,85 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) {
}
}
func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{
{
AppId: "cli_new_app",
AppSecret: SecretInput{Ref: &SecretRef{
Source: "keychain",
ID: "appsecret:cli_old_app",
}},
Brand: BrandFeishu,
},
},
}
_, err := ResolveConfigFromMulti(raw, nil, "")
if err == nil {
t.Fatal("expected error for mismatched appId and appSecret keychain key")
}
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected ConfigError, got %T: %v", err, err)
}
if cfgErr.Hint == "" {
t.Error("expected non-empty hint in ConfigError")
}
}
func TestResolveConfigFromMulti_AcceptsPlainSecret(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{
{
AppId: "cli_abc",
AppSecret: PlainSecret("my-secret"),
Brand: BrandFeishu,
},
},
}
cfg, err := ResolveConfigFromMulti(raw, nil, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.AppID != "cli_abc" {
t.Errorf("AppID = %q, want %q", cfg.AppID, "cli_abc")
}
}
func TestResolveConfigFromMulti_MatchingKeychainRefPassesValidation(t *testing.T) {
// Keychain ref matches appId, so validation passes.
// The subsequent ResolveSecretInput will fail (no real keychain),
// but that proves the mismatch check itself passed.
raw := &MultiAppConfig{
Apps: []AppConfig{
{
AppId: "cli_abc",
AppSecret: SecretInput{Ref: &SecretRef{
Source: "keychain",
ID: "appsecret:cli_abc",
}},
Brand: BrandFeishu,
},
},
}
_, err := ResolveConfigFromMulti(raw, stubKeychain{}, "")
if err == nil {
// stubKeychain returns ErrNotFound, so we expect a keychain error,
// but NOT a mismatch error — that's the point of this test.
t.Fatal("expected error (keychain entry not found), got nil")
}
// The error should come from keychain resolution, NOT from our mismatch check.
var cfgErr *ConfigError
if errors.As(err, &cfgErr) {
if cfgErr.Message == "appId and appSecret keychain key are out of sync" {
t.Fatal("error came from mismatch check, but keys should match")
}
}
}
func TestResolveConfigFromMulti_DoesNotUseEnvProfileFallback(t *testing.T) {
t.Setenv("LARKSUITE_CLI_PROFILE", "missing")
@@ -96,3 +187,24 @@ func TestResolveConfigFromMulti_DoesNotUseEnvProfileFallback(t *testing.T) {
t.Fatalf("ResolveConfigFromMulti() profile = %q, want %q", cfg.ProfileName, "active")
}
}
func TestCliConfig_CanBot(t *testing.T) {
tests := []struct {
name string
supportedIdentities uint8
want bool
}{
{"unset (0) defaults to true", 0, true},
{"user only", 1, false},
{"bot only", 2, true},
{"both", 3, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &CliConfig{SupportedIdentities: tt.supportedIdentities}
if got := cfg.CanBot(); got != tt.want {
t.Errorf("CanBot() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -52,6 +52,25 @@ func ForStorage(appId string, input SecretInput, kc keychain.KeychainAccess) (Se
return SecretInput{Ref: &SecretRef{Source: "keychain", ID: key}}, nil
}
// ValidateSecretKeyMatch checks that the appSecret keychain key references the
// expected appId. This prevents silent mismatches when config.json is edited by
// hand (e.g. appId changed but appSecret.id still points to the old app).
// Only applicable when appSecret is a keychain SecretRef; other forms are skipped.
func ValidateSecretKeyMatch(appId string, secret SecretInput) error {
if secret.Ref == nil || secret.Ref.Source != "keychain" {
return nil
}
expected := secretAccountKey(appId)
if secret.Ref.ID != expected {
return fmt.Errorf(
"appSecret keychain key %q does not match appId %q (expected %q); "+
"please run `lark-cli config init` to reconfigure",
secret.Ref.ID, appId, expected,
)
}
return nil
}
// RemoveSecretStore cleans up keychain entries when an app is removed.
// Errors are intentionally ignored — cleanup is best-effort.
func RemoveSecretStore(input SecretInput, kc keychain.KeychainAccess) {

View File

@@ -0,0 +1,59 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"strings"
"testing"
)
func TestValidateSecretKeyMatch_KeychainMatches(t *testing.T) {
secret := SecretInput{Ref: &SecretRef{Source: "keychain", ID: "appsecret:cli_abc123"}}
if err := ValidateSecretKeyMatch("cli_abc123", secret); err != nil {
t.Errorf("expected no error, got: %v", err)
}
}
func TestValidateSecretKeyMatch_KeychainMismatch(t *testing.T) {
secret := SecretInput{Ref: &SecretRef{Source: "keychain", ID: "appsecret:cli_old_app"}}
err := ValidateSecretKeyMatch("cli_new_app", secret)
if err == nil {
t.Fatal("expected error for mismatched appId and keychain key")
}
// Verify the error message contains useful context
msg := err.Error()
for _, want := range []string{"cli_old_app", "cli_new_app", "appsecret:cli_new_app", "config init"} {
if !strings.Contains(msg, want) {
t.Errorf("error message missing %q: %s", want, msg)
}
}
}
func TestValidateSecretKeyMatch_PlainSecret_Skipped(t *testing.T) {
secret := PlainSecret("some-secret")
if err := ValidateSecretKeyMatch("cli_abc123", secret); err != nil {
t.Errorf("plain secret should be skipped, got: %v", err)
}
}
func TestValidateSecretKeyMatch_FileRef_Skipped(t *testing.T) {
secret := SecretInput{Ref: &SecretRef{Source: "file", ID: "/tmp/secret.txt"}}
if err := ValidateSecretKeyMatch("cli_abc123", secret); err != nil {
t.Errorf("file ref should be skipped, got: %v", err)
}
}
func TestValidateSecretKeyMatch_ZeroValue_Skipped(t *testing.T) {
if err := ValidateSecretKeyMatch("cli_abc123", SecretInput{}); err != nil {
t.Errorf("zero SecretInput should be skipped, got: %v", err)
}
}
func TestValidateSecretKeyMatch_EmptyAppId_Mismatch(t *testing.T) {
secret := SecretInput{Ref: &SecretRef{Source: "keychain", ID: "appsecret:cli_abc123"}}
err := ValidateSecretKeyMatch("", secret)
if err == nil {
t.Fatal("expected error when appId is empty but keychain key references a real app")
}
}

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential_test
import (

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keychain
import (
@@ -9,6 +12,7 @@ import (
"sync"
"time"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
@@ -21,6 +25,13 @@ var (
)
func authLogDir() string {
if dir := os.Getenv("LARKSUITE_CLI_LOG_DIR"); dir != "" {
safeDir, err := validate.SafeEnvDirPath(dir, "LARKSUITE_CLI_LOG_DIR")
if err == nil {
return safeDir
}
}
if dir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR"); dir != "" {
return filepath.Join(dir, "logs")
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keychain
import (
"path/filepath"
"testing"
)
// TestAuthLogDir_UsesValidatedLogDirEnv verifies that a valid absolute
// LARKSUITE_CLI_LOG_DIR is normalized and used as the auth log directory.
func TestAuthLogDir_UsesValidatedLogDirEnv(t *testing.T) {
base := t.TempDir()
base, _ = filepath.EvalSymlinks(base)
t.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(base, "logs", "..", "auth"))
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", "")
got := authLogDir()
want := filepath.Join(base, "auth")
if got != want {
t.Fatalf("authLogDir() = %q, want %q", got, want)
}
}
// TestAuthLogDir_InvalidLogDirFallsBackToConfigDir verifies that an invalid
// LARKSUITE_CLI_LOG_DIR falls back to LARKSUITE_CLI_CONFIG_DIR/logs.
func TestAuthLogDir_InvalidLogDirFallsBackToConfigDir(t *testing.T) {
t.Setenv("LARKSUITE_CLI_LOG_DIR", "relative-logs")
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
got := authLogDir()
want := filepath.Join(configDir, "logs")
if got != want {
t.Fatalf("authLogDir() = %q, want %q", got, want)
}
}

View File

@@ -36,10 +36,10 @@ func wrapError(op string, err error) error {
}
msg := fmt.Sprintf("keychain %s failed: %v", op, err)
hint := "Check if the OS keychain/credential manager is locked or accessible. If running inside a sandbox or CI environment, please ensure the process has the necessary permissions to access the keychain."
hint := "Check if the OS keychain/credential manager is locked or accessible. If running inside a sandbox or CI environment, please ensure the process has the necessary permissions to access the keychain, you can try running this outside the sandbox."
if errors.Is(err, errNotInitialized) {
hint = "The keychain master key may have been cleaned up or deleted. Please reconfigure the CLI by running `lark-cli config init`."
hint = "The keychain master key may have been cleaned up or deleted. If running inside a sandbox or CI environment, please ensure the process has the necessary permissions to access the keychain, you can try running this outside the sandbox. Otherwise, please reconfigure the CLI by running lark-cli config init."
}
func() {

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build darwin
package keychain

View File

@@ -16,6 +16,7 @@ import (
"regexp"
"github.com/google/uuid"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
@@ -25,6 +26,12 @@ const tagBytes = 16
// StorageDir returns the directory where encrypted files are stored.
func StorageDir(service string) string {
if dir := os.Getenv("LARKSUITE_CLI_DATA_DIR"); dir != "" {
safeDir, err := validate.SafeEnvDirPath(dir, "LARKSUITE_CLI_DATA_DIR")
if err == nil {
return filepath.Join(safeDir, service)
}
}
home, err := vfs.UserHomeDir()
if err != nil || home == "" {
// If home is missing, fallback to relative path and print warning.

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build linux
package keychain
import (
"path/filepath"
"testing"
)
// TestStorageDir_UsesValidatedDataDirEnv verifies that a valid absolute
// LARKSUITE_CLI_DATA_DIR is normalized and still preserves service isolation.
func TestStorageDir_UsesValidatedDataDirEnv(t *testing.T) {
base := t.TempDir()
base, _ = filepath.EvalSymlinks(base)
t.Setenv("LARKSUITE_CLI_DATA_DIR", filepath.Join(base, "data", "..", "store"))
got := StorageDir("svc")
want := filepath.Join(base, "store", "svc")
if got != want {
t.Fatalf("StorageDir() = %q, want %q", got, want)
}
}
// TestStorageDir_InvalidDataDirFallsBackToDefault verifies that an invalid
// LARKSUITE_CLI_DATA_DIR falls back to the default per-service storage path.
func TestStorageDir_InvalidDataDirFallsBackToDefault(t *testing.T) {
home := t.TempDir()
home, _ = filepath.EvalSymlinks(home)
t.Setenv("LARKSUITE_CLI_DATA_DIR", "relative-data")
t.Setenv("HOME", home)
got := StorageDir("svc")
want := filepath.Join(home, ".local", "share", "svc")
if got != want {
t.Fatalf("StorageDir() = %q, want %q", got, want)
}
}

View File

@@ -33,6 +33,11 @@ const (
LarkErrRefreshRevoked = 20064 // refresh_token revoked
LarkErrRefreshAlreadyUsed = 20073 // refresh_token already consumed (single-use rotation)
LarkErrRefreshServerError = 20050 // refresh endpoint server-side error, retryable
// Drive shortcut / cross-space constraints.
LarkErrDriveResourceContention = 1061045 // resource contention occurred, please retry
LarkErrDriveCrossTenantUnit = 1064510 // cross tenant and unit not support
LarkErrDriveCrossBrand = 1064511 // cross brand not support
)
// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
@@ -60,6 +65,14 @@ func ClassifyLarkError(code int, msg string) (int, string, string) {
// rate limit
case LarkErrRateLimit:
return ExitAPI, "rate_limit", "please try again later"
// drive-specific constraints that benefit from actionable hints
case LarkErrDriveResourceContention:
return ExitAPI, "conflict", "please retry later and avoid concurrent duplicate requests"
case LarkErrDriveCrossTenantUnit:
return ExitAPI, "cross_tenant_unit", "operate on source and target within the same tenant and region/unit"
case LarkErrDriveCrossBrand:
return ExitAPI, "cross_brand", "operate on source and target within the same brand environment"
}
return ExitAPI, "api_error", ""

View File

@@ -0,0 +1,64 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"strings"
"testing"
)
// TestClassifyLarkError_DriveCreateShortcutConstraints verifies known Drive shortcut errors map to actionable hints.
func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
t.Parallel()
tests := []struct {
name string
code int
wantExitCode int
wantType string
wantHint string
}{
{
name: "resource contention",
code: LarkErrDriveResourceContention,
wantExitCode: ExitAPI,
wantType: "conflict",
wantHint: "avoid concurrent duplicate requests",
},
{
name: "cross tenant unit",
code: LarkErrDriveCrossTenantUnit,
wantExitCode: ExitAPI,
wantType: "cross_tenant_unit",
wantHint: "same tenant and region/unit",
},
{
name: "cross brand",
code: LarkErrDriveCrossBrand,
wantExitCode: ExitAPI,
wantType: "cross_brand",
wantHint: "same brand environment",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotExitCode, gotType, gotHint := ClassifyLarkError(tt.code, "raw msg")
if gotExitCode != tt.wantExitCode {
t.Fatalf("exitCode=%d, want %d", gotExitCode, tt.wantExitCode)
}
if gotType != tt.wantType {
t.Fatalf("type=%q, want %q", gotType, tt.wantType)
}
if gotHint == "" {
t.Fatal("expected non-empty hint")
}
if !strings.Contains(gotHint, tt.wantHint) {
t.Fatalf("hint=%q, want substring %q", gotHint, tt.wantHint)
}
})
}
}

View File

@@ -564,3 +564,54 @@ func TestCollectScopesForProjects_NonexistentProject(t *testing.T) {
t.Errorf("expected empty scopes for nonexistent project, got %d", len(scopes))
}
}
// --- auth_domain functions ---
func TestGetAuthDomain_Configured(t *testing.T) {
// whiteboard has auth_domain: "docs" in service_descriptions.json
if got := GetAuthDomain("whiteboard"); got != "docs" {
t.Errorf("GetAuthDomain(whiteboard) = %q, want %q", got, "docs")
}
}
func TestGetAuthDomain_NotConfigured(t *testing.T) {
if got := GetAuthDomain("calendar"); got != "" {
t.Errorf("GetAuthDomain(calendar) = %q, want empty", got)
}
}
func TestGetAuthDomain_Unknown(t *testing.T) {
if got := GetAuthDomain("nonexistent_xyz"); got != "" {
t.Errorf("GetAuthDomain(nonexistent_xyz) = %q, want empty", got)
}
}
func TestHasAuthDomain(t *testing.T) {
if !HasAuthDomain("whiteboard") {
t.Error("HasAuthDomain(whiteboard) = false, want true")
}
if HasAuthDomain("calendar") {
t.Error("HasAuthDomain(calendar) = true, want false")
}
}
func TestGetAuthChildren(t *testing.T) {
children := GetAuthChildren("docs")
found := false
for _, c := range children {
if c == "whiteboard" {
found = true
break
}
}
if !found {
t.Errorf("GetAuthChildren(docs) = %v, want to contain 'whiteboard'", children)
}
}
func TestGetAuthChildren_NoChildren(t *testing.T) {
children := GetAuthChildren("calendar")
if len(children) != 0 {
t.Errorf("GetAuthChildren(calendar) = %v, want empty", children)
}
}

View File

@@ -4,8 +4,7 @@
"im:message:send_as_bot": 1,
"calendar:calendar:read": 70,
"calendar:calendar:readonly": 1,
"sheets:spreadsheet:write_only": 45,
"docs:document.comment:delete": 60,
"sheets:spreadsheet:write_only": 60,
"drive:drive:readonly": 1,
"docs:doc:readonly": 1,
"sheets:spreadsheet:readonly": 1,

File diff suppressed because it is too large Load Diff

View File

@@ -19,8 +19,9 @@ type serviceDescLocale struct {
// serviceDescEntry holds bilingual descriptions for a service domain.
type serviceDescEntry struct {
En serviceDescLocale `json:"en"`
Zh serviceDescLocale `json:"zh"`
En serviceDescLocale `json:"en"`
Zh serviceDescLocale `json:"zh"`
AuthDomain string `json:"auth_domain,omitempty"`
}
var serviceDescMap map[string]serviceDescEntry
@@ -76,3 +77,31 @@ func GetServiceDetailDescription(name, lang string) string {
}
return loc.Description
}
// GetAuthDomain returns the auth_domain for a service, or "" if not set.
// When auth_domain is set, the service's scopes are collected under the
// parent domain during auth login.
func GetAuthDomain(service string) string {
m := loadServiceDescriptions()
if entry, ok := m[service]; ok {
return entry.AuthDomain
}
return ""
}
// HasAuthDomain reports whether the service has an auth_domain configured.
func HasAuthDomain(service string) bool {
return GetAuthDomain(service) != ""
}
// GetAuthChildren returns all service names whose auth_domain equals parent.
func GetAuthChildren(parent string) []string {
m := loadServiceDescriptions()
var children []string
for name, entry := range m {
if entry.AuthDomain == parent {
children = append(children, name)
}
}
return children
}

View File

@@ -43,6 +43,10 @@
"en": { "title": "Sheets", "description": "Spreadsheet operations" },
"zh": { "title": "电子表格", "description": "电子表格操作" }
},
"slides": {
"en": { "title": "Slides", "description": "Create and manage presentations, read content, and add or remove slides" },
"zh": { "title": "幻灯片", "description": "创建和管理演示文稿、读取内容,以及新增或删除幻灯片页面" }
},
"task": {
"en": { "title": "Task", "description": "Task, task list, and subtask management" },
"zh": { "title": "任务", "description": "任务、清单、子任务管理" }
@@ -53,7 +57,8 @@
},
"whiteboard": {
"en": { "title": "Whiteboard", "description": "Create and edit boards" },
"zh": { "title": "画板", "description": "画板创建、编辑" }
"zh": { "title": "画板", "description": "画板创建、编辑" },
"auth_domain": "docs"
},
"wiki": {
"en": { "title": "Wiki", "description": "Wiki space and node management" },

View File

@@ -0,0 +1,240 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package selfupdate handles installation detection, npm-based updates,
// skills updates, and platform-specific binary replacement for the CLI
// self-update flow.
package selfupdate
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
"time"
"github.com/larksuite/cli/internal/vfs"
)
// InstallMethod describes how the CLI was installed.
type InstallMethod int
const (
InstallNpm InstallMethod = iota
InstallManual
)
const (
NpmPackage = "@larksuite/cli"
)
const (
npmInstallTimeout = 10 * time.Minute
skillsUpdateTimeout = 2 * time.Minute
verifyTimeout = 10 * time.Second
)
// DetectResult holds installation detection results.
type DetectResult struct {
Method InstallMethod
ResolvedPath string
NpmAvailable bool
}
// CanAutoUpdate returns true if the CLI can update itself automatically.
func (d DetectResult) CanAutoUpdate() bool {
return d.Method == InstallNpm && d.NpmAvailable
}
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
func (d DetectResult) ManualReason() string {
if d.Method == InstallNpm && !d.NpmAvailable {
return "installed via npm, but npm is not available in PATH"
}
return "not installed via npm"
}
// NpmResult holds the result of an npm install or skills update execution.
type NpmResult struct {
Stdout bytes.Buffer
Stderr bytes.Buffer
Err error
}
// CombinedOutput returns stdout + stderr concatenated.
func (r *NpmResult) CombinedOutput() string {
return r.Stdout.String() + r.Stderr.String()
}
// Updater manages self-update operations.
// Platform-specific methods (PrepareSelfReplace, CleanupStaleFiles)
// are in updater_unix.go and updater_windows.go.
//
// Override DetectOverride / NpmInstallOverride / SkillsUpdateOverride / VerifyOverride
// / RestoreAvailableOverride for testing.
type Updater struct {
DetectOverride func() DetectResult
NpmInstallOverride func(version string) *NpmResult
SkillsUpdateOverride func() *NpmResult
VerifyOverride func(expectedVersion string) error
RestoreAvailableOverride func() bool
// backupCreated is set to true by PrepareSelfReplace (Windows) when the
// running binary is successfully renamed to .old. Used by
// CanRestorePreviousVersion to report whether rollback is possible.
backupCreated bool
}
// New creates an Updater with default (real) behavior.
func New() *Updater { return &Updater{} }
// DetectInstallMethod determines how the CLI was installed and whether
// npm is available for auto-update.
func (u *Updater) DetectInstallMethod() DetectResult {
if u.DetectOverride != nil {
return u.DetectOverride()
}
exe, err := vfs.Executable()
if err != nil {
return DetectResult{Method: InstallManual}
}
resolved, err := vfs.EvalSymlinks(exe)
if err != nil {
return DetectResult{Method: InstallManual, ResolvedPath: exe}
}
method := InstallManual
if strings.Contains(resolved, "node_modules") {
method = InstallNpm
}
npmAvailable := false
if method == InstallNpm {
if _, err := exec.LookPath("npm"); err == nil {
npmAvailable = true
}
}
return DetectResult{
Method: method,
ResolvedPath: resolved,
NpmAvailable: npmAvailable,
}
}
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
func (u *Updater) RunNpmInstall(version string) *NpmResult {
if u.NpmInstallOverride != nil {
return u.NpmInstallOverride(version)
}
r := &NpmResult{}
npmPath, err := exec.LookPath("npm")
if err != nil {
r.Err = fmt.Errorf("npm not found in PATH: %w", err)
return r
}
ctx, cancel := context.WithTimeout(context.Background(), npmInstallTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, npmPath, "install", "-g", NpmPackage+"@"+version)
cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr
r.Err = cmd.Run()
if ctx.Err() == context.DeadlineExceeded {
r.Err = fmt.Errorf("npm install timed out after %s", npmInstallTimeout)
}
return r
}
// RunSkillsUpdate installs skills, trying the .well-known source first and
// falling back to the GitHub repo on failure or timeout.
func (u *Updater) RunSkillsUpdate() *NpmResult {
if u.SkillsUpdateOverride != nil {
return u.SkillsUpdateOverride()
}
r := u.runSkillsAdd("https://open.feishu.cn")
if r.Err != nil {
r = u.runSkillsAdd("larksuite/cli")
}
return r
}
func (u *Updater) runSkillsAdd(source string) *NpmResult {
r := &NpmResult{}
npxPath, err := exec.LookPath("npx")
if err != nil {
r.Err = fmt.Errorf("npx not found in PATH: %w", err)
return r
}
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", source, "-g", "-y")
cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr
r.Err = cmd.Run()
if ctx.Err() == context.DeadlineExceeded {
r.Err = fmt.Errorf("skills update timed out after %s", skillsUpdateTimeout)
}
return r
}
// VerifyBinary checks that the installed binary reports the expected version
// by running "lark-cli --version" and comparing the version token exactly.
// Output format is "lark-cli version X.Y.Z"; the last field is extracted and
// compared against expectedVersion (both stripped of any "v" prefix).
func (u *Updater) VerifyBinary(expectedVersion string) error {
if u.VerifyOverride != nil {
return u.VerifyOverride(expectedVersion)
}
// Prefer the current executable path (what the user actually launched).
// Use Executable() directly without EvalSymlinks — after npm install the
// symlink target may have changed, but the path itself is still valid for
// execution. Fall back to LookPath only if Executable() fails entirely.
exe, err := vfs.Executable()
if err != nil {
exe, err = exec.LookPath("lark-cli")
if err != nil {
return fmt.Errorf("cannot locate binary: %w", err)
}
}
ctx, cancel := context.WithTimeout(context.Background(), verifyTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, exe, "--version").Output()
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("binary verification timed out after %s", verifyTimeout)
}
if err != nil {
return fmt.Errorf("binary not executable: %w", err)
}
fields := strings.Fields(strings.TrimSpace(string(out)))
if len(fields) == 0 {
return fmt.Errorf("empty version output")
}
actual := strings.TrimPrefix(fields[len(fields)-1], "v")
expected := strings.TrimPrefix(expectedVersion, "v")
if actual != expected {
return fmt.Errorf("expected version %s, got %q", expectedVersion, actual)
}
return nil
}
// Truncate returns the last maxLen runes of s.
func Truncate(s string, maxLen int) string {
if maxLen <= 0 {
return ""
}
r := []rune(s)
if len(r) <= maxLen {
return s
}
return string(r[len(r)-maxLen:])
}
// resolveExe returns the resolved path of the current running binary.
func (u *Updater) resolveExe() (string, error) {
exe, err := vfs.Executable()
if err != nil {
return "", err
}
return vfs.EvalSymlinks(exe)
}

View File

@@ -0,0 +1,89 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package selfupdate
import (
"os"
"path/filepath"
"runtime"
"testing"
"github.com/larksuite/cli/internal/vfs"
)
type executableTestFS struct {
vfs.OsFs
exe string
}
func (f executableTestFS) Executable() (string, error) { return f.exe, nil }
func TestResolveExe(t *testing.T) {
u := New()
p, err := u.resolveExe()
if err != nil {
t.Fatalf("resolveExe() error: %v", err)
}
if !filepath.IsAbs(p) {
t.Errorf("expected absolute path, got: %s", p)
}
}
func TestPrepareSelfReplace_ReturnsNoError(t *testing.T) {
u := New()
restore, err := u.PrepareSelfReplace()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
restore()
}
func TestCleanupStaleFiles_NoPanic(t *testing.T) {
u := New()
u.CleanupStaleFiles()
}
func TestVerifyBinaryChecksVersion(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell script")
}
dir := t.TempDir()
exe := filepath.Join(dir, "lark-cli")
// Script prints version string matching real CLI format when --version is passed.
script := "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo \"lark-cli version 2.0.0\"; exit 0; fi\nexit 12\n"
if err := os.WriteFile(exe, []byte(script), 0755); err != nil {
t.Fatalf("write test binary: %v", err)
}
// Mock vfs.Executable to return our test script, matching VerifyBinary's
// primary lookup path. Also prepend to PATH for the LookPath fallback.
origFS := vfs.DefaultFS
vfs.DefaultFS = executableTestFS{OsFs: vfs.OsFs{}, exe: exe}
t.Cleanup(func() { vfs.DefaultFS = origFS })
origPath := os.Getenv("PATH")
t.Setenv("PATH", dir+string(os.PathListSeparator)+origPath)
// Matching version → success.
if err := New().VerifyBinary("2.0.0"); err != nil {
t.Fatalf("VerifyBinary(matching) error = %v, want nil", err)
}
// Mismatched version → error.
if err := New().VerifyBinary("3.0.0"); err == nil {
t.Fatal("VerifyBinary(mismatched) expected error, got nil")
}
// Substring of actual version must not match (e.g. "0.0" is in "2.0.0").
if err := New().VerifyBinary("0.0"); err == nil {
t.Fatal("VerifyBinary(substring) expected error, got nil")
}
// Version that is a prefix of actual must not match (e.g. "2.0.0" in "12.0.0").
// Binary reports "2.0.0", asking for "12.0.0" must fail.
if err := New().VerifyBinary("12.0.0"); err == nil {
t.Fatal("VerifyBinary(prefix-mismatch) expected error, got nil")
}
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !windows
package selfupdate
// PrepareSelfReplace is a no-op on Unix.
// Unix allows overwriting a running executable via inode semantics.
func (u *Updater) PrepareSelfReplace() (restore func(), err error) {
return func() {}, nil
}
// CleanupStaleFiles is a no-op on Unix (no .old files are created).
func (u *Updater) CleanupStaleFiles() {}
// CanRestorePreviousVersion reports whether PrepareSelfReplace created a
// restorable backup for the current update attempt.
func (u *Updater) CanRestorePreviousVersion() bool {
if u.RestoreAvailableOverride != nil {
return u.RestoreAvailableOverride()
}
return u.backupCreated
}

View File

@@ -0,0 +1,87 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package selfupdate
import (
"fmt"
"github.com/larksuite/cli/internal/vfs"
)
// PrepareSelfReplace renames the running .exe to .old so that npm's
// postinstall script can write the new binary without hitting EBUSY.
// Returns a restore function that undoes the rename on failure.
func (u *Updater) PrepareSelfReplace() (restore func(), err error) {
noop := func() {}
exe, err := u.resolveExe()
if err != nil {
return noop, nil // best-effort; don't block update
}
oldPath := exe + ".old"
// Clean up stale .old from a previous upgrade.
vfs.Remove(oldPath)
// Rename running.exe → running.exe.old (Windows allows rename of locked files).
if err := vfs.Rename(exe, oldPath); err != nil {
return noop, fmt.Errorf("cannot rename binary for update: %w", err)
}
u.backupCreated = true
// Restore: move .old back to the original path.
// Guard with Stat: run.js may have already recovered .old on its own
// during VerifyBinary; if .old is gone, skip to avoid deleting the
// only working binary.
// On any failure, clear backupCreated so CanRestorePreviousVersion
// reports the real outcome instead of claiming success.
restore = func() {
if _, err := vfs.Stat(oldPath); err != nil {
u.backupCreated = false
return
}
vfs.Remove(exe)
if err := vfs.Rename(oldPath, exe); err != nil {
u.backupCreated = false
}
}
return restore, nil
}
// CleanupStaleFiles removes leftover .old files from previous upgrades.
// If the original binary is missing but .old exists (crash mid-update),
// it restores the .old to recover the installation.
func (u *Updater) CleanupStaleFiles() {
exe, err := u.resolveExe()
if err != nil {
return
}
oldPath := exe + ".old"
if _, err := vfs.Stat(oldPath); err != nil {
return // no .old file
}
if _, err := vfs.Stat(exe); err != nil {
// Original missing, .old exists — restore to recover.
vfs.Rename(oldPath, exe)
return
}
// Both exist — .old is stale, clean up.
vfs.Remove(oldPath)
}
// CanRestorePreviousVersion reports whether PrepareSelfReplace created a
// restorable backup for the current update attempt.
func (u *Updater) CanRestorePreviousVersion() bool {
if u.RestoreAvailableOverride != nil {
return u.RestoreAvailableOverride()
}
return u.backupCreated
}

View File

@@ -218,8 +218,8 @@ func fetchLatestVersion() (string, error) {
// is considered newer — an unparseable local version is assumed outdated.
// When a cannot be parsed, returns false (can't confirm it's newer).
func IsNewer(a, b string) bool {
ap := ParseVersion(a)
bp := ParseVersion(b)
ap := parseVersionDetail(a)
bp := parseVersionDetail(b)
if ap == nil {
return false // can't confirm remote is newer
}
@@ -227,28 +227,59 @@ func IsNewer(a, b string) bool {
return true // local version unparseable → assume outdated
}
for i := 0; i < 3; i++ {
if ap[i] > bp[i] {
if ap.core[i] > bp.core[i] {
return true
}
if ap[i] < bp[i] {
if ap.core[i] < bp.core[i] {
return false
}
}
return false
return comparePrerelease(ap.prerelease, bp.prerelease) > 0
}
// ParseVersion parses "X.Y.Z" (with optional "v" prefix and pre-release suffix)
// into [major, minor, patch]. Returns nil on invalid input.
func ParseVersion(v string) []int {
parsed := parseVersionDetail(v)
if parsed == nil {
return nil
}
return []int{parsed.core[0], parsed.core[1], parsed.core[2]}
}
type parsedVersion struct {
core [3]int
prerelease string
}
// validPrerelease matches semver pre-release identifiers (dot-separated).
// Each identifier is either: "0", a non-zero-leading numeric, or alphanumeric with at least one letter/hyphen.
// Rejects empty identifiers ("1.0.0-"), leading-zero numerics ("1.0.0-01"), etc.
var validPrerelease = regexp.MustCompile(
`^(?:0|[1-9]\d*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)` +
`(?:\.(?:0|[1-9]\d*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*$`)
func parseVersionDetail(v string) *parsedVersion {
v = strings.TrimPrefix(v, "v")
if idx := strings.Index(v, "+"); idx >= 0 {
v = v[:idx]
}
prerelease := ""
if idx := strings.Index(v, "-"); idx >= 0 {
prerelease = v[idx+1:]
v = v[:idx]
if prerelease == "" || !validPrerelease.MatchString(prerelease) {
return nil
}
}
parts := strings.SplitN(v, ".", 3)
if len(parts) != 3 {
return nil
}
nums := make([]int, 3)
var nums [3]int
for i, p := range parts {
if idx := strings.IndexAny(p, "-+"); idx >= 0 {
p = p[:idx]
if len(p) > 1 && p[0] == '0' {
return nil // leading zero in core part (e.g. "01.0.0")
}
n, err := strconv.Atoi(p)
if err != nil {
@@ -256,5 +287,56 @@ func ParseVersion(v string) []int {
}
nums[i] = n
}
return nums
return &parsedVersion{core: nums, prerelease: prerelease}
}
func comparePrerelease(a, b string) int {
if a == "" && b == "" {
return 0
}
if a == "" {
return 1
}
if b == "" {
return -1
}
ap := strings.Split(a, ".")
bp := strings.Split(b, ".")
for i := 0; i < len(ap) && i < len(bp); i++ {
cmp := comparePrereleaseIdentifier(ap[i], bp[i])
if cmp != 0 {
return cmp
}
}
switch {
case len(ap) > len(bp):
return 1
case len(ap) < len(bp):
return -1
default:
return 0
}
}
func comparePrereleaseIdentifier(a, b string) int {
an, aErr := strconv.Atoi(a)
bn, bErr := strconv.Atoi(b)
aNumeric := aErr == nil
bNumeric := bErr == nil
switch {
case aNumeric && bNumeric:
if an > bn {
return 1
}
if an < bn {
return -1
}
return 0
case aNumeric:
return -1
case bNumeric:
return 1
default:
return strings.Compare(a, b)
}
}

View File

@@ -56,6 +56,9 @@ func TestIsNewer(t *testing.T) {
{"1.0.0", "9b933f1", true}, // bare commit hash → assume outdated
{"", "1.0.0", false}, // empty remote → false
{"1.1.0", "v1.0.0-12-g9b933f1-dirty", true}, // git describe: 1.1.0 > 1.0.0
{"1.0.0", "1.0.0-rc.1", true}, // stable release > prerelease
{"1.0.0-rc.2", "1.0.0-rc.1", true}, // prerelease identifiers are ordered
{"1.0.0-rc.1", "1.0.0", false}, // prerelease < stable release
}
for _, tt := range tests {
got := IsNewer(tt.a, tt.b)
@@ -74,6 +77,16 @@ func TestParseVersion(t *testing.T) {
{"v1.2.3", []int{1, 2, 3}},
{"0.0.1", []int{0, 0, 1}},
{"1.0.0-beta.1", []int{1, 0, 0}},
{"1.0.0-rc.1", []int{1, 0, 0}},
{"1.0.0-0", []int{1, 0, 0}},
{"1.0.0+build.123", []int{1, 0, 0}},
{"1.0.0-beta.1+build", []int{1, 0, 0}},
{"1.0.0-", nil}, // empty pre-release
{"1.0.0-01", nil}, // leading zero in numeric pre-release
{"1.0.0-beta..1", nil}, // empty identifier between dots
{"01.0.0", nil}, // leading zero in major
{"1.00.0", nil}, // leading zero in minor
{"1.0.00", nil}, // leading zero in patch
{"DEV", nil},
{"", nil},
{"1.2", nil},

View File

@@ -4,74 +4,20 @@
package validate
import (
"fmt"
"io"
"os"
"path/filepath"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
// AtomicWrite writes data to path atomically by creating a temp file in the
// same directory, writing and fsyncing the data, then renaming over the target.
// It replaces os.WriteFile for all config and download file writes.
//
// os.WriteFile truncates the target before writing, so a process kill (CI timeout,
// OOM, Ctrl+C) between truncate and completion leaves the file empty or partial.
// AtomicWrite avoids this: on any failure the temp file is cleaned up and the
// original file remains untouched.
// AtomicWrite writes data to path atomically.
// Delegates to localfileio.AtomicWrite.
func AtomicWrite(path string, data []byte, perm os.FileMode) error {
return atomicWrite(path, perm, func(tmp *os.File) error {
_, err := tmp.Write(data)
return err
})
return localfileio.AtomicWrite(path, data, perm)
}
// AtomicWriteFromReader atomically copies reader contents into path.
// Delegates to localfileio.AtomicWriteFromReader.
func AtomicWriteFromReader(path string, reader io.Reader, perm os.FileMode) (int64, error) {
var copied int64
err := atomicWrite(path, perm, func(tmp *os.File) error {
n, err := io.Copy(tmp, reader)
copied = n
return err
})
if err != nil {
return 0, err
}
return copied, nil
}
func atomicWrite(path string, perm os.FileMode, writeFn func(tmp *os.File) error) error {
dir := filepath.Dir(path)
tmp, err := vfs.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
tmpName := tmp.Name()
success := false
defer func() {
if !success {
tmp.Close()
vfs.Remove(tmpName)
}
}()
if err := tmp.Chmod(perm); err != nil {
return err
}
if err := writeFn(tmp); err != nil {
return err
}
if err := tmp.Sync(); err != nil {
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := vfs.Rename(tmpName, path); err != nil {
return err
}
success = true
return nil
return localfileio.AtomicWriteFromReader(path, reader, perm)
}

View File

@@ -6,25 +6,17 @@ package validate
import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/charcheck"
)
// RejectControlChars rejects C0 control characters (except \t and \n) and
// dangerous Unicode characters from user input.
//
// Control characters cause subtle security issues: null bytes truncate strings
// at the C layer, \r\n enables HTTP header injection
// Unicode characters allow visual spoofing (e.g. making "report.exe" display
// as "report.txt").
// Delegates to charcheck.RejectControlChars — the single source of truth
// for character-level security checks.
func RejectControlChars(value, flagName string) error {
for _, r := range value {
if r != '\t' && r != '\n' && (r < 0x20 || r == 0x7f) {
return fmt.Errorf("%s contains invalid control characters", flagName)
}
if isDangerousUnicode(r) {
return fmt.Errorf("%s contains dangerous Unicode characters", flagName)
}
}
return nil
return charcheck.RejectControlChars(value, flagName)
}
// RejectCRLF rejects strings containing carriage return (\r) or line feed (\n).
@@ -48,23 +40,3 @@ func StripQueryFragment(path string) string {
}
return path
}
// isDangerousUnicode identifies Unicode code points used for visual spoofing attacks.
// These characters are invisible or alter text direction, allowing attackers to make
// "report.exe" display as "report.txt" (Bidi override) or insert hidden content
// (zero-width characters).
func isDangerousUnicode(r rune) bool {
switch {
case r >= 0x200B && r <= 0x200D: // zero-width space/non-joiner/joiner
return true
case r == 0xFEFF: // BOM / ZWNBSP
return true
case r >= 0x202A && r <= 0x202E: // Bidi: LRE/RLE/PDF/LRO/RLO
return true
case r >= 0x2028 && r <= 0x2029: // line/paragraph separator
return true
case r >= 0x2066 && r <= 0x2069: // Bidi isolates: LRI/RLI/FSI/PDI
return true
}
return false
}

View File

@@ -3,127 +3,28 @@
package validate
import (
"fmt"
"path/filepath"
"strings"
import "github.com/larksuite/cli/internal/vfs/localfileio"
"github.com/larksuite/cli/internal/vfs"
)
// SafeOutputPath validates a download/export target path for --output flags.
// It rejects absolute paths, resolves symlinks to their real location, and
// verifies the canonical result is still under the current working directory.
// This prevents an AI Agent from being tricked into writing files outside the
// working directory (e.g. "../../.ssh/authorized_keys") or following symlinks
// to sensitive locations.
//
// The returned absolute path MUST be used for all subsequent I/O to prevent
// time-of-check-to-time-of-use (TOCTOU) race conditions.
// SafeOutputPath validates a download/export target path.
// Delegates to localfileio.SafeOutputPath.
func SafeOutputPath(path string) (string, error) {
return safePath(path, "--output")
return localfileio.SafeOutputPath(path)
}
// SafeInputPath validates an upload/read source path for --file flags.
// It applies the same rules as SafeOutputPath — rejecting absolute paths,
// resolving symlinks, and enforcing working directory containment — to prevent an AI Agent
// from being tricked into reading sensitive files like /etc/passwd.
// SafeInputPath validates an upload/read source path.
// Delegates to localfileio.SafeInputPath.
func SafeInputPath(path string) (string, error) {
return safePath(path, "--file")
return localfileio.SafeInputPath(path)
}
// SafeEnvDirPath validates an environment-provided application directory path.
// Delegates to localfileio.SafeEnvDirPath.
func SafeEnvDirPath(path, envName string) (string, error) {
return localfileio.SafeEnvDirPath(path, envName)
}
// SafeLocalFlagPath validates a flag value as a local file path.
// Empty values and http/https URLs are returned unchanged without validation,
// allowing the caller to handle non-path inputs (e.g. API keys, URLs) upstream.
// For all other values, SafeInputPath rules apply.
// The original relative path is returned unchanged (not resolved to absolute) so
// upload helpers can re-validate at the actual I/O point via SafeUploadPath.
// Delegates to localfileio.SafeLocalFlagPath.
func SafeLocalFlagPath(flagName, value string) (string, error) {
if value == "" || strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
return value, nil
}
if _, err := SafeInputPath(value); err != nil {
return "", fmt.Errorf("%s: %v", flagName, err)
}
return value, nil
}
// safePath is the shared implementation for SafeOutputPath and SafeInputPath.
func safePath(raw, flagName string) (string, error) {
if err := RejectControlChars(raw, flagName); err != nil {
return "", err
}
path := filepath.Clean(raw)
if filepath.IsAbs(path) {
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
}
cwd, err := vfs.Getwd()
if err != nil {
return "", fmt.Errorf("cannot determine working directory: %w", err)
}
resolved := filepath.Join(cwd, path)
// Resolve symlinks: for existing paths, follow to real location;
// for non-existing paths, walk up to the nearest existing ancestor,
// resolve its symlinks, and re-attach the remaining tail segments.
// This prevents TOCTOU attacks where a non-existent intermediate
// directory is replaced with a symlink between check and use.
if _, err := vfs.Lstat(resolved); err == nil {
resolved, err = filepath.EvalSymlinks(resolved)
if err != nil {
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
}
} else {
resolved, err = resolveNearestAncestor(resolved)
if err != nil {
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
}
}
canonicalCwd, _ := filepath.EvalSymlinks(cwd)
if !isUnderDir(resolved, canonicalCwd) {
return "", fmt.Errorf("%s %q resolves outside the current working directory (hint: the path must stay within the working directory after resolving .. and symlinks)", flagName, raw)
}
return resolved, nil
}
// resolveNearestAncestor walks up from path until it finds an existing
// ancestor, resolves that ancestor's symlinks, and re-joins the tail.
// This ensures even deeply nested non-existent paths are anchored to a
// real filesystem location, closing the TOCTOU symlink gap.
func resolveNearestAncestor(path string) (string, error) {
var tail []string
cur := path
for {
if _, err := vfs.Lstat(cur); err == nil {
real, err := filepath.EvalSymlinks(cur)
if err != nil {
return "", err
}
parts := append([]string{real}, tail...)
return filepath.Join(parts...), nil
}
parent := filepath.Dir(cur)
if parent == cur {
// Reached filesystem root without finding an existing ancestor;
// return path as-is and let the containment check reject it.
parts := append([]string{cur}, tail...)
return filepath.Join(parts...), nil
}
tail = append([]string{filepath.Base(cur)}, tail...)
cur = parent
}
}
// isUnderDir checks whether child is under parent directory.
func isUnderDir(child, parent string) bool {
rel, err := filepath.Rel(parent, child)
if err != nil {
return false
}
return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".."
return localfileio.SafeLocalFlagPath(flagName, value)
}

View File

@@ -283,3 +283,30 @@ func TestSafeInputPath_ErrorMessageContainsCorrectFlagName(t *testing.T) {
t.Errorf("error should mention --output, got: %s", err.Error())
}
}
// TestSafeEnvDirPath_RequiresAbsolutePath verifies that environment-provided
// directory paths must be absolute.
func TestSafeEnvDirPath_RequiresAbsolutePath(t *testing.T) {
_, err := SafeEnvDirPath("logs", "LARKSUITE_CLI_LOG_DIR")
if err == nil {
t.Fatal("expected error for relative path")
}
if !strings.Contains(err.Error(), "LARKSUITE_CLI_LOG_DIR") {
t.Fatalf("error should mention env name, got %v", err)
}
}
// TestSafeEnvDirPath_ReturnsNormalizedAbsolutePath verifies that a valid
// absolute environment directory is cleaned and resolved to its canonical path.
func TestSafeEnvDirPath_ReturnsNormalizedAbsolutePath(t *testing.T) {
base := t.TempDir()
base, _ = filepath.EvalSymlinks(base)
got, err := SafeEnvDirPath(filepath.Join(base, "logs", "..", "auth"), "LARKSUITE_CLI_LOG_DIR")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := filepath.Join(base, "auth")
if got != want {
t.Fatalf("SafeEnvDirPath() = %q, want %q", got, want)
}
}

View File

@@ -8,6 +8,8 @@ import (
"net/url"
"regexp"
"strings"
"github.com/larksuite/cli/internal/charcheck"
)
// unsafeResourceChars matches URL-special characters, control characters,
@@ -35,7 +37,7 @@ func ResourceName(name, flagName string) error {
return fmt.Errorf("%s contains invalid characters", flagName)
}
for _, r := range name {
if isDangerousUnicode(r) {
if charcheck.IsDangerousUnicode(r) {
return fmt.Errorf("%s contains dangerous Unicode characters", flagName)
}
}

View File

@@ -6,6 +6,8 @@ package validate
import (
"regexp"
"strings"
"github.com/larksuite/cli/internal/charcheck"
)
// ansiEscape matches ANSI CSI sequences (ESC[ ... letter) and OSC sequences (ESC] ... BEL).
@@ -34,7 +36,7 @@ func SanitizeForTerminal(text string) string {
b.WriteRune(r)
case r < 0x20 || r == 0x7f:
continue
case isDangerousUnicode(r):
case charcheck.IsDangerousUnicode(r):
continue
default:
b.WriteRune(r)

View File

@@ -5,6 +5,8 @@ package validate
import (
"testing"
"github.com/larksuite/cli/internal/charcheck"
)
func TestSanitizeForTerminal_StripsEscapesAndDangerousChars(t *testing.T) {
@@ -74,16 +76,16 @@ func TestIsDangerousUnicode_IdentifiesAllDangerousRanges(t *testing.T) {
0x2066, 0x2067, 0x2068, 0x2069, // isolates
}
for _, r := range dangerous {
if !isDangerousUnicode(r) {
t.Errorf("isDangerousUnicode(%U) = false, want true", r)
if !charcheck.IsDangerousUnicode(r) {
t.Errorf("charcheck.IsDangerousUnicode(%U) = false, want true", r)
}
}
// ── GIVEN: safe Unicode code points → THEN: returns false ──
safe := []rune{'A', '中', '!', ' ', '\t', '\n', 0x200A, 0x2070}
for _, r := range safe {
if isDangerousUnicode(r) {
t.Errorf("isDangerousUnicode(%U) = true, want false", r)
if charcheck.IsDangerousUnicode(r) {
t.Errorf("charcheck.IsDangerousUnicode(%U) = true, want false", r)
}
}
}

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vfs
import (
@@ -28,3 +31,5 @@ func MkdirAll(path string, perm fs.FileMode) error { return DefaultFS.MkdirA
func ReadDir(name string) ([]os.DirEntry, error) { return DefaultFS.ReadDir(name) }
func Remove(name string) error { return DefaultFS.Remove(name) }
func Rename(oldpath, newpath string) error { return DefaultFS.Rename(oldpath, newpath) }
func EvalSymlinks(path string) (string, error) { return DefaultFS.EvalSymlinks(path) }
func Executable() (string, error) { return DefaultFS.Executable() }

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vfs
import (
@@ -26,4 +29,8 @@ type FS interface {
ReadDir(name string) ([]os.DirEntry, error)
Remove(name string) error
Rename(oldpath, newpath string) error
// Path resolution
EvalSymlinks(path string) (string, error)
Executable() (string, error)
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package localfileio
import (
"fmt"
"io"
"os"
"path/filepath"
"github.com/larksuite/cli/internal/vfs"
)
// AtomicWrite writes data to path atomically via temp file + rename.
func AtomicWrite(path string, data []byte, perm os.FileMode) error {
return atomicWrite(path, perm, func(tmp *os.File) error {
_, err := tmp.Write(data)
return err
})
}
// AtomicWriteFromReader atomically copies reader contents into path.
func AtomicWriteFromReader(path string, reader io.Reader, perm os.FileMode) (int64, error) {
var copied int64
err := atomicWrite(path, perm, func(tmp *os.File) error {
n, err := io.Copy(tmp, reader)
copied = n
return err
})
if err != nil {
return 0, err
}
return copied, nil
}
func atomicWrite(path string, perm os.FileMode, writeFn func(tmp *os.File) error) error {
dir := filepath.Dir(path)
tmp, err := vfs.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
tmpName := tmp.Name()
closed := false
success := false
defer func() {
if !success {
if !closed {
tmp.Close()
}
vfs.Remove(tmpName)
}
}()
if err := tmp.Chmod(perm); err != nil {
return err
}
if err := writeFn(tmp); err != nil {
return err
}
if err := tmp.Sync(); err != nil {
return err
}
if err := tmp.Close(); err != nil {
return err
}
closed = true
if err := vfs.Rename(tmpName, path); err != nil {
return err
}
success = true
return nil
}

View File

@@ -0,0 +1,146 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package localfileio
import (
"os"
"path/filepath"
"runtime"
"sync"
"testing"
)
func TestAtomicWrite_WritesContentAndPermissionCorrectly(t *testing.T) {
// GIVEN: a target path in a temp directory
dir := t.TempDir()
path := filepath.Join(dir, "test.json")
data := []byte(`{"key":"value"}`)
// WHEN: AtomicWrite writes data with 0644 permission
if err := AtomicWrite(path, data, 0644); err != nil {
t.Fatalf("AtomicWrite failed: %v", err)
}
// THEN: file content matches exactly
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile failed: %v", err)
}
if string(got) != string(data) {
t.Errorf("content = %q, want %q", got, data)
}
}
func TestAtomicWrite_SetsRestrictivePermission(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("permission test not reliable on Windows")
}
// GIVEN: a target path
dir := t.TempDir()
path := filepath.Join(dir, "secret.json")
// WHEN: AtomicWrite writes with 0600 permission
if err := AtomicWrite(path, []byte("secret"), 0600); err != nil {
t.Fatalf("AtomicWrite failed: %v", err)
}
// THEN: file permission is exactly 0600 (owner read-write only)
info, _ := os.Stat(path)
if perm := info.Mode().Perm(); perm != 0600 {
t.Errorf("permission = %04o, want 0600", perm)
}
}
func TestAtomicWrite_OverwritesExistingFile(t *testing.T) {
// GIVEN: an existing file with old content
dir := t.TempDir()
path := filepath.Join(dir, "test.json")
AtomicWrite(path, []byte("old"), 0644)
// WHEN: AtomicWrite overwrites with new content
if err := AtomicWrite(path, []byte("new"), 0644); err != nil {
t.Fatalf("second write failed: %v", err)
}
// THEN: file contains new content
got, _ := os.ReadFile(path)
if string(got) != "new" {
t.Errorf("content = %q, want %q", got, "new")
}
}
func TestAtomicWrite_LeavesNoResidualTempFileOnError(t *testing.T) {
// GIVEN: a target path in a non-existent nested directory
path := filepath.Join(t.TempDir(), "nonexistent", "subdir", "file.txt")
// WHEN: AtomicWrite fails (parent directory doesn't exist)
err := AtomicWrite(path, []byte("data"), 0644)
// THEN: the write fails
if err == nil {
t.Fatal("expected error writing to nonexistent dir")
}
// THEN: no .tmp files are left behind
parentDir := filepath.Dir(filepath.Dir(path))
entries, _ := os.ReadDir(parentDir)
for _, e := range entries {
if filepath.Ext(e.Name()) == ".tmp" {
t.Errorf("residual temp file found: %s", e.Name())
}
}
}
func TestAtomicWrite_PreservesOriginalFileOnFailure(t *testing.T) {
// GIVEN: an existing file with known content
dir := t.TempDir()
original := []byte("original content")
path := filepath.Join(dir, "file.json")
if err := AtomicWrite(path, original, 0644); err != nil {
t.Fatal(err)
}
// WHEN: AtomicWrite targets a non-existent directory (guaranteed to fail even as root)
badPath := filepath.Join(dir, "no", "such", "dir", "file.json")
err := AtomicWrite(badPath, []byte("new"), 0644)
// THEN: write fails
if err == nil {
t.Fatal("expected error writing to non-existent dir")
}
// THEN: the original file at the valid path is untouched
got, _ := os.ReadFile(path)
if string(got) != string(original) {
t.Errorf("original file corrupted: got %q, want %q", got, original)
}
}
func TestAtomicWrite_HandlesCorrectlyUnderConcurrentWrites(t *testing.T) {
// GIVEN: a target file that will be written by 20 concurrent goroutines
dir := t.TempDir()
path := filepath.Join(dir, "concurrent.json")
// WHEN: 20 goroutines write simultaneously
var wg sync.WaitGroup
for i := range 20 {
wg.Add(1)
go func(n int) {
defer wg.Done()
data := []byte(`{"n":` + string(rune('0'+n%10)) + `}`)
AtomicWrite(path, data, 0644)
}(i)
}
wg.Wait()
// THEN: file exists and is valid (not corrupted by interleaved writes)
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile failed: %v", err)
}
if len(got) == 0 {
t.Error("file is empty after concurrent writes")
}
}

View File

@@ -0,0 +1,81 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package localfileio
import (
"context"
"io"
"path/filepath"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/vfs"
)
// Provider is the default fileio.Provider backed by the local filesystem.
type Provider struct{}
func (p *Provider) Name() string { return "local" }
func (p *Provider) ResolveFileIO(_ context.Context) fileio.FileIO {
return &LocalFileIO{}
}
func init() {
fileio.Register(&Provider{})
}
// LocalFileIO implements fileio.FileIO using the local filesystem.
// Path validation (SafeInputPath/SafeOutputPath), directory creation,
// and atomic writes are handled internally.
type LocalFileIO struct{}
// Open opens a local file for reading after validating the path.
func (l *LocalFileIO) Open(name string) (fileio.File, error) {
safePath, err := SafeInputPath(name)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Open(safePath)
}
// Stat returns file metadata after validating the path.
func (l *LocalFileIO) Stat(name string) (fileio.FileInfo, error) {
safePath, err := SafeInputPath(name)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Stat(safePath)
}
// saveResult implements fileio.SaveResult.
type saveResult struct{ size int64 }
func (r *saveResult) Size() int64 { return r.size }
// ResolvePath returns the validated absolute path for the given output path.
func (l *LocalFileIO) ResolvePath(path string) (string, error) {
resolved, err := SafeOutputPath(path)
if err != nil {
return "", &fileio.PathValidationError{Err: err}
}
return resolved, nil
}
// Save writes body to path atomically after validating the output path.
// Parent directories are created as needed. The body is streamed directly
// to a temp file and renamed, avoiding full in-memory buffering.
func (l *LocalFileIO) Save(path string, _ fileio.SaveOptions, body io.Reader) (fileio.SaveResult, error) {
safePath, err := SafeOutputPath(path)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
return nil, &fileio.MkdirError{Err: err}
}
n, err := AtomicWriteFromReader(safePath, body, 0600)
if err != nil {
return nil, &fileio.WriteError{Err: err}
}
return &saveResult{size: n}, nil
}

View File

@@ -0,0 +1,306 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package localfileio
import (
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/extension/fileio"
)
// testChdir temporarily changes the working directory for a test.
// Not compatible with t.Parallel().
func testChdir(t *testing.T, dir string) {
t.Helper()
orig, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chdir(orig) })
}
// ── Provider ──
func TestProvider_Name(t *testing.T) {
p := &Provider{}
if got := p.Name(); got != "local" {
t.Errorf("Provider.Name() = %q, want %q", got, "local")
}
}
func TestProvider_ResolveFileIO(t *testing.T) {
p := &Provider{}
fio := p.ResolveFileIO(nil)
if fio == nil {
t.Fatal("Provider.ResolveFileIO returned nil")
}
if _, ok := fio.(*LocalFileIO); !ok {
t.Errorf("expected *LocalFileIO, got %T", fio)
}
}
// ── Open ──
func TestLocalFileIO_Open_ValidFile(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
content := []byte("hello world")
os.WriteFile("test.txt", content, 0644)
fio := &LocalFileIO{}
f, err := fio.Open("test.txt")
if err != nil {
t.Fatalf("Open failed: %v", err)
}
defer f.Close()
got, err := io.ReadAll(f)
if err != nil {
t.Fatalf("ReadAll failed: %v", err)
}
if string(got) != string(content) {
t.Errorf("content = %q, want %q", got, content)
}
}
func TestLocalFileIO_Open_RejectsTraversal(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
fio := &LocalFileIO{}
_, err := fio.Open("../../etc/passwd")
if err == nil {
t.Error("expected error for path traversal")
}
}
func TestLocalFileIO_Open_RejectsAbsolutePath(t *testing.T) {
fio := &LocalFileIO{}
_, err := fio.Open("/etc/passwd")
if err == nil {
t.Error("expected error for absolute path")
}
if err != nil && !strings.Contains(err.Error(), "relative path") {
t.Errorf("error should mention relative path, got: %v", err)
}
}
func TestLocalFileIO_Open_NonexistentFile(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
fio := &LocalFileIO{}
_, err := fio.Open("nonexistent.txt")
if err == nil {
t.Error("expected error for nonexistent file")
}
}
// ── Stat ──
func TestLocalFileIO_Stat_ValidFile(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
os.WriteFile("stat.txt", []byte("12345"), 0644)
fio := &LocalFileIO{}
info, err := fio.Stat("stat.txt")
if err != nil {
t.Fatalf("Stat failed: %v", err)
}
if info.Size() != 5 {
t.Errorf("Size() = %d, want 5", info.Size())
}
if info.IsDir() {
t.Error("expected IsDir() = false")
}
}
func TestLocalFileIO_Stat_RejectsTraversal(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
fio := &LocalFileIO{}
_, err := fio.Stat("../../etc/passwd")
if err == nil {
t.Error("expected error for path traversal")
}
if err != nil && os.IsNotExist(err) {
t.Error("traversal should not be os.IsNotExist, should be a validation error")
}
}
func TestLocalFileIO_Stat_NonexistentReturnsIsNotExist(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
fio := &LocalFileIO{}
_, err := fio.Stat("nope.txt")
if err == nil {
t.Error("expected error for nonexistent file")
}
if !os.IsNotExist(err) {
t.Errorf("expected os.IsNotExist, got: %v", err)
}
}
// ── Save ──
func TestLocalFileIO_Save_WritesContent(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
fio := &LocalFileIO{}
body := strings.NewReader("saved content")
result, err := fio.Save("output.bin", fileio.SaveOptions{}, body)
if err != nil {
t.Fatalf("Save failed: %v", err)
}
if result.Size() != int64(len("saved content")) {
t.Errorf("Size() = %d, want %d", result.Size(), len("saved content"))
}
got, _ := os.ReadFile(filepath.Join(dir, "output.bin"))
if string(got) != "saved content" {
t.Errorf("file content = %q, want %q", got, "saved content")
}
}
func TestLocalFileIO_Save_CreatesParentDirs(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
fio := &LocalFileIO{}
body := strings.NewReader("nested")
_, err := fio.Save(filepath.Join("a", "b", "c.txt"), fileio.SaveOptions{}, body)
if err != nil {
t.Fatalf("Save with nested dir failed: %v", err)
}
got, _ := os.ReadFile(filepath.Join(dir, "a", "b", "c.txt"))
if string(got) != "nested" {
t.Errorf("file content = %q, want %q", got, "nested")
}
}
func TestLocalFileIO_Save_RejectsTraversal(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
fio := &LocalFileIO{}
_, err := fio.Save("../../evil.txt", fileio.SaveOptions{}, strings.NewReader("bad"))
if err == nil {
t.Error("expected error for path traversal in Save")
}
}
func TestLocalFileIO_Save_RejectsAbsolutePath(t *testing.T) {
fio := &LocalFileIO{}
_, err := fio.Save("/tmp/evil.txt", fileio.SaveOptions{}, strings.NewReader("bad"))
if err == nil {
t.Error("expected error for absolute path in Save")
}
}
// ── ResolvePath ──
func TestLocalFileIO_ResolvePath_ReturnsAbsolute(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
fio := &LocalFileIO{}
resolved, err := fio.ResolvePath("file.txt")
if err != nil {
t.Fatalf("ResolvePath failed: %v", err)
}
if !filepath.IsAbs(resolved) {
t.Errorf("expected absolute path, got %q", resolved)
}
if filepath.Base(resolved) != "file.txt" {
t.Errorf("expected base name file.txt, got %q", filepath.Base(resolved))
}
}
func TestLocalFileIO_ResolvePath_RejectsTraversal(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
fio := &LocalFileIO{}
_, err := fio.ResolvePath("../../etc/passwd")
if err == nil {
t.Error("expected error for path traversal in ResolvePath")
}
}
func TestLocalFileIO_ResolvePath_RejectsAbsolute(t *testing.T) {
fio := &LocalFileIO{}
_, err := fio.ResolvePath("/etc/passwd")
if err == nil {
t.Error("expected error for absolute path in ResolvePath")
}
}
// ── Error message consistency ──
func TestLocalFileIO_ErrorMessages_ContainCorrectFlagName(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
fio := &LocalFileIO{}
// Open/Stat use SafeInputPath → errors should mention "--file"
_, err := fio.Open("/absolute/path")
if err == nil || !strings.Contains(err.Error(), "--file") {
t.Errorf("Open absolute path error should mention --file, got: %v", err)
}
_, err = fio.Stat("/absolute/path")
if err == nil || !strings.Contains(err.Error(), "--file") {
t.Errorf("Stat absolute path error should mention --file, got: %v", err)
}
// Save/ResolvePath use SafeOutputPath → errors should mention "--output"
_, err = fio.Save("/absolute/path", fileio.SaveOptions{}, strings.NewReader(""))
if err == nil || !strings.Contains(err.Error(), "--output") {
t.Errorf("Save absolute path error should mention --output, got: %v", err)
}
_, err = fio.ResolvePath("/absolute/path")
if err == nil || !strings.Contains(err.Error(), "--output") {
t.Errorf("ResolvePath absolute path error should mention --output, got: %v", err)
}
}
// ── Control character / Unicode rejection ──
func TestLocalFileIO_RejectsControlCharsInPath(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
fio := &LocalFileIO{}
paths := []string{
"file\x00name.txt", // null byte
"file\x1fname.txt", // control char
"file\u200Bname.txt", // zero-width space
"file\u202Ename.txt", // bidi override
}
for _, p := range paths {
if _, err := fio.Open(p); err == nil {
t.Errorf("Open(%q) should reject control/dangerous chars", p)
}
if _, err := fio.Save(p, fileio.SaveOptions{}, strings.NewReader("")); err == nil {
t.Errorf("Save(%q) should reject control/dangerous chars", p)
}
}
}

View File

@@ -0,0 +1,131 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package localfileio
import (
"fmt"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/charcheck"
"github.com/larksuite/cli/internal/vfs"
)
// SafeOutputPath validates a download/export target path for --output flags.
func SafeOutputPath(path string) (string, error) {
return safePath(path, "--output")
}
// SafeInputPath validates an upload/read source path for --file flags.
func SafeInputPath(path string) (string, error) {
return safePath(path, "--file")
}
// SafeLocalFlagPath validates a flag value as a local file path.
// Empty values and http/https URLs are returned unchanged without validation.
func SafeLocalFlagPath(flagName, value string) (string, error) {
if value == "" || strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
return value, nil
}
if _, err := SafeInputPath(value); err != nil {
return "", fmt.Errorf("%s: %v", flagName, err)
}
return value, nil
}
// SafeEnvDirPath validates an environment-provided application directory path.
// It requires an absolute path, rejects control characters, normalizes the
// input, and resolves symlinks through the nearest existing ancestor.
func SafeEnvDirPath(path, envName string) (string, error) {
if err := charcheck.RejectControlChars(path, envName); err != nil {
return "", err
}
path = filepath.Clean(path)
if !filepath.IsAbs(path) {
return "", fmt.Errorf("%s must be an absolute path, got %q", envName, path)
}
resolved, err := resolveNearestAncestor(path)
if err != nil {
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
}
return resolved, nil
}
// safePath is the shared implementation for SafeOutputPath and SafeInputPath.
func safePath(raw, flagName string) (string, error) {
if err := charcheck.RejectControlChars(raw, flagName); err != nil {
return "", err
}
path := filepath.Clean(raw)
if filepath.IsAbs(path) {
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
}
cwd, err := vfs.Getwd()
if err != nil {
return "", fmt.Errorf("cannot determine working directory: %w", err)
}
resolved := filepath.Join(cwd, path)
if _, err := vfs.Lstat(resolved); err == nil {
resolved, err = filepath.EvalSymlinks(resolved)
if err != nil {
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
}
} else {
resolved, err = resolveNearestAncestor(resolved)
if err != nil {
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
}
}
canonicalCwd, _ := filepath.EvalSymlinks(cwd)
if !isUnderDir(resolved, canonicalCwd) {
return "", fmt.Errorf("%s %q resolves outside the current working directory (hint: the path must stay within the working directory after resolving .. and symlinks)", flagName, raw)
}
return resolved, nil
}
func resolveNearestAncestor(path string) (string, error) {
var tail []string
cur := path
for {
if _, err := vfs.Lstat(cur); err == nil {
real, err := filepath.EvalSymlinks(cur)
if err != nil {
return "", err
}
parts := append([]string{real}, tail...)
return filepath.Join(parts...), nil
}
parent := filepath.Dir(cur)
if parent == cur {
parts := append([]string{cur}, tail...)
return filepath.Join(parts...), nil
}
tail = append([]string{filepath.Base(cur)}, tail...)
cur = parent
}
}
func isUnderDir(child, parent string) bool {
rel, err := filepath.Rel(parent, child)
if err != nil {
return false
}
return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".."
}
// RejectControlChars delegates to charcheck.RejectControlChars.
// Kept as a package-level alias for backward compatibility with callers
// that import localfileio directly.
var RejectControlChars = charcheck.RejectControlChars
// IsDangerousUnicode delegates to charcheck.IsDangerousUnicode.
var IsDangerousUnicode = charcheck.IsDangerousUnicode

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