Compare commits

...

129 Commits

Author SHA1 Message Date
liangshuo-1
776ee686ff chore: release v1.0.17 (#614)
Change-Id: I12f59a72996c9d21dacd5478190a85af765bb1a4
2026-04-22 20:17:43 +08:00
chenxingtong-bytedance
4da6d610e2 feat(im): use Content-Disposition filename when downloading message resources (#536)
When downloading message resources, the saved filename was always derived from
file_key (e.g. file_v2_abc123.xlsx), ignoring the original filename the
sender uploaded. This PR resolves filenames from the Content-Disposition
response header first, falling back to Content-Type-based extension inference
only when the header is absent.

Change-Id: I68b48cf428aa8aded4ad9d55fa042f9d68263c3a
2026-04-22 19:52:27 +08:00
zl-bytedance
3f4352d50c feat: add image support to whiteboard-cli skill (#553)
* feat: add image support to whiteboard-cli skill

- Add references/image.md with image processing workflow
- Update content.md with strict image trigger condition
- Update schema.md with Image node type definition
- Update layout.md with image card layout rules
- Add scenes/photo-showcase.md for image showcase layouts
- Strict trigger: only when user explicitly requests images/配图/插图

* docs: sanitize image.md examples - remove real token, use placeholder URLs, cross-platform file check
2026-04-22 18:54:36 +08:00
wittam-01
543a8365d6 docs: clarify that lark-drive comment listing defaults to unresolved comments only (#609)
Change-Id: Ie4200fe14f1e3c4735c1fcc4aba4a3f9a4900e22
2026-04-22 17:37:23 +08:00
fangshuyu-768
0192cee859 docs(lark-doc): fix --markdown examples that teach literal \n (#602)
Skill examples taught the pattern --markdown "## A\n\n- x\n- y",
which in bash double quotes is a literal backslash + n, not a
newline. lark-cli forwards the value byte-for-byte to MCP, so
the resulting Feishu doc renders "\n\n" as visible text. Agents
and users copy-pasting the examples reliably produced broken
docs.

Documentation-only fix (issue #580 Option 1, non-breaking):

- Replace 9 "...\n..." examples with multi-line quoted strings,
  plus 1 single-quoted example that had the same bug inside
  Markdown-block content
- Add a one-sentence warning callout at the top of each file
- Add a stdin/heredoc example in lark-doc-create.md for longer
  content
- Leave existing $'...' ANSI-C examples untouched — those
  already produce real newlines

No CLI behavior change. Byte-for-byte forwarding is standard
shell semantics; auto-interpreting \n (Option 2) would be a
breaking change and is intentionally not pursued.

Fixes #580
2026-04-22 16:50:24 +08:00
sang-neo03
18e227f281 feat(cmdutil): add X-Cli-Build header for CLI build classification (#596)
* feat(cmdutil): add X-Cli-Build header for CLI build classification

  Adds X-Cli-Build (official / extended / unknown) so the gateway can distinguish official CLI from ISV-repackaged builds.

* test(cmdutil): lift coverage on build-kind classification

Extract classifyBuild as a pure helper so every branch (unknown / extended
main-path / extended credential / extended transport / extended fileio /
official) is reachable without mutating process-wide provider registries.

Also cover: isBuiltinProvider non-pointer values, BuildHeaderTransport
nil-Base fallback path, and fix the Name-spoof test so the test double
returns a value that actually mimics an ISV provider.

Coverage on PR-changed functions:
- classifyBuild: 100% (new)
- computeBuildKind: 61.5% -> 93.3%
- BuildHeaderTransport.RoundTrip: 80% -> 100%
2026-04-22 16:30:32 +08:00
caojie0621
7e9beec422 feat(drive): add +apply-permission to request doc access (#588)
Wrap the POST /drive/v1/permissions/:token/members/apply endpoint as a
user-only shortcut. --token accepts either a bare token or a document
URL, with type auto-inferred from the URL path (/docx/, /sheets/,
/base/, /bitable/, /file/, /wiki/, /doc/, /mindnote/, /minutes/,
/slides/); an explicit --type always wins. --perm is limited to view or
edit; full_access is rejected client-side to match the spec.

Classifier gains two domain-specific hints for the endpoint's newly
documented error codes: 1063006 (per-user-per-document quota of 5/day
reached) and 1063007 (document does not accept apply requests — covers
disallow-external-apply, already-has-access, and unsupported-type).

test(drive): add dry-run E2E for +apply-permission

Invoke the real CLI binary via clie2e.RunCmd under --dry-run and
parse the rendered request JSON with gjson to lock in method, URL
path (including the token segment), type query parameter (auto-inferred
for docx / sheet / slides URLs, taken from explicit --type for bare
tokens), perm body field, and remark presence/omission. A separate
test asserts --perm full_access is rejected by the enum validator
before reaching the server. Fake LARKSUITE_CLI_APP_ID / APP_SECRET /
BRAND are enough because dry-run short-circuits before any API call.

Update drive coverage.md to add a row and refresh metrics.

test(drive): isolate E2E dry-run subprocess from local CLI config

Set LARKSUITE_CLI_CONFIG_DIR to t.TempDir() in both +apply-permission
dry-run tests so the subprocess can't read a developer's real
credentials/profile instead of the fake env vars the tests inject.

test(drive): add E2E case that exercises URL inference override

Previous "bare token with explicit type wins over inference" row used a
bare token, which has no URL-derived type to override. Replace it with
a /docx/ URL + --type wiki combo that actually forces the explicit flag
to win over URL inference, and add a separate bare-token row to keep
the simpler path covered. Refresh coverage.md wording to match.
2026-04-22 16:28:48 +08:00
chanthuang
462d38e8f7 docs(mail): remove get_signatures from skill reference, exposed via +signature instead (#545)
Change-Id: I3463cbd08d595c1cb9cda4fadc6e2a5ad1c62189
2026-04-22 16:08:56 +08:00
kongenpei
e4d263948c fix(base): add default-table follow-up hint to base-create (#600)
* fix(base): add default-table follow-up hint to base-create

* fix(base): route base-create hint to stderr

* fix(base): prefix base-create stderr tip

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-22 14:53:58 +08:00
tuxedomm
11191df703 fix: skip flag-completion registration outside completion path (#598)
* fix: skip flag-completion registration outside completion path

Cobra keeps completion callbacks in a package-global map keyed by
*pflag.Flag with no removal path, so registrations made during Build()
outlive the command itself. Route all seven call sites through
cmdutil.RegisterFlagCompletion and enable registration only when the
invocation actually serves a __complete request.

Measured over 30 dropped Builds: ~202 KB / 2180 retained objects per
Build before, ~0 after.

Change-Id: I734d598a4c91a92c33b02e0f292f640cc0e224c6
2026-04-22 11:55:11 +08:00
yballul-bytedance
e23b3a8dc6 fix: add record-share-link-create in SKILL.md (#597)
Change-Id: Ie8dc96521ee692804b734b030f7c143171193eb9
2026-04-22 11:54:01 +08:00
yballul-bytedance
f3699298aa feat: cli 支持记录分享 no-meego (#466)
Change-Id: Ie78da99096cc1fc8a4671d8178176f4c587466ba
2026-04-22 10:31:37 +08:00
chanthuang
018eeb6414 fix(mail): remove leftover conflict marker in skill docs (#594)
The <<<<<<< HEAD marker was accidentally left in mail.md and SKILL.md
by commit cb301a3 (draft preview URL). Remove it.

Change-Id: I6e1d5c0c66761302a3c4ee1421a16961b666bd80
2026-04-21 21:58:01 +08:00
liangshuo-1
3e5dc3262f chore(release): v1.0.16 (#593)
Change-Id: I2ba82ed0b3cb21cecca0ac09b058b10ea656a98a
2026-04-21 21:44:45 +08:00
chanthuang
c13644a247 feat(mail): support large email attachments (#537)
* feat(mail): add large attachment support via medias/upload API

When attachments would cause the EML to exceed the 25MB limit, they are
automatically uploaded to the mail attachment storage (medias/upload_all
with parent_type="email") and a download-link card is injected into the
HTML body, matching the desktop client's exportLargeFileArea style.

Key changes:
- Add classifyAttachments: EML-size-based splitting of normal vs oversized
- Add uploadLargeAttachments: upload via medias API with email MountPoint
- Add buildLargeAttachmentHTML: desktop-aligned card with CDN icons
- Add processLargeAttachments: unified entry point for all compose shortcuts
- Add LargeAttachmentHTML to emlbuilder.Builder for HTML block injection
- Fix 7bit line folding: use RFC 5322 limit (998) instead of incorrect 76
- Integrate into +draft-create, +forward, +reply, +reply-all

Known limitation: recipient access to large attachment links requires
backend support to register tokens with the draft (see progress doc).

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

* refactor(mail): remove legacy size check, add 3GB limit, integrate +send

- Remove checkAttachmentSizeLimit (replaced by processLargeAttachments)
- Remove 25MB pre-check from validateComposeInlineAndAttachments so that
  large files reach Execute where they are uploaded as large attachments
- Integrate processLargeAttachments into +send shortcut
- Add 3GB single file limit aligned with desktop client
- Clean up unused imports from helpers.go and helpers_test.go

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

* feat(mail): quote-aware HTML insertion, +draft-edit support, cleanup emlbuilder

- Add insertBeforeQuoteOrAppend: insert large attachment HTML before the
  quote block (lark-mail-quote) instead of appending to body end, matching
  desktop's exportLargeFileArea placement logic
- Add preprocessLargeAttachmentsForDraftEdit: intercept add_attachment
  patch ops before draft.Apply, upload oversized files, inject HTML into
  snapshot's HTML body Part directly. No changes to draft sub-package.
- Remove LargeAttachmentHTML field/setter/logic from emlbuilder — it was
  business logic (quote-aware insertion) that doesn't belong in a generic
  EML builder. processLargeAttachments now sets the full HTML body via
  bld.HTMLBody() after merging the large attachment card at the right position.
- All compose shortcuts pass htmlBody to processLargeAttachments for
  quote-aware insertion (composedHTMLBody for reply/forward, body for others).

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

* fix(mail): align large attachment HTML IDs with desktop client

- Container ID: lark-mail-large-file-container → large-file-area (matching
  desktop's MAIL_LARGE_FILE_CONTAINER constant)
- Item ID: lark-mail-large-file-item → large-file-item (matching
  desktop's MAIL_LARGE_FILE_ITEM constant)
- Timestamp: truncate to 9 digits (matching TIMESTAMP_CUT_OUT_ID = 9)
- Refactor HTML generation to use template constants for readability

These IDs are used by the desktop client's BigAttachmentPlugin
([id^=large-file-area]) and the server's LargeFileRule to identify and
remove the HTML block when rendering the attachment card UI.

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

* docs(mail): document large attachment behavior in skill references

Update --attach parameter descriptions across all compose shortcuts
(+send, +reply, +reply-all, +forward, +draft-create, +draft-edit) to
describe automatic large attachment handling when EML exceeds 25 MB.

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

* fix(mail): pass signature-injected HTML to processLargeAttachments

When both --signature-id and large attachments are used, the htmlBody
passed to processLargeAttachments must include the already-injected
signature. Previously mail_send and mail_draft_create passed the
original body, causing processLargeAttachments to overwrite the
signature-injected HTML body when inserting the large attachment card.

Use composedHTMLBody variable (same pattern as reply/forward) to
capture the full processed HTML including signature.

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

* fix(mail): skip draft.Apply when all ops consumed by large attachment preprocessing

When all patch ops are add_attachment targeting oversized files,
preprocessLargeAttachmentsForDraftEdit uploads them and removes the
ops from the patch. The resulting empty patch caused draft.Apply to
fail with "patch ops is required". Now skip Apply when no ops remain.

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

* fix(mail): add X-Lms-Large-Attachment-Ids header in draft-edit large attachment flow

draft-edit's preprocessLargeAttachmentsForDraftEdit uploaded oversized files
and injected HTML cards but never wrote the X-Lms-Large-Attachment-Ids header
into the snapshot, so the mail server could not associate the attachments with
the draft. Merge new token IDs with any existing ones already in the snapshot.

Also extract the duplicated largeAttID struct and header name string into
package-level declarations.

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

* fix(mail): i18n large attachment HTML text aligned with desktop client

Parameterize title and download text in large attachment HTML templates.
Chinese lang uses "来自Lark邮箱的超大附件"/"下载", others use
"Large file from Lark Mail"/"Download", matching desktop's i18n keys
Mail_Attachment_AttachmentFromFeishuMail and Mail_Attachment_Download.

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

* fix(mail): insert large attachment card before quote wrapper, not inside nested quote

insertBeforeQuoteOrAppend matched id="lark-mail-quote" which can appear
deeply nested inside quoted content from previous replies in a thread.
This caused the card to be placed inside the quote area instead of before
it. Switch to matching the "history-quote-wrapper" class which is the
outermost quote container generated by the CLI.

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

* feat(mail): unify large attachment handling in +draft-edit with normal attachments

Extend +draft-edit so that large attachments behave like normal attachments
from the user's perspective: survive body edits, are listed in inspect
output, and are removed via the same remove_attachment op.

Code-wise:
- remove_attachment target now accepts token (for large attachments) in
  addition to part_id / cid; priority part_id > cid > token.
- setBody / setReplyBody auto-preserve the large attachment card in the
  HTML body, mirroring how normal attachments (MIME parts) survive body
  edits. Detection checks only the user-authored region of the value so
  cards inside an appended quote block (from the original quoted message)
  are not mistaken for user-supplied cards.
- --inspect returns large_attachments_summary (token, filename, size) by
  parsing the X-Lms-Large-Attachment-Ids header and the HTML card DOM.
- Well-known Lark HTML/header constants (LargeAttachmentIDsHeader,
  LargeFileContainerIDPrefix, LargeFileItemID, LargeAttachmentTokenAttr)
  moved to the draft package alongside QuoteWrapperClass; the mail package
  consumes them.
- Shared helpers FindHTMLBodyPart and InsertBeforeQuoteOrAppend exported
  from the draft package; mail package switched to consume them, removing
  local duplicates.

Skill reference (lark-mail-draft-edit.md) updated: three locator fields by
attachment type, unified remove_attachment examples, set_body behavior.

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

* fix(mail): place signature before large attachment card consistently

When inserting a signature into a draft that already has a large
attachment card, the signature was placed after the card, diverging from
the compose-time layout where the order is [user][sig][card][quote].

Root cause: insertSignatureOp split only at the quote block, so the
"user region" side inadvertently included the card.

Centralize signature placement in draft.PlaceSignatureBeforeSystemTail,
which splits at the earliest system-managed element (card or quote,
whichever comes first). Both edit-time insertSignatureOp and compose-time
injectSignatureIntoBody now share this single source of truth, removing
the duplicated HTML splicing logic.

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

* fix(mail): auto-preserve signature in set_body and set_reply_body

Previously set_body / set_reply_body replaced the entire HTML body,
silently dropping the signature block. The "replace whole body" semantic
treated signature as user-authored content, which is inconsistent with
how attachments (normal + large) and quote blocks survive body edits —
signature is a system-managed element managed via insert_signature /
remove_signature ops.

Unify the mental model: body-edit ops replace user-authored content
only; signature, large attachment card, normal attachments, and (for
set_reply_body) quote block are all auto-preserved. Users can override
by including equivalents in value, or explicitly delete via dedicated
ops (remove_signature, remove_attachment).

- Add ExtractSignatureBlock helper (symmetric to RemoveSignatureHTML).
- Rename autoPreserveLargeAttachmentCard to
  autoPreserveSystemManagedRegions; extract and inject both sig and card
  from old body, respecting user-supplied equivalents in value's
  user-authored region.
- Update skill doc and patch template notes to reflect the new
  semantics consistently.

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

* fix(mail): use brand-aware display name in large attachment card title

The title "Large file from Lark Mail" / "来自Lark邮箱的超大附件" hard-coded
"Lark" regardless of brand. The desktop client switches between
"Feishu"/"飞书" and "Lark" based on the APP_DISPLAY_NAME i18n
substitution.

Add brandDisplayName(brand, lang) helper:
  - BrandLark    → "Lark"
  - BrandFeishu  → "飞书" (zh) / "Feishu" (en)

Applied to title in buildLargeAttachmentHTML, aligning with the icon CDN
and download URL, which already branch on brand.

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

* style(mail): apply gofmt

CI fast-gate check flagged gofmt-unformatted files. Run gofmt -w on
touched mail files only.

Change-Id: Iec690dc63adfaa54b8f7c85ab5b3ca035476ddbd

* fix(mail): address review feedback on large attachment PR

- Strip <html><head><body> wrapper from xhtml.Render output in
  removeLargeFileItemFromHTML to avoid polluting the HTML body
- Reject plain-text messages with oversized attachments instead of
  silently losing the body content
- Fix attachment count limit in skill doc (100 → 250)
- Remove unused fio/attachFlag params from validateComposeInlineAndAttachments
- Add token escaping test for large attachment HTML builder

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

* fix(mail): recognize server-format X-Lark-Large-Attachment header in draft-edit

When a draft with large attachments is created by the desktop client,
the server returns X-Lark-Large-Attachment (with file_key/file_name/
file_size fields) instead of the CLI-written X-Lms-Large-Attachment-Ids.
Previously CLI only recognized its own header, causing existing large
attachments to be silently dropped when the draft was edited.

- Parse both header formats via IsLargeAttachmentHeader and unified
  largeAttHeaderEntry struct
- Convert server-format entries to CLI-format on save so the server
  can process the update
- Fix inline attachment classification: require non-empty CID to
  classify as inline image (large attachments may have is_inline=true
  but no CID)

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

* fix(mail): skip large attachments in forward URL validation

Large attachments do not have download URLs since they are referenced
by token, not embedded in the EML. Validate only normal attachments
to avoid false "missing download URL" errors when forwarding messages
that contain expired or token-based large attachments.

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

* fix(mail): classify forwarded original attachments for large attachment upload

Previously, all original attachments were unconditionally embedded in
the EML before user attachments were processed for large attachment
upload. When original + user attachments together exceeded the 25 MB
EML limit, the build would fail.

Now all attachments (original + user-added) are classified together
via classifyAttachments. Original attachments that push the EML over
the limit are re-uploaded as large attachments with download cards,
matching the compose/reply flow behavior.

Also refactors uploadLargeAttachmentBytes to reuse the shared
common.UploadDriveMediaAll utility (via new Reader field on the
config struct) instead of duplicating the upload logic, and replaces
bare fmt.Errorf with output.ErrValidation for user input errors.

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

* fix(mail): normalize large attachment header on draft edit to prevent loss

Server returns X-Lark-Large-Attachment header on draft readback, but only
recognizes X-Lms-Large-Attachment-Ids on write. Without normalization,
editing a draft with existing large attachments (e.g. adding a small
attachment) would send back the server-format header unchanged, causing
the server to drop the large attachment association.

Add normalizeLargeAttachmentHeader() at the entry of
preprocessLargeAttachmentsForDraftEdit to convert server-format headers
to CLI format before any processing or early return.

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

* fix(mail): extract large attachment card from quote on forward

When forwarding a message that contains large attachments, the original
message's download card (large-file-area div) was left inside the
forward quote block. Extract it and place it in the main body area
(after signature, before quote), matching the desktop client behavior.

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

* fix(mail): use octet-stream for re-embedded attachments and file-based large upload on forward

- Use application/octet-stream instead of original content type when
  re-embedding downloaded attachments in forward EML. Prevents the mail
  server from treating image/* attachments as inline parts.
- Replace in-memory uploadLargeAttachmentBytes with temp-file-based
  uploadLargeAttachments for oversized original attachments. This
  enables multipart upload for files >20MB which the single-part API
  does not support.

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

* fix(mail): address PR review — blocked extension bypass, index-based op filtering, plain-text draft guard

1. Move CheckBlockedExtension into statAttachmentFiles so oversized
   attachments are validated before classification, covering compose,
   draft-edit, and forward paths.

2. Replace path-based oversized op filtering with SourceIndex-based
   filtering in preprocessLargeAttachmentsForDraftEdit to avoid
   incorrectly removing duplicate-path normal ops.

3. Add HTML body preflight in preprocessLargeAttachmentsForDraftEdit
   before uploading, so plain-text-only drafts fail early instead of
   silently producing a draft with tokens but no download card.

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

* fix(mail): preserve original content type for normal forwarded attachments

The octet-stream override was only needed for the large attachment
upload path (to prevent image/* from being treated as inline by the
drive API). Normal attachments embedded in the EML should retain their
original MIME type so recipients can preview/open them correctly.

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

* fix(mail): reconstruct missing large attachment HTML cards on draft edit

The server strips HTML download cards from the EML body when storing
drafts, so every draft read-back (regardless of creator) lacks them.
Add ensureLargeAttachmentCards which runs before header normalization,
compares server-format header tokens against existing HTML cards via
data-mail-token, and rebuilds only the missing ones. This ensures
external recipients see download links after draft-edit → send.

Also exports ParseLargeAttachmentSummariesFromHeader and
ParseLargeAttachmentItemsFromHTML from the draft package for
cross-package use.

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

* feat(mail): support large attachments in plain-text emails

Previously large attachments required an HTML body for the download card.
Now plain-text emails (--plain-text or text/plain-only drafts) get download
info appended as structured text (title + filename + size + URL), with
i18n and brand awareness matching the HTML card.

Changes:
- Add buildLargeAttachmentPlainText and injectLargeAttachmentTextIntoSnapshot
- Add FindTextBodyPart in draft/projection.go
- Update processLargeAttachments to accept textBody parameter
- Update ensureLargeAttachmentCards to handle text/plain body reconstruction
- Update preprocessLargeAttachmentsForDraftEdit to allow text/plain drafts
- Update all callers (send, draft-create, reply, reply-all, forward)

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

* fix(mail): FindBodyPart skips attachment-disposition parts; update skill docs

FindHTMLBodyPart and FindTextBodyPart now skip parts with
Content-Disposition: attachment, preventing .txt/.html file attachments
from being mistakenly treated as the email body.

Also update all lark-mail skill reference docs to reflect that large
attachments now work in both HTML (download card) and plain-text
(download link text) modes.

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

* fix(mail): fix origIdx mismatch, predictable temp files, and attachment count on forward

- Use SourceIndex instead of linear origIdx counter so classifyAttachments
  reordering does not cause content mismatch between normal/oversized loops
- Use os.CreateTemp for temp files instead of predictable names in CWD
- Include original large attachment count in totalCount limit check

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

* fix(mail): use composed body size and source inline bytes in EML size estimation

estimateEMLBaseSize was using len(body) (raw --body flag) instead of the
actual composed body (which includes quotes, signatures, forward headers).
Source inline images downloaded from the original message were also not
counted. This could cause borderline attachments to be misclassified.

- Use len(composedHTMLBody) + len(composedTextBody) for body size
- Return total downloaded bytes from addInlineImagesToBuilder and pass
  as extraBytes to estimateEMLBaseSize
- Fix applied to all compose shortcuts: send, draft-create, reply,
  reply-all, forward

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

* fix(mail): merge large attachment items into single container on draft edit

When draft-edit had both set_body and add_attachment (oversized), the
ensureLargeAttachmentCards and preprocessLargeAttachmentsForDraftEdit
each created independent large-file-area containers. The subsequent
set_body's autoPreserveSystemManagedRegions only captured the first
container via SplitAtLargeAttachment, discarding the second one.

Fix: injectLargeAttachmentHTMLIntoSnapshot now detects an existing
large-file-area container and appends new items inside it instead of
creating a new container, matching the desktop client's single-container
behavior.

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

* fix(mail): strip large attachment card from reply/reply-all quote

Reply and reply-all should not carry over the original email's large
attachment HTML card into the quoted block. Extract the shared
stripLargeAttachmentCard helper (also used by forward) that removes
the card from orig.bodyRaw before quote construction.

- Reply/reply-all: card is discarded (not re-inserted)
- Forward: card is moved to body area before the quote (unchanged)

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

* fix(mail): skip invalid attachments on forward instead of blocking

When forwarding a message with deleted/expired attachments, the forward
flow now automatically removes them instead of either blocking (normal
attachments) or silently including dead references (large attachments).

- Propagate failed_ids from fetchAttachmentURLs into composeSourceMessage
- Skip failed attachments in the forward download loop with a warning
- Remove corresponding large attachment HTML card items from the body
- Extend itemContainsToken to match server-generated href?token= format

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

* fix(mail): restore dry-run file preflight and reserve card overhead in classifier

1. Restore file existence and blocked-extension checks in
   validateComposeInlineAndAttachments so --dry-run surfaces local
   path errors before Execute.
2. Reserve 3KB per oversized file in classifyAttachments to account
   for the HTML card / plain-text block injected after classification.

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

* fix(mail): revert classifier overhead reserve for simplicity

The 3KB-per-oversized-file reserve in classifyAttachments addressed
a boundary case that is practically impossible to trigger (requires
Normal attachments to fill to within a few KB of 25MB). Remove it
to keep the classifier simple.

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

* style(mail): fix gofmt indentation in draft create tests

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

* fix(mail): remove temp files in forward, use in-memory upload instead

Replace os.CreateTemp/os.WriteFile/os.Remove with in-memory Data field
on attachmentFile, conforming to the project's forbidigo rule against
temp files in shortcuts. Also remove dead uploadLargeAttachmentBytes.

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

* test(mail): add tests for validateComposeInlineAndAttachments and fileTypeIcon

Covers all branches: inline+plain-text conflict, inline+non-HTML body,
missing file, blocked extension, valid pass-through, and all file type
icon mappings.

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

* test(mail): improve coverage for large attachment and draft edit functions

Add tests for snapshotEMLBaseSize, flattenSnapshotParts, estimateEMLBaseSize,
normalizeLargeAttachmentHeader, processLargeAttachments error paths,
preprocessLargeAttachmentsForDraftEdit early-return paths, inject edge cases,
buildLargeAttachmentItems, statAttachmentFiles edge cases, and
prettyDraftAddresses.

Change-Id: Ie661e6ebea63512864d97e20135dd89cb9e9304e
Co-Authored-By: AI
2026-04-21 20:56:37 +08:00
qioqio
cb301a3d1a feat(mail): add draft preview URL to draft operations (#438)
* feat(mail): add draft preview URL to draft operations

- Add draftPreviewURL helpers for send-preview link generation
- Integrate preview_url output in +draft-create, +draft-edit, +reply,
  +forward, +reply-all shortcuts
- Add unit tests (7 test cases, all passing)

Change-Id: Ie3cbb8f96b308aae225bc69f4c3fc2226af0c230

* fix(mail): derive draft preview url from meta service

Change-Id: Ibd10767bf4e4de7f453fff72487fe25090e14605

* fix: streamline mail draft and send outputs

Change-Id: I75a969af29fa862bdf94947a3aa775d6eebee812

* fix(mail): keep draft reference on create and update

Change-Id: Ie5787cf255ec2347c49f0a271209c1a2e4008fe3

* docs: refine mail draft link guidance for skills

Change-Id: Ieaa5afef310edd5253f07eef06678b7a5db38fc0

* fix(mail): return draft reference for save flows

Change-Id: Ied6031a05bdefecdcf60b09f66c5d3947d849f83

* refactor(mail): unify draft save output handling

Change-Id: I400b8f9df97d614b33da3cbdde410ef615444741

* fix(mail): surface automation disable reason

Change-Id: I23293fe6c2febf248c58ea14c87c05dde49872a1

* feat: flatten mail automation send disable output

Change-Id: I747bf54bc3251387b05d94f87fe61da958d78104

* fix(mail): address review feedback for draft docs and tests

Change-Id: I690df5612f36681c1690645d375c5c5e3ef9ca60

* test(mail): reuse upstream send-scope test factory

Change-Id: I7f73956696c5405d8eb81fcd2128f0e9898ea539

* refactor(mail): merge recall fields into send output helper

Change-Id: I5af612d70b05a3c0d8abbc9561fe76bb83b5b359

* fix(mail): omit raw recall status from send output

Change-Id: I2918226a0eb68a45f6cc4ea997e1c941d8c16d52

* style(mail): format send output tests

Change-Id: I8e0ec37aac48bcda6b5ad948f397d184a2a4d81d

* test(mail): cover draft reference output flows

Change-Id: Idd8abdb84613727a24e3fccb7b329e69566bc890
2026-04-21 20:55:41 +08:00
MK
04e3a28529 fix(docs): validate --selection-by-title format early (#256)
* fix(docs): validate --selection-by-title format early

* fix(docs): reject multiline selection-by-title before prefix check

* chore: refresh CI against current main (no code change)

* test(doc): cover DocsUpdate.Validate integration for selection-by-title

codecov/patch was at 27.27% because the PR added three lines to the
Validate closure (the `if err := validateSelectionByTitle(selTitle); err
!= nil { return err }` block) but nothing in the test file exercised
that closure — only the helper function was tested directly.

TestDocsUpdateValidate now builds a bare RuntimeContext via
common.TestNewRuntimeContext, sets the relevant flags on a cobra
command, and calls DocsUpdate.Validate(ctx, rt) across five cases:

  1. Heading-style selection-by-title passes — covers the happy path
     through the new call site and the final `return nil`.
  2. Plain-text title is rejected with heading-prefix guidance —
     covers the new error branch.
  3. Multi-line title is rejected as not a single heading line —
     covers the other error branch inside the helper.
  4. Invalid --mode is still rejected first — proves the new check
     doesn't swallow pre-existing validation.
  5. Conflicting --selection-with-ellipsis + --selection-by-title is
     rejected at the mutual-exclusion check — same ordering contract.

Coverage profile confirms the three added production lines
(docs_update.go L65-67) are now hit: condition 3x, error branch 2x,
happy path via the closure's return nil 1x.
2026-04-21 18:25:26 +08:00
hugang-lark
e02c442aea feat: support event share link and error details (#583)
* feat: support event share info

Change-Id: I4876df38effe44de04e587ac18ace7e230c9fa3a

* fix: return detail err info for calendar
2026-04-21 17:51:56 +08:00
tuxedomm
fbed6beac3 refactor: split Execute into Build + Execute with explicit IO and keychain injection (#371)
* refactor(cmd): split Execute into Build with IO/Keychain injection

Introduce a public cmd.Build entry point so external consumers (cli-server,
MCP server, other embedders) can assemble the full CLI command tree without
going through os.Args or the platform keychain. Build takes an
InvocationContext plus functional BuildOptions:

  * WithIO(in, out, errOut) — inject custom streams; terminal detection
    is derived from the input's underlying *os.File when present.
  * WithKeychain(kc)        — swap the credential store.
  * HideProfile(bool)       — registered later in cmd.HideProfile.

The existing Execute() keeps using the internal buildInternal (which
still returns the Factory so error handling can attribute exit codes),
and SetDefaultFS replaces the global VFS implementation at startup.

Hardening applied up front:

  * cmdutil.NewIOStreams(in, out, errOut) centralizes terminal detection
    so SystemIO() and WithIO share one path.
  * cmdutil.NewDefault normalizes partial IOStreams — callers may pass
    &IOStreams{Out: buf} without tripping nil-writer panics in the
    RoundTripper warnings, Cobra, or the credential provider.
  * Build guards against nil functional options.
  * An API contract test (cmd/build_api_test.go) exercises Build +
    WithIO + WithKeychain + HideProfile + SetDefaultFS so the public
    surface is reachable by deadcode analysis.

Change-Id: I7c895e6019817401accbde2db3ef800da40ad319

* feat(schema): filter methods by strict mode in schema output

When strict mode is active, schema output now excludes methods that
are incompatible with the forced identity. This applies to both
pretty and JSON output formats at the resource and method levels.

Change-Id: I39647d5578466c3e23dc545bfb917ae075203ad7

* refactor: centralize strict-mode as flag registration

Change-Id: Iec11151c5002c2f58a8aa067d08747db2e4d2d8c

* fix(cmd): align strict-mode completion and build context; drop dead register shims

Thread a context.Context through RegisterShortcuts, RegisterServiceCommands,
and service.registerService/Resource/Method by introducing explicit
*WithContext variants. Pass that context into NewCmdServiceMethodWithContext
so shortcut and service command construction can honor cancellation and
strict-mode pruning consistently.

Also drop the context-less registerMethod and registerResource shims —
they became unreachable once the WithContext variants took over, and
were the source of new deadcode warnings. registerService is retained
because service_test.go still calls it directly.

Change-Id: I3fe5673aed663c7383bbbc5b0ae94d1f3491f22d

* refactor(cmd): hide --profile in single-app mode via build option

- GlobalOptions gains HideProfile; RegisterGlobalFlags stays pure and reads
  the policy off the struct. No boolean-trap parameter, one call per site.
- buildConfig holds GlobalOptions inline so HideProfile(bool) BuildOption
  mutates it directly. buildInternal stays a pure assembly function and
  requires callers to supply WithIO — no implicit os.Std* fallback.
- Add WithIO BuildOption (wrapping raw io.Reader/Writer with automatic
  *os.File TTY detection); Execute injects streams explicitly and decides
  profile visibility via HideProfile(isSingleAppMode()).
- installTipsHelpFunc force-shows hidden root flags while rendering the
  root command's own help, so single-app users still discover --profile
  via lark-cli --help without it polluting subcommand helps.

Change-Id: I7755387e993992ca969e0a4a6f54441cc1993eef

* feat(transport): extension abort hook and shared base transport

Two transport-layer changes bundled because both reshape the base
round-tripper contract used by the HTTP client, the Lark SDK client,
and the in-process updater.

1. Extension abort hook (PreRoundTripE).

   Extensions implementing exttransport.AbortableInterceptor can now
   return an error from PreRoundTripE to skip the built-in chain. The
   post hook still fires with (nil, reason) so extensions can unwind
   resources. extensionMiddleware captures the provider name so the
   returned *AbortError carries attribution.

2. Shared base transport to stop RPC leak.

   util.NewBaseTransport cloned http.DefaultTransport on every call, so
   each cmdutil.Factory produced a fresh *http.Transport whose
   persistConn readLoop/writeLoop goroutines lingered until
   IdleConnTimeout (~90s). Invisible in a single-process CLI, but the
   fork is consumed by cli-server where each RPC request constructs a
   new Factory, causing linear memory + goroutine growth under load.

   Replace NewBaseTransport with SharedTransport — returns
   http.DefaultTransport (the stdlib-wide singleton) by default, and
   a cached proxy-disabled clone only when LARK_CLI_NO_PROXY is set.
   Return type is http.RoundTripper to discourage in-place mutation of
   the shared instance. FallbackTransport is kept as a thin
   *http.Transport wrapper so existing callers in internal/auth and
   internal/cmdutil transport decorators (which were already on the
   singleton path) do not have to migrate.

   Leak-site migrations: factory_default.go (HTTP + SDK base) and
   update.go now call SharedTransport directly.

Change-Id: Ia82462134c5c5ee838be878b887860f41446a235

* fix: unblock Build() zero-opts path and sidecar demo build

Two regressions surfaced on refactor/build-execute-split:

1. cmd.Build(ctx, inv) without WithIO panicked at rootCmd.SetIn/Out/Err
   because cfg.streams stayed nil — NewDefault normalized internally
   but cmd/build.go never saw the normalized value. Default cfg.streams
   to cmdutil.SystemIO() before the root command wires them, and add a
   TestBuild_NoOptions regression guard.

2. sidecar/server-demo/main.go still called cmdutil.NewDefault(inv),
   so `go build -tags authsidecar_demo ./sidecar/server-demo` failed
   with "not enough arguments". Pass nil for the new streams parameter
   to preserve the prior behavior (NewDefault substitutes SystemIO).

Change-Id: I20227b2355cde7d19e22eba3eb841c6d8611e8a7
2026-04-21 14:48:40 +08:00
JackZhao10086
e15aef922e refactor(auth): simplify scope reporting in login flow (#582) 2026-04-21 14:07:51 +08:00
河伯
ccc27ce417 feat(doc): add pre-write semantic warnings to docs +update (#569)
* feat(doc): add pre-write semantic warnings to docs +update

Two static checks run before the MCP update-doc call:

1. replace_* + blank-line markdown: replace_range / replace_all only
   swap text inside an existing block — a \n\n in the payload will
   render as literal text, not a paragraph break. Hint to use
   delete_range + insert_before instead.

2. Combined bold+italic emphases (***text***, **_text_**, _**text**_)
   cannot round-trip through Lark and are silently downgraded to a
   single emphasis. Hint to split into two separate emphases.

Both warnings go to stderr and never block the update — they inform,
not gate. Adds table-driven tests for each check plus an aggregation
test, and wires the checks into Execute right before CallMCPTool.

Closes the first batch of items from the docs +update pitfalls
review (Cases 1 and 5).

* fix(doc): exclude code regions and escaped markers from docs +update checks (#578)

* fix(doc): exclude code regions and escaped markers from docs +update checks

Addresses the three review comments on #569: the blank-line paragraph
check and the bold+italic emphasis check both operate on the raw
markdown string, so fenced code blocks / inline code spans / literal
escaped markers produce false-positive warnings on content users
expect to pass through verbatim.

Changes:

- Add proseHasBlankLine(): fence-aware detector that returns true only
  when a blank line sits outside of ```...``` or ~~~...~~~ regions.
  Replaces the raw strings.Contains("\n\n") check in
  checkDocsUpdateReplaceMultilineMarkdown.

- Add stripMarkdownCodeRegions(): blanks out fenced code lines and
  masks inline code spans (via scanInlineCodeSpans from markdown_fix.go)
  with equal-length whitespace so byte offsets outside the stripped
  regions are preserved.

- Add stripEscapedEmphasisMarkers(): removes "\*" and "\_" so literal
  sequences like "\***text***" — which CommonMark renders as a literal
  asterisk plus bold — don't match the combined bold+italic regex.

- Wire both helpers into checkDocsUpdateBoldItalic(): the regex now runs
  on stripEscapedEmphasisMarkers(stripMarkdownCodeRegions(markdown)),
  so code samples and escaped markers are sanitized away before
  detection.

Shared fence-parsing helpers (codeFenceOpenMarker, isCodeFenceClose,
leadingRun) are kept local to this file to avoid touching files outside
the scope of the reviewed PR. If a future change wants to reuse them
across the doc package, they can be promoted then.

Tests:

- TestCheckDocsUpdateReplaceMultilineMarkdown: add 4 negative/positive
  cases — blank line inside backtick and tilde fences (no flag), blank
  line in prose while fence also has blanks (flag wins), fenced code
  with no blank lines (no flag).

- TestCheckDocsUpdateBoldItalic: add 9 cases — ***text*** / **_text_** /
  _**text**_ inside fenced code (backtick and tilde), inside inline
  code spans, and escaped \***text*** / \*\*_text_\*\* (none flagged);
  plus two positive cases to verify the strip doesn't over-sanitize
  (real emphasis in prose still fires when inline/fenced code is nearby).

* fix(doc): close CommonMark gaps and add three more combined-emphasis shapes

Self-review of the first commit turned up three issues:

- isCodeFenceClose was strict on exact marker length. Per CommonMark
  §4.5, a closing fence must be at least as long as the opener, not
  exactly the same length. A 3-backtick open legitimately closed by a
  4-backtick closer (used to embed triple-backticks inside the code
  sample) was left open-ended, causing the rest of the document to be
  treated as code and both checks to silently skip it.

- Both fence helpers accepted any amount of leading whitespace because
  they ran on strings.TrimSpace(line). CommonMark allows 0..3 leading
  spaces before a fence marker; 4+ spaces (or any tab in leading
  position, which expands to 4 columns) makes the line indented code
  block content, not a fence open/close. Indented fence-like lines now
  correctly remain prose and blank lines around them are detected.

- The bold/italic check only covered three of the six documented
  combined-emphasis shapes. Added ___text___, __*text*__, and
  *__text__* so parity with the asterisk variants is complete. The
  regex set is now table-driven (combinedEmphasisPatterns) to make
  adding future shapes a one-line change.

Implementation changes:

- New fenceIndentOK(line) helper: returns (body, true) for 0..3 leading
  spaces with no tabs, else (_, false). Used by both codeFenceOpenMarker
  and isCodeFenceClose.
- isCodeFenceClose now counts the fence-char run and accepts any run
  length >= len(marker), with trailing whitespace only.
- checkDocsUpdateBoldItalic replaced three named var regexes with a
  table of six {shape, re} entries and a single early-exit loop.
- Updated docsUpdateWarnings top docstring to list all six shapes.
- Noted the known limitation of stripEscapedEmphasisMarkers around
  doubled backslash escapes ("\\***text***"), which is a false negative
  we accept in exchange for keeping this a simple string replace.

Test additions (docs_update_check_test.go):

- Fence close: longer-marker close correctly ends fence; real prose
  blank after a longer-close fence is still detected.
- Indentation: 4-space indented fence-like line is not a fence open,
  so a surrounding blank line still flags; tab-indented variant same;
  3-space indented fence is still a real fence.
- New shapes: ___text___ positive + all three negative-guards (fenced
  code, inline code, escaped); __*text*__ and *__text__* positive +
  fenced/inline negative-guards; plus two composition tests to ensure
  the strip does not over-sanitize across the six-regex alternative set.

All 53 sub-tests in this file pass; go vet and gofmt are clean.

---------

Co-authored-by: fangshuyu-768 <shuyufang768@outlook.com>

* fix(doc): address CodeRabbit review on docs +update warnings (#581)

Two CodeRabbit nits from #569:

1. Unit test hint assertion only checked for `delete_range` in the
   remediation message; the companion `insert_before` half of the
   guidance could regress undetected. Broaden the assertion to require
   both tokens so a future edit that drops half the remediation
   produces an immediate test failure.

2. No E2E coverage proved the dry-run contract in the PR description
   ("Not emitted in dry-run mode — kept quiet during planning"). The
   helper itself is unit-tested, but nothing caught a regression where
   a later refactor wired docsUpdateWarnings into the DryRun path.

   Add tests/cli_e2e/docs/docs_update_dryrun_test.go:
   TestDocs_UpdateDryRunSuppressesSemanticWarnings invokes
   `docs +update --dry-run --mode=replace_range --markdown "***x***\n\nb"`
   — an input crafted to trip BOTH pre-write warnings — and asserts
   neither the "warning:" prefix, the blank-line message, nor the
   combined-emphasis message appears on stdout or stderr.

   Note: the file needs -f to add because .gitignore has a bare
   `docs/` rule that accidentally matches tests/cli_e2e/docs/. The
   existing tracked files under that directory predate the rule; new
   additions have to be force-added until the ignore pattern is
   narrowed. Not worth rewriting .gitignore for one file.

Verified manually that the new E2E fails cleanly when warnings are
injected into DryRun and passes again after reverting — the test has
real regression-detection power, not just a sticker.

Co-authored-by: fangshuyu-768 <shuyufang768@outlook.com>
2026-04-21 12:38:48 +08:00
zhoule.hhh
24e0bb38eb fix(whiteboard): register +media-upload shortcut and add whiteboard parent type
- Register DocMediaUpload in doc/shortcuts.go (was defined but never
  registered, so lark-cli docs +media-upload was unavailable)
- Rename MediaUpload to DocMediaUpload for consistency with
  DocMediaInsert/DocMediaPreview/DocMediaDownload
- Add whiteboard to --parent-type flag description
- Update --parent-node description to mention board_token for whiteboard

Drive +upload (parent_type=explorer) produces file tokens that the
whiteboard API does not recognize (500 error). The correct approach
is docs +media-upload with parent_type=whiteboard.
2026-04-21 11:31:10 +08:00
河伯
9057299430 feat(doc): add --selection-with-ellipsis position flag to +media-insert (#335)
* feat(doc): add --after-keyword/--before-keyword flags to +media-insert

Allows inserting images/files at a position relative to the first block
whose plain text matches a keyword (case-insensitive substring match).

- Add --after-keyword: insert after the matched root-level block
- Add --before-keyword: insert before the matched root-level block
- Flags are mutually exclusive; default behavior (append to end) unchanged
- fetchAllBlocks: paginated block listing (up to 50 pages × 200 blocks)
- extractBlockPlainText: covers text, heading1-9, bullet, ordered, todo, code, quote
- findInsertIndexByKeyword: walks parent_id chain to resolve nested blocks to their root-level ancestor
- DryRun updated to show block-listing step when keyword flag is set

* test(doc): add fetchAllBlocks pagination and keyword dry-run coverage

- TestFetchAllBlocksPaginationViaExecute: exercises fetchAllBlocks via a
  full Execute flow with --after-keyword, covering multi-page block listing
  (fetchAllBlocks was previously at 0% coverage)
- TestDocMediaInsertDryRunWithAfterKeyword: verifies that the dry-run output
  includes a block-listing step and mentions "search blocks" in the
  description when --after-keyword is provided

fetchAllBlocks coverage: 0% → 76.2%

* refactor(doc): use MCP locate-doc for keyword-based block positioning

Replace fetchAllBlocks + keyword scan with MCP locate-doc tool,
consistent with DriveAddComment. Flags changed from --after-keyword /
--before-keyword to --selection-with-ellipsis + --before.

* fix(doc): show <locate_index> in dry-run create-block when selection is set

When --selection-with-ellipsis is provided, the create-block step in dry-run
now shows index: "<locate_index>" instead of "<children_len>" to accurately
reflect that the insertion position is computed from MCP locate-doc, not
appended to end.

* fix(doc): address CodeRabbit review on +media-insert selection feature

- Validate: reject blank/whitespace --selection-with-ellipsis unconditionally
  so a mis-typed empty value cannot silently fall back to append-mode.
- Redact the raw selection string when logging to stderr and when emitting
  error messages. --selection-with-ellipsis is copied verbatim from document
  content and may contain confidential text; the new redactSelection helper
  keeps a short prefix and rune count so operators can still identify the
  failing selection.
- Harden the after/before mode tests: root children now have three entries
  so the two modes land on different indices, and the tests decode the
  create-block request body to assert the computed `index` actually reaches
  the /children API. A regression that ignored --before would now fail.
- Harden the nested-block test so it exercises the fallback parent-walk:
  the anchor is now two levels deep (blk_grandchild under blk_section_child
  under blk_section), which forces the walk to fetch the intermediate block
  via GET /blocks/{id} to discover the root-level ancestor.

* fix(doc): harden +media-insert selection UX on top of #335 (#577)

Follow-up to #335 review: closes a handful of UX and robustness gaps in
the new --selection-with-ellipsis flow.

- Flag description rewritten to make the "insert at the top-level
  ancestor" semantics explicit — when the selection is inside a callout,
  table cell, or nested list, media lands outside that container, not
  inside. Also calls out the 'start...end' disambiguator.

- locate-doc is now called with limit=2 so an ambiguous selection
  (same phrase in more than one block) surfaces a stderr warning
  pointing at 'start...end', instead of silently picking the first
  match. The first-match return behaviour is unchanged.

- When the anchor is nested below the root, locateInsertIndex now
  logs a note to stderr naming the walk depth and the root-level
  ancestor's insert index. Users don't have to guess why the image
  landed outside the callout they were editing.

- maxDepth bumped 8 → 32 with a comment explaining the invariants:
  `visited` is the real cycle guard, `maxDepth` is belt-and-suspenders.
  32 comfortably exceeds real docx nesting depth so a deeply-nested
  but well-formed anchor is no longer silently rejected.

- Comment added before the parent-walk loop noting why the API calls
  are serial (each level's parent_id is only known after the previous
  GET returns; can't be batched or parallelised).

Tests:

- TestLocateInsertIndexWarnsOnMultipleMatches: stubs two matches,
  asserts the stderr warning names the ambiguity and mentions
  'start...end', and that the first-match insert index is unchanged.
- TestLocateInsertIndexLogsNestedAnchor: anchor two levels below root,
  asserts stderr carries the "nested … top-level ancestor" note.
- TestLocateInsertIndexCycleDetection: malformed parent chain with
  blk_x.parent = blk_y and blk_y.parent = blk_x, neither reachable
  from root. Registering a single GET /blocks/blk_y stub also bounds
  the call count — a regression that broke `visited` tracking would
  either hang or fail via httpmock's extra-call guard.

Co-authored-by: fangshuyu-768 <shuyufang768@outlook.com>
2026-04-20 23:24:11 +08:00
fangshuyu-768
9e891b758e test(doc): harden markdown_fix pipeline with invariant tests (#576)
Adds 5 invariant-level tests on top of #469's transforms:

- TestFixExportedMarkdownIdempotent — f(f(x)) == f(x) across rich
  fixtures (kitchen sink, CJK, nested containers). Protects the core
  round-trip promise from future transform interactions that rewrite
  their own output.
- TestFixExportedMarkdownPreservesFencedCodeByteForByte — packs every
  pipeline-touching shape into a fence and asserts byte-identical output.
  Code samples must never be silently rewritten by a formatting pass.
- TestFixExportedMarkdownPreservesCRLF — CRLF input preserves line
  endings AND still triggers transforms. Windows-authored markdown
  should not be silently LF-normalized.
- TestFixExportedMarkdownTransformInteractions — composition regressions:
  nested-list + trailing-space bold, text→list transition, callout
  containing list with emphasis, heading vs paragraph bold.
- TestNormalizeNestedListIndentationDocumentedSkips — locks in the
  deliberate no-op branches (odd-space indent, blank-line loose-list
  sibling, 4-space indented code block, parentless two-space) as an
  explicit spec so future heuristic tweaks surface in the test diff.

All transforms, fixtures, and expectations are derived from the head of
PR #469. No production code changes.

Co-authored-by: fangshuyu-768 <shuyufang768@outlook.com>
2026-04-20 22:54:51 +08:00
高春晖
293a9f896f fix(doc): preserve round-trip formatting in fetch output (#469)
* fix(doc): preserve round-trip formatting in fetch output

- trim leading spaces inside bold and italic emphasis exported by docs +fetch

- normalize nested list indentation to avoid flattening and literal text on re-import

- add regression tests for emphasis spacing and nested list indentation

* fix(doc): avoid false positives in markdown spacing fixes

- keep literal * x * and ** x ** text unchanged

- only normalize indented nested list markers when a parent list item exists

- add regression coverage for both CodeRabbit findings

* fix(doc): 修正嵌套列表缩进的空行误判

- 遇到空行时停止向上查找父级列表项,避免把 loose list sibling 误改成嵌套列表
- 避免把列表项中的四空格缩进代码块误改成 tab 缩进列表项
- 补充两个回归测试,并更新 fixBoldSpacing 注释使其与当前实现一致

* fix(doc): 修复 Markdown emphasis 空格回写

- 将 fixBoldSpacingLine 改为按星号 run 扫描,修复 ** hello **、* hello * 和同一行多个 italic span 的空格清理
- 保留 inline code、heading 和 *** hello** 这类近邻字面量,避免误改 emphasis nesting
2026-04-20 22:40:56 +08:00
liangshuo-1
0a0cdc8879 chore(release): v1.0.15 (#575) 2026-04-20 22:03:08 +08:00
zhengquanbin
67e51ec8d7 fix: base role view & record default perm on edit(#530)
fix: address coderabbit review comments on role-config docs

- Update `allow_edit` field description to reflect conditional default:
  `true` when table perm is `edit`, `false` for `read_only` or explicit restriction
- Move `record_operations.delete` out of "默认关闭项" into new "默认开启项(条件性)"
  section to accurately reflect it is default-included when `perm = edit`
- Add `view_rule.allow_edit` to "默认开启项(条件性)" section with same logic

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 21:08:04 +08:00
sang-neo03
5943a20e2b Feat/auth sidecar proxy (#532)
* feat(sidecar): add sidecar proxy for sandbox credential isolation

Keep real secrets (app_secret, access_token) out of sandbox environments.
CLI instances inside sandboxes connect to a trusted sidecar process via
HTTP; the sidecar verifies HMAC-signed requests and injects real tokens
before forwarding to the Lark API.

Key components:

- `auth proxy` subcommand to start the sidecar server (build tag: authsidecar)
- Noop credential provider returns sentinel tokens in sidecar mode
- Transport interceptor rewrites requests to sidecar with HMAC signature
- Env provider yields to sidecar provider when AUTH_PROXY is set
- Supports both feishu and lark brand endpoints

* feat(sidecar): implement priority ordering for credential providers

* feat(sidecar): strip client-supplied auth headers and improve shutdown logging

* feat(sidecar): buffer request body to prevent HMAC mismatches on read errors

* feat(sidecar): fix CI

* refactor(sidecar): publish protocol package and move server to reference demo

  The sidecar server is no longer shipped as a `lark-cli auth proxy`
  subcommand. Instead, the CLI provides only the standard sidecar *client*
  (via `-tags authsidecar`), while the wire-protocol utilities are exposed
  as a public package for integrators to implement their own server.

  Changes:
  - Move `internal/sidecar/` → `sidecar/` so external integrators can
    import HMAC signing, headers, sentinels and address validators.
  - Remove `cmd/auth/proxy.go`, `proxy_stub.go`, `proxy_test.go` and the
    conditional registration in `cmd/auth/auth.go`.
  - Add `sidecar/server-demo/` — a reference server implementation behind
    the `authsidecar_demo` build tag. It reuses the lark-cli credential
    pipeline for local development; production integrators are expected
    to replace the credential layer with their own secrets source.
  - Update all internal imports from `internal/sidecar` to `sidecar`.

  Rationale:
  - Each integrator has different secrets management / HA / multi-tenant
    requirements, so a one-size-fits-all server doesn't belong in the
    shipped CLI.
  - Keeping the client in-tree guarantees all sandbox-side code stays
    protocol-compatible without a second repo to sync.
  - The public `sidecar/` package pins the wire protocol as a stable
    contract third-party servers must conform to.

  Build matrix after this change:
  - `go build`                         → standard CLI, no sidecar code
  - `go build -tags authsidecar`       → CLI + sidecar client
  - `go build -tags authsidecar_demo \
      ./sidecar/server-demo/`          → reference server binary

  No production users are affected today because the server was not yet
  released; existing sidecar-client users are unchanged.

* feat(sidecar): close 5 pre-release security gaps
  - Server: enforce https-only target (no path/query/userinfo), pin
    forwardURL to https:// — blocks cleartext token leak
  - Protocol v1: canonical now covers version/identity/auth-header,
    blocks identity-flip replay within drift window
  - Client: ValidateProxyAddr requires loopback or same-host alias,
    rejects userinfo and https (interceptor is http-only); cross-machine
    is out of scope
  - Build: non-authsidecar builds exit(2) when AUTH_PROXY is set,
    preventing silent fallback to env credentials
  - Demo: whitelist auth-header to Authorization / X-Lark-MCP-{UAT,TAT},
    blocks token injection into Cookie / UA / X-Forwarded-For exfil paths
2026-04-20 20:24:51 +08:00
kongenpei
cd666422ac fix(base): preserve attachment metadata on base uploads (#563)
* fix: preserve attachment metadata on base uploads

* test: cover attachment mime detection

* fix: address attachment upload review feedback

* fix: preserve source extension for attachment mime detection

* fix: avoid registry test refresh data race

* Revert "fix: avoid registry test refresh data race"

This reverts commit c1d12d0cf1.

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-20 19:14:45 +08:00
mazhe-nerd
9acd121259 fix: update install message (#529) 2026-04-20 12:03:16 +08:00
caojie0621
1262aac480 fix(sheets): normalize single-cell range in +set-style and +batch-set-style (#548)
/style and /styles_batch_update require full "A1:A1" form and reject
single-cell shorthand "A1". +set-style was using normalizeSheetRange
(prefix-only) and +batch-set-style passed --data through unchanged,
so both failed with `wrong range` when callers supplied a single cell.

Switch +set-style to normalizePointRange, and walk each ranges[]
entry in +batch-set-style through normalizePointRange before sending.
Multi-cell spans pass through unchanged.
2026-04-18 23:29:14 +08:00
caojie0621
abb02cd46c feat(sheets): add float image shortcuts (#494)
Implement +create-float-image, +update-float-image, +get-float-image,
+list-float-images, and +delete-float-image shortcuts wrapping the v3
spreadsheet float_image API. The create reference doc includes the
prerequisite media upload step with the correct parent_type
(sheet_image) to avoid common token mismatch errors.
2026-04-18 23:27:11 +08:00
haozhenghua-code
db7d3cb64d fix(im): cap basic_batch user_ids at 10 per API limit (#551)
The POST /contact/v3/users/basic_batch endpoint caps user_ids at 1~10
per request, but batchResolveByBasicContact was chunking by 50. When
user identity needed to resolve >10 unresolved sender names, the
single oversized request was rejected, causing the batch resolver to
bail out and leave sender names empty for the rest.

Lower batchSize to 10 and add a unit test that exercises 25 missing
IDs and asserts they are sent as 10 / 10 / 5.
2026-04-18 18:41:30 +08:00
Paulazaaza-dev
5134719da9 feat: add remind/initiated method (#554)
Change-Id: I27c00d96a9478efbf39fbc1118bb6bcb75fe6b14
2026-04-18 17:46:23 +08:00
zkh-bytedance
5a0e1d3dd9 fix(whiteboard): Deprecate old lark-whiteboard-cli skill (#547) 2026-04-18 00:36:56 +08:00
syh-cpdsss
09e60eeaf4 fix: add OKR API restriction in SKILL.md (#546)
Change-Id: Ic2734f1da8525ec48f091ccd72c96921b9bb0fc1
2026-04-17 22:36:50 +08:00
liangshuo-1
4f90fd3b77 chore: cut v1.0.14 (#544)
Change-Id: I601e893a048a155635ecd75d5c433b99c42e55fe
2026-04-17 21:46:09 +08:00
chanthuang
6212513c43 feat(mail): add email priority support for compose and read (#538)
* feat(mail): add email priority support for compose and read

Write: all compose shortcuts (+send, +reply, +reply-all, +forward,
+draft-create) accept --priority (high/normal/low) which sets the
X-Cli-Priority EML header. +draft-edit accepts --set-priority.

Read: normalizeMessage now infers priority from label_ids
(HIGH_PRIORITY/LOW_PRIORITY), with priority_type as fallback.

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

* docs(mail): add --priority and --set-priority to skill references

Update 6 skill reference docs: +send, +reply, +reply-all, +forward,
+draft-create add --priority param; +draft-edit adds --set-priority.

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

* test(mail): add unit and integration tests for --priority

- helpers_test.go: cover parsePriority (valid/invalid/case/whitespace)
  and applyPriority (empty vs non-empty) end-to-end via EML builder
- mail_draft_create_test.go: verify --priority propagates to X-Cli-Priority
  header in the built EML, and no header when priority is empty

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

* test(mail): cover buildDraftEditPatch --set-priority and label-based priority

- helpers_test.go: TestBuildMessageOutput_PriorityFromLabels verifies
  HIGH_PRIORITY/LOW_PRIORITY labels map to priority_type_text, and that
  label values override the priority_type fallback field
- mail_draft_edit_test.go (new): cover --set-priority high/low/normal
  (set_header vs remove_header), invalid value rejection, and absence
  of priority op when the flag is unused

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

* fix(mail): write priority_type to output when inferred from label_ids

buildMessageOutput only wrote priority_type_text but not priority_type
when priority was inferred from HIGH_PRIORITY/LOW_PRIORITY labels.
Also covers the case where label overrides an explicit priority_type field.

Change-Id: I7879976d21235b8006b5c8ebe6a413e2815354e1

* fix(mail): validate --priority in Validate so invalid values fail before dry-run/Execute

Change-Id: Ic277ab683967c47f28c892d3512b0ab745bd86f6

* test(mail): add TestValidatePriorityFlag to cover invalid --priority rejected in Validate

Change-Id: I7f12c0a0b0d15c491c28fdcb8729f2f648ba0244
2026-04-17 20:49:32 +08:00
caojie0621
e8df0ea63e feat(drive): support sheet cell comments in +add-comment (#518)
Extend +add-comment to accept sheet URLs and wiki URLs that resolve
to sheets. Reuse --block-id with <sheetId>!<cell> format (e.g.
a281f9!D6) for sheet cell positioning.

Wiki links resolving to sheet type are handled by first calling
get_node, then redirecting to the sheet comment path with proper
parameter validation.
2026-04-17 20:05:08 +08:00
nickzhang
6d0d687be2 feat(doc): add --file-view flag to +media-insert (#419)
* feat(doc): add --file-view flag to +media-insert for file block rendering

The docx File block supports three render modes via view_type
(1=card, 2=preview inline player, 3=inline), but --type=file today
always creates with the default card view. Because view_type can only
be set at creation time (PATCH replace_file ignores it), callers
wanting an inline audio/video player had to abandon the shortcut and
reimplement the full 4-step orchestration manually.

Add --file-view card|preview|inline that threads into file.view_type
on block creation. Omitting the flag preserves the exact request body
that the shortcut sends today, so existing users are unaffected.

--file-view is rejected when combined with --type=image (images have
their own rendering) and when an unknown value is passed.

* refactor(doc): narrow view_type gate and relax file-view test

Address review feedback from automated reviewers on #419:

- Replace `fileViewType > 0` with an explicit 1|2|3 whitelist inside
  buildCreateBlockData so a stray positive int cannot escape into the
  request payload if a future caller bypasses Validate.
- Relax TestFileViewMapCoversDocumentedValues to assert only the
  documented keys rather than full-map equality, so future aliases
  (e.g. a "player" synonym for preview) do not falsely break the test.

No behaviour change for any existing --file-view input.

* test(doc): cover --file-view Validate contract and explicit card path

Pins down the two CLI guard branches (unknown --file-view value and
--file-view passed with --type!=file) that were previously only covered
indirectly through buildCreateBlockData. Also adds the --file-view card
case so the explicit view_type=1 payload (different from the legacy
file: {} shape when the flag is omitted) is locked in as part of the
public flag contract.

* fix: repair unit tests

Change-Id: I8c6bb69bfa22c9455a2cbb0f46b401e2cbe87762

---------

Co-authored-by: Nick Zhang <nickzhangcomes@users.noreply.github.com>
Co-authored-by: wangweiming <wangweiming@bytedance.com>
2026-04-17 19:27:00 +08:00
syh-cpdsss
148a04a7f8 Feat: Add OKR business domain (#522)
* feat: okr domain

Change-Id: I1877c56e33e3620b696351ed9e4c8615dbe17c4b

* feat: okr skill update

Change-Id: I1877c56e33e3620b696351ed9e4c8615dbe17c4b
2026-04-17 18:04:15 +08:00
liujinkun2025
ba19bd9f93 feat(wiki): improve wiki skill docs and add wiki domain template (#512)
- Reorder sections, fix formatting and indentation in SKILL.md
- Add spaces.create method and its scope to API resources and permission table
- Add wiki domain template for skill-template

Change-Id: Ib03dacc02cf2b42f807615c2adedbf79694b5dc0
2026-04-17 18:03:21 +08:00
zkh-bytedance
830fb3bbe5 refactor(skills): introduce lark-doc-whiteboard.md and streamline whiteboard workflow (#502)
- add lark-doc/references/lark-doc-whiteboard.md: defines role boundaries
  between lark-doc and lark-whiteboard, step-by-step doc↔whiteboard
  coordination flow, and semantic-to-chart-type mapping table
- lark-doc-create.md: tighten post-create whiteboard flow (step 2 now
  directly references the "渲染 & 写入画板" section); strengthen 主动画板
  guideline with explicit placeholder syntax, prohibition on PNG/SVG
  substitution, and concrete routing examples
- lark-whiteboard/SKILL.md: upgrade to v0.2, rewrite with structured
  quick-decision table, creation/modification workflows, render routing
  table, and dry-run write guard
- extract rendering routes into routes/{dsl,mermaid,svg}.md; add
  per-chart scene guides under scenes/
- remove lark-whiteboard-cli/SKILL.md (absorbed into lark-whiteboard)
2026-04-17 17:51:50 +08:00
Yuxuan Zhao
1ad7cfab5b test: inject user env only for cli e2e user commands (#541) 2026-04-17 17:33:37 +08:00
Yuxuan Zhao
5280517d4b Feat/cli e2e tests with UAT (#528)
* test: expand and stabilize cli e2e workflows

* ci: run deadcode with test entrypoints
2026-04-17 16:57:17 +08:00
JackZhao10086
3ad6f2fac4 Revert "Add client_secret to device authorization request (#517)" (#539)
This reverts commit 663c24aadf.
2026-04-17 16:29:04 +08:00
feng zhi hao
be79485fe3 feat: mail support scheduled send (#534)
feat: mail support scheduled send (#534)
2026-04-17 15:41:42 +08:00
zgz2048
94bba91224 feat(base): auto grant current user for bot create and copy (#497)
* feat(base): auto grant current user for bot create and copy

* fix(base): declare auto-grant permission scope

* Apply suggestion from @kongenpei

Co-authored-by: kongenpei <kongenpei.jojo@bytedance.com>

* Apply suggestion from @kongenpei

Co-authored-by: kongenpei <kongenpei.jojo@bytedance.com>

* style(base): format auth-specific scope declarations

* fix(base): use bitable permission target for auto-grant

---------

Co-authored-by: kongenpei <kongenpei.jojo@bytedance.com>
2026-04-17 14:30:47 +08:00
wangqiucheng2bd
0d50616e77 feat(base): add identity priority strategy and error handling (#505)
* feat(base): add identity priority strategy and 91403 error handling

Establish user-first identity selection with graceful degradation to bot,
and add no-retry rule for error code 91403 (permission denied on Base).

* fix(base): add 91403 early-exit before identity fallback logic

Move non-retryable error code check (e.g. 91403) to a dedicated step
before the user/bot fallback decision, resolving conflicting instructions
between the error table and the execution rules.


* Update SKILL.md

* Update SKILL.md

---------
2026-04-17 13:46:05 +08:00
JackZhao10086
d5784eac28 feat(auth): improve login scope handling and messages (#523)
* feat(auth): improve login scope handling and messages

- Add AuthorizedUser message to display current authorized account
- Update scope mismatch message wording to be more accurate
- Reorganize login success output to show scope issues first
- Remove redundant success message when scope issues exist

* fix(auth): update login success message wording from "login" to "authorization"

Update both Chinese and English login success messages to use "authorization" instead of "login" for consistency with the authentication flow. Also update corresponding test cases to match the new wording.

* test(auth): update login test for missing scope case

Update test assertions to verify correct error messages when requested scopes are not granted. Remove checks for success message in this scenario.
2026-04-17 12:16:29 +08:00
Uğur Tafralı
663c24aadf Add client_secret to device authorization request (#517) 2026-04-17 11:39:23 +08:00
zero-my
6ad25cd452 docs(task): document custom_fields and custom_field_options API resources and permissions (#524) 2026-04-16 23:40:55 +08:00
liangshuo-1
c442fa27d1 chore: cut v1.0.13 with reviewed release notes (#519)
Change-Id: If9a08002588cc9ae96280bdd8bbc4a05ba0f92a1
2026-04-16 21:09:55 +08:00
ILUO
35a8288baf feat: default skip_task_detail in docs +fetch (#471) 2026-04-16 19:15:57 +08:00
tuxedomm
79379fbc6f fix(im): preserve original URL filename for uploaded file messages (#514)
mediaBuffer.FileName() returned a hardcoded "media"+ext, so IM file
messages sent via URL displayed generic names like "media.pdf" instead
of the filename parsed from the URL. This regressed the pre-refactor
tempfile path which at least carried a unique basename.

Store fileNameFromURL(rawURL) on the buffer and return it from
FileName(). Split newMediaBuffer so the URL-to-filename wiring is
reachable from tests without going through the hardened download
transport.

Also lock in that the local upload branch keeps filepath.Base(filePath)
as file_name, so the URL fix cannot silently regress the local branch
later.

Change-Id: I729b217e9dc9237aeb89c2b89df86a37ad64a840
2026-04-16 19:01:21 +08:00
liangshuo-1
d0ab8ee7dc ci: consolidate workflows into layered CI pyramid with results gate (#510)
* ci: consolidate 6 workflows into layered CI pyramid with results gate

Merge tests.yml, lint.yml, coverage.yml, cli-e2e.yml, gitleaks.yml,
and license-header.yml into a single ci.yml with fail-fast layering:

- L1 fast-gate: build, vet, gofmt, go mod tidy
- L2 quality: unit-test, lint, coverage (40% threshold + Codecov), deadcode (incremental)
- L3 e2e: dry-run (no secrets) + live (with secrets, fork-skip)
- L4 security: gitleaks, govulncheck, go-licenses, license-header

Results gate aggregates all jobs as the single required check for
branch protection.

Also adds:
- arch-audit.yml: weekly cron for dead code, complexity, deps, E2E gaps
- .golangci.yml: depguard shortcuts-no-raw-http, forbidigo fmt.Print/log.Fatal
- AGENTS.md: E2E testing conventions, updated pre-PR checks

Change-Id: I2e21067a9e9e12d366d1b1a092227e9f7d60fe41
2026-04-16 18:16:31 +08:00
zhouyue-bytedance
1608f95632 refactor: 删除第6、7章(参考文档和命令分组) (#500)
- 删除第6章:参考文档列表(重复了第2章的 reference 信息)
- 删除第7章:命令分组表(重复了第2章的模块导航)

这两章是静态索引,维护成本高且容易过时。第2章已提供完整的导航和 reference 链接。
2026-04-16 17:20:34 +08:00
zhouyue-bytedance
e10bf8eca2 fix: 统一 record 批量写入限制为 200 条,移除'建议'改为强制要求 (#499)
- 第106行:批量单次改为 200 条(与 lark-base-record-batch-create.md 对齐)
- 第290行:批量写入改为 200 条(与 API 限制对齐)
- 第291行:连续写入改为'必须串行'(强制要求,非建议)
- 第314行:错误代码 1254104 改为 200 条限制

修复 SKILL.md 与 reference 文档的数字不一致问题
2026-04-16 15:41:26 +08:00
syh-cpdsss
c1d6042552 fix: add atomic overwrite for whiteboard +update (#483) 2026-04-16 14:06:36 +08:00
chenxingtong-bytedance
656c16a47f feat(im): support user access token upload file/media/audio/image and send the resource message (#474)
The /open-apis/im/v1/images and /open-apis/im/v1/files APIs now support User Access Token (UAT) in addition to Tenant Access Token (TAT). Previously the upload helpers forced bot identity unconditionally; this PR aligns them with the surrounding shortcut's --as flag so uploads and sends share the same identity.

Change-Id: I3d7fd528dd30fef9aea2d88100ceb03db4c7c3ac
2026-04-16 14:04:25 +08:00
wittam-01
9dfaff4664 feat: add drive create-folder shortcut and wiki node auto-grant (#470)
Change-Id: I1acd001a1d4616bc5a957cad437e5aa4f1afeb51
2026-04-16 11:19:19 +08:00
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
528 changed files with 69595 additions and 8726 deletions

View File

@@ -6,3 +6,6 @@ coverage:
patch:
default:
target: 60%
github_checks:
annotations: true

116
.github/workflows/arch-audit.yml vendored Normal file
View File

@@ -0,0 +1,116 @@
name: Architecture Audit
on:
schedule:
- cron: '0 9 * * 1' # Monday 09:00 UTC
workflow_dispatch:
permissions:
contents: read
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Dead code detection
run: |
echo "## Dead Code" >> report.md
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | tee deadcode.txt
count=$(wc -l < deadcode.txt | tr -d ' ')
echo "Found **$count** unreachable functions" >> report.md
echo '```' >> report.md
cat deadcode.txt >> report.md
echo '```' >> report.md
- name: Package complexity
run: |
echo "## Package Complexity" >> report.md
echo "" >> report.md
echo "Packages exceeding 2 000 lines or 20 files:" >> report.md
echo "" >> report.md
echo "| Package | Files | Lines | Deps |" >> report.md
echo "|---------|-------|-------|------|" >> report.md
found=0
for pkg in $(go list ./cmd/... ./internal/... ./shortcuts/...); do
dir=$(go list -f '{{.Dir}}' "$pkg")
files=$(find "$dir" -maxdepth 1 -name '*.go' ! -name '*_test.go' | wc -l | tr -d ' ')
lines=$(find "$dir" -maxdepth 1 -name '*.go' ! -name '*_test.go' -exec cat {} + 2>/dev/null | wc -l | tr -d ' ')
deps=$(go list -f '{{len .Imports}}' "$pkg")
if [ "$lines" -gt 2000 ] || [ "$files" -gt 20 ]; then
echo "| **$pkg** | **$files** | **$lines** | **$deps** |" >> report.md
found=1
fi
done
if [ "$found" = "0" ]; then
echo "| _(none)_ | | | |" >> report.md
fi
- name: Dependency freshness
run: |
echo "## Outdated Dependencies" >> report.md
echo '```' >> report.md
go list -m -u all 2>/dev/null | grep '\[' >> report.md || echo "All dependencies up to date" >> report.md
echo '```' >> report.md
- name: Circular dependency check
run: |
echo "## Circular Dependencies" >> report.md
go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... | \
go run golang.org/x/tools/cmd/digraph@v0.31.0 scc 2>&1 | tee cycles.txt
if [ -s cycles.txt ]; then
echo '```' >> report.md
cat cycles.txt >> report.md
echo '```' >> report.md
else
echo "No circular dependencies detected." >> report.md
fi
- name: E2E coverage gaps
run: |
echo "## E2E Coverage Gaps" >> report.md
echo "" >> report.md
echo "Shortcut domains without E2E tests:" >> report.md
echo "" >> report.md
found=0
for domain in $(ls -d shortcuts/*/); do
name=$(basename "$domain")
if [ "$name" = "common" ]; then continue; fi
if [ ! -d "tests/cli_e2e/$name" ]; then
echo "- **$name** (no tests/cli_e2e/$name/)" >> report.md
found=1
fi
done
if [ "$found" = "0" ]; then
echo "All shortcut domains have E2E test directories." >> report.md
fi
- name: Coverage
run: |
echo "## Coverage" >> report.md
packages=$(go list ./... | grep -v 'tests/cli_e2e')
go test -coverprofile=coverage.txt -covermode=atomic $packages 2>/dev/null || true
total=$(go tool cover -func=coverage.txt 2>/dev/null | grep total | awk '{print $3}')
echo "Current total coverage: **${total:-n/a}**" >> report.md
- name: Publish report
run: |
echo "# Architecture Audit Report — $(date +%Y-%m-%d)" > $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
cat report.md >> $GITHUB_STEP_SUMMARY
- name: Upload report artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: arch-audit-${{ github.run_number }}
path: report.md
retention-days: 90

334
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,334 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
actions: read
checks: write
pull-requests: write
jobs:
# ── Layer 1: Fast Gate ─────────────────────────────────────────────
fast-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Build
run: go build ./...
- name: Vet
run: go vet ./...
- name: Check formatting
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "$unformatted"
echo "::error::Unformatted Go files detected — run 'gofmt -w .' and commit"
exit 1
fi
- name: Check go.mod tidiness
run: |
go mod tidy
if ! git diff --quiet go.mod go.sum; then
echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes."
git diff go.mod go.sum
exit 1
fi
# ── Layer 2: Quality Gate ──────────────────────────────────────────
unit-test:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run tests
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/...
lint:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run golangci-lint
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
coverage:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run tests with coverage
run: |
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
- name: Upload coverage to Codecov
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
with:
files: coverage.txt
token: ${{ secrets.CODECOV_TOKEN }}
- name: Check coverage threshold
run: |
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | tr -d '%')
threshold=40
echo "Coverage: ${total}% (threshold: ${threshold}%)"
if (( $(echo "$total < $threshold" | bc -l) )); then
echo "::error::Coverage ${total}% is below threshold ${threshold}%"
exit 1
fi
- name: Coverage summary
if: ${{ !cancelled() }}
run: |
if [ ! -f coverage.txt ]; then
echo "No coverage data available" >> $GITHUB_STEP_SUMMARY
exit 0
fi
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY
deadcode:
needs: fast-gate
if: ${{ github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Dead code check (incremental)
run: |
# Analyze current HEAD (strip line:col for stable diff across line shifts)
# Filter "go: downloading ..." lines to avoid false diffs from module cache state
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | \
grep -v '^go: ' | \
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-head.txt
# Analyze base branch via worktree
git worktree add -q /tmp/dc-base "origin/${{ github.base_ref }}"
(cd /tmp/dc-base && python3 scripts/fetch_meta.py && \
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | \
grep -v '^go: ' | \
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-base.txt) || {
echo "::warning::Failed to analyze base branch — skipping incremental dead code check"
git worktree remove -f /tmp/dc-base 2>/dev/null || true
exit 0
}
git worktree remove -f /tmp/dc-base
# Only new dead code blocks the PR
comm -23 /tmp/dc-head.txt /tmp/dc-base.txt > /tmp/dc-new.txt
if [ -s /tmp/dc-new.txt ]; then
echo "::group::New dead code"
cat /tmp/dc-new.txt
echo "::endgroup::"
echo "::error::New dead code detected — remove unreachable functions before merging"
exit 1
fi
echo "No new dead code introduced"
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
e2e-dry-run:
needs: [unit-test, lint]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Build lark-cli
run: make build
- name: Run dry-run E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
LARKSUITE_CLI_APP_ID: dry-run
LARKSUITE_CLI_APP_SECRET: dry-run
LARKSUITE_CLI_BRAND: feishu
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
e2e-live:
needs: [unit-test, lint]
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
env:
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Build lark-cli
run: make build
- name: Configure bot credentials
run: |
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
exit 1
fi
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
- name: Run CLI E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
run: |
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
if [ -z "$packages" ]; then
echo "No CLI E2E packages to test after exclusions."
exit 1
fi
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
- name: Publish CLI E2E test report
if: ${{ !cancelled() }}
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
# ── Layer 4: Security & Compliance (parallel with L2-L3) ──────────
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Gitleaks
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}
- name: govulncheck
continue-on-error: true
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
- name: Check dependency licenses
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown
license-header:
if: ${{ github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- name: Check license headers
uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad
with:
config: .licenserc.yaml
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
run: |
echo "## CI Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Layer | Job | Status |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-----|--------|" >> $GITHUB_STEP_SUMMARY
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L3 | e2e-dry-run | ${{ needs.e2e-dry-run.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L3 | e2e-live | ${{ needs.e2e-live.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
# Any failure or cancellation in any job blocks the merge.
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
# license-header on push) are OK.
FAILED=0
for result in \
"${{ needs.fast-gate.result }}" \
"${{ needs.unit-test.result }}" \
"${{ needs.lint.result }}" \
"${{ needs.coverage.result }}" \
"${{ needs.deadcode.result }}" \
"${{ needs.e2e-dry-run.result }}" \
"${{ needs.e2e-live.result }}" \
"${{ needs.security.result }}" \
"${{ needs.license-header.result }}"; do
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
FAILED=1
fi
done
if [ "$FAILED" = "1" ]; then
echo ""
echo "::error::One or more CI jobs failed — see table above"
exit 1
fi

View File

@@ -1,135 +0,0 @@
name: CLI E2E Tests
on:
push:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- Makefile
- scripts/fetch_meta.py
- tests/cli_e2e/**
- .github/workflows/cli-e2e.yml
pull_request:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- Makefile
- scripts/fetch_meta.py
- tests/cli_e2e/**
- .github/workflows/cli-e2e.yml
workflow_dispatch:
permissions:
contents: read
jobs:
cli-e2e:
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
env:
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Build lark-cli
run: make build
- name: Configure bot credentials
run: |
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
exit 1
fi
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
- name: Run CLI E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
run: |
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
if [ -z "$packages" ]; then
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
- name: Summarize 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

View File

@@ -1,58 +0,0 @@
name: Coverage
on:
push:
branches: [main]
paths:
- "**.go"
- "!tests/cli_e2e/**"
- go.mod
- go.sum
- .github/workflows/coverage.yml
pull_request:
branches: [main]
paths:
- "**.go"
- "!tests/cli_e2e/**"
- go.mod
- go.sum
- .github/workflows/coverage.yml
permissions:
contents: read
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run tests with coverage
run: |
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
- name: Generate coverage report
run: |
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY

View File

@@ -1,28 +0,0 @@
name: Gitleaks
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
gitleaks:
# Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN.
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
env:
# GITHUB_TOKEN is provided automatically by GitHub Actions.
# GITLEAKS_KEY must be configured as a repository secret.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_KEY }}

View File

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

View File

@@ -1,60 +0,0 @@
name: Lint
on:
push:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .golangci.yml
- .github/workflows/lint.yml
pull_request:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .golangci.yml
- .github/workflows/lint.yml
permissions:
contents: read
jobs:
golangci-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'
- name: Fetch meta_data.json
run: python3 scripts/fetch_meta.py
- name: Ensure go.mod and go.sum are tidy
run: |
go mod tidy
if ! git diff --quiet go.mod go.sum; then
echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes."
git diff go.mod go.sum
exit 1
fi
- name: Run golangci-lint
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
- name: Run govulncheck
continue-on-error: true # informational until Go version is upgraded
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
- name: Check dependency licenses
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown

View File

@@ -1,43 +0,0 @@
name: Tests
on:
push:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .github/workflows/tests.yml
pull_request:
branches: [main]
paths:
- "**.go"
- go.mod
- go.sum
- .github/workflows/tests.yml
permissions:
contents: read
jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run tests
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/...
- name: Build
run: go build -v ./...

3
.gitignore vendored
View File

@@ -35,3 +35,6 @@ tests/mail/reports/
# Generated / test artifacts
internal/registry/meta_data.json
cmd/api/download.bin
app.log
/sidecar-server-demo
/server-demo

View File

@@ -70,6 +70,14 @@ linters:
desc: >-
shortcuts must not import internal/vfs/localfileio directly.
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
shortcuts-no-raw-http:
files:
- "**/shortcuts/**"
deny:
- pkg: "net/http"
desc: >-
use RuntimeContext.DoAPI/CallAPI/DoAPIJSON instead of raw net/http.
The client layer handles auth, headers, and error normalization.
forbidigo:
forbid:
# ── os: already wrapped in internal/vfs ──
@@ -100,6 +108,16 @@ linters:
msg: >-
Do not use os.Exit in shortcuts/. Return an error instead and let
the caller (cmd layer) decide how to terminate.
# ── output: shortcuts must use ctx.Out() ──
- pattern: fmt\.Print(f|ln)?\b
msg: >-
use ctx.Out() or ctx.OutFormat() for structured JSON output.
fmt.Print* bypasses the output envelope and breaks --jq/--format.
# ── logging: shortcuts must return errors, not log.Fatal ──
- pattern: log\.(Print|Fatal|Panic)(f|ln)?\b
msg: >-
use structured error return, not log.Fatal/Panic.
Shortcuts must return errors to the framework for proper exit code handling.
# ── filepath: functions that access the filesystem ──
- pattern: filepath\.(EvalSymlinks|Walk|WalkDir|Glob|Abs)\b
msg: >-

View File

@@ -18,9 +18,11 @@ make test # Full: vet + unit + integration
## Pre-PR Checks (match CI gates)
1. `make unit-test`
2. `go mod tidy` — must not change `go.mod`/`go.sum`
3. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
4. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
2. `go vet ./...`
3. `gofmt -l .` — must produce no output
4. `go mod tidy` — must not change `go.mod`/`go.sum`
5. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
6. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
## Commit & PR
@@ -76,3 +78,26 @@ CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInput
- Every behavior change needs a test alongside the change.
- `cmdutil.TestFactory(t, config)` for test factories.
- `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state.
### E2E Testing
**Dry-run E2E (required for every shortcut change)**
- Validates request structure without calling real APIs
- Place in `tests/cli_e2e/dryrun/` or the corresponding domain directory
- Set env vars `LARKSUITE_CLI_APP_ID`/`APP_SECRET`/`BRAND`, use `--dry-run`, assert method/URL/params
- No secrets needed — runs on fork PRs
- Explore correct params with `lark-cli <domain> --help` and `lark-cli schema` first
**Live E2E (required for new flows or behavior changes)**
- Validates real API round-trips
- Place in `tests/cli_e2e/<domain>/`
- Must be self-contained: create -> use -> cleanup
- Needs bot credentials (CI secrets, skipped on fork PRs)
- Reference: `tests/cli_e2e/task/task_status_workflow_test.go`
| Change | Dry-run E2E | Live E2E |
|--------|:-----------:|:--------:|
| New shortcut | Required | Required |
| Modify shortcut flags/params | Required | If behavior changes |
| Shortcut bug fix | Required | If regression risk |
| Internal refactor (no shortcut impact) | Not needed | Not needed |

View File

@@ -2,6 +2,214 @@
All notable changes to this project will be documented in this file.
## [v1.0.17] - 2026-04-22
### Features
- **im**: Use `Content-Disposition` filename when downloading message resources (#536)
- **drive**: Add `+apply-permission` to request doc access (#588)
- Support record share link (#466)
- **whiteboard**: Add image support to `whiteboard-cli` skill (#553)
- **cmdutil**: Add `X-Cli-Build` header for CLI build classification (#596)
### Bug Fixes
- **base**: Add default-table follow-up hint to `base-create` (#600)
- Skip flag-completion registration outside completion path (#598)
- Add `record-share-link-create` in `SKILL.md` (#597)
- **mail**: Remove leftover conflict marker in skill docs (#594)
### Documentation
- **drive**: Clarify that comment listing defaults to unresolved comments only (#609)
- **doc**: Fix `--markdown` examples that teach literal `\n` (#602)
- **mail**: Remove `get_signatures` from skill reference, exposed via `+signature` instead (#545)
## [v1.0.16] - 2026-04-21
### Features
- **mail**: Support large email attachments (#537)
- **mail**: Add draft preview URL to draft operations (#438)
- **doc**: Add pre-write semantic warnings to `docs +update` (#569)
- **doc**: Add `--selection-with-ellipsis` position flag to `+media-insert` (#335)
- **calendar**: Support event share link and error details (#583)
### Bug Fixes
- **doc**: Preserve round-trip formatting in `+fetch` output (#469)
- **docs**: Validate `--selection-by-title` format early (#256)
- **whiteboard**: Register `+media-upload` shortcut and add whiteboard parent type
### Refactor
- Split `Execute` into `Build` + `Execute` with explicit IO and keychain injection (#371)
- **auth**: Simplify scope reporting in login flow (#582)
## [v1.0.15] - 2026-04-20
### Features
- **sheets**: Add float image shortcuts (#494)
- **approval**: Document `remind` and `initiated` methods in skill (#554)
### Bug Fixes
- **base**: Preserve attachment metadata on base uploads (#563)
- **base**: Fix role view and record default permission on edit (#530)
- **sheets**: Normalize single-cell range in `+set-style` and `+batch-set-style` (#548)
- **im**: Cap `basic_batch` user_ids at 10 per API limit (#551)
- **install**: Refine install wizard messages (#529)
- **whiteboard**: Deprecate old `lark-whiteboard-cli` skill (#547)
## [v1.0.14] - 2026-04-17
### Features
- **mail**: Add email priority support for compose and read (#538)
- **mail**: Support scheduled send (#534)
- **drive**: Support sheet cell comments in `+add-comment` (#518)
- **doc**: Add `--file-view` flag to `+media-insert` (#419)
- **base**: Auto grant current user for bot create and copy (#497)
- **base**: Add identity priority strategy and error handling (#505)
- **auth**: Improve login scope handling and messages (#523)
- Add OKR business domain (#522)
### Documentation
- **wiki**: Improve wiki skill docs and add wiki domain template (#512)
- **task**: Document `custom_fields` and `custom_field_options` API resources and permissions (#524)
### Refactor
- **skills**: Introduce `lark-doc-whiteboard.md` and streamline whiteboard workflow (#502)
## [v1.0.13] - 2026-04-16
### Features
- **im**: Support user access token for file, image, audio, and video upload, aligning upload and send identity with `--as` flag (#474)
- **drive**: Add `drive +create-folder` shortcut with root-folder fallback and bot-mode auto-grant (#470)
- **wiki**: Add bot-mode auto-grant support to `wiki +node-create` (#470)
- **doc**: Default `skip_task_detail` in `docs +fetch` to reduce unnecessary task detail expansion (#471)
### Bug Fixes
- **im**: Preserve original URL filename for uploaded file messages instead of generic `media.ext` names (#514)
- **whiteboard**: Use atomic overwrite API parameter for `whiteboard +update`, replacing read-then-delete approach (#483)
### Documentation
- **base**: Unify record batch write limit to 200 and enforce serial writes for continuous operations (#499)
- **base**: Remove redundant reference documentation and command grouping chapters from SKILL.md (#500)
### CI
- Consolidate workflows into layered CI pyramid with single `results` gate (#510)
## [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
@@ -256,6 +464,16 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.17]: https://github.com/larksuite/cli/releases/tag/v1.0.17
[v1.0.16]: https://github.com/larksuite/cli/releases/tag/v1.0.16
[v1.0.15]: https://github.com/larksuite/cli/releases/tag/v1.0.15
[v1.0.14]: https://github.com/larksuite/cli/releases/tag/v1.0.14
[v1.0.13]: https://github.com/larksuite/cli/releases/tag/v1.0.13
[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

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,12 +30,15 @@ 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 |
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments and indicators. |
## Installation & Quick Start
@@ -136,6 +139,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 +151,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,12 +30,15 @@
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
| ✅ 任务 | 创建、查询、更新和完成任务;管理任务清单、子任务、评论与提醒 |
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| 🎯 OKR | 查询、创建、更新 OKR管理目标、关键结果、对齐和指标 |
## 安装与快速开始
@@ -137,6 +140,7 @@ lark-cli auth status
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown |
| `lark-drive` | 上传、下载文件,管理权限与评论 |
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
| `lark-task` | 任务、任务清单、子任务、提醒、成员分配 |
| `lark-mail` | 浏览、搜索、阅读邮件,发送、回复、转发,草稿管理,监听新邮件 |
@@ -148,6 +152,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

@@ -41,6 +41,7 @@ type APIOptions struct {
Format string
JqExpr string
DryRun bool
File string
}
var urlPrefixRe = regexp.MustCompile(`https?://[^/]+(/open-apis/.+)`)
@@ -56,6 +57,10 @@ func normalisePath(raw string) string {
// NewCmdApi creates the api command. If runF is non-nil it is called instead of apiRun (test hook).
func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
return NewCmdApiWithContext(context.Background(), f, runF)
}
func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
opts := &APIOptions{Factory: f}
var asStr string
@@ -78,7 +83,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
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)")
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
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")
cmd.Flags().IntVar(&opts.PageSize, "page-size", 0, "page size (0 = use API default)")
@@ -87,6 +92,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 {
@@ -94,10 +100,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
}
return nil, cobra.ShellCompDirectiveNoFileComp
}
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
})
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
@@ -105,20 +108,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) {
// 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.
// 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
if opts.Params == "-" && opts.Data == "-" {
return client.RawApiRequest{}, output.ErrValidation("--params and --data cannot both read from stdin (-)")
// 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
}
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
if err != nil {
return client.RawApiRequest{}, err
return client.RawApiRequest{}, nil, err
}
if opts.PageSize > 0 {
params["page_size"] = opts.PageSize
@@ -128,14 +135,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 {
@@ -153,7 +199,7 @@ func apiRun(opts *APIOptions) error {
return err
}
request, err := buildAPIRequest(opts)
request, fileMeta, err := buildAPIRequest(opts)
if err != nil {
return err
}
@@ -164,6 +210,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.

View File

@@ -5,6 +5,7 @@ package api
import (
"errors"
"os"
"sort"
"strings"
"testing"
@@ -179,6 +180,24 @@ func TestApiValidArgsFunction(t *testing.T) {
}
}
func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
})
cmd := NewCmdApi(f, nil)
flag := cmd.Flags().Lookup("as")
if flag == nil {
t.Fatal("expected --as flag to be registered")
}
if !flag.Hidden {
t.Fatal("expected --as flag to be hidden in strict mode")
}
if got := flag.DefValue; got != "bot" {
t.Fatalf("default value = %q, want %q", got, "bot")
}
}
func TestApiCmd_PageLimitDefault(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -706,3 +725,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

@@ -72,7 +72,7 @@ browser. Run it in the background and retrieve the verification URL from its out
cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "initiate device authorization and return immediately; use --device-code to complete")
cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "poll and complete authorization with a device code from a previous --no-wait call")
_ = cmd.RegisterFlagCompletionFunc("domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
cmdutil.RegisterFlagCompletion(cmd, "domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return completeDomain(toComplete), cobra.ShellCompDirectiveNoFileComp
})
@@ -134,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
}
}
@@ -451,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)
@@ -459,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) {
@@ -472,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)
@@ -481,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

@@ -24,11 +24,11 @@ type loginMsg struct {
WaitingAuth string
AuthSuccess string
LoginSuccess string
AuthorizedUser string
ScopeMismatch string
ScopeHint string
RequestedScopes string
NewlyGrantedScopes string
MissingScopes string
NoScopes string
StatusHint string
@@ -58,13 +58,13 @@ var loginMsgZh = &loginMsg{
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AuthSuccess: "授权成功,正在获取用户信息...",
LoginSuccess: "登录成功! 用户: %s (%s)",
ScopeMismatch: "授权完成,但以下请求 scopes 未被授予: %s",
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
LoginSuccess: "授权成功! 用户: %s (%s)",
AuthorizedUser: "当前授权账号: %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",
@@ -93,13 +93,13 @@ var loginMsgEn = &loginMsg{
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",
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
LoginSuccess: "Authorization successful! User: %s (%s)",
AuthorizedUser: "Authorized account: %s (%s)",
ScopeMismatch: "authorization result is abnormal: 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.",

View File

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

View File

@@ -128,7 +128,7 @@ func emptyIfNil(s []string) []string {
return s
}
// writeLoginScopeBreakdown renders the requested/newly granted/missing scope
// writeLoginScopeBreakdown renders the requested/newly granted scope
// breakdown to stderr.
func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary *loginScopeSummary) {
if summary == nil {
@@ -136,7 +136,6 @@ func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary
}
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
@@ -190,11 +189,11 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
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 {
if msg.AuthorizedUser != "" {
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", fmt.Sprintf(msg.AuthorizedUser, userName, openId))
}
} else {
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
}
writeLoginScopeBreakdown(f.IOStreams, msg, issue.Summary)

View File

@@ -363,7 +363,7 @@ func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) {
func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
err := handleLoginScopeIssue(&LoginOptions{}, getLoginMsg("zh"), f, &loginScopeIssue{
Message: "授权完成,但以下请求 scopes 未被授予: im:message:send",
Message: "授权结果异常: 以下请求 scopes 未被授予: im:message:send",
Hint: "以上结果是本次授权请求用户最终确认后的结果请勿持续重试Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
Summary: &loginScopeSummary{
Requested: []string{"im:message:send"},
@@ -376,11 +376,10 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
}
got := stderr.String()
for _, want := range []string{
"OK: 登录成功! 用户: tester (ou_user)",
"授权完成,但以下请求 scopes 未被授予: im:message:send",
"授权结果异常: 以下请求 scopes 未被授予: im:message:send",
"当前授权账号: tester (ou_user)",
"本次请求 scopes: im:message:send",
"本次新授予 scopes: (空)",
"本次未授予 scopes: im:message:send",
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
"scope 被禁用",
"lark-cli auth status",
@@ -392,15 +391,18 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
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)
if strings.Contains(got, "授权成功") {
t.Fatalf("stderr should not contain success wording, got:\n%s", got)
}
if strings.Contains(got, "本次未授予 scopes:") {
t.Fatalf("stderr should not duplicate missing scopes, 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",
Message: "authorization result is abnormal: 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"},
@@ -469,13 +471,13 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
Granted: []string{"im:message:send", "im:message:reply"},
},
expectedPresent: []string{
"登录成功! 用户: tester (ou_user)",
"授权成功! 用户: tester (ou_user)",
"本次请求 scopes: im:message:send im:message:reply",
"本次新授予 scopes: im:message:send",
"本次未授予 scopes: (空)",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
},
expectedAbsent: []string{
"本次未授予 scopes:",
"最终已授权 scopes:",
"已有 scopes:",
},
@@ -490,10 +492,10 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
expectedPresent: []string{
"本次请求 scopes: im:message:send",
"本次新授予 scopes: (空)",
"本次未授予 scopes: (空)",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
},
expectedAbsent: []string{
"本次未授予 scopes:",
"最终已授权 scopes:",
"已有 scopes:",
},
@@ -508,9 +510,9 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
expectedPresent: []string{
"本次请求 scopes: im:message:send im:message:reply",
"本次新授予 scopes: (空)",
"本次未授予 scopes: im:message:send",
},
expectedAbsent: []string{
"本次未授予 scopes:",
"已有 scopes:",
"最终已授权 scopes:",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
@@ -619,10 +621,9 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
}
got := stderr.String()
for _, want := range []string{
"OK: 登录成功! 用户: tester (ou_user)",
"授权完成,但以下请求 scopes 未被授予: im:message:send",
"授权结果异常: 以下请求 scopes 未被授予: im:message:send",
"当前授权账号: tester (ou_user)",
"本次请求 scopes: im:message:send",
"本次未授予 scopes: im:message:send",
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
"scope 被禁用",
"lark-cli auth status",
@@ -634,6 +635,12 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
if strings.Contains(got, "最终已授权 scopes:") {
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
}
if strings.Contains(got, "OK: 授权成功") {
t.Fatalf("stderr should not contain success prefix when scopes are missing, got:\n%s", got)
}
if strings.Contains(got, "本次未授予 scopes:") {
t.Fatalf("stderr should not duplicate missing scopes, got:\n%s", got)
}
if strings.Contains(got, "ERROR:") {
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
}
@@ -743,7 +750,7 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
}
got := stderr.String()
for _, want := range []string{
"OK: 登录成功! 用户: tester (ou_user)",
"OK: 授权成功! 用户: tester (ou_user)",
"本次请求 scopes: im:message:send",
"本次新授予 scopes: im:message:send",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
@@ -771,16 +778,18 @@ func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScope
got := stderr.String()
for _, want := range []string{
"Login successful! User: tester (ou_user)",
"Authorization 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)
}
}
if strings.Contains(got, "Not granted scopes:") {
t.Fatalf("stderr should not contain not granted scopes, got:\n%s", got)
}
}
func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
@@ -903,3 +912,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)")
}
}
}

129
cmd/build.go Normal file
View File

@@ -0,0 +1,129 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"io"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
"github.com/larksuite/cli/cmd/completion"
cmdconfig "github.com/larksuite/cli/cmd/config"
"github.com/larksuite/cli/cmd/doctor"
"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"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
// BuildOption configures optional aspects of the command tree construction.
type BuildOption func(*buildConfig)
type buildConfig struct {
streams *cmdutil.IOStreams
keychain keychain.KeychainAccess
globals GlobalOptions
}
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
// Terminal detection is delegated to cmdutil.NewIOStreams.
func WithIO(in io.Reader, out, errOut io.Writer) BuildOption {
return func(c *buildConfig) {
c.streams = cmdutil.NewIOStreams(in, out, errOut)
}
}
// WithKeychain sets the secret storage backend. If not provided, the platform keychain is used.
func WithKeychain(kc keychain.KeychainAccess) BuildOption {
return func(c *buildConfig) {
c.keychain = kc
}
}
// HideProfile sets the visibility policy for the root-level --profile flag.
// When hide is true the flag stays registered (so existing invocations still
// parse) but is omitted from help and shell completion. Typically called as
// HideProfile(isSingleAppMode()).
func HideProfile(hide bool) BuildOption {
return func(c *buildConfig) {
c.globals.HideProfile = hide
}
}
// Build constructs the full command tree without executing.
// Returns only the cobra.Command; Factory is internal.
// Use Execute for the standard production entry point.
func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) *cobra.Command {
_, rootCmd := buildInternal(ctx, inv, opts...)
return rootCmd
}
// buildInternal is a pure assembly function: it wires the command tree from
// inv and BuildOptions alone. Any state-dependent decision (disk, network,
// env) belongs in the caller and must be threaded in via BuildOption.
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command) {
// cfg.globals.Profile is left zero here; it's bound to the --profile
// flag in RegisterGlobalFlags and filled by cobra's parse step.
cfg := &buildConfig{}
for _, o := range opts {
if o != nil {
o(cfg)
}
}
// Default streams when WithIO is not supplied so the root command's
// SetIn/Out/Err calls below don't deref nil. NewDefault also normalizes
// partial streams internally; keep both in sync so cfg.streams reflects
// the same values the Factory ends up using.
if cfg.streams == nil {
cfg.streams = cmdutil.SystemIO()
}
f := cmdutil.NewDefault(cfg.streams, inv)
if cfg.keychain != nil {
f.Keychain = cfg.keychain
}
rootCmd := &cobra.Command{
Use: "lark-cli",
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
Long: rootLong,
Version: build.Version,
}
rootCmd.SetContext(ctx)
rootCmd.SetIn(cfg.streams.In)
rootCmd.SetOut(cfg.streams.Out)
rootCmd.SetErr(cfg.streams.ErrOut)
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
cmd.SilenceUsage = true
}
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(profile.NewCmdProfile(f))
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
// Prune commands incompatible with strict mode.
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
pruneForStrictMode(rootCmd, mode)
}
return f, rootCmd
}

63
cmd/build_api_test.go Normal file
View File

@@ -0,0 +1,63 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"context"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/vfs"
)
// noopKeychain is a zero-side-effect KeychainAccess for exercising
// WithKeychain without touching the platform keychain.
type noopKeychain struct{}
func (noopKeychain) Get(service, account string) (string, error) { return "", nil }
func (noopKeychain) Set(service, account, value string) error { return nil }
func (noopKeychain) Remove(service, account string) error { return nil }
// TestBuild_ExternalAPI asserts the library surface that external consumers
// (e.g. cli-server) depend on: Build composes a root command from an
// InvocationContext plus BuildOptions (WithIO, WithKeychain, HideProfile),
// and SetDefaultFS swaps the global VFS. This test is the contract guard.
func TestBuild_ExternalAPI(t *testing.T) {
// Exercise SetDefaultFS both directions. Passing nil restores the OS FS.
SetDefaultFS(vfs.OsFs{})
SetDefaultFS(nil)
var in, out, errOut bytes.Buffer
rootCmd := Build(
context.Background(),
cmdutil.InvocationContext{},
WithIO(&in, &out, &errOut),
WithKeychain(noopKeychain{}),
HideProfile(true),
)
if rootCmd == nil {
t.Fatal("Build returned nil root command")
}
if rootCmd.Use != "lark-cli" {
t.Errorf("rootCmd.Use = %q, want %q", rootCmd.Use, "lark-cli")
}
if len(rootCmd.Commands()) == 0 {
t.Error("Build produced a root command with no subcommands")
}
}
// TestBuild_NoOptions guards against regression of the nil-streams panic:
// calling Build without WithIO must fall back to SystemIO rather than
// deref nil at rootCmd.SetIn/Out/Err.
func TestBuild_NoOptions(t *testing.T) {
rootCmd := Build(context.Background(), cmdutil.InvocationContext{})
if rootCmd == nil {
t.Fatal("Build returned nil root command")
}
if rootCmd.Use != "lark-cli" {
t.Errorf("rootCmd.Use = %q, want %q", rootCmd.Use, "lark-cli")
}
}

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

@@ -10,45 +10,56 @@ import (
)
type initMsg struct {
SelectAction string
CreateNewApp string
ConfigExistingApp string
Platform string
SelectPlatform string
Feishu string
ScanOrOpenLink string
WaitingForScan string
DetectedLarkTenant string
AppCreated string
ConfigSaved string
SelectAction string
CreateNewApp string
ConfigExistingApp string
Platform string
SelectPlatform string
Feishu 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{
SelectAction: "选择操作",
CreateNewApp: "一键配置应用 (推荐) ",
ConfigExistingApp: "手动输入应用凭证",
Platform: "平台",
SelectPlatform: "选择平台",
Feishu: "飞书",
ScanOrOpenLink: "\n打开以下链接配置应用:\n\n",
WaitingForScan: "等待配置应用...",
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
AppCreated: "应用配置成功! App ID: %s",
ConfigSaved: "应用配置成功! App ID: %s",
SelectAction: "选择操作",
CreateNewApp: "一键配置应用 (推荐) ",
ConfigExistingApp: "手动输入应用凭证",
Platform: "平台",
SelectPlatform: "选择平台",
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{
SelectAction: "Select action",
CreateNewApp: "Set up your app with one click (Recommended)",
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",
SelectAction: "Select action",
CreateNewApp: "Set up your app with one click (Recommended)",
ConfigExistingApp: "Enter app credentials yourself",
Platform: "Platform",
SelectPlatform: "Select platform",
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

@@ -48,17 +48,20 @@ func TestInitMsgEn_AllFieldsNonEmpty(t *testing.T) {
func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
t.Helper()
fields := map[string]string{
"SelectAction": msg.SelectAction,
"CreateNewApp": msg.CreateNewApp,
"ConfigExistingApp": msg.ConfigExistingApp,
"Platform": msg.Platform,
"SelectPlatform": msg.SelectPlatform,
"Feishu": msg.Feishu,
"ScanOrOpenLink": msg.ScanOrOpenLink,
"WaitingForScan": msg.WaitingForScan,
"DetectedLarkTenant": msg.DetectedLarkTenant,
"AppCreated": msg.AppCreated,
"ConfigSaved": msg.ConfigSaved,
"SelectAction": msg.SelectAction,
"CreateNewApp": msg.CreateNewApp,
"ConfigExistingApp": msg.ConfigExistingApp,
"Platform": msg.Platform,
"SelectPlatform": msg.SelectPlatform,
"Feishu": msg.Feishu,
"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 == "" {

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

@@ -3,15 +3,38 @@
package cmd
import "github.com/spf13/pflag"
import (
"github.com/larksuite/cli/internal/core"
"github.com/spf13/pflag"
)
// GlobalOptions are the root-level flags shared by bootstrap parsing and the
// actual Cobra command tree.
// actual Cobra command tree. Profile is the parsed --profile value; HideProfile
// is a build-time policy — when true, --profile stays parseable but is marked
// hidden from help and shell completion.
type GlobalOptions struct {
Profile string
Profile string
HideProfile bool
}
// RegisterGlobalFlags registers the root-level persistent flags.
// RegisterGlobalFlags registers the root-level persistent flags on fs and
// applies any visibility policy encoded in opts. Pure function: no disk,
// network, or environment reads — the caller decides HideProfile.
func RegisterGlobalFlags(fs *pflag.FlagSet, opts *GlobalOptions) {
fs.StringVar(&opts.Profile, "profile", "", "use a specific profile")
if opts.HideProfile {
_ = fs.MarkHidden("profile")
}
}
// isSingleAppMode reports whether the on-disk config has at most one app.
// Missing configs are treated as single-app since --profile is meaningless
// until at least two profiles exist. Intended for the Execute entry point —
// buildInternal must not call this directly to stay state-free.
func isSingleAppMode() bool {
raw, err := core.LoadMultiAppConfig()
if err != nil || raw == nil {
return true
}
return len(raw.Apps) <= 1
}

110
cmd/global_flags_test.go Normal file
View File

@@ -0,0 +1,110 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"os"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/pflag"
)
func testStreams() BuildOption { return WithIO(os.Stdin, os.Stdout, os.Stderr) }
func TestRegisterGlobalFlags_PolicyVisible(t *testing.T) {
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
opts := &GlobalOptions{}
RegisterGlobalFlags(fs, opts)
flag := fs.Lookup("profile")
if flag == nil {
t.Fatal("profile flag should be registered")
}
if flag.Hidden {
t.Fatal("profile flag should be visible when HideProfile is false")
}
}
func TestRegisterGlobalFlags_PolicyHidden(t *testing.T) {
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
opts := &GlobalOptions{HideProfile: true}
RegisterGlobalFlags(fs, opts)
flag := fs.Lookup("profile")
if flag == nil {
t.Fatal("profile flag should be registered")
}
if !flag.Hidden {
t.Fatal("profile flag should be hidden when HideProfile is true")
}
if err := fs.Parse([]string{"--profile", "x"}); err != nil {
t.Fatalf("Parse() error = %v; hidden flag should still parse", err)
}
if opts.Profile != "x" {
t.Fatalf("opts.Profile = %q, want %q", opts.Profile, "x")
}
}
func TestIsSingleAppMode_NoConfig(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if !isSingleAppMode() {
t.Fatal("isSingleAppMode() = false, want true when no config exists")
}
}
func TestIsSingleAppMode_SingleApp(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
saveAppsForTest(t, []core.AppConfig{
{Name: "default", AppId: "cli_a", AppSecret: core.PlainSecret("x"), Brand: core.BrandFeishu},
})
if !isSingleAppMode() {
t.Fatal("isSingleAppMode() = false, want true for single-app config")
}
}
func TestIsSingleAppMode_MultiApp(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
saveAppsForTest(t, []core.AppConfig{
{Name: "a", AppId: "cli_a", AppSecret: core.PlainSecret("x"), Brand: core.BrandFeishu},
{Name: "b", AppId: "cli_b", AppSecret: core.PlainSecret("y"), Brand: core.BrandFeishu},
})
if isSingleAppMode() {
t.Fatal("isSingleAppMode() = true, want false for multi-app config")
}
}
func TestBuildInternal_HideProfileOption(t *testing.T) {
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams(), HideProfile(true))
flag := root.PersistentFlags().Lookup("profile")
if flag == nil {
t.Fatal("profile flag should be registered")
}
if !flag.Hidden {
t.Fatal("profile flag should be hidden when HideProfile(true) is applied")
}
}
func TestBuildInternal_DefaultShowsProfileFlag(t *testing.T) {
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams())
flag := root.PersistentFlags().Lookup("profile")
if flag == nil {
t.Fatal("profile flag should be registered by default")
}
if flag.Hidden {
t.Fatal("profile flag should be visible by default")
}
}
func saveAppsForTest(t *testing.T, apps []core.AppConfig) {
t.Helper()
multi := &core.MultiAppConfig{CurrentApp: apps[0].Name, Apps: apps}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}

18
cmd/init.go Normal file
View File

@@ -0,0 +1,18 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import "github.com/larksuite/cli/internal/vfs"
// SetDefaultFS replaces the global filesystem implementation used by internal
// packages. The provided fs must implement the vfs.FS interface. If fs is nil,
// the default OS filesystem is restored.
//
// Call this before Build or Execute to take effect.
func SetDefaultFS(fs vfs.FS) {
if fs == nil {
fs = vfs.OsFs{}
}
vfs.DefaultFS = fs
}

View File

@@ -14,14 +14,6 @@ import (
"os"
"strconv"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
"github.com/larksuite/cli/cmd/completion"
cmdconfig "github.com/larksuite/cli/cmd/config"
"github.com/larksuite/cli/cmd/doctor"
"github.com/larksuite/cli/cmd/profile"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/cmd/service"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
@@ -29,7 +21,6 @@ import (
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/update"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
@@ -94,37 +85,13 @@ func Execute() int {
fmt.Fprintln(os.Stderr, "Error:", err)
return 1
}
f := cmdutil.NewDefault(inv)
configureFlagCompletions(os.Args)
globals := &GlobalOptions{Profile: inv.Profile}
rootCmd := &cobra.Command{
Use: "lark-cli",
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
Long: rootLong,
Version: build.Version,
}
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
RegisterGlobalFlags(rootCmd.PersistentFlags(), globals)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
cmd.SilenceUsage = true
}
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(profile.NewCmdProfile(f))
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
rootCmd.AddCommand(api.NewCmdApi(f, nil))
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
service.RegisterServiceCommands(rootCmd, f)
shortcuts.RegisterShortcuts(rootCmd, f)
// Prune commands incompatible with strict mode.
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
pruneForStrictMode(rootCmd, mode)
}
f, rootCmd := buildInternal(
context.Background(), inv,
WithIO(os.Stdin, os.Stdout, os.Stderr),
HideProfile(isSingleAppMode()),
)
// --- Update check (non-blocking) ---
if !isCompletionCommand(os.Args) {
@@ -188,6 +155,12 @@ func isCompletionCommand(args []string) bool {
return false
}
// configureFlagCompletions enables cmdutil.RegisterFlagCompletion only when
// the invocation will actually serve a __complete request.
func configureFlagCompletions(args []string) {
cmdutil.SetFlagCompletionsDisabled(!isCompletionCommand(args))
}
// handleRootError dispatches a command error to the appropriate handler
// and returns the process exit code.
func handleRootError(f *cmdutil.Factory, err error) int {
@@ -275,10 +248,19 @@ func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyErr
}
// installTipsHelpFunc wraps the default help function to append a TIPS section
// when a command has tips set via cmdutil.SetTips.
// when a command has tips set via cmdutil.SetTips. It also force-shows global
// flags that are normally hidden in single-app mode (currently --profile)
// when rendering the root command's own help, so users discovering the CLI
// still see them at `lark-cli --help`.
func installTipsHelpFunc(root *cobra.Command) {
defaultHelp := root.HelpFunc()
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
if cmd == root {
if f := root.PersistentFlags().Lookup("profile"); f != nil && f.Hidden {
f.Hidden = false
defer func() { f.Hidden = true }()
}
}
defaultHelp(cmd, args)
tips := cmdutil.GetTips(cmd)
if len(tips) == 0 {

View File

@@ -135,10 +135,12 @@ func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictM
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f := cmdutil.NewDefault(cmdutil.InvocationContext{Profile: profile})
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
f.IOStreams = &cmdutil.IOStreams{In: nil, Out: stdout, ErrOut: stderr}
f := cmdutil.NewDefault(
cmdutil.NewIOStreams(&bytes.Buffer{}, stdout, stderr),
cmdutil.InvocationContext{Profile: profile},
)
return f, stdout, stderr
}

View File

@@ -196,3 +196,28 @@ func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
}
}
func TestConfigureFlagCompletions(t *testing.T) {
t.Cleanup(func() { cmdutil.SetFlagCompletionsDisabled(false) })
tests := []struct {
name string
args []string
wantDisabled bool
}{
{"plain command", []string{"im", "+send"}, true},
{"help flag", []string{"im", "--help"}, true},
{"no args", []string{}, true},
{"__complete request", []string{"__complete", "im", "+send", ""}, false},
{"completion subcommand", []string{"completion", "bash"}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cmdutil.SetFlagCompletionsDisabled(!tc.wantDisabled)
configureFlagCompletions(tc.args)
if got := cmdutil.FlagCompletionsDisabled(); got != tc.wantDisabled {
t.Fatalf("FlagCompletionsDisabled() = %v, want %v", got, tc.wantDisabled)
}
})
}
}

View File

@@ -4,12 +4,14 @@
package schema
import (
"context"
"fmt"
"io"
"sort"
"strings"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/util"
@@ -19,6 +21,7 @@ import (
// SchemaOptions holds all inputs for the schema command.
type SchemaOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
// Positional args
Path string
@@ -41,7 +44,7 @@ func printServices(w io.Writer) {
fmt.Fprintf(w, "\n%sUsage: lark-cli schema <service>.<resource>.<method>%s\n", output.Dim, output.Reset)
}
func printResourceList(w io.Writer, spec map[string]interface{}) {
func printResourceList(w io.Writer, spec map[string]interface{}, mode core.StrictMode) {
name := registry.GetStrFromMap(spec, "name")
version := registry.GetStrFromMap(spec, "version")
title := registry.GetStrFromMap(spec, "title")
@@ -55,9 +58,13 @@ func printResourceList(w io.Writer, spec map[string]interface{}) {
resources, _ := spec["resources"].(map[string]interface{})
for _, resName := range sortedKeys(resources) {
fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset)
resMap, _ := resources[resName].(map[string]interface{})
methods, _ := resMap["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
if len(methods) == 0 {
continue
}
fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset)
for _, methodName := range sortedKeys(methods) {
m, _ := methods[methodName].(map[string]interface{})
httpMethod := registry.GetStrFromMap(m, "httpMethod")
@@ -73,6 +80,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 +93,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 +152,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 +212,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 != "" {
@@ -332,6 +366,7 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
if len(args) > 0 {
opts.Path = args[0]
}
opts.Ctx = cmd.Context()
if runF != nil {
return runF(opts)
}
@@ -340,9 +375,9 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
}
cmdutil.DisableAuthCheck(cmd)
cmd.ValidArgsFunction = completeSchemaPath
cmd.ValidArgsFunction = completeSchemaPath(f)
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
})
@@ -352,78 +387,86 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
// completeSchemaPath provides tab-completion for the schema path argument.
// It handles dotted resource names (e.g. app.table.fields) by iterating all
// resources and classifying each as a prefix-match or fully-matched.
func completeSchemaPath(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
parts := strings.Split(toComplete, ".")
parts := strings.Split(toComplete, ".")
// Level 1: complete service names
if len(parts) <= 1 {
var completions []string
for _, s := range registry.ListFromMetaProjects() {
if strings.HasPrefix(s, toComplete) {
completions = append(completions, s+".")
// Level 1: complete service names
if len(parts) <= 1 {
var completions []string
for _, s := range registry.ListFromMetaProjects() {
if strings.HasPrefix(s, toComplete) {
completions = append(completions, s+".")
}
}
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
}
serviceName := parts[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
mode := f.ResolveStrictMode(cmd.Context())
spec = filterSpecByStrictMode(spec, mode)
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
afterService := strings.Join(parts[1:], ".")
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
allTrailingDot := len(completions) > 0
for _, c := range completions {
if !strings.HasSuffix(c, ".") {
allTrailingDot = false
break
}
}
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
directive := cobra.ShellCompDirectiveNoFileComp
if allTrailingDot {
directive |= cobra.ShellCompDirectiveNoSpace
}
return completions, directive
}
}
serviceName := parts[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// afterService = everything user typed after "serviceName."
afterService := strings.Join(parts[1:], ".")
func completeSchemaPathForSpec(serviceName string, resources map[string]interface{}, afterService string) []string {
var completions []string
for resName, resVal := range resources {
if strings.HasPrefix(resName, afterService) {
// afterService is a prefix of this resource name → resource candidate
completions = append(completions, serviceName+"."+resName+".")
} else if strings.HasPrefix(afterService, resName+".") {
// This resource is fully matched; remainder is method prefix
methodPrefix := afterService[len(resName)+1:]
resMap, _ := resVal.(map[string]interface{})
if resMap == nil {
continue
}
methods, _ := resMap["methods"].(map[string]interface{})
for methodName := range methods {
if strings.HasPrefix(methodName, methodPrefix) {
completions = append(completions, serviceName+"."+resName+"."+methodName)
}
continue
}
if !strings.HasPrefix(afterService, resName+".") {
continue
}
methodPrefix := afterService[len(resName)+1:]
resMap, _ := resVal.(map[string]interface{})
if resMap == nil {
continue
}
methods, _ := resMap["methods"].(map[string]interface{})
for methodName := range methods {
if strings.HasPrefix(methodName, methodPrefix) {
completions = append(completions, serviceName+"."+resName+"."+methodName)
}
}
}
sort.Strings(completions)
// If all completions end with ".", user is still navigating resources → NoSpace
allTrailingDot := len(completions) > 0
for _, c := range completions {
if !strings.HasSuffix(c, ".") {
allTrailingDot = false
break
}
}
directive := cobra.ShellCompDirectiveNoFileComp
if allTrailingDot {
directive |= cobra.ShellCompDirectiveNoSpace
}
return completions, directive
return completions
}
func schemaRun(opts *SchemaOptions) error {
out := opts.Factory.IOStreams.Out
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
if opts.Path == "" {
printServices(out)
@@ -442,9 +485,9 @@ func schemaRun(opts *SchemaOptions) error {
if len(parts) == 1 {
if opts.Format == "pretty" {
printResourceList(out, spec)
printResourceList(out, spec, mode)
} else {
output.PrintJson(out, spec)
output.PrintJson(out, filterSpecByStrictMode(spec, mode))
}
return nil
}
@@ -465,6 +508,7 @@ func schemaRun(opts *SchemaOptions) error {
if opts.Format == "pretty" {
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
for _, mName := range sortedKeys(methods) {
m, _ := methods[mName].(map[string]interface{})
httpMethod := registry.GetStrFromMap(m, "httpMethod")
@@ -473,13 +517,26 @@ func schemaRun(opts *SchemaOptions) error {
}
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
} else {
output.PrintJson(out, resource)
// For JSON output, filter methods in a copy to avoid mutating the registry.
if mode.IsActive() {
filtered := make(map[string]interface{})
for k, v := range resource {
filtered[k] = v
}
if methods, ok := resource["methods"].(map[string]interface{}); ok {
filtered["methods"] = filterMethodsByStrictMode(methods, mode)
}
output.PrintJson(out, filtered)
} else {
output.PrintJson(out, resource)
}
}
return nil
}
methodName := remaining[0]
methods, _ := resource["methods"].(map[string]interface{})
methods = filterMethodsByStrictMode(methods, mode)
method, ok := methods[methodName].(map[string]interface{})
if !ok {
var mNames []string
@@ -498,3 +555,67 @@ func schemaRun(opts *SchemaOptions) error {
}
return nil
}
// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods
// filtered by strict mode. Returns the original spec when strict mode is off.
func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} {
if !mode.IsActive() {
return spec
}
result := make(map[string]interface{}, len(spec))
for k, v := range spec {
result[k] = v
}
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return result
}
filteredRes := make(map[string]interface{}, len(resources))
for resName, resVal := range resources {
resMap, ok := resVal.(map[string]interface{})
if !ok {
continue
}
methods, _ := resMap["methods"].(map[string]interface{})
filtered := filterMethodsByStrictMode(methods, mode)
if len(filtered) == 0 {
continue
}
resCopy := make(map[string]interface{}, len(resMap))
for k, v := range resMap {
resCopy[k] = v
}
resCopy["methods"] = filtered
filteredRes[resName] = resCopy
}
result["resources"] = filteredRes
return result
}
// filterMethodsByStrictMode removes methods incompatible with the active strict mode.
// Returns the original map unmodified when strict mode is off.
func filterMethodsByStrictMode(methods map[string]interface{}, mode core.StrictMode) map[string]interface{} {
if !mode.IsActive() || methods == nil {
return methods
}
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
filtered := make(map[string]interface{}, len(methods))
for name, val := range methods {
m, ok := val.(map[string]interface{})
if !ok {
continue
}
tokens, _ := m["accessTokens"].([]interface{})
if tokens == nil {
filtered[name] = val
continue
}
for _, t := range tokens {
if ts, ok := t.(string); ok && ts == token {
filtered[name] = val
break
}
}
}
return filtered
}

View File

@@ -4,6 +4,7 @@
package schema
import (
"bytes"
"strings"
"testing"
@@ -61,3 +62,169 @@ 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))
}
})
}
}
func TestCompleteSchemaPathForSpec(t *testing.T) {
resources := map[string]interface{}{
"records": map[string]interface{}{
"methods": map[string]interface{}{
"create": map[string]interface{}{},
"list": map[string]interface{}{},
},
},
"record_permissions": map[string]interface{}{
"methods": map[string]interface{}{
"get": map[string]interface{}{},
},
},
}
got := completeSchemaPathForSpec("base", resources, "records.cr")
if len(got) != 1 || got[0] != "base.records.create" {
t.Fatalf("completions = %v, want [base.records.create]", got)
}
got = completeSchemaPathForSpec("base", resources, "record")
if len(got) != 2 || got[0] != "base.record_permissions." || got[1] != "base.records." {
t.Fatalf("resource completions = %v", got)
}
}
func TestFilterSpecByStrictMode_RemovesIncompatibleMethodsFromCompletionSource(t *testing.T) {
spec := map[string]interface{}{
"resources": map[string]interface{}{
"records": map[string]interface{}{
"methods": map[string]interface{}{
"list": map[string]interface{}{"accessTokens": []interface{}{"tenant"}},
"create": map[string]interface{}{"accessTokens": []interface{}{"user"}},
},
},
},
}
filtered := filterSpecByStrictMode(spec, core.StrictModeBot)
resources, _ := filtered["resources"].(map[string]interface{})
got := completeSchemaPathForSpec("base", resources, "records.")
if len(got) != 1 || got[0] != "base.records.list" {
t.Fatalf("filtered completions = %v, want [base.records.list]", got)
}
}

View File

@@ -24,6 +24,10 @@ import (
// RegisterServiceCommands registers all service commands from from_meta specs.
func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
RegisterServiceCommandsWithContext(context.Background(), parent, f)
}
func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
for _, project := range registry.ListFromMetaProjects() {
spec := registry.LoadFromMeta(project)
if spec == nil {
@@ -38,11 +42,15 @@ func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
if resources == nil {
continue
}
registerService(parent, spec, resources, f)
registerServiceWithContext(ctx, parent, spec, resources, f)
}
}
func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
registerServiceWithContext(context.Background(), parent, spec, resources, f)
}
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
specName := registry.GetStrFromMap(spec, "name")
specDesc := registry.GetServiceDescription(specName, "en")
if specDesc == "" {
@@ -70,11 +78,11 @@ func registerService(parent *cobra.Command, spec map[string]interface{}, resourc
if resMap == nil {
continue
}
registerResource(svc, spec, resName, resMap, f)
registerResourceWithContext(ctx, svc, spec, resName, resMap, f)
}
}
func registerResource(parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
res := &cobra.Command{
Use: name,
Short: name + " operations",
@@ -87,7 +95,7 @@ func registerResource(parent *cobra.Command, spec map[string]interface{}, name s
if methodMap == nil {
continue
}
registerMethod(res, spec, methodMap, methodName, name, f)
registerMethodWithContext(ctx, res, spec, methodMap, methodName, name, f)
}
}
@@ -101,24 +109,35 @@ type ServiceMethodOptions struct {
SchemaPath string
// Flags
Params string
Data string
As core.Identity
Output string
PageAll bool
PageLimit int
PageDelay int
Format string
JqExpr string
DryRun bool
Params string
Data string
As core.Identity
Output string
PageAll bool
PageLimit int
PageDelay int
Format string
JqExpr string
DryRun bool
File string // --file flag value
FileFields []string // auto-detected file field names from metadata
}
func registerMethod(parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
parent.AddCommand(NewCmdServiceMethod(f, spec, method, name, resName, nil))
// detectFileFields delegates to the shared cmdutil.DetectFileFields helper.
func detectFileFields(method map[string]interface{}) []string {
return cmdutil.DetectFileFields(method)
}
func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
parent.AddCommand(NewCmdServiceMethodWithContext(ctx, f, spec, method, name, resName, nil))
}
// NewCmdServiceMethod creates a command for a dynamically registered service method.
func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
return NewCmdServiceMethodWithContext(context.Background(), f, spec, method, name, resName, runF)
}
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
desc := registry.GetStrFromMap(method, "description")
httpMethod := registry.GetStrFromMap(method, "httpMethod")
specName := registry.GetStrFromMap(spec, "name")
@@ -152,7 +171,7 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
case "POST", "PUT", "PATCH", "DELETE":
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
}
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
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")
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
@@ -161,10 +180,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")
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
})
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
// 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)")
}
}
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
@@ -212,12 +237,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)
}
@@ -303,7 +331,9 @@ 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
@@ -312,12 +342,17 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
// 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{}, output.ErrValidation("--params and --data cannot both read from stdin (-)")
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
return client.RawApiRequest{}, nil, err
}
url := registry.GetStrFromMap(spec, "servicePath") + "/" + registry.GetStrFromMap(method, "path")
@@ -330,13 +365,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)
@@ -352,7 +387,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))
}
@@ -366,22 +401,60 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
}
}
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
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"
@@ -120,6 +121,24 @@ func TestRegisterService_MergesExistingCommand(t *testing.T) {
}
}
func TestNewCmdServiceMethod_StrictModeHidesAsFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
})
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("GET", nil), "copy", "files", nil)
flag := cmd.Flags().Lookup("as")
if flag == nil {
t.Fatal("expected --as flag to be registered")
}
if !flag.Hidden {
t.Fatal("expected --as flag to be hidden in strict mode")
}
if got := flag.DefValue; got != "bot" {
t.Fatalf("default value = %q, want %q", got, "bot")
}
}
// ── NewCmdServiceMethod flags ──
func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) {
@@ -710,6 +729,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

@@ -3,7 +3,10 @@
package credential
import "sync"
import (
"sort"
"sync"
)
var (
mu sync.Mutex
@@ -11,12 +14,28 @@ var (
)
// Register registers a credential Provider.
// Providers are consulted in registration order.
// Providers are consulted in priority order (lowest value first).
// Providers that implement Priority() int are sorted accordingly;
// those that do not default to priority 10.
// Typically called from init() via blank import.
func Register(p Provider) {
mu.Lock()
defer mu.Unlock()
providers = append(providers, p)
sort.SliceStable(providers, func(i, j int) bool {
return providerPriority(providers[i]) < providerPriority(providers[j])
})
}
// providerPriority returns the priority of a provider.
// If the provider implements interface{ Priority() int }, that value is used;
// otherwise 10 is returned as the default priority.
// Lower values are consulted first.
func providerPriority(p Provider) int {
if pp, ok := p.(interface{ Priority() int }); ok {
return pp.Priority()
}
return 10
}
// Providers returns all registered providers (snapshot).

View File

@@ -37,6 +37,32 @@ func TestRegisterAndProviders(t *testing.T) {
}
}
type priorityProvider struct {
stubProvider
priority int
}
func (p *priorityProvider) Priority() int { return p.priority }
func TestRegister_PriorityOrder(t *testing.T) {
mu.Lock()
old := providers
providers = nil
mu.Unlock()
defer func() { mu.Lock(); providers = old; mu.Unlock() }()
Register(&stubProvider{name: "env"}) // priority 10 (default)
Register(&priorityProvider{stubProvider: stubProvider{name: "sidecar"}, priority: 0}) // priority 0 (first)
got := Providers()
if len(got) != 2 {
t.Fatalf("expected 2, got %d", len(got))
}
if got[0].Name() != "sidecar" || got[1].Name() != "env" {
t.Errorf("expected sidecar before env, got %s, %s", got[0].Name(), got[1].Name())
}
}
func TestProviders_ReturnsSnapshot(t *testing.T) {
mu.Lock()
old := providers

View File

@@ -0,0 +1,131 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
// Package sidecar provides a noop credential provider for the auth sidecar
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set, this provider supplies
// placeholder credentials so the CLI's auth pipeline can proceed normally.
// Real tokens are never present in the sandbox; the sidecar transport
// interceptor routes requests to the trusted sidecar process instead.
package sidecar
import (
"context"
"fmt"
"os"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
)
// Provider is the noop credential provider for sidecar mode.
type Provider struct{}
func (p *Provider) Name() string { return "sidecar" }
func (p *Provider) Priority() int { return 0 }
// ResolveAccount returns a minimal Account when sidecar mode is active.
// The account contains AppID and Brand from environment variables, a
// placeholder secret, and SupportedIdentities derived from STRICT_MODE.
// Returns nil, nil when sidecar mode is not active (AUTH_PROXY not set).
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
proxyAddr := os.Getenv(envvars.CliAuthProxy)
if proxyAddr == "" {
return nil, nil // not in sidecar mode, skip
}
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: fmt.Sprintf("invalid %s %q: %v", envvars.CliAuthProxy, proxyAddr, err),
}
}
appID := os.Getenv(envvars.CliAppID)
if appID == "" {
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: envvars.CliAuthProxy + " is set but " + envvars.CliAppID + " is missing",
}
}
if os.Getenv(envvars.CliProxyKey) == "" {
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: envvars.CliAuthProxy + " is set but " + envvars.CliProxyKey + " is missing",
}
}
brand := credential.Brand(os.Getenv(envvars.CliBrand))
if brand == "" {
brand = credential.BrandFeishu
}
acct := &credential.Account{
AppID: appID,
AppSecret: credential.NoAppSecret,
Brand: brand,
}
// Parse DefaultAs
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
case "", credential.IdentityAuto:
acct.DefaultAs = id
case credential.IdentityUser, credential.IdentityBot:
acct.DefaultAs = id
default:
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
}
}
// Parse SupportedIdentities from STRICT_MODE, default to SupportsAll.
switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode {
case "bot":
acct.SupportedIdentities = credential.SupportsBot
case "user":
acct.SupportedIdentities = credential.SupportsUser
case "off", "":
acct.SupportedIdentities = credential.SupportsAll
default:
return nil, &credential.BlockError{
Provider: "sidecar",
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
}
}
return acct, nil
}
// ResolveToken returns a sentinel token whose value encodes the token type.
// The transport interceptor reads this sentinel to determine the identity
// (user vs bot), strips it, and the sidecar injects the real token.
// Returns nil, nil when sidecar mode is not active.
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
if os.Getenv(envvars.CliAuthProxy) == "" {
return nil, nil
}
var sentinel string
switch req.Type {
case credential.TokenTypeUAT:
sentinel = sidecar.SentinelUAT
case credential.TokenTypeTAT:
sentinel = sidecar.SentinelTAT
default:
return nil, nil
}
return &credential.Token{
Value: sentinel,
Scopes: "", // empty → scope pre-check is skipped
Source: "sidecar",
}, nil
}
func init() {
credential.Register(&Provider{})
}

View File

@@ -0,0 +1,188 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
package sidecar
import (
"context"
"os"
"testing"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
)
func setEnv(t *testing.T, key, value string) {
t.Helper()
old, hadOld := os.LookupEnv(key)
os.Setenv(key, value)
t.Cleanup(func() {
if hadOld {
os.Setenv(key, old)
} else {
os.Unsetenv(key)
}
})
}
func unsetEnv(t *testing.T, key string) {
t.Helper()
old, hadOld := os.LookupEnv(key)
os.Unsetenv(key)
t.Cleanup(func() {
if hadOld {
os.Setenv(key, old)
}
})
}
func TestResolveAccount_NotActive(t *testing.T) {
unsetEnv(t, envvars.CliAuthProxy)
p := &Provider{}
acct, err := p.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if acct != nil {
t.Fatal("expected nil account when AUTH_PROXY not set")
}
}
func TestResolveAccount_Active(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envvars.CliProxyKey, "test-key")
setEnv(t, envvars.CliAppID, "cli_test123")
setEnv(t, envvars.CliBrand, "lark")
unsetEnv(t, envvars.CliDefaultAs)
unsetEnv(t, envvars.CliStrictMode)
p := &Provider{}
acct, err := p.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if acct == nil {
t.Fatal("expected non-nil account")
}
if acct.AppID != "cli_test123" {
t.Errorf("AppID = %q, want %q", acct.AppID, "cli_test123")
}
if acct.Brand != credential.BrandLark {
t.Errorf("Brand = %q, want %q", acct.Brand, credential.BrandLark)
}
if acct.AppSecret != credential.NoAppSecret {
t.Errorf("AppSecret should be NoAppSecret, got %q", acct.AppSecret)
}
if acct.SupportedIdentities != credential.SupportsAll {
t.Errorf("SupportedIdentities = %d, want %d (SupportsAll)", acct.SupportedIdentities, credential.SupportsAll)
}
}
func TestResolveAccount_MissingProxyKey(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
unsetEnv(t, envvars.CliProxyKey)
setEnv(t, envvars.CliAppID, "cli_test")
p := &Provider{}
_, err := p.ResolveAccount(context.Background())
if err == nil {
t.Fatal("expected error when PROXY_KEY is missing")
}
if _, ok := err.(*credential.BlockError); !ok {
t.Fatalf("expected BlockError, got %T: %v", err, err)
}
}
func TestResolveAccount_MissingAppID(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envvars.CliProxyKey, "test-key")
unsetEnv(t, envvars.CliAppID)
p := &Provider{}
_, err := p.ResolveAccount(context.Background())
if err == nil {
t.Fatal("expected error when APP_ID is missing")
}
if _, ok := err.(*credential.BlockError); !ok {
t.Fatalf("expected BlockError, got %T: %v", err, err)
}
}
func TestResolveAccount_StrictMode(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envvars.CliProxyKey, "test-key")
setEnv(t, envvars.CliAppID, "cli_test")
tests := []struct {
mode string
want credential.IdentitySupport
}{
{"bot", credential.SupportsBot},
{"user", credential.SupportsUser},
{"off", credential.SupportsAll},
{"", credential.SupportsAll},
}
p := &Provider{}
for _, tt := range tests {
t.Run("strict_"+tt.mode, func(t *testing.T) {
if tt.mode == "" {
unsetEnv(t, envvars.CliStrictMode)
} else {
setEnv(t, envvars.CliStrictMode, tt.mode)
}
acct, err := p.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if acct.SupportedIdentities != tt.want {
t.Errorf("SupportedIdentities = %d, want %d", acct.SupportedIdentities, tt.want)
}
})
}
}
func TestResolveToken_NotActive(t *testing.T) {
unsetEnv(t, envvars.CliAuthProxy)
p := &Provider{}
tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tok != nil {
t.Fatal("expected nil token when AUTH_PROXY not set")
}
}
func TestResolveToken_Sentinels(t *testing.T) {
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
setEnv(t, envvars.CliProxyKey, "test-key")
p := &Provider{}
// UAT
tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
if err != nil {
t.Fatalf("UAT: unexpected error: %v", err)
}
if tok.Value != sidecar.SentinelUAT {
t.Errorf("UAT value = %q, want %q", tok.Value, sidecar.SentinelUAT)
}
if tok.Scopes != "" {
t.Errorf("UAT scopes should be empty, got %q", tok.Scopes)
}
// TAT
tok, err = p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeTAT})
if err != nil {
t.Fatalf("TAT: unexpected error: %v", err)
}
if tok.Value != sidecar.SentinelTAT {
t.Errorf("TAT value = %q, want %q", tok.Value, sidecar.SentinelTAT)
}
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"errors"
"fmt"
)
// ErrAborted is a sentinel matched by errors.Is on any extension-triggered
// round-trip abort. Callers that only need to know whether an error was
// caused by an extension interception should use:
//
// if errors.Is(err, transport.ErrAborted) { ... }
var ErrAborted = errors.New("round trip aborted by extension")
// AbortError is returned by the built-in middleware when an AbortableInterceptor
// short-circuits a request via PreRoundTripE. It wraps the extension's original
// reason and carries the extension's Provider.Name() for traceability.
//
// Use errors.As to recover the typed error:
//
// var aErr *transport.AbortError
// if errors.As(err, &aErr) {
// log.Printf("blocked by %s: %v", aErr.Extension, aErr.Reason)
// }
//
// errors.Is(err, transport.ErrAborted) also works, and errors.Is against the
// inner reason still works via Unwrap.
type AbortError struct {
// Extension is the name of the Provider whose interceptor aborted the
// request (from Provider.Name()). May be empty if the provider did not
// supply a name.
Extension string
// Reason is the original non-nil error returned by PreRoundTripE.
Reason error
}
func (e *AbortError) Error() string {
if e.Extension != "" {
return fmt.Sprintf("extension %q aborted round trip: %v", e.Extension, e.Reason)
}
return fmt.Sprintf("extension aborted round trip: %v", e.Reason)
}
// Unwrap lets errors.Is / errors.As traverse to the underlying Reason.
func (e *AbortError) Unwrap() error { return e.Reason }
// Is enables errors.Is(err, ErrAborted) at any nesting depth.
func (e *AbortError) Is(target error) bool { return target == ErrAborted }

View File

@@ -0,0 +1,103 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"errors"
"fmt"
"testing"
)
func TestAbortError_Error(t *testing.T) {
tests := []struct {
name string
err *AbortError
want string
}{
{
name: "with extension name",
err: &AbortError{Extension: "audit", Reason: errors.New("bad")},
want: `extension "audit" aborted round trip: bad`,
},
{
name: "without extension name",
err: &AbortError{Reason: errors.New("bad")},
want: "extension aborted round trip: bad",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.err.Error(); got != tt.want {
t.Fatalf("Error() = %q, want %q", got, tt.want)
}
})
}
}
func TestAbortError_Unwrap(t *testing.T) {
reason := errors.New("bad")
e := &AbortError{Reason: reason}
if got := e.Unwrap(); got != reason {
t.Fatalf("Unwrap() = %v, want %v", got, reason)
}
}
func TestAbortError_IsErrAborted(t *testing.T) {
e := &AbortError{Reason: errors.New("bad")}
if !errors.Is(e, ErrAborted) {
t.Fatal("errors.Is(e, ErrAborted) = false, want true")
}
// Sanity: not matched by unrelated sentinels.
if errors.Is(e, errors.New("other")) {
t.Fatal("errors.Is matched unrelated sentinel")
}
}
func TestAbortError_UnwrapReachesInnerSentinel(t *testing.T) {
// Extensions often return typed/sentinel errors; callers should still be
// able to errors.Is against those after the middleware wraps them.
innerSentinel := errors.New("policy-deny-42")
e := &AbortError{Reason: fmt.Errorf("wrapped: %w", innerSentinel)}
if !errors.Is(e, innerSentinel) {
t.Fatal("errors.Is(e, innerSentinel) = false, want true (Unwrap chain broken)")
}
}
func TestAbortError_As(t *testing.T) {
reason := errors.New("bad")
base := &AbortError{Extension: "audit", Reason: reason}
// Direct As.
var aErr *AbortError
if !errors.As(base, &aErr) {
t.Fatal("errors.As(base, *AbortError) = false")
}
if aErr.Extension != "audit" || aErr.Reason != reason {
t.Fatalf("aErr = %+v, want {audit, bad}", aErr)
}
// Nested As: even when the *AbortError is wrapped in another error,
// errors.As must still find it via Unwrap chain.
wrapped := fmt.Errorf("outer: %w", base)
var aErr2 *AbortError
if !errors.As(wrapped, &aErr2) {
t.Fatal("errors.As(wrapped, *AbortError) = false")
}
if aErr2 != base {
t.Fatalf("aErr2 = %p, want %p", aErr2, base)
}
// errors.Is still matches the sentinel through the outer wrapper.
if !errors.Is(wrapped, ErrAborted) {
t.Fatal("errors.Is(wrapped, ErrAborted) = false via nested wrap")
}
}
func TestErrAborted_IsItselfSentinel(t *testing.T) {
// Guard against accidental re-assignment of ErrAborted: a bare ErrAborted
// value should still satisfy errors.Is(err, ErrAborted) for symmetry.
if !errors.Is(ErrAborted, ErrAborted) {
t.Fatal("errors.Is(ErrAborted, ErrAborted) = false")
}
}

View File

@@ -0,0 +1,178 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
// Package sidecar provides a transport interceptor for the auth sidecar
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an HTTP URL), all
// outgoing requests are rewritten to the sidecar address. The interceptor
// strips placeholder credentials, injects proxy headers, and signs each
// request with HMAC-SHA256. No custom DialContext is needed — Go's
// standard http.Transport connects to the sidecar via plain HTTP.
package sidecar
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"strings"
"github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
)
// Provider implements transport.Provider for the sidecar mode.
type Provider struct{}
func (p *Provider) Name() string { return "sidecar" }
// ResolveInterceptor returns a SidecarInterceptor when sidecar mode is active.
// Returns nil when sidecar mode is disabled or the proxy address is invalid;
// in the latter case a warning is emitted to stderr and requests fall back to
// the non-sidecar transport path (where the credential layer will typically
// block them for lack of a valid account).
func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor {
proxyAddr := os.Getenv(envvars.CliAuthProxy)
if proxyAddr == "" {
return nil
}
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
fmt.Fprintf(os.Stderr, "WARNING: invalid %s, sidecar interceptor disabled: %v\n", envvars.CliAuthProxy, err)
return nil
}
key := os.Getenv(envvars.CliProxyKey)
return &Interceptor{
key: []byte(key),
sidecarHost: sidecar.ProxyHost(proxyAddr),
}
}
// Interceptor rewrites requests for the sidecar proxy.
type Interceptor struct {
key []byte // HMAC signing key
sidecarHost string // sidecar host:port for URL rewriting
}
// PreRoundTrip rewrites the request for sidecar routing when it carries a
// sentinel token. Requests without a sentinel token (e.g. pre-signed download
// URLs) are passed through unmodified.
//
// Supports two auth patterns:
// - Standard OpenAPI: Authorization: Bearer <sentinel>
// - MCP protocol: X-Lark-MCP-UAT/TAT: <sentinel>
func (i *Interceptor) PreRoundTrip(req *http.Request) func(resp *http.Response, err error) {
identity, authHeader := detectSentinel(req)
if identity == "" {
return nil // not a sidecar-managed request, pass through
}
// 1. Buffer the body first, before mutating any request state. A partial
// read would sign a truncated body and cause a misleading HMAC mismatch
// on the sidecar side; bail out early and let the request fall through
// unmodified so the credential layer can surface an actionable error.
var bodyBytes []byte
if req.Body != nil {
var err error
bodyBytes, err = io.ReadAll(req.Body)
_ = req.Body.Close() // release original body (fd/pipe/etc.) after buffering
if err != nil {
fmt.Fprintf(os.Stderr, "WARNING: sidecar interceptor failed to read request body: %v\n", err)
return nil
}
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
if req.GetBody != nil {
req.GetBody = func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(bodyBytes)), nil
}
}
}
// 2. Save original target (scheme://host)
originalScheme := "https"
if req.URL.Scheme != "" {
originalScheme = req.URL.Scheme
}
originalHost := req.URL.Host
req.Header.Set(sidecar.HeaderProxyTarget, originalScheme+"://"+originalHost)
// 3. Set identity and tell sidecar which header to inject real token into
req.Header.Set(sidecar.HeaderProxyIdentity, identity)
req.Header.Set(sidecar.HeaderProxyAuthHeader, authHeader)
// 4. Strip placeholder auth header(s)
req.Header.Del("Authorization")
req.Header.Del(sidecar.HeaderMCPUAT)
req.Header.Del(sidecar.HeaderMCPTAT)
bodySHA := sidecar.BodySHA256(bodyBytes)
req.Header.Set(sidecar.HeaderBodySHA256, bodySHA)
pathAndQuery := req.URL.RequestURI()
ts := sidecar.Timestamp()
// Cover identity and authHeader in the signature so an on-path attacker
// within the replay window cannot flip the injected token's identity or
// redirect the token into a different header.
sig := sidecar.Sign(i.key, sidecar.CanonicalRequest{
Version: sidecar.ProtocolV1,
Method: req.Method,
Host: originalHost,
PathAndQuery: pathAndQuery,
BodySHA256: bodySHA,
Timestamp: ts,
Identity: identity,
AuthHeader: authHeader,
})
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
req.Header.Set(sidecar.HeaderProxySignature, sig)
// 5. Rewrite URL to route through sidecar
req.URL.Scheme = "http"
req.URL.Host = i.sidecarHost
return nil // no post-hook needed
}
// detectSentinel checks both standard Authorization and MCP auth headers for
// sentinel tokens. Returns the identity ("user"/"bot") and the header name
// that carried the sentinel.
//
// Returns ("", "") when the request carries no sentinel token — typically
// requests that require no auth (e.g. pre-signed download URLs where the
// token is embedded in the URL query parameters).
func detectSentinel(req *http.Request) (identity, authHeader string) {
// Check standard Authorization: Bearer <sentinel>
if auth := req.Header.Get("Authorization"); auth != "" {
token := strings.TrimPrefix(auth, "Bearer ")
switch token {
case sidecar.SentinelUAT:
return sidecar.IdentityUser, "Authorization"
case sidecar.SentinelTAT:
return sidecar.IdentityBot, "Authorization"
}
}
// Check MCP headers: X-Lark-MCP-UAT/TAT: <sentinel>
if v := req.Header.Get(sidecar.HeaderMCPUAT); v == sidecar.SentinelUAT {
return sidecar.IdentityUser, sidecar.HeaderMCPUAT
}
if v := req.Header.Get(sidecar.HeaderMCPTAT); v == sidecar.SentinelTAT {
return sidecar.IdentityBot, sidecar.HeaderMCPTAT
}
return "", ""
}
func init() {
proxyAddr := os.Getenv(envvars.CliAuthProxy)
if proxyAddr == "" {
return
}
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
fmt.Fprintf(os.Stderr, "WARNING: ignoring invalid %s: %v\n", envvars.CliAuthProxy, err)
return
}
transport.Register(&Provider{})
}

View File

@@ -0,0 +1,265 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
package sidecar
import (
"bytes"
"errors"
"io"
"net/http"
"testing"
"github.com/larksuite/cli/sidecar"
)
// failingBody is a ReadCloser that errors on Read and tracks Close calls.
type failingBody struct {
err error
closed bool
readCall bool
}
func (b *failingBody) Read(p []byte) (int, error) {
b.readCall = true
return 0, b.err
}
func (b *failingBody) Close() error {
b.closed = true
return nil
}
func TestInterceptor_PreRoundTrip(t *testing.T) {
key := []byte("test-key-for-hmac-signing-32byte!")
interceptor := &Interceptor{key: key, sidecarHost: "127.0.0.1:16384"}
body := []byte(`{"msg":"hello"}`)
req, _ := http.NewRequest("POST", "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id", io.NopCloser(bytes.NewReader(body)))
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
req.Header.Set("X-Cli-Source", "lark-cli")
post := interceptor.PreRoundTrip(req)
if post != nil {
t.Error("expected nil post hook")
}
// URL should be rewritten to sidecar
if req.URL.Scheme != "http" {
t.Errorf("scheme = %q, want %q", req.URL.Scheme, "http")
}
if req.URL.Host != "127.0.0.1:16384" {
t.Errorf("host = %q, want %q", req.URL.Host, "127.0.0.1:16384")
}
// Original target should be preserved
target := req.Header.Get(sidecar.HeaderProxyTarget)
if target != "https://open.feishu.cn" {
t.Errorf("target = %q, want %q", target, "https://open.feishu.cn")
}
// Identity should be user (from SentinelUAT)
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityUser {
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityUser)
}
// Authorization should be stripped
if auth := req.Header.Get("Authorization"); auth != "" {
t.Errorf("Authorization header should be stripped, got %q", auth)
}
// HMAC headers should be set
if sig := req.Header.Get(sidecar.HeaderProxySignature); sig == "" {
t.Error("signature header should be set")
}
if ts := req.Header.Get(sidecar.HeaderProxyTimestamp); ts == "" {
t.Error("timestamp header should be set")
}
if sha := req.Header.Get(sidecar.HeaderBodySHA256); sha == "" {
t.Error("body SHA256 header should be set")
}
if v := req.Header.Get(sidecar.HeaderProxyVersion); v != sidecar.ProtocolV1 {
t.Errorf("version header = %q, want %q", v, sidecar.ProtocolV1)
}
// Non-proxy headers should be preserved
if src := req.Header.Get("X-Cli-Source"); src != "lark-cli" {
t.Errorf("X-Cli-Source should be preserved, got %q", src)
}
// Body should still be readable
readBody, _ := io.ReadAll(req.Body)
if !bytes.Equal(readBody, body) {
t.Errorf("body should be preserved after PreRoundTrip")
}
}
func TestInterceptor_BotIdentity(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/calendar/v4/events", nil)
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelTAT)
interceptor.PreRoundTrip(req)
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityBot {
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityBot)
}
}
func TestInterceptor_NonSentinelToken_PassThrough(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
origURL := "https://some-cdn.example.com/presigned-download?token=abc"
req, _ := http.NewRequest("GET", origURL, nil)
req.Header.Set("Authorization", "Bearer some-real-token")
post := interceptor.PreRoundTrip(req)
// Should NOT be rewritten — no sentinel token
if post != nil {
t.Error("expected nil post hook for pass-through")
}
if req.URL.String() != origURL {
t.Errorf("URL should be unchanged, got %q", req.URL.String())
}
if req.Header.Get(sidecar.HeaderProxyTarget) != "" {
t.Error("proxy target header should not be set for pass-through")
}
if req.Header.Get("Authorization") != "Bearer some-real-token" {
t.Error("Authorization should be preserved for pass-through")
}
}
func TestInterceptor_NoAuth_PassThrough(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
origURL := "https://cdn.feishu.cn/download/file"
req, _ := http.NewRequest("GET", origURL, nil)
interceptor.PreRoundTrip(req)
// No Authorization header at all — should pass through
if req.URL.String() != origURL {
t.Errorf("URL should be unchanged for no-auth request, got %q", req.URL.String())
}
}
func TestInterceptor_MCP_UAT(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
req, _ := http.NewRequest("POST", "https://mcp.feishu.cn/mcp/v1/tools/call", bytes.NewReader([]byte(`{"jsonrpc":"2.0"}`)))
req.Header.Set(sidecar.HeaderMCPUAT, sidecar.SentinelUAT)
interceptor.PreRoundTrip(req)
// Should be intercepted and rewritten
if req.URL.Host != "127.0.0.1:16384" {
t.Errorf("host = %q, want sidecar host", req.URL.Host)
}
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityUser {
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityUser)
}
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != sidecar.HeaderMCPUAT {
t.Errorf("auth header = %q, want %q", ah, sidecar.HeaderMCPUAT)
}
// MCP sentinel should be stripped
if v := req.Header.Get(sidecar.HeaderMCPUAT); v != "" {
t.Errorf("MCP-UAT should be stripped, got %q", v)
}
}
func TestInterceptor_MCP_TAT(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
req, _ := http.NewRequest("POST", "https://mcp.feishu.cn/mcp/v1/tools/call", bytes.NewReader([]byte(`{}`)))
req.Header.Set(sidecar.HeaderMCPTAT, sidecar.SentinelTAT)
interceptor.PreRoundTrip(req)
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityBot {
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityBot)
}
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != sidecar.HeaderMCPTAT {
t.Errorf("auth header = %q, want %q", ah, sidecar.HeaderMCPTAT)
}
}
func TestInterceptor_StandardAuth_SetsAuthorizationHeader(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/test", nil)
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
interceptor.PreRoundTrip(req)
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != "Authorization" {
t.Errorf("auth header = %q, want %q", ah, "Authorization")
}
}
// TestInterceptor_BodyReadError verifies that when io.ReadAll on the request
// body fails partway, PreRoundTrip skips the rewrite entirely rather than
// signing a truncated body (which would produce a misleading HMAC mismatch on
// the sidecar side) and releases the original body.
func TestInterceptor_BodyReadError(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
const origURL = "https://open.feishu.cn/open-apis/im/v1/messages"
body := &failingBody{err: errors.New("disk gremlin")}
req, _ := http.NewRequest("POST", origURL, body)
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
post := interceptor.PreRoundTrip(req)
if post != nil {
t.Error("expected nil post hook on body read failure")
}
// Original body must be closed to avoid leaking fd/pipe-like resources.
if !body.readCall {
t.Error("expected ReadAll to have attempted reading from the body")
}
if !body.closed {
t.Error("expected original body to be Close()'d after read failure")
}
// URL must NOT be rewritten — request should fall through to the next
// layer (credential) which can surface a meaningful error.
if req.URL.String() != origURL {
t.Errorf("URL should be unchanged on read failure, got %q", req.URL.String())
}
// No proxy/HMAC headers should leak onto the request.
for _, h := range []string{
sidecar.HeaderProxyVersion,
sidecar.HeaderProxyTarget,
sidecar.HeaderProxySignature,
sidecar.HeaderProxyTimestamp,
sidecar.HeaderBodySHA256,
sidecar.HeaderProxyIdentity,
sidecar.HeaderProxyAuthHeader,
} {
if v := req.Header.Get(h); v != "" {
t.Errorf("%s should not be set on read failure, got %q", h, v)
}
}
}
func TestInterceptor_EmptyBody(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
req, _ := http.NewRequest("GET", "https://open.feishu.cn/path", nil)
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelTAT)
interceptor.PreRoundTrip(req)
sha := req.Header.Get(sidecar.HeaderBodySHA256)
expectedEmpty := sidecar.BodySHA256(nil)
if sha != expectedEmpty {
t.Errorf("body SHA256 = %q, want empty-string SHA256 %q", sha, expectedEmpty)
}
}

View File

@@ -27,6 +27,31 @@ type Provider interface {
//
// The returned function (if non-nil) is called after the built-in chain
// completes. Use it for logging, ending trace spans, or recording metrics.
//
// Body note: the middleware Clones the caller's request before invoking the
// interceptor, which copies headers/URL/etc. but shares the underlying
// io.ReadCloser. Extensions that read req.Body are responsible for restoring
// a replayable body (e.g. via req.GetBody) before returning, otherwise the
// built-in chain will see an exhausted stream.
type Interceptor interface {
PreRoundTrip(req *http.Request) func(resp *http.Response, err error)
}
// AbortableInterceptor is an optional extension of Interceptor that lets an
// extension reject a request before the built-in chain runs. Extensions that
// implement this interface are detected by the built-in middleware via a
// type assertion; both methods must be present, but when an extension
// implements PreRoundTripE the middleware will NOT call PreRoundTrip.
//
// Returning a non-nil error from PreRoundTripE aborts the request: the
// built-in chain is not executed and the middleware returns an *AbortError
// wrapping the reason. The returned post function (if non-nil) is still
// invoked with (nil, reason) so that extensions can unwind any state they
// created in the pre hook (spans, metrics, audit records).
//
// Extensions that only care about the abortable variant can provide a no-op
// PreRoundTrip method alongside PreRoundTripE to satisfy Interceptor.
type AbortableInterceptor interface {
Interceptor
PreRoundTripE(req *http.Request) (post func(resp *http.Response, err error), err error)
}

View File

@@ -200,7 +200,7 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec
errStr := getStr(data, "error")
if errStr == "" && getStr(data, "access_token") != "" {
fmt.Fprintf(errOut, "[lark-cli] device-flow: token obtained successfully\n")
fmt.Fprintf(errOut, "[lark-cli] device-flow: token response received\n")
refreshToken := getStr(data, "refresh_token")
tokenExpiresIn := getInt(data, "expires_in", 7200)
refreshExpiresIn := getInt(data, "refresh_token_expires_in", 604800)

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"sync/atomic"
"github.com/spf13/cobra"
)
// Cobra keeps completion callbacks in a package-global map keyed by
// *pflag.Flag with no removal path, so registrations made for a *cobra.Command
// outlive the command itself. Skip registration when the current invocation
// will not serve a completion request.
var flagCompletionsDisabled atomic.Bool
// SetFlagCompletionsDisabled switches RegisterFlagCompletion between
// registering and no-op. Typically set once at process start.
func SetFlagCompletionsDisabled(disabled bool) {
flagCompletionsDisabled.Store(disabled)
}
// FlagCompletionsDisabled reports the current switch state.
func FlagCompletionsDisabled() bool {
return flagCompletionsDisabled.Load()
}
// RegisterFlagCompletion wraps (*cobra.Command).RegisterFlagCompletionFunc
// and honors the package switch. The underlying error is swallowed to match
// the `_ = cmd.RegisterFlagCompletionFunc(...)` style already used here.
func RegisterFlagCompletion(cmd *cobra.Command, flagName string, fn cobra.CompletionFunc) {
if flagCompletionsDisabled.Load() {
return
}
_ = cmd.RegisterFlagCompletionFunc(flagName, fn)
}

View File

@@ -0,0 +1,78 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"runtime"
"sync/atomic"
"testing"
"time"
"github.com/spf13/cobra"
)
func TestSetFlagCompletionsDisabled_RoundTrip(t *testing.T) {
t.Cleanup(func() { SetFlagCompletionsDisabled(false) })
if FlagCompletionsDisabled() {
t.Fatal("expected default false")
}
SetFlagCompletionsDisabled(true)
if !FlagCompletionsDisabled() {
t.Fatal("expected true after Set(true)")
}
SetFlagCompletionsDisabled(false)
if FlagCompletionsDisabled() {
t.Fatal("expected false after Set(false)")
}
}
// When disabled, a *cobra.Command must be collectable after the caller drops
// its reference — i.e. the wrapper did not touch cobra's global map.
func TestRegisterFlagCompletion_Disabled_DoesNotRetainCommand(t *testing.T) {
SetFlagCompletionsDisabled(true)
t.Cleanup(func() { SetFlagCompletionsDisabled(false) })
const N = 5
var collected atomic.Int32
func() {
for range N {
cmd := &cobra.Command{Use: "x"}
cmd.Flags().String("foo", "", "")
RegisterFlagCompletion(cmd, "foo", func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveNoFileComp
})
runtime.SetFinalizer(cmd, func(_ *cobra.Command) { collected.Add(1) })
}
}()
// Finalizers run on a dedicated goroutine after GC; loop to give it time.
for range 30 {
runtime.GC()
time.Sleep(20 * time.Millisecond)
}
if got := collected.Load(); int(got) != N {
t.Fatalf("expected %d *cobra.Command finalizers to fire when completions disabled, got %d", N, got)
}
}
// When enabled, the registered completion must be reachable via cobra.
func TestRegisterFlagCompletion_Enabled_DoesRegister(t *testing.T) {
SetFlagCompletionsDisabled(false)
cmd := &cobra.Command{Use: "x"}
cmd.Flags().String("foo", "", "")
want := []cobra.Completion{"a", "b"}
RegisterFlagCompletion(cmd, "foo", func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) {
return want, cobra.ShellCompDirectiveNoFileComp
})
fn, ok := cmd.GetFlagCompletionFunc("foo")
if !ok {
t.Fatal("expected completion func to be registered")
}
got, _ := fn(cmd, nil, "")
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("unexpected completion result: %v", got)
}
}

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

@@ -8,13 +8,11 @@ import (
"fmt"
"io"
"net/http"
"os"
"sync"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"golang.org/x/term"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
@@ -34,27 +32,24 @@ import (
// Phase 2: Credential (sole data source for account info)
// Phase 3: Config derived from Credential
// Phase 4: LarkClient derived from Credential
func NewDefault(inv InvocationContext) *Factory {
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
streams = normalizeStreams(streams)
f := &Factory{
Keychain: keychain.Default(),
Invocation: inv,
}
f.IOStreams = &IOStreams{
In: os.Stdin,
Out: os.Stdout,
ErrOut: os.Stderr,
IsTerminal: term.IsTerminal(int(os.Stdin.Fd())),
IOStreams: streams,
}
// Phase 0: FileIO provider (no dependency)
f.FileIOProvider = fileio.GetProvider()
// Phase 1: HttpClient (no credential dependency)
f.HttpClient = cachedHttpClientFunc()
f.HttpClient = cachedHttpClientFunc(f)
// Phase 2: Credential (sole data source)
// Keychain is read via closure so callers can replace f.Keychain after construction.
f.Credential = buildCredentialProvider(credentialDeps{
Keychain: f.Keychain,
Keychain: func() keychain.KeychainAccess { return f.Keychain },
Profile: inv.Profile,
HttpClient: f.HttpClient,
ErrOut: f.IOStreams.ErrOut,
@@ -93,11 +88,11 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
return nil
}
func cachedHttpClientFunc() func() (*http.Client, error) {
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
util.WarnIfProxied(os.Stderr)
util.WarnIfProxied(f.IOStreams.ErrOut)
var transport http.RoundTripper = util.NewBaseTransport()
var transport http.RoundTripper = util.SharedTransport()
transport = &RetryTransport{Base: transport}
transport = &SecurityHeaderTransport{Base: transport}
transport = &auth.SecurityPolicyTransport{Base: transport} // Add our global response interceptor
@@ -122,7 +117,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHeaders(BaseSecurityHeaders()),
}
util.WarnIfProxied(os.Stderr)
util.WarnIfProxied(f.IOStreams.ErrOut)
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: buildSDKTransport(),
CheckRedirect: safeRedirectPolicy,
@@ -134,15 +129,16 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
}
func buildSDKTransport() http.RoundTripper {
var sdkTransport http.RoundTripper = util.NewBaseTransport()
var sdkTransport http.RoundTripper = util.SharedTransport()
sdkTransport = &RetryTransport{Base: sdkTransport}
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
return wrapWithExtension(sdkTransport)
}
type credentialDeps struct {
Keychain keychain.KeychainAccess
Keychain func() keychain.KeychainAccess
Profile string
HttpClient func() (*http.Client, error)
ErrOut io.Writer

View File

@@ -6,14 +6,10 @@ package cmdutil
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"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"
@@ -63,7 +59,7 @@ func TestNewDefault_InvocationProfileUsedByStrictModeAndConfig(t *testing.T) {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f := NewDefault(InvocationContext{Profile: "target"})
f := NewDefault(nil, InvocationContext{Profile: "target"})
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeBot {
t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeBot)
}
@@ -103,7 +99,7 @@ func TestNewDefault_InvocationProfileMissingSticksAcrossEarlyStrictMode(t *testi
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f := NewDefault(InvocationContext{Profile: "missing"})
f := NewDefault(nil, InvocationContext{Profile: "missing"})
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeOff)
}
@@ -120,22 +116,6 @@ func TestNewDefault_InvocationProfileMissingSticksAcrossEarlyStrictMode(t *testi
}
}
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := buildSDKTransport()
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
}
ua, ok := sec.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("middle transport type = %T, want *UserAgentTransport", sec.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
}
func TestNewDefault_ResolveAs_UsesDefaultAsFromEnvAccount(t *testing.T) {
t.Setenv(envvars.CliAppID, "env-app")
t.Setenv(envvars.CliAppSecret, "env-secret")
@@ -144,7 +124,7 @@ func TestNewDefault_ResolveAs_UsesDefaultAsFromEnvAccount(t *testing.T) {
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f := NewDefault(InvocationContext{})
f := NewDefault(nil, InvocationContext{})
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(context.Background(), cmd, "auto")
@@ -164,7 +144,7 @@ func TestNewDefault_ConfigReturnsCliConfigCopyOfCredentialAccount(t *testing.T)
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f := NewDefault(InvocationContext{})
f := NewDefault(nil, InvocationContext{})
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -189,7 +169,7 @@ func TestNewDefault_ConfigUsesRuntimePlaceholderForTokenOnlyEnvAccount(t *testin
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f := NewDefault(InvocationContext{})
f := NewDefault(nil, InvocationContext{})
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -217,7 +197,7 @@ func TestNewDefault_FileIOProviderDoesNotResolveDuringInitialization(t *testing.
fileio.Register(provider)
t.Cleanup(func() { fileio.Register(prev) })
f := NewDefault(InvocationContext{})
f := NewDefault(nil, InvocationContext{})
if f.FileIOProvider != provider {
t.Fatalf("NewDefault() provider = %T, want %T", f.FileIOProvider, provider)
}
@@ -232,170 +212,3 @@ func TestNewDefault_FileIOProviderDoesNotResolveDuringInitialization(t *testing.
t.Fatalf("ResolveFileIO() calls after explicit resolve = %d, want 1", provider.resolveCalls)
}
}
type stubTransportProvider struct {
interceptor exttransport.Interceptor
}
func (s *stubTransportProvider) Name() string { return "stub" }
func (s *stubTransportProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
if s.interceptor != nil {
return s.interceptor
}
return &stubTransportImpl{}
}
type stubTransportImpl struct{}
func (s *stubTransportImpl) PreRoundTrip(req *http.Request) func(*http.Response, error) {
return nil
}
// headerCapturingInterceptor sets custom headers in PreRoundTrip and records
// whether PostRoundTrip was called, to verify execution order.
type headerCapturingInterceptor struct {
preCalled bool
postCalled bool
}
func (h *headerCapturingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
h.preCalled = true
// Set a custom header that should survive (no built-in override)
req.Header.Set("X-Custom-Trace", "ext-trace-123")
// Try to override a security header — should be overwritten by SecurityHeaderTransport
req.Header.Set(HeaderSource, "ext-tampered")
return func(resp *http.Response, err error) {
h.postCalled = true
}
}
func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
var receivedHeaders http.Header
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedHeaders = r.Header.Clone()
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
ic := &headerCapturingInterceptor{}
exttransport.Register(&stubTransportProvider{interceptor: ic})
t.Cleanup(func() { exttransport.Register(nil) })
// Use HTTP transport chain (has SecurityHeaderTransport)
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &SecurityHeaderTransport{Base: base}
transport := wrapWithExtension(base)
client := &http.Client{Transport: transport}
req, _ := http.NewRequest("GET", srv.URL, nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
// PreRoundTrip was called
if !ic.preCalled {
t.Fatal("PreRoundTrip was not called")
}
// PostRoundTrip (closure) was called
if !ic.postCalled {
t.Fatal("PostRoundTrip closure was not called")
}
// Custom header set by extension survives (no built-in override)
if got := receivedHeaders.Get("X-Custom-Trace"); got != "ext-trace-123" {
t.Fatalf("X-Custom-Trace = %q, want %q", got, "ext-trace-123")
}
// Security header overridden by extension is restored by SecurityHeaderTransport
if got := receivedHeaders.Get(HeaderSource); got != SourceValue {
t.Fatalf("%s = %q, want %q (built-in should override extension)", HeaderSource, got, SourceValue)
}
}
func TestExtensionInterceptor_ContextTamperPrevented(t *testing.T) {
type ctxKeyType string
const testKey ctxKeyType = "original"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
var ctxValue any
// Use a custom transport that captures the context value seen by the built-in chain
capturer := roundTripFunc(func(req *http.Request) (*http.Response, error) {
ctxValue = req.Context().Value(testKey)
return http.DefaultTransport.RoundTrip(req)
})
// Interceptor that tries to tamper with context
tamperIC := interceptorFunc(func(req *http.Request) func(*http.Response, error) {
// Try to replace context with a new one
*req = *req.WithContext(context.WithValue(req.Context(), testKey, "tampered"))
return nil
})
mid := &extensionMiddleware{Base: capturer, Ext: tamperIC}
origCtx := context.WithValue(context.Background(), testKey, "original")
req, _ := http.NewRequestWithContext(origCtx, "GET", srv.URL, nil)
resp, err := mid.RoundTrip(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
// Built-in chain should see original context, not tampered
if ctxValue != "original" {
t.Fatalf("built-in chain saw context value %q, want %q", ctxValue, "original")
}
}
// interceptorFunc adapts a function to exttransport.Interceptor.
type interceptorFunc func(*http.Request) func(*http.Response, error)
func (f interceptorFunc) PreRoundTrip(req *http.Request) func(*http.Response, error) { return f(req) }
func TestBuildSDKTransport_WithExtension(t *testing.T) {
exttransport.Register(&stubTransportProvider{})
t.Cleanup(func() { exttransport.Register(nil) })
transport := buildSDKTransport()
// Chain: extensionMiddleware → SecurityPolicy → UserAgent → Retry → Base
mid, ok := transport.(*extensionMiddleware)
if !ok {
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
}
sec, ok := mid.Base.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("transport type = %T, want *auth.SecurityPolicyTransport", mid.Base)
}
ua, ok := sec.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("transport type = %T, want *UserAgentTransport", sec.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
}
}
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
exttransport.Register(nil)
transport := buildSDKTransport()
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
}
ua, ok := sec.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("middle transport type = %T, want *UserAgentTransport", sec.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
}

View File

@@ -4,11 +4,12 @@
package cmdutil
import (
"io"
"testing"
)
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
fn := cachedHttpClientFunc()
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
c1, err := fn()
if err != nil {
@@ -28,7 +29,7 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
}
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
fn := cachedHttpClientFunc()
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
c, _ := fn()
if c.Timeout == 0 {
t.Error("expected non-zero timeout")
@@ -36,7 +37,7 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
}
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
fn := cachedHttpClientFunc()
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
c, _ := fn()
if c.CheckRedirect == nil {
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")

View File

@@ -0,0 +1,144 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"bytes"
"fmt"
"io"
"strconv"
"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, formatFormFieldValue(v))
}
}
return fd, nil
}
// formatFormFieldValue renders a JSON-unmarshalled value as a multipart form
// field string. float64 is handled specially: fmt's default %v/%g switches to
// scientific notation for values >= ~1e6 (e.g. "1.185356e+06"), which some
// backends reject when parsing the field as an integer. Use decimal notation
// instead so size / block_num / offset-style numeric fields round-trip cleanly.
// All other types fall through to %v.
func formatFormFieldValue(v any) string {
if n, ok := v.(float64); ok {
return strconv.FormatFloat(n, 'f', -1, 64)
}
return fmt.Sprintf("%v", v)
}

View File

@@ -0,0 +1,375 @@
// 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")
}
})
}
// TestFormatFormFieldValue locks in the fix for the float64 -> scientific
// notation bug. JSON numbers unmarshal to float64, and fmt's default %v for
// float64 delegates to %g which switches to scientific notation at ~1e6
// (e.g. 1185356 -> "1.185356e+06"). Backends that parse the form field as an
// integer reject that, surfacing as a generic "params error".
func TestFormatFormFieldValue(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in any
want string
}{
{"float64 large integer avoids scientific", float64(1185356), "1185356"},
{"float64 below scientific threshold", float64(358934), "358934"},
{"float64 zero", float64(0), "0"},
{"float64 huge", float64(20 * 1024 * 1024), "20971520"},
{"float64 negative", float64(-42), "-42"},
{"float64 fractional preserved", float64(3.14), "3.14"},
{"string pass-through", "hello", "hello"},
{"bool true", true, "true"},
{"int via %v", 42, "42"},
{"int64 via %v", int64(9007199254740992), "9007199254740992"},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := formatFormFieldValue(tt.in)
if got != tt.want {
t.Fatalf("formatFormFieldValue(%v) = %q, want %q", tt.in, got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"fmt"
"strings"
"github.com/spf13/cobra"
)
// AddAPIIdentityFlag registers the standard --as flag shape used by api/service commands.
func AddAPIIdentityFlag(ctx context.Context, cmd *cobra.Command, f *Factory, target *string) {
addIdentityFlag(ctx, cmd, f, target, identityFlagConfig{
defaultValue: "auto",
usage: "identity type: user | bot | auto (default)",
completionValues: []string{"user", "bot"},
})
}
// AddShortcutIdentityFlag registers the standard --as flag shape used by shortcuts.
func AddShortcutIdentityFlag(ctx context.Context, cmd *cobra.Command, f *Factory, authTypes []string) {
if len(authTypes) == 0 {
authTypes = []string{"user"}
}
addIdentityFlag(ctx, cmd, f, nil, identityFlagConfig{
defaultValue: authTypes[0],
usage: "identity type: " + strings.Join(authTypes, " | "),
completionValues: authTypes,
})
}
type identityFlagConfig struct {
defaultValue string
usage string
completionValues []string
}
// addIdentityFlag centralizes --as registration and strict-mode UX.
// When strict mode is active, the flag is still accepted for compatibility
// but hidden from help/completion and locked to the forced identity by default.
func addIdentityFlag(ctx context.Context, cmd *cobra.Command, f *Factory, target *string, cfg identityFlagConfig) {
if forced := f.ResolveStrictMode(ctx).ForcedIdentity(); forced != "" {
// Keep registering --as in strict mode even though it is hidden.
// This preserves parser compatibility for existing invocations that still pass
// --as, and keeps downstream GetString("as") / ResolveAs paths stable.
// The usage text below is effectively placeholder text because the flag is hidden.
registerIdentityFlag(cmd, target, string(forced),
fmt.Sprintf("identity locked to %s by strict mode (admin-managed)", forced))
_ = cmd.Flags().MarkHidden("as")
return
}
registerIdentityFlag(cmd, target, cfg.defaultValue, cfg.usage)
RegisterFlagCompletion(cmd, "as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return cfg.completionValues, cobra.ShellCompDirectiveNoFileComp
})
}
func registerIdentityFlag(cmd *cobra.Command, target *string, defaultValue, usage string) {
if target != nil {
cmd.Flags().StringVar(target, "as", defaultValue, usage)
return
}
cmd.Flags().String("as", defaultValue, usage)
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/cobra"
)
func TestAddAPIIdentityFlag_NonStrictMode(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := &cobra.Command{Use: "test"}
AddAPIIdentityFlag(context.Background(), cmd, f, nil)
flag := cmd.Flags().Lookup("as")
if flag == nil {
t.Fatal("expected --as flag to be registered")
}
if flag.Hidden {
t.Fatal("expected --as flag to be visible outside strict mode")
}
if got := flag.DefValue; got != "auto" {
t.Fatalf("default value = %q, want %q", got, "auto")
}
}
func TestAddAPIIdentityFlag_StrictModeHidesFlagAndLocksDefault(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{
AppID: "a", AppSecret: "s", SupportedIdentities: 2,
})
cmd := &cobra.Command{Use: "test"}
AddAPIIdentityFlag(context.Background(), cmd, f, nil)
flag := cmd.Flags().Lookup("as")
if flag == nil {
t.Fatal("expected --as flag to be registered")
}
if !flag.Hidden {
t.Fatal("expected --as flag to be hidden in strict mode")
}
if got := flag.DefValue; got != "bot" {
t.Fatalf("default value = %q, want %q", got, "bot")
}
}
func TestAddShortcutIdentityFlag_UsesAuthTypes(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := &cobra.Command{Use: "test"}
AddShortcutIdentityFlag(context.Background(), cmd, f, []string{"bot"})
flag := cmd.Flags().Lookup("as")
if flag == nil {
t.Fatal("expected --as flag to be registered")
}
if flag.Hidden {
t.Fatal("expected --as flag to be visible outside strict mode")
}
if got := flag.DefValue; got != "bot" {
t.Fatalf("default value = %q, want %q", got, "bot")
}
}

View File

@@ -3,7 +3,12 @@
package cmdutil
import "io"
import (
"io"
"os"
"golang.org/x/term"
)
// IOStreams provides the standard input/output/error streams.
// Commands should use these instead of os.Stdin/Stdout/Stderr
@@ -14,3 +19,45 @@ type IOStreams struct {
ErrOut io.Writer
IsTerminal bool
}
// NewIOStreams builds an IOStreams from arbitrary readers/writers.
// IsTerminal is derived from in's underlying *os.File, if any; non-file
// readers (bytes.Buffer, strings.Reader, …) yield IsTerminal=false.
func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
isTerminal := false
if f, ok := in.(*os.File); ok {
isTerminal = term.IsTerminal(int(f.Fd()))
}
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal}
}
// SystemIO creates an IOStreams wired to the process's standard file descriptors.
//
//nolint:forbidigo // entry point for real stdio
func SystemIO() *IOStreams {
return NewIOStreams(os.Stdin, os.Stdout, os.Stderr)
}
// normalizeStreams returns a fresh IOStreams with any nil field filled from
// SystemIO(). Callers constructing a partial struct like &IOStreams{Out: buf}
// get a usable result without nil writers leaking into RoundTripper warnings,
// Cobra I/O, or credential-provider error paths.
func normalizeStreams(s *IOStreams) *IOStreams {
if s == nil {
return SystemIO()
}
out := *s
if out.In == nil || out.Out == nil || out.ErrOut == nil {
sys := SystemIO()
if out.In == nil {
out.In = sys.In
}
if out.Out == nil {
out.Out = sys.Out
}
if out.ErrOut == nil {
out.ErrOut = sys.ErrOut
}
}
return &out
}

View File

@@ -1,81 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"io"
"net/http"
"strings"
"testing"
"time"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestRetryTransport_NoRetry(t *testing.T) {
calls := 0
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))}, nil
})
rt := &RetryTransport{Base: base, MaxRetries: 0}
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
if calls != 1 {
t.Errorf("expected 1 call, got %d", calls)
}
}
func TestRetryTransport_RetryOn500(t *testing.T) {
calls := 0
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
if calls < 3 {
return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("error"))}, nil
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))}, nil
})
rt := &RetryTransport{Base: base, MaxRetries: 3, Delay: 1 * time.Millisecond}
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200 after retries, got %d", resp.StatusCode)
}
if calls != 3 {
t.Errorf("expected 3 calls, got %d", calls)
}
}
func TestRetryTransport_DefaultNoRetry(t *testing.T) {
calls := 0
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("error"))}, nil
})
rt := &RetryTransport{Base: base} // default MaxRetries=0
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 500 {
t.Errorf("expected 500 with no retries, got %d", resp.StatusCode)
}
if calls != 1 {
t.Errorf("expected 1 call with default config, got %d", calls)
}
}

View File

@@ -6,7 +6,14 @@ package cmdutil
import (
"context"
"net/http"
"reflect"
"runtime/debug"
"strings"
"sync"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/build"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
@@ -14,12 +21,21 @@ import (
const (
HeaderSource = "X-Cli-Source"
HeaderVersion = "X-Cli-Version"
HeaderBuild = "X-Cli-Build"
HeaderShortcut = "X-Cli-Shortcut"
HeaderExecutionId = "X-Cli-Execution-Id"
SourceValue = "lark-cli"
HeaderUserAgent = "User-Agent"
// BuildKindOfficial / BuildKindExtended / BuildKindUnknown are the values
// reported in the X-Cli-Build header; see DetectBuildKind for semantics.
BuildKindOfficial = "official"
BuildKindExtended = "extended"
BuildKindUnknown = "unknown"
officialModulePath = "github.com/larksuite/cli"
)
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
@@ -32,10 +48,108 @@ func BaseSecurityHeaders() http.Header {
h := make(http.Header)
h.Set(HeaderSource, SourceValue)
h.Set(HeaderVersion, build.Version)
h.Set(HeaderBuild, DetectBuildKind())
h.Set(HeaderUserAgent, UserAgentValue())
return h
}
var (
buildKindOnce sync.Once
buildKindVal string
)
// DetectBuildKind reports whether this binary is the official CLI, an
// extended/repackaged build, or unknown. The result is cached via sync.Once
// so it is computed only on the first call.
//
// IMPORTANT: must NOT be called from any package init(). Go's init ordering
// follows the import graph; ISV providers registered via blank import may not
// have run yet, which would misclassify an extended build as official. Call
// only when handling an actual request (e.g. from BaseSecurityHeaders).
func DetectBuildKind() string {
buildKindOnce.Do(func() {
buildKindVal = computeBuildKind()
})
return buildKindVal
}
// computeBuildKind performs the actual detection without any caching.
// Exposed for tests. Gathers runtime/global inputs and delegates the pure
// branching logic to classifyBuild so that logic can be unit-tested without
// mutating process-wide provider registries.
func computeBuildKind() string {
info, ok := debug.ReadBuildInfo()
mainPath := ""
if ok {
mainPath = info.Main.Path
}
credProviders := credential.Providers()
creds := make([]any, len(credProviders))
for i, p := range credProviders {
creds[i] = p
}
var tp any
if p := exttransport.GetProvider(); p != nil {
tp = p
}
var fp any
if p := fileio.GetProvider(); p != nil {
fp = p
}
return classifyBuild(mainPath, ok, creds, tp, fp)
}
// classifyBuild is the pure classification logic used by computeBuildKind.
// Callers supply concrete values so every branch is reachable from tests
// without touching debug.ReadBuildInfo or the extension registries.
//
// Priority order mirrors the design doc:
// 1. no build info → unknown
// 2. main module path not the official one → extended (ISV wrapper)
// 3. any non-builtin provider (credential / transport / fileio) → extended
// 4. otherwise → official
func classifyBuild(mainPath string, haveBuildInfo bool, credProviders []any, transportProvider, fileioProvider any) string {
if !haveBuildInfo {
return BuildKindUnknown
}
if mainPath != "" && mainPath != officialModulePath {
return BuildKindExtended
}
for _, p := range credProviders {
if !isBuiltinProvider(p) {
return BuildKindExtended
}
}
if transportProvider != nil && !isBuiltinProvider(transportProvider) {
return BuildKindExtended
}
if fileioProvider != nil && !isBuiltinProvider(fileioProvider) {
return BuildKindExtended
}
return BuildKindOfficial
}
// isBuiltinProvider reports whether p is declared under the official module
// path. Third-party providers live under their own module and fail this check.
// Using reflect.PkgPath makes this robust against Name() spoofing since
// package paths are fixed at compile time.
func isBuiltinProvider(p any) bool {
if p == nil {
return false
}
t := reflect.TypeOf(p)
if t == nil {
return false
}
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
pkg := t.PkgPath()
return pkg == officialModulePath || strings.HasPrefix(pkg, officialModulePath+"/")
}
// ── Context utilities ──
type ctxKey string

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
package cmdutil
import (
"testing"
sidecarcred "github.com/larksuite/cli/extension/credential/sidecar"
sidecartrans "github.com/larksuite/cli/extension/transport/sidecar"
)
// TestIsBuiltinProvider_SidecarProviders locks the classification for the
// sidecar-mode providers enumerated in design doc §3.3.2 as "官方自带". These
// types only compile when the `authsidecar` build tag is active, so the test
// is guarded by the same tag.
func TestIsBuiltinProvider_SidecarProviders(t *testing.T) {
cases := []struct {
name string
provider any
}{
{"sidecar credential provider", &sidecarcred.Provider{}},
{"sidecar transport provider", &sidecartrans.Provider{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if !isBuiltinProvider(tc.provider) {
t.Fatalf("%T must be classified as builtin (PkgPath under %s)", tc.provider, officialModulePath)
}
})
}
}

View File

@@ -0,0 +1,262 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"net/http"
"testing"
"github.com/larksuite/cli/extension/credential"
envcred "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
// ---------------------------------------------------------------------------
// isBuiltinProvider
// ---------------------------------------------------------------------------
// cmdutilLocalProvider has PkgPath under the official module
// ("github.com/larksuite/cli/internal/cmdutil") and should be classified
// as builtin.
type cmdutilLocalProvider struct{}
// Name intentionally returns a value that mimics an external provider; the
// PkgPath-based classifier must ignore it. See TestIsBuiltinProvider_PkgPathNotSpoofableByName.
func (cmdutilLocalProvider) Name() string { return "external-spoofed-provider" }
func (cmdutilLocalProvider) ResolveAccount(context.Context) (*credential.Account, error) {
return nil, nil
}
func (cmdutilLocalProvider) ResolveToken(context.Context, credential.TokenSpec) (*credential.Token, error) {
return nil, nil
}
func TestIsBuiltinProvider_Nil(t *testing.T) {
if isBuiltinProvider(nil) {
t.Fatal("isBuiltinProvider(nil) = true, want false")
}
}
func TestIsBuiltinProvider_TypeUnderOfficialModule(t *testing.T) {
if !isBuiltinProvider(&cmdutilLocalProvider{}) {
t.Fatal("type under github.com/larksuite/cli/... should be builtin")
}
}
func TestIsBuiltinProvider_StdlibTypeIsNotBuiltin(t *testing.T) {
// A standard library type has PkgPath "net/http" — outside official module.
// This covers the non-builtin branch, which we cannot trigger from inside
// this test file using a locally-defined type.
if isBuiltinProvider(&http.Server{}) {
t.Fatal("stdlib type classified as builtin, PkgPath check is broken")
}
}
func TestIsBuiltinProvider_PkgPathNotSpoofableByName(t *testing.T) {
// Name() returns a string, but classification uses reflect.Type.PkgPath
// which is compile-time fixed. The local type returns a name that looks
// like an ISV provider; it must still classify as builtin.
p := &cmdutilLocalProvider{}
if p.Name() != "external-spoofed-provider" {
t.Fatalf("sanity check: Name() = %q, spoof value lost", p.Name())
}
if !isBuiltinProvider(p) {
t.Fatal("isBuiltinProvider should decide by PkgPath, not Name()")
}
}
// TestIsBuiltinProvider_NonPointerValues covers the non-pointer reflect branch.
// The existing tests only exercise pointer receivers (&T{}); when a provider
// is passed by value the reflect.Kind is not Ptr and t.Elem() is skipped.
func TestIsBuiltinProvider_NonPointerValues(t *testing.T) {
if !isBuiltinProvider(cmdutilLocalProvider{}) {
t.Fatal("non-pointer local type should be builtin (PkgPath still under official module)")
}
// http.Server as a non-pointer — PkgPath "net/http", not under official.
if isBuiltinProvider(http.Server{}) {
t.Fatal("non-pointer stdlib type should not be builtin")
}
}
// TestIsBuiltinProvider_RealBuiltinProviders locks down the classification
// for the concrete providers enumerated in design doc §3.3.2 as "官方自带":
// env credential provider and local fileio provider. If any of these is
// moved out of the official module tree in the future, this test must flip
// red so the new package path is explicitly considered.
//
// The sidecar providers (extension/credential/sidecar and
// extension/transport/sidecar) are guarded by the `authsidecar` build tag
// and covered in secheader_sidecar_test.go under that tag.
func TestIsBuiltinProvider_RealBuiltinProviders(t *testing.T) {
cases := []struct {
name string
provider any
}{
{"env credential provider", &envcred.Provider{}},
{"local fileio provider", &localfileio.Provider{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if !isBuiltinProvider(tc.provider) {
t.Fatalf("%T must be classified as builtin (PkgPath under %s)", tc.provider, officialModulePath)
}
})
}
}
// ---------------------------------------------------------------------------
// computeBuildKind
// ---------------------------------------------------------------------------
func TestComputeBuildKind_ReturnsKnownValue(t *testing.T) {
// Under `go test`, Main.Path is typically the module being tested
// ("github.com/larksuite/cli"); the concrete return may still be
// official, extended, or unknown depending on Main.Path and the
// registered providers. Just assert it's one of the defined values.
got := computeBuildKind()
switch got {
case BuildKindOfficial, BuildKindExtended, BuildKindUnknown:
default:
t.Fatalf("computeBuildKind() = %q, want one of official/extended/unknown", got)
}
}
// ---------------------------------------------------------------------------
// classifyBuild — pure branching logic
// ---------------------------------------------------------------------------
//
// These tests cover every branch of classifyBuild with explicit inputs,
// which is impossible from computeBuildKind alone because debug.ReadBuildInfo
// and the process-wide provider registries can't be reshaped in a test.
func TestClassifyBuild_NoBuildInfo_ReturnsUnknown(t *testing.T) {
if got := classifyBuild("", false, nil, nil, nil); got != BuildKindUnknown {
t.Fatalf("classifyBuild(haveBuildInfo=false) = %q, want %q", got, BuildKindUnknown)
}
}
func TestClassifyBuild_ExtendedMainPath_ReturnsExtended(t *testing.T) {
cases := []string{
"github.com/acme/lark-cli-wrapper",
"example.com/isv/lark",
"gitlab.mycorp.internal/tools/lark-cli-fork",
}
for _, mp := range cases {
t.Run(mp, func(t *testing.T) {
if got := classifyBuild(mp, true, nil, nil, nil); got != BuildKindExtended {
t.Fatalf("mainPath=%q classifyBuild = %q, want %q", mp, got, BuildKindExtended)
}
})
}
}
func TestClassifyBuild_OfficialMainPath_NoProviders_ReturnsOfficial(t *testing.T) {
if got := classifyBuild(officialModulePath, true, nil, nil, nil); got != BuildKindOfficial {
t.Fatalf("classifyBuild(official, no providers) = %q, want %q", got, BuildKindOfficial)
}
}
func TestClassifyBuild_EmptyMainPath_DoesNotTriggerExtended(t *testing.T) {
// An empty Main.Path (rare, e.g. `go run` pre-1.18) must not be treated
// as extended by itself — the classifier falls through to provider checks.
if got := classifyBuild("", true, nil, nil, nil); got != BuildKindOfficial {
t.Fatalf("classifyBuild(empty mainPath, no providers) = %q, want %q", got, BuildKindOfficial)
}
}
func TestClassifyBuild_NonBuiltinCredentialProvider_ReturnsExtended(t *testing.T) {
// Any non-builtin credential provider flips the verdict to extended.
got := classifyBuild(officialModulePath, true, []any{&http.Server{}}, nil, nil)
if got != BuildKindExtended {
t.Fatalf("classifyBuild with external credential = %q, want %q", got, BuildKindExtended)
}
}
func TestClassifyBuild_MixedCredentialProviders_ExtendedWins(t *testing.T) {
// Even if most providers are builtin, a single external one decides.
providers := []any{&cmdutilLocalProvider{}, &http.Server{}}
if got := classifyBuild(officialModulePath, true, providers, nil, nil); got != BuildKindExtended {
t.Fatalf("classifyBuild mixed providers = %q, want %q", got, BuildKindExtended)
}
}
func TestClassifyBuild_NonBuiltinTransportProvider_ReturnsExtended(t *testing.T) {
got := classifyBuild(officialModulePath, true, nil, &http.Server{}, nil)
if got != BuildKindExtended {
t.Fatalf("classifyBuild with external transport = %q, want %q", got, BuildKindExtended)
}
}
func TestClassifyBuild_NonBuiltinFileioProvider_ReturnsExtended(t *testing.T) {
got := classifyBuild(officialModulePath, true, nil, nil, &http.Server{})
if got != BuildKindExtended {
t.Fatalf("classifyBuild with external fileio = %q, want %q", got, BuildKindExtended)
}
}
func TestClassifyBuild_AllBuiltinProviders_ReturnsOfficial(t *testing.T) {
// All three slots filled with builtin providers must still classify as official.
got := classifyBuild(
officialModulePath, true,
[]any{&cmdutilLocalProvider{}},
&cmdutilLocalProvider{},
&cmdutilLocalProvider{},
)
if got != BuildKindOfficial {
t.Fatalf("classifyBuild all-builtin = %q, want %q", got, BuildKindOfficial)
}
}
// TestClassifyBuild_MainPathPriorityOverProviders documents that the main
// module path takes precedence: even with only builtin providers, a non-
// official main path still yields extended.
func TestClassifyBuild_MainPathPriorityOverProviders(t *testing.T) {
got := classifyBuild(
"github.com/acme/lark-wrapper", true,
[]any{&cmdutilLocalProvider{}},
&cmdutilLocalProvider{},
&cmdutilLocalProvider{},
)
if got != BuildKindExtended {
t.Fatalf("main-path override failed: got %q, want %q", got, BuildKindExtended)
}
}
// ---------------------------------------------------------------------------
// DetectBuildKind — sync.Once caching
// ---------------------------------------------------------------------------
func TestDetectBuildKind_StableAcrossCalls(t *testing.T) {
a := DetectBuildKind()
b := DetectBuildKind()
if a != b {
t.Fatalf("DetectBuildKind() returned different values on repeat: %q vs %q", a, b)
}
}
// ---------------------------------------------------------------------------
// BaseSecurityHeaders
// ---------------------------------------------------------------------------
func TestBaseSecurityHeaders_IncludesBuildHeader(t *testing.T) {
h := BaseSecurityHeaders()
v := h.Get(HeaderBuild)
if v == "" {
t.Fatal("BaseSecurityHeaders missing X-Cli-Build header")
}
switch v {
case BuildKindOfficial, BuildKindExtended, BuildKindUnknown:
default:
t.Fatalf("X-Cli-Build = %q, want one of official/extended/unknown", v)
}
}
func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
h := BaseSecurityHeaders()
for _, key := range []string{HeaderSource, HeaderVersion, HeaderBuild, HeaderUserAgent} {
if h.Get(key) == "" {
t.Errorf("BaseSecurityHeaders missing %s", key)
}
}
}

View File

@@ -72,6 +72,24 @@ func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
return util.FallbackTransport().RoundTrip(req)
}
// BuildHeaderTransport is an http.RoundTripper that force-writes the
// X-Cli-Build header before every request. Used in the SDK transport chain,
// where SecurityHeaderTransport is not installed, to prevent extensions from
// tampering with the build classification. The direct HTTP chain is already
// covered by SecurityHeaderTransport iterating BaseSecurityHeaders.
type BuildHeaderTransport struct {
Base http.RoundTripper
}
func (t *BuildHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set(HeaderBuild, DetectBuildKind())
if t.Base != nil {
return t.Base.RoundTrip(req)
}
return util.FallbackTransport().RoundTrip(req)
}
// SecurityHeaderTransport is an http.RoundTripper that injects CLI security
// headers into every request. Shortcut headers are read from the request context.
type SecurityHeaderTransport struct {
@@ -104,20 +122,47 @@ func (t *SecurityHeaderTransport) RoundTrip(req *http.Request) (*http.Response,
}
// extensionMiddleware wraps the built-in transport chain with pre/post hooks.
// The built-in chain always executes and cannot be skipped or overridden.
// The original request context is restored after PreRoundTrip to prevent
// The built-in chain always executes unless the extension is an
// exttransport.AbortableInterceptor and its PreRoundTripE returns a non-nil
// error; it cannot otherwise be skipped or overridden.
//
// The original request context is restored after the pre hook to prevent
// extensions from tampering with cancellation, deadlines, or built-in values.
// Cloning the request isolates header/URL/etc. mutations from the caller's
// request object; req.Body is intentionally shared — extensions that consume
// it are responsible for rewinding (see Interceptor doc).
type extensionMiddleware struct {
Base http.RoundTripper
Ext exttransport.Interceptor
Base http.RoundTripper
Ext exttransport.Interceptor
ExtName string // Provider.Name(), captured at wrap time for *AbortError.Extension
}
// RoundTrip calls PreRoundTrip, restores the original context, executes
// the built-in chain, then calls the post hook if non-nil.
// RoundTrip invokes the interceptor pre hook, restores the original context,
// executes the built-in chain (unless aborted), then calls the post hook if
// non-nil. When the extension implements AbortableInterceptor and returns a
// non-nil error from PreRoundTripE, the built-in chain is skipped and an
// *exttransport.AbortError is returned; the post hook is still invoked with
// (nil, reason) so extensions can unwind resources.
func (m *extensionMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
origCtx := req.Context()
req = req.Clone(origCtx) // isolate caller's request before extension mutations
post := m.Ext.PreRoundTrip(req)
req = req.Clone(origCtx)
var (
post func(*http.Response, error)
abortEr error
)
if a, ok := m.Ext.(exttransport.AbortableInterceptor); ok {
post, abortEr = a.PreRoundTripE(req)
} else {
post = m.Ext.PreRoundTrip(req)
}
if abortEr != nil {
if post != nil {
post(nil, abortEr)
}
return nil, &exttransport.AbortError{Extension: m.ExtName, Reason: abortEr}
}
req = req.WithContext(origCtx) // restore original context
resp, err := m.Base.RoundTrip(req)
if post != nil {
@@ -137,5 +182,5 @@ func wrapWithExtension(transport http.RoundTripper) http.RoundTripper {
if tr == nil {
return transport
}
return &extensionMiddleware{Base: transport, Ext: tr}
return &extensionMiddleware{Base: transport, Ext: tr, ExtName: p.Name()}
}

View File

@@ -0,0 +1,531 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
exttransport "github.com/larksuite/cli/extension/transport"
internalauth "github.com/larksuite/cli/internal/auth"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
// ---------------------------------------------------------------------------
// RetryTransport
// ---------------------------------------------------------------------------
func TestRetryTransport_NoRetry(t *testing.T) {
calls := 0
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))}, nil
})
rt := &RetryTransport{Base: base, MaxRetries: 0}
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
if calls != 1 {
t.Errorf("expected 1 call, got %d", calls)
}
}
func TestRetryTransport_RetryOn500(t *testing.T) {
calls := 0
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
if calls < 3 {
return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("error"))}, nil
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))}, nil
})
rt := &RetryTransport{Base: base, MaxRetries: 3, Delay: 1 * time.Millisecond}
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200 after retries, got %d", resp.StatusCode)
}
if calls != 3 {
t.Errorf("expected 3 calls, got %d", calls)
}
}
func TestRetryTransport_DefaultNoRetry(t *testing.T) {
calls := 0
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("error"))}, nil
})
rt := &RetryTransport{Base: base} // default MaxRetries=0
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 500 {
t.Errorf("expected 500 with no retries, got %d", resp.StatusCode)
}
if calls != 1 {
t.Errorf("expected 1 call with default config, got %d", calls)
}
}
// ---------------------------------------------------------------------------
// buildSDKTransport chain composition
// ---------------------------------------------------------------------------
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := buildSDKTransport()
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
}
bh, ok := sec.Base.(*BuildHeaderTransport)
if !ok {
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
}
ua, ok := bh.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
}
func TestBuildSDKTransport_WithExtension(t *testing.T) {
exttransport.Register(&stubTransportProvider{})
t.Cleanup(func() { exttransport.Register(nil) })
transport := buildSDKTransport()
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → Base
mid, ok := transport.(*extensionMiddleware)
if !ok {
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
}
sec, ok := mid.Base.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("transport type = %T, want *auth.SecurityPolicyTransport", mid.Base)
}
bh, ok := sec.Base.(*BuildHeaderTransport)
if !ok {
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
}
ua, ok := bh.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
}
}
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
exttransport.Register(nil)
transport := buildSDKTransport()
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
}
bh, ok := sec.Base.(*BuildHeaderTransport)
if !ok {
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
}
ua, ok := bh.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
}
// ---------------------------------------------------------------------------
// extensionMiddleware — legacy Interceptor path
// ---------------------------------------------------------------------------
type stubTransportProvider struct {
interceptor exttransport.Interceptor
}
func (s *stubTransportProvider) Name() string { return "stub" }
func (s *stubTransportProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
if s.interceptor != nil {
return s.interceptor
}
return &stubTransportImpl{}
}
type stubTransportImpl struct{}
func (s *stubTransportImpl) PreRoundTrip(req *http.Request) func(*http.Response, error) {
return nil
}
// headerCapturingInterceptor sets custom headers in PreRoundTrip and records
// whether PostRoundTrip was called, to verify execution order.
type headerCapturingInterceptor struct {
preCalled bool
postCalled bool
}
func (h *headerCapturingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
h.preCalled = true
// Set a custom header that should survive (no built-in override)
req.Header.Set("X-Custom-Trace", "ext-trace-123")
// Try to override a security header — should be overwritten by SecurityHeaderTransport
req.Header.Set(HeaderSource, "ext-tampered")
return func(resp *http.Response, err error) {
h.postCalled = true
}
}
func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
var receivedHeaders http.Header
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedHeaders = r.Header.Clone()
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
ic := &headerCapturingInterceptor{}
exttransport.Register(&stubTransportProvider{interceptor: ic})
t.Cleanup(func() { exttransport.Register(nil) })
// Use HTTP transport chain (has SecurityHeaderTransport)
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &SecurityHeaderTransport{Base: base}
transport := wrapWithExtension(base)
client := &http.Client{Transport: transport}
req, _ := http.NewRequest("GET", srv.URL, nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
// PreRoundTrip was called
if !ic.preCalled {
t.Fatal("PreRoundTrip was not called")
}
// PostRoundTrip (closure) was called
if !ic.postCalled {
t.Fatal("PostRoundTrip closure was not called")
}
// Custom header set by extension survives (no built-in override)
if got := receivedHeaders.Get("X-Custom-Trace"); got != "ext-trace-123" {
t.Fatalf("X-Custom-Trace = %q, want %q", got, "ext-trace-123")
}
// Security header overridden by extension is restored by SecurityHeaderTransport
if got := receivedHeaders.Get(HeaderSource); got != SourceValue {
t.Fatalf("%s = %q, want %q (built-in should override extension)", HeaderSource, got, SourceValue)
}
}
// buildTamperingInterceptor tries to delete and spoof X-Cli-Build via
// PreRoundTrip. The SDK chain's BuildHeaderTransport must restore the real
// value before the request leaves the process.
type buildTamperingInterceptor struct{}
func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
req.Header.Del(HeaderBuild)
req.Header.Set(HeaderBuild, "ext-tampered-build")
return nil
}
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
// transport chain, even when an extension tries to delete or spoof it. This
// closes the gap where the SDK chain had no equivalent of
// SecurityHeaderTransport (see design doc §3.3.3).
func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
var receivedBuild string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedBuild = r.Header.Get(HeaderBuild)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(nil) })
// Replicate the SDK chain layering used by buildSDKTransport.
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &UserAgentTransport{Base: base}
base = &BuildHeaderTransport{Base: base}
transport := wrapWithExtension(base)
client := &http.Client{Transport: transport}
req, _ := http.NewRequest("GET", srv.URL, nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
if receivedBuild == "ext-tampered-build" {
t.Fatalf("%s = %q, extension tampering leaked to network", HeaderBuild, receivedBuild)
}
want := DetectBuildKind()
if receivedBuild != want {
t.Fatalf("%s = %q, want %q", HeaderBuild, receivedBuild, want)
}
}
// TestBuildHeaderTransport_OverridesEvenWithoutTamper verifies that even if
// no extension is registered, BuildHeaderTransport writes X-Cli-Build.
func TestBuildHeaderTransport_OverridesEvenWithoutTamper(t *testing.T) {
var receivedBuild string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedBuild = r.Header.Get(HeaderBuild)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
transport := &BuildHeaderTransport{Base: http.DefaultTransport}
client := &http.Client{Transport: transport}
req, _ := http.NewRequest("GET", srv.URL, nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
if receivedBuild == "" {
t.Fatalf("%s header missing, BuildHeaderTransport did not inject", HeaderBuild)
}
want := DetectBuildKind()
if receivedBuild != want {
t.Fatalf("%s = %q, want %q", HeaderBuild, receivedBuild, want)
}
}
// TestBuildHeaderTransport_NilBase_UsesFallback verifies that when Base is nil,
// the transport still sets X-Cli-Build and routes the request through
// util.FallbackTransport rather than panicking. This covers the fallback
// branch in RoundTrip that is otherwise unreachable with a non-nil Base.
func TestBuildHeaderTransport_NilBase_UsesFallback(t *testing.T) {
var receivedBuild string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedBuild = r.Header.Get(HeaderBuild)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
transport := &BuildHeaderTransport{Base: nil}
client := &http.Client{Transport: transport}
req, _ := http.NewRequest("GET", srv.URL, nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request via nil-Base transport failed: %v", err)
}
resp.Body.Close()
want := DetectBuildKind()
if receivedBuild != want {
t.Fatalf("%s = %q, want %q (header must be set even on nil-Base path)",
HeaderBuild, receivedBuild, want)
}
}
// interceptorFunc adapts a function to exttransport.Interceptor.
type interceptorFunc func(*http.Request) func(*http.Response, error)
func (f interceptorFunc) PreRoundTrip(req *http.Request) func(*http.Response, error) { return f(req) }
func TestExtensionInterceptor_ContextTamperPrevented(t *testing.T) {
type ctxKeyType string
const testKey ctxKeyType = "original"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
var ctxValue any
// Use a custom transport that captures the context value seen by the built-in chain
capturer := roundTripFunc(func(req *http.Request) (*http.Response, error) {
ctxValue = req.Context().Value(testKey)
return http.DefaultTransport.RoundTrip(req)
})
// Interceptor that tries to tamper with context
tamperIC := interceptorFunc(func(req *http.Request) func(*http.Response, error) {
// Try to replace context with a new one
*req = *req.WithContext(context.WithValue(req.Context(), testKey, "tampered"))
return nil
})
mid := &extensionMiddleware{Base: capturer, Ext: tamperIC}
origCtx := context.WithValue(context.Background(), testKey, "original")
req, _ := http.NewRequestWithContext(origCtx, "GET", srv.URL, nil)
resp, err := mid.RoundTrip(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
// Built-in chain should see original context, not tampered
if ctxValue != "original" {
t.Fatalf("built-in chain saw context value %q, want %q", ctxValue, "original")
}
}
// ---------------------------------------------------------------------------
// extensionMiddleware — PreRoundTripE abort path
// ---------------------------------------------------------------------------
// abortingInterceptor implements exttransport.AbortableInterceptor and
// records invocation of the pre and post hooks. These middleware tests only
// assert middleware-level integration; pure *AbortError behavior
// (Error/Unwrap/Is/As) is covered in extension/transport/errors_test.go.
type abortingInterceptor struct {
reason error // if non-nil, PreRoundTripE returns this to abort
nilPost bool // if true, PreRoundTripE returns a nil post func
preECalled bool
postCalled bool
postResp *http.Response
postErr error
}
// PreRoundTrip is a no-op that satisfies the legacy Interceptor method; the
// middleware never calls it when PreRoundTripE is present.
func (*abortingInterceptor) PreRoundTrip(*http.Request) func(*http.Response, error) {
return nil
}
func (a *abortingInterceptor) PreRoundTripE(req *http.Request) (func(*http.Response, error), error) {
a.preECalled = true
if a.nilPost {
return nil, a.reason
}
return func(resp *http.Response, err error) {
a.postCalled = true
a.postResp = resp
a.postErr = err
}, a.reason
}
func TestExtensionMiddleware_PreRoundTripEAbort(t *testing.T) {
innerErr := errors.New("denied by policy")
t.Run("skips base and wires AbortError fields", func(t *testing.T) {
ic := &abortingInterceptor{reason: innerErr}
baseCalls := 0
base := roundTripFunc(func(*http.Request) (*http.Response, error) {
baseCalls++
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
resp, err := mid.RoundTrip(req)
if resp != nil {
t.Fatalf("resp = %v, want nil on abort", resp)
}
if baseCalls != 0 {
t.Fatalf("base RoundTrip called %d times on abort, want 0", baseCalls)
}
if !ic.preECalled {
t.Fatal("PreRoundTripE was not called")
}
var aErr *exttransport.AbortError
if !errors.As(err, &aErr) {
t.Fatalf("errors.As(*AbortError) = false, err = %v (%T)", err, err)
}
if aErr.Extension != "stub" || aErr.Reason != innerErr {
t.Fatalf("AbortError = %+v, want {Extension:stub Reason:%v}", aErr, innerErr)
}
// Post must see the original inner err, not the *AbortError wrapper.
if !ic.postCalled {
t.Fatal("post hook was not called on abort")
}
if ic.postResp != nil {
t.Fatalf("post resp = %v, want nil", ic.postResp)
}
if ic.postErr != innerErr {
t.Fatalf("post err = %v, want original inner err %v", ic.postErr, innerErr)
}
})
t.Run("nil post still returns AbortError", func(t *testing.T) {
ic := &abortingInterceptor{reason: innerErr, nilPost: true}
base := roundTripFunc(func(*http.Request) (*http.Response, error) {
t.Fatal("base must not be called on abort")
return nil, nil
})
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
_, err := mid.RoundTrip(req)
var aErr *exttransport.AbortError
if !errors.As(err, &aErr) {
t.Fatalf("errors.As(*AbortError) = false, err = %v", err)
}
})
}
func TestExtensionMiddleware_PreRoundTripEHappyPath(t *testing.T) {
ic := &abortingInterceptor{} // reason == nil → no abort
baseCalls := 0
base := roundTripFunc(func(*http.Request) (*http.Response, error) {
baseCalls++
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
resp, err := mid.RoundTrip(req)
if err != nil {
t.Fatalf("happy path returned err: %v", err)
}
if resp == nil || resp.StatusCode != http.StatusOK {
t.Fatalf("resp = %v, want 200", resp)
}
if baseCalls != 1 {
t.Fatalf("base RoundTrip called %d times, want 1", baseCalls)
}
if !ic.preECalled {
t.Fatal("PreRoundTripE was not called")
}
if !ic.postCalled || ic.postErr != nil {
t.Fatalf("post hook not called or err != nil: called=%v err=%v", ic.postCalled, ic.postErr)
}
}

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.

View File

@@ -187,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

@@ -21,11 +21,14 @@ import (
// DefaultAccountProvider resolves account from config.json via keychain.
type DefaultAccountProvider struct {
keychain keychain.KeychainAccess
keychain func() keychain.KeychainAccess
profile string
}
func NewDefaultAccountProvider(kc keychain.KeychainAccess, profile string) *DefaultAccountProvider {
func NewDefaultAccountProvider(kc func() keychain.KeychainAccess, profile string) *DefaultAccountProvider {
if kc == nil {
kc = keychain.Default
}
return &DefaultAccountProvider{keychain: kc, profile: profile}
}
@@ -36,7 +39,7 @@ func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account,
return nil, &core.ConfigError{Code: 2, Type: "config", Message: "not configured", Hint: "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."}
}
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain, p.profile)
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile)
if err != nil {
return nil, err
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/keychain"
)
type noopKC struct{}
@@ -99,7 +100,7 @@ func TestFullChain_ConfigStrictMode(t *testing.T) {
}
ep := &envprovider.Provider{}
defaultAcct := credential.NewDefaultAccountProvider(&noopKC{}, "")
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return &noopKC{} }, "")
cp := credential.NewCredentialProvider(
[]extcred.Provider{ep},

View File

@@ -11,4 +11,8 @@ const (
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
// Sidecar proxy (auth proxy mode)
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384"
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
)

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

@@ -33,6 +33,22 @@ 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
// Sheets float image: width/height/offset out of range or invalid.
LarkErrSheetsFloatImageInvalidDims = 1310246
// Drive permission apply: per-user-per-document submission limit (5/day) reached.
LarkErrDrivePermApplyRateLimit = 1063006
// Drive permission apply: request is not applicable for this document
// (e.g. the document is configured to disallow access requests, or the
// caller already holds the requested permission, or the target type does
// not accept apply operations).
LarkErrDrivePermApplyNotApplicable = 1063007
)
// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
@@ -60,6 +76,28 @@ 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"
// sheets-specific constraints that benefit from actionable hints
case LarkErrSheetsFloatImageInvalidDims:
return ExitAPI, "invalid_params",
"check --width / --height / --offset-x / --offset-y: " +
"width/height must be >= 20 px; offsets must be >= 0 and less than the anchor cell's width/height"
// drive permission-apply specific guidance
case LarkErrDrivePermApplyRateLimit:
return ExitAPI, "rate_limit",
"permission-apply quota reached: each user may request access on the same document at most 5 times per day; wait or ask the owner directly"
case LarkErrDrivePermApplyNotApplicable:
return ExitAPI, "invalid_params",
"this document does not accept a permission-apply request (common causes: the document is configured to disallow access requests, the caller already holds the permission, or the target type does not support apply); contact the owner directly"
}
return ExitAPI, "api_error", ""

View File

@@ -0,0 +1,85 @@
// 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",
},
{
name: "sheets float image invalid dims",
code: LarkErrSheetsFloatImageInvalidDims,
wantExitCode: ExitAPI,
wantType: "invalid_params",
wantHint: "--width / --height / --offset-x / --offset-y",
},
{
name: "drive permission apply rate limit",
code: LarkErrDrivePermApplyRateLimit,
wantExitCode: ExitAPI,
wantType: "rate_limit",
wantHint: "5 times per day",
},
{
name: "drive permission apply not applicable",
code: LarkErrDrivePermApplyNotApplicable,
wantExitCode: ExitAPI,
wantType: "invalid_params",
wantHint: "does not accept a permission-apply request",
},
}
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,10 +57,15 @@
},
"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" },
"zh": { "title": "知识库", "description": "知识空间、节点管理" }
},
"okr": {
"en": { "title": "OKR", "description": "Lark OKR objectives, key results, alignments, indicators" },
"zh": { "title": "OKR", "description": "飞书 OKR 目标、关键结果、对齐、量化指标" }
}
}

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

@@ -61,7 +61,7 @@ func httpClient() *http.Client {
}
return &http.Client{
Timeout: fetchTimeout,
Transport: util.NewBaseTransport(),
Transport: util.SharedTransport(),
}
}
@@ -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

@@ -72,31 +72,47 @@ func WarnIfProxied(w io.Writer) {
})
}
// NewBaseTransport creates an *http.Transport cloned from http.DefaultTransport.
// If LARK_CLI_NO_PROXY is set, proxy support is disabled.
// Each call returns a new instance; use FallbackTransport for a shared singleton.
func NewBaseTransport() *http.Transport {
// noProxyTransport is a proxy-disabled clone of http.DefaultTransport,
// lazily built the first time LARK_CLI_NO_PROXY is observed set.
var noProxyTransport = sync.OnceValue(func() *http.Transport {
def, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return &http.Transport{}
}
t := def.Clone()
if os.Getenv(EnvNoProxy) != "" {
t.Proxy = nil
}
t.Proxy = nil
return t
}
// fallbackTransport is a lazily-initialized singleton used by transport
// decorators when their Base field is nil, preserving connection pooling.
var fallbackTransport = sync.OnceValue(func() *http.Transport {
return NewBaseTransport()
})
// FallbackTransport returns a shared *http.Transport singleton suitable for
// use as a fallback when a transport decorator's Base is nil.
// Unlike NewBaseTransport (which clones per call), this reuses a single
// instance so that TCP connections and TLS sessions are pooled.
func FallbackTransport() *http.Transport {
return fallbackTransport()
// SharedTransport returns the base http.RoundTripper for CLI HTTP clients.
//
// By default it returns http.DefaultTransport — the stdlib-provided
// process-wide singleton — so every HTTP client in the process shares one
// TCP connection pool, TLS session cache, and HTTP/2 state. When
// LARK_CLI_NO_PROXY is set it returns a separate proxy-disabled singleton
// clone; LARK_CLI_NO_PROXY is checked on every call, but the clone is built
// at most once.
//
// The returned RoundTripper MUST NOT be mutated. Callers that need a
// customized transport should assert to *http.Transport and Clone() it.
// Using a shared base is required so persistConn readLoop/writeLoop
// goroutines are reused; cloning per call leaks them until IdleConnTimeout
// (~90s) fires.
func SharedTransport() http.RoundTripper {
if os.Getenv(EnvNoProxy) != "" {
return noProxyTransport()
}
return http.DefaultTransport
}
// FallbackTransport returns a shared *http.Transport singleton. It is a
// thin wrapper over SharedTransport retained so modules that were already
// on the leak-free singleton path (internal/auth, internal/cmdutil
// transport decorators) do not have to migrate. New code should prefer
// SharedTransport and treat the base as an http.RoundTripper.
func FallbackTransport() *http.Transport {
if t, ok := SharedTransport().(*http.Transport); ok {
return t
}
return noProxyTransport()
}

View File

@@ -28,19 +28,65 @@ func TestDetectProxyEnv(t *testing.T) {
}
}
func TestNewBaseTransport_Default(t *testing.T) {
func TestSharedTransport_DefaultReturnsStdlibSingleton(t *testing.T) {
t.Setenv(EnvNoProxy, "")
tr := NewBaseTransport()
if tr.Proxy == nil {
t.Error("expected proxy func to be set when LARK_CLI_NO_PROXY is not set")
tr := SharedTransport()
if tr != http.DefaultTransport {
t.Error("SharedTransport should return http.DefaultTransport when LARK_CLI_NO_PROXY is unset")
}
}
func TestNewBaseTransport_NoProxy(t *testing.T) {
func TestSharedTransport_NoProxyReturnsClone(t *testing.T) {
t.Setenv(EnvNoProxy, "1")
tr := NewBaseTransport()
if tr.Proxy != nil {
t.Error("expected proxy func to be nil when LARK_CLI_NO_PROXY=1")
tr := SharedTransport()
if tr == http.DefaultTransport {
t.Fatal("SharedTransport should return a clone, not DefaultTransport, when LARK_CLI_NO_PROXY is set")
}
ht, ok := tr.(*http.Transport)
if !ok {
t.Fatalf("expected *http.Transport, got %T", tr)
}
if ht.Proxy != nil {
t.Error("no-proxy transport should have Proxy == nil")
}
}
func TestSharedTransport_NoProxyIsCachedSingleton(t *testing.T) {
t.Setenv(EnvNoProxy, "1")
a := SharedTransport()
b := SharedTransport()
if a != b {
t.Error("repeated SharedTransport calls with LARK_CLI_NO_PROXY set must return the same instance")
}
}
func TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) {
// Simulate a process that first runs with LARK_CLI_NO_PROXY=1 (populating
// the no-proxy singleton), then unsets it. Subsequent calls must return
// http.DefaultTransport, NOT the cached no-proxy clone.
t.Setenv(EnvNoProxy, "1")
noProxy := SharedTransport()
if noProxy == http.DefaultTransport {
t.Fatal("precondition: first call with env set should not return DefaultTransport")
}
t.Setenv(EnvNoProxy, "")
after := SharedTransport()
if after != http.DefaultTransport {
t.Errorf("after unsetting LARK_CLI_NO_PROXY, SharedTransport must return http.DefaultTransport, got %T (%p)", after, after)
}
}
func TestSharedTransport_NoProxyOverridesSystemProxy(t *testing.T) {
t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888")
t.Setenv(EnvNoProxy, "1")
ht, ok := SharedTransport().(*http.Transport)
if !ok {
t.Fatalf("expected *http.Transport, got %T", SharedTransport())
}
if ht.Proxy != nil {
t.Error("LARK_CLI_NO_PROXY should override system proxy settings")
}
}
@@ -156,35 +202,3 @@ func TestWarnIfProxied_RedactsCredentials(t *testing.T) {
t.Errorf("warning should contain redacted proxy URL, got: %s", out)
}
}
func TestNewBaseTransport_IsHTTPTransport(t *testing.T) {
t.Setenv(EnvNoProxy, "")
tr := NewBaseTransport()
// Should be a valid *http.Transport that can be used
var rt http.RoundTripper = tr
_ = rt
// Verify it's not the same pointer as DefaultTransport (should be a clone)
if tr == http.DefaultTransport {
t.Error("NewBaseTransport should return a clone, not DefaultTransport itself")
}
}
func TestNewBaseTransport_RespectsNoProxyEnv(t *testing.T) {
// Simulate: user sets both system proxy and our disable flag
t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888")
t.Setenv(EnvNoProxy, "1")
tr := NewBaseTransport()
if tr.Proxy != nil {
t.Error("LARK_CLI_NO_PROXY should override system proxy settings")
}
// Clean up and verify proxy is restored
t.Setenv(EnvNoProxy, "")
tr2 := NewBaseTransport()
if tr2.Proxy == nil {
t.Error("proxy should be enabled when LARK_CLI_NO_PROXY is unset")
}
}

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package util
import "testing"
func TestTruncateStr(t *testing.T) {
tests := []struct {
name string
s string
n int
want string
}{
{"short string", "hello", 10, "hello"},
{"exact length", "hello", 5, "hello"},
{"truncate", "hello world", 5, "hello"},
{"empty", "", 5, ""},
{"zero limit", "hello", 0, ""},
{"CJK characters", "你好世界测试", 4, "你好世界"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := TruncateStr(tt.s, tt.n); got != tt.want {
t.Errorf("TruncateStr(%q, %d) = %q, want %q", tt.s, tt.n, got, tt.want)
}
})
}
}
func TestTruncateStrWithEllipsis(t *testing.T) {
tests := []struct {
name string
s string
n int
want string
}{
{"short string", "hello", 10, "hello"},
{"exact length", "hello", 5, "hello"},
{"truncate with ellipsis", "hello world", 8, "hello..."},
{"limit less than 3", "hello", 2, "he"},
{"limit equals 3", "hello world", 3, "..."},
{"empty", "", 5, ""},
{"CJK with ellipsis", "你好世界测试", 5, "你好..."},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := TruncateStrWithEllipsis(tt.s, tt.n); got != tt.want {
t.Errorf("TruncateStrWithEllipsis(%q, %d) = %q, want %q", tt.s, tt.n, got, tt.want)
}
})
}
}

View File

@@ -31,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

@@ -29,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

@@ -6,6 +6,7 @@ package vfs
import (
"io/fs"
"os"
"path/filepath"
)
// OsFs delegates every method to the os standard library.
@@ -33,3 +34,7 @@ func (OsFs) MkdirAll(path string, perm fs.FileMode) error { return os.MkdirAll(p
func (OsFs) ReadDir(name string) ([]os.DirEntry, error) { return os.ReadDir(name) }
func (OsFs) Remove(name string) error { return os.Remove(name) }
func (OsFs) Rename(oldpath, newpath string) error { return os.Rename(oldpath, newpath) }
// Path resolution
func (OsFs) EvalSymlinks(path string) (string, error) { return filepath.EvalSymlinks(path) }
func (OsFs) Executable() (string, error) { return os.Executable() }

11
main_authsidecar.go Normal file
View File

@@ -0,0 +1,11 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
package main
import (
_ "github.com/larksuite/cli/extension/credential/sidecar" // activate sidecar credential provider
_ "github.com/larksuite/cli/extension/transport/sidecar" // activate sidecar transport interceptor
)

54
main_noauthsidecar.go Normal file
View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !authsidecar
// This file is the fail-closed guard for builds that do NOT include the
// `authsidecar` tag. The sidecar credential-isolation feature is only
// compiled in under that tag; deploying the plain build into an environment
// that expects sidecar isolation would silently fall back to direct env
// credential use — exactly the failure mode the feature is meant to prevent.
//
// When LARKSUITE_CLI_AUTH_PROXY is set, we refuse to run rather than ignore
// the variable. The operator either rebuilt without realizing (wrong
// artifact) or the sandbox inherited the var by accident; both cases want
// a loud startup error, not a mysterious token leak on the first API call.
package main
import (
"fmt"
"io"
"os"
"github.com/larksuite/cli/internal/envvars"
)
func init() {
if code := checkNoAuthsidecarBuild(os.Getenv, os.Stderr); code != 0 {
os.Exit(code)
}
}
// checkNoAuthsidecarBuild returns a non-zero exit code (and writes a
// human-readable reason to stderr) when the environment asks for sidecar
// isolation that this binary cannot provide. Factored out from init() so
// tests can exercise the decision without actually calling os.Exit.
func checkNoAuthsidecarBuild(getenv func(string) string, stderr io.Writer) int {
v := getenv(envvars.CliAuthProxy)
if v == "" {
return 0
}
fmt.Fprintf(stderr,
"ERROR: %s is set, but this lark-cli binary was built WITHOUT the "+
"'authsidecar' build tag.\n"+
"The sidecar credential-isolation feature is compiled out — "+
"running would bypass isolation and\n"+
"send any real credentials present in the environment directly "+
"to the Lark API.\n\n"+
"To fix, either:\n"+
" - rebuild the CLI with: go build -tags authsidecar\n"+
" - or unset %s if sidecar isolation is not required\n",
envvars.CliAuthProxy, envvars.CliAuthProxy)
return 2
}

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !authsidecar
package main
import (
"bytes"
"strings"
"testing"
"github.com/larksuite/cli/internal/envvars"
)
func TestCheckNoAuthsidecarBuild_Unset(t *testing.T) {
var stderr bytes.Buffer
code := checkNoAuthsidecarBuild(func(string) string { return "" }, &stderr)
if code != 0 {
t.Errorf("exit code = %d, want 0 when AUTH_PROXY is unset", code)
}
if stderr.Len() != 0 {
t.Errorf("stderr should be empty, got %q", stderr.String())
}
}
// TestCheckNoAuthsidecarBuild_Set verifies that deploying a plain build into
// a sandbox that expects sidecar isolation fails loudly at startup instead
// of silently leaking credentials through the env provider path.
func TestCheckNoAuthsidecarBuild_Set(t *testing.T) {
var stderr bytes.Buffer
env := func(k string) string {
if k == envvars.CliAuthProxy {
return "http://127.0.0.1:16384"
}
return ""
}
code := checkNoAuthsidecarBuild(env, &stderr)
if code == 0 {
t.Fatal("expected non-zero exit code when AUTH_PROXY is set")
}
msg := stderr.String()
for _, want := range []string{
envvars.CliAuthProxy,
"authsidecar", // build-tag name must appear so operators can act on it
"rebuild",
} {
if !strings.Contains(msg, want) {
t.Errorf("stderr message missing %q; got:\n%s", want, msg)
}
}
}

84
package-lock.json generated Normal file
View File

@@ -0,0 +1,84 @@
{
"name": "@larksuite/cli",
"version": "1.0.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.11",
"cpu": [
"x64",
"arm64"
],
"hasInstallScript": true,
"license": "MIT",
"os": [
"darwin",
"linux",
"win32"
],
"dependencies": {
"@clack/prompts": "^1.2.0"
},
"bin": {
"lark-cli": "scripts/run.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@clack/core": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.2.0.tgz",
"integrity": "sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==",
"license": "MIT",
"dependencies": {
"fast-wrap-ansi": "^0.1.3",
"sisteransi": "^1.0.5"
}
},
"node_modules/@clack/prompts": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.2.0.tgz",
"integrity": "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==",
"license": "MIT",
"dependencies": {
"@clack/core": "1.2.0",
"fast-string-width": "^1.1.0",
"fast-wrap-ansi": "^0.1.3",
"sisteransi": "^1.0.5"
}
},
"node_modules/fast-string-truncated-width": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-1.2.1.tgz",
"integrity": "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==",
"license": "MIT"
},
"node_modules/fast-string-width": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-1.1.0.tgz",
"integrity": "sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==",
"license": "MIT",
"dependencies": {
"fast-string-truncated-width": "^1.2.0"
}
},
"node_modules/fast-wrap-ansi": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.1.6.tgz",
"integrity": "sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==",
"license": "MIT",
"dependencies": {
"fast-string-width": "^1.1.0"
}
},
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.7",
"version": "1.0.17",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"
@@ -27,7 +27,11 @@
"license": "MIT",
"files": [
"scripts/install.js",
"scripts/install-wizard.js",
"scripts/run.js",
"CHANGELOG.md"
]
],
"dependencies": {
"@clack/prompts": "^1.2.0"
}
}

372
scripts/install-wizard.js Normal file
View File

@@ -0,0 +1,372 @@
#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("fs");
const path = require("path");
const { execFileSync, execFile } = require("child_process");
const p = require("@clack/prompts");
const PKG = "@larksuite/cli";
const SKILLS_REPO = "https://open.feishu.cn";
const SKILLS_REPO_FALLBACK = "larksuite/cli";
const isWindows = process.platform === "win32";
// ---------------------------------------------------------------------------
// i18n
// ---------------------------------------------------------------------------
const messages = {
zh: {
setup: "正在设置 Feishu/Lark CLI...",
step1: "正在安装 %s...",
step1Upgrade: "正在升级 %s (v%s → v%s)...",
step1Skip: "已安装 (v%s),跳过",
step1Done: "已全局安装",
step1Upgraded: "已升级到 v%s",
step1Fail: "全局安装失败。运行以下命令重试: npm install -g %s",
step2: "安装 AI Skills",
step2Skip: "已安装,跳过",
step2Spinner: "正在安装 Skills...",
step2Done: "Skills 已安装",
step2Fail: "Skills 安装失败。运行以下命令重试: npx skills add %s -y -g",
step3: "正在配置应用...",
step3NotFound: "未找到 lark-cli终止",
step3Found: "发现已配置应用 (App ID: %s),继续使用?",
step3Skip: "跳过应用配置",
step3Done: "应用已配置",
step3Fail: "应用配置失败。运行以下命令重试: lark-cli config init --new",
step4: "授权",
step4NotFound: "未找到 lark-cli跳过授权",
step4Confirm: "是否允许 AI 访问你个人的消息、文档、日历等飞书 / Lark 数据,并以你的名义执行操作?",
step4Skip: "跳过授权。后续运行 lark-cli auth login 完成授权",
step4Done: "授权完成",
step4Fail: "授权失败。运行以下命令重试: lark-cli auth login",
done: "安装完成!\n可以和你的 AI 工具(如 Claude Code、Trae等\"飞书/Lark CLI 能帮我做什么?结合我的情况推荐一下从哪里开始\"",
cancelled: "安装已取消",
},
en: {
setup: "Setting up Feishu/Lark CLI...",
step1: "Installing %s globally...",
step1Upgrade: "Upgrading %s (v%s → v%s)...",
step1Skip: "Already installed (v%s). Skipped",
step1Done: "Installed globally",
step1Upgraded: "Upgraded to v%s",
step1Fail: "Failed to install globally. Run manually: npm install -g %s",
step2: "Install AI skills",
step2Skip: "Already installed. Skipped",
step2Spinner: "Installing skills...",
step2Done: "Skills installed",
step2Fail: "Failed to install skills. Run manually: npx skills add %s -y -g",
step3: "Configuring app...",
step3NotFound: "lark-cli not found. Aborting",
step3Found: "Found existing app (App ID: %s). Use this app?",
step3Skip: "Skipped app configuration",
step3Done: "App configured",
step3Fail: "Failed to configure app. Run manually: lark-cli config init --new",
step4: "Authorization",
step4NotFound: "lark-cli not found. Skipping authorization",
step4Confirm: "Allow the AI to access your messages, documents, calendar, and more in Feishu/Lark, and perform actions on your behalf?",
step4Skip: "Skipped. Run lark-cli auth login to authorize later",
step4Done: "Authorization complete",
step4Fail: "Failed to authorize. Run lark-cli auth login to retry",
done: "You are all set!\nNow try asking your AI tool (Claude Code, Trae, etc.): \"What can Feishu/Lark CLI help me with, and where should I start?\"",
cancelled: "Installation cancelled",
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function handleCancel(value, msg) {
if (p.isCancel(value)) {
p.cancel(msg.cancelled);
process.exit(0);
}
return value;
}
function execCmd(cmd, args, opts) {
if (isWindows) {
return execFileSync("cmd.exe", ["/c", cmd, ...args], opts);
}
return execFileSync(cmd, args, opts);
}
function run(cmd, args, opts = {}) {
execCmd(cmd, args, { stdio: "inherit", ...opts });
}
function runSilent(cmd, args, opts = {}) {
return execCmd(cmd, args, {
stdio: ["ignore", "pipe", "pipe"],
...opts,
});
}
function runSilentAsync(cmd, args, opts = {}) {
const actualCmd = isWindows ? "cmd.exe" : cmd;
const actualArgs = isWindows ? ["/c", cmd, ...args] : args;
return new Promise((resolve, reject) => {
execFile(actualCmd, actualArgs, {
stdio: ["ignore", "pipe", "pipe"],
...opts,
}, (err, stdout) => {
if (err) reject(err);
else resolve(stdout);
});
});
}
function fmt(template, ...values) {
let i = 0;
return template.replace(/%s/g, () => values[i++] ?? "");
}
/** Resolve the path of globally installed lark-cli (skip npx temp copies). */
function whichLarkCli() {
try {
const prefix = execFileSync("npm", ["prefix", "-g"], {
stdio: ["ignore", "pipe", "pipe"],
}).toString().trim();
const bin = isWindows
? path.join(prefix, "lark-cli.cmd")
: path.join(prefix, "bin", "lark-cli");
if (fs.existsSync(bin)) return bin;
} catch (_) {
// fall through
}
// Fallback to which/where if npm prefix lookup fails.
try {
const cmd = isWindows ? "where" : "which";
return execFileSync(cmd, ["lark-cli"], { stdio: ["ignore", "pipe", "pipe"] })
.toString()
.split("\n")[0]
.trim();
} catch (_) {
return null;
}
}
/** Get the latest version of @larksuite/cli from the registry. Returns version or null. */
function getLatestVersion() {
try {
const out = runSilent("npm", ["view", PKG, "version"], { timeout: 15000 });
const ver = out.toString().trim();
return /^\d+\.\d+\.\d+/.test(ver) ? ver : null;
} catch (_) {
return null;
}
}
/** Compare two semver strings. Returns true if a < b. */
function semverLessThan(a, b) {
const pa = a.replace(/-.*$/, "").split(".").map(Number);
const pb = b.replace(/-.*$/, "").split(".").map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] || 0) < (pb[i] || 0)) return true;
if ((pa[i] || 0) > (pb[i] || 0)) return false;
}
return false;
}
/** Check whether @larksuite/cli is truly installed in npm global prefix. Returns version or null. */
function getGloballyInstalledVersion() {
try {
const out = runSilent("npm", ["list", "-g", PKG], { timeout: 15000 });
const match = out.toString().match(/@(\d+\.\d+\.\d+[^\s]*)/);
return match ? match[1] : "unknown";
} catch (_) {
return null;
}
}
/** Check whether lark-cli config already exists. Returns app ID or null. */
function getExistingAppId(binPath) {
try {
const out = runSilent(binPath, ["config", "show"], { timeout: 10000 });
const json = JSON.parse(out.toString());
return json.appId || null;
} catch (_) {
return null;
}
}
/** Parse --lang from process.argv, returns "zh", "en", or null. */
function parseLangArg() {
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i++) {
if (args[i] === "--lang" && args[i + 1]) {
const val = args[i + 1].toLowerCase();
if (val === "zh" || val === "en") return val;
}
if (args[i].startsWith("--lang=")) {
const val = args[i].split("=")[1].toLowerCase();
if (val === "zh" || val === "en") return val;
}
}
return null;
}
// ---------------------------------------------------------------------------
// Steps
// ---------------------------------------------------------------------------
async function stepSelectLang() {
const fromArg = parseLangArg();
if (fromArg) return fromArg;
const lang = await p.select({
message: "请选择语言 / Select language",
options: [
{ value: "zh", label: "中文" },
{ value: "en", label: "English" },
],
});
return handleCancel(lang, messages.zh);
}
async function stepInstallGlobally(msg) {
const installedVer = getGloballyInstalledVersion();
const latestVer = getLatestVersion();
const needsUpgrade = installedVer && latestVer && semverLessThan(installedVer, latestVer);
if (installedVer && !needsUpgrade) {
p.log.info(fmt(msg.step1Skip, installedVer));
return false;
}
const s = p.spinner();
if (needsUpgrade) {
s.start(fmt(msg.step1Upgrade, PKG, installedVer, latestVer));
} else {
s.start(fmt(msg.step1, PKG));
}
try {
await runSilentAsync("npm", ["install", "-g", PKG], { timeout: 120000 });
s.stop(needsUpgrade ? fmt(msg.step1Upgraded, latestVer) : msg.step1Done);
return needsUpgrade;
} catch (_) {
s.stop(fmt(msg.step1Fail, PKG));
process.exit(1);
}
}
async function skillsAlreadyInstalled() {
try {
const out = await runSilentAsync("npx", ["-y", "skills", "ls", "-g"], {
timeout: 120000,
});
return /^lark-/m.test(out.toString());
} catch (_) {
return false;
}
}
async function stepInstallSkills(msg) {
const s = p.spinner();
s.start(msg.step2Spinner);
try {
if (await skillsAlreadyInstalled()) {
s.stop(msg.step2Skip);
return;
}
try {
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO, "-y", "-g"], {
timeout: 120000,
});
} catch (_) {
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO_FALLBACK, "-y", "-g"], {
timeout: 120000,
});
}
s.stop(msg.step2Done);
} catch (_) {
s.stop(fmt(msg.step2Fail, SKILLS_REPO_FALLBACK));
process.exit(1);
}
}
async function stepConfigInit(msg, lang) {
const s = p.spinner();
s.start(msg.step3);
const larkCli = whichLarkCli();
if (!larkCli) {
s.stop(msg.step3NotFound);
process.exit(1);
}
const appId = getExistingAppId(larkCli);
s.stop(msg.step3);
if (appId) {
const reuse = await p.confirm({
message: fmt(msg.step3Found, appId),
});
if (handleCancel(reuse, msg) && reuse) {
p.log.info(msg.step3Skip);
return;
}
}
try {
run(larkCli, ["config", "init", "--new", "--lang", lang]);
p.log.success(msg.step3Done);
} catch (_) {
p.log.error(msg.step3Fail);
process.exit(1);
}
}
async function stepAuthLogin(msg) {
const larkCli = whichLarkCli();
if (!larkCli) {
p.log.warn(msg.step4NotFound);
return;
}
const yes = await p.confirm({
message: msg.step4Confirm,
});
if (p.isCancel(yes)) {
p.cancel(msg.cancelled);
process.exit(0);
}
if (!yes) {
p.log.info(msg.step4Skip);
return;
}
p.log.step(msg.step4);
try {
run(larkCli, ["auth", "login"]);
p.log.success(msg.step4Done);
} catch (_) {
p.log.warn(msg.step4Fail);
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const lang = await stepSelectLang();
const msg = messages[lang];
p.intro(msg.setup);
await stepInstallGlobally(msg);
await stepInstallSkills(msg);
await stepConfigInit(msg, lang);
await stepAuthLogin(msg);
p.outro(msg.done);
}
main().catch((err) => {
p.cancel("Unexpected error: " + (err.message || err));
process.exit(1);
});

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