Compare commits

...

34 Commits

Author SHA1 Message Date
liangshuo-1
c3756f3642 chore(release): v1.0.24 (#761)
Change-Id: I248e14e1d546aa1c49bdb9f443103952488f16d7
2026-05-06 20:35:36 +08:00
liangshuo-1
27a2f2758b fix(config): make agent-binding hints workspace-aware and surface user-identity risks (#728)
AI agents running inside OpenClaw / Hermes were routinely creating a parallel
app via `config init --new` instead of binding to the agent's existing app,
because every "not configured" hint and several deny errors hard-coded
`config init` regardless of workspace. Once bound, the same agents could
silently grant themselves user identity (impersonation) without the user
ever seeing a risk message in chat.

Changes:

- Introduce `core.NotConfiguredError` / `NoActiveProfileError` /
  `reconfigureHint` helpers that branch on `CurrentWorkspace()`. In agent
  workspaces they point at `lark-cli config bind --help` (a help page, not
  a ready-to-run command) so AI must read the binding workflow and confirm
  identity preset with the user before acting. In local terminals they
  preserve the previous `config init --new` guidance.

- Migrate every `config init` hint that should be workspace-aware:
  RequireConfigForProfile, default credential provider, credential provider
  fallback, secret-resolve mismatch, config show, strict-mode entry-point
  errors, default-as, profile use/rename/remove, auth list, doctor's
  config_file check (which now also wraps the OS-level "no such file"
  noise into the user-shaped "not configured" message).

- Refuse `config init` when run inside an OpenClaw / Hermes workspace by
  default; add `--force-init` for the rare case the user genuinely wants
  a parallel app. Without this guard, hint fixes were undone the moment
  AI ignored them.

- Rewrite the strict-mode deny errors in cmd/auth/login.go, cmd/prune.go,
  and internal/cmdutil/factory.go. The previous "AI agents are strictly
  prohibited from modifying this setting" terminated AI reasoning while
  providing no real gate. New errors point at `config strict-mode --help`
  with the legitimate confirmation flow and explicitly note that switching
  does NOT require re-bind. Integration test envelopes updated.

- Tighten `config bind --help` and `config strict-mode --help` to encode
  the user-confirmation discipline directly: identity preset semantics
  (bot-only vs user-default), "DO NOT switch without explicit user
  confirmation", and a cross-reference clarifying that `config bind` is
  for changing the underlying app while `config strict-mode` is the
  policy-only switch (resolves an ambiguity an audit run found).

- Surface user-identity (impersonation) risk at every config write that
  newly grants it, by reusing the canonical IdentityEscalationMessage
  string from bind_messages.go:
  - `noticeUserDefaultRisk` fires on flag-mode bind landing on
    user-default, including the first-time case `warnIdentityEscalation`
    misses (it requires a previous bot lock).
  - `setStrictMode` warns when transitioning bot → user or bot → off
    (newly permits user identity); stays quiet on narrowing changes
    and on off → user (off already permitted user).

- Add tests: notconfigured_test.go (workspace branches),
  init_guard_test.go (refuse + --force-init bypass), bind_warning_test.go
  (user-default warning fires; bot-only does not), strict_mode_warning_test.go
  (5 transitions covering both warn and no-warn paths).

Two follow-ups intentionally deferred: the keychain master-key hint at
internal/keychain/keychain.go:42 still suggests `config init` because the
keychain package can't import core (would be circular); fixing requires
either parameterizing the hint via callback or extracting workspace into
its own package. The lark-shared skill doc still tells AI to run
`config init` for first-time setup; updating the skill is in scope for
a follow-up PR.

Change-Id: I02273e044d9e061d211ceaa4f3ed5a3fb28325b3
2026-05-06 19:27:24 +08:00
JackZhao10086
15ae1fabec fix(auth): handle missing scopes and device flow improvements (#752)
* fix(auth): handle missing scopes and device flow improvements

* fix: remove redundant error return in login scope handler

* test(auth): rename test for zero interval default case

* fix: increase device code polling timeout from 180 to 600 seconds
2026-05-06 17:10:27 +08:00
wittam-01
d317493e49 fix: add url to markdown +create output (#753)
Change-Id: I4fa870415bbad76f721f8aa170180e83fd20281b
2026-05-06 16:03:33 +08:00
zgz2048
a8f078478e docs: refine field update conversion guidance (#748)
* docs: refine field update conversion guidance

* docs: refine field update conversion rules

* docs: adjust field update conversion allowlist
2026-05-06 15:32:38 +08:00
bytedance-zxy
06275415b1 feat(task): add upload task attachment shortcut (#736)
* feat(task): add upload task attachment shortcut

Change-Id: I668bf3d856baa6e35ed982a33c4bf4d03b924f4b

* feat(task): update SKILL.md adding resource_type description

Change-Id: I3ef1aba33ee22e8b03e6f59bc2fb64f55a742270
2026-05-06 14:36:41 +08:00
zgz2048
b4c9c09de0 feat(base): support batch record get and delete (#630)
* feat(base): support batch record get and delete

* fix(base): address batch record PR feedback

* docs(base): refine record skill routing

* refactor(base): use batch record get and delete only

* refactor(base): share record selection normalization

* docs(base): clarify record get field projection help
2026-05-06 14:13:22 +08:00
caojie0621
7fb71c6947 feat(sheets): add sheet management shortcuts (#722)
* feat(sheets): add sheet management shortcuts

- add +create-sheet, +copy-sheet, +delete-sheet, and +update-sheet
- cover request-shape dry-run and sheet workflow tests
- document new sheet management shortcuts in lark-sheets skill

* docs(sheets): consolidate lark-sheets reference docs
2026-05-01 15:49:24 +08:00
河伯
020aeb87ad feat(drive): pre-flight 10000-rune total cap for +add-comment reply_elements (#605)
* feat(drive): pre-flight per-text-element byte limit for +add-comment

The open-platform comment API returns an opaque [1069302] Invalid or
missing parameters whenever a single reply_elements[i] text exceeds
its implicit byte budget. The error does not name which element failed
or that length is the cause, so callers resort to binary-search
debugging.

Empirically: Chinese text up to ~80 chars (~240 bytes) lands; ~130
chars (~390 bytes) fails. Set the pre-flight limit to 300 bytes which
sits safely inside the known-good zone.

- parseCommentReplyElements now rejects any text element whose UTF-8
  byte length exceeds 300, with an ExitError naming the element index
  (#N, 1-based) and both the rune and byte counts, plus an ErrWithHint
  recommending the correct remediation (split into multiple text
  elements — the comment UI renders them as one contiguous comment).
- The previous 1000-rune check is removed: it was too lenient (a
  Chinese text under that cap would still fail server-side).
- skills/lark-drive/references/lark-drive-add-comment.md documents
  the per-element limit and the correct split pattern so agents
  avoid constructing oversized single elements upstream.

Addresses Case 12 in the 踩坑列表 doc.

* fix(drive): correct +add-comment hint to match actual escape coverage

`escapeCommentText` only expands `<` and `>` (each → 4 bytes via
`&lt;` / `&gt;`); `&` is intentionally left as-is. Both the over-limit
hint and the inline comment in `parseCommentReplyElements` previously
claimed `&` was also escaped, with a "4-5 bytes each" range that
implicitly assumed `&amp;` (5 bytes) — a string of 300 `&` chars
would actually fit in the budget, but a user reading the hint would
think otherwise and pre-emptively split it.

Code:
- Hint string ends with `Note: '<' and '>' are HTML-escaped and
  counted in their escaped form (4 bytes each).` (was: included `&`
  and "4-5 bytes")
- Inline comment above the budget check now matches:
  `escapeCommentText only expands '<' and '>' (each becomes 4 bytes:
  &lt; / &gt;); '&' is intentionally left as-is.`

Tests (regression):
- New `300 ampersands accepted (escapeCommentText leaves '&' as-is)`
  subtest pins that 300 `&` chars stay within budget. Without the fix
  this also passed (function was always correct), but the hint was
  lying — the test pins the budget contract loud and clear.
- New `TestParseCommentReplyElementsHintMatchesEscape` asserts the
  hint string itself: must mention `'<' and '>'` / `4 bytes`, must NOT
  mention `'&'` / `&amp;` / `4-5 bytes`. Catches a future drift if
  `escapeCommentText` is changed without updating the hint, or
  vice-versa.

The skill md (`skills/lark-drive/references/lark-drive-add-comment.md`)
already had the right wording (`每个 < 或 > 占 4 字节`), so it was the
in-Go strings that drifted; this commit aligns code with doc.

* fix(drive): rewrite +add-comment length cap to match real server behavior

The original PR set a 300-byte per-element pre-flight check, justified
by the empirical pattern "~80 Chinese chars succeeds, ~130 fails". A
fresh round of probing the live `/open-apis/drive/v1/files/{token}/
new_comments` endpoint with a real docx shows that pattern does not
reproduce, and the actual contract is very different:

  - 10000 ASCII / 10000 Chinese / 10000 '<' (escaped to 40000 bytes)
    in a single text element: all OK
  - 10001 of any of the above in a single text element: [1069302]
  - 5000 + 5000 across two text elements (total 10000): OK
  - 5000 + 5001 across two text elements (total 10001): [1069302]
  - 4000 + 4000 + 4000 across three (total 12000): [1069302]

Two consequences:

1. The cap is *10000 runes total across all reply_elements text*, not
   300 bytes per element. The old check rejected legitimate input
   anywhere from ~100 to 10000 Chinese chars (≈100x too aggressive).

2. The hint that recommended "split the content across multiple
   {\"type\":\"text\",\"text\":\"...\"} elements" was actively wrong —
   splitting doesn't bypass a total cap. A user told to split a
   10001-char message into 5000+5001 hits the same opaque [1069302].

This commit:

- Replaces `maxCommentTextElementBytes = 300` with
  `maxCommentTotalRunes = 10000`. The constant's doc comment records
  the probe matrix above so future maintainers know how it was
  derived.
- Switches the measurement from `len(escapeCommentText(input.Text))`
  to `utf8.RuneCountInString(input.Text)`. Server counts raw runes;
  byte width and post-escape form are irrelevant. The escape itself
  still happens — `<` and `>` still get rendered literally — but it
  no longer participates in the length check.
- Tracks a running `totalRunes` across the whole reply_elements array
  and bails at the first element that pushes the cumulative total
  over the 10000-rune budget, with index reporting that points at the
  offending element.
- Rewrites the over-cap hint to (a) name the actual 10000-rune budget,
  (b) explicitly say splitting does NOT help, (c) drop the wrong
  "comment UI still renders them as one contiguous comment" framing
  that implied splitting was a workaround.
- Adds a `TestParseCommentReplyElementsHintForbidsSplitAdvice`
  watchdog that fails if any future drift puts the discredited split
  advice back into the hint.

Tests: 11 cases on TestParseCommentReplyElementsTextLength covering
single-element boundary (ASCII / Chinese / angle brackets at exactly
10000 and at 10001), multi-element total cap (5000+5000 OK, 5000+5001
rejected with index pointing at element #2), early-element-overshoot
indexing (first element at 10001 reports index #1, not the trailing
element), and mention_user not double-counting toward the cap.

Skill md updated: removes the 300-byte / "split into multiple
elements" advice; documents the 10000-rune total cap with a note that
the schema currently advertises 1-1000 chars and is out of date,
plus a procedure for re-probing if the server-side limit ever moves.

Manual API verification: rebuilt binary and posted comments at
boundary lengths — all OK cases (100 / 5000 / 10000 chars, 5000+5000
split) accepted by server; over-cap cases (10001 / 10100 single, and
5000+5001 split) rejected by the new pre-flight before reaching the
network.

---------

Co-authored-by: fangshuyu <fangshuyu@bytedance.com>
2026-04-30 18:52:44 +08:00
liangshuo-1
686c91dc71 chore(release): v1.0.23 (#737)
Change-Id: I48f780acac9731585aeec0a51f5b403a00804dbc
2026-04-30 18:04:10 +08:00
河伯
cfd89e0e28 feat(doc): warn when callout uses type= without background-color (#467)
* feat(doc): expand callout type= shorthand into background-color and border-color

When users write <callout type="warning" emoji="📝"> without an explicit
background-color, the Feishu doc renders the block with no color. This
commit adds fixCalloutType() which maps the semantic type= attribute to
the corresponding background-color/border-color pair accepted by create-doc.

- warning → light-yellow/yellow
- info/note → light-blue/blue
- tip/success/check → light-green/green
- error/danger → light-red/red
- caution → light-orange/orange
- important → light-purple/purple

Explicit background-color or border-color attributes are always preserved.
The fix is applied via prepareMarkdownForCreate() in both +create and
+update paths, and also inside fixExportedMarkdown() for round-trip fidelity.

* refactor(doc): replace silent callout type→color injection with hint output

Per reviewer feedback (SunPeiYang996), silently rewriting user Markdown is
the wrong layer for this adaptation. The type→color mapping is not part of
the Feishu spec, and covert transforms make debugging harder.

Replace fixCalloutType() (which rewrote the Markdown) with WarnCalloutType()
which leaves the Markdown unchanged and instead writes a hint line to stderr
for each callout tag that has type= but no background-color, telling the user
the recommended explicit attributes to add:

  hint: callout type="warning" has no background-color; consider: background-color="light-yellow" border-color="yellow"

Also fixes CodeRabbit feedback: the type= regex now accepts both single-quoted
and double-quoted attribute values (type='warning' and type="warning").

* fix(doc): harden background-color detection in WarnCalloutType

CodeRabbit flagged that the previous strings.Contains(attrs,
"background-color=") check missed forms like 'background-color =
"light-red"' with whitespace around the equals sign. Replace with a
regex that tolerates optional whitespace, and add a regression test.

* fix(doc): close real review gaps left over after rebase

PR #467's review thread had three substantive comments
(`fangshuyu-768`, 2026-04-21) that the prior reply messages claimed
were fixed in commit 7d4b556 — but that commit no longer exists on the
branch (lost in a rebase / squash), and the head still ships the
original buggy code. This commit makes the fixes real.

Three behavior fixes in shortcuts/doc/markdown_fix.go:

1. (#5) Tighten the type= and background-color= regex anchors. \b sits
   at any word/non-word boundary, and `-` is a non-word char, so
   `\btype=` also matched the suffix of `data-type=` — a tag like
   `<callout data-type="warning">` would emit a bogus light-yellow
   hint. Switched both regexes to `(?:^|\s)…` so a real attribute
   separator is required. The same anchor on background-color closes
   the symmetric case where a `data-background-color=` attribute
   would silently suppress the real hint.

2. (#4) WarnCalloutType is now a fence-aware line walker. Previously
   the regex ran over the entire markdown body, so a callout sample
   inside a documentation code fence (```markdown … ```) would
   generate a phantom stderr hint every time the docs mentioned the
   feature. The walker tracks fence state via the existing
   codeFenceOpenMarker / isCodeFenceClose helpers from
   docs_update_check.go, which handle both backtick and tilde fences
   per CommonMark §4.5.

3. (#3) Drop the ReplaceAllStringFunc-as-iterator pattern. The
   previous code routed callout iteration through a rewrite primitive
   whose rebuilt-string return value was discarded, then ran the same
   regex a second time inside the callback to recover the capture
   groups. New scanCalloutTagsForWarning helper uses
   FindAllStringSubmatch — one pass, no thrown-away allocation,
   intent matches the surface (read-only scan, not a mutator).

Tests: 5 new TestWarnCalloutType subtests pin each contract:

- data-type attribute does not trigger hint (#5)
- data-background-color does not suppress hint (#5, symmetric)
- callout inside backtick fence emits no hint (#4)
- callout inside tilde fence emits no hint (#4)
- callout after fence close still emits hint (#4, fence-state reset)

All 14 TestWarnCalloutType cases pass; go vet / golangci-lint
--new-from-rev=origin/main both clean.
2026-04-30 17:51:08 +08:00
zhouyue-bytedance
ac4c34f2ad feat: support file-name for drive export (#685)
* feat: support file-name for drive export

* test: cover drive export file-name metadata
2026-04-30 17:30:23 +08:00
zgz2048
3ed691b25c feat(base): add markdown output for record reads (#726)
* feat(base): add record read SOP guidance

1. Add a unified lark-base record read SOP for get/search/list routing, field projection, temporary view querying, pagination, matrix result binding, and link field reads.
2. Inline command-focused parameter guidance into +record-get, +record-search, and +record-list help, including examples, JSON shape, view scope, projection, and limit constraints.
3. Preserve base shortcut flag order in help output and add tests covering record read help guidance.
4. Remove the single-method record read skill references in favor of the unified SOP.

* test(base): remove stale record list fixture

* fix(base): scan record markdown output

* fix(base): fallback record markdown output

* fix(base): unify base token wording in shortcuts and skills
2026-04-30 17:09:17 +08:00
fangshuyu-768
30ad38d4b6 feat(drive): add +pull shortcut for one-way Drive → local mirror (#696)
* feat(drive): add +pull shortcut to mirror a Drive folder onto local

Adds `drive +pull`, a one-way Drive → local mirror command. It
recursively lists --folder-token, downloads each type=file entry
into --local-dir at the matching relative path, and optionally
deletes local files absent from the remote (mirror semantics).

Implementation notes:

- Listing recurses through subfolders with the standard 200-page
  pagination loop. Online docs (docx, sheet, bitable, mindnote,
  slides) and shortcuts are skipped since there is no equivalent
  local binary to write back. Folder tree is reproduced under
  --local-dir, with parent directories auto-created by FileIO.Save.

- Per-file --if-exists=overwrite (default) | skip controls how
  pre-existing local files are treated; the framework's enum guard
  rejects any other value.

- --delete-local is the only destructive flag and is bound to --yes
  in Validate: --delete-local without --yes is rejected upfront so
  no listing or download even runs. --delete-local --yes performs
  downloads first, then walks --local-dir and removes regular files
  not present in the remote map. This matches the spec doc's
  "high-risk-write" intent for --delete-local without making the
  default pull path require confirmation.

- --local-dir is funneled through validate.SafeLocalFlagPath so
  errors reference --local-dir instead of the framework default
  --file. FileIO().Stat then enforces existence and IsDir.

- Scopes: drive:drive.metadata:readonly + drive:file:download. The
  broader drive:drive is disabled by enterprise policy in some
  tenants.

- Listing helper (drivePullListRemote) is duplicated locally rather
  than reused from drive_status.go because that change is still in
  open PR #692; once it merges, both can be lifted into a shared
  drive package helper. TODO marker is left in the code.

Tests cover six unit scenarios (happy-path with nested subfolder +
docx skipping, --if-exists=skip, --delete-local rejection without
--yes, --delete-local --yes deletes orphans, absolute-path
rejection, bad enum) and four E2E dry-run scenarios (request shape,
absolute path rejection, --delete-local --yes guard, missing
required flag).

* docs(skills): document drive +pull in lark-drive skill

Adds references/lark-drive-pull.md covering parameters, output schema
(summary + per-item action breakdown), the type=file scoping rule,
the --if-exists policy matrix, and the --delete-local + --yes safety
contract. Calls out the network-traffic caveat (pull is full-download,
unlike +status which only fetches when both sides have the file) and
the cwd boundary on --local-dir.

Wires +pull into the Shortcuts table in SKILL.md.

* fix(drive): walk +pull on canonical absolute root to close symlink/.. escape

Same root cause as the +status fix: --local-dir was validated through
SafeLocalFlagPath but the walk used the user-supplied raw string.
SafeLocalFlagPath returns the original value (the canonical form is
discarded), and SafeInputPath itself relies on filepath.Clean for
normalization, which shrinks "link/.." to "." purely as string
manipulation. The kernel then resolves "link/.." through the symlink
target's parent at walk time, putting the traversal outside cwd.

For +pull the bug is more dangerous than for +status because it
travels through --delete-local --yes — a raw walk would let the
delete pass land on files outside cwd.

Fix:
- In Execute, resolve --local-dir via validate.SafeInputPath to get a
  canonical absolute path, and resolve "." the same way for cwd.
- Convert the resolved root back to a cwd-relative form
  (filepath.Rel) for download targets so FileIO.Save's existing
  SafeOutputPath check (which rejects absolute paths) still applies.
- For --delete-local, walk the canonical absolute root, then delete
  via the absolute path. Both values come from the validated
  safeRoot, so kernel path resolution cannot redirect a delete to a
  file outside the canonical subtree.
- drivePullWalkLocal now returns absolute paths instead of rel paths;
  the caller computes the rel_path via filepath.Rel against safeRoot
  for output / remote-set membership checks.

Adds TestDrivePullDeleteLocalDoesNotEscapeViaSymlinkParentRef as a
regression: it stages an "escape" sibling directory containing a
sentinel file, adds a "link" symlink in cwd pointing into it, and
runs +pull --delete-local --yes against an empty remote with
--local-dir "link/..". The sentinel must survive (proving --delete
did not escape) and the in-cwd file must be removed (proving the
walk did run).

* test(drive): pin walker / download behavior on +pull symlink corner cases

Adds three regressions on top of the canonical-root walk fix:

- TestDrivePullSkipsSymlinkInsideRoot: a child symlink inside the
  validated root pointing to a sibling temp dir. Under
  --delete-local --yes with an empty remote, the sentinel inside the
  target must survive (walker did not follow the child symlink) and
  the in-cwd file must be deleted (walker did run).

- TestDrivePullSurvivesCircularSymlinkInsideRoot: a child symlink
  pointing at one of its ancestors. The walk must terminate so the
  test does not hang on the per-test timeout.

- TestDrivePullDownloadDoesNotEscapeViaSymlinkParentRef: pins the
  download half of the fix. With --local-dir "link/.." the canonical
  root resolves to cwd, so the remote file must land in cwd, not
  inside the symlink target's parent. The preexisting sentinel inside
  the escape directory must remain untouched.

* fix(drive): +pull --delete-local must not unlink local files shadowed by online docs

CodeRabbit (PR #696) flagged that the --delete-local pass treated any
local path missing from `remoteFiles` as orphaned, but `remoteFiles` only
records type=file entries. If Drive held a docx/sheet/shortcut at the
same rel_path as a local file, the local file would be unlinked even
though Drive still owned that path.

drivePullListRemote now returns two views:

  - files:    rel_path -> file_token, type=file only (download/skip set)
  - allPaths: every entry's rel_path regardless of type

The download loop continues to consume `files`; the --delete-local pass
consults `allPaths`, so an online-doc shadow of a local filename keeps
the local file safe.

Also routes the local walk and the delete through the vfs abstraction
(vfs.ReadDir + vfs.Remove) instead of filepath.WalkDir + os.Remove.
This drops the //nolint:forbidigo justifications and lines up with how
internal/keychain and internal/registry already do filesystem I/O. The
recursive vfs.ReadDir walker preserves the same "do not follow child
symlinks" semantics that filepath.WalkDir gave us, so the canonical-root
escape protections in 240b772 stay intact.

Adds TestDrivePullDeleteLocalPreservesLocalFileShadowedByOnlineDoc as a
direct regression: Drive serves keep.txt (file) plus notes.docx (docx),
local has both keep.txt and a hand-edited notes.docx; --delete-local
--yes must download keep.txt, leave notes.docx untouched, and report
deleted_local=0.

* fix(drive): count +pull delete failures in summary.failed

CodeRabbit (PR #696) flagged that both delete_failed branches in the
--delete-local pass appended an item but left the `failed` counter at
zero, so the JSON summary could legitimately report `"failed": 0` after
a partially-failed mirror. Increment failed in both branches (the
filepath.Rel error path and the vfs.Remove error path) so summary.failed
reflects every item flagged delete_failed in items[].

Adds TestDrivePullDeleteLocalCountsFailureInSummary, which forces
vfs.Remove to fail by chmod-ing the local dir 0o555 right before the
run and restoring 0o755 in t.Cleanup so t.TempDir teardown still works.

* fix(drive): swap +pull walk/remove back to filepath/os to satisfy depguard

The previous fix-up commits used vfs.ReadDir + vfs.Remove inside the
+pull shortcut, which depguard's "shortcuts-no-vfs" rule rejects:
shortcuts cannot import internal/vfs directly. CI lint failed on the
import line.

Restore the same pattern used in drive_status.go and the prior +pull
walker:

- filepath.WalkDir to enumerate files under the canonical absolute
  root, gated by //nolint:forbidigo with a comment explaining why.
- os.Remove for the actual delete, also gated by //nolint:forbidigo.

The canonical-root safety still holds: validate.SafeInputPath bounds
the walk root inside cwd before WalkDir runs, and WalkDir's default
"do not follow child symlinks" policy is preserved. The two earlier
fixes (drivePullListRemote returning allPaths so online-doc shadows
do not look orphaned, and incrementing failed on delete_failed) stay
in place.

`go test ./shortcuts/drive/...` and `golangci-lint run
--new-from-rev=origin/main` are both clean.

* fix(drive): record remote folder rel_path in +pull allPaths

Follow-up to 45fe4e3. The folder branch in drivePullListRemote merged
descendant rel_paths into allPaths but never recorded the folder's own
rel_path, so a local regular file with the same name as a remote
folder still looked orphaned and got unlinked under --delete-local.

Adds the missing allPaths[rel] for the folder case and a regression:
TestDrivePullDeleteLocalPreservesLocalFileShadowedByRemoteFolder
stages a Drive containing a folder named shadow alongside a
downloadable file, with the local side holding a regular file named
shadow; --delete-local --yes must download keep.txt and leave the
shadow file untouched.

* fix(drive): +pull pagination + dir/file conflict + skill doc symlink claim

Codex review on PR #696 surfaced three issues; addressed in one go:

1. drivePullListRemote only honored next_page_token. The shared
   common.PaginationMeta helper accepts both page_token and
   next_page_token; switched +pull over so a backend reply using
   page_token no longer makes the lister stop at page 1 (which would
   silently drop later remote files from both download and
   --delete-local).

2. --if-exists=skip swallowed mirror conflicts. The skip/overwrite
   branch only checked Stat success, so a local directory shadowing a
   remote regular file was reported as action=skipped. Now Stat's
   IsDir() is checked first; the conflict surfaces as action=failed
   with a message naming the directory, under both --if-exists=skip
   and --if-exists=overwrite, and increments summary.failed.

3. Skill doc told callers to soft-link the target into cwd if they
   wanted to pull from outside cwd. That is wrong: SafeInputPath
   evaluates symlinks before the cwd check, so a symlink pointing
   out-of-tree is rejected. Replaced the bogus shortcut with the
   actually viable options (switch the agent working directory,
   physically move/copy the target, or skip the comparison).

Two new regressions:

- TestDrivePullSurfacesDirectoryFileMirrorConflict — table test over
  both policies asserting failed=1, no skipped, action=failed, plus
  the 'is a directory' hint in the error message.

- TestDrivePullPaginationHandlesPageTokenField — first page returns
  page_token (not next_page_token) with has_more=true; asserts both
  pages are fetched and both files land on disk.

* fix(drive): +pull exits non-zero on item failures; gate --delete-local

Two PR-696 review fixes:

- Item-level failures (download error, dir/file conflict, delete error)
  now surface as a structured partial_failure ExitError instead of a
  success envelope with summary.failed > 0. Exit code becomes non-zero
  and error.detail still carries the {summary, items[]} payload, so
  AI / script callers can detect the failure via the exit code without
  reaching into the JSON body.

- A failed download pass now skips the --delete-local walk entirely.
  Previously +pull would continue removing local-only files even when
  the download phase had partially failed, leaving the mirror in a
  half-synced state (some Drive files missing locally AND some
  local-only files unlinked). Re-runs after fixing the download error
  recover cleanly.

Skill doc / shortcut description / flag desc updated to call the
operation a one-way file-level mirror, since --delete-local only
unlinks regular files and does not prune empty local directories left
behind by remote folder deletes (true directory-level mirroring is
explicitly out of scope).

Tests: existing dir/file-conflict and delete-failure cases now assert
the partial_failure ExitError shape; new test covers the
"download fails => --delete-local skipped" gating contract.

* refactor(drive): consolidate folder-listing helpers into listRemoteFolder

Closes the post-#692 / post-#709 TODO that lived in drive_pull.go (and
the matching note in drive_push.go): both #692 and #709 are now on main,
so the three near-identical recursive Drive folder listers can collapse
into one.

New shared helper in shortcuts/drive/list_remote.go:

  driveRemoteEntry { FileToken, Type, RelPath }
  listRemoteFolder(ctx, runtime, folderToken, relBase) -> map[rel]entry

Returns one entry per Drive item (every type), keyed by rel_path.
Subfolders are descended into and the folder's own entry is recorded so
callers can reason about "this rel_path is occupied by a folder"
without re-listing. Pagination via common.PaginationMeta is unchanged.

Each shortcut now derives its own per-shortcut view from the unified
listing:

  - drive_status.go: collapses to remoteFiles (Type=="file" -> token) for
    the content-hash diff.
  - drive_pull.go: derives remoteFiles (Type=="file") for the download
    set, plus remotePaths (every rel_path) as the --delete-local guard.
  - drive_push.go: derives remoteFiles (Type=="file") for upload /
    overwrite / orphan-delete, plus remoteFolders (Type=="folder") for
    the create_folder cache. drivePushRemoteEntry was a duplicate of
    driveRemoteEntry's first two fields and is dropped; the few call
    sites that read .FileToken keep working unchanged.

Per-shortcut copies removed:
  - drive_status.go: listRemoteForStatus, joinRelStatus,
    driveStatusListPageSize/FileType/FolderType
  - drive_pull.go: drivePullListRemote, drivePullJoinRel,
    drivePullListPageSize/FileType/FolderType
  - drive_push.go: drivePushListRemote, drivePushJoinRel,
    drivePushListPageSize/FileType/FolderType, drivePushRemoteEntry

drive_push_test.go's TestDrivePushHelpersRelPath is retargeted at the
shared joinRelDrive; the docstrings on the same-name-conflict tests
were tweaked to refer to "the remoteFiles view" instead of the
just-removed drivePushListRemote.

Net diff: +1 new file, -207 net lines across the four touched files.
All existing unit + e2e dry-run tests pass without behavioral change;
the rel_path / pagination / type-filter contracts each shortcut depends
on are preserved by construction.
2026-04-30 17:07:59 +08:00
calendar-assistant
4fab062219 docs: clarify minutes file-to-notes routing (#732)
Change-Id: If768200b329c5e255b13c1992b8c57d1fd8ec518
2026-04-30 16:59:22 +08:00
wittam-01
f27b8fdf40 feat: add markdown shortcuts and skill docs (#704)
Change-Id: Iced88525deb10b014b755ec68bd9a8ae6a935143
2026-04-30 15:47:36 +08:00
liangshuo-1
c100ca049e feat(cmdutil): support @file for params and data (#724)
* feat(cmdutil): support @file for --params/--data (issue #705)

Inline JSON values for --params/--data are mangled by Windows
PowerShell 5's CommandLineToArgvW. Stdin (-) was the only escape
hatch but supports just one flag at a time.

Extend ResolveInput to accept @<path> (read JSON from a file) and
@@... (escape for a literal @-prefixed value), mirroring the
shortcuts framework's resolveInputFlags semantics. With this, both
--params and --data can be sourced from files in the same call,
sidestepping shell quoting on every platform.

- internal/cmdutil/resolve.go: add @path / @@ handling, trim file
  content like stdin does, error on empty path or empty file
- internal/cmdutil/resolve_test.go: cover file read, whitespace
  trim, missing file, empty path, empty content, @@ escape, plus
  ParseJSONMap / ParseOptionalBody integration through @file
- cmd/api/api.go, cmd/service/service.go: update --params/--data
  help text to mention @file

Change-Id: I366aa0f5783fbec6f05403f7f542505098a98c82

* refactor(cmdutil): route @file through fileio.FileIO abstraction

The first cut of @file support called os.ReadFile directly inside
ResolveInput, bypassing the codebase's fileio.FileIO abstraction
(SafeInputPath validation, pluggable provider). That diverged from
how every other file-reading path works: BuildFormdata for --file
uploads and the shortcuts framework's resolveInputFlags both go
through fileio.FileIO.Open with explicit fileio.ErrPathValidation
handling.

Re-route @file through the same path:

- ResolveInput, ParseJSONMap, ParseOptionalBody now take a
  fileio.FileIO; @path uses fileIO.Open which goes through
  SafeInputPath (control-char rejection, abs-path rejection,
  symlink-escape check) — same security posture as --file
- cmd/api and cmd/service callsites pass
  Factory.ResolveFileIO(ctx); the upload path now reuses the
  resolved fileIO instead of resolving twice
- Path-validation errors surface as
  `--params: invalid file path "...": ...` distinct from
  `--params: cannot read file "...": ...` for genuine I/O errors
- Nil fileIO with an @path returns a clear
  "file input (@path) is not available" error
- Tests use localfileio.LocalFileIO with TestChdir(t, dir),
  matching the existing fileupload_test.go pattern; absolute-path
  rejection and nil-fileIO are covered

This makes the feature behave identically under any FileIO
provider (including server mode) instead of being silently bound
to the local filesystem.

Change-Id: I878c4e8fb03f43f1f19afad75ec3af9cdab7a7f9

* refactor(cmdutil): share at-file input handling

Change-Id: I92a6eb6ea8fd02054bf8f4925cd81807449d5e51
2026-04-30 15:34:45 +08:00
fangshuyu-768
4d68e09537 feat(drive): add +push shortcut for one-way local → Drive mirror (#709)
* feat(drive): add +push shortcut for one-way local → Drive mirror

Mirrors a local directory onto a Drive folder: walks --local-dir,
recursively lists --folder-token, mirrors local subdirectory structure
(including empty dirs) onto Drive via create_folder, and for each
rel_path uploads new files, overwrites already-present files, or skips
them per --if-exists. With --delete-remote --yes, any Drive type=file
entry absent locally is removed; Lark native cloud docs (docx/sheet/
bitable/mindnote/slides) and shortcuts are never overwritten or deleted.

Overwrite hits POST /open-apis/drive/v1/files/upload_all with the
existing file_token in the form body and the response's `version` is
propagated to items[].version, mirroring the markdown +overwrite
contract. Files >20MB fall back to the 3-step
upload_prepare/upload_part/upload_finish path with a single shared fd
reused via io.NewSectionReader per block.

Output is a {summary, items[]} envelope; items[].action is one of
uploaded / overwritten / skipped / folder_created / deleted_remote /
failed / delete_failed.

--delete-remote is bound to --yes upfront in Validate, same pattern as
+pull's --delete-local: a stray flag never silently deletes anything.
Path safety reuses the canonical-root walk + SafeInputPath mechanics
from the sibling +status / +pull commands.

Scopes: drive:drive.metadata:readonly + drive:file:upload +
space:folder:create. space:document:delete is intentionally NOT in the
default set — the framework's pre-flight scope check would otherwise
block plain pushes and dry-runs for callers that haven't granted delete;
--delete-remote --yes relies on the runtime DELETE call to surface
missing_scope. The skill ref calls out the scope so users running
mirror sync can grant it upfront.

13 unit tests cover the upload/overwrite/skip/delete matrix, online-doc
protection, same-name conflict between local file and native cloud doc,
empty-directory mirroring, multipart, scope/path validation, and helper
correctness. 4 dry-run e2e tests pin the request shape.

* fix(drive +push): address review — failure semantics, default skip, scope pre-check, mirror wording

- Item-level failures now bump the exit code via output.ErrBare(ExitAPI)
  while keeping the structured items[] envelope on stdout. The
  --delete-remote phase is skipped entirely when any upload / overwrite /
  folder step fails, so a partial upload never proceeds to delete remote
  orphans (a half-synced state).
- Default --if-exists flipped from "overwrite" to the safer "skip": the
  upload_all overwrite-version protocol field is still rolling out, so
  the default no longer fails a first push against a pre-populated
  folder. Callers must opt into "overwrite" explicitly.
- --delete-remote --yes now triggers a conditional space:document:delete
  scope pre-check in Validate via the new RuntimeContext.EnsureScopes
  helper, so a missing grant fails the run before any upload — instead
  of after the upload phase, which would leave orphans uncleaned.
- Description, Tips and skill doc rewritten to call this a file-level
  mirror (not a directory mirror): the command does not remove
  remote-only directories or close gaps in directory structure that
  exists only on Drive.

Tests:
- new TestDrivePushDefaultsToSkipForExistingRemote pins the new default
- new TestDrivePushSkipsDeleteAfterUploadFailure pins the half-sync
  guard and the non-zero exit on item-level failure
- new TestDrivePushExitsZeroOnCleanRun pins the inverse
- existing tests that relied on the old overwrite default now opt in
  explicitly with --if-exists=overwrite
- TestDrivePushOverwriteWithoutVersionFails updated to assert
  *output.ExitError with Code=ExitAPI
- new TestDrive_PushDryRunAcceptsDeleteRemoteWithYes (e2e) symmetric to
  the existing reject-without-yes test, pinning that EnsureScopes is a
  silent no-op when the resolver has no scope metadata

* fix(drive +push): close remaining CodeRabbit comments

Three small follow-ups on the +push review thread that were still
open after the earlier failure-semantics / default-skip / scope
pre-check fix:

- drivePushUploadAll now extracts data.file_token before checking
  larkCode, and surfaces the returned token on the partial-success
  path (non-zero code + non-empty file_token). Without this, a backend
  response where bytes already landed but code != 0 would force the
  caller to fall back to entry.FileToken and silently lose the actual
  Drive token, defeating the overwrite-error token-stability handling
  in Execute.
- TestDrivePushOverwriteWithoutVersionFails switched from "tok_keep"
  to "tok_keep_new" in the upload_all stub and now asserts that the
  returned token (not entry.FileToken) lands in items[].file_token —
  pins the contract that a regression to the fallback branch would
  otherwise pass silently.
- New TestDrivePushOverwritePartialSuccessSurfacesReturnedToken pins
  the new partial-success branch end-to-end.
- drive_push_dryrun_test.go: tightened the three Validate / cobra
  rejections from `exit != 0` to exact codes — `exit == 2` for the
  two Validate-stage rejections (--local-dir absolute,
  --delete-remote without --yes), `exit == 1` for the cobra
  required-flag check (--folder-token missing). Locks in failure
  classification so a regression that misroutes the error layer
  doesn't slip through.
2026-04-30 15:00:44 +08:00
fangshuyu-768
a3bbe00ee0 feat(drive): add +status shortcut for content-hash diff (#692)
* feat(drive): add +status shortcut for content-hash diff

Adds `drive +status`, a read-only diff primitive that walks --local-dir,
recursively lists --folder-token, and reports four buckets — new_local,
new_remote, modified, unchanged — by SHA-256 content hash.

Implementation notes:

- Drive's list/metas APIs do not expose a content hash, so files
  present on both sides are downloaded via DoAPIStream and hashed in
  memory (sha256 + io.Copy, no disk write). Files only on one side are
  not fetched. The command stays Risk: "read".

- Only Drive entries with type=file participate. Online docs (docx,
  sheet, bitable, mindnote, slides) and shortcuts are skipped — there
  is no equivalent local binary to hash against.

- --local-dir is funneled through the framework's
  validate.SafeLocalFlagPath helper so that absolute paths and any ..
  that escapes cwd are rejected with --local-dir in the error message
  (rather than the internal default --file). FileIO().Stat() then
  enforces existence and the IsDir check.

- Local walk uses filepath.WalkDir behind a //nolint:forbidigo comment.
  The runtime FileIO interface has no walker today and shortcuts can't
  import internal/vfs; SafeInputPath has already bounded the walk root
  inside cwd, so the bare walk is acceptable until a runtime-level
  walker lands.

- Scopes: drive:drive.metadata:readonly (list folders) +
  drive:file:download (fetch files for hashing). The broader
  drive:drive scope is disabled by enterprise policy in some tenants;
  this narrower pair was verified end-to-end.

Tests cover the four-bucket categorization with a nested subfolder and
docx/shortcut filtering, plus validation errors for missing local-dir,
non-directory local-dir, and absolute-path local-dir.

* docs(skills): document drive +status in lark-drive skill

Adds references/lark-drive-status.md covering parameters, output
schema, the type=file scoping rule, and the network-traffic caveat
(hash is streamed in memory, but bytes still cross the wire).

Notes that --local-dir is bounded to cwd by the CLI's path validation,
and that when a user wants to compare a directory outside cwd the
agent should ask the user to relocate or to switch the agent's working
directory rather than `cd`-ing on its own.

Wires +status into the Shortcuts table in SKILL.md.

* test(drive): cover --folder-token validation and add +status dry-run E2E

Addresses two CodeRabbit review comments on PR #692:

- Adds TestDriveStatusRejectsEmptyFolderToken and
  TestDriveStatusRejectsMalformedFolderToken so the Validate-stage
  required-check and the ResourceName format guard for --folder-token
  are exercised, not just --local-dir.

- Adds tests/cli_e2e/drive/drive_status_dryrun_test.go which drives
  the real binary in dry-run mode and asserts:

  * the request shape (GET /open-apis/drive/v1/files with
    folder_token in the dry-run envelope), plus the description text,
  * --local-dir absolute paths are rejected by Validate (which still
    runs under --dry-run) with --local-dir surfaced in the message,
  * cobra's required-flag enforcement rejects a missing
    --folder-token before any custom validation.

* fix(drive): walk +status on canonical absolute root to close symlink/.. escape

Reported in PR review: --local-dir was validated through
SafeLocalFlagPath, but the actual walk used the user-supplied raw
string. SafeLocalFlagPath returns the original value (it only checks
the path through SafeInputPath and discards the canonical form), and
SafeInputPath itself relies on filepath.Clean for path normalization.
filepath.Clean shrinks "link/.." to "." purely as string manipulation,
so the validator sees a path inside cwd. The kernel, however, resolves
"link/.." through the symlink target's parent — which is outside cwd
and is what filepath.WalkDir actually traverses.

Fix: in Execute, resolve --local-dir via validate.SafeInputPath to
get the canonical absolute path (this one fully evaluates symlinks
across the entire path), and walk that path. Each absolute walk hit
is converted to a cwd-relative form via filepath.Rel against
validate.SafeInputPath(".") so FileIO.Open's existing SafeInputPath
guard (which rejects absolute paths) still applies.

Adds TestDriveStatusDoesNotEscapeViaSymlinkParentRef as a regression:
it stages an "escape" sibling directory containing a sentinel file,
adds a "link" symlink in cwd pointing into the escape directory, and
runs +status with --local-dir "link/..". Without this fix, the raw
walk visits the sentinel and leaks it into new_local; with the fix,
the walk stays inside the canonical cwd.

A standalone repro confirms the underlying behavior: raw
filepath.WalkDir("link/..", ...) traversed dozens of unrelated files
in the kernel-resolved parent directory; walking the canonical root
visits only the legitimate cwd contents.

* test(drive): pin walker behavior on child / circular symlinks for +status

Adds two corner-case regressions to back up the canonical-root walk fix:

- TestDriveStatusSkipsSymlinkInsideRoot: a child symlink under
  --local-dir that points to a sibling temp dir outside cwd. WalkDir's
  default policy must report it as a non-regular entry so the callback
  skips it, and the sentinel inside the target must not surface in
  new_local. This pins the contract our caller relies on (walk
  declines to follow child symlinks even when the canonical root
  resolves cleanly).

- TestDriveStatusSurvivesCircularSymlinkInsideRoot: a child symlink
  pointing back at one of its ancestors. The walk must terminate and
  surface the legitimate sibling file; if WalkDir ever followed the
  loop, the per-test timeout would catch it.

* fix(drive): close +status review gaps from Codex (pagination, doc, live E2E)

Three independent fixes flagged on PR #692:

1. Route the recursive Drive folder listing through common.PaginationMeta
   instead of reading next_page_token directly. The shared helper accepts
   both page_token and next_page_token, matching what okr/im already do
   and keeping +status safe against a backend field rename. Adds
   TestDriveStatusPaginatesRemoteListing, which serves a 2-page response
   where page 1 advertises the cursor as next_page_token and page 2 as
   page_token; either spelling alone would silently drop one page.

2. The skill doc previously suggested "or symlink the target into cwd"
   as a workaround for cwd-relative --local-dir. SafeInputPath calls
   filepath.EvalSymlinks before checking isUnderDir(canonicalCwd), so
   any symlink whose final target sits outside cwd still gets rejected
   as `unsafe file path`. Rewrite the section so agents stop steering
   users into a path that always errors out.

3. Add tests/cli_e2e/drive/drive_status_workflow_test.go — the live
   E2E that AGENTS.md requires for new shortcuts. Seeds a real Drive
   folder with three uploaded files (unchanged.txt, modified.txt,
   remote-only.txt), seeds a local tree with matching/diverging
   content plus a local-only.txt, runs +status, and asserts each of
   the four buckets contains exactly the file we expect with the
   right file_token. Cleanup of every uploaded file plus the parent
   folder is registered through the existing best-effort cleanup
   helpers. Coverage table bumped: drive +status moves to ✓ and the
   denominator goes from 28→29 to account for the new shortcut.

Codex also flagged the local-side filepath.WalkDir as a vfs-bypass.
Investigated: the depguard rule shortcuts-no-vfs explicitly forbids
shortcuts from importing internal/vfs (see commit c1b0bed on the
+pull branch where the same migration was rejected by CI). The
filepath.WalkDir + nolint:forbidigo pattern in walkLocalForStatus is
the lint-required convention until FileIO grows a walker, so leaving
it as-is.
2026-04-30 14:27:25 +08:00
calendar-assistant
0250054a90 feat(minutes): add media upload shortcut (#725)
Support minutes +upload to generate a minute from an uploaded media file token.

Change-Id: I59c0719a39541134e395a23262aea7f387105715

Co-authored-by: calendar-assistant <calendar-assistant@users.noreply.github.com>
2026-04-30 11:19:22 +08:00
SunPeiYang996
d7ee5b5769 feat: guide lark-doc v2 usage (#710)
## Summary
Add explicit guidance on the parent `docs` command so agents pick the right
lark-doc API version. Without this, agents that have an older lark-doc skill
installed can mistakenly mix v2 flags into a v1 flow.

## Changes
- Add `--api-version` help flag and a Tips section to `docs` so `lark docs --help`
  (and `--api-version v2`) explain when v2 should be used.
- Refresh the lark-doc skill references and `docs_fetch_v2` keyword flag
  description for clarity.
- Add `shortcuts/register_test.go` covering the new docs help wiring.

## Test Plan
- [x] Unit tests pass (`go test ./shortcuts/...`)
- [x] Manual local verification confirms the `lark docs --help` and
      `lark docs --help --api-version v2` commands work as expected

## Related Issues
- None

Change-Id: Id3b3196e6a069bb52f95a6fc679b8258313faf3d
2026-04-29 22:40:20 +08:00
liangshuo-1
b37adfd0ee chore(release): v1.0.22 (#719)
Change-Id: If383f91a8b934a4feec3ff6d371a3f2f6a94ec09
2026-04-29 20:04:06 +08:00
bytedance-zxy
082275f32b feat(task): add resource agent & agent_task_step_info (#693)
Change-Id: I3b2d8ee72361aee9b68a5bbbafcf594f220d3105
2026-04-29 19:13:05 +08:00
zero-my
2eb9fae575 Feat/task app members (#712)
* feat: support app task members by id

* docs: clarify task member id formats
2026-04-29 19:04:27 +08:00
sang-neo03
418192507e fix(install): make Windows zip extraction resilient (issue #603) (#713)
The Windows extraction step relied on `powershell -Command Expand-Archive`,
which fails when:
  - Microsoft.PowerShell.Archive (a script module) cannot be loaded due to
    PSModulePath shadowing (Store-installed pwsh injecting WindowsApps
    paths) or ExecutionPolicy Restricted (issue #603), or
  - the temp directory contains characters that corrupt PowerShell string
    parsing (e.g. a single quote in TEMP).

Switch to a two-tier extraction:
  1. Primary: Add-Type System.IO.Compression.FileSystem +
     [ZipFile]::ExtractToDirectory. Bypasses the PowerShell module system
     entirely. .NET 4.5+, available on Win 8 / Server 2012 by default and
     widely on Win 7 SP1.
  2. Fallback: Expand-Archive -LiteralPath, kept for the rare host without
     .NET 4.5 but with PS 5.0+ (e.g. Win 7 SP1 with WMF 5).

Both paths pass file paths through env vars ($env:LARK_CLI_ARCHIVE /
$env:LARK_CLI_DEST) so quoting / wildcard chars in the path can no longer
break command parsing. -LiteralPath ensures Expand-Archive treats the value
literally rather than as a wildcard pattern. $ErrorActionPreference='Stop'
makes non-terminating cmdlet errors propagate as non-zero exit codes.

Also drop `stdio: "ignore"` so the actual PowerShell error surfaces in the
postinstall log when both paths fail, instead of leaving users with
"Command failed: powershell ..." with no detail.

Verified on Windows 10 + PS 5.1:
  - Reproduced #603 with shadow Microsoft.PowerShell.Archive +
    Restricted ExecutionPolicy: original install.js fails, patched
    install.js succeeds.
  - Reproduced single-quote-in-TEMP path corruption: original fails,
    patched succeeds.
  - Fallback path verified end-to-end with primary forced to fail.
  - Normal-environment install: no regression.
2026-04-29 17:50:46 +08:00
liangshuo-1
7752afab96 fix(config/init): respect --brand flag in --new mode (#711)
* feat(contact +search-user): add --queries multi-name fanout

Add --queries CSV flag to lark-cli contact +search-user for parallel
multi-name fanout (up to 20 entries, partial-failure tolerant).

Output shape in fanout mode:
- data.users[] rows carry matched_query (string)
- data.queries[] sidecar lists each input with {query, error?, has_more}
- top-level data.has_more removed (per-query in queries[])
- error is omitempty; absent on success

Single --query mode is byte-for-byte unchanged (regression-guarded).
--queries is mutually exclusive with --query and --user-ids; bool
filters propagate to every sub-request.

Workers run with WaitGroup + buffered semaphore + index-slot writes;
each has defer recover() converting panics to internal error: ... in
the sidecar (no stack to stderr). Pre-canceled context returns
context canceled without making the request.

All-failed exit propagates first failure's HTTP/API code via ErrAPI;
falls back to ExitInternal for transport/parse/panic/ctx-canceled
(avoids emitting code 0, which means success in the Lark protocol).
HTTP non-200 ErrMsg now includes truncated response body for diagnosis.

Drive-by: signature field is now omitempty (mostly empty in practice).

Infrastructure:
- internal/httpmock gains BodyFilter/OnMatch/Reusable/CapturedBodies
  hooks to support concurrent stub-driven tests
- internal/output adds 'users' to knownArrayFields so CSV picks the
  primary array correctly

Change-Id: I3c14195fb8e094ae150002d90c36a0e4a0cc97d0

* fix(config/init): use parseBrand(opts.Brand) instead of hardcoded BrandFeishu in --new mode

The --new flag was ignoring the --brand flag and always passing BrandFeishu
to runCreateAppFlow. Now it correctly uses parseBrand(opts.Brand) to
respect the user's --brand parameter (e.g., --brand lark for international).

Change-Id: I1d4d78b3d586142b0210e6ceaeeb467b14e9c1a1
2026-04-29 17:13:47 +08:00
liangshuo-1
f7a56f38b1 feat(contact +search-user): add --queries multi-name fanout (#707)
Add --queries CSV flag to lark-cli contact +search-user for parallel
multi-name fanout (up to 20 entries, partial-failure tolerant).

Output shape in fanout mode:
- data.users[] rows carry matched_query (string)
- data.queries[] sidecar lists each input with {query, error?, has_more}
- top-level data.has_more removed (per-query in queries[])
- error is omitempty; absent on success

Single --query mode is byte-for-byte unchanged (regression-guarded).
--queries is mutually exclusive with --query and --user-ids; bool
filters propagate to every sub-request.

Workers run with WaitGroup + buffered semaphore + index-slot writes;
each has defer recover() converting panics to internal error: ... in
the sidecar (no stack to stderr). Pre-canceled context returns
context canceled without making the request.

All-failed exit propagates first failure's HTTP/API code via ErrAPI;
falls back to ExitInternal for transport/parse/panic/ctx-canceled
(avoids emitting code 0, which means success in the Lark protocol).
HTTP non-200 ErrMsg now includes truncated response body for diagnosis.

Drive-by: signature field is now omitempty (mostly empty in practice).

Infrastructure:
- internal/httpmock gains BodyFilter/OnMatch/Reusable/CapturedBodies
  hooks to support concurrent stub-driven tests
- internal/output adds 'users' to knownArrayFields so CSV picks the
  primary array correctly

Change-Id: I3c14195fb8e094ae150002d90c36a0e4a0cc97d0
2026-04-29 17:03:21 +08:00
sang-neo03
ea056d132e feat(install): enhance binary URL resolution with environment variabl… (#690)
* feat(install): enhance binary URL resolution with environment variable support

* fix(install): defer mirror resolution into install() to surface friendly errors

resolveMirrorUrl was called at module scope, so an invalid
LARK_CLI_DOWNLOAD_HOST (e.g. file://) threw before the try/catch in the
postinstall entrypoint, dumping a raw stack trace instead of the recovery
guidance with proxy/registry/host-override options.

Move resolution into install() via getMirrorUrl() so the throw is caught
and the user sees the actionable help text.

* fix(install): keep npmmirror fallback when npm_config_registry is set

resolveMirrorUrl returned a single URL, so any non-default
npm_config_registry replaced the npmmirror fallback entirely. Corporate
npm proxies (Verdaccio, Artifactory, Nexus) often only serve npm package
metadata and don't host /-/binary/<pkg>/..., turning previously-working
installs into 404s when GitHub is unreachable.

Switch to resolveMirrorUrls returning an ordered chain:
  - LARK_CLI_DOWNLOAD_HOST set → [override] only (explicit user choice;
    no silent leak to npmmirror).
  - Otherwise → [derived_from_registry?, npmmirror_default]; npmmirror
    is always the final entry, restoring the pre-PR safety net.

install() now walks [GITHUB_URL, ...mirrorUrls] and stops at the first
success.

* fix(install): skip GitHub when LARK_CLI_DOWNLOAD_HOST is set

The download loop unconditionally tried GITHUB_URL first, even when the
user explicitly named a download host. In locked-down networks, probing
github.com can trigger DLP / firewall alerts and contradicts the
explicit-override semantics ("use only this host, nothing else").

When LARK_CLI_DOWNLOAD_HOST is set, the chain is now just [override].
When it isn't, behavior is unchanged: [GITHUB_URL, derived?, npmmirror].

* refactor(install): drop LARK_CLI_DOWNLOAD_HOST env override

Issue #640 only asked for --registry to influence the binary download.
The LARK_CLI_DOWNLOAD_HOST escape hatch was added speculatively for
locked-down networks but is YAGNI — users in those environments already
have npm-level mirrors (--registry) or proxy controls (https_proxy).

Removing it shrinks the surface area:
  - delete parseDownloadBase() and its strict https-only validation
  - drop the install() branch that skipped GitHub on explicit override
  - simplify failure-help message to two recovery options

Resolution chain becomes [GITHUB, derived_from_npm_config_registry?,
npmmirror_default]. The npmmirror tail still preserves the pre-PR safety
net when a corp registry doesn't actually serve /-/binary/<pkg>/...

End-to-end verified on Linux + Windows via real `npm install -g <tgz>`:
all four user scenarios pass, with the issue #640 path (--registry=
npmmirror + GitHub blocked) finishing in 2s on Linux / 6s on Windows.
2026-04-29 16:46:30 +08:00
kongenpei
7fc963f455 docs: clarify base search routing (#708)
* docs: clarify base search routing

* docs: refine base search guidance

* docs: clarify complex base search cases

* docs: define complex base search

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-29 16:21:34 +08:00
ethan-zhx
520acb618c feat(slides):slides template (#684)
* feat(slides):slides template

chore:add scripts

feat(slides): add template-first guidance to lark-slides skill

docs: restructure slide templates to flat layout with catalog routing

- Move 42 template XMLs from 8 category subdirs into single templates/ dir
- Encode category in filename: {category}--{name}.xml
- Add template-catalog.md as lightweight routing index (scene/tone/formality)
- Update SKILL.md workflow to include template matching step (Step 2)
- Update style guide to reference templates instead of hardcoded colors

docs: add categorized slides template XML references

Add 42 slide templates extracted from API responses, organized by category:
office(8), product(6), operations(4), marketing(8), hr(3), administration(4), personal(6), misc(3)

Change-Id: Ib3d85ffd7563a1693d4ed603fe9435fd716890ca

* refactor: optimize lark slides template

Change-Id: I40ab98d3882095262cc533bcb9baf614cff9adfa

---------

Co-authored-by: caichengjie.viper <caichengjie.viper@bytedance.com>
2026-04-29 16:00:03 +08:00
chanthuang
dce2beb91c feat(mail): support calendar events in emails (#646)
* feat(ics): add RFC 5545 iCalendar generator and parser

Add shortcuts/mail/ics package:
- builder.go: generates METHOD:REQUEST ICS with VEVENT, ORGANIZER,
  ATTENDEE, DTSTART/DTEND with timezone, UID, and X-LARK-MAIL-DRAFT
- parser.go: parses ICS into ParsedEvent struct, detects IsLarkDraft
- Handles CN quoting, control-char sanitization, email validation,
  line folding per RFC 5545, and TZID edge cases

Change-Id: I01d13285a57a5a4de50891c54d655efa8423c3c1

* feat(mail): support calendar events in emails

- Add --event-summary/start/end/location flags to +send, +reply,
  +reply-all, +forward, +draft-create
- Build ICS and embed as text/calendar in multipart/alternative
- Validate event time range and enforce --event/--send-time mutual
  exclusion (extracted into validateEventSendTimeExclusion)
- CalendarBody() in emlbuilder places ICS correctly
- Exclude BCC from ATTENDEE list

Change-Id: Icf9e49ababebc4e8fcf36760ab613c64938c2744

* feat(mail): X-LARK-MAIL-DRAFT and read-only calendar guard

- ics.Build() writes X-LARK-MAIL-DRAFT:TRUE so Feishu client
  recognizes CLI-created calendar events as editable
- ics.ParseEvent() detects IsLarkDraft field
- +draft-edit rejects --set-event-* on calendars without
  X-LARK-MAIL-DRAFT marker (read-only after send)
- Export FindPartByMediaType from draft package for cross-package use
- Add set_calendar/remove_calendar patch ops with full test coverage

Change-Id: I7d547a4b40880e8d4ee3fecf68864d6ea89e66cd

* feat(mail): forward preserves original calendar ICS

When forwarding an email that contains a calendar event (body_calendar),
pass through the original ICS bytes as text/calendar part if no new
--event-* flags are specified.

Change-Id: I67d2e82604eaf969cee8c7e0bedcf32198d12d57

* docs(mail): document calendar invitation feature

- Add --event-* params to +send, +reply, +reply-all, +forward,
  +draft-create, +draft-edit reference docs
- Add calendar_event output section to +message reference
- Add calendar invitation workflow to skill-template/domains/mail.md
- Regenerate SKILL.md via gen-skills

Change-Id: Iccacd06990d91e1cf3beb896d5b772d27e5e29ff

* fix(mail): reject --set-event-start/end/location without --set-event-summary

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

* fix(mail): include --event-location in validateEventFlags; fix stale comment

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

* fix(mail): clear stale headers when wrapping single-leaf body in multipart/alternative

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

* fix(mail): add method=REQUEST to text/calendar MIME part created by set_calendar

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

* fix(mail): use post-edit recipients for ICS attendees when --set-to combined with --set-event-*

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

* fix(mail): cover add_recipient/remove_recipient in ICS attendee resolution

Extract effectiveRecipients() to replay all three recipient op types
(set_recipients, add_recipient, remove_recipient) before building the
ICS for set_calendar, so patch-file recipient changes are reflected in
ATTENDEE metadata.

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

* fix(mail): derive method= from ICS body in writeCalendarPart instead of hardcoding REQUEST

Passthrough ICS (e.g. forwarded METHOD:CANCEL) previously emitted a
Content-Type with method=REQUEST, disagreeing with the body. Now
extractICSMethod() scans the ICS for METHOD: and falls back to REQUEST
when absent, keeping existing behavior for our own generated ICS.

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

* fix(mail): normalize calendar_event start/end to UTC in output

Callers expect RFC 3339 UTC strings; source ICS with TZID offsets
previously emitted +08:00 instead of Z.

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

* fix(mail): make ICS parser case-insensitive and handle parameterized property names

RFC 5545 §3.1 allows any case and optional parameters on all property
names. Unify UID/SUMMARY/LOCATION/DTSTART/etc. to compare via
strings.ToUpper(name) and add HasPrefix checks for the NAME; form,
consistent with how ORGANIZER and ATTENDEE were already handled.

Change-Id: I7dc642dd210a3256f2189a901a2d9518ea284815
Co-Authored-By: AI
2026-04-29 15:31:38 +08:00
zgz2048
97968b6ef2 docs(base): align base skills and view config contracts (#653)
* docs(base): align base skills and view config contracts

1. Rework the lark-base source-of-truth docs around canonical field, cell, record and view payload shapes.

2. Refresh view, workflow, lookup and related references against current openapi behavior and remove stale or broken guidance.

3. Remove dead array-wrapper handling from view sort/group setters and add unit plus dry-run e2e coverage for object-only input.

* docs(base): drop view config code changes from doc refactor

1. Revert the temporary Base view config Go and test adjustments so this PR only keeps lark-base skill and reference updates.

2. Preserve the documentation contract changes while leaving runtime behavior unchanged from the pre-refactor implementation.

* docs(base): revert temporary view config code cleanup

1. Restore the pre-refactor Base view config Go paths and related unit tests so this PR keeps runtime behavior unchanged.

2. Leave the lark-base skill and reference updates in place as the only intended product change in this branch.

* docs(base): fix progress color typo

* docs(base): trim padding in reference docs

1. Remove obviously excessive alignment spaces from base reference examples and operator lists.

2. Shorten a few overlong separator rows in the formula guide to reduce low-value formatting noise.

3. Keep the changes scoped to four lark-base reference files without changing documented behavior.

* docs(base): clarify field description guidance

* test: isolate dry-run e2e config state

* chore: update data-query prompt

* docs(base): simplify formula filter guidance

* docs(base): drop stage field mention from data query

* revert: keep e2e changes scoped to base docs

* docs(base): clarify dashboard field type wording

* docs(base): trim number filter operators
2026-04-29 15:30:11 +08:00
Yuxuan Zhao
6bb988a655 test: align e2e yes flags with risk metadata (#701) 2026-04-28 23:06:43 +08:00
liangshuo-1
4422265d5f test(im): drop --yes from chats link e2e (not high-risk-write) (#700)
`im chats link` is registered as a regular service method (no
`risk: high-risk-write` annotation), so the framework does not register
the `--yes` flag on it. Setting `Yes: true` on the e2e Request makes
the runner append `--yes`, which cobra rejects with `unknown flag:
--yes` before the request is ever issued — the rest of the assertions
then fall through with empty stdout.

The flag was added in #633 alongside the risk-tiering rollout that
covered other workflows that genuinely flipped to high-risk-write.
For chats link the API call (creating a chat share link with a
configurable validity period) is not destructive and was never
re-classified, so the line is just leftover from that pass. Drop it
to restore the e2e green; if we ever decide to gate share-link
creation behind confirmation we can re-add it together with the
metadata flip.

Change-Id: Ieb094407a7f0fa18cd130a9d80c7146274b5ecc7
2026-04-28 22:06:13 +08:00
370 changed files with 101096 additions and 5951 deletions

View File

@@ -2,6 +2,64 @@
All notable changes to this project will be documented in this file.
## [v1.0.24] - 2026-05-06
### Features
- **sheets**: Add sheet management shortcuts (#722)
- **base**: Support batch record get and delete (#630)
- **task**: Add upload task attachment shortcut (#736)
- **drive**: Pre-flight 10000-rune total cap for `+add-comment` `reply_elements` (#605)
### Bug Fixes
- **auth**: Handle missing scopes and device flow improvements (#752)
- Add url to markdown `+create` output (#753)
### Documentation
- Refine field update conversion guidance (#748)
## [v1.0.23] - 2026-04-30
### Features
- **drive**: Add `+pull` shortcut for one-way Drive → local mirror (#696)
- **drive**: Add `+push` shortcut for one-way local → Drive mirror (#709)
- **drive**: Add `+status` shortcut for content-hash diff (#692)
- **drive**: Support `--file-name` for drive export (#685)
- **base**: Add markdown output for record reads (#726)
- **minutes**: Add media upload shortcut (#725)
- **doc**: Warn when callout uses `type=` without `background-color` (#467)
- **cmdutil**: Support `@file` for params and data (#724)
- Add markdown shortcuts and skill docs (#704)
### Documentation
- **doc**: Guide lark-doc v2 usage (#710)
- **minutes**: Clarify minutes file-to-notes routing (#732)
## [v1.0.22] - 2026-04-29
### Features
- **task**: Add resource agent & `agent_task_step_info` (#693)
- **task**: Support app task members by id (#712)
- **contact**: Add `--queries` multi-name fanout to `+search-user` (#707)
- **slides**: Add slide templates with template-first skill guidance (#684)
- **mail**: Support calendar events in emails (#646)
- **install**: Honor `npm_config_registry` for binary URL resolution with npmmirror fallback (#690)
### Bug Fixes
- **install**: Make Windows zip extraction resilient (#713)
- **config/init**: Respect `--brand` flag in `--new` mode (#711)
### Documentation
- **base**: Clarify base search routing (#708)
- **base**: Align base skills and view config contracts (#653)
## [v1.0.21] - 2026-04-28
### Features
@@ -539,6 +597,9 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.24]: https://github.com/larksuite/cli/releases/tag/v1.0.24
[v1.0.23]: https://github.com/larksuite/cli/releases/tag/v1.0.23
[v1.0.22]: https://github.com/larksuite/cli/releases/tag/v1.0.22
[v1.0.21]: https://github.com/larksuite/cli/releases/tag/v1.0.21
[v1.0.20]: https://github.com/larksuite/cli/releases/tag/v1.0.20
[v1.0.19]: https://github.com/larksuite/cli/releases/tag/v1.0.19

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, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 23 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, Markdown, and more, with 200+ commands and 24 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** — 23 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 16 business domains, 200+ curated commands, 23 AI Agent [Skills](./skills/)
- **Agent-Native Design** — 24 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 17 business domains, 200+ curated commands, 24 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
@@ -28,6 +28,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media |
| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards |
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
| 📝 Markdown | Create, fetch, and overwrite Drive-native `.md` files |
| 📊 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 |
@@ -139,6 +140,7 @@ lark-cli auth status
| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions |
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
| `lark-drive` | Upload, download files, manage permissions & comments |
| `lark-markdown` | Create, fetch, and overwrite Drive-native Markdown files |
| `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 |

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 23 个 AI Agent [Skills](./skills/)。
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 24 个 AI Agent [Skills](./skills/)。
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
## 为什么选 lark-cli
- **为 Agent 原生设计** — 23 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 16 大业务域、200+ 精选命令、23 个 AI Agent [Skills](./skills/)
- **为 Agent 原生设计** — 24 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 17 大业务域、200+ 精选命令、24 个 AI Agent [Skills](./skills/)
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
@@ -28,6 +28,7 @@
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
| 📝 Markdown | 创建、读取、覆盖更新 Drive 中的原生 `.md` 文件 |
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
@@ -140,6 +141,7 @@ lark-cli auth status
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown |
| `lark-drive` | 上传、下载文件,管理权限与评论 |
| `lark-markdown` | 创建、读取、覆盖更新 Drive 中的原生 Markdown 文件 |
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |

View File

@@ -81,8 +81,8 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
},
}
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(&opts.Params, "params", "", "query parameters JSON (supports - for stdin, @file for file input)")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
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")
@@ -112,6 +112,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
// 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
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
// Validate --file mutual exclusions first.
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
@@ -123,7 +124,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}
@@ -145,7 +146,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
// 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)
dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}
@@ -161,7 +162,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
}
fd, err := cmdutil.BuildFormdata(
opts.Factory.ResolveFileIO(opts.Ctx),
fileIO,
fieldName, filePath, isStdin, stdin, dataFields,
)
if err != nil {
@@ -171,7 +172,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
} else {
// Normal path: JSON body.
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}

View File

@@ -4,6 +4,7 @@
package auth
import (
"errors"
"fmt"
"github.com/spf13/cobra"
@@ -42,7 +43,18 @@ func authListRun(opts *ListOptions) error {
multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 {
fmt.Fprintln(f.IOStreams.ErrOut, "Not configured yet. Run `lark-cli config init` to initialize.")
// auth list is a read-only probe; the "configured but no users"
// branch below already returns exit 0 with a stderr hint, so we
// keep the same contract here. We still want the hint to be
// workspace-aware, so we pull the message+hint out of
// NotConfiguredError() instead of hard-coding it.
var cfgErr *core.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) {
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
if cfgErr.Hint != "" {
fmt.Fprintln(f.IOStreams.ErrOut, " hint: "+cfgErr.Hint)
}
}
return nil
}

59
cmd/auth/list_test.go Normal file
View File

@@ -0,0 +1,59 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// TestAuthListRun_NotConfigured_ReturnsExitZero pins the contract that
// `lark-cli auth list` is a read-only probe and must not fail-hard when no
// config exists yet — scripts and AI agents use it as an idempotent "do I
// have any users?" check, so the exit code carries semantic weight. Pair
// that with the existing "configured but no logged-in users" branch (also
// exit 0) and both empty states are consistent.
func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f}); err != nil {
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
}
// Local workspace → hint must mention init, not bind.
out := stderr.String()
if !strings.Contains(out, "config init") {
t.Errorf("local hint missing config init: %s", out)
}
if strings.Contains(out, "config bind") {
t.Errorf("local hint must not mention config bind: %s", out)
}
}
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
// reason this hint exists workspace-aware in the first place: an AI agent
// in OpenClaw / Hermes that probes auth list before binding gets routed to
// `config bind --help` instead of the local-only `config init`.
func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
prev := core.CurrentWorkspace()
t.Cleanup(func() { core.SetCurrentWorkspace(prev) })
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f}); err != nil {
t.Fatalf("auth list should still succeed under agent workspace; got: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "config bind --help") {
t.Errorf("agent hint must point at config bind --help: %s", out)
}
if strings.Contains(out, "config init") {
t.Errorf("agent hint must not mention config init: %s", out)
}
}

View File

@@ -49,10 +49,9 @@ For AI agents: this command blocks until the user completes authorization in the
browser. Run it in the background and retrieve the verification URL from its output.`,
RunE: func(cmd *cobra.Command, args []string) error {
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
return output.Errorf(output.ExitValidation, "strict_mode",
"strict mode is %q, user login is not allowed. "+
"This setting is managed by the administrator and must not be modified by AI agents.",
mode)
return output.ErrWithHint(output.ExitValidation, "strict_mode",
fmt.Sprintf("strict mode is %q, user login is disabled in this profile", mode),
"if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
}
opts.Ctx = cmd.Context()
if runF != nil {
@@ -243,7 +242,11 @@ func authLoginRun(opts *LoginOptions) error {
return nil
}
// Step 2: Show user code and verification URL
// Step 2: Show user code and verification URL.
// Both branches surface AgentTimeoutHint, but on different channels:
// JSON mode embeds it as a structured field (so an agent that captures
// stdout into a JSON parser sees it without stream-mixing surprises),
// text mode prints to stderr (alongside the URL prompt).
if opts.JSON {
data := map[string]interface{}{
"event": "device_authorization",
@@ -251,6 +254,7 @@ func authLoginRun(opts *LoginOptions) error {
"verification_uri_complete": authResp.VerificationUriComplete,
"user_code": authResp.UserCode,
"expires_in": authResp.ExpiresIn,
"agent_hint": msg.AgentTimeoutHint,
}
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
@@ -260,6 +264,7 @@ func authLoginRun(opts *LoginOptions) error {
} else {
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
// Step 3: Poll for token
@@ -346,9 +351,15 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
}
}
// Skip the stderr hint in JSON mode — the --no-wait call that issued the
// device_code already returned the hint as a JSON field, and writing
// text to stderr would pollute consumers that combine streams via 2>&1.
if !opts.JSON {
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
log(msg.WaitingAuth)
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
opts.DeviceCode, 5, 180, f.IOStreams.ErrOut)
opts.DeviceCode, 5, 600, f.IOStreams.ErrOut)
if !result.OK {
if shouldRemoveLoginRequestedScope(result) {

View File

@@ -22,6 +22,7 @@ type loginMsg struct {
// Non-interactive prompts (login.go)
OpenURL string
WaitingAuth string
AgentTimeoutHint string
AuthSuccess string
LoginSuccess string
AuthorizedUser string
@@ -58,6 +59,7 @@ var loginMsgZh = &loginMsg{
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout ≥ 600s如不支持长 timeout请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 后再用 `lark-cli auth login --device-code <code>` 续上轮询,**不要短 timeout 反复重试**——每次重启会作废上一轮的 device code导致用户授权的链接失效。",
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
LoginSuccess: "授权成功! 用户: %s (%s)",
AuthorizedUser: "当前授权账号: %s (%s)",
@@ -93,6 +95,7 @@ var loginMsgEn = &loginMsg{
OpenURL: "Open this URL in your browser to authenticate:\n\n",
WaitingAuth: "Waiting for user authorization...",
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is ≥ 600s. If long timeouts are not supported, use `lark-cli auth login --no-wait --json` to get a device_code, then `lark-cli auth login --device-code <code>` to resume polling. **Do NOT retry with a short timeout** — each restart invalidates the previous device code, so any URL the user already authorized becomes useless.",
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
LoginSuccess: "Authorization successful! User: %s (%s)",
AuthorizedUser: "Authorized account: %s (%s)",
@@ -122,5 +125,5 @@ func getLoginMsg(lang string) *loginMsg {
// (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json.
func getShortcutOnlyDomainNames() []string {
return []string{"base", "contact", "docs"}
return []string{"base", "contact", "docs", "markdown"}
}

View File

@@ -6,6 +6,7 @@ package auth
import (
"fmt"
"reflect"
"strings"
"testing"
)
@@ -94,3 +95,21 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
}
}
}
// TestAgentTimeoutHint_CarriesKeyInfo guards the contract that the synchronous
// auth-login output tells AI agents two things: (a) this command blocks for
// minutes — set a long runner timeout, and (b) the alternative is the
// --no-wait + --device-code split-flow. Without (a) AI sets a 10s timeout and
// kills the process before the user can authorize; without (b) the AI has no
// recovery path and just retries with the same short timeout, invalidating
// each new device code in turn.
func TestAgentTimeoutHint_CarriesKeyInfo(t *testing.T) {
for _, lang := range []string{"zh", "en"} {
hint := getLoginMsg(lang).AgentTimeoutHint
for _, want := range []string{"--no-wait", "--device-code"} {
if !strings.Contains(hint, want) {
t.Errorf("%s AgentTimeoutHint missing %q: %s", lang, want, hint)
}
}
}
}

View File

@@ -169,7 +169,7 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
if loginSucceeded {
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, issue.Summary, issue))
fmt.Fprintln(f.IOStreams.Out, string(b))
return nil
return output.ErrBare(output.ExitAuth)
}
detail := map[string]interface{}{
"requested": issue.Summary.Requested,
@@ -200,9 +200,6 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
if issue.Hint != "" {
fmt.Fprintln(f.IOStreams.ErrOut, issue.Hint)
}
if loginSucceeded {
return nil
}
return output.ErrBare(output.ExitAuth)
}

View File

@@ -17,6 +17,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts/common"
"github.com/zalando/go-keyring"
@@ -371,8 +372,12 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
Granted: []string{"base:app:copy"},
},
}, "ou_user", "tester")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %v", err)
}
if exitErr.Code != output.ExitAuth {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
}
got := stderr.String()
for _, want := range []string{
@@ -410,8 +415,12 @@ func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
Granted: []string{"base:app:copy"},
},
}, "ou_user", "tester")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %v", err)
}
if exitErr.Code != output.ExitAuth {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
}
var data map[string]interface{}
@@ -616,8 +625,12 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
Ctx: context.Background(),
Scope: "im:message:send",
})
if err != nil {
t.Fatalf("expected nil error, got %v", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %v", err)
}
if exitErr.Code != output.ExitAuth {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
}
got := stderr.String()
for _, want := range []string{

View File

@@ -62,11 +62,32 @@ func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.
Short: "Bind Agent config to a workspace (source / app-id / force)",
Long: `Bind an AI Agent's (OpenClaw / Hermes) Feishu credentials to a lark-cli workspace.
For AI agents: pass --source and --app-id to bind non-interactively.
Credentials are synced once; subsequent calls in the Agent's process
context automatically use the bound workspace.`,
Example: ` lark-cli config bind --source openclaw --app-id <id>
lark-cli config bind --source hermes`,
--source is auto-detected from env (OPENCLAW_HOME / HERMES_HOME); pass it only to override.
For AI agents — DO NOT bind without user confirmation. Binding may
overwrite an existing one and locks in an identity policy. Ask the user:
--identity bot-only bot only (safer default; no impersonation;
cannot access user resources like personal
calendar / mail / drive)
--identity user-default user identity allowed (impersonates the user;
needed for personal-resource access)
Default to bot-only if the user is unsure. Only run the command after
the user confirms both intent and identity preset.
If lark-cli is already bound and the user only wants to change identity
policy on the SAME app, use 'config strict-mode' — that's the policy
switch and does not require re-bind. Use 'config bind' only when the
underlying app itself changes.
Interactive terminal use: run with no flags to enter the TUI form.`,
Example: ` # AI flow: confirm intent + identity with user FIRST, then run:
lark-cli config bind --source openclaw --app-id <id> --identity bot-only
lark-cli config bind --source hermes --identity user-default
# Interactive (terminal user) — TUI prompts for everything:
lark-cli config bind`,
RunE: func(cmd *cobra.Command, args []string) error {
opts.langExplicit = cmd.Flags().Changed("lang")
if runF != nil {
@@ -125,6 +146,7 @@ func configBindRun(opts *BindOptions) error {
return err
}
applyPreferences(appConfig, opts)
noticeUserDefaultRisk(opts)
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
}
@@ -308,6 +330,23 @@ func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error
msg.IdentityEscalationMessage, msg.IdentityEscalationHint)
}
// noticeUserDefaultRisk surfaces the user-identity impersonation risk on every
// flag-mode bind that lands on user-default. The bot-only → user-default
// escalation is already covered by warnIdentityEscalation (errors out before
// applyPreferences runs), and the TUI flow shows IdentityUserDefaultDesc
// during identity selection — so this fires specifically for the case those
// two miss: a fresh flag-mode bind that goes directly to user-default with
// no previous bot lock to escalate from. Without this, AI agents finish such
// a bind with only a "配置成功" message and never relay to the user that the
// AI can now act under their identity.
func noticeUserDefaultRisk(opts *BindOptions) {
if opts.IsTUI || opts.Identity != "user-default" {
return
}
msg := getBindMsg(opts.Lang)
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, "⚠️ "+msg.IdentityEscalationMessage)
}
// applyPreferences expands the chosen identity preset into the underlying
// StrictMode + DefaultAs on the AppConfig. Always writes both fields so the
// profile's intent survives later changes to global strict-mode settings.

View File

@@ -377,16 +377,28 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
if err == nil {
t.Fatal("expected error for unbound workspace")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError", err)
// Should be a structured ConfigError suggesting config bind, not config init.
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
}
if cfgErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
}
if cfgErr.Type != "openclaw" {
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
}
if !strings.Contains(cfgErr.Message, "openclaw context detected") {
t.Errorf("message missing 'openclaw context detected': %q", cfgErr.Message)
}
// Hint must point at config bind --help (NOT a ready-to-run bind command):
// AI must read the help and confirm identity preset with the user first.
if !strings.Contains(cfgErr.Hint, "config bind --help") {
t.Errorf("hint must point at `config bind --help`; got %q", cfgErr.Hint)
}
if strings.Contains(cfgErr.Hint, "config init") {
t.Errorf("agent hint must not mention config init; got %q", cfgErr.Hint)
}
// Should suggest config bind, not config init
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "openclaw",
Message: "openclaw context detected but lark-cli not bound to openclaw workspace",
Hint: "run: lark-cli config bind --source openclaw",
})
}
// ── Helper function tests (dotenv, brand, path resolution) ──

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
)
// runHermesBindWithIdentity boots a Hermes-shaped fake env, runs `config bind`
// with the given identity preset in flag (non-TUI) mode, and returns captured
// stderr. Hermes is the simplest source to fake (single .env file).
func runHermesBindWithIdentity(t *testing.T, identity string) string {
t.Helper()
saveWorkspace(t)
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
envContent := "FEISHU_APP_ID=cli_hermes_abc\nFEISHU_APP_SECRET=hermes_secret_123\nFEISHU_DOMAIN=lark\n"
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte(envContent), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{
Factory: f,
Source: "hermes",
Identity: identity,
Lang: "zh",
})
if err != nil {
t.Fatalf("bind failed: %v", err)
}
return stderr.String()
}
// TestConfigBindRun_UserDefaultIdentity_WarnsAboutImpersonation covers the
// gap that previously slipped through: a fresh flag-mode bind landing on
// user-default. warnIdentityEscalation requires a previous bot lock to fire,
// and IdentityUserDefaultDesc only renders in TUI selection — so without
// noticeUserDefaultRisk the user/AI never see the impersonation risk on a
// first-time user-default bind.
func TestConfigBindRun_UserDefaultIdentity_WarnsAboutImpersonation(t *testing.T) {
out := runHermesBindWithIdentity(t, "user-default")
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
t.Errorf("user-default bind must surface IdentityEscalationMessage; got: %s", out)
}
}
func TestConfigBindRun_BotOnlyIdentity_NoImpersonationWarning(t *testing.T) {
out := runHermesBindWithIdentity(t, "bot-only")
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
t.Errorf("bot-only bind must NOT warn about impersonation; got: %s", out)
}
}

View File

@@ -90,15 +90,15 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError", err)
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
}
if exitErr.Code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
if cfgErr.Code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "config" || exitErr.Detail.Message != "not configured" {
t.Fatalf("detail = %#v, want config/not configured", exitErr.Detail)
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
}
}

View File

@@ -20,14 +20,14 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
Long: "Without arguments, shows the current default identity. Pass user, bot, or auto to set a new default.",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
multi, err := core.LoadMultiAppConfig()
multi, err := core.LoadOrNotConfigured()
if err != nil {
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
return err
}
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
return core.NoActiveProfileError()
}
if len(args) == 0 {

View File

@@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/charmbracelet/huh"
@@ -33,6 +34,13 @@ type ConfigInitOptions struct {
Lang string
langExplicit bool // true when --lang was explicitly passed
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
// ForceInit overrides the agent-workspace guard. Without it, running
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
// at config bind — which is what AI agents almost always want. Manual
// users with a legitimate need for a separate app can pass --force-init
// to bypass.
ForceInit bool
}
// NewCmdConfigInit creates the config init subcommand.
@@ -46,10 +54,18 @@ func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *
For AI agents: use --new to create a new app. The command blocks until the user
completes setup in the browser. Run it in the background and retrieve the
verification URL from its output.`,
verification URL from its output.
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command
refuses by default — use 'lark-cli config bind' to bind to the Agent's
existing app instead of creating a parallel one. Pass --force-init only
if the user explicitly wants a separate app inside the Agent workspace.`,
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
opts.langExplicit = cmd.Flags().Changed("lang")
if err := guardAgentWorkspace(opts); err != nil {
return err
}
if runF != nil {
return runF(opts)
}
@@ -63,10 +79,33 @@ verification URL from its output.`,
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
return cmd
}
// guardAgentWorkspace refuses 'config init' when run inside an OpenClaw or
// Hermes Agent context, because the Agent has already provisioned an app
// and 'config bind' is the right tool for hooking lark-cli into it.
// Running init here would create a parallel app under the agent's workspace
// dir, breaking the binding the user actually wants. --force-init lets a
// human user override when they really do want a separate app.
func guardAgentWorkspace(opts *ConfigInitOptions) error {
if opts.ForceInit {
return nil
}
ws := core.DetectWorkspaceFromEnv(os.Getenv)
if ws.IsLocal() {
return nil
}
return &core.ConfigError{
Code: 2,
Type: ws.Display(),
Message: fmt.Sprintf("config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()),
Hint: "see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.",
}
}
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool {
return o.New || o.AppID != "" || o.AppSecretStdin
@@ -269,7 +308,7 @@ func configInitRun(opts *ConfigInitOptions) error {
// Mode 3: Create new app directly (--new)
if opts.New {
result, err := runCreateAppFlow(opts.Ctx, f, core.BrandFeishu, msg)
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), msg)
if err != nil {
return err
}

View File

@@ -0,0 +1,69 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
t.Setenv("OPENCLAW_HOME", "")
t.Setenv("OPENCLAW_CLI", "")
t.Setenv("HERMES_HOME", "")
if err := guardAgentWorkspace(&ConfigInitOptions{}); err != nil {
t.Errorf("local workspace should allow init, got: %v", err)
}
}
func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) {
t.Setenv("OPENCLAW_HOME", t.TempDir())
err := guardAgentWorkspace(&ConfigInitOptions{})
if err == nil {
t.Fatal("expected refusal in OpenClaw context, got nil")
}
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
}
if cfgErr.Type != "openclaw" {
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
}
if !strings.Contains(cfgErr.Hint, "config bind --help") {
t.Errorf("hint must point to config bind --help; got %q", cfgErr.Hint)
}
if !strings.Contains(cfgErr.Hint, "--force-init") {
t.Errorf("hint must mention --force-init escape hatch; got %q", cfgErr.Hint)
}
}
func TestGuardAgentWorkspace_HermesRefuses(t *testing.T) {
t.Setenv("HERMES_HOME", t.TempDir())
err := guardAgentWorkspace(&ConfigInitOptions{})
if err == nil {
t.Fatal("expected refusal in Hermes context, got nil")
}
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
}
if cfgErr.Type != "hermes" {
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
}
}
func TestGuardAgentWorkspace_ForceInitOverride(t *testing.T) {
t.Setenv("OPENCLAW_HOME", t.TempDir())
// --force-init must let the user proceed even inside an Agent context.
if err := guardAgentWorkspace(&ConfigInitOptions{ForceInit: true}); err != nil {
t.Errorf("--force-init should bypass the guard, got: %v", err)
}
}

View File

@@ -44,12 +44,12 @@ func configShowRun(opts *ConfigShowOptions) error {
config, err := core.LoadMultiAppConfig()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return notConfiguredError()
return core.NotConfiguredError()
}
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
}
if config == nil || len(config.Apps) == 0 {
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
return core.NotConfiguredError()
}
app := config.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
@@ -75,18 +75,3 @@ func configShowRun(opts *ConfigShowOptions) error {
fmt.Fprintf(f.IOStreams.ErrOut, "\nConfig file path: %s\n", core.GetConfigPath())
return nil
}
// notConfiguredError returns the "not configured" error with a hint that
// points the user to the right next step: config init for the default local
// workspace, config bind for an Agent workspace that has not been bound yet.
func notConfiguredError() error {
ws := core.CurrentWorkspace()
if ws.IsLocal() {
return output.ErrWithHint(output.ExitValidation, "config",
"not configured",
"run: lark-cli config init")
}
return output.ErrWithHint(output.ExitValidation, ws.Display(),
fmt.Sprintf("%s context detected but lark-cli not bound to %s workspace", ws.Display(), ws.Display()),
fmt.Sprintf("run: lark-cli config bind --source %s", ws.Display()))
}

View File

@@ -21,44 +21,44 @@ func NewCmdConfigStrictMode(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "strict-mode [bot|user|off]",
Short: "View or set strict mode (identity restriction policy)",
Long: `View or set strict mode (identity restriction policy).
Long: `View or set strict mode — the identity restriction policy.
Without arguments, shows the current strict mode status and its source.
Pass "bot", "user", or "off" to set strict mode.
Use --global to set at the global level.
Use --reset to clear the profile-level setting (inherit global).
bot only bot identity allowed (user commands hidden)
user only user identity allowed (bot commands hidden)
off no restriction (default)
Modes:
bot — only bot identity is allowed, user commands are hidden
user — only user identity is allowed, bot commands are hidden
off — no restriction (default)
No args: show current mode. Switching does NOT require re-bind.
WARNING: Strict mode is a security policy set by the administrator.
AI agents are strictly prohibited from modifying this setting.`,
For AI agents: this is a security policy. DO NOT switch without
explicit user confirmation — never run on your own initiative.`,
Example: ` lark-cli config strict-mode # show current
lark-cli config strict-mode user # switch (after user confirms)
lark-cli config strict-mode bot --global # set globally
lark-cli config strict-mode --reset # clear profile override`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
multi, err := core.LoadMultiAppConfig()
multi, err := core.LoadOrNotConfigured()
if err != nil {
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
return err
}
if reset {
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
return core.NoActiveProfileError()
}
return resetStrictMode(f, multi, app, global, args)
}
if len(args) == 0 {
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
return core.NoActiveProfileError()
}
return showStrictMode(cmd.Context(), f, multi, app)
}
app := multi.CurrentAppConfig(f.Invocation.Profile)
if !global && app == nil {
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
return core.NoActiveProfileError()
}
return setStrictMode(f, multi, app, args[0], global)
},
@@ -106,6 +106,24 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
return output.ErrValidation("invalid value %q, valid values: bot | user | off", value)
}
// Capture the old mode at the SAME scope being changed, so we can warn
// only when the policy actually expands user-identity at that scope.
// --global → compare raw multi.StrictMode (profiles with explicit
// overrides are unaffected; their warning comes from the existing
// "profile %q has strict-mode explicitly set" notice below).
// profile → compare effective mode (override > global > default), so
// a profile flipping from inherited bot to explicit off still warns.
// The previous version always used the profile's effective mode, which
// false-positived (--global change while current profile has an explicit
// override) and false-negatived (--global broadening that doesn't affect
// the current profile but does affect other inheriting profiles).
var oldMode core.StrictMode
if global {
oldMode = multi.StrictMode
} else {
oldMode, _ = resolveStrictModeStatus(multi, app)
}
if global {
multi.StrictMode = mode
for _, a := range multi.Apps {
@@ -119,7 +137,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
}
} else {
if app == nil {
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
return core.NoActiveProfileError()
}
app.StrictMode = &mode
}
@@ -127,6 +145,11 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
if oldMode == core.StrictModeBot && (mode == core.StrictModeUser || mode == core.StrictModeOff) {
fmt.Fprintln(f.IOStreams.ErrOut, "⚠️ "+strictModeRelaxLang(app).IdentityEscalationMessage)
}
scope := "profile"
if global {
scope = "global"
@@ -135,6 +158,16 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
return nil
}
// strictModeRelaxLang picks the bind-message bundle whose language matches the
// active profile's Lang setting. Falls back to bindMsgZh when no profile is
// available (global mutation with no current app).
func strictModeRelaxLang(app *core.AppConfig) *bindMsg {
if app != nil {
return getBindMsg(app.Lang)
}
return getBindMsg("")
}
func resolveStrictModeStatus(multi *core.MultiAppConfig, app *core.AppConfig) (core.StrictMode, string) {
if app != nil && app.StrictMode != nil {
return *app.StrictMode, fmt.Sprintf("profile %q", app.ProfileName())

View File

@@ -0,0 +1,140 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// runStrictMode is a small helper that runs `config strict-mode <args...>` and
// returns the captured stderr — that's where success-path messages and the
// new user-identity warning land.
func runStrictMode(t *testing.T, args ...string) string {
t.Helper()
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
t.Fatalf("strict-mode %v failed: %v", args, err)
}
return stderr.String()
}
// expandsUserIdentity covers the only two transitions where AI gains the
// ability to act under the user's identity, and asserts the warning fires.
// Reuses bind_messages.go's IdentityEscalationMessage as the canonical text
// so all three call sites (bind upgrade, fresh user-default bind, strict-mode
// relax) stay phrased identically.
func TestStrictMode_BotToUser_WarnsAboutIdentityRisk(t *testing.T) {
setupStrictModeTestConfig(t)
runStrictMode(t, "bot")
out := runStrictMode(t, "user")
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
t.Errorf("bot→user transition must surface IdentityEscalationMessage; got: %s", out)
}
}
func TestStrictMode_BotToOff_WarnsAboutIdentityRisk(t *testing.T) {
setupStrictModeTestConfig(t)
runStrictMode(t, "bot")
out := runStrictMode(t, "off")
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
t.Errorf("bot→off transition must surface IdentityEscalationMessage; got: %s", out)
}
}
// narrowingDoesNotWarn covers the cases that revoke or keep user-identity
// scope — those should stay quiet, otherwise AI will spam users with risk
// text on every restrictive change.
func TestStrictMode_UserToBot_NoWarning(t *testing.T) {
setupStrictModeTestConfig(t)
runStrictMode(t, "user")
out := runStrictMode(t, "bot")
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
t.Errorf("user→bot is a narrowing change; must not warn. got: %s", out)
}
}
func TestStrictMode_OffToBot_NoWarning(t *testing.T) {
setupStrictModeTestConfig(t)
// Default starts at off; explicitly set bot — narrowing.
out := runStrictMode(t, "bot")
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
t.Errorf("off→bot is a narrowing change; must not warn. got: %s", out)
}
}
func TestStrictMode_OffToUser_NoWarning(t *testing.T) {
// Off already permits user-identity, so off→user is not a NEW grant
// even though it forces user identity. Don't warn.
setupStrictModeTestConfig(t)
out := runStrictMode(t, "user")
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
t.Errorf("off→user does not newly permit user identity; must not warn. got: %s", out)
}
}
// --- --global path: comparison must use multi.StrictMode, not profile's
// effective mode. The previous (buggy) version used resolveStrictModeStatus
// here too, leading to both false positives (current profile has explicit
// override unaffected by --global → still warned) and false negatives
// (current profile has explicit override that masks an actual bot → off
// global broadening for OTHER inheriting profiles → didn't warn).
func TestStrictMode_GlobalBotToUser_Warns(t *testing.T) {
setupStrictModeTestConfig(t)
runStrictMode(t, "bot", "--global")
out := runStrictMode(t, "user", "--global")
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
t.Errorf("global bot→user must warn (broadens user-identity for inheriting profiles); got: %s", out)
}
}
func TestStrictMode_GlobalBotToOff_Warns(t *testing.T) {
setupStrictModeTestConfig(t)
runStrictMode(t, "bot", "--global")
out := runStrictMode(t, "off", "--global")
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
t.Errorf("global bot→off must warn (newly permits user identity in inheriting profiles); got: %s", out)
}
}
// FalsePositive: current profile has explicit "bot" override, global goes
// off → user. The current profile is unaffected (still bot via override),
// and off→user at the global level is not a new grant either. Must not warn.
func TestStrictMode_GlobalOffToUser_WithProfileBotOverride_NoWarning(t *testing.T) {
setupStrictModeTestConfig(t)
runStrictMode(t, "bot") // profile-level explicit bot
runStrictMode(t, "off", "--global") // global = off
out := runStrictMode(t, "user", "--global")
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
t.Errorf("global off→user with profile-bot-override must not warn (profile unaffected, global wasn't bot); got: %s", out)
}
}
// FalseNegative: global = bot, current profile has explicit "off" override.
// Running --global off broadens OTHER inheriting profiles (bot → off). The
// current profile doesn't change effective mode, but the policy still expanded
// user-identity, so warning must fire. The pre-fix logic compared via the
// current profile's effective mode and missed this case.
func TestStrictMode_GlobalBotToOff_WithProfileOffOverride_Warns(t *testing.T) {
setupStrictModeTestConfig(t)
runStrictMode(t, "bot", "--global") // global = bot
runStrictMode(t, "off") // profile-level explicit off (already shows the warning at profile scope)
out := runStrictMode(t, "off", "--global")
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
t.Errorf("global bot→off must warn even when current profile has explicit off (other profiles inherit and newly permit user identity); got: %s", out)
}
}

View File

@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"net/http"
"os"
"sync"
"time"
@@ -83,7 +84,20 @@ func doctorRun(opts *DoctorOptions) error {
// ── 1. Config file ──
_, err := core.LoadMultiAppConfig()
if err != nil {
checks = append(checks, fail("config_file", err.Error(), "run: lark-cli config init"))
// For "config not present" cases, prefer the workspace-aware
// NotConfiguredError message + hint (e.g. "openclaw context
// detected but lark-cli is not bound to it" → bind --help) over
// the OS-level "open ... no such file or directory".
// For other errors (parse, perms), keep the raw error so the
// underlying problem is still visible.
msg, hint := err.Error(), ""
if errors.Is(err, os.ErrNotExist) {
var cfgErr *core.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) {
msg, hint = cfgErr.Message, cfgErr.Hint
}
}
checks = append(checks, fail("config_file", msg, hint))
return finishDoctor(f, checks)
}
checks = append(checks, pass("config_file", "config.json found"))

View File

@@ -32,9 +32,9 @@ func NewCmdProfileRemove(f *cmdutil.Factory) *cobra.Command {
}
func profileRemoveRun(f *cmdutil.Factory, name string) error {
multi, err := core.LoadMultiAppConfig()
multi, err := core.LoadOrNotConfigured()
if err != nil {
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
return err
}
idx := multi.FindAppIndex(name)

View File

@@ -32,9 +32,9 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
return output.ErrValidation("%v", err)
}
multi, err := core.LoadMultiAppConfig()
multi, err := core.LoadOrNotConfigured()
if err != nil {
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
return err
}
idx := multi.FindAppIndex(oldName)

View File

@@ -31,9 +31,9 @@ func NewCmdProfileUse(f *cmdutil.Factory) *cobra.Command {
}
func profileUseRun(f *cmdutil.Factory, name string) error {
multi, err := core.LoadMultiAppConfig()
multi, err := core.LoadOrNotConfigured()
if err != nil {
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
return err
}
// Handle "-" for toggle-back

View File

@@ -4,6 +4,7 @@
package cmd
import (
"fmt"
"slices"
"github.com/larksuite/cli/internal/cmdutil"
@@ -48,10 +49,9 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
Hidden: true,
DisableFlagParsing: true,
RunE: func(cmd *cobra.Command, args []string) error {
return output.Errorf(output.ExitValidation, "strict_mode",
"strict mode is %q, only %s identity is allowed. "+
"This setting is managed by the administrator and must not be modified by AI agents.",
mode, mode.ForcedIdentity())
return output.ErrWithHint(output.ExitValidation, "strict_mode",
fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()),
"if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
},
}
}

View File

@@ -343,11 +343,15 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelop
"auth", "login", "--json", "--scope", "im:message.send_as_user",
})
// auth login is user-only, so it gets pruned in strict-mode-bot and the
// stub error fires (not login.go's inline check, which is shadowed by
// pruning).
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "strict_mode",
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
}
@@ -364,7 +368,8 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnve
OK: false,
Error: &output.ErrDetail{
Type: "strict_mode",
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
}
@@ -401,7 +406,8 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
Identity: "bot",
Error: &output.ErrDetail{
Type: "strict_mode",
Message: `strict mode is "user", only user identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
Message: `strict mode is "user", only user-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
}
@@ -419,7 +425,8 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
Identity: "user",
Error: &output.ErrDetail{
Type: "strict_mode",
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
}
@@ -436,7 +443,8 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE
OK: false,
Error: &output.ErrDetail{
Type: "strict_mode",
Message: `strict mode is "user", only user identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
Message: `strict mode is "user", only user-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
}
@@ -454,7 +462,8 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop
Identity: "user",
Error: &output.ErrDetail{
Type: "strict_mode",
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
}

View File

@@ -167,10 +167,10 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
},
}
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin, @file for file input)")
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
}
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
@@ -354,6 +354,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
// 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
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
// Validate --file mutual exclusions.
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, httpMethod); err != nil {
@@ -362,7 +363,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
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)
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}
@@ -431,7 +432,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
// Parse --data as form fields.
var dataFields any
if opts.Data != "" {
dataFields, err = cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
dataFields, err = cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}
@@ -447,7 +448,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
}
fd, err := cmdutil.BuildFormdata(
opts.Factory.ResolveFileIO(opts.Ctx),
fileIO,
fieldName, filePath, isStdin, stdin, dataFields,
)
if err != nil {
@@ -456,7 +457,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
request.Data = fd
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload())
} else {
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin, fileIO)
if err != nil {
return client.RawApiRequest{}, nil, err
}

View File

@@ -142,8 +142,12 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec
errOut = io.Discard
}
if interval < 1 {
interval = 5
}
const maxPollInterval = 60
const maxPollAttempts = 200
const maxPollAttempts = 600
endpoints := ResolveOAuthEndpoints(brand)
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)

View File

@@ -5,10 +5,12 @@ package auth
import (
"bytes"
"context"
"fmt"
"log"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"
@@ -17,6 +19,12 @@ import (
"github.com/larksuite/cli/internal/keychain"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
// TestResolveOAuthEndpoints_Feishu validates endpoints for the Feishu brand.
func TestResolveOAuthEndpoints_Feishu(t *testing.T) {
ep := ResolveOAuthEndpoints(core.BrandFeishu)
@@ -172,3 +180,33 @@ func TestLogAuthError_RecordsStructuredEntry(t *testing.T) {
t.Fatalf("expected truncated cmdline in log, got %q", got)
}
}
func TestPollDeviceToken_DefaultsZeroIntervalToFiveSeconds(t *testing.T) {
t.Parallel()
var requests atomic.Int32
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
requests.Add(1)
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: http.NoBody,
}, nil
}),
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
t.Cleanup(cancel)
result := PollDeviceToken(ctx, client, "cli_a", "secret_b", core.BrandFeishu, "device-code", 0, 10, nil)
if result == nil {
t.Fatal("PollDeviceToken() returned nil result")
}
if result.Message != "Polling was cancelled" {
t.Fatalf("PollDeviceToken() message = %q, want polling cancellation", result.Message)
}
if got := requests.Load(); got != 0 {
t.Fatalf("PollDeviceToken() sent %d requests before context cancellation, want 0", got)
}
}

View File

@@ -160,10 +160,9 @@ func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
func (f *Factory) CheckStrictMode(ctx context.Context, as core.Identity) error {
mode := f.ResolveStrictMode(ctx)
if mode.IsActive() && !mode.AllowsIdentity(as) {
return output.Errorf(output.ExitValidation, "strict_mode",
"strict mode is %q, only %s identity is allowed. "+
"This setting is managed by the administrator and must not be modified by AI agents.",
mode, mode.ForcedIdentity())
return output.ErrWithHint(output.ExitValidation, "strict_mode",
fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()),
"if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
}
return nil
}

View File

@@ -7,19 +7,20 @@ import (
"encoding/json"
"io"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
)
// ParseOptionalBody parses --data JSON for methods that accept a request body.
// Supports stdin (-) and single-quote stripping via ResolveInput.
// Supports stdin (-), @file, @@-escape, and single-quote stripping via ResolveInput.
// Returns (nil, nil) if the method has no body or data is empty.
func ParseOptionalBody(httpMethod, data string, stdin io.Reader) (interface{}, error) {
func ParseOptionalBody(httpMethod, data string, stdin io.Reader, fileIO fileio.FileIO) (interface{}, error) {
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
default:
return nil, nil
}
resolved, err := ResolveInput(data, stdin)
resolved, err := ResolveInput(data, stdin, fileIO)
if err != nil {
return nil, output.ErrValidation("--data: %s", err)
}
@@ -34,9 +35,9 @@ func ParseOptionalBody(httpMethod, data string, stdin io.Reader) (interface{}, e
}
// ParseJSONMap parses a JSON string into a map. Returns an empty map if input is empty.
// Supports stdin (-) and single-quote stripping via ResolveInput.
func ParseJSONMap(input, label string, stdin io.Reader) (map[string]any, error) {
resolved, err := ResolveInput(input, stdin)
// Supports stdin (-), @file, @@-escape, and single-quote stripping via ResolveInput.
func ParseJSONMap(input, label string, stdin io.Reader, fileIO fileio.FileIO) (map[string]any, error) {
resolved, err := ResolveInput(input, stdin, fileIO)
if err != nil {
return nil, output.ErrValidation("%s: %s", label, err)
}

View File

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

View File

@@ -4,19 +4,27 @@
package cmdutil
import (
"errors"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/extension/fileio"
)
// ResolveInput resolves special input conventions for a raw flag value:
// - "-" → read all bytes from stdin
// - "'...'" → strip surrounding single quotes (Windows cmd.exe compatibility)
// - other → return as-is
// - "-" → read all bytes from stdin
// - "@<path>" → read all bytes from the file at <path> via fileIO
// - "@@..." → strip leading @ (escape for a literal @-prefixed value)
// - "'...'" → strip surrounding single quotes (Windows cmd.exe compatibility)
// - other → return as-is
//
// This allows callers to bypass shell quoting issues (especially on Windows
// PowerShell) by piping JSON via stdin instead of command-line arguments.
func ResolveInput(raw string, stdin io.Reader) (string, error) {
// fileIO is required for "@<path>" inputs and goes through path validation
// (SafeInputPath); pass nil only when callers know "@" inputs are not possible.
//
// Allows callers to bypass shell quoting issues (especially Windows PowerShell 5)
// by reading JSON from a file (@path) or piping via stdin (-).
func ResolveInput(raw string, stdin io.Reader, fileIO fileio.FileIO) (string, error) {
if raw == "" {
return "", nil
}
@@ -37,6 +45,28 @@ func ResolveInput(raw string, stdin io.Reader) (string, error) {
return s, nil
}
// escape: @@... → literal @... (no file read)
if strings.HasPrefix(raw, "@@") {
return raw[1:], nil
}
// file: @path
if strings.HasPrefix(raw, "@") {
path := strings.TrimSpace(raw[1:])
if path == "" {
return "", fmt.Errorf("file path cannot be empty after @")
}
data, err := ReadInputFile(fileIO, path)
if err != nil {
return "", err
}
s := strings.TrimSpace(string(data))
if s == "" {
return "", fmt.Errorf("file %q is empty", path)
}
return s, nil
}
// strip surrounding single quotes (Windows cmd.exe passes them literally)
if len(raw) >= 2 && raw[0] == '\'' && raw[len(raw)-1] == '\'' {
raw = raw[1 : len(raw)-1]
@@ -44,3 +74,28 @@ func ResolveInput(raw string, stdin io.Reader) (string, error) {
return raw, nil
}
// ReadInputFile reads path through fileIO. Open/read failures are wrapped with
// path context; fileio.ErrPathValidation remains matchable with errors.Is.
func ReadInputFile(fileIO fileio.FileIO, path string) ([]byte, error) {
if fileIO == nil {
return nil, fmt.Errorf("file input is not available in this context")
}
f, err := fileIO.Open(path)
if err != nil {
return nil, wrapInputFileError(path, err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, wrapInputFileError(path, err)
}
return data, nil
}
func wrapInputFileError(path string, err error) error {
if errors.Is(err, fileio.ErrPathValidation) {
return fmt.Errorf("invalid file path %q: %w", path, err)
}
return fmt.Errorf("cannot read file %q: %w", path, err)
}

View File

@@ -5,12 +5,15 @@ package cmdutil
import (
"fmt"
"os"
"strings"
"testing"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
func TestResolveInput_Stdin(t *testing.T) {
got, err := ResolveInput("-", strings.NewReader(`{"key":"value"}`))
got, err := ResolveInput("-", strings.NewReader(`{"key":"value"}`), nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -20,7 +23,7 @@ func TestResolveInput_Stdin(t *testing.T) {
}
func TestResolveInput_Stdin_TrimNewline(t *testing.T) {
got, err := ResolveInput("-", strings.NewReader("{\"k\":\"v\"}\n"))
got, err := ResolveInput("-", strings.NewReader("{\"k\":\"v\"}\n"), nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -30,7 +33,7 @@ func TestResolveInput_Stdin_TrimNewline(t *testing.T) {
}
func TestResolveInput_Stdin_Empty(t *testing.T) {
_, err := ResolveInput("-", strings.NewReader(""))
_, err := ResolveInput("-", strings.NewReader(""), nil)
if err == nil {
t.Error("expected error for empty stdin")
}
@@ -44,21 +47,21 @@ type errorReader struct{}
func (errorReader) Read([]byte) (int, error) { return 0, fmt.Errorf("disk failure") }
func TestResolveInput_Stdin_ReadError(t *testing.T) {
_, err := ResolveInput("-", errorReader{})
_, err := ResolveInput("-", errorReader{}, nil)
if err == nil || !strings.Contains(err.Error(), "failed to read stdin") {
t.Errorf("expected read error, got: %v", err)
}
}
func TestResolveInput_Stdin_WhitespaceOnly(t *testing.T) {
_, err := ResolveInput("-", strings.NewReader(" \n\t\n "))
_, err := ResolveInput("-", strings.NewReader(" \n\t\n "), nil)
if err == nil {
t.Error("expected error for whitespace-only stdin")
}
}
func TestResolveInput_Stdin_Nil(t *testing.T) {
_, err := ResolveInput("-", nil)
_, err := ResolveInput("-", nil, nil)
if err == nil {
t.Error("expected error for nil stdin")
}
@@ -77,7 +80,7 @@ func TestResolveInput_StripSingleQuotes(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ResolveInput(tt.in, nil)
got, err := ResolveInput(tt.in, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -89,7 +92,7 @@ func TestResolveInput_StripSingleQuotes(t *testing.T) {
}
func TestResolveInput_Empty(t *testing.T) {
got, err := ResolveInput("", nil)
got, err := ResolveInput("", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -99,7 +102,7 @@ func TestResolveInput_Empty(t *testing.T) {
}
func TestResolveInput_PlainValue(t *testing.T) {
got, err := ResolveInput(`{"already":"valid"}`, nil)
got, err := ResolveInput(`{"already":"valid"}`, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -108,21 +111,103 @@ func TestResolveInput_PlainValue(t *testing.T) {
}
}
func TestResolveInput_AtPrefixPassedThrough(t *testing.T) {
// Without @file support, @-prefixed values are passed as-is
got, err := ResolveInput("@something", nil)
func TestResolveInput_AtFile(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile("params.json", []byte(`{"folder_token":"abc123"}`), 0o600); err != nil {
t.Fatal(err)
}
got, err := ResolveInput("@params.json", nil, fio)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "@something" {
t.Errorf("got %q, want %q", got, "@something")
if got != `{"folder_token":"abc123"}` {
t.Errorf("got %q", got)
}
}
func TestResolveInput_AtFile_TrimsWhitespace(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile("p.json", []byte("\n {\"k\":\"v\"}\n"), 0o600); err != nil {
t.Fatal(err)
}
got, err := ResolveInput("@p.json", nil, fio)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != `{"k":"v"}` {
t.Errorf("got %q", got)
}
}
func TestResolveInput_AtFile_NotFound(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
_, err := ResolveInput("@missing.json", nil, fio)
if err == nil || !strings.Contains(err.Error(), "cannot read file") {
t.Errorf("expected read error, got: %v", err)
}
}
func TestResolveInput_AtFile_PathValidation(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
// Absolute paths are rejected by SafeInputPath; the error must surface
// as an invalid-path message, not a generic read failure.
_, err := ResolveInput("@/etc/passwd", nil, fio)
if err == nil || !strings.Contains(err.Error(), "invalid file path") {
t.Errorf("expected path-validation error, got: %v", err)
}
}
func TestResolveInput_AtFile_EmptyPath(t *testing.T) {
fio := &localfileio.LocalFileIO{}
_, err := ResolveInput("@", nil, fio)
if err == nil || !strings.Contains(err.Error(), "file path cannot be empty after @") {
t.Errorf("expected empty-path error, got: %v", err)
}
}
func TestResolveInput_AtFile_EmptyContent(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile("empty.json", []byte(" \n"), 0o600); err != nil {
t.Fatal(err)
}
_, err := ResolveInput("@empty.json", nil, fio)
if err == nil || !strings.Contains(err.Error(), "is empty") {
t.Errorf("expected empty-file error, got: %v", err)
}
}
func TestResolveInput_AtFile_NoFileIO(t *testing.T) {
// When fileIO is nil, @path must error rather than silently fall back.
_, err := ResolveInput("@params.json", nil, nil)
if err == nil || !strings.Contains(err.Error(), "not available") {
t.Errorf("expected unavailable error, got: %v", err)
}
}
func TestResolveInput_DoubleAtEscape(t *testing.T) {
got, err := ResolveInput("@@literal", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "@literal" {
t.Errorf("got %q, want %q", got, "@literal")
}
}
// Integration: ResolveInput flows through ParseJSONMap correctly.
func TestParseJSONMap_WithStdin(t *testing.T) {
stdin := strings.NewReader(`{"message_id":"om_xxx","user_id_type":"open_id"}`)
got, err := ParseJSONMap("-", "--params", stdin)
got, err := ParseJSONMap("-", "--params", stdin, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -131,8 +216,48 @@ func TestParseJSONMap_WithStdin(t *testing.T) {
}
}
// Integration: @file flows through ParseJSONMap correctly.
func TestParseJSONMap_WithAtFile(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile("params.json", []byte(`{"folder_token":"abc123","type":"folder"}`), 0o600); err != nil {
t.Fatal(err)
}
got, err := ParseJSONMap("@params.json", "--params", nil, fio)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 2 {
t.Errorf("got %d keys, want 2", len(got))
}
if got["folder_token"] != "abc123" {
t.Errorf("got %v, want folder_token=abc123", got)
}
}
func TestParseOptionalBody_WithAtFile(t *testing.T) {
fio := &localfileio.LocalFileIO{}
dir := t.TempDir()
TestChdir(t, dir)
if err := os.WriteFile("data.json", []byte(`{"text":"hello"}`), 0o600); err != nil {
t.Fatal(err)
}
got, err := ParseOptionalBody("POST", "@data.json", nil, fio)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
m, ok := got.(map[string]interface{})
if !ok {
t.Fatalf("expected map, got %T", got)
}
if m["text"] != "hello" {
t.Errorf("got %v, want text=hello", m)
}
}
func TestParseJSONMap_StripSingleQuotes_CmdExe(t *testing.T) {
got, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil)
got, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -143,7 +268,7 @@ func TestParseJSONMap_StripSingleQuotes_CmdExe(t *testing.T) {
func TestParseOptionalBody_WithStdin(t *testing.T) {
stdin := strings.NewReader(`{"text":"hello"}`)
got, err := ParseOptionalBody("POST", "-", stdin)
got, err := ParseOptionalBody("POST", "-", stdin, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -176,7 +301,7 @@ func TestParseJSONMap_WindowsShellScenarios(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseJSONMap(tt.input, "--params", nil)
got, err := ParseJSONMap(tt.input, "--params", nil, nil)
if (err != nil) != tt.wantErr {
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
return

View File

@@ -225,7 +225,7 @@ func RequireConfig(kc keychain.KeychainAccess) (*CliConfig, error) {
func RequireConfigForProfile(kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) {
raw, err := LoadMultiAppConfig()
if err != nil || raw == nil || len(raw.Apps) == 0 {
return nil, &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."}
return nil, NotConfiguredError()
}
return ResolveConfigFromMulti(raw, kc, profileOverride)
}

View File

@@ -0,0 +1,120 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"errors"
"fmt"
"os"
)
// LoadOrNotConfigured wraps LoadMultiAppConfig with the standard "not yet
// configured vs. couldn't read" disambiguation that every config-required
// command should use:
//
// - file missing → workspace-aware NotConfiguredError (init / bind hint)
// - parse error / permission error → real load failure with the original
// cause preserved, so the user can actually fix the broken file
//
// Without this, every call site that did `if err != nil { return
// NotConfiguredError() }` silently coerced corrupt-config into "run init",
// which sent users in circles when their config.json was just malformed.
func LoadOrNotConfigured() (*MultiAppConfig, error) {
multi, err := LoadMultiAppConfig()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, NotConfiguredError()
}
// Surface the real cause (parse error, permission denied, etc.)
// so the user can fix the broken file. Wrapping as ConfigError
// keeps it on the standard structured-envelope path at the root
// command's error sink.
return nil, &ConfigError{
Code: 2,
Type: "config",
Message: fmt.Sprintf("failed to load config: %v", err),
}
}
if multi == nil || len(multi.Apps) == 0 {
return nil, NotConfiguredError()
}
return multi, nil
}
const (
// localInitHint is the canonical "you're in a regular terminal, run
// init" guidance — shared by NotConfiguredError and NoActiveProfileError
// so the same session can't show two different recommended commands.
localInitHint = "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."
// agentBindHint is the canonical "you're in an Agent workspace, see
// the binding workflow" guidance. Always points at --help (never a
// ready-to-run bind command) so the AI reads the confirmation
// discipline (identity preset, user opt-in) before acting.
agentBindHint = "read `lark-cli config bind --help`, then ask the user to confirm intent and identity preset (bot-only or user-default); only after both are confirmed, run `lark-cli config bind`"
)
// NotConfiguredError returns the canonical "not configured" error, with a
// hint that depends on the active workspace:
//
// - WorkspaceLocal → suggest `config init --new` (creates a new app).
// - WorkspaceOpenClaw / WorkspaceHermes → point at `config bind --help`
// rather than a ready-to-run command, because binding is policy-laden:
// the user must pick an identity preset (bot-only vs user-default),
// and re-binding may overwrite an existing one. The help text walks
// the AI through the confirmation flow.
//
// All "config not loaded yet" call sites should use this helper rather than
// hand-rolling a hint, so AI agents always get a workspace-correct next step.
func NotConfiguredError() error {
ws := CurrentWorkspace()
if ws.IsLocal() {
return &ConfigError{
Code: 2,
Type: "config",
Message: "not configured",
Hint: localInitHint,
}
}
return &ConfigError{
Code: 2,
Type: ws.Display(),
Message: fmt.Sprintf("%s context detected but lark-cli is not bound to it", ws.Display()),
Hint: agentBindHint,
}
}
// reconfigureHint returns the workspace-aware "fix it from scratch" hint
// used by error paths that aren't full ConfigErrors (e.g. plain fmt.Errorf
// strings from keychain / secret validation). Local → `config init`;
// Agent → `config bind --help` so the AI reads the binding workflow and
// confirms identity preset with the user before running the actual command.
func reconfigureHint() string {
if CurrentWorkspace().IsLocal() {
return "please run `lark-cli config init` to reconfigure"
}
return agentBindHint
}
// NoActiveProfileError mirrors NotConfiguredError for the related
// "config exists but the requested profile cannot be resolved" case. In agent
// workspaces a missing profile typically means the binding was wiped while
// the workspace marker remained — re-binding is the correct fix, not init.
func NoActiveProfileError() error {
ws := CurrentWorkspace()
if ws.IsLocal() {
return &ConfigError{
Code: 2,
Type: "config",
Message: "no active profile",
Hint: localInitHint,
}
}
return &ConfigError{
Code: 2,
Type: ws.Display(),
Message: fmt.Sprintf("no active profile in %s workspace", ws.Display()),
Hint: agentBindHint,
}
}

View File

@@ -0,0 +1,181 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"errors"
"os"
"strings"
"testing"
)
// saveAndRestoreWorkspace ensures package-level currentWorkspace is reset
// between subtests so cross-test pollution can't make assertions pass by
// accident.
func saveAndRestoreWorkspace(t *testing.T) {
t.Helper()
prev := CurrentWorkspace()
t.Cleanup(func() { SetCurrentWorkspace(prev) })
}
func TestNotConfiguredError_Local(t *testing.T) {
saveAndRestoreWorkspace(t)
SetCurrentWorkspace(WorkspaceLocal)
err := NotConfiguredError()
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *ConfigError", err)
}
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
t.Errorf("unexpected detail: %+v", cfgErr)
}
if !strings.Contains(cfgErr.Hint, "config init --new") {
t.Errorf("local hint should suggest config init --new; got %q", cfgErr.Hint)
}
if strings.Contains(cfgErr.Hint, "config bind") {
t.Errorf("local hint must not mention config bind; got %q", cfgErr.Hint)
}
}
func TestNotConfiguredError_OpenClaw(t *testing.T) {
saveAndRestoreWorkspace(t)
SetCurrentWorkspace(WorkspaceOpenClaw)
err := NotConfiguredError()
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *ConfigError", err)
}
if cfgErr.Type != "openclaw" {
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
}
// Hint must point at --help (read first, confirm with user, then bind),
// NOT a directly-executable bind command — binding is policy-laden
// (identity preset, may overwrite existing binding).
if !strings.Contains(cfgErr.Hint, "config bind --help") {
t.Errorf("agent hint must point to `config bind --help`; got %q", cfgErr.Hint)
}
if strings.Contains(cfgErr.Hint, "config init") {
t.Errorf("agent hint must NOT mention config init (would cause AI to create a new app); got %q", cfgErr.Hint)
}
}
func TestNotConfiguredError_Hermes(t *testing.T) {
saveAndRestoreWorkspace(t)
SetCurrentWorkspace(WorkspaceHermes)
err := NotConfiguredError()
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *ConfigError", err)
}
if cfgErr.Type != "hermes" {
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
}
if !strings.Contains(cfgErr.Hint, "config bind --help") {
t.Errorf("hermes hint must point to `config bind --help`; got %q", cfgErr.Hint)
}
}
func TestNoActiveProfileError_Local(t *testing.T) {
saveAndRestoreWorkspace(t)
SetCurrentWorkspace(WorkspaceLocal)
err := NoActiveProfileError()
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *ConfigError", err)
}
if cfgErr.Message != "no active profile" {
t.Errorf("message = %q, want %q", cfgErr.Message, "no active profile")
}
}
func TestNoActiveProfileError_AgentSuggestsBind(t *testing.T) {
saveAndRestoreWorkspace(t)
SetCurrentWorkspace(WorkspaceOpenClaw)
err := NoActiveProfileError()
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *ConfigError", err)
}
if !strings.Contains(cfgErr.Hint, "config bind --help") {
t.Errorf("agent hint must point to `config bind --help`; got %q", cfgErr.Hint)
}
}
func TestReconfigureHint_Local(t *testing.T) {
saveAndRestoreWorkspace(t)
SetCurrentWorkspace(WorkspaceLocal)
got := reconfigureHint()
if !strings.Contains(got, "config init") {
t.Errorf("local reconfigure hint must mention config init; got %q", got)
}
}
func TestReconfigureHint_Agent(t *testing.T) {
saveAndRestoreWorkspace(t)
SetCurrentWorkspace(WorkspaceHermes)
got := reconfigureHint()
if !strings.Contains(got, "config bind --help") {
t.Errorf("agent reconfigure hint must point to `config bind --help`; got %q", got)
}
}
func TestLoadOrNotConfigured_FileMissing_ReturnsNotConfigured(t *testing.T) {
saveAndRestoreWorkspace(t)
SetCurrentWorkspace(WorkspaceLocal)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
_, err := LoadOrNotConfigured()
if err == nil {
t.Fatal("expected error")
}
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *ConfigError", err)
}
if cfgErr.Message != "not configured" {
t.Errorf("message = %q, want \"not configured\"", cfgErr.Message)
}
if !strings.Contains(cfgErr.Hint, "config init --new") {
t.Errorf("missing-file in local must hint `config init --new`; got %q", cfgErr.Hint)
}
}
// TestLoadOrNotConfigured_CorruptFile_PreservesCause is the regression guard
// for the previous "every load error → not configured" coercion: a malformed
// config.json must surface its real failure cause so the user can fix it,
// not get sent in circles by an init/bind hint that wouldn't help here.
func TestLoadOrNotConfigured_CorruptFile_PreservesCause(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
// Write garbage that will fail JSON parsing.
if err := os.WriteFile(dir+"/config.json", []byte("{not valid json"), 0600); err != nil {
t.Fatal(err)
}
_, err := LoadOrNotConfigured()
if err == nil {
t.Fatal("expected error for corrupt config")
}
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *ConfigError", err)
}
if !strings.Contains(cfgErr.Message, "failed to load config") {
t.Errorf("corrupt-file message must say 'failed to load config'; got %q", cfgErr.Message)
}
// And it must NOT pretend the user just hasn't initialised yet.
if cfgErr.Message == "not configured" {
t.Errorf("corrupt-file must not be coerced to 'not configured'")
}
if strings.Contains(cfgErr.Hint, "config init") || strings.Contains(cfgErr.Hint, "config bind") {
t.Errorf("corrupt-file hint must not redirect to init/bind; got %q", cfgErr.Hint)
}
}

View File

@@ -63,9 +63,8 @@ func ValidateSecretKeyMatch(appId string, secret SecretInput) error {
expected := secretAccountKey(appId)
if secret.Ref.ID != expected {
return fmt.Errorf(
"appSecret keychain key %q does not match appId %q (expected %q); "+
"please run `lark-cli config init` to reconfigure",
secret.Ref.ID, appId, expected,
"appSecret keychain key %q does not match appId %q (expected %q); %s",
secret.Ref.ID, appId, expected, reconfigureHint(),
)
}
return nil

View File

@@ -203,7 +203,7 @@ func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, er
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
return acct, nil
}
return nil, fmt.Errorf("no credential provider returned an account; run 'lark-cli config' to set up")
return nil, core.NotConfiguredError()
}
// enrichUserInfo resolves user identity when extension provides a UAT.

View File

@@ -36,7 +36,7 @@ func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account,
// Load config once — used for both credentials and strict mode.
multi, err := core.LoadMultiAppConfig()
if err != nil {
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."}
return nil, core.NotConfiguredError()
}
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile)

View File

@@ -25,10 +25,26 @@ type Stub struct {
Headers http.Header // optional full response headers (takes precedence over ContentType)
matched bool
// BodyFilter (optional): match only when the captured request body satisfies
// this predicate. Used to disambiguate multiple stubs that share a URL.
BodyFilter func([]byte) bool
// OnMatch (optional): runs synchronously after the stub matches but before
// the response is composed. Used in tests to inject panics or count
// in-flight goroutines.
OnMatch func(req *http.Request)
// Reusable (optional): when true, the stub stays available for further
// matches after the first hit. Each match appends to CapturedBodies.
Reusable bool
// CapturedHeaders records the request headers of the matched request.
// Populated after RoundTrip matches this stub.
CapturedHeaders http.Header
CapturedBody []byte
// CapturedBodies records every captured request body when Reusable is set.
// (CapturedBody continues to record the most recent capture for back-compat.)
CapturedBodies [][]byte
}
// Registry records stubs and implements http.RoundTripper.
@@ -51,8 +67,43 @@ func (r *Registry) Register(s *Stub) {
func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) {
urlStr := req.URL.String()
// Read body once up-front so BodyFilter can inspect it without consuming
// the original reader; restore for downstream consumers afterwards.
// http.RoundTripper requires us to close the original body.
var capturedBody []byte
if req.Body != nil {
var err error
capturedBody, err = io.ReadAll(req.Body)
_ = req.Body.Close()
if err != nil {
return nil, fmt.Errorf("httpmock: read request body: %w", err)
}
req.Body = io.NopCloser(bytes.NewReader(capturedBody))
}
matched := r.match(req, urlStr, capturedBody)
if matched != nil {
// Restore body again in case OnMatch wants to read it.
req.Body = io.NopCloser(bytes.NewReader(capturedBody))
if matched.OnMatch != nil {
matched.OnMatch(req)
}
resp, err := stubResponse(matched)
if err != nil {
return nil, fmt.Errorf("httpmock: stub %s %s: %w", matched.Method, matched.URL, err)
}
return resp, nil
}
return nil, fmt.Errorf("httpmock: no stub for %s %s", req.Method, req.URL)
}
// match selects the first stub whose Method/URL/BodyFilter all match the
// request, mutates its capture state, and returns it. defer-Unlock guarantees
// a panicking user-supplied BodyFilter cannot leak the mutex.
func (r *Registry) match(req *http.Request, urlStr string, capturedBody []byte) *Stub {
r.mu.Lock()
var matched *Stub
defer r.mu.Unlock()
for _, s := range r.stubs {
if s.matched {
continue
@@ -63,25 +114,18 @@ func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) {
if s.URL != "" && !strings.Contains(urlStr, s.URL) {
continue
}
s.matched = true
if s.BodyFilter != nil && !s.BodyFilter(capturedBody) {
continue
}
if !s.Reusable {
s.matched = true
}
s.CapturedHeaders = req.Header.Clone()
if req.Body != nil {
s.CapturedBody, _ = io.ReadAll(req.Body)
req.Body = io.NopCloser(bytes.NewReader(s.CapturedBody))
}
matched = s
break
s.CapturedBody = capturedBody
s.CapturedBodies = append(s.CapturedBodies, capturedBody)
return s
}
r.mu.Unlock()
if matched != nil {
resp, err := stubResponse(matched)
if err != nil {
return nil, fmt.Errorf("httpmock: stub %s %s: %w", matched.Method, matched.URL, err)
}
return resp, nil
}
return nil, fmt.Errorf("httpmock: no stub for %s %s", req.Method, req.URL)
return nil
}
// Verify asserts all stubs were matched.
@@ -90,9 +134,14 @@ func (r *Registry) Verify(t testing.TB) {
r.mu.Lock()
defer r.mu.Unlock()
for _, s := range r.stubs {
if !s.matched {
t.Errorf("httpmock: unmatched stub: %s %s", s.Method, s.URL)
if s.matched {
continue
}
// Reusable stubs never set s.matched; treat any captured hit as a match.
if s.Reusable && len(s.CapturedBodies) > 0 {
continue
}
t.Errorf("httpmock: unmatched stub: %s %s", s.Method, s.URL)
}
}

View File

@@ -10,7 +10,7 @@ const (
ExitOK = 0 // 成功
ExitAPI = 1 // API / 通用错误(含 permission、not_found、conflict、rate_limit
ExitValidation = 2 // 参数校验失败
ExitAuth = 3 // 认证失败token 无效 / 过期)
ExitAuth = 3 // 认证失败token 无效 / 过期),或登录成功但请求 scopes 未全部授予
ExitNetwork = 4 // 网络错误连接超时、DNS 解析失败等)
ExitInternal = 5 // 内部错误(不应发生)
ExitContentSafety = 6 // content safety violation (block mode)

View File

@@ -15,6 +15,7 @@ import (
var knownArrayFields = []string{
"items", "files", "events", "rooms", "records", "nodes",
"members", "departments", "calendar_list", "acl_list", "freebusy_list",
"users",
}
// FindArrayField finds the primary array field in a response's data object.

View File

@@ -35,6 +35,10 @@
"en": { "title": "Mail", "description": "Email, draft, folder, and contacts management" },
"zh": { "title": "邮箱", "description": "查看和管理用户邮箱数据,包括邮件、草稿、文件夹和联系人" }
},
"markdown": {
"en": { "title": "Markdown", "description": "Drive-native Markdown file create, fetch, and overwrite" },
"zh": { "title": "Markdown", "description": "Drive 原生 Markdown 文件的创建、读取和覆盖更新" }
},
"minutes": {
"en": { "title": "Minutes", "description": "Minutes content and metadata retrieval" },
"zh": { "title": "妙记", "description": "妙记信息获取、内容查询" }

View File

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

View File

@@ -10,15 +10,16 @@ const crypto = require("crypto");
const VERSION = require("../package.json").version.replace(/-.*$/, "");
const REPO = "larksuite/cli";
const NAME = "lark-cli";
const DEFAULT_MIRROR_HOST = "https://registry.npmmirror.com";
// Allowlist gates the *initial* request URL only. curl --location follows
// redirects (capped by --max-redirs 3) without re-checking the target host.
// This is acceptable because checksum verification is the primary integrity
// control; the allowlist is defense-in-depth to reject obviously wrong URLs.
const ALLOWED_HOSTS = [
const ALLOWED_HOSTS = new Set([
"github.com",
"objects.githubusercontent.com",
"registry.npmmirror.com",
];
]);
const PLATFORM_MAP = {
darwin: "darwin",
@@ -38,18 +39,77 @@ const isWindows = process.platform === "win32";
const ext = isWindows ? ".zip" : ".tar.gz";
const archiveName = `${NAME}-${VERSION}-${platform}-${arch}${ext}`;
const GITHUB_URL = `https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`;
const MIRROR_URL = `https://registry.npmmirror.com/-/binary/lark-cli/v${VERSION}/${archiveName}`;
const binDir = path.join(__dirname, "..", "bin");
const dest = path.join(binDir, NAME + (isWindows ? ".exe" : ""));
// Build the ordered list of binary mirror URLs to try. Resolution rules:
// 1. npm_config_registry — when the user has set a non-default
// registry (npmmirror clone, corp Verdaccio,
// Artifactory, …), include the derived path
// first. Many of these proxies don't actually
// host /-/binary/<pkg>/..., so we ALWAYS
// append the public npmmirror as a final
// fallback so the install does not regress
// from the previous behavior of "GitHub →
// npmmirror".
// 2. registry.npmmirror.com — public China mirror, always tried last.
// The default public npmjs registry is skipped in step 1 because it does not
// host binaries under /-/binary/...
//
// Non-https / malformed npm_config_registry is silently ignored so npm users
// with http-only internal registries don't have their installs broken.
function resolveMirrorUrls(env, archive, version) {
const binaryPath = `/-/binary/lark-cli/v${version}/${archive}`;
const defaultUrl = joinUrl(DEFAULT_MIRROR_HOST, binaryPath);
const urls = [];
const registry = (env.npm_config_registry || "").trim();
if (registry && !isDefaultNpmjsRegistry(registry) && isValidDownloadBase(registry)) {
const base = new URL(registry);
urls.push(joinUrl(base.origin + base.pathname, binaryPath));
}
if (!urls.includes(defaultUrl)) urls.push(defaultUrl);
return urls;
}
function joinUrl(base, suffix) {
return base.replace(/\/+$/, "") + suffix;
}
function isValidDownloadBase(raw) {
try {
const parsed = new URL(raw);
return parsed.protocol === "https:" && !!parsed.hostname;
} catch (_) {
return false;
}
}
function isDefaultNpmjsRegistry(url) {
try {
const { hostname } = new URL(url);
return hostname === "registry.npmjs.org";
} catch (_) {
return false;
}
}
function assertAllowedHost(url) {
const { hostname } = new URL(url);
if (!ALLOWED_HOSTS.includes(hostname)) {
if (!ALLOWED_HOSTS.has(hostname)) {
throw new Error(`Download host not allowed: ${hostname}`);
}
}
// Resolve the mirror URL chain and admit each host. Called from install() so
// derived hosts only become trusted when actually needed.
function getMirrorUrls(env) {
const urls = resolveMirrorUrls(env, archiveName, VERSION);
for (const u of urls) ALLOWED_HOSTS.add(new URL(u).hostname);
return urls;
}
function download(url, destPath) {
assertAllowedHost(url);
const args = [
@@ -65,27 +125,69 @@ function download(url, destPath) {
execFileSync("curl", args, { stdio: ["ignore", "ignore", "pipe"] });
}
function extractZipWindows(archivePath, destDir) {
const psOpts = ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command"];
const psStdio = ["ignore", "inherit", "inherit"];
const psEnv = {
...process.env,
LARK_CLI_ARCHIVE: archivePath,
LARK_CLI_DEST: destDir,
};
try {
const dotnet =
"$ErrorActionPreference='Stop';" +
"Add-Type -AssemblyName System.IO.Compression.FileSystem;" +
"[System.IO.Compression.ZipFile]::ExtractToDirectory($env:LARK_CLI_ARCHIVE,$env:LARK_CLI_DEST)";
execFileSync("powershell.exe", [...psOpts, dotnet], { stdio: psStdio, env: psEnv });
} catch (primaryErr) {
try {
const cmdlet =
"$ErrorActionPreference='Stop';" +
"Expand-Archive -LiteralPath $env:LARK_CLI_ARCHIVE -DestinationPath $env:LARK_CLI_DEST -Force";
execFileSync("powershell.exe", [...psOpts, cmdlet], { stdio: psStdio, env: psEnv });
} catch (fallbackErr) {
throw new Error(
`Failed to extract ${archivePath}. ` +
`.NET ZipFile attempt: ${primaryErr.message}. ` +
`Expand-Archive fallback: ${fallbackErr.message}`
);
}
}
}
function install() {
const mirrorUrls = getMirrorUrls(process.env);
const downloadUrls = [GITHUB_URL, ...mirrorUrls];
fs.mkdirSync(binDir, { recursive: true });
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lark-cli-"));
const archivePath = path.join(tmpDir, archiveName);
try {
try {
download(GITHUB_URL, archivePath);
} catch (err) {
download(MIRROR_URL, archivePath);
// Walk the chain in order; stop at the first success. Default chain:
// GitHub → derived(npm_config_registry)? → npmmirror. The npmmirror
// tail preserves the pre-PR safety net when a corporate proxy doesn't
// actually host /-/binary/<pkg>/...
let lastErr;
let downloaded = false;
for (const url of downloadUrls) {
try {
download(url, archivePath);
downloaded = true;
break;
} catch (e) {
lastErr = e;
}
}
if (!downloaded) throw lastErr;
const expectedHash = getExpectedChecksum(archiveName);
verifyChecksum(archivePath, expectedHash);
if (isWindows) {
execFileSync("powershell", [
"-Command",
`Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'`,
], { stdio: "ignore" });
extractZipWindows(archivePath, tmpDir);
} else {
execFileSync("tar", ["-xzf", archivePath, "-C", tmpDir], {
stdio: "ignore",
@@ -176,12 +278,15 @@ if (require.main === module) {
} catch (err) {
console.error(`Failed to install ${NAME}:`, err.message);
console.error(
`\nIf you are behind a firewall or in a restricted network, try setting a proxy:\n` +
`\nIf you are behind a firewall or in a restricted network, try one of:\n` +
` # 1. Use a proxy:\n` +
` export https_proxy=http://your-proxy:port\n` +
` npm install -g @larksuite/cli`
` npm install -g @larksuite/cli\n\n` +
` # 2. Point to a corporate npm mirror that proxies /-/binary/lark-cli/...:\n` +
` npm install -g @larksuite/cli --registry=https://your-corp-mirror/`
);
process.exit(1);
}
}
module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost };
module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls };

View File

@@ -9,7 +9,7 @@ const os = require("os");
const crypto = require("crypto");
const { getExpectedChecksum, verifyChecksum, assertAllowedHost } = require("./install.js");
const { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls } = require("./install.js");
describe("getExpectedChecksum", () => {
function makeTmpChecksums(content) {
@@ -164,3 +164,117 @@ describe("assertAllowedHost", () => {
);
});
});
describe("resolveMirrorUrls", () => {
const ARCHIVE = "lark-cli-1.0.0-linux-amd64.tar.gz";
const VERSION = "1.0.0";
const DEFAULT = "https://registry.npmmirror.com/-/binary/lark-cli/v1.0.0/lark-cli-1.0.0-linux-amd64.tar.gz";
it("returns only the default mirror when no env vars are set", () => {
assert.deepEqual(resolveMirrorUrls({}, ARCHIVE, VERSION), [DEFAULT]);
});
it("does not derive from the default npmjs registry", () => {
// The public npmjs registry doesn't host /-/binary/<pkg>/..., so we must
// not point downloads at it.
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "https://registry.npmjs.org/" },
ARCHIVE,
VERSION
),
[DEFAULT]
);
});
it("derives from non-default npm_config_registry AND keeps default as fallback", () => {
// Critical: a corporate npm proxy (Verdaccio/Artifactory/Nexus) often
// doesn't actually serve /-/binary/<pkg>/..., so we must keep the
// public npmmirror as a final fallback or installs regress vs. the
// pre-PR "GitHub → npmmirror" behavior.
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "https://corp.example.com/repository/npm-public/" },
ARCHIVE,
VERSION
),
[
"https://corp.example.com/repository/npm-public/-/binary/lark-cli/v1.0.0/lark-cli-1.0.0-linux-amd64.tar.gz",
DEFAULT,
]
);
});
it("derived URL appears before the default in the chain", () => {
const urls = resolveMirrorUrls(
{ npm_config_registry: "https://corp.example.com/" },
ARCHIVE,
VERSION
);
assert.equal(urls.length, 2);
assert.match(urls[0], /^https:\/\/corp\.example\.com\//);
assert.equal(urls[1], DEFAULT);
});
it("does not duplicate the default if the registry already points at it", () => {
// If npm_config_registry happens to be the public npmmirror, we still
// want a single entry, not two identical ones.
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "https://registry.npmmirror.com/" },
ARCHIVE,
VERSION
),
[DEFAULT]
);
});
it("strips trailing slashes from the registry URL", () => {
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "https://corp.example.com///" },
ARCHIVE,
VERSION
),
[
"https://corp.example.com/-/binary/lark-cli/v1.0.0/lark-cli-1.0.0-linux-amd64.tar.gz",
DEFAULT,
]
);
});
it("ignores empty/whitespace npm_config_registry", () => {
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "" },
ARCHIVE,
VERSION
),
[DEFAULT]
);
});
it("silently falls back when npm_config_registry is non-https", () => {
// Implicit feature: don't break installs whose npm registry is plain http.
// The user didn't opt into binary-mirror behavior, so just use the default.
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "http://internal.example.com/" },
ARCHIVE,
VERSION
),
[DEFAULT]
);
});
it("silently falls back when npm_config_registry is file://", () => {
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "file:///tmp" },
ARCHIVE,
VERSION
),
[DEFAULT]
);
});
});

View File

@@ -112,11 +112,43 @@ func TestDryRunRecordOps(t *testing.T) {
nil,
map[string]int{"max-version": 11, "page-size": 30},
)
assertDryRunContains(t, dryRunRecordGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1")
assertDryRunContains(t, dryRunRecordUpsert(ctx, rt), "PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1")
assertDryRunContains(t, dryRunRecordDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1")
assertDryRunContains(t, dryRunRecordHistoryList(ctx, rt), "GET /open-apis/base/v3/bases/app_x/record_history", "max_version=11", "page_size=30", "record_id=rec_1", "table_id=tbl_1")
getSingleRT := newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
map[string][]string{"record-id": {"rec_1"}},
nil,
nil,
)
assertDryRunContains(t, dryRunRecordGet(ctx, getSingleRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_1"]`)
assertDryRunContains(t, dryRunRecordDelete(ctx, getSingleRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_delete", `"record_id_list":["rec_1"]`)
getSingleFieldsRT := newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
map[string][]string{"record-id": {"rec_1"}, "field-id": {"Name", "Age"}},
nil,
nil,
)
assertDryRunContains(t, dryRunRecordGet(ctx, getSingleFieldsRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_1"]`, `"select_fields":["Name","Age"]`)
getBatchRT := newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
map[string][]string{"record-id": {"rec_2", "rec_1"}, "field-id": {"Name", "Age"}},
nil,
nil,
)
assertDryRunContains(t, dryRunRecordGet(ctx, getBatchRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_2","rec_1"]`, `"select_fields":["Name","Age"]`)
assertDryRunContains(t, dryRunRecordDelete(ctx, getBatchRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_delete", `"record_id_list":["rec_2","rec_1"]`)
getJSONRT := newBaseTestRuntime(
map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `{"record_id_list":["rec_3"],"select_fields":["Status"]}`},
nil,
nil,
)
assertDryRunContains(t, dryRunRecordGet(ctx, getJSONRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_3"]`, `"select_fields":["Status"]`)
assertDryRunContains(t, dryRunRecordDelete(ctx, getJSONRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_delete", `"record_id_list":["rec_3"]`)
uploadAttachmentRT := newBaseTestRuntime(
map[string]string{
"base-token": "app_x",

View File

@@ -827,28 +827,6 @@ func TestBaseTableExecuteReadAndDelete(t *testing.T) {
}
func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
t.Run("list", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "limit=1&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"records": map[string]interface{}{
"schema": []interface{}{"Name", "Age"},
"record_ids": []interface{}{"rec_1"},
"rows": []interface{}{[]interface{}{"Alice", 18}},
}},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"records"`) || !strings.Contains(got, `"Alice"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list with fields and view", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -864,7 +842,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--limit", "1", "--field-id", "Name", "--field-id", "Age"}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--limit", "1", "--field-id", "Name", "--field-id", "Age", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
@@ -887,7 +865,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-id", "A,B", "--field-id", "C"}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-id", "A,B", "--field-id", "C", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"A,B"`) || !strings.Contains(got, `"rec_json_fields"`) {
@@ -895,7 +873,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list new shape", func(t *testing.T) {
t.Run("list json format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
@@ -904,13 +882,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name", "Age"},
"field_id_list": []interface{}{"fld_name", "fld_age"},
"record_id_list": []interface{}{"rec_2"},
"data": []interface{}{[]interface{}{"Bob", 20}},
"total": 1,
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1"}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"Bob"`) || !strings.Contains(got, `"rec_2"`) {
@@ -918,6 +897,47 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list markdown format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "field_id=Name&field_id=Age&limit=2&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name", "Age"},
"field_id_list": []interface{}{"fld_name", "fld_age"},
"record_id_list": []interface{}{"rec_1", "rec_2"},
"data": []interface{}{
[]interface{}{"Alice", 18},
[]interface{}{"Bob", 20},
},
"has_more": false,
"query_context": map[string]interface{}{
"record_scope": "all_records",
"field_scope": "selected_fields",
},
"ignored_fields": []interface{}{"Formula"},
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "2", "--field-id", "Name", "--field-id", "Age"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
for _, want := range []string{
"`_record_id` is metadata for record operations, not a table field.",
"| _record_id | Name | Age |",
"| rec_1 | Alice | 18 |",
"Meta: count=2; has_more=false; record_scope=all_records; field_scope=selected_fields; ignored_fields=1",
"Ignored fields: Formula",
} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
})
t.Run("search", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
searchStub := &httpmock.Stub{
@@ -948,6 +968,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
"--base-token", "app_x",
"--table-id", "tbl_x",
"--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"offset":0,"limit":2}`,
"--format", "json",
},
factory,
stdout,
@@ -968,6 +989,53 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("search markdown format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Title", "Owner"},
"field_id_list": []interface{}{"fld_title", "fld_owner"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Created by AI", "Alice"}},
"has_more": false,
"query_context": map[string]interface{}{
"record_scope": "view_filtered_records",
"field_scope": "selected_fields",
"search_scope": "fld_title(Title)",
},
},
},
})
if err := runShortcut(
t,
BaseRecordSearch,
[]string{
"+record-search",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--json", `{"keyword":"Created","search_fields":["Title"],"select_fields":["Title","Owner"],"limit":2}`,
},
factory,
stdout,
); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
for _, want := range []string{
"| _record_id | Title | Owner |",
"| rec_1 | Created by AI | Alice |",
"Meta: count=1; has_more=false; record_scope=view_filtered_records; field_scope=selected_fields; search_scope=fld_title(Title)",
} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
})
t.Run("list legacy fields flag rejected", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout)
@@ -986,42 +1054,322 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
t.Run("get", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_1",
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"records": map[string]interface{}{
"schema": []interface{}{"Name", "Age"},
"record_ids": []interface{}{"rec_1"},
"rows": []interface{}{[]interface{}{"Alice", 18}},
}},
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_1"},
"fields": []interface{}{"Name", "Age"},
"data": []interface{}{[]interface{}{"Alice", 18}},
},
},
})
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_ids"`) || !strings.Contains(got, `"Name"`) || strings.Contains(got, `"raw"`) {
got := stdout.String()
for _, want := range []string{
"`_record_id` is metadata for record operations, not a table field.",
"- `_record_id`: rec_1",
"- `Name`: Alice",
"- `Age`: 18",
"Meta: count=1",
} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
body := string(batchStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_1"]`) {
t.Fatalf("request body=%s", body)
}
})
t.Run("get json format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_1"},
"fields": []interface{}{"Name", "Age"},
"data": []interface{}{[]interface{}{"Alice", 18}},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"fields"`) || !strings.Contains(got, `"Alice"`) || !strings.Contains(got, `"Age"`) || strings.Contains(got, `"record":`) || strings.Contains(got, `"raw"`) {
t.Fatalf("stdout=%s", got)
}
if got := stdout.String(); !strings.Contains(got, `"rec_1"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("get passthrough fallback", func(t *testing.T) {
t.Run("get with selected fields", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_2",
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"unexpected": "shape", "record_id": "rec_2"},
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_1"},
"fields": []interface{}{"Name", "Age"},
"data": []interface{}{[]interface{}{"Alice", 18}},
},
},
})
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2"}, factory, stdout); err != nil {
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--field-id", "Name", "--field-id", "Age", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"unexpected": "shape"`) || strings.Contains(got, `"raw"`) || strings.Contains(got, `"record":`) {
if got := stdout.String(); !strings.Contains(got, `"fields"`) || !strings.Contains(got, `"Name"`) || !strings.Contains(got, `"Age"`) || !strings.Contains(got, `"Alice"`) || strings.Contains(got, `"record":`) {
t.Fatalf("stdout=%s", got)
}
body := string(batchStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_1"]`) || !strings.Contains(body, `"select_fields":["Name","Age"]`) {
t.Fatalf("request body=%s", body)
}
})
t.Run("get batch with repeated record-id flags", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_2", "rec_1"},
"fields": []interface{}{"Name"},
"data": []interface{}{[]interface{}{"Bob"}, []interface{}{"Alice"}},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--field-id", "Name"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
for _, want := range []string{
"| _record_id | Name |",
"| rec_2 | Bob |",
"| rec_1 | Alice |",
"Meta: count=2",
} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
body := string(batchStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_2","rec_1"]`) || !strings.Contains(body, `"select_fields":["Name"]`) {
t.Fatalf("request body=%s", body)
}
})
t.Run("get batch json format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_2", "rec_1"},
"fields": []interface{}{"Name"},
"data": []interface{}{[]interface{}{"Bob"}, []interface{}{"Alice"}},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--field-id", "Name", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_2"`) || !strings.Contains(got, `"Bob"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("get batch with json selector", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_3"},
"fields": []interface{}{"Name"},
"data": []interface{}{[]interface{}{"Carol"}},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_3"],"select_fields":["Name"]}`, "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"Carol"`) {
t.Fatalf("stdout=%s", got)
}
body := string(batchStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_3"]`) || !strings.Contains(body, `"select_fields":["Name"]`) {
t.Fatalf("request body=%s", body)
}
})
t.Run("get single returns batch_get error when batch_get is unavailable", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Status: 404,
Body: map[string]interface{}{"code": 404, "msg": "not found"},
}
reg.Register(batchStub)
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1"}, factory, stdout)
if err == nil {
t.Fatalf("expected batch_get error")
}
if !strings.Contains(string(batchStub.CapturedBody), `"record_id_list":["rec_1"]`) {
t.Fatalf("request body=%s", string(batchStub.CapturedBody))
}
if stdout.Len() != 0 {
t.Fatalf("stdout=%s", stdout.String())
}
})
t.Run("get single missing record renders not found markdown", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_missing"},
"fields": []interface{}{"Name"},
"data": []interface{}{[]interface{}{nil}},
"has_more": false,
"record_not_found": []interface{}{"rec_missing"},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_missing"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
for _, want := range []string{
"Record not found.",
"- `_record_id`: rec_missing",
"Meta: count=1; has_more=false; record_not_found=1",
"Missing records: rec_missing",
} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "- `Name`:") {
t.Fatalf("missing record output should not render business fields:\n%s", got)
}
})
t.Run("get batch returns batch_get error when batch_get is unavailable", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Status: 404,
Body: map[string]interface{}{"code": 404, "msg": "not found"},
}
reg.Register(batchStub)
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--field-id", "Name"}, factory, stdout)
if err == nil {
t.Fatalf("expected batch_get error")
}
body := string(batchStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_2","rec_1"]`) || !strings.Contains(body, `"select_fields":["Name"]`) {
t.Fatalf("request body=%s", body)
}
if stdout.Len() != 0 {
t.Fatalf("stdout=%s", stdout.String())
}
})
t.Run("get batch with json record ids and field flags", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_4"},
"fields": []interface{}{"Status"},
"data": []interface{}{[]interface{}{"Done"}},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_4"]}`, "--field-id", "Status", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"Done"`) {
t.Fatalf("stdout=%s", got)
}
body := string(batchStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_4"]`) || !strings.Contains(body, `"select_fields":["Status"]`) {
t.Fatalf("request body=%s", body)
}
})
t.Run("get rejects duplicate record ids", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--record-id", "rec_1"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "duplicate record id") {
t.Fatalf("err=%v", err)
}
})
t.Run("get rejects duplicate field ids", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--field-id", "Name", "--field-id", "Name"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "duplicate field id") {
t.Fatalf("err=%v", err)
}
})
t.Run("get rejects mixed record-id and json", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--json", `{"record_id_list":["rec_2"]}`}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err=%v", err)
}
})
t.Run("get rejects mixed field-id and json select_fields", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_2"],"select_fields":["Name"]}`, "--field-id", "Age"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "select_fields") || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err=%v", err)
}
})
t.Run("get rejects empty selection", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "provide at least one --record-id") {
t.Fatalf("err=%v", err)
}
})
t.Run("create", func(t *testing.T) {
@@ -1121,17 +1469,121 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
t.Run("delete", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_1",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
})
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_1"},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--yes"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"record_id": "rec_1"`) {
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) || strings.Contains(got, `"deleted": true`) {
t.Fatalf("stdout=%s", got)
}
if !strings.Contains(string(batchStub.CapturedBody), `"record_id_list":["rec_1"]`) {
t.Fatalf("request body=%s", string(batchStub.CapturedBody))
}
})
t.Run("delete returns batch_delete error when unavailable", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
Status: 404,
Body: map[string]interface{}{"code": 404, "msg": "not found"},
}
reg.Register(batchStub)
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--yes"}, factory, stdout)
if err == nil {
t.Fatalf("expected batch_delete error")
}
if !strings.Contains(string(batchStub.CapturedBody), `"record_id_list":["rec_1"]`) {
t.Fatalf("request body=%s", string(batchStub.CapturedBody))
}
if stdout.Len() != 0 {
t.Fatalf("stdout=%s", stdout.String())
}
})
t.Run("delete batch with repeated record-id flags", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_2", "rec_1"},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--yes"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_2"`) {
t.Fatalf("stdout=%s", got)
}
body := string(batchStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_2","rec_1"]`) {
t.Fatalf("request body=%s", body)
}
})
t.Run("delete batch with json selector", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_3"},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_3"]}`, "--yes"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_3"`) {
t.Fatalf("stdout=%s", got)
}
body := string(batchStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_3"]`) {
t.Fatalf("request body=%s", body)
}
})
t.Run("delete requires yes for batch", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
t.Fatalf("err=%v", err)
}
})
t.Run("delete rejects duplicate record ids", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--record-id", "rec_1", "--yes"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "duplicate record id") {
t.Fatalf("err=%v", err)
}
})
t.Run("delete rejects mixed record-id and json", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--json", `{"record_id_list":["rec_2"]}`, "--yes"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err=%v", err)
}
})
t.Run("upload attachment", func(t *testing.T) {
@@ -1674,7 +2126,7 @@ func TestBaseRecordExecuteListWithViewPagination(t *testing.T) {
}, "total": 201},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--offset", "200", "--limit", "1"}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--offset", "200", "--limit", "1", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"rec_last"`) || !strings.Contains(got, `"total": 201`) {

View File

@@ -18,7 +18,7 @@ var BaseFormDelete = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "base-token", Desc: "Base app token (base_token)", Required: true},
baseTokenFlag(true),
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form ID", Required: true},
},

View File

@@ -20,7 +20,7 @@ var BaseFormGet = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "base-token", Desc: "Base app token (base_token)", Required: true},
baseTokenFlag(true),
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form ID", Required: true},
},

View File

@@ -21,7 +21,7 @@ var BaseFormQuestionsList = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "base-token", Desc: "Base app token (base_token)", Required: true},
baseTokenFlag(true),
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form ID", Required: true},
},

View File

@@ -210,6 +210,140 @@ func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) {
}
}
func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantHelp []string
wantTips []string
}{
{
name: "record list",
shortcut: BaseRecordList,
wantHelp: []string{
"field ID or name to include; repeat to project only needed fields",
"view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view",
"pagination size, range 1-200",
"output format: markdown (default) | json",
},
wantTips: []string{
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50",
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --field-id Name --field-id Status --limit 50",
"Default output is markdown",
"Use --field-id repeatedly to keep output small",
"Use --view-id when the user asks for a specific view or after creating a temporary filtered/sorted view",
"lark-base record read SOP",
},
},
{
name: "record search",
shortcut: BaseRecordSearch,
wantHelp: []string{
"requires keyword/search_fields",
"optional select_fields/view_id/offset/limit",
"output format: markdown (default) | json",
},
wantTips: []string{
`lark-cli base +record-search --base-token <base_token> --table-id <table_id> --json`,
`"select_fields":["Name","Status"]`,
`JSON shape: {"keyword":"<text>","search_fields":["<field_id_or_name>"]`,
"search_fields length 1-20",
"limit range 1-200 defaults to 10",
"view_id scopes search to records in that view",
"Default output is markdown",
"only for keyword search",
"lark-base record read SOP",
},
},
{
name: "record get",
shortcut: BaseRecordGet,
wantHelp: []string{
"record ID (repeatable)",
"field ID or name to project; repeat to keep only needed columns",
"output format: markdown (default) | json",
},
wantTips: []string{
"lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id <record_id>",
"lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id rec_001 --record-id rec_002 --field-id Name --field-id Status",
"Default output is markdown",
"projection boundary",
"record_id is already known",
"lark-base record read SOP",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
help := cmd.Flags().FlagUsages()
for _, want := range tt.wantHelp {
if !strings.Contains(help, want) {
t.Fatalf("flag help missing %q:\n%s", want, help)
}
}
assertHelpOrder(t, help, "base token", "output format")
assertHelpOrder(t, help, "table ID", "output format")
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
for _, want := range tt.wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
})
}
}
func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
help := cmd.Flags().FlagUsages()
wantHelp := []string{
"complete field definition JSON object; update uses full PUT semantics, not a patch",
}
for _, want := range wantHelp {
if !strings.Contains(help, want) {
t.Fatalf("flag help missing %q:\n%s", want, help)
}
}
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
wantTips := []string{
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"text"}'`,
`"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`,
"full field-definition PUT semantics",
"Read the current field first with +field-get",
"Type conversion is allowlist-based",
"web UI",
"Formula and lookup updates require reading the corresponding guide first.",
"lark-base skill's field-update guide",
}
for _, want := range wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
}
func assertHelpOrder(t *testing.T, help string, before string, after string) {
t.Helper()
beforeIndex := strings.Index(help, before)
afterIndex := strings.Index(help, after)
if beforeIndex < 0 || afterIndex < 0 {
return
}
if beforeIndex > afterIndex {
t.Fatalf("flag help order mismatch: %q should appear before %q:\n%s", before, after, help)
}
}
func TestBaseFieldValidate(t *testing.T) {
ctx := context.Background()
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
@@ -259,8 +393,8 @@ func TestBaseRecordValidate(t *testing.T) {
if BaseRecordSearch.Validate == nil {
t.Fatalf("record search validate should reject invalid JSON before dry-run")
}
if BaseRecordGet.Validate != nil {
t.Fatalf("record get validate should be nil")
if BaseRecordGet.Validate == nil {
t.Fatalf("record get validate should reject invalid record selection before dry-run")
}
if BaseRecordUpsert.Validate == nil {
t.Fatalf("record upsert validate should reject invalid JSON before dry-run")

View File

@@ -20,12 +20,16 @@ var BaseFieldUpdate = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
fieldRefFlag(true),
{Name: "json", Desc: "field property JSON object", Required: true},
{Name: "json", Desc: "complete field definition JSON object; update uses full PUT semantics, not a patch", Required: true},
{Name: "i-have-read-guide", Type: "bool", Desc: "acknowledge reading formula/lookup guide before creating or updating those field types", Hidden: true},
},
Tips: []string{
`Example: --json '{"name":"Status","type":"text"}'`,
"Agent hint: use the lark-base skill's field-update guide for usage and limits.",
`Example: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"text"}'`,
`Example: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}'`,
"Update uses full field-definition PUT semantics. Read the current field first with +field-get, then send the target state.",
"Type conversion is allowlist-based: only use CLI for safe conversions; otherwise migrate through a new field, or ask the user to finish high-risk conversions in the web UI.",
"Formula and lookup updates require reading the corresponding guide first.",
"Agent hint: use the lark-base skill's field-update guide for JSON shape, type-conversion rules, and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateFieldUpdate(runtime)

10
shortcuts/base/help.go Normal file
View File

@@ -0,0 +1,10 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import "github.com/spf13/cobra"
func preserveFlagOrder(cmd *cobra.Command) {
cmd.Flags().SortFlags = false
}

View File

@@ -195,6 +195,62 @@ func TestRecordAndChunkHelpers(t *testing.T) {
}
}
func TestRecordSelectionHelpers(t *testing.T) {
recordIDs, err := normalizeRecordIDs([]string{" rec_1 ", "rec_2"})
if err != nil || !reflect.DeepEqual(recordIDs, []string{"rec_1", "rec_2"}) {
t.Fatalf("recordIDs=%v err=%v", recordIDs, err)
}
if _, err := normalizeRecordIDs([]interface{}{}); err == nil || !strings.Contains(err.Error(), "provide at least one --record-id") {
t.Fatalf("err=%v", err)
}
if _, err := normalizeRecordIDs([]interface{}{"rec_1", "rec_1"}); err == nil || !strings.Contains(err.Error(), "duplicate record id") {
t.Fatalf("err=%v", err)
}
if _, err := normalizeRecordIDs([]interface{}{" "}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
t.Fatalf("err=%v", err)
}
if _, err := normalizeRecordIDs([]interface{}{1}); err == nil || !strings.Contains(err.Error(), "must be a string") {
t.Fatalf("err=%v", err)
}
tooManyRecords := make([]string, maxRecordSelectionCount+1)
if _, err := normalizeRecordIDs(tooManyRecords); err == nil || !strings.Contains(err.Error(), "exceeds maximum limit") {
t.Fatalf("err=%v", err)
}
fields, err := normalizeRecordGetSelectFields([]interface{}{" Name ", "fld_status"})
if err != nil || !reflect.DeepEqual(fields, []string{"Name", "fld_status"}) {
t.Fatalf("fields=%v err=%v", fields, err)
}
if fields, err := normalizeRecordGetSelectFields(nil); err != nil || fields != nil {
t.Fatalf("fields=%v err=%v", fields, err)
}
if _, err := normalizeRecordGetSelectFields([]interface{}{"Name", "Name"}); err == nil || !strings.Contains(err.Error(), "duplicate field id") {
t.Fatalf("err=%v", err)
}
if _, err := normalizeRecordGetSelectFields([]interface{}{""}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
t.Fatalf("err=%v", err)
}
if _, err := normalizeRecordGetSelectFields([]interface{}{1}); err == nil || !strings.Contains(err.Error(), "must be a string") {
t.Fatalf("err=%v", err)
}
tooManyFields := make([]string, maxBatchGetSelectFieldCount+1)
if _, err := normalizeRecordGetSelectFields(tooManyFields); err == nil || !strings.Contains(err.Error(), "exceeds maximum limit") {
t.Fatalf("err=%v", err)
}
fields, err = resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{"Name"}})
if err != nil || !reflect.DeepEqual(fields, []string{"Name"}) {
t.Fatalf("fields=%v err=%v", fields, err)
}
if _, err := resolveRecordGetSelectFields([]string{"Name"}, map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err=%v", err)
}
if _, err := resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
t.Fatalf("err=%v", err)
}
}
func TestResolveHelpers(t *testing.T) {
fields := []map[string]interface{}{{"id": "fld_1", "name": "Name", "type": "text"}, {"field_id": "fld_2", "field_name": "Age", "type": "number", "multiple": true}}
tables := []map[string]interface{}{{"id": "tbl_1", "name": "Orders"}}

View File

@@ -12,12 +12,20 @@ import (
var BaseRecordDelete = common.Shortcut{
Service: "base",
Command: "+record-delete",
Description: "Delete a record by ID",
Description: "Delete one or more records by ID",
Risk: "high-risk-write",
Scopes: []string{"base:record:delete"},
AuthTypes: authTypes(),
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), recordRefFlag(true)},
DryRun: dryRunRecordDelete,
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordSelection(runtime)
},
DryRun: dryRunRecordDelete,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordDelete(runtime)
},

View File

@@ -7,21 +7,42 @@ import (
"context"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
var BaseRecordGet = common.Shortcut{
Service: "base",
Command: "+record-get",
Description: "Get a record by ID",
Description: "Get one or more records by ID",
Risk: "read",
Scopes: []string{"base:record:read"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
recordRefFlag(true),
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
{Name: "field-id", Type: "string_array", Desc: "field ID or name to project; repeat to keep only needed columns"},
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
recordReadFormatFlag(),
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateRecordReadFormat(runtime); err != nil {
return err
}
return validateRecordSelection(runtime)
},
Tips: []string{
"Example: lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id <record_id>",
"Example with projection: lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id rec_001 --record-id rec_002 --field-id Name --field-id Status",
"Default output is markdown; pass --format json to get the raw JSON envelope.",
"Use --field-id as a projection boundary to avoid loading large cell values into context when they are not needed.",
"Use +record-get when record_id is already known; otherwise use +record-search or +record-list.",
"Agent hint: follow the lark-base record read SOP for record read routing.",
},
DryRun: dryRunRecordGet,
PostMount: func(cmd *cobra.Command) {
preserveFlagOrder(cmd)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordGet(runtime)
},

View File

@@ -7,6 +7,7 @@ import (
"context"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
var BaseRecordList = common.Shortcut{
@@ -19,13 +20,46 @@ var BaseRecordList = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "field-id", Type: "string_array", Desc: "field ID or field name to include (repeatable)"},
{Name: "view-id", Desc: "view ID"},
recordListFieldRefFlag(),
recordListViewRefFlag(),
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size"},
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
recordReadFormatFlag(),
},
Tips: []string{
"Example: lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50",
"Example with projection: lark-cli base +record-list --base-token <base_token> --table-id <table_id> --field-id Name --field-id Status --limit 50",
"Default output is markdown; pass --format json to get the raw JSON envelope.",
"Use --field-id repeatedly to keep output small and aligned with the task.",
"Use --view-id when the user asks for a specific view or after creating a temporary filtered/sorted view.",
"For structured filters, sorting, Top/Bottom N, and link fields, follow the lark-base record read SOP.",
},
DryRun: dryRunRecordList,
PostMount: func(cmd *cobra.Command) {
preserveFlagOrder(cmd)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordList(runtime)
},
}
func recordListFieldRefFlag() common.Flag {
flag := fieldRefFlag(false)
flag.Type = "string_array"
flag.Desc = "field ID or name to include; repeat to project only needed fields"
return flag
}
func recordListViewRefFlag() common.Flag {
flag := viewRefFlag(false)
flag.Desc = "view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view"
return flag
}
func recordReadFormatFlag() common.Flag {
return common.Flag{
Name: "format",
Default: "markdown",
Desc: "output format: markdown (default) | json",
}
}

View File

@@ -0,0 +1,337 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const maxRecordMarkdownIgnoredFields = 20
func validateRecordReadFormat(runtime *common.RuntimeContext) error {
switch runtime.Str("format") {
case "", "json", "markdown":
return nil
default:
return output.ErrValidation("--format must be json or markdown")
}
}
func outputRecordMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error {
return outputRecordMarkdownWithRenderer(runtime, data, renderRecordMarkdown)
}
func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[string]interface{}, renderer func(map[string]interface{}) (string, error)) error {
if runtime.JqExpr != "" {
if !runtime.Changed("format") {
runtime.Out(data, nil)
return nil
}
return output.ErrValidation("--jq and --format markdown are mutually exclusive")
}
rendered, err := renderer(data)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: record markdown render failed, falling back to json: %v\n", err)
runtime.Out(data, nil)
return nil
}
scanResult := output.ScanForSafety(runtime.Cmd.CommandPath(), data, runtime.IO().ErrOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(runtime.IO().ErrOut, scanResult.Alert)
}
fmt.Fprint(runtime.IO().Out, rendered)
return nil
}
func outputRecordGetMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error {
return outputRecordMarkdownWithRenderer(runtime, data, renderRecordGetMarkdown)
}
func renderRecordGetMarkdown(data map[string]interface{}) (string, error) {
fields := stringSliceValue(data["fields"])
recordIDs := stringSliceValue(data["record_id_list"])
rows, ok := data["data"].([]interface{})
if len(fields) == 0 || !ok {
return "", output.ErrValidation("--format markdown requires record matrix response with fields, record_id_list, and data")
}
if len(recordIDs) == 1 && len(rows) == 1 {
rowItems, _ := rows[0].([]interface{})
if recordMarkedNotFound(data["record_not_found"], recordIDs[0]) {
return renderMissingSingleRecordMarkdown(recordIDs[0], data), nil
}
return renderSingleRecordMarkdown(recordIDs[0], fields, rowItems, data), nil
}
return renderRecordMarkdown(data)
}
func renderRecordMarkdown(data map[string]interface{}) (string, error) {
fields := stringSliceValue(data["fields"])
recordIDs := stringSliceValue(data["record_id_list"])
rows, ok := data["data"].([]interface{})
if len(fields) == 0 || !ok {
return "", output.ErrValidation("--format markdown requires record matrix response with fields, record_id_list, and data")
}
var b strings.Builder
b.WriteString("`_record_id` is metadata for record operations, not a table field.\n\n")
columns := append([]string{"_record_id"}, fields...)
writeMarkdownRow(&b, columns)
writeMarkdownSeparator(&b, len(columns))
for i, rowValue := range rows {
rowItems, _ := rowValue.([]interface{})
cells := make([]string, 0, len(columns))
if i < len(recordIDs) {
cells = append(cells, recordIDs[i])
} else {
cells = append(cells, "")
}
for j := range fields {
if j < len(rowItems) {
cells = append(cells, markdownCell(rowItems[j]))
} else {
cells = append(cells, "")
}
}
writeMarkdownRow(&b, cells)
}
meta := recordMarkdownMeta(data)
if len(meta) > 0 {
b.WriteString("\nMeta: ")
b.WriteString(strings.Join(meta, "; "))
b.WriteByte('\n')
}
if ignored := ignoredFieldsMarkdown(data["ignored_fields"]); ignored != "" {
b.WriteString("Ignored fields: ")
b.WriteString(ignored)
b.WriteByte('\n')
}
if missing := recordNotFoundMarkdown(data["record_not_found"]); missing != "" {
b.WriteString("Missing records: ")
b.WriteString(missing)
b.WriteByte('\n')
}
return b.String(), nil
}
func renderSingleRecordMarkdown(recordID string, fields []string, rowItems []interface{}, data map[string]interface{}) string {
var b strings.Builder
b.WriteString("`_record_id` is metadata for record operations, not a table field.\n\n")
b.WriteString("- `_record_id`: ")
b.WriteString(markdownInlineValue(recordID))
b.WriteByte('\n')
for i, field := range fields {
b.WriteString("- `")
b.WriteString(field)
b.WriteString("`: ")
if i < len(rowItems) {
b.WriteString(markdownInlineValue(rowItems[i]))
}
b.WriteByte('\n')
}
meta := recordMarkdownMeta(data)
if len(meta) > 0 {
b.WriteString("\nMeta: ")
b.WriteString(strings.Join(meta, "; "))
b.WriteByte('\n')
}
if ignored := ignoredFieldsMarkdown(data["ignored_fields"]); ignored != "" {
b.WriteString("Ignored fields: ")
b.WriteString(ignored)
b.WriteByte('\n')
}
if missing := recordNotFoundMarkdown(data["record_not_found"]); missing != "" {
b.WriteString("Missing records: ")
b.WriteString(missing)
b.WriteByte('\n')
}
return b.String()
}
func renderMissingSingleRecordMarkdown(recordID string, data map[string]interface{}) string {
var b strings.Builder
b.WriteString("Record not found.\n\n")
b.WriteString("- `_record_id`: ")
b.WriteString(markdownInlineValue(recordID))
b.WriteByte('\n')
meta := recordMarkdownMeta(data)
if len(meta) > 0 {
b.WriteString("\nMeta: ")
b.WriteString(strings.Join(meta, "; "))
b.WriteByte('\n')
}
if missing := recordNotFoundMarkdown(data["record_not_found"]); missing != "" {
b.WriteString("Missing records: ")
b.WriteString(missing)
b.WriteByte('\n')
}
return b.String()
}
func recordMarkdownMeta(data map[string]interface{}) []string {
meta := []string{fmt.Sprintf("count=%d", ignoredFieldsCount(data["record_id_list"]))}
if hasMore, ok := data["has_more"]; ok {
meta = append(meta, "has_more="+markdownInlineValue(hasMore))
}
if queryContext, ok := data["query_context"].(map[string]interface{}); ok {
for _, key := range []string{"record_scope", "field_scope", "search_scope"} {
if value, ok := queryContext[key]; ok {
meta = append(meta, key+"="+markdownInlineValue(value))
}
}
}
if ignoredCount := ignoredFieldsCount(data["ignored_fields"]); ignoredCount > 0 {
meta = append(meta, fmt.Sprintf("ignored_fields=%d", ignoredCount))
}
if missingCount := ignoredFieldsCount(data["record_not_found"]); missingCount > 0 {
meta = append(meta, fmt.Sprintf("record_not_found=%d", missingCount))
}
return meta
}
func ignoredFieldsCount(value interface{}) int {
switch v := value.(type) {
case []interface{}:
return len(v)
case []string:
return len(v)
case nil:
return 0
default:
return 1
}
}
func ignoredFieldsMarkdown(value interface{}) string {
items := markdownListItems(value)
if len(items) == 0 {
return ""
}
total := len(items)
if len(items) > maxRecordMarkdownIgnoredFields {
items = items[:maxRecordMarkdownIgnoredFields]
items = append(items, fmt.Sprintf("...(%d total)", total))
}
return strings.Join(items, ", ")
}
func recordNotFoundMarkdown(value interface{}) string {
return strings.Join(markdownListItems(value), ", ")
}
func recordMarkedNotFound(value interface{}, recordID string) bool {
for _, item := range markdownListItems(value) {
if item == recordID {
return true
}
}
return false
}
func markdownListItems(value interface{}) []string {
switch v := value.(type) {
case []interface{}:
items := make([]string, 0, len(v))
for _, item := range v {
items = append(items, markdownInlineValue(item))
}
return items
case []string:
items := make([]string, 0, len(v))
for _, item := range v {
items = append(items, markdownInlineValue(item))
}
return items
case nil:
return nil
default:
return []string{markdownInlineValue(v)}
}
}
func writeMarkdownRow(b *strings.Builder, cells []string) {
b.WriteString("| ")
for i, cell := range cells {
if i > 0 {
b.WriteString(" | ")
}
b.WriteString(markdownTableText(cell))
}
b.WriteString(" |\n")
}
func writeMarkdownSeparator(b *strings.Builder, columns int) {
b.WriteString("| ")
for i := 0; i < columns; i++ {
if i > 0 {
b.WriteString(" | ")
}
b.WriteString("---")
}
b.WriteString(" |\n")
}
func markdownCell(value interface{}) string {
return markdownInlineValue(value)
}
func markdownInlineValue(value interface{}) string {
switch v := value.(type) {
case nil:
return ""
case string:
return v
case json.Number:
return v.String()
case bool:
if v {
return "true"
}
return "false"
case float64:
return fmt.Sprintf("%v", v)
case int:
return fmt.Sprintf("%d", v)
default:
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}
}
func markdownTableText(value string) string {
value = strings.ReplaceAll(value, "\\", "\\\\")
value = strings.ReplaceAll(value, "|", "\\|")
value = strings.ReplaceAll(value, "\r\n", "<br>")
value = strings.ReplaceAll(value, "\n", "<br>")
return value
}
func stringSliceValue(value interface{}) []string {
switch v := value.(type) {
case []interface{}:
out := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
case []string:
return append([]string(nil), v...)
default:
return nil
}
}

View File

@@ -0,0 +1,298 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"github.com/spf13/cobra"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
type recordMarkdownCSTestProvider struct {
alert *extcs.Alert
}
func (p *recordMarkdownCSTestProvider) Name() string { return "test" }
func (p *recordMarkdownCSTestProvider) Scan(_ context.Context, _ extcs.ScanRequest) (*extcs.Alert, error) {
return p.alert, nil
}
func newRecordMarkdownTestRuntime(stdout, stderr *bytes.Buffer) *common.RuntimeContext {
parentCmd := &cobra.Command{Use: "lark-cli"}
baseCmd := &cobra.Command{Use: "base"}
cmd := &cobra.Command{Use: "+record-list"}
cmd.Flags().String("format", "markdown", "")
parentCmd.AddCommand(baseCmd)
baseCmd.AddCommand(cmd)
return &common.RuntimeContext{
Config: &core.CliConfig{Brand: core.BrandFeishu},
Cmd: cmd,
Factory: &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}},
}
}
func TestRenderRecordMarkdownEmptyResult(t *testing.T) {
got, err := renderRecordMarkdown(map[string]interface{}{
"fields": []interface{}{"Name", "Age"},
"record_id_list": []interface{}{},
"data": []interface{}{},
"has_more": false,
})
if err != nil {
t.Fatalf("err=%v", err)
}
for _, want := range []string{
"| _record_id | Name | Age |",
"Meta: count=0; has_more=false",
} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestRenderRecordMarkdownEscapesTableCells(t *testing.T) {
got, err := renderRecordMarkdown(map[string]interface{}{
"fields": []interface{}{"Name|Label", "Note"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"A|B", "line1\nline2"}},
})
if err != nil {
t.Fatalf("err=%v", err)
}
for _, want := range []string{
"| _record_id | Name\\|Label | Note |",
"| rec_1 | A\\|B | line1<br>line2 |",
} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestRenderRecordGetMarkdownSingleRecordUsesKVLayout(t *testing.T) {
got, err := renderRecordGetMarkdown(map[string]interface{}{
"fields": []interface{}{"Name|Label", "Note"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"A|B", "line1\nline2"}},
"has_more": false,
})
if err != nil {
t.Fatalf("err=%v", err)
}
for _, want := range []string{
"- `_record_id`: rec_1",
"- `Name|Label`: A|B",
"- `Note`: line1\nline2",
"Meta: count=1; has_more=false",
} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestRenderRecordGetMarkdownSingleMissingRecordUsesNotFoundLayout(t *testing.T) {
got, err := renderRecordGetMarkdown(map[string]interface{}{
"fields": []interface{}{"Name", "Note"},
"record_id_list": []interface{}{"rec_missing"},
"data": []interface{}{[]interface{}{nil, nil}},
"record_not_found": []interface{}{"rec_missing"},
"has_more": false,
})
if err != nil {
t.Fatalf("err=%v", err)
}
for _, want := range []string{
"Record not found.",
"- `_record_id`: rec_missing",
"Meta: count=1; has_more=false; record_not_found=1",
"Missing records: rec_missing",
} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "- `Name`:") {
t.Fatalf("missing record layout should not render business fields:\n%s", got)
}
}
func TestRenderRecordMarkdownIncludesMissingRecords(t *testing.T) {
got, err := renderRecordMarkdown(map[string]interface{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1", "rec_missing"},
"data": []interface{}{[]interface{}{"Alice"}, []interface{}{nil}},
"record_not_found": []interface{}{"rec_missing"},
"has_more": false,
})
if err != nil {
t.Fatalf("err=%v", err)
}
for _, want := range []string{
"Meta: count=2; has_more=false; record_not_found=1",
"Missing records: rec_missing",
} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestRenderRecordMarkdownTruncatesIgnoredFields(t *testing.T) {
ignored := make([]interface{}, maxRecordMarkdownIgnoredFields+2)
for i := range ignored {
ignored[i] = fmt.Sprintf("Field%d", i+1)
}
got, err := renderRecordMarkdown(map[string]interface{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Alice"}},
"ignored_fields": ignored,
})
if err != nil {
t.Fatalf("err=%v", err)
}
if !strings.Contains(got, fmt.Sprintf("ignored_fields=%d", len(ignored))) ||
!strings.Contains(got, fmt.Sprintf("...(%d total)", len(ignored))) ||
strings.Contains(got, "Field22") {
t.Fatalf("ignored field truncation mismatch:\n%s", got)
}
}
func TestOutputRecordMarkdownContentSafetyWarnKeepsStdoutClean(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
extcs.Register(&recordMarkdownCSTestProvider{
alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}},
})
defer extcs.Register(nil)
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
err := outputRecordMarkdown(newRecordMarkdownTestRuntime(stdout, stderr), map[string]interface{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Alice"}},
})
if err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "| rec_1 | Alice |") || strings.Contains(got, "content safety") {
t.Fatalf("stdout should contain only markdown data, got:\n%s", got)
}
if got := stderr.String(); !strings.Contains(got, "warning: content safety alert") {
t.Fatalf("stderr missing content safety warning:\n%s", got)
}
}
func TestOutputRecordMarkdownContentSafetyBlockDoesNotWriteStdout(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
extcs.Register(&recordMarkdownCSTestProvider{
alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}},
})
defer extcs.Register(nil)
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
err := outputRecordMarkdown(newRecordMarkdownTestRuntime(stdout, stderr), map[string]interface{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Alice"}},
})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitContentSafety {
t.Fatalf("err=%v, want content safety exit error", err)
}
if stdout.Len() > 0 {
t.Fatalf("block mode should not write stdout, got:\n%s", stdout.String())
}
if stderr.Len() > 0 {
t.Fatalf("block mode should not write warning to stderr, got:\n%s", stderr.String())
}
}
func TestOutputRecordMarkdownFallsBackToJSONWhenRenderFails(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
err := outputRecordMarkdown(newRecordMarkdownTestRuntime(stdout, stderr), map[string]interface{}{
"records": map[string]interface{}{
"schema": []interface{}{"Name"},
"rows": []interface{}{[]interface{}{"Alice"}},
},
})
if err != nil {
t.Fatalf("err=%v", err)
}
if strings.Contains(stdout.String(), "markdown render failed") {
t.Fatalf("stdout should not contain fallback warning:\n%s", stdout.String())
}
var env output.Envelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("stdout should be JSON fallback, got err=%v stdout=%s", err, stdout.String())
}
if !env.OK || !strings.Contains(stdout.String(), `"records"`) {
t.Fatalf("stdout missing JSON fallback data:\n%s", stdout.String())
}
if got := stderr.String(); !strings.Contains(got, "warning: record markdown render failed, falling back to json") {
t.Fatalf("stderr missing fallback warning:\n%s", got)
}
}
func TestOutputRecordMarkdownDefaultFormatAllowsJqJSONFallback(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
runtime := newRecordMarkdownTestRuntime(stdout, stderr)
runtime.JqExpr = ".data.record_id_list[0]"
err := outputRecordMarkdown(runtime, map[string]interface{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Alice"}},
})
if err != nil {
t.Fatalf("err=%v", err)
}
if got := strings.TrimSpace(stdout.String()); got != "rec_1" {
t.Fatalf("stdout jq fallback mismatch: %q", got)
}
if stderr.Len() > 0 {
t.Fatalf("stderr should be empty, got:\n%s", stderr.String())
}
}
func TestOutputRecordMarkdownExplicitFormatRejectsJq(t *testing.T) {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
runtime := newRecordMarkdownTestRuntime(stdout, stderr)
runtime.JqExpr = ".data"
if err := runtime.Cmd.Flags().Set("format", "markdown"); err != nil {
t.Fatalf("set format: %v", err)
}
err := outputRecordMarkdown(runtime, map[string]interface{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Alice"}},
})
if err == nil || !strings.Contains(err.Error(), "--jq and --format markdown are mutually exclusive") {
t.Fatalf("err=%v, want jq markdown conflict", err)
}
if stdout.Len() > 0 {
t.Fatalf("stdout should be empty, got:\n%s", stdout.String())
}
}

View File

@@ -7,10 +7,194 @@ import (
"context"
"net/url"
"strconv"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
const maxRecordSelectionCount = 200
const maxBatchGetSelectFieldCount = 100
type recordSelection struct {
recordIDs []string
selectFields []string
fromJSON bool
}
type stringListNormalizeOptions struct {
typeError string
emptyError string
itemName string
duplicateName string
limitName string
max int
allowNil bool
allowEmpty bool
}
func validateRecordSelection(runtime *common.RuntimeContext) error {
_, err := resolveRecordSelection(runtime)
return err
}
func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, error) {
recordIDs := runtime.StrArray("record-id")
fieldIDs := runtime.StrArray("field-id")
jsonRaw := strings.TrimSpace(runtime.Str("json"))
if len(recordIDs) > 0 && jsonRaw != "" {
return recordSelection{}, common.FlagErrorf("--record-id and --json are mutually exclusive")
}
if jsonRaw != "" {
pc := newParseCtx(runtime)
body, err := parseJSONObject(pc, jsonRaw, "json")
if err != nil {
return recordSelection{}, err
}
recordIDListValue, ok := body["record_id_list"]
if !ok {
return recordSelection{}, common.FlagErrorf(`--json must include "record_id_list" as a non-empty string array; %s`, jsonInputTip("json"))
}
recordIDItems, ok := recordIDListValue.([]interface{})
if !ok {
return recordSelection{}, common.FlagErrorf(`--json field "record_id_list" must be a string array; %s`, jsonInputTip("json"))
}
normalized, err := normalizeRecordIDs(recordIDItems)
if err != nil {
return recordSelection{}, err
}
selectFields, err := resolveRecordGetSelectFields(fieldIDs, body)
if err != nil {
return recordSelection{}, err
}
return recordSelection{
recordIDs: normalized,
selectFields: selectFields,
fromJSON: true,
}, nil
}
normalized, err := normalizeRecordIDs(recordIDs)
if err != nil {
return recordSelection{}, err
}
selectFields, err := resolveRecordGetSelectFields(fieldIDs, nil)
if err != nil {
return recordSelection{}, err
}
return recordSelection{
recordIDs: normalized,
selectFields: selectFields,
}, nil
}
func normalizeRecordIDs(values interface{}) ([]string, error) {
return normalizeStringList(values, stringListNormalizeOptions{
typeError: "record selection must be a string array",
emptyError: `provide at least one --record-id, or use --json with "record_id_list"`,
itemName: "record selection item",
duplicateName: "record id",
limitName: "record selection",
max: maxRecordSelectionCount,
})
}
func resolveRecordGetSelectFields(flagFields []string, body map[string]interface{}) ([]string, error) {
fromFlags, err := normalizeRecordGetSelectFields(flagFields)
if err != nil {
return nil, err
}
if body == nil {
return fromFlags, nil
}
rawJSONFields, ok := body["select_fields"]
if !ok {
return fromFlags, nil
}
if len(fromFlags) > 0 {
return nil, common.FlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
}
items, ok := rawJSONFields.([]interface{})
if !ok {
return nil, common.FlagErrorf(`--json field "select_fields" must be a string array; %s`, jsonInputTip("json"))
}
if len(items) == 0 {
return nil, common.FlagErrorf(`--json field "select_fields" must not be empty; %s`, jsonInputTip("json"))
}
normalized, err := normalizeRecordGetSelectFields(items)
if err != nil {
return nil, err
}
return normalized, nil
}
func normalizeRecordGetSelectFields(values interface{}) ([]string, error) {
return normalizeStringList(values, stringListNormalizeOptions{
typeError: "field selection must be a string array",
itemName: "field selection item",
duplicateName: "field id",
limitName: "field selection",
max: maxBatchGetSelectFieldCount,
allowNil: true,
allowEmpty: true,
})
}
func normalizeStringList(values interface{}, opts stringListNormalizeOptions) ([]string, error) {
var rawItems []interface{}
switch typed := values.(type) {
case nil:
if opts.allowNil {
return nil, nil
}
return nil, common.FlagErrorf(opts.typeError)
case []interface{}:
rawItems = typed
case []string:
rawItems = make([]interface{}, 0, len(typed))
for _, item := range typed {
rawItems = append(rawItems, item)
}
default:
return nil, common.FlagErrorf(opts.typeError)
}
if len(rawItems) == 0 {
if opts.allowEmpty {
return nil, nil
}
return nil, common.FlagErrorf(opts.emptyError)
}
if opts.max > 0 && len(rawItems) > opts.max {
return nil, common.FlagErrorf("%s exceeds maximum limit of %d (got %d)", opts.limitName, opts.max, len(rawItems))
}
seen := make(map[string]int, len(rawItems))
result := make([]string, 0, len(rawItems))
for index, value := range rawItems {
item, ok := value.(string)
if !ok {
return nil, common.FlagErrorf("%s %d must be a string", opts.itemName, index+1)
}
item = strings.TrimSpace(item)
if item == "" {
return nil, common.FlagErrorf("%s %d must not be empty", opts.itemName, index+1)
}
if first, exists := seen[item]; exists {
return nil, common.FlagErrorf("duplicate %s %q at positions %d and %d", opts.duplicateName, item, first, index+1)
}
seen[item] = index + 1
result = append(result, item)
}
return result, nil
}
func recordGetBatchBody(selection recordSelection) map[string]interface{} {
body := map[string]interface{}{
"record_id_list": selection.recordIDs,
}
if len(selection.selectFields) > 0 {
body["select_fields"] = selection.selectFields
}
return body
}
func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
offset := runtime.Int("offset")
if offset < 0 {
@@ -34,11 +218,15 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common
}
func dryRunRecordGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
selection, err := resolveRecordSelection(runtime)
if err != nil {
return common.NewDryRunAPI()
}
return common.NewDryRunAPI().
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/batch_get").
Body(recordGetBatchBody(selection)).
Set("base_token", runtime.Str("base-token")).
Set("table_id", baseTableID(runtime)).
Set("record_id", runtime.Str("record-id"))
Set("table_id", baseTableID(runtime))
}
func dryRunRecordSearch(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -90,11 +278,15 @@ func dryRunRecordBatchUpdate(_ context.Context, runtime *common.RuntimeContext)
}
func dryRunRecordDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
selection, err := resolveRecordSelection(runtime)
if err != nil {
return common.NewDryRunAPI()
}
return common.NewDryRunAPI().
DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/batch_delete").
Body(map[string]interface{}{"record_id_list": selection.recordIDs}).
Set("base_token", runtime.Str("base-token")).
Set("table_id", baseTableID(runtime)).
Set("record_id", runtime.Str("record-id"))
Set("table_id", baseTableID(runtime))
}
func dryRunRecordHistoryList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -173,6 +365,9 @@ func recordListFields(runtime *common.RuntimeContext) []string {
}
func executeRecordList(runtime *common.RuntimeContext) error {
if err := validateRecordReadFormat(runtime); err != nil {
return err
}
offset := runtime.Int("offset")
if offset < 0 {
offset = 0
@@ -190,15 +385,29 @@ func executeRecordList(runtime *common.RuntimeContext) error {
if err != nil {
return err
}
if runtime.Str("format") == "markdown" {
return outputRecordMarkdown(runtime, data)
}
runtime.Out(data, nil)
return nil
}
func executeRecordGet(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, nil)
if err := validateRecordReadFormat(runtime); err != nil {
return err
}
selection, err := resolveRecordSelection(runtime)
if err != nil {
return err
}
result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "batch_get"), nil, recordGetBatchBody(selection))
data, err := handleBaseAPIResult(result, err, "batch get records")
if err != nil {
return err
}
if runtime.Str("format") == "markdown" {
return outputRecordGetMarkdown(runtime, data)
}
runtime.Out(data, nil)
return nil
}
@@ -213,6 +422,9 @@ func executeRecordSearch(runtime *common.RuntimeContext) error {
if err != nil {
return err
}
if runtime.Str("format") == "markdown" {
return outputRecordMarkdown(runtime, data)
}
runtime.Out(data, nil)
return nil
}
@@ -272,10 +484,17 @@ func executeRecordBatchUpdate(runtime *common.RuntimeContext) error {
}
func executeRecordDelete(runtime *common.RuntimeContext) error {
_, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, nil)
selection, err := resolveRecordSelection(runtime)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"deleted": true, "record_id": runtime.Str("record-id")}, nil)
result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "batch_delete"), nil, map[string]interface{}{
"record_id_list": selection.recordIDs,
})
data, err := handleBaseAPIResult(result, err, "batch delete records")
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
}

View File

@@ -7,6 +7,7 @@ import (
"context"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
var BaseRecordSearch = common.Shortcut{
@@ -19,16 +20,28 @@ var BaseRecordSearch = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "json", Desc: "record search JSON object", Required: true},
{Name: "json", Desc: `record search JSON object; requires keyword/search_fields, optional select_fields/view_id/offset/limit`, Required: true},
recordReadFormatFlag(),
},
Tips: []string{
`Example: --json '{"keyword":"Alice","search_fields":["Name"]}'`,
"Agent hint: use the lark-base skill's record-search guide for usage and limits.",
`Example: lark-cli base +record-search --base-token <base_token> --table-id <table_id> --json '{"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}'`,
`JSON shape: {"keyword":"<text>","search_fields":["<field_id_or_name>"],"select_fields":["<field_id_or_name>"],"view_id":"<view_id_or_name>","offset":0,"limit":10}.`,
"JSON constraints: keyword length >=1; search_fields length 1-20; select_fields length <=50; offset >=0 defaults to 0; limit range 1-200 defaults to 10.",
"view_id scopes search to records in that view; when select_fields is omitted, returned fields follow that view's visible fields.",
"Default output is markdown; pass --format json to get the raw JSON envelope.",
"Use +record-search only for keyword search; use a filtered view plus +record-list for structured conditions.",
"Agent hint: follow the lark-base record read SOP for record read routing and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateRecordReadFormat(runtime); err != nil {
return err
}
return validateRecordJSON(runtime)
},
DryRun: dryRunRecordSearch,
PostMount: func(cmd *cobra.Command) {
preserveFlagOrder(cmd)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordSearch(runtime)
},

View File

@@ -155,6 +155,19 @@ func (ctx *RuntimeContext) LarkSDK() *lark.Client {
return ctx.larkSDK
}
// EnsureScopes runs the same pre-flight scope check used by the framework
// before Validate, but on a caller-supplied set of scopes. Use it from a
// shortcut's Validate to enforce conditional scope requirements that depend
// on flag values (e.g. --delete-remote needing space:document:delete) so a
// destructive operation never starts on a token that can't finish it.
//
// Behavior matches checkShortcutScopes: when no token is available or the
// resolver doesn't expose scope metadata, this is a silent no-op — the
// downstream API call still surfaces missing_scope at runtime.
func (ctx *RuntimeContext) EnsureScopes(scopes []string) error {
return checkShortcutScopes(ctx.Factory, ctx.ctx, ctx.As(), ctx.Config, scopes)
}
// ── Flag accessors ──
// Str returns a string flag value.
@@ -882,17 +895,9 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
if path == "" {
return FlagErrorf("--%s: file path cannot be empty after @", fl.Name)
}
f, err := rctx.FileIO().Open(path)
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
if err != nil {
if errors.Is(err, fileio.ErrPathValidation) {
return FlagErrorf("--%s: invalid file path %q: %v", fl.Name, path, err)
}
return FlagErrorf("--%s: cannot read file %q: %v", fl.Name, path, err)
}
data, err := io.ReadAll(f)
f.Close()
if err != nil {
return FlagErrorf("--%s: cannot read file %q: %v", fl.Name, path, err)
return FlagErrorf("--%s: %v", fl.Name, err)
}
rctx.Cmd.Flags().Set(fl.Name, string(data))
continue

View File

@@ -5,7 +5,6 @@ package common
import (
"os"
"path/filepath"
"strings"
"testing"
@@ -60,13 +59,12 @@ func TestResolveInputFlags_Stdin(t *testing.T) {
func TestResolveInputFlags_File(t *testing.T) {
dir := t.TempDir()
orig, _ := os.Getwd()
os.Chdir(dir)
t.Cleanup(func() { os.Chdir(orig) })
cmdutil.TestChdir(t, dir)
content := "## Hello\n\nThis is **markdown** from a file.\n"
fpath := filepath.Join(dir, "test.md")
os.WriteFile(fpath, []byte(content), 0644)
if err := os.WriteFile("test.md", []byte(content), 0644); err != nil {
t.Fatal(err)
}
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@test.md"}, "")
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
@@ -79,6 +77,25 @@ func TestResolveInputFlags_File(t *testing.T) {
}
}
func TestResolveInputFlags_EmptyFile(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("empty.md", nil, 0644); err != nil {
t.Fatal(err)
}
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@empty.md"}, "")
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
if err := resolveInputFlags(rctx, flags); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := rctx.Str("markdown"); got != "" {
t.Errorf("expected empty string, got %q", got)
}
}
func TestResolveInputFlags_EmptyInput(t *testing.T) {
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": ""}, "")
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
@@ -132,9 +149,7 @@ func TestResolveInputFlags_FileNotSupported(t *testing.T) {
func TestResolveInputFlags_FileNotFound(t *testing.T) {
dir := t.TempDir()
orig, _ := os.Getwd()
os.Chdir(dir)
t.Cleanup(func() { os.Chdir(orig) })
cmdutil.TestChdir(t, dir)
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@nonexistent.md"}, "")
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
@@ -156,7 +171,7 @@ func TestResolveInputFlags_EmptyFilePath(t *testing.T) {
if err == nil {
t.Fatal("expected error for empty file path")
}
if !strings.Contains(err.Error(), "file path cannot be empty") {
if !strings.Contains(err.Error(), "file path cannot be empty after @") {
t.Errorf("unexpected error: %v", err)
}
}

View File

@@ -18,7 +18,7 @@ const (
// Flag describes a CLI flag for a shortcut.
type Flag struct {
Name string // flag name (e.g. "calendar-id")
Type string // "string" (default) | "bool" | "int" | "string_array"
Type string // "string" (default) | "bool" | "int" | "string_array" | "string_slice"
Default string // default value as string
Desc string // help text
Hidden bool // hidden from --help, still readable at runtime

View File

@@ -123,7 +123,7 @@ type searchUser struct {
P2PChatID string `json:"p2p_chat_id"`
HasChatted bool `json:"has_chatted"`
Department string `json:"department"`
Signature string `json:"signature"`
Signature string `json:"signature,omitempty"`
ChatRecencyHint string `json:"chat_recency_hint"`
MatchSegments []string `json:"match_segments"`
}
@@ -150,18 +150,38 @@ var ContactSearchUser = common.Shortcut{
{Name: "left-organization", Type: "bool", Desc: "restrict to users who have left the organization (omit to disable; =false rejected)"},
{Name: "lang", Desc: "override locale for localized_name (e.g. zh_cn, en_us)"},
{Name: "page-size", Type: "int", Default: "20", Desc: "rows per request, 1-30"},
{Name: "queries", Desc: "comma-separated keywords searched in parallel; output is a flat users[] with matched_query plus a queries[] sidecar"},
},
Tips: []string{
"Keyword search: lark-cli contact +search-user --query 'alice' --format json",
"Look up by ID (or 'me' for self): lark-cli contact +search-user --user-ids 'ou_xxx,me' --format json",
"Filter-only enumeration — users you've chatted with: lark-cli contact +search-user --has-chatted --format json",
"Keyword search: lark-cli contact +search-user --query 'alice'",
"Look up by ID (or 'me' for self): lark-cli contact +search-user --user-ids 'ou_xxx,me'",
"Filter-only enumeration — users you've chatted with: lark-cli contact +search-user --has-chatted",
"Refine same-name hits: lark-cli contact +search-user --query '张三' --has-chatted --exclude-external-users",
"Multi-name fanout: lark-cli contact +search-user --queries 'alice,bob,张三'",
"open_id is the stable identifier for follow-up commands; on has_more=true add filters or tighten --query — there is no auto-pagination.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateSearchUser(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if raw := strings.TrimSpace(runtime.Str("queries")); raw != "" {
queries := parseAndDedupQueries(raw)
filter, err := buildFanoutFilter(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
api := common.NewDryRunAPI()
for _, q := range queries {
body := &searchUserAPIRequest{Query: q}
if filter != nil {
body.Filter = filter
}
api.POST(searchUserURL).
Params(map[string]interface{}{"page_size": runtime.Int("page-size")}).
Body(body)
}
return api
}
body, err := buildSearchUserBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
@@ -175,6 +195,13 @@ var ContactSearchUser = common.Shortcut{
}
func executeSearchUser(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("queries")) != "" {
return executeSearchUserFanout(ctx, runtime)
}
return executeSearchUserSingle(ctx, runtime)
}
func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildSearchUserBody(runtime)
if err != nil {
return err
@@ -347,10 +374,32 @@ func rowFromItem(item *searchUserAPIItem, lang string, brand core.LarkBrand) sea
func validateSearchUser(runtime *common.RuntimeContext) error {
if !hasAnySearchInput(runtime) {
return common.FlagErrorf(
"specify at least one of --query, --user-ids, --has-chatted, --has-enterprise-email, --exclude-external-users, --left-organization",
"specify at least one of --query, --queries, --user-ids, --has-chatted, --has-enterprise-email, --exclude-external-users, --left-organization",
)
}
queriesRaw := strings.TrimSpace(runtime.Str("queries"))
if queriesRaw != "" {
if strings.TrimSpace(runtime.Str("query")) != "" {
return common.FlagErrorf("--query and --queries are mutually exclusive")
}
if strings.TrimSpace(runtime.Str("user-ids")) != "" {
return common.FlagErrorf("--user-ids and --queries are mutually exclusive")
}
queries := parseAndDedupQueries(queriesRaw)
if len(queries) == 0 {
return common.FlagErrorf("--queries: no valid query parsed from %q (separate entries with ',')", queriesRaw)
}
if len(queries) > maxFanoutQueries {
return common.FlagErrorf("--queries: must be at most %d entries (got %d)", maxFanoutQueries, len(queries))
}
for _, q := range queries {
if utf8.RuneCountInString(q) > maxSearchUserQueryChars {
return common.FlagErrorf("--queries: entry %q exceeds %d characters", q, maxSearchUserQueryChars)
}
}
}
if q := strings.TrimSpace(runtime.Str("query")); q != "" {
if utf8.RuneCountInString(q) > maxSearchUserQueryChars {
return common.FlagErrorf("--query: length must be between 1 and %d characters", maxSearchUserQueryChars)
@@ -399,6 +448,9 @@ func hasAnySearchInput(runtime *common.RuntimeContext) bool {
if strings.TrimSpace(runtime.Str("query")) != "" {
return true
}
if strings.TrimSpace(runtime.Str("queries")) != "" {
return true
}
if strings.TrimSpace(runtime.Str("user-ids")) != "" {
return true
}

View File

@@ -0,0 +1,275 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
const (
maxFanoutQueries = 20
fanoutConcurrency = 5
)
// parseAndDedupQueries splits the raw CSV, trims whitespace, drops empty
// entries, and deduplicates case-sensitively while preserving first-occurrence
// order.
func parseAndDedupQueries(raw string) []string {
parts := common.SplitCSV(raw)
seen := make(map[string]bool, len(parts))
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" || seen[p] {
continue
}
seen[p] = true
out = append(out, p)
}
return out
}
type fanoutResult struct {
Index int
Query string
Users []searchUser
HasMore bool
ErrMsg string // empty = success
ErrCode int // 0 = success or unknown; otherwise an HTTP status or Lark API code corresponding to the first error
}
// isFanoutSummaryFormat gates the per-fanout stderr summary line. Includes csv
// because that summary lives on stderr and never corrupts the csv stream on
// stdout — single-query mode keeps the narrower isHumanReadableFormat predicate
// for its refine hint, so adding csv here doesn't regress that path.
func isFanoutSummaryFormat(format string) bool {
return format == "pretty" || format == "table" || format == "csv"
}
// runOneQuery converts every failure mode (transport, HTTP status, parse,
// API code) into an ErrMsg string instead of returning a Go error. The
// fanout dispatcher (Task 6) relies on this so a single failed query never
// short-circuits the remaining workers.
func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int, query string,
filter *searchUserAPIFilter) fanoutResult {
// Pre-check ctx so queued workers see cancellation before issuing a
// request; in-flight workers continue until DoAPI returns.
if err := ctx.Err(); err != nil {
return fanoutResult{Index: index, Query: query, ErrMsg: err.Error()}
}
body := &searchUserAPIRequest{Query: query}
if filter != nil {
body.Filter = filter
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: searchUserURL,
Body: body,
QueryParams: larkcore.QueryParams{"page_size": []string{strconv.Itoa(runtime.Int("page-size"))}},
})
if err != nil {
return fanoutResult{Index: index, Query: query, ErrMsg: err.Error()}
}
if apiResp.StatusCode != http.StatusOK {
body := strings.TrimSpace(string(apiResp.RawBody))
const maxBody = 200
if len(body) > maxBody {
body = body[:maxBody] + "..."
}
msg := fmt.Sprintf("HTTP %d %s", apiResp.StatusCode, http.StatusText(apiResp.StatusCode))
if body != "" {
msg = fmt.Sprintf("%s: %s", msg, body)
}
return fanoutResult{Index: index, Query: query,
ErrMsg: msg,
ErrCode: apiResp.StatusCode}
}
var resp searchUserAPIEnvelope
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
return fanoutResult{Index: index, Query: query,
ErrMsg: fmt.Sprintf("parse response failed: %v", err)}
}
if resp.Code != 0 {
return fanoutResult{Index: index, Query: query,
ErrMsg: fmt.Sprintf("API %d: %s", resp.Code, resp.Msg),
ErrCode: resp.Code}
}
users, hasMore := projectUsers(resp.Data, runtime.Str("lang"), runtime.Config.Brand)
return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore}
}
type fanoutUser struct {
searchUser
MatchedQuery string `json:"matched_query"`
}
type querySummary struct {
Query string `json:"query"`
Error string `json:"error,omitempty"`
HasMore bool `json:"has_more"`
}
type fanoutResponse struct {
Users []fanoutUser `json:"users"`
Queries []querySummary `json:"queries"`
}
// buildFanoutResponse walks results by Index (input order), flattens users[]
// with matched_query, lists every input in queries[] (including successes),
// and returns an error only when every query failed. The error wraps the
// first failing query's ErrMsg so the CLI exits non-zero on full failure.
func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutResponse, error) {
indexed := make([]fanoutResult, len(queries))
for _, r := range results {
indexed[r.Index] = r
}
out := &fanoutResponse{
Users: make([]fanoutUser, 0),
Queries: make([]querySummary, 0, len(queries)),
}
failed := 0
var firstErrMsg, firstErrQuery string
var firstErrCode int
for i, r := range indexed {
out.Queries = append(out.Queries, querySummary{
Query: queries[i],
Error: r.ErrMsg,
HasMore: r.HasMore,
})
if r.ErrMsg != "" {
failed++
if firstErrMsg == "" {
firstErrMsg = r.ErrMsg
firstErrQuery = queries[i]
firstErrCode = r.ErrCode
}
continue
}
for _, u := range r.Users {
out.Users = append(out.Users, fanoutUser{searchUser: u, MatchedQuery: queries[i]})
}
}
if failed == len(queries) && len(queries) > 0 {
msg := fmt.Sprintf("all %d queries failed; first: %s (query=%q)",
len(queries), firstErrMsg, firstErrQuery)
// Only the HTTP-status / Lark-API-code branches in runOneQuery populate
// ErrCode; transport, parse, panic, and ctx-canceled stay at 0. Code 0
// means success in the Lark protocol, so don't pretend it's an API error
// when we have nothing structured to report.
if firstErrCode != 0 {
return nil, output.ErrAPI(firstErrCode, msg, "")
}
return nil, output.ErrWithHint(output.ExitInternal, "fanout", msg, "")
}
return out, nil
}
func executeSearchUserFanout(ctx context.Context, runtime *common.RuntimeContext) error {
queries := parseAndDedupQueries(runtime.Str("queries"))
filter, err := buildFanoutFilter(runtime)
if err != nil {
return err
}
results := make([]fanoutResult, len(queries))
var wg sync.WaitGroup
sem := make(chan struct{}, fanoutConcurrency)
for i, q := range queries {
wg.Add(1)
sem <- struct{}{}
go func(i int, q string) {
defer wg.Done()
defer func() { <-sem }()
defer func() {
if r := recover(); r != nil {
results[i] = fanoutResult{
Index: i,
Query: q,
ErrMsg: fmt.Sprintf("internal error: %v", r),
}
}
}()
results[i] = runOneQuery(ctx, runtime, i, q, filter)
}(i, q)
}
wg.Wait()
resp, err := buildFanoutResponse(queries, results)
if err != nil {
return err
}
failed, hasMoreCount := 0, 0
for _, qs := range resp.Queries {
if qs.Error != "" {
failed++
}
if qs.HasMore {
hasMoreCount++
}
}
runtime.OutFormat(resp, &output.Meta{Count: len(resp.Users)}, func(w io.Writer) {
if len(resp.Users) == 0 {
fmt.Fprintln(w, "No users found.")
return
}
output.PrintTable(w, prettyFanoutUserRows(resp.Users))
})
if isFanoutSummaryFormat(runtime.Format) {
fmt.Fprintf(runtime.IO().ErrOut, "\n%d queries, %d total users; %d failed, %d with has_more\n",
len(queries), len(resp.Users), failed, hasMoreCount)
}
return nil
}
func buildFanoutFilter(runtime *common.RuntimeContext) (*searchUserAPIFilter, error) {
filter := &searchUserAPIFilter{}
hasFilter := false
for _, bf := range searchUserBoolFilters {
if runtime.Cmd.Flags().Changed(bf.Flag) && runtime.Bool(bf.Flag) {
bf.Apply(filter)
hasFilter = true
}
}
if !hasFilter {
return nil, nil
}
return filter, nil
}
func prettyFanoutUserRows(users []fanoutUser) []map[string]interface{} {
rows := make([]map[string]interface{}, 0, len(users))
for _, u := range users {
rows = append(rows, map[string]interface{}{
"matched_query": u.MatchedQuery,
"localized_name": u.LocalizedName,
"department": common.TruncateStr(u.Department, 50),
"enterprise_email": u.EnterpriseEmail,
"has_chatted": u.HasChatted,
"chat_recency_hint": u.ChatRecencyHint,
"open_id": u.OpenID,
})
}
return rows
}

View File

@@ -5,10 +5,14 @@ package contact
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"
"unicode/utf8"
"github.com/larksuite/cli/internal/cmdutil"
@@ -620,6 +624,46 @@ func TestSearchUser_Integration_JSONStructuredFields(t *testing.T) {
}
}
// Most users have no signature; the field is omitempty so an empty value
// must not appear at all in the JSON, not as "" — agents shouldn't have to
// distinguish "absent" from "empty string".
func TestSearchUser_Integration_EmptySignatureOmitted(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"id": "ou_a",
"meta_data": map[string]interface{}{
"i18n_names": map[string]interface{}{"zh_cn": "无签名用户"},
"mail_address": "x@example.com",
"description": "",
},
},
},
"has_more": false,
},
},
})
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "x", "--format", "json", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v\nstdout=%s", err, stdout.String())
}
users := got["data"].(map[string]interface{})["users"].([]interface{})
u := users[0].(map[string]interface{})
if _, present := u["signature"]; present {
t.Errorf(`signature must be absent (not "") when empty; got %v`, u["signature"])
}
}
func TestSearchUser_Integration_NDJSONHasNoRefineHint(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
@@ -808,6 +852,345 @@ func TestSearchUser_Integration_PageSizeFlowsToQuery(t *testing.T) {
reg.Verify(t)
}
func newSearchUserTestCommandWithQueries() *cobra.Command {
cmd := newSearchUserTestCommand()
cmd.Flags().String("queries", "", "")
return cmd
}
func TestValidateQueries_QueryAndQueriesMutex(t *testing.T) {
cmd := newSearchUserTestCommandWithQueries()
_ = cmd.Flags().Set("query", "alice")
_ = cmd.Flags().Set("queries", "bob,carol")
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "--query and --queries are mutually exclusive") {
t.Fatalf("expected mutex error, got %v", err)
}
}
func TestValidateQueries_UserIDsAndQueriesMutex(t *testing.T) {
cmd := newSearchUserTestCommandWithQueries()
_ = cmd.Flags().Set("user-ids", "ou_a")
_ = cmd.Flags().Set("queries", "bob")
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "--user-ids and --queries are mutually exclusive") {
t.Fatalf("expected mutex error, got %v", err)
}
}
func TestValidateQueries_AllSeparators_Errors(t *testing.T) {
for _, raw := range []string{",,,", " , , ", ","} {
cmd := newSearchUserTestCommandWithQueries()
_ = cmd.Flags().Set("queries", raw)
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "no valid query parsed") {
t.Fatalf("raw=%q: expected 'no valid query parsed' error, got %v", raw, err)
}
}
}
func TestValidateQueries_OverLength_Errors(t *testing.T) {
cmd := newSearchUserTestCommandWithQueries()
long := strings.Repeat("a", 51)
_ = cmd.Flags().Set("queries", "short,"+long)
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "exceeds 50 characters") {
t.Fatalf("expected length error mentioning 50, got %v", err)
}
}
func TestValidateQueries_Over20_Errors(t *testing.T) {
cmd := newSearchUserTestCommandWithQueries()
parts := make([]string, 21)
for i := range parts {
parts[i] = fmt.Sprintf("q%02d", i)
}
_ = cmd.Flags().Set("queries", strings.Join(parts, ","))
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
err := validateSearchUser(rt)
if err == nil || !strings.Contains(err.Error(), "must be at most 20 entries") {
t.Fatalf("expected 20-cap error, got %v", err)
}
}
func TestParseQueries_TrimAndSkipEmpty(t *testing.T) {
got := parseAndDedupQueries("a, ,b ,")
want := []string{"a", "b"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Errorf("parseAndDedupQueries: got %v, want %v", got, want)
}
}
func TestParseQueries_DedupCaseSensitive(t *testing.T) {
got := parseAndDedupQueries("alice,Alice,alice")
want := []string{"alice", "Alice"}
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
t.Errorf("got %v, want %v (case-sensitive dedup keeps first-occurrence order)", got, want)
}
}
func TestExecuteSingleQuery_OutputUnchanged(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(searchUserStub())
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "张三", "--format", "json", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data, _ := got["data"].(map[string]interface{})
if _, hasQueries := data["queries"]; hasQueries {
t.Errorf("single-query mode must NOT emit data.queries; got=%v", data)
}
users, _ := data["users"].([]interface{})
if len(users) != 1 {
t.Fatalf("users len = %d, want 1", len(users))
}
u, _ := users[0].(map[string]interface{})
if _, hasMatched := u["matched_query"]; hasMatched {
t.Errorf("single-query mode users[] must NOT carry matched_query; got=%v", u)
}
if _, hasTopHasMore := data["has_more"]; !hasTopHasMore {
t.Errorf("single-query mode must keep top-level data.has_more; data=%v", data)
}
}
// runOneQueryRuntime wires a Factory-backed RuntimeContext bound to the test
// command's flag set, so runOneQuery can be exercised directly without going
// through the cobra dispatcher. Mirrors what mountAndRun would build, minus
// the parent-command plumbing the worker doesn't need.
func runOneQueryRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
t.Helper()
f, _, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
cmd := newSearchUserTestCommand()
rt := common.TestNewRuntimeContextForAPI(context.Background(), cmd, searchUserDefaultConfig(), f, core.AsUser)
return rt, reg
}
func TestRunOneQuery_Success(t *testing.T) {
rt, reg := runOneQueryRuntime(t)
reg.Register(searchUserStub())
got := runOneQuery(context.Background(), rt, 0, "张三", nil)
if got.ErrMsg != "" {
t.Fatalf("unexpected ErrMsg: %q", got.ErrMsg)
}
if got.Index != 0 || got.Query != "张三" {
t.Errorf("Index/Query mismatch: %+v", got)
}
if len(got.Users) != 1 || got.Users[0].OpenID != "ou_a" {
t.Errorf("Users mismatch: %+v", got.Users)
}
if got.HasMore {
t.Errorf("HasMore should be false")
}
}
func TestRunOneQuery_APINonZeroCode(t *testing.T) {
rt, reg := runOneQueryRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: searchUserURL,
Body: map[string]interface{}{"code": 99991663, "msg": "rate limited"},
})
got := runOneQuery(context.Background(), rt, 3, "alice", nil)
if got.Index != 3 || got.Query != "alice" {
t.Errorf("Index/Query mismatch: %+v", got)
}
if got.ErrMsg != "API 99991663: rate limited" {
t.Errorf("ErrMsg = %q, want 'API 99991663: rate limited'", got.ErrMsg)
}
if got.Users != nil || got.HasMore {
t.Errorf("on error, Users/HasMore must be zero values; got %+v", got)
}
}
func TestRunOneQuery_HTTPNon200(t *testing.T) {
rt, reg := runOneQueryRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: searchUserURL,
Status: 503,
Body: map[string]interface{}{"reason": "upstream_unavailable"},
})
got := runOneQuery(context.Background(), rt, 1, "bob", nil)
if !strings.HasPrefix(got.ErrMsg, "HTTP 503 Service Unavailable: ") {
t.Errorf("ErrMsg should start with status line; got %q", got.ErrMsg)
}
if !strings.Contains(got.ErrMsg, "upstream_unavailable") {
t.Errorf("ErrMsg should include response body for diagnosis; got %q", got.ErrMsg)
}
if got.ErrCode != 503 {
t.Errorf("ErrCode = %d, want 503", got.ErrCode)
}
}
func TestRunOneQuery_HTTPNon200_BodyTruncated(t *testing.T) {
rt, reg := runOneQueryRuntime(t)
long := strings.Repeat("x", 1000)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: searchUserURL,
Status: 500,
Body: map[string]interface{}{"detail": long},
})
got := runOneQuery(context.Background(), rt, 0, "alice", nil)
if !strings.HasSuffix(got.ErrMsg, "...") {
t.Errorf("oversized body should be truncated with '...' suffix; got %q", got.ErrMsg)
}
if len(got.ErrMsg) > 300 {
t.Errorf("ErrMsg %d chars exceeds reasonable budget; got %q", len(got.ErrMsg), got.ErrMsg)
}
}
// SDK-level transport / envelope-unmarshal failures arrive as Go errors from
// runtime.DoAPI; the worker converts them by calling err.Error() rather than
// adding its own prefix, so the assertion here is "ErrMsg is non-empty and
// preserves the underlying message" — the exact text comes from the SDK.
func TestRunOneQuery_TransportError(t *testing.T) {
rt, reg := runOneQueryRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: searchUserURL,
RawBody: []byte("{not-json"),
})
got := runOneQuery(context.Background(), rt, 2, "carol", nil)
if got.ErrMsg == "" {
t.Fatalf("expected non-empty ErrMsg for malformed body")
}
if got.Index != 2 || got.Query != "carol" {
t.Errorf("Index/Query mismatch: %+v", got)
}
if got.Users != nil || got.HasMore {
t.Errorf("on error, Users/HasMore must be zero values; got %+v", got)
}
}
func TestFanoutAssemble_OrderAndShape(t *testing.T) {
results := []fanoutResult{
{Index: 1, Query: "bob", Users: []searchUser{{OpenID: "ou_b"}}, HasMore: true},
{Index: 0, Query: "alice", Users: []searchUser{{OpenID: "ou_a1"}, {OpenID: "ou_a2"}}, HasMore: false},
{Index: 2, Query: "carol", ErrMsg: "API 1: nope"},
}
resp, err := buildFanoutResponse([]string{"alice", "bob", "carol"}, results)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp.Users) != 3 {
t.Fatalf("Users length: got %d, want 3 (carol failed → 0 users)", len(resp.Users))
}
if resp.Users[0].OpenID != "ou_a1" || resp.Users[0].MatchedQuery != "alice" {
t.Errorf("Users[0]: got %+v", resp.Users[0])
}
if resp.Users[1].OpenID != "ou_a2" || resp.Users[1].MatchedQuery != "alice" {
t.Errorf("Users[1]: got %+v", resp.Users[1])
}
if resp.Users[2].OpenID != "ou_b" || resp.Users[2].MatchedQuery != "bob" {
t.Errorf("Users[2]: got %+v", resp.Users[2])
}
if len(resp.Queries) != 3 {
t.Fatalf("Queries length: got %d, want 3 (full enumeration)", len(resp.Queries))
}
want := []querySummary{
{Query: "alice", Error: "", HasMore: false},
{Query: "bob", Error: "", HasMore: true},
{Query: "carol", Error: "API 1: nope", HasMore: false},
}
for i, w := range want {
if resp.Queries[i] != w {
t.Errorf("Queries[%d]: got %+v, want %+v", i, resp.Queries[i], w)
}
}
}
func TestFanoutAssemble_AllFailed_ReturnsError(t *testing.T) {
results := []fanoutResult{
{Index: 0, Query: "alice", ErrMsg: "API 99991663: rate limit"},
{Index: 1, Query: "bob", ErrMsg: "HTTP 500 Internal Server Error"},
}
_, err := buildFanoutResponse([]string{"alice", "bob"}, results)
if err == nil {
t.Fatalf("expected error when all queries failed")
}
if !strings.Contains(err.Error(), "rate limit") {
t.Errorf("expected first error (rate limit) to be returned; got %v", err)
}
// Document the count is part of the message — agents grep for it.
if !strings.Contains(err.Error(), "all 2 queries failed") {
t.Errorf("expected 'all 2 queries failed' substring; got %v", err)
}
}
// Codes from the first failure must propagate through output.ErrAPI so the
// CLI's exit-code classifier sees the real signal (e.g., 99991663 rate limit)
// instead of 0, which would mean "success" in the Lark protocol.
func TestFanoutAssemble_AllFailed_PropagatesFirstCode(t *testing.T) {
results := []fanoutResult{
{Index: 0, Query: "alice", ErrMsg: "API 99991663: rate limit", ErrCode: 99991663},
{Index: 1, Query: "bob", ErrMsg: "HTTP 500", ErrCode: 500},
}
_, err := buildFanoutResponse([]string{"alice", "bob"}, results)
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "rate limit") {
t.Errorf("error should contain first ErrMsg; got %v", err)
}
}
func TestFanoutAssemble_PartialFailureOK(t *testing.T) {
results := []fanoutResult{
{Index: 0, Query: "alice", Users: []searchUser{{OpenID: "ou_a"}}},
{Index: 1, Query: "bob", ErrMsg: "API 5: not found"},
}
resp, err := buildFanoutResponse([]string{"alice", "bob"}, results)
if err != nil {
t.Fatalf("partial failure must NOT be a hard error; got %v", err)
}
if len(resp.Users) != 1 {
t.Errorf("Users: got %d, want 1", len(resp.Users))
}
if resp.Queries[1].Error != "API 5: not found" {
t.Errorf("Queries[1].Error: got %q", resp.Queries[1].Error)
}
}
func TestFanoutAssemble_NoTopLevelHasMore(t *testing.T) {
results := []fanoutResult{
{Index: 0, Query: "alice", HasMore: true},
}
resp, err := buildFanoutResponse([]string{"alice"}, results)
if err != nil {
t.Fatalf("unexpected: %v", err)
}
raw, _ := json.Marshal(resp)
var asMap map[string]interface{}
if err := json.Unmarshal(raw, &asMap); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if _, ok := asMap["has_more"]; ok {
t.Errorf("fanoutResponse must not have top-level has_more; got %v", asMap)
}
if _, ok := asMap["users"]; !ok {
t.Errorf("fanoutResponse missing users")
}
if _, ok := asMap["queries"]; !ok {
t.Errorf("fanoutResponse missing queries")
}
}
// Verifies that with the auto-pagination flags removed, --page-all / --page-limit
// are no longer accepted. cobra must reject the unknown flag at parse time —
// no stub is registered because the command should never reach the API.
@@ -827,3 +1210,341 @@ func TestSearchUser_Integration_NoAutoPaginationFlags(t *testing.T) {
})
}
}
func TestFanout_FilterAppliedToEachQuery(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search",
Reusable: true,
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false}},
}
reg.Register(stub)
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "alice,bob", "--has-chatted",
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(stub.CapturedBodies) < 2 {
t.Fatalf("expected ≥2 captured request bodies, got %d", len(stub.CapturedBodies))
}
bodyByQuery := map[string]map[string]interface{}{}
for i, raw := range stub.CapturedBodies {
var body map[string]interface{}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("unmarshal req %d: %v", i, err)
}
bodyByQuery[body["query"].(string)] = body
filter, _ := body["filter"].(map[string]interface{})
if filter == nil || filter["has_contact"] != true {
t.Errorf("req %d (query=%v): expected filter.has_contact=true; got body=%v", i, body["query"], body)
}
}
if _, ok := bodyByQuery["alice"]; !ok {
t.Errorf("missing request for query=alice; captured=%v", bodyByQuery)
}
if _, ok := bodyByQuery["bob"]; !ok {
t.Errorf("missing request for query=bob; captured=%v", bodyByQuery)
}
}
func TestFanout_PartialFailure_ExitZero(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"alice"`) },
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
"has_more": false,
}},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"bob"`) },
Status: 500,
Body: map[string]interface{}{},
})
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "alice,bob", "--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("partial failure should NOT propagate as error; got %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v\nstdout=%s", err, stdout.String())
}
data := got["data"].(map[string]interface{})
users := data["users"].([]interface{})
if len(users) != 1 {
t.Errorf("users: expected 1 (alice), got %d; stdout=%s", len(users), stdout.String())
}
queries := data["queries"].([]interface{})
if len(queries) != 2 {
t.Fatalf("queries: expected 2, got %d", len(queries))
}
q1 := queries[1].(map[string]interface{})
if !strings.HasPrefix(q1["error"].(string), "HTTP 500") {
t.Errorf("queries[1].error: got %q", q1["error"])
}
}
func TestFanout_AllFailed_ExitNonZero(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
Reusable: true,
Status: 500, Body: map[string]interface{}{"reason": "boom"},
})
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "alice,bob", "--format", "json", "--as", "user",
}, f, stdout)
if err == nil {
t.Fatalf("expected error when all queries failed")
}
// First failure's HTTP code (500) and a digestible reason must propagate
// so agents can classify (vs. a generic ExitInternal masking the upstream).
msg := err.Error()
if !strings.Contains(msg, "500") {
t.Errorf("error must propagate first failure's HTTP 500 code; got %q", msg)
}
if !strings.Contains(msg, "all 2 queries failed") {
t.Errorf("error must indicate the all-failed mode; got %q", msg)
}
}
func TestFanout_ConcurrencyLimitFive(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
var inFlight, peak int32
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search",
Reusable: true,
OnMatch: func(req *http.Request) {
cur := atomic.AddInt32(&inFlight, 1)
defer atomic.AddInt32(&inFlight, -1)
for {
p := atomic.LoadInt32(&peak)
if cur <= p || atomic.CompareAndSwapInt32(&peak, p, cur) {
break
}
}
time.Sleep(50 * time.Millisecond)
},
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false}},
})
queries := []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"}
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", strings.Join(queries, ","),
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if peak > 5 {
t.Errorf("concurrency peak = %d, want ≤ 5", peak)
}
if peak < 2 {
t.Errorf("concurrency peak = %d, want ≥ 2 (test should observe parallelism)", peak)
}
}
func TestFanout_PanicRecovery(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"boom"`) },
OnMatch: func(req *http.Request) {
panic("synthetic test panic")
},
Body: map[string]interface{}{},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search",
Reusable: true,
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false}},
})
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "ok,boom,fine", "--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("partial panic must not bubble; got %v", err)
}
var got map[string]interface{}
_ = json.Unmarshal(stdout.Bytes(), &got)
queries := got["data"].(map[string]interface{})["queries"].([]interface{})
q1 := queries[1].(map[string]interface{})
if !strings.HasPrefix(q1["error"].(string), "internal error:") {
t.Errorf("queries[1].error: expected 'internal error:' prefix, got %q", q1["error"])
}
for _, marker := range []string{"goroutine ", ".go:", "runtime."} {
if strings.Contains(stderr.String(), marker) {
t.Errorf("stderr leaked stack-trace marker %q; got=%s", marker, stderr.String())
}
}
}
func TestFanout_MatchedQueryFidelity(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search",
Reusable: true,
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "ou_x"}},
"has_more": false,
}},
})
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "张三,Alice 王", "--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
_ = json.Unmarshal(stdout.Bytes(), &got)
users := got["data"].(map[string]interface{})["users"].([]interface{})
if len(users) != 2 {
t.Fatalf("users: got %d, want 2", len(users))
}
want := []string{"张三", "Alice 王"}
for i, w := range want {
mq := users[i].(map[string]interface{})["matched_query"]
if mq != w {
t.Errorf("users[%d].matched_query: got %v, want %q (must be original input verbatim)", i, mq, w)
}
}
}
func TestFanout_NDJSONStdoutClean(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search",
Reusable: true,
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
"has_more": false,
}},
})
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "a,a,b", "--format", "ndjson", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
for _, marker := range []string{"queries,", "total users", "with has_more"} {
if strings.Contains(stdout.String(), marker) {
t.Errorf("ndjson stdout must not contain %q; got=%q", marker, stdout.String())
}
}
_ = stderr
}
func TestFanout_CSVHasMatchedQueryColumn(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/search",
Reusable: true,
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
"has_more": false,
}},
})
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "alice,bob", "--format", "csv", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if !strings.Contains(stdout.String(), "matched_query") {
t.Errorf("csv stdout must include matched_query column; got=%q", stdout.String())
}
if !strings.Contains(stderr.String(), "queries") || !strings.Contains(stderr.String(), "total users") {
t.Errorf("csv summary should land on stderr; got=%q", stderr.String())
}
}
func TestFanout_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, searchUserDefaultConfig())
err := mountAndRun(t, ContactSearchUser, []string{
"+search-user", "--queries", "alice,bob", "--has-chatted", "--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
out := stdout.String()
for _, want := range []string{"alice", "bob", "POST", "/contact/v3/users/search", "has_contact"} {
if !strings.Contains(out, want) {
t.Errorf("dry-run output missing %q; got=%q", want, out)
}
}
// One DryRunAPI description per query.
if strings.Count(out, "/contact/v3/users/search") < 2 {
t.Errorf("dry-run should describe ≥2 API calls (one per query); got=%q", out)
}
}
// Spec §7 promises single-query --query mode is "零变化". The fanout summary
// hint was broadened to csv (good — stderr can carry it without corrupting
// the csv stream on stdout); the single-query refine hint must NOT inherit
// that broadening, since pre-fanout it only fired on pretty/table.
func TestSearchUser_Integration_CSVSingleQueryNoRefineHint(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/contact/v3/users/search",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
"has_more": true,
"page_token": "tok_next",
},
},
})
err := mountAndRun(t, ContactSearchUser, []string{"+search-user", "--query", "x", "--format", "csv", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if strings.Contains(stderr.String(), "refine") {
t.Errorf("single-query --format csv must NOT emit the refine hint; got stderr=%q", stderr.String())
}
}
// A pre-canceled ctx must be observed by runOneQuery before it dispatches the
// HTTP call. The error string is exactly "context canceled" because that's
// what context.Context.Err().Error() returns — agents may grep for it.
func TestRunOneQuery_CtxCanceledEarly(t *testing.T) {
rt, _ := runOneQueryRuntime(t)
// Deliberately register no stub: runOneQuery must short-circuit before
// touching the transport, so the absence of a stub is the assertion.
ctx, cancel := context.WithCancel(context.Background())
cancel()
got := runOneQuery(ctx, rt, 0, "alice", nil)
if got.ErrMsg != "context canceled" {
t.Errorf("ErrMsg: got %q, want %q", got.ErrMsg, "context canceled")
}
if got.Index != 0 || got.Query != "alice" {
t.Errorf("Index/Query mismatch: %+v", got)
}
}

View File

@@ -43,6 +43,7 @@ var DocsCreate = common.Shortcut{
Risk: "write",
AuthTypes: []string{"user", "bot"},
Scopes: []string{"docx:document:create"},
Tips: docsVersionSelectionTips,
Flags: concatFlags(
[]common.Flag{
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
@@ -110,6 +111,11 @@ func dryRunCreateV1(_ context.Context, runtime *common.RuntimeContext) *common.D
func executeCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
warnDeprecatedV1(runtime, "+create")
// Surface callout type= hint so users know to switch to background-color/
// border-color when they want a colored callout. Non-blocking, advisory.
if md := runtime.Str("markdown"); md != "" {
WarnCalloutType(md, runtime.IO().ErrOut)
}
args := buildCreateArgsV1(runtime)
result, err := common.CallMCPTool(runtime, "create-doc", args)
if err != nil {
@@ -122,8 +128,9 @@ func executeCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
}
func buildCreateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
md := runtime.Str("markdown")
args := map[string]interface{}{
"markdown": runtime.Str("markdown"),
"markdown": md,
}
if v := runtime.Str("title"); v != "" {
args["title"] = v

View File

@@ -49,6 +49,7 @@ var DocsFetch = common.Shortcut{
Scopes: []string{"docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Tips: docsVersionSelectionTips,
Flags: concatFlags(
[]common.Flag{
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},

View File

@@ -22,7 +22,7 @@ func v2FetchFlags() []common.Flag {
{Name: "scope", Desc: "partial read scope: outline | range | keyword | section (omit to read whole doc)", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
{Name: "start-block-id", Desc: "range/section mode: start (anchor) block id"},
{Name: "end-block-id", Desc: "range mode: end block id; \"-1\" = to end of document"},
{Name: "keyword", Desc: "keyword mode: search string (case-insensitive); use '|' to match multiple keywords, e.g. 'foo|bar|baz'"},
{Name: "keyword", Desc: "keyword mode: substring + regex match (case-insensitive); use '|' for OR branches, e.g. 'foo|bar' or 'bug|缺陷'"},
{Name: "context-before", Desc: "range/keyword/section mode: sibling blocks before match", Type: "int", Default: "0"},
{Name: "context-after", Desc: "range/keyword/section mode: sibling blocks after match", Type: "int", Default: "0"},
{Name: "max-depth", Desc: "outline: heading level cap; range/keyword/section: block subtree depth (-1 = unlimited)", Type: "int", Default: "-1"},

View File

@@ -64,6 +64,7 @@ var DocsUpdate = common.Shortcut{
Risk: "write",
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
Tips: docsVersionSelectionTips,
Flags: concatFlags(
[]common.Flag{
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
@@ -159,6 +160,12 @@ func executeUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
}
// Surface callout type= hint so users know to switch to background-color/
// border-color when they want a colored callout. Non-blocking, advisory.
if md := runtime.Str("markdown"); md != "" {
WarnCalloutType(md, runtime.IO().ErrOut)
}
args := buildUpdateArgsV1(runtime)
result, err := common.CallMCPTool(runtime, "update-doc", args)

View File

@@ -4,6 +4,8 @@
package doc
import (
"fmt"
"io"
"regexp"
"strings"
"unicode"
@@ -306,6 +308,113 @@ func fixSetextAmbiguity(md string) string {
return setextRe.ReplaceAllString(md, "$1\n\n$2")
}
// calloutTypeColors maps the semantic type= shorthand to a recommended
// [background-color, border-color] pair for Feishu callout blocks.
// Used only for hint messages — the Markdown itself is never rewritten.
var calloutTypeColors = map[string][2]string{
"warning": {"light-yellow", "yellow"},
"caution": {"light-orange", "orange"},
"note": {"light-blue", "blue"},
"info": {"light-blue", "blue"},
"tip": {"light-green", "green"},
"success": {"light-green", "green"},
"check": {"light-green", "green"},
"error": {"light-red", "red"},
"danger": {"light-red", "red"},
"important": {"light-purple", "purple"},
}
// calloutOpenTagRe matches a <callout …> opening tag.
var calloutOpenTagRe = regexp.MustCompile(`<callout(\s[^>]*)?>`)
// calloutTypeAttrRe extracts the value of a type= attribute (single or
// double quoted) from a callout opening tag's attribute string. The
// (?:^|\s) anchor instead of \b is intentional: \b sits at any
// word/non-word boundary, and `-` is a non-word character, so
// `\btype=` would also match the suffix of `data-type=` and yield a
// bogus type lookup. Anchoring on start-of-string-or-whitespace
// requires a real attribute separator before the name.
var calloutTypeAttrRe = regexp.MustCompile(`(?:^|\s)type=(?:"([^"]*)"|'([^']*)')`)
// calloutBackgroundColorAttrRe matches a background-color= attribute
// name with optional whitespace around the equals sign, so forms like
// `background-color="..."` and `background-color = "..."` are both
// accepted. Same (?:^|\s) anchor as calloutTypeAttrRe, for the same
// reason: `data-background-color="..."` must not look like a present
// background-color and silently suppress the hint.
var calloutBackgroundColorAttrRe = regexp.MustCompile(`(?:^|\s)background-color\s*=`)
// WarnCalloutType scans md for callout tags that carry a type= attribute but
// no background-color= attribute, then writes a hint line to w for each one
// suggesting the explicit Feishu color attributes to use instead.
//
// Callout tags inside fenced code blocks (``` or ~~~) are skipped — they
// are documentation samples, not real callouts the user wants Feishu to
// render. Fence detection uses the shared codeFenceOpenMarker /
// isCodeFenceClose helpers so both backtick and tilde fences are handled
// (matching CommonMark §4.5).
//
// The Markdown is not modified — the caller is responsible for acting on
// the hints or ignoring them. This keeps the create/update path
// transparent: user input reaches create-doc exactly as written.
func WarnCalloutType(md string, w io.Writer) {
fenceMarker := ""
for _, line := range strings.Split(md, "\n") {
if fenceMarker != "" {
// Inside a fenced block — skip everything until the matching
// closer. Code samples that show literal <callout type=...>
// must not produce a phantom hint.
if isCodeFenceClose(line, fenceMarker) {
fenceMarker = ""
}
continue
}
if marker := codeFenceOpenMarker(line); marker != "" {
fenceMarker = marker
continue
}
scanCalloutTagsForWarning(line, w)
}
}
// scanCalloutTagsForWarning emits a hint to w for every <callout type="...">
// tag in s that lacks an explicit background-color= attribute. Pulled out
// of WarnCalloutType so the line walker only handles fence state and the
// per-tag scan is its own readable unit.
//
// The previous implementation routed the tag iteration through
// calloutOpenTagRe.ReplaceAllStringFunc with a callback that always
// returned the original tag and threw the rebuilt string away — using a
// rewrite primitive purely for its iteration side-effect, plus a second
// regex execution to recover the capture groups inside the callback.
// FindAllStringSubmatch hands us both the iteration and the groups in one
// pass, no allocation thrown away.
func scanCalloutTagsForWarning(s string, w io.Writer) {
for _, m := range calloutOpenTagRe.FindAllStringSubmatch(s, -1) {
attrs := m[1]
// Skip tags that already carry an explicit background-color.
if calloutBackgroundColorAttrRe.MatchString(attrs) {
continue
}
parts := calloutTypeAttrRe.FindStringSubmatch(attrs)
if len(parts) < 3 {
continue // no type= attribute
}
// parts[1] is the double-quoted capture, parts[2] is single-quoted.
typeName := parts[1]
if typeName == "" {
typeName = parts[2]
}
colors, ok := calloutTypeColors[typeName]
if !ok {
continue // unknown type — no hint to give
}
fmt.Fprintf(w,
"hint: callout type=%q has no background-color; consider: background-color=%q border-color=%q\n",
typeName, colors[0], colors[1])
}
}
// calloutEmojiAliases maps named emoji strings that fetch-doc emits to actual
// Unicode emoji characters that create-doc accepts.
var calloutEmojiAliases = map[string]string{

View File

@@ -359,6 +359,135 @@ func TestFixExportedMarkdown(t *testing.T) {
}
}
func TestWarnCalloutType(t *testing.T) {
tests := []struct {
name string
input string
wantHint bool // whether a hint line is expected
hintContains string // substring the hint must contain
}{
{
name: "warning type without background-color emits hint",
input: `<callout type="warning" emoji="📝">`,
wantHint: true,
hintContains: `background-color="light-yellow"`,
},
{
name: "info type without background-color emits hint",
input: `<callout type="info" emoji="">`,
wantHint: true,
hintContains: `background-color="light-blue"`,
},
{
name: "single-quoted type attribute emits hint",
input: `<callout type='warning' emoji="📝">`,
wantHint: true,
hintContains: `background-color="light-yellow"`,
},
{
name: "explicit background-color suppresses hint",
input: `<callout type="warning" emoji="📝" background-color="light-red">`,
wantHint: false,
},
{
name: "whitespace around equals is tolerated in background-color",
input: `<callout type="warning" emoji="📝" background-color = "light-red">`,
wantHint: false,
},
{
name: "unknown type emits no hint",
input: `<callout type="custom" emoji="🔥">`,
wantHint: false,
},
{
name: "no type attribute emits no hint",
input: `<callout emoji="💡" background-color="light-green">`,
wantHint: false,
},
{
name: "non-callout tag emits no hint",
input: `<div type="warning">`,
wantHint: false,
},
{
name: "hint includes border-color suggestion",
input: `<callout type="error" emoji="❌">`,
wantHint: true,
hintContains: `border-color="red"`,
},
{
// Regression: the old `\btype=` regex matched the suffix of
// `data-type=` because `-` is a non-word character, so a tag
// carrying only data-attrs would silently get a bogus hint.
// The (?:^|\s) anchor requires a real attribute separator.
name: "data-type attribute does not trigger hint",
input: `<callout data-type="warning" emoji="📝">`,
wantHint: false,
},
{
// Symmetric guard for the background-color regex: a future
// `data-background-color=` attribute must not be mistaken
// for a present background-color and silently suppress the
// hint that the real type= would otherwise produce.
name: "data-background-color does not suppress hint",
input: `<callout type="warning" data-background-color="anything">`,
wantHint: true,
hintContains: `background-color="light-yellow"`,
},
{
// Regression for the code-fence skip: a documentation sample
// inside a ``` fence is NOT a real callout the user wants
// rendered, so it must produce no stderr noise.
name: "callout inside backtick fence emits no hint",
input: "```markdown\n" +
`<callout type="warning" emoji="📝">` + "\n" +
"```\n",
wantHint: false,
},
{
// Same skip works for tilde fences (CommonMark §4.5 makes
// `~~~` an equivalent fence character).
name: "callout inside tilde fence emits no hint",
input: "~~~markdown\n" +
`<callout type="info" emoji="">` + "\n" +
"~~~\n",
wantHint: false,
},
{
// Closing the fence must restore normal scanning: a real
// callout that follows a documentation block still gets a
// hint. Pins that fenceMarker is reset, not stuck.
name: "callout after fence close still emits hint",
input: "```markdown\n" +
`<callout type="warning">sample</callout>` + "\n" +
"```\n" +
`<callout type="error" emoji="❌">real</callout>` + "\n",
wantHint: true,
hintContains: `border-color="red"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf strings.Builder
WarnCalloutType(tt.input, &buf)
got := buf.String()
if tt.wantHint {
if got == "" {
t.Errorf("WarnCalloutType(%q): expected hint, got no output", tt.input)
return
}
if tt.hintContains != "" && !strings.Contains(got, tt.hintContains) {
t.Errorf("WarnCalloutType(%q): hint %q missing %q", tt.input, got, tt.hintContains)
}
} else {
if got != "" {
t.Errorf("WarnCalloutType(%q): expected no output, got %q", tt.input, got)
}
}
})
}
}
func TestFixCalloutEmoji(t *testing.T) {
tests := []struct {
name string

View File

@@ -3,7 +3,24 @@
package doc
import "github.com/larksuite/cli/shortcuts/common"
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
const docsServiceHelpDefault = `Document and content operations.`
const docsServiceHelpV2 = `Document and content operations (v2).`
var docsVersionSelectionTips = []string{
"Agent version rule: use --api-version v2 only when the installed lark-doc skill explicitly instructs docs +create, docs +fetch, or docs +update to use v2; otherwise use the default v1 flags.",
"Do not mix versions: if the skill does not mention v2, follow its legacy v1 examples and flags.",
}
// Shortcuts returns all docs shortcuts.
func Shortcuts() []common.Shortcut {
@@ -18,3 +35,48 @@ func Shortcuts() []common.Shortcut {
DocMediaDownload,
}
}
// ConfigureServiceHelp adds docs-specific guidance to the parent `docs` command.
// The shortcut-level help remains compatible with legacy v1 skills; this parent
// help gives agents enough context to choose v2 only when their installed skill
// explicitly asks for `--api-version v2`.
func ConfigureServiceHelp(cmd *cobra.Command) {
if cmd == nil {
return
}
serviceCmd := cmd
cmd.Long = strings.TrimSpace(docsServiceHelpDefault)
if cmd.Flags().Lookup("api-version") == nil {
cmd.Flags().String("api-version", "", "show docs help for API version (v1|v2)")
cmdutil.RegisterFlagCompletion(cmd, "api-version", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"v1", "v2"}, cobra.ShellCompDirectiveNoFileComp
})
}
defaultHelp := cmd.HelpFunc()
cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
if cmd != serviceCmd {
defaultHelp(cmd, args)
return
}
apiVersion, _ := cmd.Flags().GetString("api-version")
previousLong := cmd.Long
if apiVersion == "v2" {
cmd.Long = strings.TrimSpace(docsServiceHelpV2)
} else {
cmd.Long = strings.TrimSpace(docsServiceHelpDefault)
}
defer func() {
cmd.Long = previousLong
}()
defaultHelp(cmd, args)
out := cmd.OutOrStdout()
fmt.Fprintln(out)
fmt.Fprintln(out, "Tips:")
for _, tip := range docsVersionSelectionTips {
fmt.Fprintf(out, " • %s\n", tip)
}
})
}

View File

@@ -30,13 +30,6 @@ func installVersionedHelp(cmd *cobra.Command, defaultVersion string, flagVersion
}
})
origHelp(cmd, args)
if ver == "v1" {
fmt.Fprintf(cmd.OutOrStdout(),
"\n[NOTE] v1 API is deprecated and will be removed in a future release.\n"+
" Use --api-version v2 for the latest API:\n"+
" %s %s --api-version v2 --help\n",
cmd.Parent().Name(), cmd.Name())
}
})
}

View File

@@ -18,6 +18,35 @@ import (
const defaultLocateDocLimit = 10
// maxCommentTotalRunes is the cap on the combined character (rune) count
// across all `reply_elements[].text` fields in a single
// `drive +add-comment` request.
//
// The open-platform `/open-apis/drive/v1/files/{token}/new_comments`
// endpoint returns an opaque `[1069302] Invalid or missing parameters`
// when this is exceeded — no indication that length is the cause or
// which element is at fault.
//
// Empirically (probing the live API):
//
// - 10000 runes in a single text element: OK (10000 ASCII / 30000
// bytes for Chinese / 40000 bytes if all '<' — server counts the
// raw rune count, not byte width and not the post-escape form)
// - 10001 runes in a single text element: [1069302]
// - 5000 + 5000 across two elements (total 10000): OK
// - 5000 + 5001 across two elements (total 10001): [1069302]
//
// So the cap is applied to the *total* across all reply_elements, not
// per element. Splitting an over-the-cap message into multiple text
// elements does NOT help — the server enforces the same limit on the
// sum.
//
// The schema doc currently advertises a 1-1000 character limit, but
// the live API accepts up to 10000 runes; the schema is out of date.
// If this constant ever needs to track a server-side change, re-probe
// with `drive file.comments create_v2` against a fresh docx.
const maxCommentTotalRunes = 10000
type commentDocRef struct {
Kind string
Token string
@@ -604,6 +633,7 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
}
replyElements := make([]map[string]interface{}, 0, len(inputs))
totalRunes := 0
for i, input := range inputs {
index := i + 1
elementType := strings.TrimSpace(input.Type)
@@ -612,9 +642,27 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
if strings.TrimSpace(input.Text) == "" {
return nil, output.ErrValidation("--content element #%d type=text requires non-empty text", index)
}
if utf8.RuneCountInString(input.Text) > 1000 {
return nil, output.ErrValidation("--content element #%d text exceeds 1000 characters", index)
// Measure the raw rune count of the user input — that is what
// the server actually counts. byte width and post-escape form
// don't matter (10000 '<' chars succeed even though they
// expand to 40000 bytes when escaped, and 10000 Chinese chars
// succeed even though they encode as 30000 UTF-8 bytes).
runes := utf8.RuneCountInString(input.Text)
totalRunes += runes
if totalRunes > maxCommentTotalRunes {
return nil, output.ErrWithHint(
output.ExitValidation,
"text_too_long",
fmt.Sprintf("--content reply_elements text totals %d characters at element #%d (this element: %d); the server caps the combined length at %d characters across ALL reply_elements",
totalRunes, index, runes, maxCommentTotalRunes),
fmt.Sprintf("shorten the comment so the combined text across all reply_elements fits within %d characters. The server enforces this cap on the TOTAL — splitting one long element into multiple smaller text elements does NOT help (they all add up against the same %d-rune budget). Server returns an opaque [1069302] on overflow, so this check is pre-flight; no escape transform changes the count (server reads raw runes).", maxCommentTotalRunes, maxCommentTotalRunes),
)
}
// Escape '<' and '>' so the rendered comment displays them as
// literal characters instead of being interpreted as markup
// by Lark's comment renderer. This is independent of the
// length check — the server sees the escaped form, but
// counts characters by the raw input length above.
replyElements = append(replyElements, map[string]interface{}{
"type": "text",
"text": escapeCommentText(input.Text),

View File

@@ -5,11 +5,13 @@ package drive
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func decodeJSONMap(t *testing.T, raw string) map[string]interface{} {
@@ -292,6 +294,186 @@ func TestParseCommentReplyElementsEscapesAngleBrackets(t *testing.T) {
}
}
func TestParseCommentReplyElementsTextLength(t *testing.T) {
t.Parallel()
// Cap is 10000 runes total across all reply_elements text fields,
// empirically derived from the live API. See the comment on
// maxCommentTotalRunes for the probe results.
exactCapASCII := strings.Repeat("a", 10000)
overCapASCII := strings.Repeat("a", 10001)
// Chinese chars cost 3 bytes each in UTF-8 but the server counts
// runes, not bytes — so the cap is the same 10000 here.
exactCapCJK := strings.Repeat("文", 10000)
overCapCJK := strings.Repeat("文", 10001)
// '<' would expand to '&lt;' (4 bytes) under escapeCommentText, but
// since the server counts raw runes the cap is still 10000 chars,
// not 2500. This pins that distinction.
exactCapAngle := strings.Repeat("<", 10000)
overCapAngle := strings.Repeat("<", 10001)
// Two-element split exactly hitting the cap together.
splitFiveK := strings.Repeat("a", 5000)
splitFiveKPlusOne := strings.Repeat("a", 5001)
tests := []struct {
name string
input string
wantErr string
wantHint string // substring of the hint portion; "" means don't check hint
wantCount int // expected parsed element count when no error expected
}{
{
name: "single element exactly at 10000 ASCII chars accepted",
input: `[{"type":"text","text":"` + exactCapASCII + `"}]`,
wantCount: 1,
},
{
name: "single element at 10001 ASCII chars rejected",
input: `[{"type":"text","text":"` + overCapASCII + `"}]`,
wantErr: "totals 10001 characters at element #1",
wantHint: "splitting one long element into multiple smaller text elements does NOT help",
},
{
name: "single element exactly at 10000 chinese chars accepted (server counts runes, not bytes)",
input: `[{"type":"text","text":"` + exactCapCJK + `"}]`,
wantCount: 1,
},
{
name: "single element at 10001 chinese chars rejected",
input: `[{"type":"text","text":"` + overCapCJK + `"}]`,
wantErr: "totals 10001 characters at element #1",
},
{
name: "10000 angle brackets accepted (server counts raw runes, not escaped form)",
input: `[{"type":"text","text":"` + exactCapAngle + `"}]`,
wantCount: 1,
},
{
name: "10001 angle brackets rejected (escape state irrelevant to cap)",
input: `[{"type":"text","text":"` + overCapAngle + `"}]`,
wantErr: "totals 10001 characters at element #1",
},
{
// Pins the multi-element TOTAL cap: two 5000-char elements
// fit together exactly (10000 sum). This is the boundary the
// previous PR's "split into multiple elements" advice
// implied was a workaround — it's actually only valid if
// the sum still fits.
name: "two elements totalling exactly 10000 accepted",
input: `[{"type":"text","text":"` + splitFiveK + `"},{"type":"text","text":"` + splitFiveK + `"}]`,
wantCount: 2,
},
{
// Companion to the above and the headline reason the prior
// "split into multiple elements" hint is wrong: 5000+5001
// sums to 10001 which the server rejects with the same
// opaque [1069302], regardless of how many elements it's
// distributed across.
name: "two elements totalling 10001 rejected with index pointing at offending element",
input: `[{"type":"text","text":"` + splitFiveK + `"},{"type":"text","text":"` + splitFiveKPlusOne + `"}]`,
wantErr: "totals 10001 characters at element #2",
wantHint: "splitting one long element into multiple smaller text elements does NOT help",
},
{
// Streaming-cap correctness: when an EARLY element by itself
// already overshoots, the index reported is that early
// element (not the last one in the array).
name: "first element over the cap reports index 1",
input: `[{"type":"text","text":"` + overCapASCII + `"},{"type":"text","text":"trailing"}]`,
wantErr: "totals 10001 characters at element #1",
},
{
// mention_user / link elements don't count toward the
// rune cap (their content is ID / URL, not user-visible
// running text). Pin that a moderate text plus a mention
// stays accepted even though the mention adds bytes.
name: "text plus mention_user does not double-count toward cap",
input: `[{"type":"text","text":"` + exactCapASCII + `"},{"type":"mention_user","text":"ou_1234567890abcdef"}]`,
wantCount: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := parseCommentReplyElements(tt.input)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil (parsed %d elements)", tt.wantErr, len(got))
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
}
if tt.wantHint != "" {
// Hint lives on ExitError.Detail.Hint, not err.Error().
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with Detail, got %T (%v)", err, err)
}
if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) {
t.Errorf("expected hint substring %q, got %q", tt.wantHint, exitErr.Detail.Hint)
}
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != tt.wantCount {
t.Fatalf("expected %d reply elements, got %d", tt.wantCount, len(got))
}
})
}
}
// TestParseCommentReplyElementsHintForbidsSplitAdvice pins that the
// over-cap hint does NOT recommend splitting into multiple text
// elements as a workaround. An earlier version of this PR shipped
// that advice; live-API probing showed the cap is on the *total* run
// of characters across all reply_elements, so splitting doesn't
// bypass it. If the hint ever drifts back into recommending a split,
// users will be sent down a dead end where their first attempt fails
// pre-flight, their "fixed" attempt also fails server-side, and
// they're stuck.
func TestParseCommentReplyElementsHintForbidsSplitAdvice(t *testing.T) {
t.Parallel()
_, err := parseCommentReplyElements(`[{"type":"text","text":"` + strings.Repeat("a", 10001) + `"}]`)
if err == nil {
t.Fatal("expected over-cap error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with Detail, got %T (%v)", err, err)
}
hint := exitErr.Detail.Hint
// The hint must explicitly call out that splitting does NOT help.
if !strings.Contains(hint, "does NOT help") {
t.Errorf("hint must explicitly say splitting does NOT help, got: %q", hint)
}
// Anti-pattern check: the hint must not phrase any "split into
// multiple elements" recommendation as a workaround. Look for the
// previous PR's exact phrasing variants.
for _, banned := range []string{
"split the content across multiple",
"split into multiple text elements",
"renders them as one contiguous comment",
} {
if strings.Contains(hint, banned) {
t.Errorf("hint must not contain the discredited %q advice, got: %q", banned, hint)
}
}
// And it should reference the actual number so callers know the
// budget without having to read the source.
if !strings.Contains(hint, "10000") {
t.Errorf("hint should name the 10000-rune budget, got: %q", hint)
}
}
func TestParseCommentReplyElementsInvalid(t *testing.T) {
t.Parallel()

View File

@@ -33,6 +33,7 @@ var DriveExport = common.Shortcut{
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable"}},
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base"}},
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
{Name: "file-name", Desc: "preferred output filename (optional)"},
{Name: "output-dir", Default: ".", Desc: "local output directory (default: current directory)"},
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
},
@@ -54,14 +55,19 @@ var DriveExport = common.Shortcut{
// Markdown export is a special case: docx markdown comes from docs content
// directly instead of the Drive export task API.
if spec.FileExtension == "markdown" {
return common.NewDryRunAPI().
dr := common.NewDryRunAPI().
Desc("2-step orchestration: fetch docx markdown -> write local file").
GET("/open-apis/docs/v1/content").
Params(map[string]interface{}{
"doc_token": spec.Token,
"doc_type": "docx",
"content_type": "markdown",
})
}).
Set("output_dir", runtime.Str("output-dir"))
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
}
body := map[string]interface{}{
@@ -73,10 +79,15 @@ var DriveExport = common.Shortcut{
body["sub_id"] = spec.SubID
}
return common.NewDryRunAPI().
dr := common.NewDryRunAPI().
Desc("3-step orchestration: create export task -> limited polling -> download file").
POST("/open-apis/drive/v1/export_tasks").
Body(body)
Body(body).
Set("output_dir", runtime.Str("output-dir"))
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := driveExportSpec{
@@ -86,6 +97,7 @@ var DriveExport = common.Shortcut{
SubID: runtime.Str("sub-id"),
}
outputDir := runtime.Str("output-dir")
preferredFileName := strings.TrimSpace(runtime.Str("file-name"))
overwrite := runtime.Bool("overwrite")
// Markdown export bypasses the async export task and writes the fetched
@@ -106,14 +118,18 @@ var DriveExport = common.Shortcut{
return err
}
// Prefer the remote title for the exported file name, but still fall
// back to the token if metadata is empty.
title, err := fetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
title = spec.Token
fileName := preferredFileName
if fileName == "" {
// Prefer the remote title for the exported file name, but still fall
// back to the token if metadata is empty.
title, err := fetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
title = spec.Token
}
fileName = title
}
fileName := ensureExportFileExtension(sanitizeExportFileName(title, spec.Token), spec.FileExtension)
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(common.GetString(data, "content")), overwrite)
if err != nil {
return err
@@ -166,7 +182,11 @@ var DriveExport = common.Shortcut{
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
fileName := ensureExportFileExtension(sanitizeExportFileName(status.FileName, spec.Token), spec.FileExtension)
fileName := preferredFileName
if fileName == "" {
fileName = status.FileName
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
if err != nil {
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
@@ -227,7 +247,7 @@ var DriveExport = common.Shortcut{
}
// Return the last observed status so callers can resume from a known task
// state instead of losing all progress information on timeout.
runtime.Out(map[string]interface{}{
result := map[string]interface{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
@@ -238,7 +258,11 @@ var DriveExport = common.Shortcut{
"job_status_label": jobStatusLabel,
"timed_out": true,
"next_command": nextCommand,
}, nil)
}
if preferredFileName != "" {
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
}
runtime.Out(result, nil)
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
return nil
},

View File

@@ -130,6 +130,155 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
}
}
func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs/v1/content",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"content": "# custom\n",
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "markdown",
"--file-name", "custom-notes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile(filepath.Join(tmpDir, "custom-notes.md"))
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != "# custom\n" {
t.Fatalf("markdown content = %q", string(data))
}
if !strings.Contains(stdout.String(), `"file_name": "custom-notes.md"`) {
t.Fatalf("stdout missing provided file name: %s", stdout.String())
}
}
func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
tests := []struct {
name string
wantURL string
wantFileName string
args []string
}{
{
name: "markdown",
wantURL: "/open-apis/docs/v1/content",
wantFileName: `"file_name": "notes.md"`,
args: []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "markdown",
"--file-name", "notes",
"--output-dir", "./exports",
"--dry-run",
"--as", "bot",
},
},
{
name: "async export",
wantURL: "/open-apis/drive/v1/export_tasks",
wantFileName: `"file_name": "report.pdf"`,
args: []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "pdf",
"--file-name", "report",
"--output-dir", "./exports",
"--dry-run",
"--as", "bot",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveExport, tt.args, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, tt.wantURL) {
t.Fatalf("stdout missing URL %q: %s", tt.wantURL, out)
}
if !strings.Contains(out, tt.wantFileName) {
t.Fatalf("stdout missing file_name metadata %q: %s", tt.wantFileName, out)
}
if !strings.Contains(out, `"output_dir": "./exports"`) {
t.Fatalf("stdout missing output_dir metadata: %s", out)
}
})
}
}
func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs/v1/content",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"content": "# fallback\n",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Status: 500,
Body: map[string]interface{}{
"code": 999,
"msg": "metadata unavailable",
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "markdown",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile(filepath.Join(tmpDir, "docx123.md"))
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != "# fallback\n" {
t.Fatalf("markdown content = %q", string(data))
}
if !strings.Contains(stdout.String(), `"file_name": "docx123.md"`) {
t.Fatalf("stdout missing fallback file name: %s", stdout.String())
}
}
func TestDriveExportAsyncSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
@@ -200,6 +349,77 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
}
}
func TestDriveExportAsyncUsesProvidedFileName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"ticket": "tk_custom"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_custom",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": map[string]interface{}{
"job_status": 0,
"file_token": "box_custom",
"file_name": "server-name",
"file_extension": "pdf",
"type": "docx",
"file_size": 3,
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/file/box_custom/download",
Status: 200,
RawBody: []byte("pdf"),
Headers: http.Header{
"Content-Type": []string{"application/pdf"},
"Content-Disposition": []string{`attachment; filename="server-name.pdf"`},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
driveExportPollAttempts, driveExportPollInterval = 1, 0
t.Cleanup(func() {
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "pdf",
"--file-name", "custom-report",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile(filepath.Join(tmpDir, "custom-report.pdf"))
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != "pdf" {
t.Fatalf("downloaded content = %q", string(data))
}
if !strings.Contains(stdout.String(), `"file_name": "custom-report.pdf"`) {
t.Fatalf("stdout missing provided file name: %s", stdout.String())
}
}
func TestDriveExportBitableBaseAsyncSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
createStub := &httpmock.Stub{
@@ -425,6 +645,51 @@ func TestDriveExportTimeoutReturnsFollowUpCommand(t *testing.T) {
}
}
func TestDriveExportTimeoutPreservesProvidedFileName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"ticket": "tk_name"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_name",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": map[string]interface{}{
"job_status": 2,
},
},
},
})
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
driveExportPollAttempts, driveExportPollInterval = 1, 0
t.Cleanup(func() {
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "pdf",
"--file-name", "quarterly-report",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), `"file_name": "quarterly-report.pdf"`) {
t.Fatalf("stdout missing preserved file name: %s", stdout.String())
}
}
func TestDriveExportPollErrorsReturnLastErrorWithRecoveryHint(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{

View File

@@ -0,0 +1,337 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
drivePullIfExistsOverwrite = "overwrite"
drivePullIfExistsSkip = "skip"
)
type drivePullItem struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
Action string `json:"action"`
Error string `json:"error,omitempty"`
}
// DrivePull performs a one-way file-level mirror from a Drive folder onto
// a local directory: recursively lists --folder-token, downloads each
// type=file entry under --local-dir, and optionally deletes local files
// absent from Drive (--delete-local --yes).
//
// Only Drive entries with type=file participate; online docs (docx, sheet,
// bitable, mindnote, slides) and shortcuts are skipped because there is no
// equivalent local binary to write back. Directories are reproduced when
// remote folders contain downloadable files, but local directories that
// become orphaned after a remote folder is removed are NOT pruned —
// --delete-local only unlinks regular files.
var DrivePull = common.Shortcut{
Service: "drive",
Command: "+pull",
Description: "One-way file-level mirror of a Drive folder onto a local directory (Drive → local)",
Risk: "write",
Scopes: []string{"drive:drive.metadata:readonly", "drive:file:download"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
{Name: "folder-token", Desc: "source Drive folder token", Required: true},
{Name: "if-exists", Desc: "policy when a local file already exists", Default: drivePullIfExistsOverwrite, Enum: []string{drivePullIfExistsOverwrite, drivePullIfExistsSkip}},
{Name: "delete-local", Type: "bool", Desc: "delete local regular files absent from Drive (file-level mirror; empty directories are NOT pruned); requires --yes"},
{Name: "yes", Type: "bool", Desc: "confirm --delete-local before deleting local files"},
},
Tips: []string{
"Only entries with type=file are downloaded; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
"Subfolders recurse and are reproduced as local directories under --local-dir; missing parents are created automatically.",
"--delete-local requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
if localDir == "" {
return common.FlagErrorf("--local-dir is required")
}
if folderToken == "" {
return common.FlagErrorf("--folder-token is required")
}
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
}
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
return output.ErrValidation("%s", err)
}
info, err := runtime.FileIO().Stat(localDir)
if err != nil {
return common.WrapInputStatError(err)
}
if !info.IsDir() {
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
}
if runtime.Bool("delete-local") && !runtime.Bool("yes") {
return output.ErrValidation("--delete-local requires --yes (high-risk: deletes local files absent from Drive)")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("Recursively list --folder-token, download each type=file entry into --local-dir, and (when --delete-local --yes is set) remove local files absent from Drive.").
GET("/open-apis/drive/v1/files").
Set("folder_token", runtime.Str("folder-token"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
ifExists := strings.TrimSpace(runtime.Str("if-exists"))
if ifExists == "" {
ifExists = drivePullIfExistsOverwrite
}
deleteLocal := runtime.Bool("delete-local")
// Resolve --local-dir to its canonical absolute path before we
// touch the filesystem. SafeInputPath fully evaluates symlinks
// across the entire path; this matters because filepath.Clean
// alone shrinks "link/.." to "." while the kernel resolves it
// through the symlink target's parent — meaning a raw walk on
// the user-supplied string can land outside cwd. Walking the
// canonical root sidesteps that, and using cwd canonical lets
// us emit cwd-relative download targets that FileIO.Save's
// SafeOutputPath check still accepts. The risk is much higher
// here than in +status because --delete-local would otherwise
// remove the wrong files outside cwd.
safeRoot, err := validate.SafeInputPath(localDir)
if err != nil {
return output.ErrValidation("--local-dir: %s", err)
}
cwdCanonical, err := validate.SafeInputPath(".")
if err != nil {
return output.ErrValidation("could not resolve cwd: %s", err)
}
// rootRelToCwd is the localDir form FileIO.Save accepts (it
// rejects absolute paths). For cwd itself it becomes ".", which
// joins cleanly with the rel_paths returned by the lister.
rootRelToCwd, err := filepath.Rel(cwdCanonical, safeRoot)
if err != nil {
return output.ErrValidation("--local-dir resolves outside cwd: %s", err)
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
if err != nil {
return err
}
// Two views over the same listing:
// - remoteFiles drives the download/skip loop (only type=file
// has hashable bytes the local mirror can write back).
// - remotePaths is the --delete-local guard: it carries every
// rel_path Drive owns regardless of type, so a local file
// shadowed by a remote folder / online doc / shortcut is NOT
// treated as orphaned.
remoteFiles := make(map[string]string, len(entries))
remotePaths := make(map[string]struct{}, len(entries))
for rel, entry := range entries {
remotePaths[rel] = struct{}{}
if entry.Type == driveTypeFile {
remoteFiles[rel] = entry.FileToken
}
}
var downloaded, skipped, failed, deletedLocal int
downloadFailed := 0
items := make([]drivePullItem, 0)
// Deterministic iteration order for output stability.
downloadablePaths := make([]string, 0, len(remoteFiles))
for p := range remoteFiles {
downloadablePaths = append(downloadablePaths, p)
}
sort.Strings(downloadablePaths)
for _, rel := range downloadablePaths {
token := remoteFiles[rel]
target := filepath.Join(rootRelToCwd, rel)
if info, statErr := runtime.FileIO().Stat(target); statErr == nil {
// Mirror conflict: remote is a regular file but local
// has a directory at the same rel_path. Neither
// "skipped" nor "downloaded" describes reality —
// SafeOutputPath would refuse to write a file over a
// directory, and pretending the directory is a
// pre-existing file under --if-exists=skip silently
// hides the conflict. Surface as a failure.
if info.IsDir() {
items = append(items, drivePullItem{
RelPath: rel,
FileToken: token,
Action: "failed",
Error: fmt.Sprintf("local path is a directory, remote is a regular file: %s", target),
})
failed++
downloadFailed++
continue
}
if ifExists == drivePullIfExistsSkip {
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "skipped"})
skipped++
continue
}
}
if err := drivePullDownload(ctx, runtime, token, target); err != nil {
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "failed", Error: err.Error()})
failed++
downloadFailed++
continue
}
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "downloaded"})
downloaded++
}
// Gate --delete-local on a clean download pass. With download
// failures still in items[], proceeding to the delete walk would
// leave the mirror in a half-synced state where some files Drive
// owns are missing locally AND some local-only files have been
// removed. Surface the failure first; the operator can re-run
// after fixing whatever caused the download error.
if deleteLocal && downloadFailed == 0 {
// Walk the canonical absolute root, build the list of
// rel_paths, then delete via the absolute path. Both
// values come from the validated safeRoot, so kernel
// path resolution cannot redirect the delete to a file
// outside the canonical subtree.
localAbsPaths, err := drivePullWalkLocal(safeRoot)
if err != nil {
return err
}
for _, absPath := range localAbsPaths {
rel, relErr := filepath.Rel(safeRoot, absPath)
if relErr != nil {
items = append(items, drivePullItem{RelPath: absPath, Action: "delete_failed", Error: relErr.Error()})
failed++
continue
}
rel = filepath.ToSlash(rel)
// Consult remotePaths (every Drive entry, regardless of
// type) rather than remoteFiles (downloadable subset
// only). Otherwise an online doc / shortcut at e.g.
// "notes.docx" would leave a same-named local file
// looking orphaned and get unlinked even though Drive
// still knows about that path.
if _, ok := remotePaths[rel]; ok {
continue
}
// FileIO has no Remove(); the absolute path comes from
// walking safeRoot, which validate.SafeInputPath has
// already bounded inside cwd, so a bare os.Remove is
// acceptable here. Shortcuts cannot import internal/vfs
// directly (depguard rule shortcuts-no-vfs).
if err := os.Remove(absPath); err != nil { //nolint:forbidigo // see comment above
items = append(items, drivePullItem{RelPath: rel, Action: "delete_failed", Error: err.Error()})
failed++
continue
}
items = append(items, drivePullItem{RelPath: rel, Action: "deleted_local"})
deletedLocal++
}
}
payload := map[string]interface{}{
"summary": map[string]interface{}{
"downloaded": downloaded,
"skipped": skipped,
"failed": failed,
"deleted_local": deletedLocal,
},
"items": items,
}
// Item-level failures (download error, dir/file conflict, delete
// error) must surface as a non-zero exit so AI / script callers
// don't have to reach into summary.failed to detect a partial
// sync. The same structured payload rides along in error.detail
// so forensics aren't lost. When --delete-local was skipped
// because of an earlier download failure, callers see
// deleted_local=0 plus the download failure that aborted it,
// which is what makes the partial state self-explanatory.
if failed > 0 {
msg := fmt.Sprintf("%d item(s) failed during +pull; partial sync — re-run after resolving the failures", failed)
if deleteLocal && downloadFailed > 0 {
msg += " (--delete-local was skipped because the download pass had failures)"
}
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "partial_failure",
Message: msg,
Detail: payload,
},
}
}
runtime.Out(payload, nil)
return nil
},
}
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target string) error {
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: "GET",
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return output.ErrNetwork("download %s: %s", common.MaskToken(fileToken), err)
}
defer resp.Body.Close()
if _, err := runtime.FileIO().Save(target, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: resp.ContentLength,
}, resp.Body); err != nil {
return common.WrapSaveErrorByCategory(err, "io")
}
return nil
}
// drivePullWalkLocal walks the canonical absolute root and returns the
// absolute paths of every regular file underneath it. The caller deletes
// some of these paths, so it is critical that they are produced by
// walking a canonical root (no symlinks in the path) — otherwise OS path
// resolution could redirect a delete to a file outside cwd. Same threat
// model as drive_status.go.
func drivePullWalkLocal(root string) ([]string, error) {
var paths []string
// FileIO has no walker today; shortcuts cannot import internal/vfs
// (depguard rule shortcuts-no-vfs). The root passed in is the
// canonical absolute path returned by validate.SafeInputPath, so
// WalkDir's default "do not follow child symlinks" policy keeps the
// traversal inside the validated subtree.
err := filepath.WalkDir(root, func(absPath string, d fs.DirEntry, walkErr error) error { //nolint:forbidigo // see comment above
if walkErr != nil {
return walkErr
}
if d.IsDir() || !d.Type().IsRegular() {
return nil
}
paths = append(paths, absPath)
return nil
})
if err != nil {
return nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
}
return paths, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,717 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"path"
"path/filepath"
"sort"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
drivePushIfExistsOverwrite = "overwrite"
drivePushIfExistsSkip = "skip"
)
type drivePushItem struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
Action string `json:"action"`
Version string `json:"version,omitempty"`
SizeBytes int64 `json:"size_bytes,omitempty"`
Error string `json:"error,omitempty"`
}
// DrivePush is a one-way, file-level mirror from a local directory onto a
// Drive folder: walks --local-dir, recursively lists --folder-token, and for
// each rel_path uploads (or overwrites) the corresponding Drive file. With
// --delete-remote --yes, any type=file entry on Drive that has no local
// counterpart is removed; online docs (docx/sheet/bitable/...), shortcuts
// and folders are never deleted, so this is "file-level" mirror — the
// command does not attempt to remove remote-only directories or close gaps
// in directory structure that exists on Drive but not locally.
//
// Only Drive entries with type=file participate in upload/overwrite/delete;
// online documents have no equivalent local binary. Sub-folders are created
// on Drive on demand via /open-apis/drive/v1/files/create_folder so the
// remote tree mirrors the local tree.
//
// The overwrite path passes the existing file_token as a form field on
// /open-apis/drive/v1/files/upload_all, mirroring the markdown +overwrite
// contract in shortcuts/markdown. The Drive backend exposing that field is
// being rolled out; until rollout completes, --if-exists defaults to "skip"
// so the safe path (do not touch existing remote files) is the default and
// callers must opt into "overwrite" explicitly.
var DrivePush = common.Shortcut{
Service: "drive",
Command: "+push",
Description: "File-level mirror of a local directory onto a Drive folder (local → Drive; remote-only directories are not removed)",
Risk: "write",
// Narrowed scopes follow the precedent set by drive +status / +pull:
// drive:drive is policy-disabled in some tenants, so this shortcut sticks
// to the smallest set the *core* path needs. space:folder:create is
// always declared because mirroring a non-flat tree calls
// /open-apis/drive/v1/files/create_folder on demand and we want the
// framework's pre-flight scope check to catch missing grants before any
// upload — otherwise a partial push could land top-level files and then
// trip on a missing folder grant for a sub-tree, leaving a half-synced
// state.
//
// space:document:delete is intentionally NOT in the default set even
// though --delete-remote needs it. The framework pre-check (runner.go
// checkShortcutScopes) runs unconditionally before Validate / dry-run,
// so declaring it here would make every plain push (and every
// --dry-run) fail for callers that only granted upload scopes.
//
// Instead, Validate runs a *conditional* pre-flight via
// runtime.EnsureScopes when both --delete-remote and --yes are on, so
// the missing grant fails the run upfront — before any upload —
// rather than landing files first and tripping on missing_scope when
// the cleanup pass tries to delete. That avoids the half-synced state
// (files uploaded, orphans never cleaned up) that the unconditional
// pre-check would otherwise prevent only by also blocking plain
// pushes.
Scopes: []string{"drive:drive.metadata:readonly", "drive:file:upload", "space:folder:create"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
{Name: "folder-token", Desc: "target Drive folder token", Required: true},
{Name: "if-exists", Desc: "policy when a Drive file already exists at the same rel_path (default: skip — safe; opt into overwrite explicitly while the backend version field is rolling out)", Default: drivePushIfExistsSkip, Enum: []string{drivePushIfExistsOverwrite, drivePushIfExistsSkip}},
{Name: "delete-remote", Type: "bool", Desc: "delete Drive files absent locally (file-level mirror; remote-only directories are not removed); requires --yes"},
{Name: "yes", Type: "bool", Desc: "confirm --delete-remote before deleting Drive files"},
},
Tips: []string{
"This is a file-level mirror: only type=file entries are uploaded, overwritten or deleted. Online docs (docx, sheet, bitable, mindnote, slides), shortcuts, and remote-only directories are never touched.",
"Local directory structure (including empty directories) is mirrored to Drive via create_folder; existing remote folders are reused.",
"Default --if-exists=skip is the safe choice while the upload_all overwrite-version field is rolling out. Pass --if-exists=overwrite to replace remote bytes; on tenants without the field it surfaces a structured api_error and the run exits non-zero.",
"--delete-remote requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
"--delete-remote --yes also requires the space:document:delete scope. Validate runs a dynamic pre-flight check when the flag is on, so a missing grant fails the run before any upload — preventing a half-synced state where files were uploaded but the cleanup pass cannot delete.",
"Item-level failures (upload, overwrite, folder, delete) bump summary.failed and the run exits non-zero. If any upload or folder step fails, the --delete-remote phase is skipped entirely so a partial upload never triggers remote deletion.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
if localDir == "" {
return common.FlagErrorf("--local-dir is required")
}
if folderToken == "" {
return common.FlagErrorf("--folder-token is required")
}
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
}
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
return output.ErrValidation("%s", err)
}
info, err := runtime.FileIO().Stat(localDir)
if err != nil {
return common.WrapInputStatError(err)
}
if !info.IsDir() {
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
}
if runtime.Bool("delete-remote") && !runtime.Bool("yes") {
return output.ErrValidation("--delete-remote requires --yes (high-risk: deletes Drive files absent locally)")
}
// Conditional scope pre-check: when --delete-remote --yes is set, the
// run will issue DELETE /open-apis/drive/v1/files/<token> after the
// upload phase. The default Scopes list intentionally omits
// space:document:delete so plain pushes don't get blocked on a grant
// they don't need (see the Scopes block above), but at this point we
// know the run will need it — pre-flight here so a missing grant
// fails before any upload, instead of after, which would otherwise
// leave the tenant in a half-synced state (files uploaded, remote
// orphans never cleaned up). EnsureScopes is a silent no-op when no
// token / scope metadata is available, so test envs and tenants
// where the resolver doesn't expose scopes still proceed and rely on
// the API-level missing_scope error.
if runtime.Bool("delete-remote") && runtime.Bool("yes") {
if err := runtime.EnsureScopes([]string{"space:document:delete"}); err != nil {
return err
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("Walk --local-dir, recursively list --folder-token, then upload new files, overwrite (when --if-exists=overwrite) or skip existing, and (when --delete-remote --yes is set) delete Drive files absent locally.").
GET("/open-apis/drive/v1/files").
Set("folder_token", runtime.Str("folder-token"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
ifExists := strings.TrimSpace(runtime.Str("if-exists"))
if ifExists == "" {
// Default to the safe "skip" policy: do not touch already-present
// remote files. Callers must pass --if-exists=overwrite to opt
// into the overwrite-with-version path that depends on the
// rolling-out upload_all `file_token`/`version` protocol field.
ifExists = drivePushIfExistsSkip
}
deleteRemote := runtime.Bool("delete-remote")
// Resolve --local-dir to its canonical absolute path before walking.
// SafeInputPath fully evaluates symlinks across the entire path,
// which closes the kernel-level escape route that filepath.Clean
// alone misses (e.g. "link/.." string-cleans to "." but the kernel
// resolves through link's target's parent). Walking the canonical
// root sidesteps that, and the matching cwd canonical lets each
// absolute walk hit be converted to a cwd-relative path that
// FileIO.Open's SafeInputPath check still accepts.
safeRoot, err := validate.SafeInputPath(localDir)
if err != nil {
return output.ErrValidation("--local-dir: %s", err)
}
cwdCanonical, err := validate.SafeInputPath(".")
if err != nil {
return output.ErrValidation("could not resolve cwd: %s", err)
}
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
localFiles, localDirs, err := drivePushWalkLocal(safeRoot, cwdCanonical)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
if err != nil {
return err
}
// Two views over the same listing:
// - remoteFiles drives upload / overwrite / orphan-delete
// decisions (only type=file entries are upload candidates;
// online docs / shortcuts are intentionally never overwritten
// or deleted by --delete-remote).
// - remoteFolders is the create_folder cache: lets the upload
// path skip create_folder when an intermediate folder already
// exists, and keeps directory recreation idempotent across
// reruns.
remoteFiles := make(map[string]driveRemoteEntry, len(entries))
remoteFolders := make(map[string]driveRemoteEntry, len(entries))
for rel, entry := range entries {
switch entry.Type {
case driveTypeFile:
remoteFiles[rel] = entry
case driveTypeFolder:
remoteFolders[rel] = entry
}
}
var uploaded, skipped, failed, deletedRemote int
items := make([]drivePushItem, 0)
// uploadFailed tracks whether any folder-creation, upload or
// overwrite step failed. The --delete-remote phase only runs when
// this stays false: a partial upload that then proceeds to delete
// remote orphans would leave the tenant half-synced (files missing
// locally and now on Drive too), which is the worst-of-both-worlds
// outcome the review flagged.
uploadFailed := false
// folderCache holds rel_path → folder_token. Seeded from the remote
// listing (so we don't recreate folders that already exist) and
// extended in-place as drivePushEnsureFolder mints new ones.
folderCache := map[string]string{"": folderToken}
for relDir, entry := range remoteFolders {
folderCache[relDir] = entry.FileToken
}
// Mirror local directory structure first, so empty directories
// are not silently dropped. Pre-creating also frees the upload
// loop from doing on-demand mkdir for every file's parent chain
// (the cache makes both paths idempotent, but pre-creation keeps
// items[] in a tidy "folders, then files" shape).
for _, relDir := range localDirs {
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
// Folder already exists on Drive — nothing to do; staying
// silent (no items[] entry) avoids noise on reruns.
continue
}
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
items = append(items, drivePushItem{RelPath: relDir, Action: "failed", Error: ensureErr.Error()})
failed++
uploadFailed = true
continue
}
items = append(items, drivePushItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created"})
}
// Upload local-only and overwrite/skip already-present files in a
// stable order so output is reproducible.
localPaths := make([]string, 0, len(localFiles))
for p := range localFiles {
localPaths = append(localPaths, p)
}
sort.Strings(localPaths)
for _, rel := range localPaths {
localFile := localFiles[rel]
if entry, ok := remoteFiles[rel]; ok {
if ifExists == drivePushIfExistsSkip {
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "skipped", SizeBytes: localFile.Size})
skipped++
continue
}
token, version, upErr := drivePushUploadFile(ctx, runtime, localFile, entry.FileToken, folderToken)
if upErr != nil {
// Token contract on overwrite failure: an in-place
// overwrite preserves the file's token, so the
// existing entry.FileToken is normally still the
// authoritative pointer to the (possibly already
// rewritten) Drive file. But the protocol does not
// strictly forbid the backend from minting a new
// token, and a partial-success response can return a
// non-empty file_token alongside an error (the
// missing-version case below is the immediate
// concern: bytes hit the disk, version field
// missing, so we surface a structured error). Prefer
// the freshly returned token when one was produced,
// fall back to entry.FileToken otherwise — that way
// callers still have a usable handle to whatever
// state Drive ended up in.
failedToken := token
if failedToken == "" {
failedToken = entry.FileToken
}
items = append(items, drivePushItem{RelPath: rel, FileToken: failedToken, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()})
failed++
uploadFailed = true
continue
}
items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "overwritten", Version: version, SizeBytes: localFile.Size})
uploaded++
continue
}
parentRel := drivePushParentRel(rel)
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
if ensureErr != nil {
items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: ensureErr.Error()})
failed++
uploadFailed = true
continue
}
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
if upErr != nil {
items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()})
failed++
uploadFailed = true
continue
}
items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "uploaded", SizeBytes: localFile.Size})
uploaded++
}
// Skip the delete phase entirely on any upstream failure. The orphan
// loop deletes by remote token and is unrecoverable; running it
// after a failed upload risks deleting a file the partial upload
// would have replaced on a successful re-run, leaving the tenant
// in a worse state than where we started. Surface the skipped
// delete as a hint in stderr so operators know the cleanup pass
// is pending and can re-run after fixing the upload.
if deleteRemote && uploadFailed {
fmt.Fprintf(runtime.IO().ErrOut,
"Skipping --delete-remote: %d earlier failure(s) — re-run after resolving them.\n",
failed)
}
if deleteRemote && !uploadFailed {
// Stable iteration order so failures (and tests) are deterministic.
remoteRelPaths := make([]string, 0, len(remoteFiles))
for p := range remoteFiles {
remoteRelPaths = append(remoteRelPaths, p)
}
sort.Strings(remoteRelPaths)
for _, rel := range remoteRelPaths {
if _, ok := localFiles[rel]; ok {
continue
}
entry := remoteFiles[rel]
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "delete_failed", Error: err.Error()})
failed++
continue
}
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "deleted_remote"})
deletedRemote++
}
}
runtime.Out(map[string]interface{}{
"summary": map[string]interface{}{
"uploaded": uploaded,
"skipped": skipped,
"failed": failed,
"deleted_remote": deletedRemote,
},
"items": items,
}, nil)
// Bump the exit code on any item-level failure (upload, overwrite,
// folder, or delete) so callers / scripts / agents can react. The
// summary + items[] envelope was just written to stdout via Out(),
// so ErrBare here only affects the exit code — the structured
// per-item context is still in the stdout JSON.
if failed > 0 {
return output.ErrBare(output.ExitAPI)
}
return nil
},
}
// drivePushLocalFile records what we need to upload a local regular file:
// a rel_path used for output and Drive layout, the cwd-relative path that
// FileIO.Open accepts, the file size (drives single/multipart selection),
// and the basename used as Drive's file_name.
type drivePushLocalFile struct {
RelPath string
OpenPath string
FileName string
Size int64
}
// drivePushWalkLocal walks the canonical absolute root produced by
// SafeInputPath. Same threat model as +pull/+status: the validated root
// is not a symlink itself, and WalkDir's default policy (do not follow
// child symlinks) keeps the traversal inside that canonical subtree, so
// the OpenPath we hand to FileIO.Open stays inside cwd.
//
// Returns two views:
// - files: rel_path → file metadata; drives the upload/skip/overwrite loop.
// - dirs: every non-root directory rel_path encountered. Used to mirror
// empty directories (which would otherwise be silently dropped because
// the upload loop only iterates files); non-empty directories appear
// here too but are harmless because drivePushEnsureFolder is cached.
func drivePushWalkLocal(root, cwdCanonical string) (map[string]drivePushLocalFile, []string, error) {
files := make(map[string]drivePushLocalFile)
dirsSet := make(map[string]struct{})
// FileIO has no walker today and shortcuts can't import internal/vfs
// (depguard rule shortcuts-no-vfs). The walk root is the canonical
// absolute path returned by validate.SafeInputPath, so it is no
// longer a symlink itself, and WalkDir's default child-symlink
// policy keeps the traversal inside the validated subtree.
err := filepath.WalkDir(root, func(absPath string, d fs.DirEntry, walkErr error) error { //nolint:forbidigo // see comment above
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(root, absPath)
if err != nil {
return err
}
relSlash := filepath.ToSlash(rel)
if d.IsDir() {
// Skip the root itself ("."): that is --folder-token, already
// the parent we mirror into, not a sub-folder we need to
// create.
if relSlash != "." {
dirsSet[relSlash] = struct{}{}
}
return nil
}
if !d.Type().IsRegular() {
return nil
}
relToCwd, err := filepath.Rel(cwdCanonical, absPath)
if err != nil {
return err
}
info, err := d.Info()
if err != nil {
return err
}
files[relSlash] = drivePushLocalFile{
RelPath: relSlash,
OpenPath: relToCwd,
FileName: filepath.Base(rel),
Size: info.Size(),
}
return nil
})
if err != nil {
return nil, nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
}
dirs := make([]string, 0, len(dirsSet))
for d := range dirsSet {
dirs = append(dirs, d)
}
// Shallow-first ordering ensures parents are created before children;
// drivePushEnsureFolder also handles parent recursion on its own, but
// emitting items[] in shallow-first order matches what users expect.
sort.Slice(dirs, func(i, j int) bool {
di, dj := strings.Count(dirs[i], "/"), strings.Count(dirs[j], "/")
if di != dj {
return di < dj
}
return dirs[i] < dirs[j]
})
return files, dirs, nil
}
// drivePushEnsureFolder ensures a folder chain (rel_dir relative to the root
// folder identified by rootFolderToken) exists on Drive, creating any
// missing segments via /open-apis/drive/v1/files/create_folder. Returns the
// token of the deepest folder, suitable as parent_node for the upload.
//
// folderCache is shared with the caller so each segment is only created
// once per push, and so subsequent uploads under the same sub-tree reuse
// the freshly minted folder token without an extra round trip.
func drivePushEnsureFolder(ctx context.Context, runtime *common.RuntimeContext, rootFolderToken, relDir string, folderCache map[string]string) (string, error) {
if token, ok := folderCache[relDir]; ok {
return token, nil
}
parentRel, name := drivePushSplitRel(relDir)
parentToken, err := drivePushEnsureFolder(ctx, runtime, rootFolderToken, parentRel, folderCache)
if err != nil {
return "", err
}
data, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/files/create_folder",
nil,
map[string]interface{}{
"name": name,
"folder_token": parentToken,
},
)
if err != nil {
return "", err
}
token := common.GetString(data, "token")
if token == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "create_folder for %q returned no folder token", relDir)
}
folderCache[relDir] = token
return token, nil
}
// drivePushUploadFile uploads (or overwrites) a single local file. When
// existingToken is non-empty, the request adds the file_token form field to
// trigger overwrite-with-version semantics on the backend; the response is
// expected to carry a non-empty `version`, which is propagated to the
// caller for the items[].version field. When existingToken is empty, this
// is a fresh upload under parentToken.
//
// Files larger than common.MaxDriveMediaUploadSinglePartSize fall back to
// the three-step prepare/part/finish flow, which mirrors drive +upload's
// existing multipart logic.
func drivePushUploadFile(ctx context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) {
if file.Size > common.MaxDriveMediaUploadSinglePartSize {
token, err := drivePushUploadMultipart(ctx, runtime, file, existingToken, parentToken)
// Multipart finish does not return version on the existing
// /open-apis/drive/v1/files/upload_finish contract; surface an
// empty version in that case rather than fabricating one. The
// markdown +overwrite path has the same gap and is tracked for a
// follow-up once the multipart endpoint exposes the field.
return token, "", err
}
return drivePushUploadAll(ctx, runtime, file, existingToken, parentToken)
}
func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) {
f, err := runtime.FileIO().Open(file.OpenPath)
if err != nil {
return "", "", common.WrapInputStatError(err)
}
defer f.Close()
fd := larkcore.NewFormdata()
fd.AddField("file_name", file.FileName)
fd.AddField("parent_type", driveUploadParentTypeExplorer)
fd.AddField("parent_node", parentToken)
fd.AddField("size", fmt.Sprintf("%d", file.Size))
if existingToken != "" {
// Overwrite mode: the backend interprets a non-empty file_token on
// upload_all as "replace this file's content and bump its version",
// matching the markdown +overwrite contract.
fd.AddField("file_token", existingToken)
}
fd.AddFile("file", f)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/files/upload_all",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return "", "", err
}
return "", "", output.ErrNetwork("upload failed: %v", err)
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return "", "", output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
}
// Extract the token before the larkCode check: the backend can produce
// a partial-success response (code != 0 alongside a non-empty
// data.file_token) where bytes have already landed under that token.
// Returning "" here would force the caller to fall back to
// entry.FileToken and silently lose the token Drive actually used,
// defeating the overwrite-error token-stability handling in Execute.
data, _ := result["data"].(map[string]interface{})
token := common.GetString(data, "file_token")
if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 {
msg, _ := result["msg"].(string)
return token, "", output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"])
}
if token == "" {
return "", "", output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
}
version := common.GetString(data, "version")
if version == "" {
// Some backends return the version under data_version; accept either
// per the markdown +overwrite contract.
version = common.GetString(data, "data_version")
}
if existingToken != "" && version == "" {
// The protocol guarantees a non-empty version on overwrite. If the
// deployed backend hasn't shipped the field yet we surface the gap
// rather than report a phantom success — callers can downgrade to
// --if-exists=skip in the meantime.
return token, "", output.Errorf(output.ExitAPI, "api_error", "overwrite for %q succeeded but no version was returned by upload_all", file.RelPath)
}
return token, version, nil
}
func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, error) {
prepareBody := map[string]interface{}{
"file_name": file.FileName,
"parent_type": driveUploadParentTypeExplorer,
"parent_node": parentToken,
"size": file.Size,
}
if existingToken != "" {
prepareBody["file_token"] = existingToken
}
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
if err != nil {
return "", err
}
uploadID := common.GetString(prepareResult, "upload_id")
blockSize := int64(common.GetFloat(prepareResult, "block_size"))
blockNum := int(common.GetFloat(prepareResult, "block_num"))
if uploadID == "" || blockSize <= 0 || blockNum <= 0 {
return "", output.Errorf(output.ExitAPI, "api_error",
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
uploadID, blockSize, blockNum)
}
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload: %s, block size %s, %d block(s)\n",
common.FormatSize(file.Size), common.FormatSize(blockSize), blockNum)
// Open the local file ONCE for the whole multipart loop. fileio.File
// implements io.ReaderAt, so each block is a fresh
// io.NewSectionReader over a shared fd — no need to reopen N times
// (which is what drive +upload's existing multipart helper does and
// what the original drive_push copy inherited; that pattern wastes
// one Open + Close + path-validation per block).
partFile, err := runtime.FileIO().Open(file.OpenPath)
if err != nil {
return "", common.WrapInputStatError(err)
}
defer partFile.Close()
for seq := 0; seq < blockNum; seq++ {
offset := int64(seq) * blockSize
partSize := blockSize
if remaining := file.Size - offset; partSize > remaining {
partSize = remaining
}
fd := larkcore.NewFormdata()
fd.AddField("upload_id", uploadID)
fd.AddField("seq", fmt.Sprintf("%d", seq))
fd.AddField("size", fmt.Sprintf("%d", partSize))
fd.AddFile("file", io.NewSectionReader(partFile, offset, partSize))
apiResp, doErr := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/files/upload_part",
Body: fd,
}, larkcore.WithFileUpload())
if doErr != nil {
var exitErr *output.ExitError
if errors.As(doErr, &exitErr) {
return "", doErr
}
return "", output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, doErr)
}
var partResult map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &partResult); err != nil {
return "", output.Errorf(output.ExitAPI, "api_error", "upload part %d/%d: invalid response JSON: %v", seq+1, blockNum, err)
}
if larkCode := int(common.GetFloat(partResult, "code")); larkCode != 0 {
msg, _ := partResult["msg"].(string)
return "", output.ErrAPI(larkCode, fmt.Sprintf("upload part %d/%d failed: [%d] %s", seq+1, blockNum, larkCode, msg), partResult["error"])
}
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, blockNum, common.FormatSize(partSize))
}
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
"upload_id": uploadID,
"block_num": blockNum,
})
if err != nil {
return "", err
}
token := common.GetString(finishResult, "file_token")
if token == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned")
}
return token, nil
}
// drivePushDeleteFile deletes a single Drive file (type=file). Folders are
// never reached here because --delete-remote only iterates the type=file
// subset of the remote listing.
func drivePushDeleteFile(_ context.Context, runtime *common.RuntimeContext, fileToken string) error {
_, err := runtime.CallAPI(
"DELETE",
fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(fileToken)),
map[string]interface{}{"type": driveTypeFile},
nil,
)
return err
}
// drivePushParentRel returns the parent rel_path of rel ("" when the file
// lives at the root). The local walker emits forward-slash rel_paths so
// path.Dir is the right primitive here, not filepath.Dir.
func drivePushParentRel(rel string) string {
dir := path.Dir(rel)
if dir == "." || dir == "/" {
return ""
}
return dir
}
// drivePushSplitRel splits a non-empty rel into (parent, basename), both
// using forward slashes.
func drivePushSplitRel(rel string) (string, string) {
idx := strings.LastIndex(rel, "/")
if idx < 0 {
return "", rel
}
return rel[:idx], rel[idx+1:]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,265 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"io/fs"
"path/filepath"
"sort"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type driveStatusEntry struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
}
// DriveStatus walks --local-dir, recursively lists --folder-token, and reports
// four buckets (new_local, new_remote, modified, unchanged) by SHA-256 hash.
//
// Only Drive entries with type=file are compared; online docs (docx, sheet,
// bitable, mindnote, slides) and shortcuts are skipped because there is no
// equivalent local binary to hash against.
//
// SafeInputPath (applied by runtime.FileIO()) rejects absolute paths and any
// path that resolves outside cwd, which keeps the local side bounded to the
// caller's working directory.
var DriveStatus = common.Shortcut{
Service: "drive",
Command: "+status",
Description: "Compare a local directory with a Drive folder by content hash",
Risk: "read",
Scopes: []string{"drive:drive.metadata:readonly", "drive:file:download"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
{Name: "folder-token", Desc: "Drive folder token", Required: true},
},
Tips: []string{
"Only entries with type=file are compared; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
"Files present on both sides are downloaded and SHA-256 hashed in memory to decide modified vs unchanged; expect noticeable I/O on large folders.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
if localDir == "" {
return common.FlagErrorf("--local-dir is required")
}
if folderToken == "" {
return common.FlagErrorf("--folder-token is required")
}
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
}
// Path safety (absolute paths, traversal, symlink escape) is enforced
// upfront by the framework helper so the error message references the
// correct flag name; FileIO().Stat below would do the same check, but
// surface --file in its hint.
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
return output.ErrValidation("%s", err)
}
info, err := runtime.FileIO().Stat(localDir)
if err != nil {
return common.WrapInputStatError(err)
}
if !info.IsDir() {
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("Walk --local-dir, recursively list --folder-token, and download files present on both sides to compare SHA-256.").
GET("/open-apis/drive/v1/files").
Set("folder_token", runtime.Str("folder-token"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
// Resolve --local-dir to its canonical absolute path before walking.
// SafeInputPath fully evaluates symlinks across the entire path,
// which closes the kernel-level escape route that filepath.Clean
// alone misses: e.g. "link/.." string-cleans to "." but the kernel
// resolves through link's target's parent, so a raw walk on the
// user-supplied string can land outside cwd. Walking the canonical
// root sidesteps that — and the matching cwd canonical lets each
// absolute walk hit be converted to a cwd-relative path that
// FileIO.Open's SafeInputPath check still accepts.
//
// Validate already ran SafeLocalFlagPath (with the proper flag
// name in the error message), so a failure here is unexpected and
// only possible under a Validate↔Execute race.
safeRoot, err := validate.SafeInputPath(localDir)
if err != nil {
return output.ErrValidation("--local-dir: %s", err)
}
cwdCanonical, err := validate.SafeInputPath(".")
if err != nil {
return output.ErrValidation("could not resolve cwd: %s", err)
}
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
localHashes, err := walkLocalForStatus(runtime, safeRoot, cwdCanonical)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
if err != nil {
return err
}
// +status only diffs binary content, so collapse the unified
// listing to type=file. Online docs / shortcuts have no
// hashable bytes and are intentionally absent from the diff
// view (a docx living next to a same-named local file is a
// known no-op).
remoteFiles := make(map[string]string, len(entries))
for rel, entry := range entries {
if entry.Type == driveTypeFile {
remoteFiles[rel] = entry.FileToken
}
}
paths := mergeStatusPaths(localHashes, remoteFiles)
var newLocal, newRemote, modified, unchanged []driveStatusEntry
for _, relPath := range paths {
localHash, hasLocal := localHashes[relPath]
remoteToken, hasRemote := remoteFiles[relPath]
switch {
case hasLocal && !hasRemote:
newLocal = append(newLocal, driveStatusEntry{RelPath: relPath})
case !hasLocal && hasRemote:
newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteToken})
default:
remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteToken)
if err != nil {
return err
}
entry := driveStatusEntry{RelPath: relPath, FileToken: remoteToken}
if localHash == remoteHash {
unchanged = append(unchanged, entry)
} else {
modified = append(modified, entry)
}
}
}
runtime.Out(map[string]interface{}{
"new_local": emptyIfNil(newLocal),
"new_remote": emptyIfNil(newRemote),
"modified": emptyIfNil(modified),
"unchanged": emptyIfNil(unchanged),
}, nil)
return nil
},
}
// walkLocalForStatus walks the canonical absolute root produced by
// SafeInputPath. Using the canonical root keeps the kernel from
// following any symlink hidden inside the user-supplied --local-dir
// (e.g. "link/..", which filepath.Clean shrinks to "." but which OS
// path resolution would resolve through the symlink target). For each
// hit, we report rel_path relative to root for the JSON output, and
// convert the absolute path to a cwd-relative form so FileIO.Open's
// SafeInputPath check (which rejects absolute paths) still applies.
func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical string) (map[string]string, error) {
files := make(map[string]string)
// FileIO has no walker today and shortcuts can't import internal/vfs.
// The walk root is the canonical absolute path returned by
// validate.SafeInputPath, so it is no longer a symlink itself, and
// WalkDir's default policy (do not follow child symlinks) keeps the
// traversal inside that canonical subtree.
err := filepath.WalkDir(root, func(absPath string, d fs.DirEntry, walkErr error) error { //nolint:forbidigo // see comment above
if walkErr != nil {
return walkErr
}
if d.IsDir() || !d.Type().IsRegular() {
return nil
}
rel, err := filepath.Rel(root, absPath)
if err != nil {
return err
}
relToCwd, err := filepath.Rel(cwdCanonical, absPath)
if err != nil {
return err
}
sum, err := hashLocalForStatus(runtime, relToCwd)
if err != nil {
return err
}
files[filepath.ToSlash(rel)] = sum
return nil
})
if err != nil {
return nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
}
return files, nil
}
func hashLocalForStatus(runtime *common.RuntimeContext, path string) (string, error) {
f, err := runtime.FileIO().Open(path)
if err != nil {
return "", common.WrapInputStatError(err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", output.Errorf(output.ExitInternal, "io", "hash %s: %s", path, err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func hashRemoteForStatus(ctx context.Context, runtime *common.RuntimeContext, fileToken string) (string, error) {
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: "GET",
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return "", output.ErrNetwork("download %s: %s", common.MaskToken(fileToken), err)
}
defer resp.Body.Close()
h := sha256.New()
if _, err := io.Copy(h, resp.Body); err != nil {
return "", output.ErrNetwork("hash remote %s: %s", common.MaskToken(fileToken), err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func mergeStatusPaths(local, remote map[string]string) []string {
seen := make(map[string]struct{}, len(local)+len(remote))
for p := range local {
seen[p] = struct{}{}
}
for p := range remote {
seen[p] = struct{}{}
}
out := make([]string, 0, len(seen))
for p := range seen {
out = append(out, p)
}
sort.Strings(out)
return out
}
func emptyIfNil(s []driveStatusEntry) []driveStatusEntry {
if s == nil {
return []driveStatusEntry{}
}
return s
}

View File

@@ -0,0 +1,498 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
// TestDriveStatusCategorizesByHash exercises the four-bucket classification
// against a real walk of the temp dir and a mocked Drive listing.
func TestDriveStatusCategorizesByHash(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
// Local layout:
// local/a.txt — also on remote with different content → modified
// local/b.txt — only local → new_local
// local/sub/c.txt — also on remote with same content → unchanged
// Remote-only:
// d.txt → new_remote
if err := os.MkdirAll("local/sub", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile("local/a.txt", []byte("aaa"), 0o644); err != nil {
t.Fatalf("WriteFile a.txt: %v", err)
}
if err := os.WriteFile("local/b.txt", []byte("bbb"), 0o644); err != nil {
t.Fatalf("WriteFile b.txt: %v", err)
}
if err := os.WriteFile("local/sub/c.txt", []byte("ccc"), 0o644); err != nil {
t.Fatalf("WriteFile sub/c.txt: %v", err)
}
// Root folder list — order matters: stubs match in registration order.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"},
map[string]interface{}{"token": "tok_sub", "name": "sub", "type": "folder"},
map[string]interface{}{"token": "tok_d", "name": "d.txt", "type": "file"},
// noise: an online doc and a shortcut should be ignored
map[string]interface{}{"token": "tok_doc", "name": "ignored.docx", "type": "docx"},
map[string]interface{}{"token": "tok_sc", "name": "ignored.lnk", "type": "shortcut"},
},
"has_more": false,
},
},
})
// Subfolder list
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=tok_sub",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_c", "name": "c.txt", "type": "file"},
},
"has_more": false,
},
},
})
// Download a.txt: remote content differs from local "aaa" → modified.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/tok_a/download",
Status: 200,
Body: []byte("AAA"),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})
// Download c.txt: remote content matches local "ccc" → unchanged.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/tok_c/download",
Status: 200,
Body: []byte("ccc"),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
checks := []struct {
bucket string
path string
token string
}{
{"new_local", "b.txt", ""},
{"new_remote", "d.txt", "tok_d"},
{"modified", "a.txt", "tok_a"},
{"unchanged", "sub/c.txt", "tok_c"},
}
for _, c := range checks {
if !strings.Contains(out, `"`+c.bucket+`":`) {
t.Errorf("output missing bucket %q\noutput: %s", c.bucket, out)
}
if !strings.Contains(out, `"rel_path": "`+c.path+`"`) {
t.Errorf("output missing rel_path %q (expected in %s)\noutput: %s", c.path, c.bucket, out)
}
if c.token != "" && !strings.Contains(out, `"file_token": "`+c.token+`"`) {
t.Errorf("output missing file_token %q (expected in %s)\noutput: %s", c.token, c.bucket, out)
}
}
if strings.Contains(out, "ignored.docx") || strings.Contains(out, "ignored.lnk") {
t.Errorf("output should skip docx/shortcut entries\noutput: %s", out)
}
reg.Verify(t)
}
// TestDriveStatusPaginatesRemoteListing pins multi-page handling end-to-end
// AND the dual-field tolerance of common.PaginationMeta. Page 1 surfaces
// `next_page_token` (Drive's historical name); page 2 surfaces `page_token`
// (what the shared helper also accepts). If the shortcut had hard-coded
// either field name, one of the two pages' files would be silently dropped
// from the comparison and would land in the wrong bucket. Stub order is
// significant: httpmock matches in registration order, and both stubs key on
// the GET .../files URL — they pop in turn, so page 1's response (with the
// continuation token) must be registered before page 2's terminator.
func TestDriveStatusPaginatesRemoteListing(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
// Page 1: returns one file plus a continuation token via
// next_page_token (the field Drive currently emits).
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_p1", "name": "page1.txt", "type": "file"},
},
"has_more": true,
"next_page_token": "cursor-page-2",
},
},
})
// Page 2: returns the second file with has_more=false. This stub uses
// page_token (the alternate spelling) to lock in that the shared
// PaginationMeta helper accepts BOTH field names.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_p2", "name": "page2.txt", "type": "file"},
},
"has_more": false,
"page_token": "",
},
},
})
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
// Both pages contributed to new_remote (local is empty).
for _, want := range []string{
`"rel_path": "page1.txt"`,
`"file_token": "tok_p1"`,
`"rel_path": "page2.txt"`,
`"file_token": "tok_p2"`,
} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q (a page must have been silently dropped)\noutput: %s", want, out)
}
}
reg.Verify(t)
}
func TestDriveStatusRejectsMissingLocalDir(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "does-not-exist",
"--folder-token", "folder_root",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected validation error for missing local dir, got nil")
}
}
func TestDriveStatusRejectsLocalFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("not-a-dir.txt", []byte("x"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "not-a-dir.txt",
"--folder-token", "folder_root",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected validation error when --local-dir is a file, got nil")
}
if !strings.Contains(err.Error(), "not a directory") {
t.Fatalf("unexpected error message: %v", err)
}
}
func TestDriveStatusRejectsAbsoluteLocalDir(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "/etc",
"--folder-token", "folder_root",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected validation error for absolute --local-dir, got nil")
}
}
// TestDriveStatusRejectsEmptyFolderToken covers the Validate-stage required
// check that runs before ResourceName: an empty --folder-token must surface
// a structured FlagError referencing the flag name.
func TestDriveStatusRejectsEmptyFolderToken(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected validation error for empty --folder-token, got nil")
}
if !strings.Contains(err.Error(), "--folder-token") {
t.Fatalf("error must reference --folder-token, got: %v", err)
}
}
// TestDriveStatusDoesNotEscapeViaSymlinkParentRef is the regression for the
// "link/.." escape: filepath.Clean string-shrinks "link/.." to ".", so a
// raw walk on the user-supplied input can land on the kernel-resolved
// path through link's target's parent — outside cwd. The fix is to walk
// SafeInputPath's canonical absolute root instead of the raw input.
//
// Setup: an "escape" sibling directory contains a sentinel file; cwd
// contains a "link" symlink pointing into that escape directory.
// Calling +status with --local-dir "link/.." must not surface the
// sentinel — the walk must stay inside cwd.
func TestDriveStatusDoesNotEscapeViaSymlinkParentRef(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
// Sentinel lives outside cwd; the agent must never see it.
escapeDir := t.TempDir()
if err := os.WriteFile(filepath.Join(escapeDir, "secret.txt"), []byte("S3CRET"), 0o644); err != nil {
t.Fatalf("WriteFile secret: %v", err)
}
// cwd has a symlink that points into the sentinel's parent.
cwdDir := t.TempDir()
withDriveWorkingDir(t, cwdDir)
if err := os.Symlink(escapeDir, filepath.Join(cwdDir, "link")); err != nil {
t.Fatalf("Symlink: %v", err)
}
// A normal file inside cwd just to make the walk non-trivial.
if err := os.WriteFile(filepath.Join(cwdDir, "ok.txt"), []byte("ok"), 0o644); err != nil {
t.Fatalf("WriteFile ok: %v", err)
}
// Empty remote folder so any path that surfaces in the output
// must have come from the local walk.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{},
"has_more": false,
},
},
})
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "link/..",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if strings.Contains(out, "secret.txt") || strings.Contains(out, "S3CRET") {
t.Fatalf("walk escaped via link/..: secret.txt leaked into output\noutput:\n%s", out)
}
// ok.txt is in cwd and must classify as new_local (no remote stub for it).
if !strings.Contains(out, `"rel_path": "ok.txt"`) {
t.Fatalf("expected ok.txt in new_local, got:\n%s", out)
}
}
// TestDriveStatusSkipsSymlinkInsideRoot pins down WalkDir's default policy
// for symlinks discovered as child entries: they are reported with a
// non-regular file mode and the callback skips them, so a symlink inside
// the validated root pointing into an out-of-tree directory cannot leak
// the target's contents.
func TestDriveStatusSkipsSymlinkInsideRoot(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
// Sentinel sits outside cwd; a child symlink inside the walked root
// points there. If the walker followed child symlinks (it must not),
// the sentinel's name would surface in new_local.
escapeDir := t.TempDir()
if err := os.WriteFile(filepath.Join(escapeDir, "secret.txt"), []byte("S3CRET"), 0o644); err != nil {
t.Fatalf("WriteFile secret: %v", err)
}
cwdDir := t.TempDir()
withDriveWorkingDir(t, cwdDir)
if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "ok.txt"), []byte("ok"), 0o644); err != nil {
t.Fatalf("WriteFile ok: %v", err)
}
// Child-of-root symlink that resolves out of the validated subtree.
if err := os.Symlink(escapeDir, filepath.Join("local", "sub", "escape")); err != nil {
t.Fatalf("Symlink: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{},
"has_more": false,
},
},
})
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if strings.Contains(out, "secret.txt") || strings.Contains(out, "S3CRET") {
t.Fatalf("walk followed child symlink and leaked sentinel:\n%s", out)
}
if !strings.Contains(out, `"rel_path": "ok.txt"`) {
t.Fatalf("expected ok.txt in new_local; got:\n%s", out)
}
}
// TestDriveStatusSurvivesCircularSymlinkInsideRoot makes sure WalkDir
// terminates even when a child symlink points back at one of its
// ancestors. WalkDir's default policy already declines to follow child
// symlinks; this test pins that contract for our caller.
func TestDriveStatusSurvivesCircularSymlinkInsideRoot(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
cwdDir := t.TempDir()
withDriveWorkingDir(t, cwdDir)
if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "sub", "real.txt"), []byte("real"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
// loop symlink: cwd/local/sub/loop -> cwd/local (an ancestor).
loopTarget, err := filepath.Abs(filepath.Join("local"))
if err != nil {
t.Fatalf("Abs: %v", err)
}
if err := os.Symlink(loopTarget, filepath.Join("local", "sub", "loop")); err != nil {
t.Fatalf("Symlink: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{},
"has_more": false,
},
},
})
// If WalkDir followed the loop, this test would never finish; the
// test runner's per-test timeout would surface that as a failure.
err = mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
if !strings.Contains(stdout.String(), `"rel_path": "sub/real.txt"`) {
t.Fatalf("expected sub/real.txt in new_local; got:\n%s", stdout.String())
}
}
// TestDriveStatusRejectsMalformedFolderToken covers the ResourceName format
// guard: a token with control characters (newline) must be rejected before
// any API call is made.
func TestDriveStatusRejectsMalformedFolderToken(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "tok\nwithnewline",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected validation error for malformed --folder-token, got nil")
}
if !strings.Contains(err.Error(), "--folder-token") {
t.Fatalf("error must reference --folder-token, got: %v", err)
}
}

View File

@@ -0,0 +1,116 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"github.com/larksuite/cli/shortcuts/common"
)
const (
driveListRemotePageSize = 200
driveTypeFile = "file"
driveTypeFolder = "folder"
)
// driveRemoteEntry is one Drive entry returned by listRemoteFolder. It
// carries enough metadata for every shortcut that consumes the listing
// to build its own per-shortcut view by filtering on Type.
type driveRemoteEntry struct {
// FileToken is the Drive token for this entry. For type=folder this
// is the folder_token; for everything else it is the file_token.
FileToken string
// Type is the Drive entry kind verbatim from the API:
// "file" | "folder" | "docx" | "doc" | "sheet" | "bitable" |
// "mindnote" | "slides" | "shortcut" | …
Type string
// RelPath is the entry's path relative to the listing root. Encoded
// with "/" separators on every platform so it matches the rel_paths
// produced by the shortcuts' local walkers.
RelPath string
}
// listRemoteFolder recursively lists folderToken under relBase and
// returns one entry per Drive item, keyed by rel_path. Subfolders are
// descended into and the folder's own entry is also recorded — callers
// can reason about "this rel_path is occupied by a folder" without
// re-listing.
//
// This is the shared backbone for the three sync-disk shortcuts. None
// of them need every field at every call site, so each one filters
// on Type:
//
// - +status (drive_status.go) keeps Type=="file" and uses FileToken
// to drive content-hash diffs against the local tree.
// - +pull (drive_pull.go) keeps Type=="file" + FileToken for the
// download set, and the full key set (every rel_path) as the
// guard for --delete-local.
// - +push (drive_push.go) keeps Type=="file" + FileToken for upload /
// overwrite / orphan-delete decisions, and Type=="folder" + FileToken
// for the create_folder cache.
//
// Pagination uses common.PaginationMeta, which accepts both
// page_token and next_page_token — the Drive list endpoint has
// historically returned the latter, but the helper future-proofs
// against a backend rename.
func listRemoteFolder(ctx context.Context, runtime *common.RuntimeContext, folderToken, relBase string) (map[string]driveRemoteEntry, error) {
out := make(map[string]driveRemoteEntry)
pageToken := ""
for {
params := map[string]interface{}{
"folder_token": folderToken,
"page_size": fmt.Sprint(driveListRemotePageSize),
}
if pageToken != "" {
params["page_token"] = pageToken
}
result, err := runtime.CallAPI("GET", "/open-apis/drive/v1/files", params, nil)
if err != nil {
return nil, err
}
rawFiles, _ := result["files"].([]interface{})
for _, item := range rawFiles {
f, ok := item.(map[string]interface{})
if !ok {
continue
}
fType := common.GetString(f, "type")
fName := common.GetString(f, "name")
fToken := common.GetString(f, "token")
if fName == "" || fToken == "" {
continue
}
rel := joinRelDrive(relBase, fName)
out[rel] = driveRemoteEntry{FileToken: fToken, Type: fType, RelPath: rel}
if fType == driveTypeFolder {
sub, err := listRemoteFolder(ctx, runtime, fToken, rel)
if err != nil {
return nil, err
}
for k, v := range sub {
out[k] = v
}
}
}
hasMore, nextToken := common.PaginationMeta(result)
if !hasMore || nextToken == "" {
break
}
pageToken = nextToken
}
return out, nil
}
// joinRelDrive joins a rel_path base with an entry name using "/".
// Empty base means the entry sits at the listing root. Mirrors the
// behavior the per-shortcut helpers used to ship and keeps rel_paths
// stable across +status / +pull / +push.
func joinRelDrive(base, name string) string {
if base == "" {
return name
}
return base + "/" + name
}

View File

@@ -18,6 +18,9 @@ func Shortcuts() []common.Shortcut {
DriveImport,
DriveMove,
DriveDelete,
DriveStatus,
DrivePush,
DrivePull,
DriveTaskResult,
DriveApplyPermission,
DriveSearch,

View File

@@ -21,6 +21,9 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+import",
"+move",
"+delete",
"+status",
"+push",
"+pull",
"+task_result",
"+apply-permission",
"+search",

View File

@@ -9,6 +9,7 @@ import (
"mime"
"net/mail"
"strings"
"time"
"github.com/larksuite/cli/extension/fileio"
)
@@ -215,11 +216,24 @@ type PatchOp struct {
Target AttachmentTarget `json:"target,omitempty"`
SignatureID string `json:"signature_id,omitempty"`
// Calendar event fields, used by set_calendar. The raw ISO 8601 strings
// are shown in dry-run output; the shortcut layer pre-builds the ICS
// blob into CalendarICS below before Apply runs.
EventSummary string `json:"event_summary,omitempty"`
EventStart string `json:"event_start,omitempty"`
EventEnd string `json:"event_end,omitempty"`
EventLocation string `json:"event_location,omitempty"`
// RenderedSignatureHTML is set by the shortcut layer (not from JSON) after
// fetching and interpolating the signature. The patch layer uses this
// pre-rendered content for insert_signature ops.
RenderedSignatureHTML string `json:"-"`
SignatureImages []SignatureImage `json:"-"`
// CalendarICS holds the pre-built RFC 5545 ICS blob for a set_calendar
// op. Populated by the shortcut layer after the snapshot is parsed and
// organizer/attendee addresses can be resolved. Not serialised.
CalendarICS []byte `json:"-"`
}
// SignatureImage holds pre-downloaded image data for signature inline images.
@@ -327,6 +341,26 @@ func (op PatchOp) Validate() error {
}
case "remove_signature":
// No required fields.
case "set_calendar":
if strings.TrimSpace(op.EventSummary) == "" {
return fmt.Errorf("set_calendar requires event_summary")
}
if strings.TrimSpace(op.EventStart) == "" || strings.TrimSpace(op.EventEnd) == "" {
return fmt.Errorf("set_calendar requires event_start and event_end")
}
start, err := parseISO8601(op.EventStart)
if err != nil {
return fmt.Errorf("set_calendar: event_start must be a valid ISO 8601 timestamp")
}
end, err := parseISO8601(op.EventEnd)
if err != nil {
return fmt.Errorf("set_calendar: event_end must be a valid ISO 8601 timestamp")
}
if !end.After(start) {
return fmt.Errorf("set_calendar: event_end must be after event_start")
}
case "remove_calendar":
// No required fields.
default:
return fmt.Errorf("unsupported op %q", op.Op)
}
@@ -400,3 +434,19 @@ func MustJSON(v interface{}) string {
}
return string(data)
}
// parseISO8601 tries common ISO 8601 timestamp layouts, accepting both
// with-seconds (RFC 3339) and without-seconds variants.
func parseISO8601(s string) (time.Time, error) {
for _, layout := range []string{
time.RFC3339,
"2006-01-02T15:04Z07:00",
"2006-01-02T15:04:05",
"2006-01-02T15:04",
} {
if t, err := time.Parse(layout, s); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("cannot parse %q as ISO 8601", s)
}

View File

@@ -136,6 +136,10 @@ func applyOp(dctx *DraftCtx, snapshot *DraftSnapshot, op PatchOp, options PatchO
return insertSignatureOp(snapshot, op)
case "remove_signature":
return removeSignatureOp(snapshot)
case "set_calendar":
return applyCalendarSet(snapshot, op.CalendarICS)
case "remove_calendar":
return applyCalendarRemove(snapshot)
default:
return fmt.Errorf("unsupported patch op %q", op.Op)
}

View File

@@ -0,0 +1,188 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package draft
import (
"fmt"
"strings"
)
const calendarMediaType = "text/calendar"
// applyCalendarSet installs or replaces the text/calendar MIME part in the
// snapshot. The caller is expected to have pre-built icsData using the
// snapshot's From/To/Cc addresses.
func applyCalendarSet(snapshot *DraftSnapshot, icsData []byte) error {
if len(icsData) == 0 {
return fmt.Errorf("set_calendar: ICS data is empty (shortcut layer must pre-build it)")
}
setCalendarPart(snapshot, icsData)
return nil
}
// applyCalendarRemove strips the text/calendar part from the snapshot.
// No-op if no calendar part exists.
func applyCalendarRemove(snapshot *DraftSnapshot) error {
removeCalendarPart(snapshot)
return nil
}
// setCalendarPart places exactly one text/calendar part inside
// multipart/alternative, matching the Feishu client behavior. Any existing
// text/calendar parts elsewhere in the tree are removed first.
func setCalendarPart(snapshot *DraftSnapshot, icsData []byte) {
newPart := &Part{
MediaType: calendarMediaType,
MediaParams: map[string]string{"charset": "UTF-8", "method": "REQUEST"},
Body: icsData,
Dirty: true,
}
if snapshot.Body == nil {
snapshot.Body = newPart
return
}
// Remove all existing text/calendar parts from everywhere in the tree.
if strings.EqualFold(snapshot.Body.MediaType, calendarMediaType) {
snapshot.Body = newPart
return
}
removeAllPartsByMediaType(snapshot.Body, calendarMediaType)
// Place inside the existing multipart/alternative.
if alt := FindPartByMediaType(snapshot.Body, "multipart/alternative"); alt != nil {
alt.Children = append(alt.Children, newPart)
alt.Dirty = true
return
}
// No multipart/alternative exists. If the body is a single leaf,
// wrap it in multipart/alternative together with the calendar.
if !snapshot.Body.IsMultipart() {
original := *snapshot.Body
// Reset all header-carrying fields so the serializer constructs a fresh
// Content-Type from MediaType instead of reusing the stale leaf headers.
snapshot.Body.Headers = nil
snapshot.Body.MediaType = "multipart/alternative"
snapshot.Body.MediaParams = nil
snapshot.Body.ContentDisposition = ""
snapshot.Body.ContentDispositionArg = nil
snapshot.Body.ContentID = ""
snapshot.Body.PartID = ""
snapshot.Body.Body = nil
snapshot.Body.TransferEncoding = ""
snapshot.Body.RawEntity = nil
snapshot.Body.Preamble = nil
snapshot.Body.Epilogue = nil
snapshot.Body.EncodingProblem = false
snapshot.Body.Children = []*Part{&original, newPart}
snapshot.Body.Dirty = true
return
}
// Multipart body without an alternative sub-part (e.g. multipart/mixed
// with a text/html child). Find the first text/* child and wrap it in
// a new multipart/alternative that also contains the calendar.
for i, child := range snapshot.Body.Children {
if child != nil && strings.HasPrefix(strings.ToLower(child.MediaType), "text/") {
alt := &Part{
MediaType: "multipart/alternative",
Children: []*Part{child, newPart},
Dirty: true,
}
snapshot.Body.Children[i] = alt
snapshot.Body.Dirty = true
return
}
}
// Fallback: append to the root multipart container.
snapshot.Body.Children = append(snapshot.Body.Children, newPart)
snapshot.Body.Dirty = true
}
func removeCalendarPart(snapshot *DraftSnapshot) {
if snapshot.Body == nil {
return
}
if strings.EqualFold(snapshot.Body.MediaType, calendarMediaType) {
snapshot.Body = nil
return
}
removeAllPartsByMediaType(snapshot.Body, calendarMediaType)
}
// FindPartByMediaType walks the MIME tree and returns the first part with
// the given media type, or nil when not found.
func FindPartByMediaType(root *Part, mediaType string) *Part {
if root == nil {
return nil
}
if strings.EqualFold(root.MediaType, mediaType) {
return root
}
for _, child := range root.Children {
if found := FindPartByMediaType(child, mediaType); found != nil {
return found
}
}
return nil
}
// findAllPartsByMediaType walks the MIME tree and returns every part with
// the given media type. Used in tests to assert tree contents.
func findAllPartsByMediaType(root *Part, mediaType string) []*Part {
if root == nil {
return nil
}
var result []*Part
if strings.EqualFold(root.MediaType, mediaType) {
result = append(result, root)
}
for _, child := range root.Children {
result = append(result, findAllPartsByMediaType(child, mediaType)...)
}
return result
}
// removePartByMediaType removes the first part with the given media type from
// the MIME tree. The parent is marked dirty when a removal happens.
func removePartByMediaType(root *Part, mediaType string) {
if root == nil {
return
}
for i, child := range root.Children {
if child != nil && strings.EqualFold(child.MediaType, mediaType) {
root.Children = append(root.Children[:i], root.Children[i+1:]...)
root.Dirty = true
return
}
removePartByMediaType(child, mediaType)
}
}
// removeAllPartsByMediaType removes every part with the given media type from
// the MIME tree, at all nesting levels.
func removeAllPartsByMediaType(root *Part, mediaType string) {
if root == nil {
return
}
var kept []*Part
removed := false
for _, child := range root.Children {
if child != nil && strings.EqualFold(child.MediaType, mediaType) {
removed = true
continue
}
kept = append(kept, child)
}
if removed {
root.Children = kept
root.Dirty = true
}
for _, child := range root.Children {
removeAllPartsByMediaType(child, mediaType)
}
}

View File

@@ -0,0 +1,429 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package draft
import (
"strings"
"testing"
)
const fixtureCalData = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n"
// ---------------------------------------------------------------------------
// set_calendar — validate
// ---------------------------------------------------------------------------
func TestSetCalendar_ValidateRequiresSummary(t *testing.T) {
err := PatchOp{Op: "set_calendar", EventStart: "2026-04-25T10:00+08:00", EventEnd: "2026-04-25T11:00+08:00"}.Validate()
if err == nil || !strings.Contains(err.Error(), "event_summary") {
t.Errorf("expected event_summary error, got %v", err)
}
}
func TestSetCalendar_ValidateRequiresStartAndEnd(t *testing.T) {
err := PatchOp{Op: "set_calendar", EventSummary: "Meeting", EventStart: "2026-04-25T10:00+08:00"}.Validate()
if err == nil || !strings.Contains(err.Error(), "event_start and event_end") {
t.Errorf("expected start/end error, got %v", err)
}
}
func TestSetCalendar_ValidateInvalidStartFormat(t *testing.T) {
err := PatchOp{Op: "set_calendar", EventSummary: "M", EventStart: "not-a-date", EventEnd: "2026-04-25T11:00+08:00"}.Validate()
if err == nil || !strings.Contains(err.Error(), "event_start") {
t.Errorf("expected event_start error for bad format, got %v", err)
}
}
func TestSetCalendar_ValidateInvalidEndFormat(t *testing.T) {
err := PatchOp{Op: "set_calendar", EventSummary: "M", EventStart: "2026-04-25T10:00+08:00", EventEnd: "not-a-date"}.Validate()
if err == nil || !strings.Contains(err.Error(), "event_end") {
t.Errorf("expected event_end error for bad format, got %v", err)
}
}
func TestSetCalendar_ValidateEndNotAfterStart(t *testing.T) {
err := PatchOp{Op: "set_calendar", EventSummary: "M", EventStart: "2026-04-25T11:00+08:00", EventEnd: "2026-04-25T10:00+08:00"}.Validate()
if err == nil || !strings.Contains(err.Error(), "after") {
t.Errorf("expected end-after-start error, got %v", err)
}
}
func TestSetCalendar_ValidateOK(t *testing.T) {
err := PatchOp{
Op: "set_calendar",
EventSummary: "Meeting",
EventStart: "2026-04-25T10:00+08:00",
EventEnd: "2026-04-25T11:00+08:00",
}.Validate()
if err != nil {
t.Errorf("expected no error, got %v", err)
}
}
// ---------------------------------------------------------------------------
// set_calendar — Apply adds text/calendar part when none exists
// ---------------------------------------------------------------------------
func TestSetCalendar_AddsCalendarPartToHTMLDraft(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<p>Hello</p>`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "set_calendar",
EventSummary: "Meeting",
EventStart: "2026-04-25T10:00+08:00",
EventEnd: "2026-04-25T11:00+08:00",
CalendarICS: []byte(fixtureCalData),
}},
})
if err != nil {
t.Fatalf("Apply: %v", err)
}
part := FindPartByMediaType(snapshot.Body, calendarMediaType)
if part == nil {
t.Fatal("text/calendar part not added to draft")
}
if string(part.Body) != fixtureCalData {
t.Errorf("calendar part body mismatch: got %q", part.Body)
}
if part.MediaParams["method"] != "REQUEST" {
t.Errorf("calendar part missing method=REQUEST in MediaParams: %v", part.MediaParams)
}
}
// ---------------------------------------------------------------------------
// set_calendar — Apply replaces existing text/calendar part
// ---------------------------------------------------------------------------
func TestSetCalendar_ReplacesExistingCalendarPart(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="b1"
--b1
Content-Type: text/html; charset=UTF-8
<p>Hello</p>
--b1
Content-Type: text/calendar; charset=UTF-8
BEGIN:VCALENDAR
VERSION:2.0
SUMMARY:OLD
END:VCALENDAR
--b1--`)
newICS := []byte("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nSUMMARY:NEW\r\nEND:VCALENDAR\r\n")
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "set_calendar",
EventSummary: "NEW",
EventStart: "2026-04-25T10:00+08:00",
EventEnd: "2026-04-25T11:00+08:00",
CalendarICS: newICS,
}},
})
if err != nil {
t.Fatalf("Apply: %v", err)
}
part := FindPartByMediaType(snapshot.Body, calendarMediaType)
if part == nil {
t.Fatal("text/calendar part missing")
}
if !strings.Contains(string(part.Body), "SUMMARY:NEW") {
t.Errorf("expected new SUMMARY, got %q", part.Body)
}
if strings.Contains(string(part.Body), "SUMMARY:OLD") {
t.Errorf("old SUMMARY not replaced")
}
}
// ---------------------------------------------------------------------------
// set_calendar — Apply requires pre-built ICS
// ---------------------------------------------------------------------------
func TestSetCalendar_EmptyICSIsError(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<p>Hello</p>`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "set_calendar",
EventSummary: "Meeting",
EventStart: "2026-04-25T10:00+08:00",
EventEnd: "2026-04-25T11:00+08:00",
// CalendarICS intentionally nil — simulates missing pre-process.
}},
})
if err == nil {
t.Fatal("expected error for missing CalendarICS")
}
if !strings.Contains(err.Error(), "ICS data is empty") {
t.Errorf("unexpected error: %v", err)
}
}
// ---------------------------------------------------------------------------
// remove_calendar
// ---------------------------------------------------------------------------
func TestRemoveCalendar_StripsCalendarPart(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="b1"
--b1
Content-Type: text/html; charset=UTF-8
<p>Hello</p>
--b1
Content-Type: text/calendar; charset=UTF-8
BEGIN:VCALENDAR
END:VCALENDAR
--b1--`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "remove_calendar"}},
})
if err != nil {
t.Fatalf("Apply: %v", err)
}
if part := FindPartByMediaType(snapshot.Body, calendarMediaType); part != nil {
t.Errorf("text/calendar part should be removed, but still found")
}
}
func TestRemoveCalendar_NoOpWhenAbsent(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Plain
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<p>Hello</p>`)
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "remove_calendar"}},
})
if err != nil {
t.Fatalf("Apply: %v", err)
}
// Body remains intact.
if snapshot.Body == nil {
t.Fatal("body unexpectedly nil")
}
}
// ---------------------------------------------------------------------------
// Internal MIME helpers (coverage)
// ---------------------------------------------------------------------------
func TestFindPartByMediaType_CaseInsensitive(t *testing.T) {
root := &Part{
MediaType: "multipart/mixed",
Children: []*Part{
{MediaType: "TEXT/Calendar"},
},
}
got := FindPartByMediaType(root, "text/calendar")
if got == nil {
t.Fatal("expected to find part despite case mismatch")
}
}
func TestRemovePartByMediaType_MarksParentDirty(t *testing.T) {
root := &Part{
MediaType: "multipart/mixed",
Children: []*Part{
{MediaType: "text/calendar"},
{MediaType: "text/html"},
},
}
removePartByMediaType(root, "text/calendar")
if len(root.Children) != 1 {
t.Fatalf("expected 1 remaining child, got %d", len(root.Children))
}
if !root.Dirty {
t.Error("parent not marked dirty after removal")
}
}
func TestSetCalendar_CollapsesToOneInsideAlternative(t *testing.T) {
// Feishu client creates two text/calendar copies: one inside
// multipart/alternative and one as an inline attachment in
// multipart/mixed. set_calendar must collapse them to a single
// copy inside multipart/alternative.
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="outer"
--outer
Content-Type: multipart/alternative; boundary="inner"
--inner
Content-Type: text/html; charset=UTF-8
<p>Hello</p>
--inner
Content-Type: text/calendar; charset=UTF-8
BEGIN:VCALENDAR
SUMMARY:OLD
END:VCALENDAR
--inner--
--outer
Content-Type: text/calendar; charset=UTF-8; name="invite.ics"
Content-Id: <invite.ics>
BEGIN:VCALENDAR
SUMMARY:OLD
END:VCALENDAR
--outer--`)
newICS := []byte("BEGIN:VCALENDAR\r\nSUMMARY:NEW\r\nEND:VCALENDAR\r\n")
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{
Op: "set_calendar",
EventSummary: "NEW",
EventStart: "2026-04-25T10:00+08:00",
EventEnd: "2026-04-25T11:00+08:00",
CalendarICS: newICS,
}},
})
if err != nil {
t.Fatalf("Apply: %v", err)
}
// Exactly one text/calendar part should remain, inside alternative.
parts := findAllPartsByMediaType(snapshot.Body, calendarMediaType)
if len(parts) != 1 {
t.Fatalf("expected 1 text/calendar part, got %d", len(parts))
}
if !strings.Contains(string(parts[0].Body), "SUMMARY:NEW") {
t.Errorf("expected SUMMARY:NEW, got %q", parts[0].Body)
}
// The calendar part must be a child of multipart/alternative.
alt := FindPartByMediaType(snapshot.Body, "multipart/alternative")
if alt == nil {
t.Fatal("multipart/alternative not found")
}
found := false
for _, child := range alt.Children {
if strings.EqualFold(child.MediaType, calendarMediaType) {
found = true
}
}
if !found {
t.Error("text/calendar part not inside multipart/alternative")
}
}
func TestRemoveCalendar_RootLevelCalendarBody(t *testing.T) {
// When the snapshot body is itself a text/calendar leaf (no multipart
// wrapper), removeCalendarPart must nil out snapshot.Body rather than
// trying to remove it from a parent's children slice.
snapshot := &DraftSnapshot{
Body: &Part{
MediaType: "text/calendar",
Body: []byte(fixtureCalData),
},
}
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
Ops: []PatchOp{{Op: "remove_calendar"}},
})
if err != nil {
t.Fatalf("Apply: %v", err)
}
if snapshot.Body != nil {
t.Errorf("snapshot.Body should be nil after removing root-level text/calendar, got %+v", snapshot.Body)
}
}
func TestSetCalendarPart_OnNilBodyCreatesLeaf(t *testing.T) {
snapshot := &DraftSnapshot{}
setCalendarPart(snapshot, []byte(fixtureCalData))
if snapshot.Body == nil {
t.Fatal("body should be created")
}
if !strings.EqualFold(snapshot.Body.MediaType, calendarMediaType) {
t.Errorf("expected %s leaf, got %s", calendarMediaType, snapshot.Body.MediaType)
}
}
func TestSetCalendarPart_MixedWithoutAlternativeWrapsTextChild(t *testing.T) {
// multipart/mixed with a text/html child but no alternative sub-part.
// setCalendarPart should wrap the text/html in a new alternative.
snapshot := &DraftSnapshot{
Body: &Part{
MediaType: "multipart/mixed",
Children: []*Part{
{MediaType: "text/html", Body: []byte("<p>Hi</p>")},
{MediaType: "application/pdf", Body: []byte("pdf-data")},
},
},
}
setCalendarPart(snapshot, []byte(fixtureCalData))
if snapshot.Body.MediaType != "multipart/mixed" {
t.Fatalf("root should stay multipart/mixed, got %s", snapshot.Body.MediaType)
}
alt := FindPartByMediaType(snapshot.Body, "multipart/alternative")
if alt == nil {
t.Fatal("expected a multipart/alternative child to be created")
}
if len(alt.Children) != 2 {
t.Fatalf("alternative should have 2 children, got %d", len(alt.Children))
}
if !strings.EqualFold(alt.Children[0].MediaType, "text/html") {
t.Errorf("first alternative child should be text/html, got %s", alt.Children[0].MediaType)
}
if !strings.EqualFold(alt.Children[1].MediaType, calendarMediaType) {
t.Errorf("second alternative child should be text/calendar, got %s", alt.Children[1].MediaType)
}
}
func TestSetCalendarPart_FallbackAppendsToMultipart(t *testing.T) {
// multipart/mixed with only non-text children (no text/* to wrap).
snapshot := &DraftSnapshot{
Body: &Part{
MediaType: "multipart/mixed",
Children: []*Part{
{MediaType: "application/pdf", Body: []byte("pdf-data")},
},
},
}
setCalendarPart(snapshot, []byte(fixtureCalData))
found := false
for _, child := range snapshot.Body.Children {
if strings.EqualFold(child.MediaType, calendarMediaType) {
found = true
}
}
if !found {
t.Error("text/calendar should be appended as fallback child")
}
}

View File

@@ -420,8 +420,9 @@ func (b Builder) HTMLBody(body []byte) Builder {
}
// CalendarBody sets the text/calendar body (e.g. for meeting invitations).
// May be combined with TextBody and/or HTMLBody; the resulting parts are wrapped
// in multipart/alternative.
// When combined with TextBody or HTMLBody, the calendar part is placed inside
// multipart/alternative alongside the body parts, matching the Feishu client
// convention for calendar invitation emails.
func (b Builder) CalendarBody(body []byte) Builder {
b.calendarBody = body
return b
@@ -731,6 +732,9 @@ func (b Builder) Build() ([]byte, error) {
// ── Body ───────────────────────────────────────────────────────────────────
// Full MIME hierarchy (outer layers only present when needed):
// multipart/mixed → multipart/related → multipart/alternative → body parts
//
// text/calendar lives inside multipart/alternative as an alternative
// representation of the message body, matching the Feishu client behavior.
if len(b.attachments) > 0 {
outerB := newBoundary()
writeHeader(&buf, "Content-Type", "multipart/mixed; boundary="+outerB)
@@ -809,27 +813,27 @@ func writePrimaryBody(buf *bytes.Buffer, b Builder) {
}
}
// writeAlternativeOrSingleBody writes the text body block.
// If multiple body types (text/plain, text/html, text/calendar) are present,
// they are wrapped in multipart/alternative. Otherwise a single part is written.
// writeAlternativeOrSingleBody writes the body block. When multiple content
// types coexist (text/plain, text/html, text/calendar), they are wrapped in
// multipart/alternative. text/calendar lives inside alternative as an
// alternative representation, matching the Feishu client behavior.
func writeAlternativeOrSingleBody(buf *bytes.Buffer, b Builder) {
hasText := len(b.textBody) > 0
hasHTML := len(b.htmlBody) > 0
hasCal := len(b.calendarBody) > 0
bodyCount := 0
partCount := 0
if hasText {
bodyCount++
partCount++
}
if hasHTML {
bodyCount++
partCount++
}
if hasCal {
bodyCount++
partCount++
}
switch {
case bodyCount > 1:
if partCount > 1 {
boundary := newBoundary()
writeHeader(buf, "Content-Type", "multipart/alternative; boundary="+boundary)
buf.WriteByte('\n')
@@ -840,15 +844,15 @@ func writeAlternativeOrSingleBody(buf *bytes.Buffer, b Builder) {
writeBodyPart(buf, boundary, "text/html", b.htmlBody)
}
if hasCal {
writeBodyPart(buf, boundary, "text/calendar", b.calendarBody)
fmt.Fprintf(buf, "--%s\n", boundary)
writeCalendarPart(buf, b.calendarBody)
}
fmt.Fprintf(buf, "--%s--\n", boundary)
case hasHTML:
} else if hasHTML {
writeSingleBodyPartHeaders(buf, "text/html", b.htmlBody)
case hasCal:
writeSingleBodyPartHeaders(buf, "text/calendar", b.calendarBody)
default:
// text/plain (also handles empty body)
} else if hasCal {
writeCalendarPart(buf, b.calendarBody)
} else {
writeSingleBodyPartHeaders(buf, "text/plain", b.textBody)
}
}
@@ -992,6 +996,35 @@ func writeSingleBodyPartHeaders(buf *bytes.Buffer, ct string, body []byte) {
writeFoldedBody(buf, encodeBodyContent(body, cte), lineWidthForCTE(cte))
}
// writeCalendarPart writes the text/calendar MIME part. The method= parameter
// is derived from the METHOD property in the ICS body (defaulting to REQUEST
// when absent) so that passthrough ICS with METHOD:CANCEL or METHOD:REPLY
// produce a Content-Type that matches the body.
func writeCalendarPart(buf *bytes.Buffer, body []byte) {
method := extractICSMethod(body)
if method == "" {
method = "REQUEST"
}
cte := selectCTE(body)
fmt.Fprintf(buf, "Content-Type: text/calendar; method=%s; charset=UTF-8\n", method)
fmt.Fprintf(buf, "Content-Transfer-Encoding: %s\n\n", cte)
writeFoldedBody(buf, encodeBodyContent(body, cte), lineWidthForCTE(cte))
buf.WriteByte('\n')
}
// extractICSMethod scans the ICS body for the top-level METHOD property and
// returns its value (e.g. "REQUEST", "CANCEL", "REPLY"). Returns "" when the
// property is absent so callers can apply their own default.
func extractICSMethod(body []byte) string {
for _, line := range strings.Split(string(body), "\n") {
line = strings.TrimRight(line, "\r")
if strings.HasPrefix(strings.ToUpper(line), "METHOD:") {
return strings.TrimSpace(line[7:])
}
}
return ""
}
// writeAttachmentPart writes a MIME attachment part.
// Body is always base64 (StdEncoding), written in 76-character lines per RFC 2045.
func writeAttachmentPart(buf *bytes.Buffer, att attachment) {

View File

@@ -678,6 +678,8 @@ func TestBuild_CalendarWithText(t *testing.T) {
}
eml := string(raw)
// text/calendar lives inside multipart/alternative as an alternative
// representation of the body, matching Feishu client behavior.
if !strings.Contains(eml, "multipart/alternative") {
t.Errorf("expected multipart/alternative for text+calendar:\n%s", eml)
}
@@ -1359,3 +1361,35 @@ func TestHeaderValueTabAllowed(t *testing.T) {
t.Errorf("Header with tab in value: expected no error, got %v", err)
}
}
func TestWriteCalendarPart_MethodFromBody(t *testing.T) {
cases := []struct {
name string
ics string
wantCT string
}{
{"request", "BEGIN:VCALENDAR\r\nMETHOD:REQUEST\r\nEND:VCALENDAR\r\n", "method=REQUEST"},
{"cancel", "BEGIN:VCALENDAR\r\nMETHOD:CANCEL\r\nEND:VCALENDAR\r\n", "method=CANCEL"},
{"reply", "BEGIN:VCALENDAR\r\nMETHOD:REPLY\r\nEND:VCALENDAR\r\n", "method=REPLY"},
{"no method defaults to REQUEST", "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n", "method=REQUEST"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
eml, err := New().
From("", "sender@example.com").
To("", "recipient@example.com").
Subject("Test").
Date(fixedDate).
MessageID("test-method@x").
HTMLBody([]byte("<p>hi</p>")).
CalendarBody([]byte(tc.ics)).
Build()
if err != nil {
t.Fatalf("Build: %v", err)
}
if !strings.Contains(string(eml), tc.wantCT) {
t.Errorf("expected Content-Type to contain %q\n%s", tc.wantCT, eml)
}
})
}
}

View File

@@ -25,6 +25,7 @@ import (
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
"github.com/larksuite/cli/shortcuts/mail/ics"
)
// hintIdentityFirst prints a one-line tip to stderr for read-only mail shortcuts
@@ -184,6 +185,15 @@ func printMessageOutputSchema(runtime *common.RuntimeContext) {
"attachments[].attachment_type": "Attachment type. Values: 1 = normal, 2 = large attachment",
"attachments[].is_inline": "true = inline image, false = regular attachment",
"attachments[].cid": "Content-ID for inline images (maps to <img src='cid:...'>)",
"calendar_event": "Parsed calendar invitation; present when the email contains a text/calendar part",
"calendar_event.method": "iTIP method, e.g. REQUEST, CANCEL, REPLY",
"calendar_event.uid": "Globally unique event identifier (UID property)",
"calendar_event.summary": "Event title (SUMMARY property)",
"calendar_event.start": "Event start time in RFC 3339 / ISO 8601 format (UTC)",
"calendar_event.end": "Event end time in RFC 3339 / ISO 8601 format (UTC)",
"calendar_event.location": "Event location string; omitted when not set",
"calendar_event.organizer": "Organizer email address",
"calendar_event.attendees": "List of attendee email addresses",
},
"thread_extra_fields": map[string]string{
"thread_id": "Thread ID",
@@ -1199,11 +1209,23 @@ type normalizedMessageForCompose struct {
BodyPlainText string `json:"body_plain_text"`
BodyPreview string `json:"body_preview"`
BodyHTML string `json:"body_html,omitempty"`
CalendarEvent *calendarEventOutput `json:"calendar_event,omitempty"`
Attachments []mailAttachmentOutput `json:"attachments"`
Images []mailImageOutput `json:"images"`
Warnings []warningEntry `json:"warnings,omitempty"`
}
type calendarEventOutput struct {
Method string `json:"method,omitempty"`
UID string `json:"uid,omitempty"`
Summary string `json:"summary,omitempty"`
Start string `json:"start,omitempty"`
End string `json:"end,omitempty"`
Location string `json:"location,omitempty"`
Organizer string `json:"organizer,omitempty"`
Attendees []string `json:"attendees,omitempty"`
}
// fetchAttachmentURLs fetches download URLs for the given attachment IDs in batches of 20.
// List params are embedded directly in the URL (SDK workaround for repeated query params).
// It never returns an error: failed batches/IDs are converted to structured warnings so caller can continue.
@@ -1349,6 +1371,9 @@ func buildMessageOutput(msg map[string]interface{}, html bool) map[string]interf
if html && normalized.BodyHTML != "" {
out["body_html"] = normalized.BodyHTML
}
if normalized.CalendarEvent != nil {
out["calendar_event"] = normalized.CalendarEvent
}
out["attachments"] = buildPublicAttachments(msg)
return out
@@ -1458,6 +1483,29 @@ func buildMessageForCompose(msg map[string]interface{}, urlMap map[string]string
out.BodyHTML = decodeBase64URL(strVal(msg["body_html"]))
}
// Calendar event
if bodyCalendar := strVal(msg["body_calendar"]); bodyCalendar != "" {
if decoded := decodeBase64URL(bodyCalendar); decoded != "" {
if parsed := ics.ParseEvent(decoded); parsed != nil {
ce := &calendarEventOutput{
Method: parsed.Method,
UID: parsed.UID,
Summary: parsed.Summary,
Location: parsed.Location,
Organizer: parsed.Organizer,
Attendees: parsed.Attendees,
}
if !parsed.Start.IsZero() {
ce.Start = parsed.Start.UTC().Format(time.RFC3339)
}
if !parsed.End.IsZero() {
ce.End = parsed.End.UTC().Format(time.RFC3339)
}
out.CalendarEvent = ce
}
}
}
// Attachments
attachments := make([]mailAttachmentOutput, 0)
images := make([]mailImageOutput, 0)
@@ -1568,6 +1616,7 @@ type composeSourceMessage struct {
ForwardAttachments []forwardSourceAttachment
InlineImages []inlineSourcePart
FailedAttachmentIDs map[string]bool
OriginalCalendarICS []byte // raw ICS bytes from body_calendar (for forward passthrough)
}
// fetchComposeSourceMessage loads a message via the +message pipeline and converts it
@@ -1577,6 +1626,12 @@ func fetchComposeSourceMessage(runtime *common.RuntimeContext, mailboxID, messag
if err != nil {
return composeSourceMessage{}, err
}
var originalCalICS []byte
if bodyCalendar := strVal(msg["body_calendar"]); bodyCalendar != "" {
if decoded := decodeBase64URL(bodyCalendar); decoded != "" {
originalCalICS = []byte(decoded)
}
}
attIDs := extractAttachmentIDs(msg)
urlMap, warnings := fetchAttachmentURLs(runtime, mailboxID, messageID, attIDs)
failedIDs := make(map[string]bool)
@@ -1592,6 +1647,7 @@ func fetchComposeSourceMessage(runtime *common.RuntimeContext, mailboxID, messag
ForwardAttachments: toForwardSourceAttachments(out),
InlineImages: toInlineSourceParts(out),
FailedAttachmentIDs: failedIDs,
OriginalCalendarICS: originalCalICS,
}, nil
}
@@ -2252,6 +2308,21 @@ func inlineSpecFilePaths(specs []InlineSpec) []string {
return paths
}
// validateEventSendTimeExclusion checks that --send-time and --event-* are not
// used together. This is enforced here (in Validate, before Execute) because the
// Shortcut framework does not expose a cobra-level hook for MarkFlagsMutuallyExclusive.
func validateEventSendTimeExclusion(runtime *common.RuntimeContext) error {
if runtime.Str("send-time") == "" {
return nil
}
for _, f := range []string{"event-summary", "event-start", "event-end", "event-location"} {
if runtime.Str(f) != "" {
return common.FlagErrorf("--send-time and --event-* are mutually exclusive: a calendar invitation must be sent immediately so recipients can respond before the event")
}
}
return nil
}
// validateSendTime checks that --send-time, if provided, requires --confirm-send,
// is a valid Unix timestamp in seconds, and is at least 5 minutes in the future.
func validateSendTime(runtime *common.RuntimeContext) error {
@@ -2391,3 +2462,143 @@ func validateComposeInlineAndAttachments(fio fileio.FileIO, attachFlag, inlineFl
}
return nil
}
// buildCalendarBodyFromArgs builds ICS from explicit string arguments (for draft-edit).
// Callers are expected to have pre-validated startStr/endStr via parseEventTimeRange;
// parse errors are silently ignored here and produce a zero-time DTSTART/DTEND.
func buildCalendarBodyFromArgs(summary, startStr, endStr, location, senderEmail, toAddrs, ccAddrs string) []byte {
if summary == "" {
return nil
}
start, _ := parseISO8601(startStr)
end, _ := parseISO8601(endStr)
var attendees []ics.Address
for _, addr := range parseNetAddrs(toAddrs) {
if addr.Address != "" {
attendees = append(attendees, ics.Address{Name: addr.Name, Email: addr.Address})
}
}
for _, addr := range parseNetAddrs(ccAddrs) {
if addr.Address != "" {
attendees = append(attendees, ics.Address{Name: addr.Name, Email: addr.Address})
}
}
return ics.Build(ics.Event{
Summary: summary,
Location: location,
Start: start,
End: end,
Organizer: ics.Address{Email: senderEmail},
Attendees: attendees,
})
}
// joinAddresses joins draft Address list into comma-separated string.
func joinAddresses(addrs []draftpkg.Address) string {
if len(addrs) == 0 {
return ""
}
parts := make([]string, len(addrs))
for i, a := range addrs {
parts[i] = a.Address
}
return strings.Join(parts, ",")
}
// Calendar event flag definitions, shared by all compose shortcuts.
// Declared as individual vars (like priorityFlag and signatureFlag) so
// callers can list them explicitly in their Flags slice without relying
// on slice-index access.
var (
eventSummaryFlag = common.Flag{Name: "event-summary", Desc: "Calendar event title. Setting this enables calendar invitation mode."}
eventStartFlag = common.Flag{Name: "event-start", Desc: "Event start time (ISO 8601, e.g. 2026-04-20T14:00+08:00). Required when --event-summary is set."}
eventEndFlag = common.Flag{Name: "event-end", Desc: "Event end time (ISO 8601). Required when --event-summary is set."}
eventLocationFlag = common.Flag{Name: "event-location", Desc: "Event location (optional)."}
)
// validateEventFlags checks that --event-summary, --event-start, --event-end are either all set or all empty.
func validateEventFlags(runtime *common.RuntimeContext) error {
summary := runtime.Str("event-summary")
start := runtime.Str("event-start")
end := runtime.Str("event-end")
location := runtime.Str("event-location")
hasAny := summary != "" || start != "" || end != "" || location != ""
hasAll := summary != "" && start != "" && end != ""
if hasAny && !hasAll {
return fmt.Errorf("--event-summary, --event-start, and --event-end must all be provided together")
}
if summary == "" {
return nil
}
if _, _, err := parseEventTimeRange(start, end); err != nil {
return prefixEventRangeError("--event-", err)
}
return nil
}
// parseEventTimeRange parses start/end ISO 8601 strings and verifies that
// end is strictly after start. Shared by validateEventFlags (compose path)
// and buildDraftEditPatch (draft-edit path) so the rules stay in one place.
func parseEventTimeRange(start, end string) (time.Time, time.Time, error) {
startT, err := parseISO8601(start)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("start: invalid ISO 8601 time %q", start)
}
endT, err := parseISO8601(end)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("end: invalid ISO 8601 time %q", end)
}
if !endT.After(startT) {
return time.Time{}, time.Time{}, fmt.Errorf("end time must be after start time")
}
return startT, endT, nil
}
// prefixEventRangeError rewrites parseEventTimeRange's "start:" / "end:"
// error with the caller's flag-name prefix so users see the exact flag
// that caused the failure.
func prefixEventRangeError(flagPrefix string, err error) error {
msg := err.Error()
switch {
case strings.HasPrefix(msg, "start: "):
return fmt.Errorf("%sstart: %s", flagPrefix, strings.TrimPrefix(msg, "start: "))
case strings.HasPrefix(msg, "end: "):
return fmt.Errorf("%send: %s", flagPrefix, strings.TrimPrefix(msg, "end: "))
default:
return err
}
}
// parseISO8601 parses common ISO 8601 time formats.
func parseISO8601(s string) (time.Time, error) {
formats := []string{
time.RFC3339,
"2006-01-02T15:04:05Z07:00",
"2006-01-02T15:04Z07:00",
"2006-01-02T15:04:05",
"2006-01-02T15:04",
"2006-01-02",
}
for _, f := range formats {
if t, err := time.Parse(f, s); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("cannot parse %q as ISO 8601", s)
}
// buildCalendarBody generates an ICS VCALENDAR from compose flags and returns the bytes.
// Returns nil if --event-summary is not set.
func buildCalendarBody(runtime *common.RuntimeContext, senderEmail string, toAddrs, ccAddrs string) []byte {
return buildCalendarBodyFromArgs(
runtime.Str("event-summary"),
runtime.Str("event-start"),
runtime.Str("event-end"),
runtime.Str("event-location"),
senderEmail, toAddrs, ccAddrs,
)
}

View File

@@ -1085,7 +1085,39 @@ func TestValidateSendTime_Valid(t *testing.T) {
}
}
// TestParsePriority verifies parse priority.
func TestValidateEventSendTimeExclusion(t *testing.T) {
future := strconv.FormatInt(time.Now().Unix()+10*60, 10)
cases := []struct {
name string
eventFlag string
eventVal string
}{
{"event-summary triggers exclusion", "event-summary", "Team meeting"},
{"event-start triggers exclusion", "event-start", "2026-05-01T10:00+08:00"},
{"event-end triggers exclusion", "event-end", "2026-05-01T11:00+08:00"},
{"event-location triggers exclusion", "event-location", "Room 5F"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("send-time", "", "")
for _, f := range []string{"event-summary", "event-start", "event-end", "event-location"} {
cmd.Flags().String(f, "", "")
}
_ = cmd.Flags().Set("send-time", future)
_ = cmd.Flags().Set(tc.eventFlag, tc.eventVal)
rt := &common.RuntimeContext{Cmd: cmd}
err := validateEventSendTimeExclusion(rt)
if err == nil {
t.Fatalf("expected error when --send-time and --%s are both set", tc.eventFlag)
}
if !strings.Contains(err.Error(), "--event-*") {
t.Errorf("expected error to mention --event-*, got: %v", err)
}
})
}
}
func TestParsePriority(t *testing.T) {
cases := []struct {
name string
@@ -1334,7 +1366,6 @@ func newRequestReceiptRuntime(t *testing.T, requestReceipt bool) *common.Runtime
return &common.RuntimeContext{Cmd: cmd}
}
// TestRequireSenderForRequestReceipt verifies require sender for request receipt.
func TestRequireSenderForRequestReceipt(t *testing.T) {
cases := []struct {
name string
@@ -1365,7 +1396,6 @@ func TestRequireSenderForRequestReceipt(t *testing.T) {
}
}
// TestShellQuoteForHint verifies shell quote for hint.
func TestShellQuoteForHint(t *testing.T) {
cases := []struct {
name string
@@ -1391,7 +1421,6 @@ func TestShellQuoteForHint(t *testing.T) {
}
}
// TestSanitizeForSingleLine verifies sanitize for single line.
func TestSanitizeForSingleLine(t *testing.T) {
cases := []struct {
name string
@@ -1415,7 +1444,6 @@ func TestSanitizeForSingleLine(t *testing.T) {
}
}
// TestValidateHeaderAddress verifies validate header address.
func TestValidateHeaderAddress(t *testing.T) {
cases := []struct {
name string
@@ -1447,3 +1475,199 @@ func TestValidateHeaderAddress(t *testing.T) {
})
}
}
// ---------------------------------------------------------------------------
// parseEventTimeRange
// ---------------------------------------------------------------------------
func TestParseEventTimeRange_OK(t *testing.T) {
s, e, err := parseEventTimeRange("2026-04-25T14:00+08:00", "2026-04-25T15:00+08:00")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !e.After(s) {
t.Errorf("end should be after start; got start=%v end=%v", s, e)
}
}
func TestParseEventTimeRange_EndBeforeStart(t *testing.T) {
_, _, err := parseEventTimeRange("2026-04-25T15:00+08:00", "2026-04-25T14:00+08:00")
if err == nil {
t.Fatal("expected error when end < start")
}
if !strings.Contains(err.Error(), "end time must be after start time") {
t.Errorf("unexpected error message: %v", err)
}
}
func TestParseEventTimeRange_EndEqualsStart(t *testing.T) {
_, _, err := parseEventTimeRange("2026-04-25T14:00+08:00", "2026-04-25T14:00+08:00")
if err == nil {
t.Fatal("expected error when end == start (zero duration)")
}
}
func TestParseEventTimeRange_InvalidStart(t *testing.T) {
_, _, err := parseEventTimeRange("not-a-time", "2026-04-25T15:00+08:00")
if err == nil || !strings.Contains(err.Error(), "start: invalid ISO 8601") {
t.Errorf("expected start parse error, got: %v", err)
}
}
func TestParseEventTimeRange_InvalidEnd(t *testing.T) {
_, _, err := parseEventTimeRange("2026-04-25T14:00+08:00", "not-a-time")
if err == nil || !strings.Contains(err.Error(), "end: invalid ISO 8601") {
t.Errorf("expected end parse error, got: %v", err)
}
}
func TestPrefixEventRangeError(t *testing.T) {
start := fmt.Errorf("start: invalid ISO 8601 time %q", "x")
if got := prefixEventRangeError("--event-", start).Error(); got != `--event-start: invalid ISO 8601 time "x"` {
t.Errorf("got %q", got)
}
end := fmt.Errorf("end: invalid ISO 8601 time %q", "x")
if got := prefixEventRangeError("--set-event-", end).Error(); got != `--set-event-end: invalid ISO 8601 time "x"` {
t.Errorf("got %q", got)
}
// Non-prefixed error passes through unchanged.
other := fmt.Errorf("end time must be after start time")
if got := prefixEventRangeError("--event-", other).Error(); got != "end time must be after start time" {
t.Errorf("got %q", got)
}
}
// ---------------------------------------------------------------------------
// validateEventFlags (runtime-backed)
// ---------------------------------------------------------------------------
func newEventFlagsRuntime(t *testing.T, summary, start, end string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("event-summary", "", "")
cmd.Flags().String("event-start", "", "")
cmd.Flags().String("event-end", "", "")
cmd.Flags().String("event-location", "", "")
if summary != "" {
_ = cmd.Flags().Set("event-summary", summary)
}
if start != "" {
_ = cmd.Flags().Set("event-start", start)
}
if end != "" {
_ = cmd.Flags().Set("event-end", end)
}
return &common.RuntimeContext{Cmd: cmd}
}
func TestValidateEventFlags_AllEmptyOK(t *testing.T) {
rt := newEventFlagsRuntime(t, "", "", "")
if err := validateEventFlags(rt); err != nil {
t.Errorf("expected no error, got %v", err)
}
}
func TestValidateEventFlags_AllSetOK(t *testing.T) {
rt := newEventFlagsRuntime(t, "Meeting", "2026-04-25T10:00+08:00", "2026-04-25T11:00+08:00")
if err := validateEventFlags(rt); err != nil {
t.Errorf("expected no error, got %v", err)
}
}
func TestValidateEventFlags_PartialRejected(t *testing.T) {
cases := []struct {
name string
summary string
start string
end string
}{
{"only_summary", "Meeting", "", ""},
{"only_start", "", "2026-04-25T10:00+08:00", ""},
{"only_end", "", "", "2026-04-25T11:00+08:00"},
{"missing_end", "Meeting", "2026-04-25T10:00+08:00", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rt := newEventFlagsRuntime(t, tc.summary, tc.start, tc.end)
err := validateEventFlags(rt)
if err == nil || !strings.Contains(err.Error(), "must all be provided together") {
t.Errorf("expected 'all together' error, got %v", err)
}
})
}
}
func TestValidateEventFlags_EndBeforeStartRejected(t *testing.T) {
rt := newEventFlagsRuntime(t, "Meeting", "2026-04-25T11:00+08:00", "2026-04-25T10:00+08:00")
err := validateEventFlags(rt)
if err == nil || !strings.Contains(err.Error(), "after start") {
t.Errorf("expected end-after-start error, got %v", err)
}
}
func TestValidateEventFlags_InvalidTimeFormatRejected(t *testing.T) {
rt := newEventFlagsRuntime(t, "Meeting", "not-a-time", "2026-04-25T11:00+08:00")
err := validateEventFlags(rt)
if err == nil || !strings.Contains(err.Error(), "--event-start") {
t.Errorf("expected --event-start error, got %v", err)
}
}
// ---------------------------------------------------------------------------
// buildCalendarBodyFromArgs
// ---------------------------------------------------------------------------
func TestBuildCalendarBodyFromArgs_EmptySummaryReturnsNil(t *testing.T) {
got := buildCalendarBodyFromArgs("", "2026-04-25T10:00+08:00", "2026-04-25T11:00+08:00", "", "sender@example.com", "to@example.com", "")
if got != nil {
t.Errorf("expected nil for empty summary, got %d bytes", len(got))
}
}
func TestBuildCalendarBodyFromArgs_IncludesSummaryAndAddresses(t *testing.T) {
got := buildCalendarBodyFromArgs(
"Product Review",
"2026-04-25T14:00+08:00",
"2026-04-25T15:00+08:00",
"5F Room",
"sender@example.com",
"a@example.com,b@example.com",
"c@example.com",
)
if got == nil {
t.Fatal("expected non-nil ICS bytes")
}
s := string(got)
checks := []string{
"BEGIN:VCALENDAR",
"SUMMARY:Product Review",
"LOCATION:5F Room",
"sender@example.com",
"a@example.com",
"b@example.com",
"c@example.com",
}
for _, want := range checks {
if !strings.Contains(s, want) {
t.Errorf("missing %q in generated ICS:\n%s", want, s)
}
}
}
func TestBuildCalendarBodyFromArgs_NoCcWorks(t *testing.T) {
got := buildCalendarBodyFromArgs(
"Meeting",
"2026-04-25T10:00+08:00",
"2026-04-25T11:00+08:00",
"",
"sender@example.com",
"to@example.com",
"",
)
if got == nil {
t.Fatal("expected non-nil ICS bytes")
}
if !strings.Contains(string(got), "to@example.com") {
t.Error("attendee missing")
}
}

View File

@@ -0,0 +1,180 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package ics provides RFC 5545 iCalendar generation and parsing for mail calendar invitations.
package ics
import (
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
)
// Event holds the data needed to generate an ICS VCALENDAR invitation.
type Event struct {
UID string // auto-generated if empty
Summary string // SUMMARY (required)
Location string // LOCATION (optional)
Start time.Time // DTSTART (required)
End time.Time // DTEND (required)
Organizer Address // ORGANIZER
Attendees []Address // ATTENDEE list (To + Cc, excluding Bcc)
}
// Address represents a name + email pair for ORGANIZER / ATTENDEE.
type Address struct {
Name string
Email string
}
// Build generates a RFC 5545 VCALENDAR byte slice with METHOD:REQUEST.
// The output is suitable for use as a text/calendar MIME part.
func Build(event Event) []byte {
uid := event.UID
if uid == "" {
uid = uuid.New().String()
}
now := time.Now().UTC()
nowICS := formatICSTime(now)
var b strings.Builder
b.WriteString("BEGIN:VCALENDAR\r\n")
b.WriteString("CALSCALE:GREGORIAN\r\n")
b.WriteString("VERSION:2.0\r\n")
b.WriteString("PRODID:-//Lark CLI//EN\r\n")
b.WriteString("METHOD:REQUEST\r\n")
b.WriteString("X-LARK-MAIL-DRAFT:TRUE\r\n")
b.WriteString("BEGIN:VEVENT\r\n")
writeFolded(&b, "UID", uid)
writeFolded(&b, "DTSTAMP", nowICS)
writeFolded(&b, "CREATED", nowICS)
writeFolded(&b, "LAST-MODIFIED", nowICS)
writeFolded(&b, "DTSTART", formatICSTime(event.Start.UTC()))
writeFolded(&b, "DTEND", formatICSTime(event.End.UTC()))
writeFolded(&b, "SUMMARY", escapeTextValue(event.Summary))
if event.Location != "" {
writeFolded(&b, "LOCATION", escapeTextValue(event.Location))
}
b.WriteString("STATUS:CONFIRMED\r\n")
b.WriteString("TRANSP:OPAQUE\r\n")
b.WriteString("SEQUENCE:0\r\n")
if event.Organizer.Email != "" {
organizer := "ORGANIZER;ROLE=CHAIR"
if event.Organizer.Name != "" {
organizer += ";CN=" + quoteCNParam(event.Organizer.Name)
} else {
organizer += ";CN=" + quoteCNParam(event.Organizer.Email)
}
writeFolded(&b, organizer, mailtoScheme+sanitizeMailtoAddress(event.Organizer.Email))
}
for _, a := range event.Attendees {
attendee := "ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CUTYPE=INDIVIDUAL"
if a.Name != "" {
attendee += ";CN=" + quoteCNParam(a.Name)
} else {
attendee += ";CN=" + quoteCNParam(a.Email)
}
attendee += ";PARTSTAT=NEEDS-ACTION"
writeFolded(&b, attendee, mailtoScheme+sanitizeMailtoAddress(a.Email))
}
b.WriteString("END:VEVENT\r\n")
b.WriteString("END:VCALENDAR\r\n")
return []byte(b.String())
}
// formatICSTime formats a time.Time as ICS UTC: YYYYMMDDTHHMMSSZ.
func formatICSTime(t time.Time) string {
return t.Format("20060102T150405Z")
}
// escapeTextValue escapes a string for use as an ICS TEXT value per RFC 5545
// §3.3.11: backslash, newline, semicolon, and comma carry structural meaning
// and must be escaped. Applied to SUMMARY, LOCATION, DESCRIPTION etc. — not
// to identifiers (UID), date-times (DTSTART/DTEND), or URIs.
//
// Without this, a user-supplied summary containing a newline or colon would
// let the payload inject a fake property line, e.g.
//
// --event-summary "foo\nDTSTART:20000101T000000Z"
//
// would turn into a second DTSTART line after folding.
func escapeTextValue(s string) string {
// Normalise CR / CRLF so downstream only sees LF.
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
// Order matters: escape backslash first so its own replacement is not
// picked up by later rules.
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, ";", `\;`)
s = strings.ReplaceAll(s, ",", `\,`)
return s
}
// quoteCNParam wraps a CN parameter value in double-quotes per RFC 5545 §3.2
// when the value contains characters that are not allowed in an unquoted
// paramtext (, ; :). Characters that are illegal inside a quoted-string are
// stripped: DQUOTE (%x22) is excluded by QSAFE-CHAR, and control characters
// (%x00%x08, %x0A%x1F, %x7F) would break the property line structure.
func quoteCNParam(s string) string {
s = strings.Map(func(r rune) rune {
if r == '"' || r < 0x09 || (r >= 0x0A && r <= 0x1F) || r == 0x7F {
return -1
}
return r
}, s)
if strings.ContainsAny(s, ",:;") {
return `"` + s + `"`
}
return s
}
// writeFolded writes a property line with RFC 5545 line folding (75-octet limit).
// Long lines are folded by inserting CRLF + space at UTF-8 character boundaries.
// Continuation lines begin with a single SPACE (1 octet), so their content is
// limited to 74 octets to keep the total physical line at ≤ 75 octets.
func writeFolded(b *strings.Builder, name, value string) {
line := fmt.Sprintf("%s:%s", name, value)
const maxLineOctets = 75 // RFC 5545 §3.1: lines SHOULD NOT be longer than 75 octets
limit := maxLineOctets
for len(line) > limit {
// Find the last complete UTF-8 character that fits within the limit.
cut := 0
for i := 0; i < len(line); {
_, size := utf8.DecodeRuneInString(line[i:])
if i+size > limit {
break
}
i += size
cut = i
}
if cut == 0 {
// Single character exceeds limit (shouldn't happen in practice).
cut = limit
}
b.WriteString(line[:cut])
b.WriteString("\r\n ")
line = line[cut:]
limit = maxLineOctets - 1 // continuation lines: 1-octet SPACE + 74 content = 75
}
b.WriteString(line)
b.WriteString("\r\n")
}
// sanitizeMailtoAddress strips control characters (CR, LF, and other chars
// below 0x20 or equal to 0x7F) from an email address before embedding it in a
// MAILTO: URI value. Prevents property-injection attacks analogous to the CN
// parameter protection in quoteCNParam.
func sanitizeMailtoAddress(s string) string {
return strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7F {
return -1
}
return r
}, s)
}

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