Compare commits

..

29 Commits

Author SHA1 Message Date
AlbertSun
054ff9339b feat(sec): integrate enterprise cli 2026-05-22 16:43:24 +08:00
AlbertSun
bdb0cd14d1 feat(sec): fetch lark-sec-cli install manifest via OAPI
Replace the embedded bootstrap manifest with a typed OAPI call to
GET /open-apis/security_plugin/v1/sec_cli/manifest, resolving the
download URL per-platform/per-arch against the live release set.
TAT auth flows through the existing credential chain; an x-tt-env
header is injected when LARKSUITE_CLI_X_TT_ENV is set, for BOE
routing.

Drop the standalone `sec install` verb — `sec run --auto-install`
(default on) makes it redundant. Add a persistent --verbose / -v
flag on the sec parent, inherited by every subcommand, that emits
step-by-step trace output on stderr.

bootstrap.json and bootstrap.go remain in-tree as dead code; they
will be removed in a follow-up cleanup.
2026-05-20 20:29:24 +08:00
AlbertSun
6c41d12792 feat(sec): add lark-sec-cli bootstrap install lifecycle
Scaffold the lark-cli sec subsystem: the `sec` command tree
(install, run, stop, status, config init) and the internal/sec
package that drives it.

The bootstrap manifest is embedded at build time as JSON, mapping
(platform, arch, region) to download URLs. The installer resolves
the right artifact for the current host, downloads with optional
SHA256 verification, extracts into versions/<version>/, swaps the
`current` symlink atomically (copy on Windows), and writes
state.json.

`sec run` enables the binary as a user-level system service
(launchd / systemd-user / registry+VBS) so the OS supervises
restarts. After this first install, lark-sec-cli takes over its
own upgrade lifecycle.
2026-05-20 20:29:24 +08:00
zhaojunchang
2286937366 feat(secplugin): add security plugin for proxy and auth token handling 2026-05-20 11:52:31 +08:00
河伯
d793790807 feat(doc): warn before overwrite when document contains whiteboard or file blocks (#825)
* feat(doc): warn before overwrite when document contains whiteboard or file blocks

Before executing an overwrite in v1 mode, pre-fetch the current document
and scan the Markdown for <whiteboard> and <file> resource blocks. If any
are found, print a warning to stderr listing the counts and suggesting the
user take a backup with `docs +fetch` first.

Overwrite replaces the entire document and cannot reconstruct these blocks
from Markdown; previously the data was lost with no indication to the caller.
The check is best-effort: a failed pre-fetch silently skips the guard rather
than blocking the overwrite.

* test(doc): add validateSelectionByTitleV1 tests and drop redundant empty-md guard in warnOverwriteResourceBlocks

* fix(doc): use regex for resource block detection, add latency/coverage comments, document skip_task_detail purpose
2026-05-20 11:28:57 +08:00
liangshuo-1
13411d9a51 chore(release): v1.0.34 (#972)
Change-Id: I0908c20f6ab9cf76a5d75cc1c81871591aa6a841
2026-05-19 20:03:56 +08:00
search_zhuhao
939b7b6fb6 docs(lark-vc): clarify meeting search evidence flow (#866)
* docs(lark-vc): clarify meeting search evidence flow

Change-Id: I997ec0654b9448eb0cc6ed7c15493dd2316ffa39

* docs(lark-vc): clarify pagination precedence

Change-Id: Icdcc38db2ce3db3a3371c6451624fd52a71170e3
2026-05-19 19:41:12 +08:00
SunPeiYang996
a4c5ec99c8 docs(drive): clarify add comment constraints (#967)
Change-Id: I637cfaf2d6a228c43e3b3041fef8e030bc80b9d0
2026-05-19 18:09:28 +08:00
fangshuyu-768
7c54f9b023 feat(drive): switch markdown export to V2 docs_ai fetch API (#948)
Switch `drive +export --file-extension markdown` from the legacy V1
GET /open-apis/docs/v1/content API to the V2
POST /open-apis/docs_ai/v1/documents/{token}/fetch API for
higher-quality Lark-flavored Markdown output.

- Update DryRun and Execute paths to use V2 endpoint with JSON body
- Add docx:document:readonly scope for the new API
- Validate V2 response structure (fail fast on missing document/content)
- Encode token in URL path via validate.EncodePathSegment
- Update unit tests and add V2 response validation error path tests
- Add E2E dry-run test for markdown export path
- Update skill documentation
2026-05-19 17:53:54 +08:00
liangshuo-1
e6bc292575 fix(identitydiag): harden verify path and tighten status semantics (#961)
* fix(identitydiag): harden verify path and tighten status semantics

Follow-ups to #957:

- bound bot/user verify calls with a 10s timeout (mirrors the doctor
  endpoint probe) so a hanging server cannot wedge `auth status --verify`
  or `doctor`
- return StatusNotConfigured (not StatusMissing) when the user-identity
  path is blocked by missing app config, matching the bot side
- surface the `{code, msg}` envelope on bot-info HTTP 4xx responses so
  callers see why bot auth was rejected, not just the bare HTTP code
- introduce identity{User,Bot,None} constants in cmd/auth/status.go and
  use the exported StatusMessage() in the human-readable note instead of
  raw status codes like "not_configured"
- collapse the duplicated verify-failed identity construction in the
  user path into a local helper
- cover the new failure paths with unit tests (HTTP 4xx with envelope,
  business error code, user server-rejected, expired user token,
  strict-mode user-only, missing app config for user)

Change-Id: I581348a65f15b1452a6f48a3e3245d09257314ac

* fix(identitydiag): decode bot/v3/info from "bot" field, not "data"

`/open-apis/bot/v3/info` returns `{code, msg, bot: {...}}` — the bot
payload is under `bot`, not `data` as the newer Lark API convention
would suggest. The decoder was reading from a non-existent `data`
field, so `envelope.Data.OpenID` was always empty and every successful
verify was reported as `Bot identity: verify failed: open_id is empty`.

The pre-existing test mocks used `{"data": {...}}` matching the buggy
decoder, so unit tests passed while production reads of every Lark
account failed verification.

Fix:
- change the JSON tag on the envelope from `json:"data"` to `json:"bot"`
- update mocks in identitydiag and cmd/auth/status tests to emit `bot`

Verified locally: `lark-cli doctor` now reports `bot_identity: pass`
for both a normal account and a bot-only profile, restoring the
behavior that #957 set out to deliver.

Change-Id: Ib26dfdd5a0cc37d2d62537ae2bf5e854e67cb83c

* fix(shortcuts/common): decode bot/v3/info from "bot" field, not "data"

Same schema bug as the one fixed in identitydiag — `RuntimeContext.
fetchBotInfo` reads from a non-existent "data" key, so every successful
call would report "open_id is empty" once a caller starts depending on
it.

There are no production callers of `RuntimeContext.BotInfo()` yet
(only tests + the `TestNewRuntimeContextWithBotInfo` helper), so this
bug is dormant — but the pre-existing tests pass with the same wrong
schema in their mocks, so the first real consumer would silently break.

Fix: tag `json:"data"` → `json:"bot"` plus aligning the four mock
fixtures in runner_botinfo_test.go. The Go field name `Data` is kept
to minimize the diff; only the JSON contract is corrected.

Change-Id: I11e1e871603e5349f8df29b1d58e35d07b628dfd
2026-05-19 15:50:40 +08:00
fangshuyu-768
4aa61db8b2 feat(drive): add +inspect shortcut for document URL inspection with wiki unwrapping (#947)
* feat(drive): add +inspect shortcut for document URL inspection with wiki unwrapping

Implements #662: `lark-cli drive +inspect --url <url>` inspects any
Lark/Feishu document URL to get its type, title, and canonical token,
with automatic wiki URL unwrapping via get_node API.

- Add ParseResourceURL (inverse of BuildResourceURL) in common
- Extract FetchDriveMetaTitle as public shared helper
- Add drive +inspect shortcut with wiki unwrapping support
- Add skill reference docs and update SKILL.md
- Dry-run E2E tests for docx URL, wiki URL, and bare token

* refactor: move host validation from ParseResourceURL to +inspect

ParseResourceURL is a general-purpose URL parser that should not
hardcode domain lists — future Lark domains would silently break.
Move isLarkHost/larkHostSuffixes to drive_inspect.go where host
validation is a business decision of the +inspect command.
Add E2E test for non-Lark host with Lark-like path.

* refactor: remove host validation from +inspect

Lark supports custom enterprise domains, so a hardcoded suffix list
can never be exhaustive and would falsely reject valid URLs.
Path-based matching in ParseResourceURL is sufficient; invalid URLs
will fail naturally at the API call stage.
2026-05-19 15:19:35 +08:00
liujinkun2025
28c66be199 fix(wiki): surface real node url for +node-create / +node-copy (#960)
* fix(wiki): surface real node url for +node-create / +node-copy

The create-node and copy-node OpenAPI responses carry a real `url`
field (present in practice though absent from the documented schema).
Both shortcuts ignored it: +node-create synthesized a link via
BuildResourceURL, and +node-copy emitted no URL at all.

Parse `url` into the shared wikiNodeRecord and add a wikiNodeURL helper
that prefers the response url, falling back to BuildResourceURL only
when it is blank. Wire +node-create and +node-copy to the helper so
both surface the canonical link when available.

Change-Id: I0ca5f91b02c24e81d083793e6a8e4f8c966aeec3

* refactor(wiki): move wikiNodeURL to shared wiki_helpers.go

The helper is consumed by both +node-create and +node-copy, so its
placement should reflect the broader usage rather than living in the
create command's file. Pure move; no behavior change.

Change-Id: I9990c12da042f631fe2519911c6a9d663fd5c22b
2026-05-19 15:19:15 +08:00
xzcong0820
0e70b056f8 feat(mail): bot+mailbox=me validation and dynamic --as help tests (#895)
* feat(mail): bot+mailbox=me validation and dynamic --as help tests

Add validateBotMailboxNotMe helper to shortcuts/mail/helpers.go and
wire it as a Validate callback into +message, +messages, +thread and
+triage, so bot identity combined with the default --mailbox me is
rejected early with a clear fixup hint instead of a late opaque API
error.

The --as help text was already dynamic via AddShortcutIdentityFlag;
add TC-10/TC-11 tests in internal/cmdutil/identity_flag_test.go to
pin that behaviour, and TC-1 through TC-9 in
shortcuts/mail/mail_shortcut_validation_test.go to cover the new
Validate callbacks.

+watch is excluded: its AuthTypes is ["user"], so bot is never valid.

sprint: S2

* test(cmdutil): add Hidden and DefValue assertions to identity flag tests

* fix(mail): add bot+mailbox=me validation to +template-create and +template-update

* fix(mail): add bot+mailbox=me validation to +template-update

* fix(mail): gofmt mail_template_create.go

* fix(mail): gofmt mail_template_update.go

* fix(mail): skip bot+mailbox=me check for print-patch-template local path
2026-05-19 15:07:43 +08:00
search_zhuhao
95ffff4212 docs(lark-im): clarify message activity search (#865)
* docs(lark-im): clarify message activity search

Change-Id: I2a9a928aab2354dfaf103cdf53add435088ff9e2

* docs(lark-im): keep bot history guidance additive

Change-Id: I6d89610db9f9d1488f207dcc6b92f7aada839f8b
2026-05-19 14:37:28 +08:00
xzcong0820
e511404065 feat(mail): expose draft priority in --inspect projection and document --set-priority (#779)
Add a Priority field to DraftProjection populated from the EML header pair
X-Cli-Priority (CLI/OAPI primary) → X-Priority (RFC fallback for IMAP-回灌
historical drafts), with case-insensitive lookup via the existing
headerValue helper and a local mapping table aligned with the backend
gopkg/mail_priority.PriorityValueToType vocabulary. When neither header is
present (the symmetric read of --set-priority normal=remove_header) the
projection emits "unknown" so agents have a stable read-side surface.

Append one notes entry to buildDraftEditPatchTemplate documenting the
--set-priority flag and the X-Cli-Priority translation contract.

The write-side (--set-priority flag, parsePriority helper, translation
branch in mail_draft_edit.go, EML header target) is unchanged — already
shipped on master.

sprint: S4
2026-05-19 14:02:01 +08:00
RZERO
b8469d2dc6 fix(auth): split bot and user identity diagnostics (#957) 2026-05-19 13:46:57 +08:00
liangshuo-1
afa084e7a4 chore(lint): exclude bidichk from test files (#959)
Test files legitimately need to construct dangerous Unicode inputs
(RLO, ZWSP, BOM, etc.) to verify validation logic rejects them.
bidichk treats decoded \u escape literals as Trojan Source risks,
which is a false positive for intentional test data.

Change-Id: I555028a992ab008da16129eb41075c333d0099b8
2026-05-19 13:26:39 +08:00
zgz2048
3354494579 fix: address Base attachment review follow-ups (#958) 2026-05-19 13:20:07 +08:00
zgz2048
2bb69d1942 feat: support Base attachment APIs (#887)
* feat: support base attachment APIs

* fix: handle duplicate base attachment downloads

* fix: remove unused attachment token helper
2026-05-19 11:52:47 +08:00
liujinkun2025
c4fb7006d2 feat(wiki): add +node-get / +node-delete / +space-create shortcuts (#904)
- +node-get: wrap wiki.spaces.get_node; accepts node_token, obj_token,
  or a Lark URL (URL path auto-infers obj_type); formatted output with
  creator / updated_at. No synthesized url — get_node returns none and a
  BuildResourceURL fallback is a non-canonical link that misleads in a
  read/confirm command (sibling read shortcuts omit it too)
- +node-delete: wrap space.node delete; high-risk-write (--yes gated),
  async delete-node task polling, auto-resolves space_id via get_node
  when --space-id omitted, actionable hints for codes 131011 / 131003.
  The delete-node task result lives under the gateway's generic
  `simple_task_result` key (NOT `delete_node_result`)
- +space-create: wrap spaces.create; user-only identity, --name
  required (no empty-name spaces), flattened space output, no url
- factor the shared wiki async-task poll loop into wiki_async_task.go;
  preserve upstream Lark Detail.Code on poll exhaustion (no longer
  rebuilt via lossy ErrWithHint)
- drive +task_result: add wiki_delete_node scenario so +node-delete's
  async-timeout next_command actually resolves
- skill docs: reference pages for the 3 new shortcuts + SKILL.md
  shortcuts table (no raw nodes.delete API exists — it's shortcut-only,
  so it is intentionally absent from API Resources / permission table);
  drop the circular TestWikiShortcutsIncludeAllCommands change-detector

Change-Id: I316f78290cec5bc50f80d629173e3bf2a35dd005
2026-05-19 11:21:54 +08:00
afengzi
583349e572 fix(docs): clarify replace_all selection errors (#954) 2026-05-19 10:54:49 +08:00
Yuxuan Zhao
315e0ab50c test: verify e2e resource cleanup (#949)
Change-Id: I3e04a82f622853549f11ac49cbd6fefa194c7c56
2026-05-18 22:35:10 +08:00
liangshuo-1
ef89d1fd40 chore(release): v1.0.33 (#952)
Change-Id: Iea77769a6a0f4e77e8946b72ddb619782be3ea42
2026-05-18 22:25:05 +08:00
JackZhao10086
c8b9809f96 Revert "feat(auth): add QR code support for device auth flow (#942)" (#950)
This reverts commit 7af616b9e5.
2026-05-18 22:12:03 +08:00
wangweiming-01
de00343063 feat: add markdown +patch shortcut (#857)
* feat: add markdown +patch shortcut

Change-Id: I8159941ff9dec4e5cbf0c757ec19ee172b302224

* fix: align markdown patch validation and dry-run

Change-Id: I98079901e980b74998938afc4917b91a79689948
2026-05-18 20:54:11 +08:00
ethan-zhx
67b16c5ec3 feat(slides): improve slide planning and validation guidance (#847)
refactor(slides): rename slide layout lint scope

Change-Id: I1b0e42b6508ec2c5f6ae6dc0d1b7ac23c5bbe2e3

feat(slides): improve lark slides skill guidance

Change-Id: I49563da4ca623a89f5391f36ceb8f5a31417e321

feat(slides): strengthen lark slides planning guidance

Change-Id: If49330e1f9b779bc76a919565ed61a31c255f508

feat(slides): remove lark slides layout lint rules

Change-Id: I64f1fc3b33d05c069c9ef58e61d00aa57ac18ecd

refactor(slides): streamline skill guidance

Change-Id: I3b39faaab7dcac52fac1572590fc5d8934428da5

feat(slides): add slides asset planning guidance

Change-Id: I37303043f7704e4ba484552158390a4e24bf9c42

feat(slides): add visual planning guidance

Change-Id: Idee7c392d41ff02124313d572c547d0a086d9c35

feat(slides): add lark slides planning layer

Change-Id: I3f0765aa53656070d9ba9b388dade19355e7bc6f
2026-05-18 20:44:50 +08:00
JackZhao10086
7af616b9e5 feat(auth): add QR code support for device auth flow (#942)
* feat(auth): add QR code support for device auth flow

* docs: update login QR code display hints for AI agent

* feat(auth): add ASCII QR code support for auth flow

* docs: add comments for login and auth helper functions

* chore: remove unused qrCodeToBase64 helper function

* fix(auth/login): clarify verification_url handling in login hint
2026-05-18 20:17:15 +08:00
fangshuyu-768
df4b657737 feat(drive): add +sync workflow for Drive directories (#873)
Bidirectional sync between a local directory and a Drive folder with
diff detection (new_local, new_remote, modified, unchanged) and
conflict resolution strategies (--on-conflict: remote-wins, local-wins,
keep-both, ask).

Key behaviors:
- Type conflict detection: hard-fail when local file vs remote non-file
  or local directory vs remote file
- Keep-both: rename local with __lark_<hash> suffix, then pull remote;
  occupied map includes localDirs to prevent suffix collision
- Local-wins partial-success: prefer returned file_token on upload failure
- Empty directory mirroring: pre-create local dirs on Drive via
  drivePushWalkLocal before scope preflight
- Structured errors throughout (output.Errorf / output.ErrWithHint)

Includes unit tests and E2E tests (dry-run + live workflow).
2026-05-18 19:56:43 +08:00
史启明(QimingShi)
4b721c0410 fix(sheets): explicitly document safe JSON unmarshal ignore in DryRun (#935)
Two DryRun functions in the sheets shortcuts called json.Unmarshal without
checking the return value. This looks like a bug, but Validate already
parses and validates the same --style / --data JSON before DryRun runs,
so the error is structurally impossible at this point.

Use _ = assignment + comment to silence the unchecked-error lint warning
and make the safety invariant explicit to future readers.

Co-authored-by: KhanCold <KhanCold@users.noreply.github.com>
2026-05-18 17:34:18 +08:00
162 changed files with 18890 additions and 1239 deletions

2
.gitignore vendored
View File

@@ -34,9 +34,11 @@ tests/mail/reports/
# Generated / test artifacts
.hammer/
.lark-slides/
internal/registry/meta_data.json
cmd/api/download.bin
app.log
/sidecar-server-demo
/server-demo
.tmp/
cover*.out

View File

@@ -45,6 +45,7 @@ linters:
- path: _test\.go$
linters:
- bodyclose
- bidichk
- gocritic
- depguard
- forbidigo

View File

@@ -2,6 +2,59 @@
All notable changes to this project will be documented in this file.
## [v1.0.34] - 2026-05-19
### Features
- **drive**: Switch markdown export to V2 `docs_ai` fetch API (#948)
- **drive**: Add `+inspect` shortcut for document URL inspection with wiki unwrapping (#947)
- **wiki**: Add `+node-get` / `+node-delete` / `+space-create` shortcuts (#904)
- **base**: Support Base attachment APIs (#887)
- **mail**: Validate `bot` + `mailbox=me` and add dynamic `--as` help tests (#895)
- **mail**: Expose draft priority in `--inspect` projection and document `--set-priority` (#779)
### Bug Fixes
- **identitydiag**: Harden verify path and tighten status semantics (#961)
- **wiki**: Surface real node URL for `+node-create` / `+node-copy` (#960)
- **auth**: Split bot and user identity diagnostics (#957)
- **base**: Address Base attachment review follow-ups (#958)
- **docs**: Clarify `replace_all` selection errors (#954)
### Documentation
- **drive**: Clarify add comment constraints (#967)
- **lark-im**: Clarify message activity search (#865)
### Tests
- Verify e2e resource cleanup (#949)
- **lint**: Exclude `bidichk` from test files (#959)
## [v1.0.33] - 2026-05-18
### Features
- **markdown**: Add `+patch` shortcut (#857)
- **slides**: Improve slide planning and validation guidance (#847)
- **drive**: Add `+sync` workflow for Drive directories (#873)
- **drive**: Add drive version shortcut (#841)
- **extension**: Plugin / Hook framework with command pruning (#910)
### Bug Fixes
- **sheets**: Explicitly document safe JSON unmarshal ignore in `DryRun` (#935)
- **base**: Mark base field update high risk (#936)
- **auth**: Guide agents to yield during auth device flow (#933)
### Documentation
- **lark-wiki**: Correct the `--as` default-identity claim (#919)
### Tests
- Drop stale e2e `--yes` flags (#920)
## [v1.0.32] - 2026-05-15
### Features
@@ -721,6 +774,8 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.34]: https://github.com/larksuite/cli/releases/tag/v1.0.34
[v1.0.33]: https://github.com/larksuite/cli/releases/tag/v1.0.33
[v1.0.32]: https://github.com/larksuite/cli/releases/tag/v1.0.32
[v1.0.31]: https://github.com/larksuite/cli/releases/tag/v1.0.31
[v1.0.30]: https://github.com/larksuite/cli/releases/tag/v1.0.30

View File

@@ -28,7 +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 |
| 📝 Markdown | Create, fetch, patch, 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 |
@@ -132,7 +132,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-markdown` | Create, fetch, patch, 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

@@ -28,7 +28,7 @@
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
| 📝 Markdown | 创建、读取、覆盖更新 Drive 中的原生 `.md` 文件 |
| 📝 Markdown | 创建、读取、局部 patch、覆盖更新 Drive 中的原生 `.md` 文件 |
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
@@ -133,7 +133,7 @@ lark-cli auth status
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown |
| `lark-drive` | 上传、下载文件,管理权限与评论 |
| `lark-markdown` | 创建、读取、覆盖更新 Drive 中的原生 Markdown 文件 |
| `lark-markdown` | 创建、读取、局部 patch、覆盖更新 Drive 中的原生 Markdown 文件 |
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |

View File

@@ -5,13 +5,11 @@ package auth
import (
"context"
"time"
"github.com/spf13/cobra"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
)
@@ -60,73 +58,83 @@ func authStatusRun(opts *StatusOptions) error {
"defaultAs": defaultAs,
}
if config.UserOpenId == "" {
result["identity"] = "bot"
result["note"] = "No user logged in. Only bot (tenant) identity is available for API calls. Run `lark-cli auth login` to log in."
output.PrintJson(f.IOStreams.Out, result)
return nil
}
stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId)
if stored == nil {
result["identity"] = "bot"
result["userName"] = config.UserName
result["userOpenId"] = config.UserOpenId
result["note"] = "Token does not exist or has been cleared. Only bot (tenant) identity is available. Re-login: lark-cli auth login"
output.PrintJson(f.IOStreams.Out, result)
return nil
}
status := larkauth.TokenStatus(stored)
if status == "expired" {
result["identity"] = "bot"
result["note"] = "User token has expired. Only bot (tenant) identity is available. Re-login: lark-cli auth login"
} else {
result["identity"] = "user"
}
result["userName"] = config.UserName
result["userOpenId"] = config.UserOpenId
result["tokenStatus"] = status
result["scope"] = stored.Scope
result["expiresAt"] = time.UnixMilli(stored.ExpiresAt).Format(time.RFC3339)
result["refreshExpiresAt"] = time.UnixMilli(stored.RefreshExpiresAt).Format(time.RFC3339)
result["grantedAt"] = time.UnixMilli(stored.GrantedAt).Format(time.RFC3339)
// --verify: call the server to confirm token is actually usable.
if opts.Verify && status != "expired" {
verified, verifyErr := verifyTokenOnServer(f, config)
result["verified"] = verified
if verifyErr != "" {
result["verifyError"] = verifyErr
}
}
diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify)
result["identities"] = diagnostics
result["identity"] = effectiveIdentity(diagnostics)
addLegacyUserFields(result, diagnostics.User)
addEffectiveVerification(result, diagnostics)
addStatusNote(result, diagnostics)
output.PrintJson(f.IOStreams.Out, result)
return nil
}
// verifyTokenOnServer obtains a valid access token (refreshing if needed)
// and calls /authen/v1/user_info to confirm the server accepts it.
// Returns (true, "") on success or (false, reason) on failure.
func verifyTokenOnServer(f *cmdutil.Factory, config *core.CliConfig) (bool, string) {
httpClient, err := f.HttpClient()
if err != nil {
return false, "failed to create HTTP client: " + err.Error()
}
const (
identityUser = "user"
identityBot = "bot"
identityNone = "none"
)
token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(config, f.IOStreams.ErrOut))
if err != nil {
return false, "token unusable: " + err.Error()
func effectiveIdentity(d identitydiag.Result) string {
switch {
case d.User.Available:
return identityUser
case d.Bot.Available:
return identityBot
default:
return identityNone
}
}
func addLegacyUserFields(result map[string]interface{}, user identitydiag.Identity) {
if user.OpenID == "" {
return
}
result["userName"] = user.UserName
result["userOpenId"] = user.OpenID
if user.TokenStatus != "" {
result["tokenStatus"] = user.TokenStatus
}
if user.Scope != "" {
result["scope"] = user.Scope
}
if user.ExpiresAt != "" {
result["expiresAt"] = user.ExpiresAt
}
if user.RefreshExpiresAt != "" {
result["refreshExpiresAt"] = user.RefreshExpiresAt
}
if user.GrantedAt != "" {
result["grantedAt"] = user.GrantedAt
}
}
func addEffectiveVerification(result map[string]interface{}, d identitydiag.Result) {
switch result["identity"] {
case identityUser:
if d.User.Verified != nil {
result["verified"] = *d.User.Verified
if !*d.User.Verified {
result["verifyError"] = d.User.Message
}
}
case identityBot:
if d.Bot.Verified != nil {
result["verified"] = *d.Bot.Verified
if !*d.Bot.Verified {
result["verifyError"] = d.Bot.Message
}
}
}
}
func addStatusNote(result map[string]interface{}, d identitydiag.Result) {
switch {
case !d.User.Available && d.Bot.Available:
result["note"] = "User identity is " + identitydiag.StatusMessage(d.User.Status) + "; bot identity is ready for bot/tenant API calls. Run `lark-cli auth login` to enable user identity."
case d.User.Status == identitydiag.StatusNeedsRefresh:
result["note"] = "User identity needs refresh and will be refreshed automatically on the next user API call."
case !d.User.Available && !d.Bot.Available:
result["note"] = "No usable identity is available. Configure bot credentials or run `lark-cli auth login`."
}
sdk, err := f.LarkClient()
if err != nil {
return false, "failed to create SDK client: " + err.Error()
}
if err := larkauth.VerifyUserToken(context.Background(), sdk, token); err != nil {
return false, "server rejected token: " + err.Error()
}
return true, ""
}

96
cmd/auth/status_test.go Normal file
View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"net/http"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
})
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
t.Fatalf("authStatusRun() error = %v", err)
}
var got statusOutput
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if got.Identity != "bot" {
t.Fatalf("identity = %q, want bot", got.Identity)
}
if got.Identities.Bot.Status != "ready" || !got.Identities.Bot.Available {
t.Fatalf("bot = %#v, want ready and available", got.Identities.Bot)
}
if got.Identities.User.Status != "missing" || got.Identities.User.Available {
t.Fatalf("user = %#v, want missing and unavailable", got.Identities.User)
}
}
func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"bot": map[string]interface{}{
"open_id": "ou_bot",
"app_name": "diagnostic bot",
},
},
})
if err := authStatusRun(&StatusOptions{Factory: f, Verify: true}); err != nil {
t.Fatalf("authStatusRun() error = %v", err)
}
var got statusOutput
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if got.Identity != "bot" {
t.Fatalf("identity = %q, want bot", got.Identity)
}
if got.Verified == nil || !*got.Verified {
t.Fatalf("verified = %v, want true", got.Verified)
}
if got.Identities.Bot.Verified == nil || !*got.Identities.Bot.Verified {
t.Fatalf("bot verified = %v, want true", got.Identities.Bot.Verified)
}
if got.Identities.Bot.OpenID != "ou_bot" {
t.Fatalf("bot open id = %q, want ou_bot", got.Identities.Bot.OpenID)
}
if got.Identities.User.Status != "missing" {
t.Fatalf("user status = %q, want missing", got.Identities.User.Status)
}
}
type statusOutput struct {
Identity string `json:"identity"`
Verified *bool `json:"verified"`
Identities struct {
Bot statusIdentity `json:"bot"`
User statusIdentity `json:"user"`
} `json:"identities"`
}
type statusIdentity struct {
Status string `json:"status"`
Available bool `json:"available"`
Verified *bool `json:"verified"`
OpenID string `json:"openId"`
}

View File

@@ -15,6 +15,7 @@ import (
cmdevent "github.com/larksuite/cli/cmd/event"
"github.com/larksuite/cli/cmd/profile"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/cmd/sec"
"github.com/larksuite/cli/cmd/service"
cmdupdate "github.com/larksuite/cli/cmd/update"
_ "github.com/larksuite/cli/events"
@@ -133,6 +134,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
rootCmd.AddCommand(sec.NewCmdSec(f))
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)

View File

@@ -14,10 +14,10 @@ import (
"github.com/spf13/cobra"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/update"
)
@@ -51,7 +51,7 @@ func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command {
// checkResult represents one diagnostic check.
type checkResult struct {
Name string `json:"name"`
Status string `json:"status"` // "pass", "fail", "skip"
Status string `json:"status"` // "pass", "warn", "fail", "skip"
Message string `json:"message"`
Hint string `json:"hint,omitempty"`
}
@@ -118,59 +118,31 @@ func doctorRun(opts *DoctorOptions) error {
ep := core.ResolveEndpoints(cfg.Brand)
// ── 3. Token exists ──
if cfg.UserOpenId == "" {
checks = append(checks, fail("token_exists", "no user logged in", "run: lark-cli auth login --help"))
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
return finishDoctor(f, checks)
}
stored := larkauth.GetStoredToken(cfg.AppID, cfg.UserOpenId)
if stored == nil {
checks = append(checks, fail("token_exists", "no token in keychain for "+cfg.UserOpenId, "run: lark-cli auth login --help"))
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
return finishDoctor(f, checks)
}
checks = append(checks, pass("token_exists", fmt.Sprintf("token found for %s (%s)", cfg.UserName, cfg.UserOpenId)))
// ── 4. Token local validity ──
status := larkauth.TokenStatus(stored)
switch status {
case "valid":
checks = append(checks, pass("token_local", "token valid, expires "+time.UnixMilli(stored.ExpiresAt).Format(time.RFC3339)))
case "needs_refresh":
checks = append(checks, pass("token_local", "token needs refresh (will auto-refresh on next call)"))
default: // expired
checks = append(checks, fail("token_local", "token expired", "run: lark-cli auth login --help"))
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
return finishDoctor(f, checks)
}
// ── 5. Token server verification ──
if opts.Offline {
checks = append(checks, skip("token_verified", "skipped (--offline)"))
// ── 3. Identity readiness ──
diagnostics := identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline)
checks = append(checks,
identityCheck("bot_identity", diagnostics.Bot),
identityCheck("user_identity", diagnostics.User),
)
if diagnostics.Bot.Available || diagnostics.User.Available {
checks = append(checks, pass("identity_ready", "at least one identity is available"))
} else {
httpClient := mustHTTPClient(f)
token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(cfg, f.IOStreams.ErrOut))
if err != nil {
checks = append(checks, fail("token_verified", "cannot obtain valid token: "+err.Error(), "run: lark-cli auth login --help"))
} else {
sdk, err := f.LarkClient()
if err != nil {
checks = append(checks, fail("token_verified", "SDK init failed: "+err.Error(), ""))
} else if err := larkauth.VerifyUserToken(opts.Ctx, sdk, token); err != nil {
checks = append(checks, fail("token_verified", "server rejected token: "+err.Error(), "run: lark-cli auth login --help"))
} else {
checks = append(checks, pass("token_verified", "server confirmed token is valid"))
}
}
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
}
// ── 6 & 7. Endpoint reachability ──
// ── 4 & 5. Endpoint reachability ──
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
return finishDoctor(f, checks)
}
func identityCheck(name string, id identitydiag.Identity) checkResult {
if id.Available {
return pass(name, id.Message)
}
return warn(name, id.Message, id.Hint)
}
// networkChecks probes Open API and MCP endpoints concurrently.
func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) []checkResult {
if opts.Offline {
@@ -232,15 +204,6 @@ func probeEndpoint(ctx context.Context, client *http.Client, url string) error {
return nil
}
// mustHTTPClient returns f.HttpClient() or a default client.
func mustHTTPClient(f *cmdutil.Factory) *http.Client {
c, err := f.HttpClient()
if err != nil {
return &http.Client{Timeout: 30 * time.Second}
}
return c
}
// checkCLIUpdate actively queries the npm registry for the latest version.
// Unlike the root-level async check, this does a synchronous fetch with timeout
// and works regardless of build version (dev builds included).

View File

@@ -95,3 +95,59 @@ func TestNetworkChecks_Offline(t *testing.T) {
}
}
}
func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "test-app",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
},
},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
})
err := doctorRun(&DoctorOptions{
Factory: f,
Ctx: context.Background(),
Offline: true,
})
if err != nil {
t.Fatalf("doctorRun() error = %v", err)
}
var got struct {
OK bool `json:"ok"`
Checks []checkResult `json:"checks"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if !got.OK {
t.Fatalf("ok = false, want true; checks = %#v", got.Checks)
}
assertCheck(t, got.Checks, "bot_identity", "pass")
assertCheck(t, got.Checks, "user_identity", "warn")
assertCheck(t, got.Checks, "identity_ready", "pass")
}
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
t.Helper()
for _, check := range checks {
if check.Name == name {
if check.Status != status {
t.Fatalf("%s status = %q, want %q", name, check.Status, status)
}
return
}
}
t.Fatalf("check %q not found in %#v", name, checks)
}

251
cmd/sec/config_init.go Normal file
View File

@@ -0,0 +1,251 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// NewCmdSecConfig is the parent for `lark-cli sec config <verb>`. Currently
// it only carries `init`; future verbs (e.g. `show`, `reset`) plug in here.
func NewCmdSecConfig(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Manage lark-sec-cli daemon configuration",
}
cmd.AddCommand(NewCmdSecConfigInit(f, nil))
return cmd
}
// ConfigInitOptions holds inputs for `lark-cli sec config init`.
type ConfigInitOptions struct {
Factory *cmdutil.Factory
AppID string
AppSecret string
Brand string
Yes bool // skip the interactive form when all required values are provided
}
// NewCmdSecConfigInit collects App ID / App Secret / Brand from the user and
// registers them with the running lark-sec-cli daemon's admin endpoint. The
// daemon stashes the secret in the OS keychain and switches into sidecar mode
// for SEC_AUTH credential isolation.
func NewCmdSecConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command {
opts := &ConfigInitOptions{Factory: f}
cmd := &cobra.Command{
Use: "init",
Short: "Register a Lark App with the running lark-sec-cli daemon",
Long: `Register an App ID / App Secret with the lark-sec-cli daemon.
The daemon must already be running (start it with "lark-cli sec run"). The
registration POSTs to /_sec/api/v1/register-app on the local proxy port,
HMAC-signed with the daemon's proxy.key.`,
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}
return runConfigInit(cmd, opts)
},
}
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (skips the prompt when set)")
cmd.Flags().StringVar(&opts.AppSecret, "app-secret", "", "App Secret (skips the prompt when set)")
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark")
cmd.Flags().BoolVarP(&opts.Yes, "yes", "y", false, "skip the interactive form when all required values are provided")
return cmd
}
// secBridge mirrors what the daemon writes to ~/.lark-cli/sec_config.json.
// It's the single contract between lark-cli and lark-sec-cli at runtime —
// we don't reach into lark-sec-cli internals, only what it chooses to publish.
type secBridge struct {
Enable bool `json:"LARKSUITE_CLI_SEC_ENABLE"`
Proxy string `json:"LARKSUITE_CLI_SEC_PROXY"`
CA string `json:"LARKSUITE_CLI_SEC_CA"`
Auth bool `json:"LARKSUITE_CLI_SEC_AUTH"`
}
func runConfigInit(cmd *cobra.Command, opts *ConfigInitOptions) error {
errOut := opts.Factory.IOStreams.ErrOut
trace := verboseOut(cmd, errOut)
tracef(trace, "sec config init", "loading daemon bridge from %s/sec_config.json", core.GetConfigDir())
bridge, err := loadBridge()
if err != nil {
return output.ErrWithHint(output.ExitValidation, "sec_bridge_missing",
fmt.Sprintf("daemon bridge file unreadable: %v", err),
"Start the daemon first: `lark-cli sec run`.")
}
tracef(trace, "sec config init", "bridge: enable=%t proxy=%s ca=%s auth=%t", bridge.Enable, bridge.Proxy, bridge.CA, bridge.Auth)
if !bridge.Enable || bridge.Proxy == "" {
return output.ErrWithHint(output.ExitValidation, "sec_not_running",
"lark-sec-cli is not advertising an active proxy",
"Run `lark-cli sec run` to start it.")
}
// The HMAC key sits next to the CA in the daemon's config dir. Deriving
// from the bridge's SEC_CA path keeps lark-cli decoupled from the daemon's
// install location — if the daemon ever moves, the bridge follows and we
// follow with it.
tracef(trace, "sec config init", "reading daemon HMAC key beside %s", bridge.CA)
hmacKey, err := readHMACKey(bridge.CA)
if err != nil {
return output.Errorf(output.ExitInternal, "sec_hmac_key", "read daemon HMAC key: %v", err)
}
if err := promptForMissing(opts); err != nil {
return err
}
tracef(trace, "sec config init", "POST %s/_sec/api/v1/register-app app_id=%s brand=%s", bridge.Proxy, opts.AppID, opts.Brand)
if err := registerApp(cmd.Context(), bridge.Proxy, hmacKey, opts.AppID, opts.AppSecret, opts.Brand); err != nil {
return output.Errorf(output.ExitAPI, "sec_register_app", "register-app: %v", err)
}
output.PrintSuccess(errOut,
fmt.Sprintf("registered app %s with lark-sec-cli (%s)", opts.AppID, opts.Brand))
return nil
}
// loadBridge reads the daemon-written sec_config.json from lark-cli's config dir.
func loadBridge() (*secBridge, error) {
path := filepath.Join(core.GetConfigDir(), "sec_config.json")
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var b secBridge
if err := json.Unmarshal(data, &b); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return &b, nil
}
// readHMACKey returns the daemon's proxy.key bytes. The daemon writes the key
// hex-encoded (64 ASCII chars); we hex-decode here. If the file is a raw
// 32-byte blob (older daemon variants), we use it as-is.
func readHMACKey(caPath string) ([]byte, error) {
if caPath == "" {
return nil, errors.New("sec_config.json has no LARKSUITE_CLI_SEC_CA — can't locate proxy.key")
}
keyPath := filepath.Join(filepath.Dir(caPath), "proxy.key")
raw, err := os.ReadFile(keyPath)
if err != nil {
return nil, err
}
raw = bytes.TrimSpace(raw)
if len(raw) == 64 {
if decoded, err := hex.DecodeString(string(raw)); err == nil {
return decoded, nil
}
}
return raw, nil
}
// promptForMissing fills in any of AppID / AppSecret / Brand the user didn't
// provide via flags. --yes refuses to prompt; that's caller error if any are
// still missing at that point.
func promptForMissing(opts *ConfigInitOptions) error {
if opts.AppID != "" && opts.AppSecret != "" && opts.Brand != "" {
return nil
}
if opts.Yes {
return output.ErrValidation("--yes set but missing one of --app-id / --app-secret / --brand")
}
groups := []*huh.Group{}
if opts.AppID == "" {
groups = append(groups, huh.NewGroup(
huh.NewInput().Title("App ID").Placeholder("cli_xxxx").Value(&opts.AppID),
))
}
if opts.AppSecret == "" {
groups = append(groups, huh.NewGroup(
huh.NewInput().Title("App Secret").EchoMode(huh.EchoModePassword).Value(&opts.AppSecret),
))
}
if opts.Brand == "" {
opts.Brand = "feishu"
groups = append(groups, huh.NewGroup(
huh.NewSelect[string]().Title("Brand").Options(
huh.NewOption("Feishu (cn)", "feishu"),
huh.NewOption("Lark (intl)", "lark"),
).Value(&opts.Brand),
))
}
if len(groups) == 0 {
return nil
}
form := huh.NewForm(groups...).WithTheme(cmdutil.ThemeFeishu())
if err := form.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
return output.ErrBare(1)
}
return err
}
return nil
}
// registerApp POSTs to /_sec/api/v1/register-app with the daemon's HMAC scheme.
// Canonical signing input is "method\npath\nsha256hex(body)\ntimestamp", per
// lark-sec-cli/internal/proxy/admin_handler.go's verifyHMAC.
func registerApp(ctx context.Context, proxyURL string, hmacKey []byte, appID, appSecret, brand string) error {
const path = "/_sec/api/v1/register-app"
body, err := json.Marshal(map[string]string{
"app_id": appID,
"app_secret": appSecret,
"brand": brand,
})
if err != nil {
return err
}
ts := strconv.FormatInt(time.Now().Unix(), 10)
bodyHash := sha256.Sum256(body)
canonical := http.MethodPost + "\n" + path + "\n" + hex.EncodeToString(bodyHash[:]) + "\n" + ts
mac := hmac.New(sha256.New, hmacKey)
mac.Write([]byte(canonical))
sig := hex.EncodeToString(mac.Sum(nil))
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, proxyURL+path, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Lark-Admin-Signature", sig)
req.Header.Set("X-Lark-Admin-Timestamp", ts)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
return fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
}

33
cmd/sec/factory.go Normal file
View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"fmt"
"github.com/larksuite/cli/internal/cmdutil"
intsec "github.com/larksuite/cli/internal/sec"
)
// installer wires up an internal/sec.Installer using the Factory's HTTP client,
// the default platform paths, and a lazy OAPI-client provider used to fetch
// the install manifest. APIClientFunc is a method value, not an eager call —
// commands that short-circuit (or that never install, like sec status / sec
// stop) avoid decrypting credentials from the keychain. Every cmd/sec
// subcommand starts here.
func installer(f *cmdutil.Factory) (*intsec.Installer, *intsec.Paths, error) {
paths, err := intsec.DefaultPaths()
if err != nil {
return nil, nil, fmt.Errorf("resolve sec paths: %w", err)
}
httpClient, err := f.HttpClient()
if err != nil {
return nil, nil, fmt.Errorf("resolve http client: %w", err)
}
return &intsec.Installer{
Paths: paths,
HTTPClient: httpClient,
APIClientFunc: f.NewAPIClient,
}, paths, nil
}

127
cmd/sec/run.go Normal file
View File

@@ -0,0 +1,127 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"bytes"
"fmt"
"os/exec"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
intsec "github.com/larksuite/cli/internal/sec"
)
// RunOptions holds inputs for `lark-cli sec run`.
type RunOptions struct {
Factory *cmdutil.Factory
ProxyPort int
// AutoInstall runs `sec install` first when no binary is recorded.
AutoInstall bool
}
// NewCmdSecRun starts lark-sec-cli as a user-level system service so it
// persists across logins and gets restarted by the OS supervisor if it
// crashes. Under the hood it shells out to `lark-sec-cli service enable`,
// which is the recommended startup path per the lark-sec-cli manual:
//
// - macOS → user-level launchd plist with KeepAlive=true
// - Linux → user systemd unit with Restart=always
// - Windows → registry autostart + a VBS watchdog loop
//
// Switching to this from a detached `exec.Command(... Setsid:true)` spawn
// fixes two latent issues at once: (1) daemon logs survive past lark-cli
// exit because the service supervisor — not our terminated pipes — owns
// the daemon's stdout, and (2) the daemon's own self-upgrade module can
// now fire (it gates on running-under-supervisor).
func NewCmdSecRun(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command {
opts := &RunOptions{Factory: f, AutoInstall: true}
cmd := &cobra.Command{
Use: "run",
Short: "Enable lark-sec-cli as a user system service (the daemon runs in the background)",
Long: `Install lark-sec-cli as a user-level system service so the proxy
daemon runs automatically, persists across logins, and is restarted by the
OS if it exits. The daemon writes its own log file (default: under
~/.lark-sec-cli/logs/daemon.log) so logs persist independently of this
command.
After enabling, the daemon writes ~/.lark-cli/sec_config.json itself with
the proxy port and CA path, so subsequent lark-cli runs route through the
sidecar without any further action.
To stop and remove the service: lark-cli sec stop.`,
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}
return runRun(cmd, opts)
},
}
cmd.Flags().IntVar(&opts.ProxyPort, "proxy-port", 0, "force lark-sec-cli to bind this port (default: dynamic)")
cmd.Flags().BoolVar(&opts.AutoInstall, "auto-install", true, "bootstrap-install lark-sec-cli first when no binary is recorded")
return cmd
}
func runRun(cmd *cobra.Command, opts *RunOptions) error {
ctx := cmd.Context()
errOut := opts.Factory.IOStreams.ErrOut
trace := verboseOut(cmd, errOut)
tracef(trace, "sec run", "constructing installer (lazy credentials)")
inst, paths, err := installer(opts.Factory)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
// Make sure we have a binary on disk before asking it to install itself
// as a service.
tracef(trace, "sec run", "loading state from %s", paths.StateFile())
state, err := intsec.LoadState(paths.StateFile())
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err)
}
if state == nil {
tracef(trace, "sec run", "no install on disk (auto-install=%t)", opts.AutoInstall)
if !opts.AutoInstall {
return output.ErrWithHint(output.ExitValidation, "sec_not_installed",
"lark-sec-cli is not installed",
"Re-run `lark-cli sec run` with --auto-install (default on), or remove --auto-install=false.")
}
state, err = inst.Install(ctx, intsec.InstallOptions{Verbose: trace})
if err != nil {
return output.Errorf(output.ExitNetwork, "sec_install", "auto-install lark-sec-cli: %v", err)
}
} else {
tracef(trace, "sec run", "existing install: version=%s binary=%s", state.Version, state.BinaryPath)
}
args := []string{"service", "enable"}
if opts.ProxyPort > 0 {
args = append(args, fmt.Sprintf("--proxy-port=%d", opts.ProxyPort))
}
fmt.Fprintf(errOut, "Running: %s %v\n", state.BinaryPath, args)
tracef(trace, "sec run", "shelling out to %s %v", state.BinaryPath, args)
c := exec.CommandContext(ctx, state.BinaryPath, args...)
var stdout, stderr bytes.Buffer
c.Stdout = &stdout
c.Stderr = &stderr
if err := c.Run(); err != nil {
return output.Errorf(output.ExitInternal, "sec_service_enable",
"`lark-sec-cli service enable` failed: %v\nstderr: %s", err, stderr.String())
}
tracef(trace, "sec run", "service enable returned ok (%d bytes stdout)", stdout.Len())
// Forward the installer's stdout to the user — it contains the launchd /
// systemd unit name, the registered executable path, and a confirmation
// that the supervisor will respawn the daemon on exit. Useful diagnostic
// output that's better seen than swallowed.
fmt.Fprint(errOut, stdout.String())
output.PrintSuccess(errOut,
"lark-sec-cli enabled as a user system service. Run `lark-cli sec status` to verify, `lark-cli sec stop` to disable.")
return nil
}

49
cmd/sec/sec.go Normal file
View File

@@ -0,0 +1,49 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package sec exposes the `lark-cli sec` command tree that bootstraps the
// lark-sec-cli sidecar daemon: install, run, stop, status, and `config init`.
// The internal/sec package owns the implementation; this package is a thin
// Cobra wrapper that mirrors the conventions in cmd/auth.
//
// After bootstrap install, lark-sec-cli handles its own upgrade lifecycle —
// lark-cli is not in the update path, which is why there's no `sec update`
// subcommand here.
package sec
import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
)
// NewCmdSec builds the parent `sec` command and registers all subcommands.
//
// The persistent --verbose / -v flag is inherited by every subcommand:
// `sec run -v`, `sec status -v`, etc. all emit step-by-step trace output to
// stderr.
//
// There is no `sec install` subcommand — `sec run` auto-installs lark-sec-cli
// if no binary is on disk, so a separate install verb was redundant.
func NewCmdSec(f *cmdutil.Factory) *cobra.Command {
var verbose bool
cmd := &cobra.Command{
Use: "sec",
Short: "Manage the lark-sec-cli security sidecar (run, status, stop, config)",
Long: `Manage the lark-sec-cli security sidecar.
lark-sec-cli is a local HTTPS proxy daemon that intercepts lark-cli's traffic,
injects BDMS risk-control signatures, and manages credentials via the OS
keychain. These subcommands handle the runtime lifecycle from lark-cli's side:
start the daemon (auto-installing on first run), inspect its state, register
an app with it, and stop it. Updates after the first install are managed by
lark-sec-cli itself.`,
}
cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false,
"print step-by-step pipeline output to stderr")
cmd.AddCommand(NewCmdSecRun(f, nil))
cmd.AddCommand(NewCmdSecStop(f, nil))
cmd.AddCommand(NewCmdSecStatus(f, nil))
cmd.AddCommand(NewCmdSecConfig(f))
return cmd
}

38
cmd/sec/sec_test.go Normal file
View File

@@ -0,0 +1,38 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"sort"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// TestNewCmdSec_HasAllSubcommands locks in the public command surface so a
// future refactor doesn't silently drop run/status/etc. The `update` verb
// was intentionally removed when lark-sec-cli took over its own upgrade
// lifecycle; if it ever needs to come back, add it here too. `install` was
// removed because `sec run --auto-install` (default on) makes a standalone
// install verb redundant.
func TestNewCmdSec_HasAllSubcommands(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := NewCmdSec(f)
var got []string
for _, c := range cmd.Commands() {
got = append(got, c.Name())
}
sort.Strings(got)
want := []string{"config", "run", "status", "stop"}
if len(got) != len(want) {
t.Fatalf("subcommands = %v, want %v", got, want)
}
for i, name := range want {
if got[i] != name {
t.Errorf("subcommands[%d] = %q, want %q", i, got[i], name)
}
}
}

115
cmd/sec/status.go Normal file
View File

@@ -0,0 +1,115 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"bytes"
"fmt"
"os/exec"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
intsec "github.com/larksuite/cli/internal/sec"
)
// StatusOptions holds inputs for `lark-cli sec status`.
type StatusOptions struct {
Factory *cmdutil.Factory
}
// NewCmdSecStatus shows install + runtime state. Implementation strategy:
//
// 1. Read lark-cli's local install record (state.json) — works even when the
// daemon's not installed, and gives the user a version/buildId/path
// fingerprint regardless of whether the service is up.
// 2. If the install exists, shell out to `lark-sec-cli status` for the
// live daemon view (service registration, pid liveness, proxy probe,
// sec_config.json contents). The daemon's own status command does a
// thorough check; we just pass it through.
func NewCmdSecStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command {
opts := &StatusOptions{Factory: f}
cmd := &cobra.Command{
Use: "status",
Short: "Show lark-sec-cli install and runtime state",
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}
return runStatus(cmd, opts)
},
}
return cmd
}
func runStatus(cmd *cobra.Command, opts *StatusOptions) error {
errOut := opts.Factory.IOStreams.ErrOut
trace := verboseOut(cmd, errOut)
tracef(trace, "sec status", "constructing installer (lazy credentials)")
_, paths, err := installer(opts.Factory)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
out := opts.Factory.IOStreams.Out
tracef(trace, "sec status", "loading state from %s", paths.StateFile())
state, err := intsec.LoadState(paths.StateFile())
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err)
}
if state == nil {
fmt.Fprintln(out, "lark-sec-cli: not installed")
fmt.Fprintln(out, " run: lark-cli sec run")
return nil
}
fmt.Fprintf(out, "lark-sec-cli %s\n", state.Version)
fmt.Fprintf(out, " binary: %s\n", state.BinaryPath)
// Daemon-side detail via `lark-sec-cli status`. The daemon's status
// command already covers service registration + pid + proxy reachability
// + bridge file — better than re-implementing those here.
tracef(trace, "sec status", "shelling out to %s status", state.BinaryPath)
c := exec.CommandContext(cmd.Context(), state.BinaryPath, "status")
var stdout, stderr bytes.Buffer
c.Stdout = &stdout
c.Stderr = &stderr
runErr := c.Run()
tracef(trace, "sec status", "daemon status exit=%v stdout=%d bytes stderr=%d bytes", runErr, stdout.Len(), stderr.Len())
fmt.Fprintln(out, " --- lark-sec-cli status ---")
if stdout.Len() > 0 {
fmt.Fprint(out, indent(stdout.String(), " "))
}
if stderr.Len() > 0 {
fmt.Fprint(out, indent(stderr.String(), " "))
}
// `lark-sec-cli status` exits 1 when not running — that's diagnostic
// data, not a failure of OUR command. Surface it for the user but don't
// propagate the non-zero exit upward.
_ = runErr
return nil
}
// indent prefixes every line of s with prefix. Cheap pass-through formatter
// used to make the embedded `lark-sec-cli status` output read as a sub-block
// under our own header.
func indent(s, prefix string) string {
if s == "" {
return s
}
var buf bytes.Buffer
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
buf.WriteString(prefix)
buf.WriteString(s[start : i+1])
start = i + 1
}
}
if start < len(s) {
buf.WriteString(prefix)
buf.WriteString(s[start:])
}
return buf.String()
}

82
cmd/sec/stop.go Normal file
View File

@@ -0,0 +1,82 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"bytes"
"fmt"
"os/exec"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
intsec "github.com/larksuite/cli/internal/sec"
)
// StopOptions holds inputs for `lark-cli sec stop`.
type StopOptions struct {
Factory *cmdutil.Factory
}
// NewCmdSecStop disables and removes the lark-sec-cli user system service.
// Counterpart to `sec run` — internally invokes `lark-sec-cli service disable`,
// which uninstalls the launchd / systemd / VBS-watchdog registration.
//
// The daemon itself wipes ~/.lark-cli/sec_config.json on shutdown (see its
// --disable-on-exit flag, default true), so subsequent lark-cli runs route
// directly to the upstream API instead of dangling through a dead local proxy.
func NewCmdSecStop(f *cmdutil.Factory, runF func(*StopOptions) error) *cobra.Command {
opts := &StopOptions{Factory: f}
cmd := &cobra.Command{
Use: "stop",
Short: "Disable and remove the lark-sec-cli user system service",
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}
return runStop(cmd, opts)
},
}
return cmd
}
func runStop(cmd *cobra.Command, opts *StopOptions) error {
out := opts.Factory.IOStreams.ErrOut
trace := verboseOut(cmd, out)
tracef(trace, "sec stop", "constructing installer (lazy credentials)")
_, paths, err := installer(opts.Factory)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
tracef(trace, "sec stop", "loading state from %s", paths.StateFile())
state, err := intsec.LoadState(paths.StateFile())
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err)
}
if state == nil {
// Nothing on disk to stop — no-op.
tracef(trace, "sec stop", "no install on disk; nothing to stop")
output.PrintSuccess(out, "lark-sec-cli not installed; nothing to stop")
return nil
}
args := []string{"service", "disable"}
fmt.Fprintf(out, "Running: %s %v\n", state.BinaryPath, args)
tracef(trace, "sec stop", "shelling out to %s %v", state.BinaryPath, args)
c := exec.CommandContext(cmd.Context(), state.BinaryPath, args...)
var stdout, stderr bytes.Buffer
c.Stdout = &stdout
c.Stderr = &stderr
if err := c.Run(); err != nil {
return output.Errorf(output.ExitInternal, "sec_service_disable",
"`lark-sec-cli service disable` failed: %v\nstderr: %s", err, stderr.String())
}
tracef(trace, "sec stop", "service disable returned ok (%d bytes stdout)", stdout.Len())
fmt.Fprint(out, stdout.String())
output.PrintSuccess(out, "lark-sec-cli service disabled")
return nil
}

32
cmd/sec/verbose.go Normal file
View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"fmt"
"io"
"github.com/spf13/cobra"
)
// verboseOut returns the trace destination for a sec subcommand: the given
// stderr writer when the inherited --verbose / -v flag is set, otherwise nil.
// Pair with tracef — a nil destination silently drops traces, so callers can
// emit unconditionally.
func verboseOut(cmd *cobra.Command, errOut io.Writer) io.Writer {
if v, _ := cmd.Flags().GetBool("verbose"); v {
return errOut
}
return nil
}
// tracef writes one trace line to w when w is non-nil. The prefix names the
// emitting subcommand (e.g. "sec run") so layered output from the install
// pipeline + the command itself stays distinguishable.
func tracef(w io.Writer, prefix, format string, args ...any) {
if w == nil {
return
}
fmt.Fprintf(w, "[%s] "+format+"\n", append([]any{prefix}, args...)...)
}

View File

@@ -0,0 +1,227 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package secplugin provides a placeholder credential provider for SEC_AUTH mode.
//
// When ~/.lark-cli/sec_config.json has:
//
// LARKSUITE_CLI_SEC_ENABLE=true
// LARKSUITE_CLI_SEC_AUTH=true
//
// this provider returns a minimal Account and placeholder tokens. The proxy
// is expected to replace the placeholder tokens with real ones.
package secplugin
import (
"context"
"fmt"
"os"
"strings"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
internalsec "github.com/larksuite/cli/internal/secplugin"
)
// Provider supplies placeholder credentials when SEC_AUTH mode is enabled.
type Provider struct{}
// Name returns the registered credential provider name.
func (p *Provider) Name() string { return "secplugin" }
// Priority is higher than env (default 10) but lower than sidecar (0),
// so authsidecar builds keep sidecar semantics when both are present.
func (p *Provider) Priority() int { return 1 }
// loadSecConfig is replaceable in tests so provider behavior can be isolated
// from on-disk SEC configuration state.
var loadSecConfig = internalsec.Load
func validateDefaultAs(value string) error {
switch id := credential.Identity(strings.TrimSpace(value)); id {
case "", credential.IdentityAuto, credential.IdentityUser, credential.IdentityBot:
return nil
default:
return fmt.Errorf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id)
}
}
// ResolveAccount builds an account that advertises SEC_AUTH placeholder support.
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
cfg, err := loadSecConfig()
if err != nil {
return nil, &credential.BlockError{Provider: p.Name(), Reason: err.Error()}
}
if cfg == nil || !cfg.AuthEnabled() {
return nil, nil
}
appID := strings.TrimSpace(os.Getenv(envvars.CliAppID))
brand := credential.Brand(strings.TrimSpace(os.Getenv(envvars.CliBrand)))
var defaultAs credential.Identity
// Prefer explicit env; if missing, allow sec_config.json to provide defaults.
if appID == "" && strings.TrimSpace(cfg.AppID) != "" {
appID = strings.TrimSpace(cfg.AppID)
}
if brand == "" && strings.TrimSpace(cfg.Brand) != "" {
brand = credential.Brand(strings.TrimSpace(cfg.Brand))
}
if defaultAs == "" && strings.TrimSpace(cfg.DefaultAs) != "" {
defaultAs = credential.Identity(strings.TrimSpace(cfg.DefaultAs))
if err := validateDefaultAs(string(defaultAs)); err != nil {
return nil, &credential.BlockError{
Provider: p.Name(),
Reason: err.Error(),
}
}
}
// Prefer explicit env for sandbox use; otherwise fall back to on-disk config
// without resolving any secrets.
if appID == "" || brand == "" {
multi, err := core.LoadMultiAppConfig()
if err != nil || multi == nil {
return nil, &credential.BlockError{
Provider: p.Name(),
Reason: "SEC_AUTH is enabled but no app config is available; run `lark-cli config init --new` (trusted env), or set " + envvars.CliAppID + " and " + envvars.CliBrand,
}
}
app := multi.CurrentAppConfig("") // profile override not available in provider API
if app == nil {
return nil, &credential.BlockError{
Provider: p.Name(),
Reason: "SEC_AUTH is enabled but no active profile is available in config.json",
}
}
if appID == "" {
appID = app.AppId
}
if brand == "" {
brand = credential.Brand(app.Brand)
}
if defaultAs == "" {
defaultAs = credential.Identity(app.DefaultAs)
}
// Map strict mode to supported identities (0 = allow all).
mode := multi.StrictMode
if app.StrictMode != nil {
mode = *app.StrictMode
}
switch mode {
case core.StrictModeBot:
// Keep sandbox locked down to bot.
return &credential.Account{
AppID: appID,
AppSecret: credential.NoAppSecret,
Brand: brand,
DefaultAs: defaultAs,
SupportedIdentities: credential.SupportsBot,
}, nil
case core.StrictModeUser:
return &credential.Account{
AppID: appID,
AppSecret: credential.NoAppSecret,
Brand: brand,
DefaultAs: defaultAs,
SupportedIdentities: credential.SupportsUser,
}, nil
}
}
if appID == "" {
return nil, &credential.BlockError{
Provider: p.Name(),
Reason: "SEC_AUTH is enabled but " + envvars.CliAppID + " is missing",
}
}
if brand == "" {
brand = credential.BrandFeishu
}
if brand != credential.BrandFeishu && brand != credential.BrandLark {
return nil, &credential.BlockError{
Provider: p.Name(),
Reason: fmt.Sprintf("invalid %s %q (want feishu or lark)", envvars.CliBrand, brand),
}
}
// DefaultAs comes from env if present (optional).
envDefaultAs := strings.TrimSpace(os.Getenv(envvars.CliDefaultAs))
if err := validateDefaultAs(envDefaultAs); err != nil {
return nil, &credential.BlockError{
Provider: p.Name(),
Reason: err.Error(),
}
}
switch id := credential.Identity(envDefaultAs); id {
case "", credential.IdentityAuto:
// keep defaultAs from config/env; empty is allowed
case credential.IdentityUser, credential.IdentityBot:
defaultAs = id
}
// If STRICT_MODE env is not set, allow sec_config.json to provide a default.
strictModeRaw := strings.TrimSpace(os.Getenv(envvars.CliStrictMode))
if strictModeRaw == "" && strings.TrimSpace(cfg.StrictMode) != "" {
strictModeRaw = strings.TrimSpace(cfg.StrictMode)
}
// SupportedIdentities from STRICT_MODE (optional). Default: allow both.
support := credential.SupportsAll
switch strictMode := strictModeRaw; strictMode {
case "bot":
support = credential.SupportsBot
case "user":
support = credential.SupportsUser
case "off", "":
// Keep the default: allow both identities.
default:
return nil, &credential.BlockError{
Provider: p.Name(),
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
}
}
return &credential.Account{
AppID: appID,
AppSecret: credential.NoAppSecret,
Brand: brand,
DefaultAs: defaultAs,
SupportedIdentities: support,
}, nil
}
// ResolveToken returns placeholder tokens that a trusted proxy must replace.
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
cfg, err := internalsec.Load()
if err != nil {
return nil, &credential.BlockError{Provider: p.Name(), Reason: err.Error()}
}
if cfg == nil || !cfg.AuthEnabled() {
return nil, nil
}
switch req.Type {
case credential.TokenTypeUAT:
return &credential.Token{
Value: internalsec.SentinelUAT,
Scopes: "", // empty => skip scope pre-check
Source: "secplugin",
}, nil
case credential.TokenTypeTAT:
return &credential.Token{
Value: internalsec.SentinelTAT,
Scopes: "",
Source: "secplugin",
}, nil
default:
return nil, nil
}
}
// init registers the SEC_AUTH placeholder credential provider.
func init() {
credential.Register(&Provider{})
}

View File

@@ -0,0 +1,486 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package secplugin
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
internalsec "github.com/larksuite/cli/internal/secplugin"
)
// TestProvider_Metadata verifies the registered provider metadata.
func TestProvider_Metadata(t *testing.T) {
p := &Provider{}
if p.Name() != "secplugin" {
t.Fatalf("Name() = %q, want secplugin", p.Name())
}
if p.Priority() != 1 {
t.Fatalf("Priority() = %d, want 1", p.Priority())
}
}
// TestProvider_UsesSecConfigDefaults verifies that SEC config defaults populate
// the placeholder account when env vars are absent.
func TestProvider_UsesSecConfigDefaults(t *testing.T) {
prev := loadSecConfig
t.Cleanup(func() { loadSecConfig = prev })
loadSecConfig = func() (*internalsec.Config, error) {
return &internalsec.Config{
Enable: true,
Auth: true,
AppID: "cli_test_app",
Brand: "lark",
DefaultAs: "bot",
StrictMode: "bot",
}, nil
}
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliBrand, "")
t.Setenv(envvars.CliDefaultAs, "")
t.Setenv(envvars.CliStrictMode, "")
p := &Provider{}
acct, err := p.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if acct == nil {
t.Fatal("ResolveAccount() = nil, want account")
}
if acct.AppID != "cli_test_app" {
t.Fatalf("acct.AppID = %q, want %q", acct.AppID, "cli_test_app")
}
if string(acct.Brand) != "lark" {
t.Fatalf("acct.Brand = %q, want %q", acct.Brand, "lark")
}
if string(acct.DefaultAs) != "bot" {
t.Fatalf("acct.DefaultAs = %q, want %q", acct.DefaultAs, "bot")
}
// StrictMode=bot => SupportsBot only.
if acct.SupportedIdentities != 2 {
t.Fatalf("acct.SupportedIdentities = %d, want %d (SupportsBot)", acct.SupportedIdentities, 2)
}
}
// TestProvider_EnvOverridesSecConfigDefaults verifies that explicit environment
// variables override SEC config defaults.
func TestProvider_EnvOverridesSecConfigDefaults(t *testing.T) {
prev := loadSecConfig
t.Cleanup(func() { loadSecConfig = prev })
loadSecConfig = func() (*internalsec.Config, error) {
return &internalsec.Config{
Enable: true,
Auth: true,
AppID: "cli_test_app",
Brand: "feishu",
DefaultAs: "bot",
StrictMode: "bot",
}, nil
}
t.Setenv(envvars.CliAppID, "cli_env_app")
t.Setenv(envvars.CliBrand, "lark")
t.Setenv(envvars.CliDefaultAs, "user")
t.Setenv(envvars.CliStrictMode, "user")
p := &Provider{}
acct, err := p.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if acct == nil {
t.Fatal("ResolveAccount() = nil, want account")
}
if acct.AppID != "cli_env_app" {
t.Fatalf("acct.AppID = %q, want %q", acct.AppID, "cli_env_app")
}
if string(acct.Brand) != "lark" {
t.Fatalf("acct.Brand = %q, want %q", acct.Brand, "lark")
}
if string(acct.DefaultAs) != "user" {
t.Fatalf("acct.DefaultAs = %q, want %q", acct.DefaultAs, "user")
}
// StrictMode=user => SupportsUser only (bit 1).
if acct.SupportedIdentities != 1 {
t.Fatalf("acct.SupportedIdentities = %d, want %d (SupportsUser)", acct.SupportedIdentities, 1)
}
}
// TestProvider_ResolveAccount_ReturnsNilWhenDisabled verifies early nil returns
// when SEC_AUTH mode is unavailable.
func TestProvider_ResolveAccount_ReturnsNilWhenDisabled(t *testing.T) {
prev := loadSecConfig
t.Cleanup(func() { loadSecConfig = prev })
cases := []struct {
name string
cfg *internalsec.Config
}{
{name: "nil config", cfg: nil},
{name: "auth disabled", cfg: &internalsec.Config{Enable: true, Auth: false}},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
loadSecConfig = func() (*internalsec.Config, error) { return tt.cfg, nil }
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if acct != nil {
t.Fatalf("ResolveAccount() = %#v, want nil", acct)
}
})
}
}
// TestProvider_ResolveAccount_LoadErrorBlocks verifies that SEC config load failures
// stop provider resolution.
func TestProvider_ResolveAccount_LoadErrorBlocks(t *testing.T) {
prev := loadSecConfig
t.Cleanup(func() { loadSecConfig = prev })
loadSecConfig = func() (*internalsec.Config, error) {
return nil, context.DeadlineExceeded
}
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err == nil {
t.Fatal("ResolveAccount() error = nil, want block error")
}
if acct != nil {
t.Fatalf("ResolveAccount() = %#v, want nil", acct)
}
blockErr, ok := err.(*credential.BlockError)
if !ok {
t.Fatalf("ResolveAccount() error = %T, want *credential.BlockError", err)
}
if blockErr.Provider != "secplugin" {
t.Fatalf("blockErr.Provider = %q, want secplugin", blockErr.Provider)
}
if !strings.Contains(blockErr.Reason, context.DeadlineExceeded.Error()) {
t.Fatalf("blockErr.Reason = %q, want load error text", blockErr.Reason)
}
}
// TestProvider_ResolveAccount_DefaultsBrandAndSupport verifies fallback defaults
// for brand and supported identities.
func TestProvider_ResolveAccount_DefaultsBrandAndSupport(t *testing.T) {
prev := loadSecConfig
t.Cleanup(func() { loadSecConfig = prev })
loadSecConfig = func() (*internalsec.Config, error) {
return &internalsec.Config{
Enable: true,
Auth: true,
}, nil
}
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliBrand, "")
t.Setenv(envvars.CliDefaultAs, "")
t.Setenv(envvars.CliStrictMode, "")
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
Apps: []core.AppConfig{{
Name: "default",
AppId: "app_from_disk",
AppSecret: core.PlainSecret("secret"),
DefaultAs: core.AsBot,
}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if acct == nil {
t.Fatal("ResolveAccount() = nil, want account")
}
if acct.Brand != credential.BrandFeishu {
t.Fatalf("acct.Brand = %q, want %q", acct.Brand, credential.BrandFeishu)
}
if acct.SupportedIdentities != credential.SupportsAll {
t.Fatalf("acct.SupportedIdentities = %d, want %d", acct.SupportedIdentities, credential.SupportsAll)
}
if acct.DefaultAs != credential.Identity("bot") {
t.Fatalf("acct.DefaultAs = %q, want bot", acct.DefaultAs)
}
if acct.AppID != "app_from_disk" {
t.Fatalf("acct.AppID = %q, want app_from_disk", acct.AppID)
}
}
// TestProvider_ResolveAccount_InvalidValuesBlock verifies validation failures for
// brand and identity-related settings.
func TestProvider_ResolveAccount_InvalidValuesBlock(t *testing.T) {
prev := loadSecConfig
t.Cleanup(func() { loadSecConfig = prev })
cases := []struct {
name string
cfg *internalsec.Config
envKey string
envValue string
want string
}{
{
name: "invalid brand from config",
cfg: &internalsec.Config{Enable: true, Auth: true, AppID: "cli_test_app", Brand: "bad-brand"},
want: "invalid " + envvars.CliBrand,
},
{
name: "invalid default as from config",
cfg: &internalsec.Config{
Enable: true,
Auth: true,
AppID: "cli_test_app",
Brand: "lark",
DefaultAs: "bad",
},
want: "invalid " + envvars.CliDefaultAs,
},
{
name: "invalid default as from env",
cfg: &internalsec.Config{Enable: true, Auth: true, AppID: "cli_test_app", Brand: "lark"},
envKey: envvars.CliDefaultAs,
envValue: "bad",
want: "invalid " + envvars.CliDefaultAs,
},
{
name: "invalid strict mode from env",
cfg: &internalsec.Config{Enable: true, Auth: true, AppID: "cli_test_app", Brand: "lark"},
envKey: envvars.CliStrictMode,
envValue: "bad",
want: "invalid " + envvars.CliStrictMode,
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
loadSecConfig = func() (*internalsec.Config, error) { return tt.cfg, nil }
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliBrand, "")
t.Setenv(envvars.CliDefaultAs, "")
t.Setenv(envvars.CliStrictMode, "")
if tt.envKey != "" {
t.Setenv(tt.envKey, tt.envValue)
}
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err == nil {
t.Fatal("ResolveAccount() error = nil, want block error")
}
if acct != nil {
t.Fatalf("ResolveAccount() = %#v, want nil", acct)
}
blockErr, ok := err.(*credential.BlockError)
if !ok {
t.Fatalf("ResolveAccount() error = %T, want *credential.BlockError", err)
}
if !strings.Contains(blockErr.Reason, tt.want) {
t.Fatalf("blockErr.Reason = %q, want substring %q", blockErr.Reason, tt.want)
}
})
}
}
// TestProvider_ResolveAccount_FallbackToDiskConfig verifies fallback behavior
// when SEC config omits app identity fields.
func TestProvider_ResolveAccount_FallbackToDiskConfig(t *testing.T) {
prev := loadSecConfig
t.Cleanup(func() { loadSecConfig = prev })
loadSecConfig = func() (*internalsec.Config, error) {
return &internalsec.Config{Enable: true, Auth: true}, nil
}
t.Run("missing config blocks", func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliBrand, "")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err == nil {
t.Fatal("ResolveAccount() error = nil, want block error")
}
if acct != nil {
t.Fatalf("ResolveAccount() = %#v, want nil", acct)
}
blockErr := err.(*credential.BlockError)
if !strings.Contains(blockErr.Reason, "no app config is available") {
t.Fatalf("blockErr.Reason = %q, want missing app config message", blockErr.Reason)
}
})
t.Run("missing active profile blocks", func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "missing",
Apps: []core.AppConfig{{
Name: "default",
AppId: "app_from_disk",
AppSecret: core.PlainSecret("secret"),
Brand: core.LarkBrand("lark"),
}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err == nil {
t.Fatal("ResolveAccount() error = nil, want block error")
}
if acct != nil {
t.Fatalf("ResolveAccount() = %#v, want nil", acct)
}
blockErr := err.(*credential.BlockError)
if !strings.Contains(blockErr.Reason, "no active profile") {
t.Fatalf("blockErr.Reason = %q, want no active profile message", blockErr.Reason)
}
})
t.Run("strict mode from disk", func(t *testing.T) {
cases := []struct {
name string
mode core.StrictMode
wantIDs credential.IdentitySupport
}{
{name: "bot", mode: core.StrictModeBot, wantIDs: credential.SupportsBot},
{name: "user", mode: core.StrictModeUser, wantIDs: credential.SupportsUser},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
mode := tt.mode
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
Apps: []core.AppConfig{{
Name: "default",
AppId: "app_from_disk",
AppSecret: core.PlainSecret("secret"),
Brand: core.LarkBrand("lark"),
DefaultAs: core.AsBot,
StrictMode: &mode,
}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if acct == nil {
t.Fatal("ResolveAccount() = nil, want account")
}
if acct.AppID != "app_from_disk" {
t.Fatalf("acct.AppID = %q, want app_from_disk", acct.AppID)
}
if acct.Brand != credential.Brand("lark") {
t.Fatalf("acct.Brand = %q, want lark", acct.Brand)
}
if acct.DefaultAs != credential.Identity("bot") {
t.Fatalf("acct.DefaultAs = %q, want bot", acct.DefaultAs)
}
if acct.SupportedIdentities != tt.wantIDs {
t.Fatalf("acct.SupportedIdentities = %d, want %d", acct.SupportedIdentities, tt.wantIDs)
}
})
}
})
}
// TestProvider_ResolveAccount_StrictModePreservesConfiguredDefaultAs verifies
// cfg.DefaultAs is not overwritten by disk profile default in strict-mode path.
func TestProvider_ResolveAccount_StrictModePreservesConfiguredDefaultAs(t *testing.T) {
prev := loadSecConfig
t.Cleanup(func() { loadSecConfig = prev })
loadSecConfig = func() (*internalsec.Config, error) {
return &internalsec.Config{
Enable: true,
Auth: true,
Brand: "lark",
DefaultAs: "user",
StrictMode: "bot",
}, nil
}
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliBrand, "")
t.Setenv(envvars.CliDefaultAs, "")
t.Setenv(envvars.CliStrictMode, "")
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
Apps: []core.AppConfig{{
Name: "default",
AppId: "app_from_disk",
AppSecret: core.PlainSecret("secret"),
Brand: core.LarkBrand("lark"),
DefaultAs: core.AsBot,
}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if acct == nil {
t.Fatal("ResolveAccount() = nil, want account")
}
if acct.DefaultAs != credential.IdentityUser {
t.Fatalf("acct.DefaultAs = %q, want %q", acct.DefaultAs, credential.IdentityUser)
}
if acct.SupportedIdentities != credential.SupportsBot {
t.Fatalf("acct.SupportedIdentities = %d, want %d (SupportsBot)", acct.SupportedIdentities, credential.SupportsBot)
}
}
// TestProvider_ResolveToken_ReturnsSentinels verifies placeholder token behavior
// for SEC_AUTH mode.
func TestProvider_ResolveToken_ReturnsSentinels(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv(envvars.CliSecEnable, "true")
t.Setenv(envvars.CliSecAuth, "true")
t.Setenv(envvars.CliSecProxy, "http://127.0.0.1:3128")
t.Setenv(envvars.CliSecCA, "")
p := &Provider{}
uat, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
if err != nil {
t.Fatalf("ResolveToken(UAT) error = %v", err)
}
if uat == nil || uat.Value != internalsec.SentinelUAT || uat.Source != "secplugin" {
t.Fatalf("ResolveToken(UAT) = %#v, want sentinel UAT token", uat)
}
tat, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeTAT})
if err != nil {
t.Fatalf("ResolveToken(TAT) error = %v", err)
}
if tat == nil || tat.Value != internalsec.SentinelTAT || tat.Source != "secplugin" {
t.Fatalf("ResolveToken(TAT) = %#v, want sentinel TAT token", tat)
}
tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenType("other")})
if err != nil {
t.Fatalf("ResolveToken(other) error = %v", err)
}
if tok != nil {
t.Fatalf("ResolveToken(other) = %#v, want nil", tok)
}
}

View File

@@ -5,6 +5,7 @@ package cmdutil
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
@@ -66,3 +67,49 @@ func TestAddShortcutIdentityFlag_NoDefault(t *testing.T) {
t.Fatalf("default value = %q, want empty string", got)
}
}
// TC-10: AuthTypes=["user"] → usage contains "identity type: user" and NOT "bot".
func TestAddShortcutIdentityFlag_UserOnlyAuthTypes(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := &cobra.Command{Use: "test"}
AddShortcutIdentityFlag(context.Background(), cmd, f, []string{"user"})
flag := cmd.Flags().Lookup("as")
if flag == nil {
t.Fatal("expected --as flag to be registered")
}
if flag.Hidden {
t.Fatal("expected --as flag to be visible")
}
wantUsage := "identity type: user"
if flag.Usage != wantUsage {
t.Errorf("Usage = %q, want %q", flag.Usage, wantUsage)
}
if strings.Contains(flag.Usage, "bot") {
t.Errorf("Usage should not contain \"bot\" for user-only shortcut, got %q", flag.Usage)
}
}
// TC-11: AuthTypes=["user","bot"] → usage == "identity type: user | bot".
func TestAddShortcutIdentityFlag_UserBotAuthTypes(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := &cobra.Command{Use: "test"}
AddShortcutIdentityFlag(context.Background(), cmd, f, []string{"user", "bot"})
flag := cmd.Flags().Lookup("as")
if flag == nil {
t.Fatal("expected --as flag to be registered")
}
if flag.Hidden {
t.Fatal("expected --as flag to be visible")
}
if got := flag.DefValue; got != "" {
t.Fatalf("default value = %q, want empty string", got)
}
wantUsage := "identity type: user | bot"
if flag.Usage != wantUsage {
t.Errorf("Usage = %q, want %q", flag.Usage, wantUsage)
}
}

View File

@@ -4,18 +4,45 @@
package envvars
const (
CliAppID = "LARKSUITE_CLI_APP_ID"
CliAppSecret = "LARKSUITE_CLI_APP_SECRET"
CliBrand = "LARKSUITE_CLI_BRAND"
CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN"
// CliAppID is the app ID environment variable consumed by the CLI.
CliAppID = "LARKSUITE_CLI_APP_ID"
// CliAppSecret is the app secret environment variable consumed by the CLI.
CliAppSecret = "LARKSUITE_CLI_APP_SECRET"
// CliBrand selects the tenant brand environment variable consumed by the CLI.
CliBrand = "LARKSUITE_CLI_BRAND"
// CliUserAccessToken is the user access token override environment variable.
CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN"
// CliTenantAccessToken is the tenant access token override environment variable.
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
// Sidecar proxy (auth proxy mode)
// CliDefaultAs selects the default identity environment variable.
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
// CliStrictMode selects the strict identity mode environment variable.
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
// CliAuthProxy is the auth sidecar HTTP address environment variable.
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384"
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
// Content safety scanning mode
// CliProxyKey is the shared HMAC signing key environment variable for the sidecar.
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
// CliSecEnable enables sec plugin mode from the environment.
CliSecEnable = "LARKSUITE_CLI_SEC_ENABLE"
// CliSecProxy sets the fixed sec plugin HTTP proxy address.
CliSecProxy = "LARKSUITE_CLI_SEC_PROXY"
// CliSecCA points to an extra PEM bundle trusted by sec plugin mode.
CliSecCA = "LARKSUITE_CLI_SEC_CA"
// CliSecAuth enables placeholder-token auth mode for sec plugin flows.
CliSecAuth = "LARKSUITE_CLI_SEC_AUTH"
// CliContentSafetyMode selects the content safety scanning mode.
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
)

View File

@@ -0,0 +1,325 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package identitydiag
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
const (
StatusReady = "ready"
StatusNotConfigured = "not_configured"
StatusMissing = "missing"
StatusNeedsRefresh = "needs_refresh"
StatusVerifyFailed = "verify_failed"
)
// verifyTimeout bounds each network call made during --verify so that a
// hanging server cannot wedge `auth status --verify` or `doctor`. Mirrors
// the 10s timeout used by the doctor endpoint probe.
const verifyTimeout = 10 * time.Second
// Result describes the independently usable bot and user identities.
type Result struct {
Bot Identity `json:"bot"`
User Identity `json:"user"`
}
// Identity is a single identity diagnostic result.
type Identity struct {
Status string `json:"status"`
Available bool `json:"available"`
Verified *bool `json:"verified,omitempty"`
Message string `json:"message,omitempty"`
Hint string `json:"hint,omitempty"`
OpenID string `json:"openId,omitempty"`
AppName string `json:"appName,omitempty"`
UserName string `json:"userName,omitempty"`
TokenStatus string `json:"tokenStatus,omitempty"`
Scope string `json:"scope,omitempty"`
ExpiresAt string `json:"expiresAt,omitempty"`
RefreshExpiresAt string `json:"refreshExpiresAt,omitempty"`
GrantedAt string `json:"grantedAt,omitempty"`
}
// Diagnose checks bot and user identities separately. When verify is false,
// it only reports local readiness and skips server calls.
func Diagnose(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Result {
if ctx == nil {
ctx = context.Background()
}
return Result{
Bot: diagnoseBot(ctx, f, cfg, verify),
User: diagnoseUser(ctx, f, cfg, verify),
}
}
func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity {
if cfg == nil || cfg.AppID == "" {
return Identity{
Status: StatusNotConfigured,
Message: "Bot identity: not configured (missing app config)",
Hint: "run: lark-cli config --help",
}
}
if !cfg.CanBot() {
return Identity{
Status: StatusNotConfigured,
Message: "Bot identity: not configured (bot identity is not available in current credential context)",
Hint: "check strict mode or the active credential provider",
}
}
if cfg.SupportedIdentities == 0 && !credential.HasRealAppSecret(cfg.AppSecret) {
return Identity{
Status: StatusNotConfigured,
Message: "Bot identity: not configured (missing app secret or bot token)",
Hint: "run: lark-cli config --help",
}
}
id := Identity{
Status: StatusReady,
Available: true,
Message: "Bot identity: ready",
}
if !verify {
return id
}
token, err := resolveBotToken(ctx, f, cfg)
if err != nil {
status := StatusVerifyFailed
var unavailable *credential.TokenUnavailableError
if errors.As(err, &unavailable) {
status = StatusNotConfigured
}
return Identity{
Status: status,
Verified: boolPtr(false),
Message: "Bot identity: " + StatusMessage(status) + ": " + err.Error(),
Hint: "check app credentials or the active credential provider",
}
}
info, err := fetchBotInfo(ctx, f, cfg, token)
if err != nil {
return Identity{
Status: StatusVerifyFailed,
Verified: boolPtr(false),
Message: "Bot identity: verify failed: " + err.Error(),
Hint: "check app credentials, scopes, network, or tenant access token configuration",
}
}
id.Verified = boolPtr(true)
id.OpenID = info.OpenID
id.AppName = info.AppName
return id
}
func diagnoseUser(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity {
if cfg == nil || cfg.AppID == "" {
return Identity{
Status: StatusNotConfigured,
Message: "User identity: not configured (missing app config)",
Hint: "run: lark-cli config --help",
}
}
if cfg.UserOpenId == "" {
return Identity{
Status: StatusMissing,
Message: "User identity: missing (no user logged in)",
Hint: "run: lark-cli auth login --help",
}
}
id := Identity{
UserName: cfg.UserName,
OpenID: cfg.UserOpenId,
}
stored := larkauth.GetStoredToken(cfg.AppID, cfg.UserOpenId)
if stored == nil {
id.Status = StatusMissing
id.Message = "User identity: missing (no token in keychain for " + cfg.UserOpenId + ")"
id.Hint = "run: lark-cli auth login --help"
return id
}
fillTokenFields(&id, stored)
switch larkauth.TokenStatus(stored) {
case "valid":
id.Status = StatusReady
id.Available = true
id.Message = "User identity: ready"
case "needs_refresh":
id.Status = StatusNeedsRefresh
id.Available = true
id.Message = "User identity: needs refresh (will auto-refresh on next user API call)"
default:
id.Status = StatusMissing
id.Message = "User identity: missing (refresh token expired)"
id.Hint = "run: lark-cli auth login --help"
return id
}
if !verify {
return id
}
markVerifyFailed := func(reason, hint string) Identity {
id.Status = StatusVerifyFailed
id.Available = false
id.Verified = boolPtr(false)
id.Message = "User identity: verify failed: " + reason
if hint != "" {
id.Hint = hint
}
return id
}
httpClient, err := f.HttpClient()
if err != nil {
return markVerifyFailed("create HTTP client: "+err.Error(), "")
}
token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(cfg, f.IOStreams.ErrOut))
if err != nil {
return markVerifyFailed("token unusable: "+err.Error(), "run: lark-cli auth login --help")
}
sdk, err := f.LarkClient()
if err != nil {
return markVerifyFailed("SDK init failed: "+err.Error(), "")
}
verifyCtx, cancel := context.WithTimeout(ctx, verifyTimeout)
defer cancel()
if err := larkauth.VerifyUserToken(verifyCtx, sdk, token); err != nil {
return markVerifyFailed("server rejected token: "+err.Error(), "run: lark-cli auth login --help")
}
id.Verified = boolPtr(true)
if id.Status == StatusReady {
id.Message = "User identity: ready"
} else {
id.Message = "User identity: needs refresh (server verification succeeded after refresh)"
}
return id
}
func resolveBotToken(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig) (string, error) {
if f == nil || f.Credential == nil {
return "", &credential.TokenUnavailableError{Type: credential.TokenTypeTAT}
}
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, cfg.AppID))
if err != nil {
return "", err
}
if result == nil || result.Token == "" {
return "", &credential.TokenUnavailableError{Type: credential.TokenTypeTAT}
}
return result.Token, nil
}
type botInfo struct {
OpenID string
AppName string
}
func fetchBotInfo(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, token string) (*botInfo, error) {
httpClient, err := f.HttpClient()
if err != nil {
return nil, fmt.Errorf("create HTTP client: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, verifyTimeout)
defer cancel()
url := strings.TrimRight(core.ResolveEndpoints(cfg.Brand).Open, "/") + "/open-apis/bot/v3/info"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
// /open-apis/bot/v3/info returns `{code, msg, bot: {...}}` — the bot
// payload is under "bot", not "data" as the newer Lark API convention.
var envelope struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
OpenID string `json:"open_id"`
AppName string `json:"app_name"`
} `json:"bot"`
}
parseErr := json.Unmarshal(body, &envelope)
if resp.StatusCode >= 400 {
// Lark error responses are usually `{code, msg}` envelopes even on
// non-2xx — surface them when present so callers see why bot auth
// was rejected, not just the bare HTTP code.
if parseErr == nil && envelope.Code != 0 {
return nil, fmt.Errorf("HTTP %d: [%d] %s", resp.StatusCode, envelope.Code, envelope.Msg)
}
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
if parseErr != nil {
return nil, fmt.Errorf("parse response: %w", parseErr)
}
if envelope.Code != 0 {
return nil, fmt.Errorf("[%d] %s", envelope.Code, envelope.Msg)
}
if envelope.Data.OpenID == "" {
return nil, errors.New("open_id is empty")
}
return &botInfo{OpenID: envelope.Data.OpenID, AppName: envelope.Data.AppName}, nil
}
func fillTokenFields(id *Identity, token *larkauth.StoredUAToken) {
id.TokenStatus = larkauth.TokenStatus(token)
id.Scope = token.Scope
id.ExpiresAt = formatMillis(token.ExpiresAt)
id.RefreshExpiresAt = formatMillis(token.RefreshExpiresAt)
id.GrantedAt = formatMillis(token.GrantedAt)
}
func formatMillis(ms int64) string {
if ms <= 0 {
return ""
}
return time.UnixMilli(ms).Format(time.RFC3339)
}
func StatusMessage(status string) string {
switch status {
case StatusNotConfigured:
return "not configured"
case StatusVerifyFailed:
return "verify failed"
case StatusNeedsRefresh:
return "needs refresh"
case StatusMissing:
return "missing"
default:
return status
}
}
func boolPtr(v bool) *bool {
return &v
}

View File

@@ -0,0 +1,350 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package identitydiag
import (
"context"
"net/http"
"strings"
"testing"
"time"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/zalando/go-keyring"
)
func TestDiagnose_NoUserReportsBotReadyAndUserMissing(t *testing.T) {
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
got := Diagnose(context.Background(), f, cfg, false)
if got.Bot.Status != StatusReady || !got.Bot.Available {
t.Fatalf("bot = %#v, want ready and available", got.Bot)
}
if got.User.Status != StatusMissing || got.User.Available {
t.Fatalf("user = %#v, want missing and unavailable", got.User)
}
}
func TestDiagnose_BotIdentityNotConfigured(t *testing.T) {
cfg := &core.CliConfig{AppID: "test-app", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
got := Diagnose(context.Background(), f, cfg, false)
if got.Bot.Status != StatusNotConfigured || got.Bot.Available {
t.Fatalf("bot = %#v, want not_configured and unavailable", got.Bot)
}
}
func TestDiagnose_VerifyBotIdentity(t *testing.T) {
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu}
f, _, _, reg := cmdutil.TestFactory(t, cfg)
stub := &httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"bot": map[string]interface{}{
"open_id": "ou_bot",
"app_name": "diagnostic bot",
},
},
}
reg.Register(stub)
got := Diagnose(context.Background(), f, cfg, true)
if got.Bot.Status != StatusReady || !got.Bot.Available {
t.Fatalf("bot = %#v, want ready and available", got.Bot)
}
if got.Bot.Verified == nil || !*got.Bot.Verified {
t.Fatalf("bot verified = %v, want true", got.Bot.Verified)
}
if got.Bot.OpenID != "ou_bot" || got.Bot.AppName != "diagnostic bot" {
t.Fatalf("bot info = %#v, want open id and app name", got.Bot)
}
if got := stub.CapturedHeaders.Get("Authorization"); got != "Bearer test-token" {
t.Fatalf("Authorization = %q, want %q", got, "Bearer test-token")
}
}
func TestDiagnose_VerifyUserIdentity(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "test-app-user",
AppSecret: "secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_user",
UserName: "tester",
}
now := time.Now()
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: cfg.AppID,
UserOpenId: cfg.UserOpenId,
AccessToken: "user-access-token",
RefreshToken: "refresh-token",
ExpiresAt: now.Add(time.Hour).UnixMilli(),
RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(),
GrantedAt: now.Add(-time.Hour).UnixMilli(),
Scope: "offline_access",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, _, reg := cmdutil.TestFactory(t, cfg)
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"bot": map[string]interface{}{
"open_id": "ou_bot",
"app_name": "diagnostic bot",
},
},
})
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: larkauth.PathUserInfoV1,
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
})
got := Diagnose(context.Background(), f, cfg, true)
if got.User.Status != StatusReady || !got.User.Available {
t.Fatalf("user = %#v, want ready and available", got.User)
}
if got.User.Verified == nil || !*got.User.Verified {
t.Fatalf("user verified = %v, want true", got.User.Verified)
}
if got.User.OpenID != "ou_user" || got.User.UserName != "tester" {
t.Fatalf("user = %#v, want user identity details", got.User)
}
}
func TestDiagnose_VerifyBotIdentity_HTTPErrorSurfacesEnvelope(t *testing.T) {
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu}
f, _, _, reg := cmdutil.TestFactory(t, cfg)
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/bot/v3/info",
Status: http.StatusUnauthorized,
Body: map[string]interface{}{
"code": 99991663,
"msg": "app ticket invalid",
},
})
got := Diagnose(context.Background(), f, cfg, true)
if got.Bot.Status != StatusVerifyFailed || got.Bot.Available {
t.Fatalf("bot = %#v, want verify_failed and unavailable", got.Bot)
}
if got.Bot.Verified == nil || *got.Bot.Verified {
t.Fatalf("bot verified = %v, want false", got.Bot.Verified)
}
if !strings.Contains(got.Bot.Message, "401") || !strings.Contains(got.Bot.Message, "99991663") {
t.Fatalf("bot message = %q, want both HTTP code and envelope code", got.Bot.Message)
}
}
func TestDiagnose_VerifyBotIdentity_BusinessErrorCode(t *testing.T) {
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu}
f, _, _, reg := cmdutil.TestFactory(t, cfg)
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 10013,
"msg": "scope not granted",
},
})
got := Diagnose(context.Background(), f, cfg, true)
if got.Bot.Status != StatusVerifyFailed || got.Bot.Available {
t.Fatalf("bot = %#v, want verify_failed and unavailable", got.Bot)
}
if !strings.Contains(got.Bot.Message, "10013") || !strings.Contains(got.Bot.Message, "scope not granted") {
t.Fatalf("bot message = %q, want envelope code/msg", got.Bot.Message)
}
}
func TestDiagnose_VerifyUserIdentity_ServerRejects(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "test-app-reject",
AppSecret: "secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_user",
UserName: "tester",
}
now := time.Now()
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: cfg.AppID,
UserOpenId: cfg.UserOpenId,
AccessToken: "user-access-token",
RefreshToken: "refresh-token",
ExpiresAt: now.Add(time.Hour).UnixMilli(),
RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(),
GrantedAt: now.Add(-time.Hour).UnixMilli(),
Scope: "offline_access",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, _, reg := cmdutil.TestFactory(t, cfg)
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 0,
"bot": map[string]interface{}{"open_id": "ou_bot", "app_name": "bot"},
},
})
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: larkauth.PathUserInfoV1,
Body: map[string]interface{}{
"code": 99991661,
"msg": "access token invalid",
},
})
got := Diagnose(context.Background(), f, cfg, true)
if got.User.Status != StatusVerifyFailed || got.User.Available {
t.Fatalf("user = %#v, want verify_failed and unavailable", got.User)
}
if got.User.Verified == nil || *got.User.Verified {
t.Fatalf("user verified = %v, want false", got.User.Verified)
}
if !strings.Contains(got.User.Message, "server rejected token") {
t.Fatalf("user message = %q, want 'server rejected token'", got.User.Message)
}
}
func TestDiagnose_UserIdentityExpired(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "test-app-expired",
AppSecret: "secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_expired",
UserName: "tester",
}
now := time.Now()
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: cfg.AppID,
UserOpenId: cfg.UserOpenId,
AccessToken: "user-access-token",
RefreshToken: "refresh-token",
ExpiresAt: now.Add(-time.Hour).UnixMilli(),
RefreshExpiresAt: now.Add(-time.Minute).UnixMilli(),
GrantedAt: now.Add(-24 * time.Hour).UnixMilli(),
Scope: "offline_access",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
got := Diagnose(context.Background(), f, cfg, false)
if got.User.Status != StatusMissing || got.User.Available {
t.Fatalf("user = %#v, want missing and unavailable", got.User)
}
if got.User.Hint == "" {
t.Fatalf("user hint is empty, want re-login hint")
}
}
func TestDiagnose_BotIdentityStrictUserOnly(t *testing.T) {
// SupportedIdentities = SupportsUser (1) only — bot path should be
// reported as not_configured even though an app secret is present.
cfg := &core.CliConfig{
AppID: "test-app",
AppSecret: "secret",
Brand: core.BrandFeishu,
SupportedIdentities: 1,
}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
got := Diagnose(context.Background(), f, cfg, false)
if got.Bot.Status != StatusNotConfigured || got.Bot.Available {
t.Fatalf("bot = %#v, want not_configured and unavailable", got.Bot)
}
}
func TestDiagnose_UserIdentityMissingAppConfig(t *testing.T) {
cfg := &core.CliConfig{Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
got := Diagnose(context.Background(), f, cfg, false)
if got.User.Status != StatusNotConfigured || got.User.Available {
t.Fatalf("user = %#v, want not_configured and unavailable", got.User)
}
}
func TestStatusMessage(t *testing.T) {
cases := map[string]string{
StatusReady: StatusReady,
StatusNotConfigured: "not configured",
StatusVerifyFailed: "verify failed",
StatusNeedsRefresh: "needs refresh",
StatusMissing: "missing",
"unknown": "unknown",
}
for in, want := range cases {
if got := StatusMessage(in); got != want {
t.Errorf("StatusMessage(%q) = %q, want %q", in, got, want)
}
}
}
func TestDiagnose_UserIdentityNeedsRefresh(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "test-app-needs-refresh",
AppSecret: "secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_refresh",
UserName: "tester",
}
now := time.Now()
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: cfg.AppID,
UserOpenId: cfg.UserOpenId,
AccessToken: "user-access-token",
RefreshToken: "refresh-token",
ExpiresAt: now.Add(time.Minute).UnixMilli(),
RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(),
GrantedAt: now.Add(-time.Hour).UnixMilli(),
Scope: "offline_access",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
got := Diagnose(context.Background(), f, cfg, false)
if got.User.Status != StatusNeedsRefresh || !got.User.Available {
t.Fatalf("user = %#v, want needs_refresh and available", got.User)
}
if got.User.TokenStatus != "needs_refresh" {
t.Fatalf("token status = %q, want needs_refresh", got.User.TokenStatus)
}
}

138
internal/sec/archive.go Normal file
View File

@@ -0,0 +1,138 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"archive/zip"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
)
// maxArchiveBytes is a sanity ceiling for total uncompressed size to prevent
// a malicious or corrupt zip from filling the disk. The lark-sec-cli zip is a
// single binary plus one shared library; 1 GiB is several orders of magnitude
// over the real size and well under most users' free disk.
const maxArchiveBytes = 1 << 30
// ExtractZip unpacks src into dst, refusing entries whose target paths would
// escape dst (zip slip). Existing files inside dst are overwritten; dst must
// already exist.
//
// Executable permission is preserved when the zip stores POSIX mode bits;
// otherwise we apply 0o755 to suspected binaries (matching BinaryName() /
// legacy names or anything *.dylib/*.so/*.dll) and 0o644 to everything else.
func ExtractZip(src, dst string) error {
r, err := zip.OpenReader(src)
if err != nil {
return fmt.Errorf("open zip: %w", err)
}
defer r.Close()
dstAbs, err := filepath.Abs(dst)
if err != nil {
return err
}
var totalSize uint64
for _, f := range r.File {
totalSize += f.UncompressedSize64
if totalSize > maxArchiveBytes {
return fmt.Errorf("zip exceeds %d bytes; refusing", maxArchiveBytes)
}
if err := extractZipEntry(f, dstAbs); err != nil {
return err
}
}
return nil
}
func extractZipEntry(f *zip.File, dstAbs string) error {
// Reject absolute paths and any traversal segments. filepath.Clean
// collapses redundant separators but does NOT resolve symlinks or strip
// leading slashes — we have to do both explicitly.
name := f.Name
if strings.ContainsRune(name, 0) {
return fmt.Errorf("zip entry name contains NUL: %q", name)
}
cleaned := filepath.Clean(name)
if filepath.IsAbs(cleaned) || strings.HasPrefix(cleaned, "..") ||
strings.Contains(cleaned, string(filepath.Separator)+".."+string(filepath.Separator)) {
return fmt.Errorf("zip entry escapes destination: %q", name)
}
target := filepath.Join(dstAbs, cleaned)
// Defense in depth: even if the checks above missed something, this rel
// check guarantees target is under dstAbs.
rel, err := filepath.Rel(dstAbs, target)
if err != nil || strings.HasPrefix(rel, "..") {
return fmt.Errorf("zip entry escapes destination: %q", name)
}
if f.FileInfo().IsDir() {
return os.MkdirAll(target, 0o755)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
// Symlink support: zip entries can be symlinks (mode bit set). The
// lark-sec-cli artifact doesn't currently use them, but if it grows to
// (e.g. for shared library version aliases) we want graceful handling.
if f.Mode()&os.ModeSymlink != 0 {
rc, err := f.Open()
if err != nil {
return err
}
linkBytes, readErr := io.ReadAll(io.LimitReader(rc, 1024))
rc.Close()
if readErr != nil {
return readErr
}
os.Remove(target) // os.Symlink fails if target exists
return os.Symlink(string(linkBytes), target)
}
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
mode := f.Mode().Perm()
if mode == 0 {
mode = guessMode(cleaned)
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
if _, err := io.Copy(out, rc); err != nil {
out.Close()
return err
}
return out.Close()
}
// guessMode supplies executable bits for entries the zip writer didn't tag
// with POSIX mode info — typically the case for archives built on Windows.
func guessMode(name string) os.FileMode {
base := filepath.Base(name)
if base == BinaryName() {
return 0o755
}
ext := strings.ToLower(filepath.Ext(base))
switch ext {
case ".dylib", ".so", ".dll":
return 0o755
}
if runtime.GOOS != "windows" && !strings.ContainsRune(base, '.') {
// Plausibly an extra unix binary shipped alongside the sec-cli binary.
return 0o755
}
return 0o644
}

View File

@@ -0,0 +1,121 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"archive/zip"
"bytes"
"io"
"os"
"path/filepath"
"testing"
)
// makeZip builds an in-memory zip with the given entries, writes it to path,
// and returns nothing — convenience for table-driven tests.
type zipEntry struct {
name string
body string
mode os.FileMode
symlink string // when set, entry is a symlink with this target
}
func makeZip(t *testing.T, path string, entries []zipEntry) {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, e := range entries {
hdr := &zip.FileHeader{Name: e.name, Method: zip.Deflate}
if e.mode != 0 {
hdr.SetMode(e.mode)
}
if e.symlink != "" {
hdr.SetMode(os.ModeSymlink | 0o777)
}
w, err := zw.CreateHeader(hdr)
if err != nil {
t.Fatalf("zip header %q: %v", e.name, err)
}
body := e.body
if e.symlink != "" {
body = e.symlink
}
if _, err := io.WriteString(w, body); err != nil {
t.Fatalf("zip write %q: %v", e.name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
if err := os.WriteFile(path, buf.Bytes(), 0o600); err != nil {
t.Fatalf("write zip: %v", err)
}
}
func TestExtractZip_HappyPath(t *testing.T) {
tmp := t.TempDir()
zipPath := filepath.Join(tmp, "src.zip")
makeZip(t, zipPath, []zipEntry{
{name: "lark-sec-cli", body: "binary", mode: 0o755},
{name: "ca.crt", body: "cert"},
{name: "lib/libMetaSecML.dylib", body: "dylib", mode: 0o755},
})
dst := filepath.Join(tmp, "out")
if err := os.MkdirAll(dst, 0o755); err != nil {
t.Fatal(err)
}
if err := ExtractZip(zipPath, dst); err != nil {
t.Fatalf("ExtractZip: %v", err)
}
for name, want := range map[string]string{
"lark-sec-cli": "binary",
"ca.crt": "cert",
"lib/libMetaSecML.dylib": "dylib",
} {
got, err := os.ReadFile(filepath.Join(dst, name))
if err != nil {
t.Errorf("read %s: %v", name, err)
continue
}
if string(got) != want {
t.Errorf("%s body = %q, want %q", name, got, want)
}
}
if info, err := os.Stat(filepath.Join(dst, "lark-sec-cli")); err == nil {
if info.Mode().Perm()&0o100 == 0 {
t.Errorf("lark-sec-cli not executable: mode=%v", info.Mode())
}
}
}
func TestExtractZip_RejectsTraversal(t *testing.T) {
tmp := t.TempDir()
zipPath := filepath.Join(tmp, "evil.zip")
makeZip(t, zipPath, []zipEntry{
{name: "../../../etc/passwd", body: "pwned"},
})
dst := filepath.Join(tmp, "out")
if err := os.MkdirAll(dst, 0o755); err != nil {
t.Fatal(err)
}
if err := ExtractZip(zipPath, dst); err == nil {
t.Fatal("ExtractZip accepted zip-slip entry")
}
}
func TestExtractZip_RejectsAbsolutePath(t *testing.T) {
tmp := t.TempDir()
zipPath := filepath.Join(tmp, "abs.zip")
makeZip(t, zipPath, []zipEntry{
{name: "/etc/passwd", body: "pwned"},
})
dst := filepath.Join(tmp, "out")
if err := os.MkdirAll(dst, 0o755); err != nil {
t.Fatal(err)
}
if err := ExtractZip(zipPath, dst); err == nil {
t.Fatal("ExtractZip accepted absolute-path entry")
}
}

33
internal/sec/bootstrap.go Normal file
View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
_ "embed"
"encoding/json"
"fmt"
)
// bootstrapManifestJSON is the lark-sec-cli release manifest shipped with this
// lark-cli build. It points directly at TOS so a fresh install does not depend
// on any external release-tracking service — first install is fully self-contained.
//
// Updating this file pins a new default version of lark-sec-cli for users who
// install via lark-cli. After install, lark-sec-cli is in charge of finding and
// applying its own updates; lark-cli does not consult any release server.
//
//go:embed bootstrap.json
var bootstrapManifestJSON []byte
// LoadBootstrap parses the embedded bootstrap manifest into a Manifest value.
func LoadBootstrap() (*Manifest, error) {
var entries []Entry
if err := json.Unmarshal(bootstrapManifestJSON, &entries); err != nil {
return nil, fmt.Errorf("decode embedded bootstrap manifest: %w", err)
}
if len(entries) == 0 {
return nil, fmt.Errorf("embedded bootstrap manifest is empty")
}
return &Manifest{Entries: entries}, nil
}

View File

@@ -0,0 +1,59 @@
[
{
"key": 0,
"buildPlatform": "linux",
"urls": [
{
"urls": {
"amd64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/linux-amd64/linux-amd64-1.0.1-alpha.23.zip",
"arm64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/linux-arm64/linux-arm64-1.0.1-alpha.23.zip"
},
"region": "cn"
}
],
"branch": "dev",
"version": "1.0.1-alpha.23",
"extra": {
"pipeline_id": "367354993",
"upload_date": 1778487420795
}
},
{
"key": 1,
"buildPlatform": "win32",
"urls": [
{
"urls": {
"x86": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/windows-386/windows-386-1.0.1-alpha.23.zip",
"amd64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/windows-amd64/windows-amd64-1.0.1-alpha.23.zip"
},
"region": "cn"
}
],
"branch": "dev",
"version": "1.0.1-alpha.23",
"extra": {
"pipeline_id": "367354993",
"upload_date": 1778487437393
}
},
{
"key": 2,
"buildPlatform": "darwin",
"urls": [
{
"urls": {
"amd64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/darwin-amd64/darwin-amd64-1.0.1-alpha.23.zip",
"arm64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/darwin-arm64/darwin-arm64-1.0.1-alpha.23.zip"
},
"region": "cn"
}
],
"branch": "dev",
"version": "1.0.1-alpha.23",
"extra": {
"pipeline_id": "367354993",
"upload_date": 1778487395152
}
}
]

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"runtime"
"strings"
"testing"
)
// TestLoadBootstrap_DecodesAllPlatforms guards against the embedded
// manifest becoming malformed or losing an OS — both would break first
// install on whatever GOOS lost its entry.
func TestLoadBootstrap_DecodesAllPlatforms(t *testing.T) {
manifest, err := LoadBootstrap()
if err != nil {
t.Fatalf("LoadBootstrap: %v", err)
}
platforms := map[string]bool{}
for _, e := range manifest.Entries {
platforms[e.BuildPlatform] = true
if e.Version == "" {
t.Errorf("entry %s missing version", e.BuildPlatform)
}
if e.Extra.PipelineID == "" {
t.Errorf("entry %s missing extra.pipeline_id", e.BuildPlatform)
}
}
for _, want := range []string{"darwin", "linux", "win32"} {
if !platforms[want] {
t.Errorf("bootstrap missing platform %q", want)
}
}
}
// TestLoadBootstrap_PickArtifactForCurrentHost ensures the embedded manifest
// resolves to a real URL for whatever platform the test runner is on, so a
// developer fixing this code locally can still smoke-test their changes.
func TestLoadBootstrap_PickArtifactForCurrentHost(t *testing.T) {
manifest, err := LoadBootstrap()
if err != nil {
t.Fatalf("LoadBootstrap: %v", err)
}
art, err := manifest.PickArtifact(runtime.GOOS, runtime.GOARCH, "cn")
if err != nil {
t.Fatalf("PickArtifact for %s/%s: %v", runtime.GOOS, runtime.GOARCH, err)
}
if !strings.HasPrefix(art.URL, "https://") {
t.Errorf("URL is not https: %q", art.URL)
}
if !strings.HasSuffix(art.URL, ".zip") {
t.Errorf("URL is not a .zip: %q", art.URL)
}
if art.BuildID == "" {
t.Error("BuildID is empty")
}
}

156
internal/sec/download.go Normal file
View File

@@ -0,0 +1,156 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"context"
"crypto/md5"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"hash"
"io"
"net/http"
"os"
)
// downloadMaxBytes caps the artifact size we'll accept. Comfortably over the
// real lark-sec-cli zip (~tens of MB) and well under what a malicious mirror
// could use to exhaust local disk before we noticed.
const downloadMaxBytes = 512 * 1024 * 1024
// DownloadOptions controls Download.
type DownloadOptions struct {
URL string
Destination string // full path to the .zip we'll create
HTTPClient *http.Client
// ExpectedSHA256, if non-empty, is the hex SHA256 the artifact MUST
// match — verified after the full body has been streamed. Use this when
// the manifest publishes a hash for the artifact (e.g. bootstrap.json's
// `extra.sha256`). Any mismatch fails the download with the .part file
// removed.
//
// When empty (the manifest doesn't carry a hash), the only integrity
// check left is the CDN's own `Content-MD5` response header, applied
// opportunistically below.
ExpectedSHA256 string
}
// Download streams URL to Destination. Writes to a sibling .part file and
// renames into place on success so a crashed or aborted run leaves no
// half-written zip the next run might mistake for valid.
//
// Two layers of integrity check, both opt-in:
//
// 1. ExpectedSHA256 (strong, manifest-provided): cryptographic, fails the
// download on mismatch. Use whenever the release manifest carries a hash.
// 2. CDN `Content-MD5` header (opportunistic): non-cryptographic, catches
// edge replacement or transit corruption when the upstream CDN populates
// the header. Runs unconditionally — if the header is present we honour it.
//
// Neither check defends against a malicious upstream that controls both the
// artifact AND the manifest. That class of risk has to be handled by signing
// the release pipeline, which is out of scope for the client.
func Download(ctx context.Context, opts DownloadOptions) error {
if opts.HTTPClient == nil {
return fmt.Errorf("Download: HTTPClient is required")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, opts.URL, nil)
if err != nil {
return err
}
resp, err := opts.HTTPClient.Do(req)
if err != nil {
return fmt.Errorf("download %s: %w", opts.URL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download %s: status %d", opts.URL, resp.StatusCode)
}
tmpPath := opts.Destination + ".part"
out, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
cleanup := func() { out.Close(); os.Remove(tmpPath) }
// Hash both ways during the single read pass. Both hashers are cheap and
// we don't know yet which check (or both) we'll actually need.
sha := sha256.New()
md := md5.New()
writer := io.MultiWriter(out, sha, md)
n, err := io.Copy(writer, io.LimitReader(resp.Body, downloadMaxBytes+1))
if err != nil {
cleanup()
return fmt.Errorf("download %s: %w", opts.URL, err)
}
if n > downloadMaxBytes {
cleanup()
return fmt.Errorf("download %s: exceeds %d bytes", opts.URL, downloadMaxBytes)
}
if err := out.Sync(); err != nil {
cleanup()
return err
}
if err := out.Close(); err != nil {
os.Remove(tmpPath)
return err
}
if err := verifyChecksums(resp, opts.ExpectedSHA256, sha, md); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("download %s: %w", opts.URL, err)
}
if err := os.Rename(tmpPath, opts.Destination); err != nil {
os.Remove(tmpPath)
return err
}
return nil
}
// verifyChecksums applies the two-layer integrity check after the body has
// been fully streamed. Returns nil when both layers (whichever apply) agree.
func verifyChecksums(resp *http.Response, expectedSHA256 string, sha, md hash.Hash) error {
if expectedSHA256 != "" {
got := hex.EncodeToString(sha.Sum(nil))
if !equalFoldHex(got, expectedSHA256) {
return fmt.Errorf("sha256 mismatch: expected %s, got %s", expectedSHA256, got)
}
}
if cdnMD5 := resp.Header.Get("Content-MD5"); cdnMD5 != "" {
got := base64.StdEncoding.EncodeToString(md.Sum(nil))
if got != cdnMD5 {
return fmt.Errorf("content-md5 mismatch: cdn=%s, computed=%s", cdnMD5, got)
}
}
return nil
}
// equalFoldHex is a non-allocating ASCII case-insensitive compare for hex
// strings. SHA256 manifests sometimes ship uppercase, sometimes lowercase.
func equalFoldHex(a, b string) bool {
if len(a) != len(b) {
return false
}
for i := 0; i < len(a); i++ {
ca, cb := a[i], b[i]
if 'A' <= ca && ca <= 'Z' {
ca += 'a' - 'A'
}
if 'A' <= cb && cb <= 'Z' {
cb += 'a' - 'A'
}
if ca != cb {
return false
}
}
return true
}

View File

@@ -0,0 +1,184 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"context"
"crypto/md5"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
const bodyContent = "lark-sec-cli pretend zip bytes"
// fixtureSHA256 / fixtureMD5 are the hashes of bodyContent.
var fixtureSHA256 string
var fixtureMD5b64 string
func init() {
sum := sha256.Sum256([]byte(bodyContent))
fixtureSHA256 = hex.EncodeToString(sum[:])
m := md5.Sum([]byte(bodyContent))
fixtureMD5b64 = base64.StdEncoding.EncodeToString(m[:])
}
func newFixtureServer(t *testing.T, setContentMD5 bool) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if setContentMD5 {
w.Header().Set("Content-MD5", fixtureMD5b64)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(bodyContent))
}))
}
// TestDownload_HappyPath_NoChecksum confirms that a download with no manifest
// SHA and no CDN MD5 succeeds — the integrity hooks are opt-in, not required.
func TestDownload_HappyPath_NoChecksum(t *testing.T) {
srv := newFixtureServer(t, false)
defer srv.Close()
dst := filepath.Join(t.TempDir(), "out.zip")
err := Download(context.Background(), DownloadOptions{
URL: srv.URL,
Destination: dst,
HTTPClient: srv.Client(),
})
if err != nil {
t.Fatalf("Download: %v", err)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
if string(got) != bodyContent {
t.Errorf("body roundtrip mismatch")
}
}
// TestDownload_SHA256_Match confirms the manifest-provided SHA256 path
// passes for a correct hash. Tests both cases (with and without CDN MD5)
// so the second layer doesn't interfere.
func TestDownload_SHA256_Match(t *testing.T) {
for _, withMD5 := range []bool{false, true} {
name := "noMD5"
if withMD5 {
name = "withCDNMd5"
}
t.Run(name, func(t *testing.T) {
srv := newFixtureServer(t, withMD5)
defer srv.Close()
dst := filepath.Join(t.TempDir(), "out.zip")
err := Download(context.Background(), DownloadOptions{
URL: srv.URL,
Destination: dst,
HTTPClient: srv.Client(),
ExpectedSHA256: fixtureSHA256,
})
if err != nil {
t.Fatalf("Download: %v", err)
}
})
}
}
// TestDownload_SHA256_Mismatch is the safety property: a wrong manifest hash
// rejects the download AND removes the .part file so the next run doesn't
// pick up a poisoned zip.
func TestDownload_SHA256_Mismatch(t *testing.T) {
srv := newFixtureServer(t, false)
defer srv.Close()
dst := filepath.Join(t.TempDir(), "out.zip")
err := Download(context.Background(), DownloadOptions{
URL: srv.URL,
Destination: dst,
HTTPClient: srv.Client(),
ExpectedSHA256: "0000000000000000000000000000000000000000000000000000000000000000",
})
if err == nil {
t.Fatal("expected sha256 mismatch error")
}
if !strings.Contains(err.Error(), "sha256 mismatch") {
t.Errorf("error should mention sha256 mismatch: %v", err)
}
if _, statErr := os.Stat(dst); statErr == nil {
t.Errorf("dst should not exist after mismatch")
}
if _, statErr := os.Stat(dst + ".part"); statErr == nil {
t.Errorf(".part should not exist after mismatch")
}
}
// TestDownload_ContentMD5_Mismatch confirms the opportunistic check fires
// even when no manifest SHA was provided. Catches a CDN edge that returned
// content but a stale/wrong Content-MD5 header (or a poisoned proxy).
func TestDownload_ContentMD5_Mismatch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-MD5", "Z3JhZmFuYTpyZWFsbHk/Pz8/Pz8/PzA9PT0=") // arbitrary
_, _ = w.Write([]byte(bodyContent))
}))
defer srv.Close()
dst := filepath.Join(t.TempDir(), "out.zip")
err := Download(context.Background(), DownloadOptions{
URL: srv.URL,
Destination: dst,
HTTPClient: srv.Client(),
})
if err == nil {
t.Fatal("expected content-md5 mismatch error")
}
if !strings.Contains(err.Error(), "content-md5 mismatch") {
t.Errorf("error should mention content-md5 mismatch: %v", err)
}
}
// TestDownload_SHA256_CaseInsensitive guards the hex compare against case
// drift in the manifest (some publishers upper-case).
func TestDownload_SHA256_CaseInsensitive(t *testing.T) {
srv := newFixtureServer(t, false)
defer srv.Close()
dst := filepath.Join(t.TempDir(), "out.zip")
err := Download(context.Background(), DownloadOptions{
URL: srv.URL,
Destination: dst,
HTTPClient: srv.Client(),
ExpectedSHA256: strings.ToUpper(fixtureSHA256),
})
if err != nil {
t.Fatalf("Download (uppercase sha): %v", err)
}
}
// TestDownload_404_NoPartFile confirms that a non-200 response leaves no
// .part file behind to confuse the next attempt.
func TestDownload_404_NoPartFile(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
dst := filepath.Join(t.TempDir(), "out.zip")
err := Download(context.Background(), DownloadOptions{
URL: srv.URL,
Destination: dst,
HTTPClient: srv.Client(),
})
if err == nil {
t.Fatal("expected error for 404")
}
if _, statErr := os.Stat(dst + ".part"); statErr == nil {
t.Errorf(".part should not exist after 404")
}
}

297
internal/sec/install.go Normal file
View File

@@ -0,0 +1,297 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"runtime"
"time"
"github.com/larksuite/cli/internal/client"
)
// Installer orchestrates first-time install of lark-sec-cli:
// fetch remote manifest via OAPI → download zip → extract into
// versions/<version>/ → swap "current" → write state.json.
//
// After this first install, lark-sec-cli takes over its own updates and
// lark-cli is no longer in the update path. The installer therefore only
// knows about the bootstrap path — no Tron, no other release sources.
type Installer struct {
Paths *Paths
HTTPClient *http.Client
// APIClientFunc resolves the OAPI client lazily. It is invoked only when
// the install pipeline actually needs to fetch the remote manifest —
// short-circuits (and other callers of installer() that don't install,
// like sec status / sec stop) avoid keychain decryption entirely.
APIClientFunc func() (*client.APIClient, error)
}
// InstallOptions tunes a single Install call.
type InstallOptions struct {
// Force re-runs the pipeline even when an install already exists. Used by
// `sec install --force` for repair / re-pinning to the bundled bootstrap.
Force bool
// Region selects which region's URLs to pick from the manifest. Defaults to
// DefaultRegion ("cn"). Reserved for future brand split.
Region string
// Verbose, when non-nil, is the destination for step-by-step trace output.
// nil = silent (production default); typically set to stderr by `sec install -v`.
Verbose io.Writer
}
// tracef writes one trace line to w if w is non-nil.
func tracef(w io.Writer, format string, args ...any) {
if w == nil {
return
}
fmt.Fprintf(w, "[sec install] "+format+"\n", args...)
}
// Install runs the bootstrap pipeline and returns the new State on success.
// If a usable install already exists on disk and Force is false, returns the
// existing state unchanged (no network call).
func (i *Installer) Install(ctx context.Context, opts InstallOptions) (*State, error) {
v := opts.Verbose
tracef(v, "ensuring sec paths under %s", i.Paths.InstallDir())
if err := i.Paths.Ensure(); err != nil {
return nil, err
}
tracef(v, "loading existing state from %s", i.Paths.StateFile())
existing, err := LoadState(i.Paths.StateFile())
if err != nil {
return nil, fmt.Errorf("load sec state: %w", err)
}
if existing != nil {
tracef(v, "existing state: version=%s binary=%s", existing.Version, existing.BinaryPath)
} else {
tracef(v, "no existing state on disk")
}
// Idempotent short-circuit: nothing to do if an install is already on disk.
// Self-upgrades after bootstrap are lark-sec-cli's job, not ours — see the
// upgrade subsystem in lark-sec-cli/internal/upgrade/.
if !opts.Force && existing != nil && binaryReady(existing.BinaryPath) {
tracef(v, "binary exists at %s — short-circuiting (no network)", existing.BinaryPath)
return existing, nil
}
if opts.Force {
tracef(v, "--force set; running full install pipeline")
} else {
tracef(v, "no usable install on disk; running full install pipeline")
}
region := opts.Region
if region == "" {
region = DefaultRegion
}
tracef(v, "region=%s", region)
if i.APIClientFunc == nil {
return nil, errors.New("sec installer: APIClientFunc is required to fetch remote manifest")
}
tracef(v, "resolving OAPI client (will decrypt credentials)")
apiClient, err := i.APIClientFunc()
if err != nil {
return nil, fmt.Errorf("resolve api client: %w", err)
}
platform, arch, err := CurrentPlatformArch()
if err != nil {
return nil, err
}
tracef(v, "detected platform=%s arch=%s", platform, arch)
tracef(v, "fetching remote manifest from %s", secCliManifestPath)
rm, err := FetchRemoteManifest(ctx, apiClient, region, platform, arch, v)
if err != nil {
return nil, err
}
tracef(v, "manifest returned %d url(s): %v", len(rm.URLs), rm.URLs)
downloadURL := rm.URLs[0]
tracef(v, "picked downloadURL=%s", downloadURL)
version, err := versionFromURL(downloadURL)
if err != nil {
return nil, err
}
tracef(v, "parsed version=%s", version)
versionDir := i.Paths.VersionDir(version)
tracef(v, "creating versionDir=%s", versionDir)
if err := os.MkdirAll(versionDir, 0o755); err != nil {
return nil, err
}
zipPath := filepath.Join(i.Paths.VersionsDir(), version+".zip")
tracef(v, "downloading %s -> %s", downloadURL, zipPath)
if err := Download(ctx, DownloadOptions{
URL: downloadURL,
Destination: zipPath,
HTTPClient: i.HTTPClient,
}); err != nil {
return nil, err
}
if info, statErr := os.Stat(zipPath); statErr == nil {
tracef(v, "downloaded %d bytes", info.Size())
}
defer os.Remove(zipPath) // free disk; we keep the unpacked version dir
tracef(v, "extracting %s -> %s", zipPath, versionDir)
if err := ExtractZip(zipPath, versionDir); err != nil {
return nil, err
}
binaryPath, err := locateBinary(versionDir)
if err != nil {
return nil, err
}
tracef(v, "located binary at %s", binaryPath)
// Ensure executable bit on POSIX — some zips lose it.
if runtime.GOOS != "windows" {
if info, err := os.Stat(binaryPath); err == nil {
_ = os.Chmod(binaryPath, info.Mode()|0o100|0o010|0o001)
}
}
tracef(v, "swapping %s -> %s", i.Paths.CurrentLink(), versionDir)
if err := swapCurrent(i.Paths.CurrentLink(), versionDir); err != nil {
return nil, fmt.Errorf("swap current: %w", err)
}
tracef(v, "writing state.json to %s", i.Paths.StateFile())
state := &State{
Version: version,
InstalledAt: time.Now().UTC(),
BinaryPath: i.Paths.BinaryPath(),
}
if err := SaveState(i.Paths.StateFile(), state); err != nil {
return nil, err
}
return state, nil
}
// locateBinary handles two artifact layouts: flat (zip root has the binary)
// and nested (zip root is a single dir containing the binary). The bootstrap
// manifest's example payload uses nested ("linux-amd64-1.0.1-alpha.23/...");
// we accommodate either since the wrapping dir name could change per build.
func locateBinary(versionDir string) (string, error) {
name := BinaryName()
flat := filepath.Join(versionDir, name)
if _, err := os.Stat(flat); err == nil {
return flat, nil
}
var found string
walkErr := filepath.WalkDir(versionDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && d.Name() == name {
found = path
return fs.SkipAll
}
return nil
})
if walkErr != nil {
return "", walkErr
}
if found == "" {
return "", fmt.Errorf("binary %q not found under %s", name, versionDir)
}
// Promote the binary's parent to be versionDir so "current" → versionDir
// produces a predictable layout. Move the *contents* up rather than the
// binary alone, because shared libs may sit beside it.
parent := filepath.Dir(found)
if parent != versionDir {
entries, err := os.ReadDir(parent)
if err != nil {
return "", err
}
for _, e := range entries {
if err := os.Rename(filepath.Join(parent, e.Name()), filepath.Join(versionDir, e.Name())); err != nil {
return "", err
}
}
_ = os.Remove(parent)
}
return filepath.Join(versionDir, name), nil
}
// swapCurrent atomically points <install>/current at versionDir. On POSIX
// we use a symlink with the standard rename-into-place trick; on Windows we
// fall back to removing the directory and copying, since junctions need
// admin / developer-mode privileges we may not have.
func swapCurrent(link, versionDir string) error {
if runtime.GOOS == "windows" {
// Remove any existing target then copy. This is non-atomic, but
// concurrent installs on the same Windows host are not a use case
// we support — `sec install` runs interactively.
if err := os.RemoveAll(link); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return copyDir(versionDir, link)
}
tmp := link + ".new"
_ = os.Remove(tmp)
if err := os.Symlink(versionDir, tmp); err != nil {
return err
}
return os.Rename(tmp, link)
}
func copyDir(src, dst string) error {
if err := os.MkdirAll(dst, 0o755); err != nil {
return err
}
return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, p)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
info, err := d.Info()
if err != nil {
return err
}
in, err := os.Open(p)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm())
if err != nil {
return err
}
_, copyErr := io.Copy(out, in)
closeErr := out.Close()
if copyErr != nil {
return copyErr
}
return closeErr
})
}
func binaryReady(path string) bool {
if path == "" {
return false
}
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}

139
internal/sec/manifest.go Normal file
View File

@@ -0,0 +1,139 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"fmt"
"runtime"
)
// Manifest describes a lark-sec-cli release set: one Entry per build platform,
// each carrying one or more region-scoped URL maps keyed by arch. It's what we
// embed at build time as the bootstrap manifest. After bootstrap, lark-sec-cli
// queries its own release source for updates — lark-cli is uninvolved.
type Manifest struct {
Entries []Entry
}
// Entry is one row of the bootstrap manifest, one per published platform.
type Entry struct {
Key int `json:"key"`
BuildPlatform string `json:"buildPlatform"` // "darwin" | "linux" | "win32"
URLs []RegionURLs `json:"urls"`
Branch string `json:"branch"`
Version string `json:"version"`
Extra EntryExtra `json:"extra"`
}
// RegionURLs maps an arch ("amd64", "arm64", "x86") to its download URL,
// scoped to a region ("cn" today; reserved for future brand split).
type RegionURLs struct {
URLs map[string]string `json:"urls"`
Region string `json:"region"`
}
// EntryExtra is metadata the release pipeline emits alongside each artifact.
// PipelineID is the build identifier lark-sec-cli will later forward to its
// own update server when checking for new versions. SHA256 (when present) is
// the hex-encoded hash of the zip artifact; the installer fails the download
// on mismatch. Manifests built before the release pipeline added the field
// leave it empty, in which case integrity falls back to the CDN's own
// Content-MD5 header.
type EntryExtra struct {
PipelineID string `json:"pipeline_id"`
UploadDate int64 `json:"upload_date"`
SHA256 string `json:"sha256,omitempty"`
}
// Artifact is the resolved download target after platform/arch/region selection.
type Artifact struct {
URL string
Version string
BuildID string // pipeline_id — recorded in state.json so lark-sec-cli knows what it was installed at
SHA256 string // hex-encoded; empty when the manifest doesn't carry one
}
// PickArtifact selects the right Entry for the current GOOS/GOARCH and the
// requested region. Returns a clear error explaining which combination was
// missing so users can tell whether the build was never published or just not
// for their platform.
func (m *Manifest) PickArtifact(goos, goarch, region string) (*Artifact, error) {
platform, err := platformKey(goos)
if err != nil {
return nil, err
}
arch, err := archKey(goos, goarch)
if err != nil {
return nil, err
}
for _, e := range m.Entries {
if e.BuildPlatform != platform {
continue
}
for _, ru := range e.URLs {
if ru.Region != region {
continue
}
url, ok := ru.URLs[arch]
if !ok || url == "" {
continue
}
return &Artifact{
URL: url,
Version: e.Version,
BuildID: e.Extra.PipelineID,
SHA256: e.Extra.SHA256,
}, nil
}
}
return nil, fmt.Errorf("no artifact for platform=%s arch=%s region=%s", platform, arch, region)
}
// platformKey maps Go's GOOS to the manifest's buildPlatform enum.
func platformKey(goos string) (string, error) {
switch goos {
case "darwin":
return "darwin", nil
case "linux":
return "linux", nil
case "windows":
return "win32", nil
default:
return "", fmt.Errorf("unsupported GOOS: %s", goos)
}
}
// archKey maps Go's GOARCH to the arch key the manifest uses inside RegionURLs.URLs.
// Windows 32-bit ships under "x86" while POSIX 32-bit (e.g. 386 on linux) is not
// currently published — surface that as an error rather than silently falling back.
func archKey(goos, goarch string) (string, error) {
switch goarch {
case "amd64":
return "amd64", nil
case "arm64":
return "arm64", nil
case "386":
if goos == "windows" {
return "x86", nil
}
return "", fmt.Errorf("32-bit %s is not published", goos)
default:
return "", fmt.Errorf("unsupported GOARCH: %s", goarch)
}
}
// CurrentPlatformArch is a convenience for the install flow.
func CurrentPlatformArch() (platform, arch string, err error) {
platform, err = platformKey(runtime.GOOS)
if err != nil {
return "", "", err
}
arch, err = archKey(runtime.GOOS, runtime.GOARCH)
return platform, arch, err
}
// DefaultRegion is the only region published today for bootstrap installs.
// Kept here for callers that still want a single source of truth.
const DefaultRegion = "cn"

View File

@@ -0,0 +1,108 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"testing"
)
// sampleManifest is the manifest example baked into bootstrap.json, trimmed to
// the three published platforms. PickArtifact must select the right URL for
// each GOOS/GOARCH combination.
func sampleManifest() *Manifest {
return &Manifest{Entries: []Entry{
{
Key: 0,
BuildPlatform: "linux",
Branch: "dev",
Version: "1.0.1-alpha.23",
Extra: EntryExtra{PipelineID: "367354993"},
URLs: []RegionURLs{{
Region: "cn",
URLs: map[string]string{
"amd64": "https://cdn/linux-amd64.zip",
"arm64": "https://cdn/linux-arm64.zip",
},
}},
},
{
Key: 1,
BuildPlatform: "win32",
Branch: "dev",
Version: "1.0.1-alpha.23",
Extra: EntryExtra{PipelineID: "367354993"},
URLs: []RegionURLs{{
Region: "cn",
URLs: map[string]string{
"x86": "https://cdn/win-386.zip",
"amd64": "https://cdn/win-amd64.zip",
},
}},
},
{
Key: 2,
BuildPlatform: "darwin",
Branch: "dev",
Version: "1.0.1-alpha.23",
Extra: EntryExtra{PipelineID: "367354993"},
URLs: []RegionURLs{{
Region: "cn",
URLs: map[string]string{
"amd64": "https://cdn/darwin-amd64.zip",
"arm64": "https://cdn/darwin-arm64.zip",
},
}},
},
}}
}
func TestPickArtifact_HappyPath(t *testing.T) {
m := sampleManifest()
cases := []struct {
goos, goarch string
wantURL string
}{
{"darwin", "arm64", "https://cdn/darwin-arm64.zip"},
{"darwin", "amd64", "https://cdn/darwin-amd64.zip"},
{"linux", "amd64", "https://cdn/linux-amd64.zip"},
{"linux", "arm64", "https://cdn/linux-arm64.zip"},
{"windows", "amd64", "https://cdn/win-amd64.zip"},
{"windows", "386", "https://cdn/win-386.zip"},
}
for _, c := range cases {
t.Run(c.goos+"/"+c.goarch, func(t *testing.T) {
art, err := m.PickArtifact(c.goos, c.goarch, "cn")
if err != nil {
t.Fatalf("PickArtifact: %v", err)
}
if art.URL != c.wantURL {
t.Errorf("URL = %q, want %q", art.URL, c.wantURL)
}
if art.Version != "1.0.1-alpha.23" {
t.Errorf("Version = %q", art.Version)
}
if art.BuildID != "367354993" {
t.Errorf("BuildID = %q", art.BuildID)
}
})
}
}
func TestPickArtifact_Linux386Rejected(t *testing.T) {
if _, err := sampleManifest().PickArtifact("linux", "386", "cn"); err == nil {
t.Fatal("expected error for linux/386 (not published)")
}
}
func TestPickArtifact_UnknownRegion(t *testing.T) {
if _, err := sampleManifest().PickArtifact("darwin", "arm64", "sg"); err == nil {
t.Fatal("expected error for region=sg (not present in fixture)")
}
}
func TestPickArtifact_UnsupportedOS(t *testing.T) {
if _, err := sampleManifest().PickArtifact("plan9", "amd64", "cn"); err == nil {
t.Fatal("expected error for plan9")
}
}

146
internal/sec/paths.go Normal file
View File

@@ -0,0 +1,146 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package sec manages the first-time bootstrap install of the lark-sec-cli
// sidecar from lark-cli's side: download the artifact, lay it out on disk,
// record what version landed. Runtime lifecycle (start / stop / status) is
// handled by shelling out to lark-sec-cli's own `service enable / disable /
// status` commands, so we don't need pid files / env capture / log tees here.
// Updates after install are lark-sec-cli's responsibility, not lark-cli's.
package sec
import (
"errors"
"os"
"path/filepath"
"runtime"
)
const (
// envInstallDirOverride lets tests and power users redirect the entire sec
// tree (install + data) to a single root. When set, install_dir is <root>
// and data_dir is <root>/data — no platform-conventional lookup happens.
envInstallDirOverride = "LARKSUITE_CLI_SEC_DIR"
)
// BinaryName returns the executable basename inside the sec-cli artifact for
// the current platform:
//
// darwin → libLarkEntCli.dylib
// linux → liblarkentcli.so
// windows → lark_enterprise_cli.exe
//
// The .dylib/.so extensions on POSIX are convention only — those files are
// normal Mach-O / ELF executables, not loadable libraries.
func BinaryName() string {
switch runtime.GOOS {
case "darwin":
return "libLarkEntCli.dylib"
case "windows":
return "lark_enterprise_cli.exe"
default:
return "liblarkentcli.so"
}
}
// Paths exposes the filesystem layout for the sec sidecar. All methods return
// absolute paths; nothing on disk is created — callers must call Ensure().
type Paths struct {
install string
data string
}
// DefaultPaths returns Paths rooted at the platform-conventional user data dir,
// or at $LARKSUITE_CLI_SEC_DIR when set.
func DefaultPaths() (*Paths, error) {
if root := os.Getenv(envInstallDirOverride); root != "" {
return &Paths{install: root, data: filepath.Join(root, "data")}, nil
}
install, data, err := platformDirs()
if err != nil {
return nil, err
}
return &Paths{install: install, data: data}, nil
}
// platformDirs returns (install_dir, data_dir) for the current OS, applying
// per-platform conventions:
//
// macOS install = data = ~/Library/Application Support/lark-cli/sec
// Linux install = $XDG_DATA_HOME/lark-cli/sec (fallback ~/.local/share/...)
// data = $XDG_STATE_HOME/lark-cli/sec (fallback ~/.local/state/...)
// Windows install = data = %LOCALAPPDATA%\lark-cli\sec
//
// Linux splits install/data along XDG lines; macOS and Windows colocate them
// because their conventions don't distinguish "share" from "state" at the
// per-user level.
func platformDirs() (install, data string, err error) {
home, err := os.UserHomeDir()
if err != nil {
return "", "", err
}
switch runtime.GOOS {
case "darwin":
base := filepath.Join(home, "Library", "Application Support", "lark-cli", "sec")
return base, filepath.Join(base, "data"), nil
case "windows":
appData := os.Getenv("LOCALAPPDATA")
if appData == "" {
return "", "", errors.New("LOCALAPPDATA is not set")
}
base := filepath.Join(appData, "lark-cli", "sec")
return base, filepath.Join(base, "data"), nil
case "linux":
dataHome := os.Getenv("XDG_DATA_HOME")
if dataHome == "" {
dataHome = filepath.Join(home, ".local", "share")
}
stateHome := os.Getenv("XDG_STATE_HOME")
if stateHome == "" {
stateHome = filepath.Join(home, ".local", "state")
}
return filepath.Join(dataHome, "lark-cli", "sec"),
filepath.Join(stateHome, "lark-cli", "sec"),
nil
default:
base := filepath.Join(home, ".lark-cli", "sec")
return base, filepath.Join(base, "data"), nil
}
}
// Ensure creates the directories the installer writes into.
func (p *Paths) Ensure() error {
for _, d := range []string{p.install, p.data, p.VersionsDir()} {
if err := os.MkdirAll(d, 0o700); err != nil {
return err
}
}
return nil
}
// InstallDir is the root for binaries and version trees.
func (p *Paths) InstallDir() string { return p.install }
// DataDir is the root for state.json (and anything else lark-cli persists
// about the install — currently just state.json).
func (p *Paths) DataDir() string { return p.data }
// VersionsDir stores each unpacked release: versions/<version>/<files>.
func (p *Paths) VersionsDir() string { return filepath.Join(p.install, "versions") }
// VersionDir is the unpack target for a specific version string.
func (p *Paths) VersionDir(version string) string {
return filepath.Join(p.VersionsDir(), version)
}
// CurrentLink points to the active version (symlink on POSIX, plain copy on Windows).
func (p *Paths) CurrentLink() string { return filepath.Join(p.install, "current") }
// BinaryPath is the active sec-cli executable, addressed through the
// `current` symlink so it stays valid across version swaps.
func (p *Paths) BinaryPath() string {
return filepath.Join(p.CurrentLink(), BinaryName())
}
// StateFile records what version is installed and where its binary lives.
func (p *Paths) StateFile() string { return filepath.Join(p.data, "state.json") }

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestDefaultPaths_OverrideViaEnv(t *testing.T) {
dir := t.TempDir()
t.Setenv(envInstallDirOverride, dir)
p, err := DefaultPaths()
if err != nil {
t.Fatalf("DefaultPaths: %v", err)
}
if p.InstallDir() != dir {
t.Errorf("InstallDir = %q, want %q", p.InstallDir(), dir)
}
if p.DataDir() != filepath.Join(dir, "data") {
t.Errorf("DataDir = %q, want %s/data", p.DataDir(), dir)
}
if !strings.HasPrefix(p.StateFile(), dir) {
t.Errorf("StateFile not under override root: %q", p.StateFile())
}
}
func TestPaths_Ensure(t *testing.T) {
dir := t.TempDir()
t.Setenv(envInstallDirOverride, dir)
p, err := DefaultPaths()
if err != nil {
t.Fatalf("DefaultPaths: %v", err)
}
if err := p.Ensure(); err != nil {
t.Fatalf("Ensure: %v", err)
}
for _, d := range []string{p.InstallDir(), p.DataDir(), p.VersionsDir()} {
info, err := os.Stat(d)
if err != nil {
t.Errorf("missing %s: %v", d, err)
continue
}
if !info.IsDir() {
t.Errorf("%s is not a directory", d)
}
}
}

View File

@@ -0,0 +1,113 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"regexp"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
)
// secCliManifestPath is the OAPI endpoint that returns the per-platform
// download URLs for lark-sec-cli, gated by tenant_access_token.
const secCliManifestPath = "/open-apis/security_plugin/v1/sec_cli/manifest"
// xTTEnvEnv, when set, injects an x-tt-env header on the manifest request.
// Used for BOE / sub-environment routing (e.g. value "boe_tns_api"). Unset
// in prod — the gateway treats absence as "no override". This is the only
// debug-routing knob in this file; brand/domain switching itself is handled
// at the network layer via the lark-env.sh Whistle pattern in the
// lark-cli maintainer doc.
const xTTEnvEnv = "LARKSUITE_CLI_X_TT_ENV"
// RemoteManifest is the payload returned by GET /open-apis/security_plugin/v1/sec_cli/manifest
// for a single (region, platform, arch) combination. The server returns only
// the download URLs; version metadata is parsed from the URL itself (see
// versionFromURL).
type RemoteManifest struct {
URLs []string `json:"urls"`
}
// FetchRemoteManifest calls the OAPI manifest endpoint with TAT (bot) auth
// and returns the typed payload for the given region/platform/arch. When the
// LARKSUITE_CLI_X_TT_ENV env var is set, its value is sent as an x-tt-env
// request header for sub-environment routing.
//
// Errors are returned as-is — there is no fallback to the embedded
// bootstrap manifest. Callers that need offline behavior must handle that
// explicitly.
func FetchRemoteManifest(
ctx context.Context,
ac *client.APIClient,
region, platform, arch string,
verbose io.Writer,
) (*RemoteManifest, error) {
req := &larkcore.ApiReq{
HttpMethod: "GET",
ApiPath: secCliManifestPath,
QueryParams: larkcore.QueryParams{
"region": []string{region},
"platform": []string{platform},
"arch": []string{arch},
},
}
tracef(verbose, "GET %s?region=%s&platform=%s&arch=%s as=bot", secCliManifestPath, region, platform, arch)
var extraOpts []larkcore.RequestOptionFunc
if v := os.Getenv(xTTEnvEnv); v != "" {
h := http.Header{}
h.Set("x-tt-env", v)
extraOpts = append(extraOpts, larkcore.WithHeaders(h))
tracef(verbose, "injecting header x-tt-env=%s (from %s)", v, xTTEnvEnv)
}
resp, err := ac.DoSDKRequest(ctx, req, core.AsBot, extraOpts...)
if err != nil {
return nil, fmt.Errorf("sec_cli manifest request: %w", err)
}
tracef(verbose, "response status=%d body-len=%d body=%q", resp.StatusCode, len(resp.RawBody), string(resp.RawBody))
var env struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data *RemoteManifest `json:"data"`
}
if err := json.Unmarshal(resp.RawBody, &env); err != nil {
// Print body unconditionally on decode failure — a malformed response is
// the most common case where the caller needs to see exactly what arrived.
fmt.Fprintf(os.Stderr, "[sec_cli manifest] decode failed; status=%d len=%d body=%q\n", resp.StatusCode, len(resp.RawBody), string(resp.RawBody))
return nil, fmt.Errorf("sec_cli manifest decode: %w", err)
}
if env.Code != 0 {
return nil, fmt.Errorf("sec_cli manifest error %d: %s", env.Code, env.Msg)
}
if env.Data == nil || len(env.Data.URLs) == 0 {
return nil, fmt.Errorf("sec_cli manifest: no urls for region=%s platform=%s arch=%s", region, platform, arch)
}
return env.Data, nil
}
// versionFromURL extracts the release version from a download URL of the form
// .../releases/<version>/<pipeline-id>/<platform-arch>/<archive>.zip
// The server-side manifest does not return version as a discrete field;
// state.json's Version needs *something* to disambiguate concurrent installs
// in versions/<version>/, so we parse it out here.
var releaseVersionRE = regexp.MustCompile(`/releases/([^/]+)/`)
func versionFromURL(u string) (string, error) {
m := releaseVersionRE.FindStringSubmatch(u)
if len(m) < 2 || m[1] == "" {
return "", fmt.Errorf("could not parse release version from URL %q", u)
}
return m[1], nil
}

79
internal/sec/state.go Normal file
View File

@@ -0,0 +1,79 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"encoding/json"
"errors"
"fmt"
"os"
"time"
)
// State is the JSON document at <data>/state.json describing the currently
// installed lark-sec-cli artifact. It is the source of truth for what binary
// to launch. After bootstrap install lark-sec-cli may upgrade itself in
// place — when that happens this state file is informational only; the
// daemon owns its own canonical version state.
type State struct {
Version string `json:"version"`
BuildID string `json:"build_id"`
InstalledAt time.Time `json:"installed_at"`
BinaryPath string `json:"binary_path"`
}
// LoadState reads state.json. Returns (nil, nil) when the file is absent —
// callers treat that as "not yet installed". Decode errors are surfaced
// so a corrupt file is never silently overwritten.
func LoadState(path string) (*State, error) {
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var s State
if err := json.Unmarshal(data, &s); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return &s, nil
}
// SaveState writes state.json atomically: a tmpfile next to the target is
// fsynced then renamed in, so concurrent readers either see the previous
// state or the new one — never a torn write.
func SaveState(path string, s *State) error {
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
tmp, err := os.CreateTemp(dirOf(path), ".state-*.json")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName) // no-op after a successful Rename
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}
func dirOf(path string) string {
for i := len(path) - 1; i >= 0; i-- {
if path[i] == '/' || path[i] == '\\' {
return path[:i]
}
}
return "."
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"path/filepath"
"testing"
"time"
)
func TestSaveLoadState_Roundtrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "state.json")
in := &State{
Version: "1.2.3",
BuildID: "build-42",
InstalledAt: time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC),
BinaryPath: "/tmp/lark-sec-cli",
}
if err := SaveState(path, in); err != nil {
t.Fatalf("SaveState: %v", err)
}
got, err := LoadState(path)
if err != nil {
t.Fatalf("LoadState: %v", err)
}
if got == nil {
t.Fatal("LoadState returned nil")
}
if got.Version != in.Version || got.BuildID != in.BuildID || got.BinaryPath != in.BinaryPath {
t.Errorf("roundtrip mismatch: got=%+v want=%+v", got, in)
}
if !got.InstalledAt.Equal(in.InstalledAt) {
t.Errorf("InstalledAt mismatch: got=%v want=%v", got.InstalledAt, in.InstalledAt)
}
}
func TestLoadState_AbsentFile(t *testing.T) {
got, err := LoadState(filepath.Join(t.TempDir(), "missing.json"))
if err != nil {
t.Fatalf("expected nil error for missing file, got %v", err)
}
if got != nil {
t.Errorf("expected nil state for missing file, got %+v", got)
}
}

View File

@@ -0,0 +1,135 @@
# secplugin Usage Guide
Chinese version: see `README.zh-CN.md`.
`secplugin` enables a secure proxy mode for the CLI. It forces outbound HTTP(S)
requests to go through a local security proxy and can optionally trust an
additional CA certificate bundle.
It supports two configuration methods:
1. `sec_config.json`
2. `LARKSUITE_CLI_SEC_*` environment variables
## Config File Location
Default config file path:
```text
~/.lark-cli/sec_config.json
```
If `LARKSUITE_CLI_CONFIG_DIR` is set, the path becomes:
```text
$LARKSUITE_CLI_CONFIG_DIR/sec_config.json
```
## Option 1: Config File
Put the following content into `sec_config.json`:
```json
{
"LARKSUITE_CLI_SEC_ENABLE": true,
"LARKSUITE_CLI_SEC_PROXY": "http://127.0.0.1:3128",
"LARKSUITE_CLI_SEC_CA": "/absolute/path/to/proxy-ca.pem",
"LARKSUITE_CLI_SEC_AUTH": true,
"LARKSUITE_CLI_APP_ID": "cli_xxx",
"LARKSUITE_CLI_BRAND": "feishu",
"LARKSUITE_CLI_DEFAULT_AS": "bot",
"LARKSUITE_CLI_STRICT_MODE": "bot"
}
```
Field descriptions:
- `LARKSUITE_CLI_SEC_ENABLE`: Enables secplugin. Boolean values are supported.
- `LARKSUITE_CLI_SEC_PROXY`: Local HTTP proxy address. It must be `http://127.0.0.1:<port>`.
- `LARKSUITE_CLI_SEC_CA`: Absolute path to an extra trusted root CA PEM file. Leave empty if not needed.
- `LARKSUITE_CLI_SEC_AUTH`: Enables proxy-injected token mode.
- `LARKSUITE_CLI_APP_ID`: Optional app ID used in `SEC_AUTH` mode.
- `LARKSUITE_CLI_BRAND`: Optional, must be `feishu` or `lark`.
- `LARKSUITE_CLI_DEFAULT_AS`: Optional, must be `user`, `bot`, or `auto`.
- `LARKSUITE_CLI_STRICT_MODE`: Optional, must be `user`, `bot`, or `off`.
## Option 2: Environment Variables
You can also enable secplugin directly with environment variables without
creating `sec_config.json`:
```bash
export LARKSUITE_CLI_SEC_ENABLE=true
export LARKSUITE_CLI_SEC_PROXY=http://127.0.0.1:3128
export LARKSUITE_CLI_SEC_CA=/absolute/path/to/proxy-ca.pem
export LARKSUITE_CLI_SEC_AUTH=true
```
If you want to provide app metadata in `SEC_AUTH` mode, set these as well:
```bash
export LARKSUITE_CLI_APP_ID=cli_xxx
export LARKSUITE_CLI_BRAND=feishu
export LARKSUITE_CLI_DEFAULT_AS=bot
export LARKSUITE_CLI_STRICT_MODE=bot
```
## Precedence
The following environment variables override the corresponding fields in
`sec_config.json` when they are present:
- `LARKSUITE_CLI_SEC_ENABLE`
- `LARKSUITE_CLI_SEC_PROXY`
- `LARKSUITE_CLI_SEC_CA`
- `LARKSUITE_CLI_SEC_AUTH`
- `LARKSUITE_CLI_APP_ID`
- `LARKSUITE_CLI_BRAND`
- `LARKSUITE_CLI_DEFAULT_AS`
- `LARKSUITE_CLI_STRICT_MODE`
This means:
- Put stable defaults in `sec_config.json`.
- Use environment variables for temporary overrides.
- SEC-related environment variables can work even without a config file.
## SEC_AUTH Mode
The CLI enters `SEC_AUTH` mode when both of the following are true:
```text
LARKSUITE_CLI_SEC_ENABLE=true
LARKSUITE_CLI_SEC_AUTH=true
```
In this mode, the CLI does not read real tokens directly. Instead, it returns
placeholder tokens and expects the proxy to replace them with real credentials.
App information is resolved in this order:
1. `LARKSUITE_CLI_APP_ID` and `LARKSUITE_CLI_BRAND` from environment variables
2. The same fields in `sec_config.json`
3. The active profile in the regular CLI `config.json`
If no valid app information can be resolved from any source, the command fails.
## Constraints
- `LARKSUITE_CLI_SEC_PROXY` must use the `http` scheme only.
- The host of `LARKSUITE_CLI_SEC_PROXY` must be `127.0.0.1`.
- `LARKSUITE_CLI_SEC_PROXY` must not contain a path.
- `LARKSUITE_CLI_SEC_CA` must be an absolute path to a PEM file.
- Boolean values support `true/false`, `1/0`, `on/off`, `yes/no`, and `y/n`.
## Recommendations
For long-term stable setup, prefer `sec_config.json`:
- Good for developer machines or controlled environments.
- Avoids repeatedly injecting environment variables into the shell.
For temporary debugging, prefer environment variables:
- Good for switching proxy or CA for just one session.
- No need to modify files on disk.

View File

@@ -0,0 +1,130 @@
# secplugin 使用说明
English version: see `README.md`.
`secplugin` 用于开启安全代理模式,让 CLI 的 HTTP(S) 请求固定走本地安全代理,并按需信任额外 CA 证书。
支持两种配置方式:
1. `sec_config.json`
2. `LARKSUITE_CLI_SEC_*` 环境变量
## 配置文件位置
默认配置文件路径:
```text
~/.lark-cli/sec_config.json
```
如果设置了 `LARKSUITE_CLI_CONFIG_DIR`,则配置文件路径变为:
```text
$LARKSUITE_CLI_CONFIG_DIR/sec_config.json
```
## 方式一:使用配置文件
`sec_config.json` 中写入:
```json
{
"LARKSUITE_CLI_SEC_ENABLE": true,
"LARKSUITE_CLI_SEC_PROXY": "http://127.0.0.1:3128",
"LARKSUITE_CLI_SEC_CA": "/absolute/path/to/proxy-ca.pem",
"LARKSUITE_CLI_SEC_AUTH": true,
"LARKSUITE_CLI_APP_ID": "cli_xxx",
"LARKSUITE_CLI_BRAND": "feishu",
"LARKSUITE_CLI_DEFAULT_AS": "bot",
"LARKSUITE_CLI_STRICT_MODE": "bot"
}
```
字段说明:
- `LARKSUITE_CLI_SEC_ENABLE`: 是否启用 secplugin支持布尔值。
- `LARKSUITE_CLI_SEC_PROXY`: 本地 HTTP 代理地址,必须是 `http://127.0.0.1:<port>`
- `LARKSUITE_CLI_SEC_CA`: 额外信任的根证书 PEM 文件绝对路径;不需要时可留空。
- `LARKSUITE_CLI_SEC_AUTH`: 是否启用代理注入 token 模式。
- `LARKSUITE_CLI_APP_ID`: 可选,`SEC_AUTH` 模式下使用的应用 ID。
- `LARKSUITE_CLI_BRAND`: 可选,取值为 `feishu``lark`
- `LARKSUITE_CLI_DEFAULT_AS`: 可选,取值为 `user``bot``auto`
- `LARKSUITE_CLI_STRICT_MODE`: 可选,取值为 `user``bot``off`
## 方式二:使用环境变量
也可以不写 `sec_config.json`,直接通过环境变量启用:
```bash
export LARKSUITE_CLI_SEC_ENABLE=true
export LARKSUITE_CLI_SEC_PROXY=http://127.0.0.1:3128
export LARKSUITE_CLI_SEC_CA=/absolute/path/to/proxy-ca.pem
export LARKSUITE_CLI_SEC_AUTH=true
```
如果你在 `SEC_AUTH` 模式下希望同时提供应用信息,也可以继续设置:
```bash
export LARKSUITE_CLI_APP_ID=cli_xxx
export LARKSUITE_CLI_BRAND=feishu
export LARKSUITE_CLI_DEFAULT_AS=bot
export LARKSUITE_CLI_STRICT_MODE=bot
```
## 配置优先级
以下环境变量存在时,会覆盖 `sec_config.json` 中对应字段:
- `LARKSUITE_CLI_SEC_ENABLE`
- `LARKSUITE_CLI_SEC_PROXY`
- `LARKSUITE_CLI_SEC_CA`
- `LARKSUITE_CLI_SEC_AUTH`
- `LARKSUITE_CLI_APP_ID`
- `LARKSUITE_CLI_BRAND`
- `LARKSUITE_CLI_DEFAULT_AS`
- `LARKSUITE_CLI_STRICT_MODE`
也就是说:
- 你可以把默认值写进 `sec_config.json`
- 再用环境变量做临时覆盖。
- 如果没有配置文件,但设置了 SEC 相关环境变量,也可以正常工作。
## SEC_AUTH 模式说明
当同时满足以下条件时CLI 会进入 `SEC_AUTH` 模式:
```text
LARKSUITE_CLI_SEC_ENABLE=true
LARKSUITE_CLI_SEC_AUTH=true
```
此时 CLI 不直接读取真实 token而是返回占位 token由代理替换成真实凭证。
应用信息来源优先级如下:
1. 环境变量中的 `LARKSUITE_CLI_APP_ID``LARKSUITE_CLI_BRAND`
2. `sec_config.json` 中的同名字段
3. 常规 CLI 配置文件 `config.json` 的当前 profile
如果以上来源都拿不到可用应用信息,命令会报错。
## 参数约束
- `LARKSUITE_CLI_SEC_PROXY` 只允许 `http` 协议。
- `LARKSUITE_CLI_SEC_PROXY` 的 host 必须是 `127.0.0.1`
- `LARKSUITE_CLI_SEC_PROXY` 不能带路径。
- `LARKSUITE_CLI_SEC_CA` 必须是 PEM 文件的绝对路径。
- 布尔值支持 `true/false``1/0``on/off``yes/no``y/n`
## 推荐用法
长期固定配置建议使用 `sec_config.json`
- 适合开发机或受控环境的稳定配置。
- 避免在 shell 中反复注入环境变量。
临时调试建议使用环境变量:
- 适合本次会话临时切换代理或证书。
- 不需要修改磁盘上的配置文件。

View File

@@ -0,0 +1,277 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package secplugin implements the ~/.lark-cli/sec_config.json based security proxy plugin mode.
//
// It supports:
// - forcing all outbound HTTP(S) requests through a fixed HTTP proxy
// - trusting an additional root CA PEM bundle for MITM/inspection proxies
// - optional "proxy injects token" mode via placeholder tokens (SEC_AUTH)
//
// In sec plugin mode, certain common CLI env vars (APP_ID / BRAND / DEFAULT_AS /
// STRICT_MODE) can also be set in sec_config.json so sandboxes can avoid
// environment injection. When both are present, environment variables win.
package secplugin
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/vfs"
)
// SEC plugin constants cover the config file name and placeholder token values.
const (
// ConfigFileName is the fixed config file name under core.GetConfigDir().
ConfigFileName = "sec_config.json"
// SentinelUAT is the placeholder user access token used in SEC_AUTH mode.
SentinelUAT = "secplugin-managed-uat"
// SentinelTAT is the placeholder tenant access token used in SEC_AUTH mode.
SentinelTAT = "secplugin-managed-tat"
)
// Config is the on-disk config format. Keys intentionally mirror env var names.
type Config struct {
// Enable turns on sec plugin transport handling.
Enable bool `json:"LARKSUITE_CLI_SEC_ENABLE"`
// Proxy is the fixed HTTP proxy address used for all outbound requests.
Proxy string `json:"LARKSUITE_CLI_SEC_PROXY"`
// CAPath points to an extra PEM bundle trusted for proxy TLS interception.
CAPath string `json:"LARKSUITE_CLI_SEC_CA"`
// Auth enables placeholder-token mode for proxy-side credential injection.
Auth bool `json:"LARKSUITE_CLI_SEC_AUTH"`
// Optional defaults for sec plugin mode; env vars override these.
// AppID supplies the app ID when the environment does not set one.
AppID string `json:"LARKSUITE_CLI_APP_ID,omitempty"`
// Brand supplies the tenant brand when the environment does not set one.
Brand string `json:"LARKSUITE_CLI_BRAND,omitempty"` // feishu | lark
// DefaultAs supplies the default identity when the environment does not set one.
DefaultAs string `json:"LARKSUITE_CLI_DEFAULT_AS,omitempty"` // user | bot | auto
// StrictMode supplies the strict mode when the environment does not set one.
StrictMode string `json:"LARKSUITE_CLI_STRICT_MODE,omitempty"` // user | bot | off
}
// Path returns the absolute path to the sec plugin config file.
func Path() string {
return filepath.Join(core.GetConfigDir(), ConfigFileName)
}
// loadOnce guards one-time SEC config loading for process-wide transport reuse.
var loadOnce sync.Once
// loadCfg stores the cached SEC config after the first successful Load call.
var loadCfg *Config
// loadErr stores the cached Load error observed during the first load attempt.
var loadErr error
// Load reads ~/.lark-cli/sec_config.json once and caches the parsed result.
// Environment variables (CliSec*) take precedence over config file values.
//
// Returns (nil, nil) only when:
// - the config file does not exist AND
// - none of the SEC-related env vars are present.
func Load() (*Config, error) {
loadOnce.Do(func() {
// Start from env-only config if any SEC env var is present.
cfg, hasEnv, err := loadFromEnv()
if err != nil {
loadErr = err
return
}
p := Path()
if _, err := vfs.Stat(p); err != nil {
if errors.Is(err, os.ErrNotExist) {
// No file: return env-only config (if any), else nil.
if hasEnv {
loadCfg = cfg
} else {
loadCfg = nil
}
loadErr = nil
return
}
loadErr = fmt.Errorf("failed to stat sec plugin config %q: %w", p, err)
return
}
b, err := vfs.ReadFile(p)
if err != nil {
loadErr = fmt.Errorf("failed to read sec plugin config %q: %w", p, err)
return
}
var fileCfg Config
if err := json.Unmarshal(b, &fileCfg); err != nil {
loadErr = fmt.Errorf("invalid sec plugin config %q: %w", p, err)
return
}
// Merge: file base + env overrides.
if cfg == nil {
cfg = &fileCfg
} else {
*cfg = fileCfg
applyEnvOverrides(cfg)
}
loadCfg = cfg
})
return loadCfg, loadErr
}
// Enabled reports whether SEC plugin mode is enabled.
func (c *Config) Enabled() bool { return c != nil && c.Enable }
// AuthEnabled reports whether SEC_AUTH token placeholder mode is enabled.
func (c *Config) AuthEnabled() bool { return c != nil && c.Enable && c.Auth }
// loadFromEnv builds a config from SEC-related environment variables only.
// It reports whether any SEC-related environment variable was present.
func loadFromEnv() (*Config, bool, error) {
_, hasEnable := os.LookupEnv(envvars.CliSecEnable)
_, hasProxy := os.LookupEnv(envvars.CliSecProxy)
_, hasCA := os.LookupEnv(envvars.CliSecCA)
_, hasAuth := os.LookupEnv(envvars.CliSecAuth)
hasAny := hasEnable || hasProxy || hasCA || hasAuth
if !hasAny {
return nil, false, nil
}
cfg := &Config{}
if err := applyEnvOverrides(cfg); err != nil {
return nil, true, err
}
return cfg, true, nil
}
// applyEnvOverrides copies SEC-related environment variable values into cfg.
func applyEnvOverrides(cfg *Config) error {
if v, ok := os.LookupEnv(envvars.CliSecEnable); ok {
b, err := parseBoolEnv(envvars.CliSecEnable, v)
if err != nil {
return err
}
cfg.Enable = b
}
if v, ok := os.LookupEnv(envvars.CliSecAuth); ok {
b, err := parseBoolEnv(envvars.CliSecAuth, v)
if err != nil {
return err
}
cfg.Auth = b
}
if v, ok := os.LookupEnv(envvars.CliSecProxy); ok {
cfg.Proxy = v
}
if v, ok := os.LookupEnv(envvars.CliSecCA); ok {
cfg.CAPath = v
}
return nil
}
// parseBoolEnv accepts common boolean spellings used in environment variables.
func parseBoolEnv(name, raw string) (bool, error) {
s := strings.TrimSpace(strings.ToLower(raw))
if s == "" {
// Treat empty as false when explicitly present.
return false, nil
}
switch s {
case "1", "true", "on", "yes", "y":
return true, nil
case "0", "false", "off", "no", "n":
return false, nil
}
if b, err := strconv.ParseBool(s); err == nil {
return b, nil
}
return false, fmt.Errorf("invalid %s %q (want true/false/1/0)", name, raw)
}
// proxyURL validates the fixed SEC proxy configuration and returns its URL.
func (c *Config) proxyURL() (*url.URL, error) {
raw := strings.TrimSpace(c.Proxy)
if raw == "" {
return nil, fmt.Errorf("%s is empty", envvars.CliSecProxy)
}
redacted := redactProxyURL(raw)
u, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("invalid %s %q: %w", envvars.CliSecProxy, redacted, err)
}
if u.Scheme != "http" {
return nil, fmt.Errorf("invalid %s %q: scheme must be http", envvars.CliSecProxy, redacted)
}
if u.Host == "" {
return nil, fmt.Errorf("invalid %s %q: missing host", envvars.CliSecProxy, redacted)
}
// Security hardening: only allow a loopback proxy. This prevents accidental
// cross-machine proxying of credentials/traffic.
if u.Hostname() != "127.0.0.1" {
return nil, fmt.Errorf("invalid %s %q: host must be 127.0.0.1", envvars.CliSecProxy, redacted)
}
if u.Port() == "" {
return nil, fmt.Errorf("invalid %s %q: explicit port is required", envvars.CliSecProxy, redacted)
}
if u.Path != "" && u.Path != "/" {
return nil, fmt.Errorf("invalid %s %q: path is not allowed", envvars.CliSecProxy, redacted)
}
if u.RawQuery != "" {
return nil, fmt.Errorf("invalid %s %q: query is not allowed", envvars.CliSecProxy, redacted)
}
if u.Fragment != "" {
return nil, fmt.Errorf("invalid %s %q: fragment is not allowed", envvars.CliSecProxy, redacted)
}
return u, nil
}
// redactProxyURL masks userinfo (username:password) in a proxy URL.
// Handles both scheme-prefixed ("http://user:pass@host") and bare formats.
func redactProxyURL(raw string) string {
u, err := url.Parse(raw)
if err == nil && u.User != nil {
u.User = url.User("***")
return u.String()
}
// Fallback: handle "user:pass@proxy:8080"
if at := strings.LastIndex(raw, "@"); at > 0 {
return "***@" + raw[at+1:]
}
return raw
}
// ApplyToTransport clones base and applies SEC plugin settings to the clone.
// Caller owns the returned *http.Transport.
func (c *Config) ApplyToTransport(base *http.Transport) (*http.Transport, error) {
if base == nil {
base = http.DefaultTransport.(*http.Transport)
}
u, err := c.proxyURL()
if err != nil {
return nil, err
}
t := base.Clone()
t.Proxy = http.ProxyURL(u) // fixed proxy overrides environment proxy vars
if err := applyExtraRootCA(t, c.CAPath); err != nil {
return nil, err
}
return t, nil
}

View File

@@ -0,0 +1,245 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package secplugin
import (
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/larksuite/cli/internal/envvars"
)
// unsetEnv clears key for the duration of the test and restores its original value.
func unsetEnv(t *testing.T, key string) {
t.Helper()
old, had := os.LookupEnv(key)
_ = os.Unsetenv(key)
t.Cleanup(func() {
if had {
_ = os.Setenv(key, old)
} else {
_ = os.Unsetenv(key)
}
})
}
// unsetSecPluginEnv clears SEC-related environment variables for deterministic tests.
func unsetSecPluginEnv(t *testing.T) {
t.Helper()
unsetEnv(t, envvars.CliSecEnable)
unsetEnv(t, envvars.CliSecProxy)
unsetEnv(t, envvars.CliSecCA)
unsetEnv(t, envvars.CliSecAuth)
}
// writeFile creates parent directories and writes test data for fixtures.
func writeFile(t *testing.T, path string, data []byte, perm os.FileMode) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(path, data, perm); err != nil {
t.Fatalf("WriteFile: %v", err)
}
}
// TestLoad_MissingFileReturnsNil verifies that Load reports no config when no file
// or SEC environment overrides exist.
func TestLoad_MissingFileReturnsNil(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
loadOnce = sync.Once{}
loadCfg = nil
loadErr = nil
unsetSecPluginEnv(t)
// TestLoad_MissingFileReturnsNil must reset loadOnce, loadCfg, and loadErr
// because multiple tests in this package share the package-level Load()
// cache via sync.Once.
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg != nil {
t.Fatalf("Load() = %#v, want nil (missing file)", cfg)
}
}
// TestApplyToTransport_SetsProxy verifies that a valid SEC config installs a fixed proxy.
func TestApplyToTransport_SetsProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
loadOnce = sync.Once{}
loadCfg = nil
loadErr = nil
unsetSecPluginEnv(t)
cfgPath := Path()
writeFile(t, cfgPath, []byte(`{
"LARKSUITE_CLI_SEC_ENABLE": true,
"LARKSUITE_CLI_SEC_PROXY": "http://127.0.0.1:3128",
"LARKSUITE_CLI_SEC_CA": "",
"LARKSUITE_CLI_SEC_AUTH": false
}`), 0600)
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg == nil || !cfg.Enabled() {
t.Fatalf("cfg.Enabled() = %v, want true", cfg)
}
base := http.DefaultTransport.(*http.Transport)
tr, err := cfg.ApplyToTransport(base)
if err != nil {
t.Fatalf("ApplyToTransport() error = %v", err)
}
if tr.Proxy == nil {
t.Fatal("Proxy func is nil, want fixed proxy")
}
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
if err != nil {
t.Fatalf("Proxy() error = %v", err)
}
if u == nil || u.String() != "http://127.0.0.1:3128" {
t.Fatalf("Proxy() = %v, want http://127.0.0.1:3128", u)
}
}
// TestLoad_RejectsNonLoopbackProxy verifies that SEC mode rejects non-loopback proxies.
func TestLoad_RejectsNonLoopbackProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
loadOnce = sync.Once{}
loadCfg = nil
loadErr = nil
unsetSecPluginEnv(t)
cfgPath := Path()
writeFile(t, cfgPath, []byte(`{
"LARKSUITE_CLI_SEC_ENABLE": true,
"LARKSUITE_CLI_SEC_PROXY": "http://10.0.0.1:3128",
"LARKSUITE_CLI_SEC_CA": "",
"LARKSUITE_CLI_SEC_AUTH": false
}`), 0600)
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg == nil || !cfg.Enabled() {
t.Fatalf("cfg.Enabled() = %v, want true", cfg)
}
_, err = cfg.ApplyToTransport(http.DefaultTransport.(*http.Transport))
if err == nil {
t.Fatal("ApplyToTransport() error = nil, want invalid proxy host error")
}
}
// TestConfig_ProxyURLRejectsUnsupportedParts verifies the SEC proxy validator
// rejects URLs with missing ports, queries, and fragments.
func TestConfig_ProxyURLRejectsUnsupportedParts(t *testing.T) {
cases := []struct {
name string
raw string
want string
}{
{
name: "missing explicit port",
raw: "http://127.0.0.1",
want: "explicit port is required",
},
{
name: "query string",
raw: "http://127.0.0.1:3128?foo=bar",
want: "query is not allowed",
},
{
name: "fragment",
raw: "http://127.0.0.1:3128#frag",
want: "fragment is not allowed",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
_, err := (&Config{Proxy: tt.raw}).proxyURL()
if err == nil {
t.Fatalf("proxyURL() error = nil, want substring %q", tt.want)
}
if !strings.Contains(err.Error(), tt.want) {
t.Fatalf("proxyURL() error = %q, want substring %q", err, tt.want)
}
})
}
}
// TestLoad_EnvOnlyConfig verifies that SEC settings can come entirely from environment variables.
func TestLoad_EnvOnlyConfig(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
loadOnce = sync.Once{}
loadCfg = nil
loadErr = nil
t.Setenv(envvars.CliSecEnable, "true")
t.Setenv(envvars.CliSecProxy, "http://127.0.0.1:7777")
t.Setenv(envvars.CliSecCA, "")
t.Setenv(envvars.CliSecAuth, "true")
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg == nil || !cfg.Enabled() {
t.Fatalf("cfg.Enabled() = %v, want true", cfg)
}
if !cfg.AuthEnabled() {
t.Fatalf("cfg.AuthEnabled() = false, want true")
}
tr, err := cfg.ApplyToTransport(http.DefaultTransport.(*http.Transport))
if err != nil {
t.Fatalf("ApplyToTransport() error = %v", err)
}
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
if err != nil {
t.Fatalf("Proxy() error = %v", err)
}
if u == nil || u.String() != "http://127.0.0.1:7777" {
t.Fatalf("Proxy() = %v, want http://127.0.0.1:7777", u)
}
}
// TestLoad_EnvOverridesFile verifies that SEC environment variables override file values.
func TestLoad_EnvOverridesFile(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
loadOnce = sync.Once{}
loadCfg = nil
loadErr = nil
// File enables with one proxy.
cfgPath := Path()
writeFile(t, cfgPath, []byte(`{
"LARKSUITE_CLI_SEC_ENABLE": true,
"LARKSUITE_CLI_SEC_PROXY": "http://127.0.0.1:3128",
"LARKSUITE_CLI_SEC_CA": "",
"LARKSUITE_CLI_SEC_AUTH": false
}`), 0600)
// Env overrides: disable + different proxy (should be irrelevant once disabled).
t.Setenv(envvars.CliSecEnable, "false")
t.Setenv(envvars.CliSecProxy, "http://127.0.0.1:9999")
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg == nil {
t.Fatalf("Load() = nil, want non-nil (file exists)")
}
if cfg.Enabled() {
t.Fatalf("cfg.Enabled() = true, want false (env override)")
}
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package secplugin
import (
"crypto/tls"
"crypto/x509"
"fmt"
"net/http"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/vfs"
)
// applyExtraRootCA augments t with an additional PEM bundle used for SEC proxy
// TLS interception.
func applyExtraRootCA(t *http.Transport, caPath string) error {
caPath = strings.TrimSpace(caPath)
if caPath == "" {
return nil
}
if !filepath.IsAbs(caPath) {
return fmt.Errorf("invalid %s %q: must be an absolute path to a PEM file", envvars.CliSecCA, caPath)
}
pemBytes, err := vfs.ReadFile(caPath)
if err != nil {
return fmt.Errorf("failed to read %s %q: %w", envvars.CliSecCA, caPath, err)
}
// Start from system pool when possible; if unavailable, create a new pool.
pool, _ := x509.SystemCertPool()
if pool == nil {
pool = x509.NewCertPool()
}
if ok := pool.AppendCertsFromPEM(pemBytes); !ok {
return fmt.Errorf("invalid %s %q: no certificates parsed from PEM", envvars.CliSecCA, caPath)
}
if t.TLSClientConfig == nil {
t.TLSClientConfig = &tls.Config{}
} else {
// Clone to avoid mutating shared config from the base transport.
t.TLSClientConfig = t.TLSClientConfig.Clone()
}
t.TLSClientConfig.MinVersion = tls.VersionTLS12
t.TLSClientConfig.RootCAs = pool
return nil
}

View File

@@ -0,0 +1,138 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package secplugin
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net/http"
"path/filepath"
"strings"
"testing"
"time"
)
// mustCreateTestCertPEM generates a short-lived self-signed CA certificate for tests.
func mustCreateTestCertPEM(t *testing.T) []byte {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("GenerateKey() error = %v", err)
}
der, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "secplugin-test-ca",
},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
IsCA: true,
BasicConstraintsValid: true,
}, &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "secplugin-test-ca",
},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
IsCA: true,
BasicConstraintsValid: true,
}, &key.PublicKey, key)
if err != nil {
t.Fatalf("CreateCertificate() error = %v", err)
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
}
// TestApplyExtraRootCA_EmptyPathIsNoop verifies that an empty CA path leaves the transport unchanged.
func TestApplyExtraRootCA_EmptyPathIsNoop(t *testing.T) {
tr := &http.Transport{}
if err := applyExtraRootCA(tr, " "); err != nil {
t.Fatalf("applyExtraRootCA() error = %v", err)
}
if tr.TLSClientConfig != nil {
t.Fatalf("TLSClientConfig = %#v, want nil", tr.TLSClientConfig)
}
}
// TestApplyExtraRootCA_RejectsRelativePath verifies that CA paths must be absolute.
func TestApplyExtraRootCA_RejectsRelativePath(t *testing.T) {
tr := &http.Transport{}
err := applyExtraRootCA(tr, "ca.pem")
if err == nil || !strings.Contains(err.Error(), "must be an absolute path") {
t.Fatalf("applyExtraRootCA() error = %v, want absolute-path error", err)
}
}
// TestApplyExtraRootCA_RejectsMissingFile verifies read errors for missing PEM bundles.
func TestApplyExtraRootCA_RejectsMissingFile(t *testing.T) {
tr := &http.Transport{}
err := applyExtraRootCA(tr, filepath.Join(t.TempDir(), "missing.pem"))
if err == nil || !strings.Contains(err.Error(), "failed to read") {
t.Fatalf("applyExtraRootCA() error = %v, want read error", err)
}
}
// TestApplyExtraRootCA_RejectsInvalidPEM verifies validation of malformed PEM bundles.
func TestApplyExtraRootCA_RejectsInvalidPEM(t *testing.T) {
caPath := filepath.Join(t.TempDir(), "invalid.pem")
writeFile(t, caPath, []byte("not a pem"), 0600)
tr := &http.Transport{}
err := applyExtraRootCA(tr, caPath)
if err == nil || !strings.Contains(err.Error(), "no certificates parsed from PEM") {
t.Fatalf("applyExtraRootCA() error = %v, want invalid PEM error", err)
}
}
// TestApplyExtraRootCA_SetsTLSConfigWhenMissing verifies initialization of TLSClientConfig when absent.
func TestApplyExtraRootCA_SetsTLSConfigWhenMissing(t *testing.T) {
caPath := filepath.Join(t.TempDir(), "ca.pem")
writeFile(t, caPath, mustCreateTestCertPEM(t), 0600)
tr := &http.Transport{}
if err := applyExtraRootCA(tr, caPath); err != nil {
t.Fatalf("applyExtraRootCA() error = %v", err)
}
if tr.TLSClientConfig == nil {
t.Fatal("TLSClientConfig = nil, want initialized config")
}
if tr.TLSClientConfig.RootCAs == nil {
t.Fatal("RootCAs = nil, want cert pool")
}
}
// TestApplyExtraRootCA_ClonesExistingTLSConfig verifies cloning when the base transport already has TLS settings.
func TestApplyExtraRootCA_ClonesExistingTLSConfig(t *testing.T) {
caPath := filepath.Join(t.TempDir(), "ca.pem")
writeFile(t, caPath, mustCreateTestCertPEM(t), 0600)
original := &tls.Config{ServerName: "open.feishu.cn"}
tr := &http.Transport{TLSClientConfig: original}
if err := applyExtraRootCA(tr, caPath); err != nil {
t.Fatalf("applyExtraRootCA() error = %v", err)
}
if tr.TLSClientConfig == original {
t.Fatal("TLSClientConfig pointer reused, want clone")
}
if tr.TLSClientConfig.ServerName != original.ServerName {
t.Fatalf("ServerName = %q, want %q", tr.TLSClientConfig.ServerName, original.ServerName)
}
if tr.TLSClientConfig.RootCAs == nil {
t.Fatal("RootCAs = nil, want cert pool")
}
}

View File

@@ -11,8 +11,11 @@ import (
"os"
"strings"
"sync"
"github.com/larksuite/cli/internal/secplugin"
)
// Proxy environment constants control shared transport proxy behavior.
const (
// EnvNoProxy disables automatic proxy support when set to any non-empty value.
EnvNoProxy = "LARK_CLI_NO_PROXY"
@@ -36,6 +39,7 @@ func DetectProxyEnv() (key, value string) {
return "", ""
}
// proxyWarningOnce ensures proxy environment warnings are emitted at most once.
var proxyWarningOnce sync.Once
// redactProxyURL masks userinfo (username:password) in a proxy URL.
@@ -84,6 +88,31 @@ var noProxyTransport = sync.OnceValue(func() *http.Transport {
return t
})
// secProxyTransport is a fixed-proxy clone of http.DefaultTransport (with optional
// custom root CA), lazily built on first use when sec plugin mode is enabled.
var secProxyTransport = sync.OnceValue(func() *http.Transport {
def, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return &http.Transport{}
}
cfg, err := secplugin.Load()
if err != nil || cfg == nil || !cfg.Enabled() {
return def
}
t, err := cfg.ApplyToTransport(def)
if err != nil {
// Fail closed: do not silently fall back to direct egress when the
// operator explicitly enabled SEC plugin mode.
blocked := def.Clone()
blocked.Proxy = func(*http.Request) (*url.URL, error) {
return nil, fmt.Errorf("sec plugin enabled but config is invalid: %v", err)
}
return blocked
}
return t
})
// SharedTransport returns the base http.RoundTripper for CLI HTTP clients.
//
// By default it returns http.DefaultTransport — the stdlib-provided
@@ -99,6 +128,23 @@ var noProxyTransport = sync.OnceValue(func() *http.Transport {
// goroutines are reused; cloning per call leaks them until IdleConnTimeout
// (~90s) fires.
func SharedTransport() http.RoundTripper {
// SEC plugin mode overrides all other proxy behavior (env proxies and
// LARK_CLI_NO_PROXY), per operator intent.
if cfg, err := secplugin.Load(); err != nil {
// Fail closed: if the config file exists but is malformed/unreadable,
// do not silently fall back to direct egress.
def, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return http.DefaultTransport
}
blocked := def.Clone()
blocked.Proxy = func(*http.Request) (*url.URL, error) {
return nil, fmt.Errorf("sec plugin config is invalid: %v", err)
}
return blocked
} else if cfg != nil && cfg.Enabled() {
return secProxyTransport()
}
if os.Getenv(EnvNoProxy) != "" {
return noProxyTransport()
}

View File

@@ -6,11 +6,43 @@ package util
import (
"bytes"
"net/http"
"os"
"sync"
"testing"
"github.com/larksuite/cli/internal/envvars"
)
// unsetEnv clears key for the duration of the test and restores its original value.
func unsetEnv(t *testing.T, key string) {
t.Helper()
old, had := os.LookupEnv(key)
_ = os.Unsetenv(key)
t.Cleanup(func() {
if had {
_ = os.Setenv(key, old)
} else {
_ = os.Unsetenv(key)
}
})
}
// unsetSecPluginEnv clears SEC-related environment variables for deterministic tests.
func unsetSecPluginEnv(t *testing.T) {
t.Helper()
// Ensure developer machine env doesn't accidentally enable SEC plugin mode
// and change expectations for SharedTransport().
unsetEnv(t, envvars.CliSecEnable)
unsetEnv(t, envvars.CliSecProxy)
unsetEnv(t, envvars.CliSecCA)
unsetEnv(t, envvars.CliSecAuth)
}
// TestDetectProxyEnv verifies proxy environment detection priority and empty-state behavior.
func TestDetectProxyEnv(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
// Clear all proxy env vars first
for _, k := range proxyEnvKeys {
t.Setenv(k, "")
@@ -28,7 +60,10 @@ func TestDetectProxyEnv(t *testing.T) {
}
}
// TestSharedTransport_DefaultReturnsStdlibSingleton verifies the default shared transport.
func TestSharedTransport_DefaultReturnsStdlibSingleton(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
t.Setenv(EnvNoProxy, "")
tr := SharedTransport()
if tr != http.DefaultTransport {
@@ -36,7 +71,10 @@ func TestSharedTransport_DefaultReturnsStdlibSingleton(t *testing.T) {
}
}
// TestSharedTransport_NoProxyReturnsClone verifies that disabling proxying returns a cloned transport.
func TestSharedTransport_NoProxyReturnsClone(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
t.Setenv(EnvNoProxy, "1")
tr := SharedTransport()
if tr == http.DefaultTransport {
@@ -51,7 +89,10 @@ func TestSharedTransport_NoProxyReturnsClone(t *testing.T) {
}
}
// TestSharedTransport_NoProxyIsCachedSingleton verifies singleton caching for the no-proxy transport.
func TestSharedTransport_NoProxyIsCachedSingleton(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
t.Setenv(EnvNoProxy, "1")
a := SharedTransport()
b := SharedTransport()
@@ -60,7 +101,10 @@ func TestSharedTransport_NoProxyIsCachedSingleton(t *testing.T) {
}
}
// TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault verifies fallback to the stdlib transport after unsetting EnvNoProxy.
func TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
// Simulate a process that first runs with LARK_CLI_NO_PROXY=1 (populating
// the no-proxy singleton), then unsets it. Subsequent calls must return
// http.DefaultTransport, NOT the cached no-proxy clone.
@@ -77,7 +121,10 @@ func TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) {
}
}
// TestSharedTransport_NoProxyOverridesSystemProxy verifies that EnvNoProxy disables system proxies.
func TestSharedTransport_NoProxyOverridesSystemProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888")
t.Setenv(EnvNoProxy, "1")
@@ -90,7 +137,10 @@ func TestSharedTransport_NoProxyOverridesSystemProxy(t *testing.T) {
}
}
// TestWarnIfProxied_WithProxy verifies that proxy detection emits a warning.
func TestWarnIfProxied_WithProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
// Reset the once guard for this test
proxyWarningOnce = sync.Once{}
@@ -111,7 +161,10 @@ func TestWarnIfProxied_WithProxy(t *testing.T) {
}
}
// TestWarnIfProxied_WithoutProxy verifies that no warning is emitted without proxy settings.
func TestWarnIfProxied_WithoutProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
proxyWarningOnce = sync.Once{}
for _, k := range proxyEnvKeys {
@@ -126,7 +179,10 @@ func TestWarnIfProxied_WithoutProxy(t *testing.T) {
}
}
// TestWarnIfProxied_SilentWhenDisabled verifies that EnvNoProxy suppresses warnings.
func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
proxyWarningOnce = sync.Once{}
t.Setenv("HTTPS_PROXY", "http://proxy:8080")
@@ -140,7 +196,10 @@ func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
}
}
// TestWarnIfProxied_OnlyOnce verifies that proxy warnings are emitted only once.
func TestWarnIfProxied_OnlyOnce(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
proxyWarningOnce = sync.Once{}
t.Setenv("HTTP_PROXY", "http://proxy:1234")
@@ -160,7 +219,10 @@ func TestWarnIfProxied_OnlyOnce(t *testing.T) {
}
}
// TestRedactProxyURL verifies redaction of proxy credentials across supported formats.
func TestRedactProxyURL(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
tests := []struct {
input string
want string
@@ -183,7 +245,10 @@ func TestRedactProxyURL(t *testing.T) {
}
}
// TestWarnIfProxied_RedactsCredentials verifies that warning output never leaks credentials.
func TestWarnIfProxied_RedactsCredentials(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
proxyWarningOnce = sync.Once{}
t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080")

View File

@@ -9,7 +9,8 @@ import (
"github.com/larksuite/cli/cmd"
_ "github.com/larksuite/cli/extension/credential/env" // activate env credential provider
_ "github.com/larksuite/cli/extension/credential/env" // activate env credential provider
_ "github.com/larksuite/cli/extension/credential/secplugin" // activate sec plugin credential provider (SEC_AUTH placeholder tokens)
)
func main() {

View File

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

View File

@@ -149,29 +149,26 @@ func TestDryRunRecordOps(t *testing.T) {
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(
uploadAttachmentRT := newBaseTestRuntimeWithArrays(
map[string]string{
"base-token": "app_x",
"table-id": "tbl_1",
"record-id": "rec_1",
"field-id": "fld_att",
"file": "/tmp/report.pdf",
"name": "report-final.pdf",
},
map[string][]string{"file": {"/tmp/report.pdf"}},
nil,
nil,
)
assertDryRunContains(t,
BaseRecordUploadAttachment.DryRun(ctx, uploadAttachmentRT),
"GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_att",
"GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1",
"POST /open-apis/drive/v1/medias/upload_all",
"bitable_file",
"PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1",
"report-final.pdf",
`"mime_type":"\u003cdetected_mime_type\u003e"`,
`"size":"\u003cfile_size\u003e"`,
"deprecated_set_attachment",
"POST /open-apis/base/v3/bases/app_x/tables/tbl_1/append_attachments",
"report.pdf",
`"image_width":"\u003cimage_width_if_image\u003e"`,
`"image_height":"\u003cimage_height_if_image\u003e"`,
)
}

View File

@@ -7,6 +7,11 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"image"
"image/color"
"image/png"
"net/url"
"os"
"path/filepath"
"strings"
@@ -15,6 +20,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/shortcuts/common"
"github.com/spf13/cobra"
)
@@ -1589,12 +1595,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
t.Run("upload attachment", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
tmpFile, err := os.CreateTemp(t.TempDir(), "base-attachment-*.txt")
tmpFile, err := os.CreateTemp(t.TempDir(), "base-attachment-*.png")
if err != nil {
t.Fatalf("CreateTemp() err=%v", err)
}
if _, err := tmpFile.WriteString("hello attachment"); err != nil {
t.Fatalf("WriteString() err=%v", err)
img := image.NewRGBA(image.Rect(0, 0, 3, 2))
img.Set(0, 0, color.RGBA{R: 255, A: 255})
if err := png.Encode(tmpFile, img); err != nil {
t.Fatalf("png.Encode() err=%v", err)
}
if err := tmpFile.Close(); err != nil {
t.Fatalf("Close() err=%v", err)
@@ -1609,28 +1617,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
"data": map[string]interface{}{"id": "fld_att", "name": "附件", "type": "attachment"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id": "rec_x",
"fields": map[string]interface{}{
"附件": []interface{}{
map[string]interface{}{
"file_token": "existing_tok",
"name": "existing.pdf",
"size": 2048,
"image_width": 640,
"image_height": 480,
"deprecated_set_attachment": false,
},
},
},
},
},
})
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
@@ -1640,34 +1626,27 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
},
}
reg.Register(uploadStub)
updateStub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
appendStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/append_attachments",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id": "rec_x",
"fields": map[string]interface{}{
"附件": []interface{}{
map[string]interface{}{
"file_token": "existing_tok",
"name": "existing.pdf",
"size": 2048,
"image_width": 640,
"image_height": 480,
"deprecated_set_attachment": true,
},
map[string]interface{}{
"file_token": "file_tok_1",
"name": "report.txt",
"deprecated_set_attachment": true,
"attachments": map[string]interface{}{
"rec_x": map[string]interface{}{
"fld_att": []interface{}{
map[string]interface{}{
"file_token": "file_tok_1",
"name": "base-attachment.png",
"size": 73,
},
},
},
},
},
},
}
reg.Register(updateStub)
reg.Register(appendStub)
if err := runShortcut(t, BaseRecordUploadAttachment, []string{
"+record-upload-attachment",
@@ -1676,11 +1655,10 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
"--record-id", "rec_x",
"--field-id", "fld_att",
"--file", "./" + filepath.Base(tmpFile.Name()),
"--name", "report.txt",
}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"file_tok_1"`) || !strings.Contains(got, `"report.txt"`) {
if got := stdout.String(); !strings.Contains(got, `"file_tok_1"`) || strings.Contains(got, `"updated"`) || strings.Contains(got, `"uploaded"`) {
t.Fatalf("stdout=%s", got)
}
@@ -1689,19 +1667,13 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
t.Fatalf("upload body=%s", uploadBody)
}
updateBody := string(updateStub.CapturedBody)
if !strings.Contains(updateBody, `"附件"`) ||
!strings.Contains(updateBody, `"file_token":"existing_tok"`) ||
!strings.Contains(updateBody, `"name":"existing.pdf"`) ||
!strings.Contains(updateBody, `"size":2048`) ||
!strings.Contains(updateBody, `"image_width":640`) ||
!strings.Contains(updateBody, `"image_height":480`) ||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) ||
!strings.Contains(updateBody, `"file_token":"file_tok_1"`) ||
!strings.Contains(updateBody, `"name":"report.txt"`) ||
!strings.Contains(updateBody, `"size":16`) ||
!strings.Contains(updateBody, `"mime_type":"text/plain"`) {
t.Fatalf("update body=%s", updateBody)
appendBody := string(appendStub.CapturedBody)
if !strings.Contains(appendBody, `"rec_x"`) ||
!strings.Contains(appendBody, `"fld_att"`) ||
!strings.Contains(appendBody, `"file_token":"file_tok_1"`) ||
!strings.Contains(appendBody, `"image_width":3`) ||
!strings.Contains(appendBody, `"image_height":2`) {
t.Fatalf("append body=%s", appendBody)
}
})
@@ -1728,17 +1700,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
"data": map[string]interface{}{"id": "fld_att", "name": "附件", "type": "attachment"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id": "rec_x",
"fields": map[string]interface{}{},
},
},
})
prepareStub := &httpmock.Stub{
Method: "POST",
@@ -1778,26 +1739,23 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
reg.Register(finishStub)
updateStub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
appendStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/append_attachments",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id": "rec_x",
"fields": map[string]interface{}{
"附件": []interface{}{
map[string]interface{}{
"file_token": "file_tok_big",
"name": "large-report.bin",
"deprecated_set_attachment": true,
"attachments": map[string]interface{}{
"rec_x": map[string]interface{}{
"fld_att": []interface{}{
map[string]interface{}{"file_token": "file_tok_big"},
},
},
},
},
},
}
reg.Register(updateStub)
reg.Register(appendStub)
if err := runShortcut(t, BaseRecordUploadAttachment, []string{
"+record-upload-attachment",
@@ -1806,17 +1764,16 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
"--record-id", "rec_x",
"--field-id", "fld_att",
"--file", "./" + filepath.Base(tmpFile.Name()),
"--name", "large-report.bin",
}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"file_tok_big"`) || !strings.Contains(got, `"large-report.bin"`) {
if got := stdout.String(); !strings.Contains(got, `"file_tok_big"`) || strings.Contains(got, `"updated"`) || strings.Contains(got, `"uploaded"`) {
t.Fatalf("stdout=%s", got)
}
prepareBody := string(prepareStub.CapturedBody)
if !strings.Contains(prepareBody, `"file_name":"large-report.bin"`) ||
if !strings.Contains(prepareBody, `"file_name":"`+filepath.Base(tmpFile.Name())+`"`) ||
!strings.Contains(prepareBody, `"parent_type":"bitable_file"`) ||
!strings.Contains(prepareBody, `"parent_node":"app_x"`) ||
!strings.Contains(prepareBody, `"size":20971521`) {
@@ -1847,14 +1804,11 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
t.Fatalf("finish body=%s", finishBody)
}
updateBody := string(updateStub.CapturedBody)
if !strings.Contains(updateBody, `"附件"`) ||
!strings.Contains(updateBody, `"file_token":"file_tok_big"`) ||
!strings.Contains(updateBody, `"name":"large-report.bin"`) ||
!strings.Contains(updateBody, `"size":20971521`) ||
!strings.Contains(updateBody, `"mime_type":"application/octet-stream"`) ||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) {
t.Fatalf("update body=%s", updateBody)
appendBody := string(appendStub.CapturedBody)
if !strings.Contains(appendBody, `"rec_x"`) ||
!strings.Contains(appendBody, `"fld_att"`) ||
!strings.Contains(appendBody, `"file_token":"file_tok_big"`) {
t.Fatalf("append body=%s", appendBody)
}
})
@@ -1928,6 +1882,434 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
t.Fatalf("err=%v", err)
}
})
t.Run("upload attachment rejects deprecated name flag", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
tmpFile, err := os.CreateTemp(t.TempDir(), "base-name-*.txt")
if err != nil {
t.Fatalf("CreateTemp() err=%v", err)
}
if err := tmpFile.Close(); err != nil {
t.Fatalf("Close() err=%v", err)
}
withBaseWorkingDir(t, filepath.Dir(tmpFile.Name()))
err = runShortcut(t, BaseRecordUploadAttachment, []string{
"+record-upload-attachment",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--record-id", "rec_x",
"--field-id", "fld_att",
"--file", "./" + filepath.Base(tmpFile.Name()),
"--name", "renamed.txt",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "--name is no longer supported") {
t.Fatalf("err=%v", err)
}
})
t.Run("download attachment includes extra query parameter", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
extra := `{"bitablePerm":{"tableId":"tbl_x","attachments":{"fld_att":{"rec_x":["box_a"]}}}}`
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"attachments": map[string]interface{}{
"rec_x": map[string]interface{}{
"fld_att": []interface{}{
map[string]interface{}{
"file_token": "box_a",
"name": "pic.png",
"size": 7,
"extra_info": extra,
},
},
},
},
},
},
})
downloadStub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_a/download?" + url.Values{"extra": []string{extra}}.Encode(),
RawBody: []byte("payload"),
ContentType: "image/png",
}
reg.Register(downloadStub)
tmpDir := t.TempDir()
withBaseWorkingDir(t, tmpDir)
if err := os.Mkdir("downloads", 0700); err != nil {
t.Fatalf("Mkdir() err=%v", err)
}
if err := runShortcut(t, BaseRecordDownloadAttachment, []string{
"+record-download-attachment",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--record-id", "rec_x",
"--file-token", "box_a",
"--output", "downloads",
}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "pic.png")); err != nil {
t.Fatalf("expected downloaded file: %v", err)
}
data := decodeBaseEnvelope(t, stdout)
gotItems, _ := data["downloaded"].([]interface{})
if len(gotItems) != 1 {
t.Fatalf("downloaded=%#v", data["downloaded"])
}
got, _ := gotItems[0].(map[string]interface{})
if got["file_token"] != "box_a" || got["saved_path"] == "" || got["extra_info_used"] != nil {
t.Fatalf("download output=%#v", got)
}
})
t.Run("download all row attachments when file token omitted", 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/get_attachments",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"attachments": map[string]interface{}{
"rec_x": map[string]interface{}{
"fld_att": []interface{}{
map[string]interface{}{"file_token": "box_a", "name": "a.txt", "size": 7},
map[string]interface{}{"file_token": "box_b", "name": "b.txt", "size": 8},
},
},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_a/download",
RawBody: []byte("payload-a"),
ContentType: "text/plain",
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_b/download",
RawBody: []byte("payload-b"),
ContentType: "text/plain",
})
tmpDir := t.TempDir()
withBaseWorkingDir(t, tmpDir)
if err := os.Mkdir("downloads", 0700); err != nil {
t.Fatalf("Mkdir() err=%v", err)
}
if err := runShortcut(t, BaseRecordDownloadAttachment, []string{
"+record-download-attachment",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--record-id", "rec_x",
"--output", "downloads",
}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil {
t.Fatalf("expected downloaded file a.txt: %v", err)
}
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "b.txt")); err != nil {
t.Fatalf("expected downloaded file b.txt: %v", err)
}
data := decodeBaseEnvelope(t, stdout)
gotItems, _ := data["downloaded"].([]interface{})
if len(gotItems) != 2 {
t.Fatalf("downloaded=%#v", data["downloaded"])
}
})
t.Run("download without file token requires output directory", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
tmpDir := t.TempDir()
withBaseWorkingDir(t, tmpDir)
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
"+record-download-attachment",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--record-id", "rec_x",
"--output", "file.txt",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "--output must be an existing directory") {
t.Fatalf("err=%v", err)
}
})
t.Run("download all disambiguates duplicate attachment names with file token", 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/get_attachments",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"attachments": map[string]interface{}{
"rec_x": map[string]interface{}{
"fld_att": []interface{}{
map[string]interface{}{"file_token": "box_a", "name": "same.txt", "size": 7},
map[string]interface{}{"file_token": "box_a", "name": "same.txt", "size": 7},
map[string]interface{}{"file_token": "box_b", "name": "same.txt", "size": 8},
},
},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_a/download",
RawBody: []byte("payload-a"),
ContentType: "text/plain",
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_b/download",
RawBody: []byte("payload-b"),
ContentType: "text/plain",
})
tmpDir := t.TempDir()
withBaseWorkingDir(t, tmpDir)
if err := os.Mkdir("downloads", 0700); err != nil {
t.Fatalf("Mkdir() err=%v", err)
}
if err := runShortcut(t, BaseRecordDownloadAttachment, []string{
"+record-download-attachment",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--record-id", "rec_x",
"--output", "downloads",
}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "same_box_a.txt")); err != nil {
t.Fatalf("expected downloaded file same_box_a.txt: %v", err)
}
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "same_box_b.txt")); err != nil {
t.Fatalf("expected downloaded file same_box_b.txt: %v", err)
}
data := decodeBaseEnvelope(t, stdout)
gotItems, _ := data["downloaded"].([]interface{})
if len(gotItems) != 2 {
t.Fatalf("downloaded=%#v", data["downloaded"])
}
})
t.Run("download duplicate requested file token only once", 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/get_attachments",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"attachments": map[string]interface{}{
"rec_x": map[string]interface{}{
"fld_att": []interface{}{
map[string]interface{}{"file_token": "box_a", "name": "a.txt", "size": 7},
},
},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_a/download",
RawBody: []byte("payload-a"),
ContentType: "text/plain",
})
tmpDir := t.TempDir()
withBaseWorkingDir(t, tmpDir)
if err := runShortcut(t, BaseRecordDownloadAttachment, []string{
"+record-download-attachment",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--record-id", "rec_x",
"--file-token", "box_a",
"--file-token", "box_a",
"--output", "a.txt",
}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
gotItems, _ := data["downloaded"].([]interface{})
if len(gotItems) != 1 {
t.Fatalf("downloaded=%#v", data["downloaded"])
}
})
t.Run("download all preflights local target conflicts before writing", 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/get_attachments",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"attachments": map[string]interface{}{
"rec_x": map[string]interface{}{
"fld_att": []interface{}{
map[string]interface{}{"file_token": "box_a", "name": "a.txt", "size": 7},
map[string]interface{}{"file_token": "box_b", "name": "b.txt", "size": 8},
},
},
},
},
},
})
tmpDir := t.TempDir()
withBaseWorkingDir(t, tmpDir)
if err := os.Mkdir("downloads", 0700); err != nil {
t.Fatalf("Mkdir() err=%v", err)
}
if err := os.WriteFile(filepath.Join("downloads", "b.txt"), []byte("existing"), 0600); err != nil {
t.Fatalf("WriteFile() err=%v", err)
}
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
"+record-download-attachment",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--record-id", "rec_x",
"--output", "downloads",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "output file already exists: downloads/b.txt") {
t.Fatalf("err=%v", err)
}
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err == nil {
t.Fatalf("a.txt should not be written after preflight conflict")
}
})
t.Run("download reports progress when later attachment fails", 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/get_attachments",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"attachments": map[string]interface{}{
"rec_x": map[string]interface{}{
"fld_att": []interface{}{
map[string]interface{}{"file_token": "box_a", "name": "a.txt", "size": 7},
map[string]interface{}{"file_token": "box_b", "name": "b.txt", "size": 8},
},
},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_a/download",
RawBody: []byte("payload-a"),
ContentType: "text/plain",
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_b/download",
Status: 500,
RawBody: []byte("server error"),
})
tmpDir := t.TempDir()
withBaseWorkingDir(t, tmpDir)
if err := os.Mkdir("downloads", 0700); err != nil {
t.Fatalf("Mkdir() err=%v", err)
}
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
"+record-download-attachment",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--record-id", "rec_x",
"--output", "downloads",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "download failed after 1 attachment(s) succeeded and 1 failed") {
t.Fatalf("err=%v", err)
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured error, got %T %v", err, err)
}
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
downloaded, _ := detail["downloaded"].([]map[string]interface{})
failed, _ := detail["failed"].([]map[string]interface{})
if len(downloaded) != 1 || downloaded[0]["file_token"] != "box_a" || len(failed) != 1 || failed[0]["file_token"] != "box_b" {
t.Fatalf("detail=%#v", exitErr.Detail.Detail)
}
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil {
t.Fatalf("expected first file to remain: %v", err)
}
})
t.Run("remove attachment", 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/fields/fld_att",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"id": "fld_att", "name": "附件", "type": "attachment"},
},
})
removeStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/remove_attachments",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"attachments": map[string]interface{}{
"rec_x": map[string]interface{}{"fld_att": []interface{}{}},
},
},
},
}
reg.Register(removeStub)
if err := runShortcut(t, BaseRecordRemoveAttachment, []string{
"+record-remove-attachment",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--record-id", "rec_x",
"--field-id", "fld_att",
"--file-token", "box_a",
"--file-token", "box_b",
"--yes",
}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); strings.Contains(got, `"removed"`) || strings.Contains(got, `"updated"`) {
t.Fatalf("stdout=%s", got)
}
body := string(removeStub.CapturedBody)
if !strings.Contains(body, `"rec_x"`) ||
!strings.Contains(body, `"fld_att"`) ||
!strings.Contains(body, `"file_token":"box_a"`) ||
!strings.Contains(body, `"file_token":"box_b"`) {
t.Fatalf("remove body=%s", body)
}
})
}
func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) {

View File

@@ -132,7 +132,7 @@ func TestShortcutsCatalog(t *testing.T) {
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
"+record-list", "+record-search", "+record-get", "+record-upsert", "+record-batch-create", "+record-batch-update", "+record-share-link-create", "+record-upload-attachment", "+record-delete",
"+record-list", "+record-search", "+record-get", "+record-upsert", "+record-batch-create", "+record-batch-update", "+record-share-link-create", "+record-upload-attachment", "+record-download-attachment", "+record-remove-attachment", "+record-delete",
"+record-history-list",
"+base-get", "+base-copy", "+base-create",
"+role-create", "+role-delete", "+role-update", "+role-list", "+role-get", "+advperm-enable", "+advperm-disable",
@@ -175,14 +175,15 @@ func TestBaseFieldUpdateRisk(t *testing.T) {
func TestBaseDeleteShortcutsRisk(t *testing.T) {
cases := map[string]string{
BaseFieldDelete.Command: BaseFieldDelete.Risk,
BaseViewDelete.Command: BaseViewDelete.Risk,
BaseRecordDelete.Command: BaseRecordDelete.Risk,
BaseFormDelete.Command: BaseFormDelete.Risk,
BaseFormQuestionsDelete.Command: BaseFormQuestionsDelete.Risk,
BaseDashboardDelete.Command: BaseDashboardDelete.Risk,
BaseDashboardBlockDelete.Command: BaseDashboardBlockDelete.Risk,
BaseRoleDelete.Command: BaseRoleDelete.Risk,
BaseFieldDelete.Command: BaseFieldDelete.Risk,
BaseViewDelete.Command: BaseViewDelete.Risk,
BaseRecordDelete.Command: BaseRecordDelete.Risk,
BaseRecordRemoveAttachment.Command: BaseRecordRemoveAttachment.Risk,
BaseFormDelete.Command: BaseFormDelete.Risk,
BaseFormQuestionsDelete.Command: BaseFormQuestionsDelete.Risk,
BaseDashboardDelete.Command: BaseDashboardDelete.Risk,
BaseDashboardBlockDelete.Command: BaseDashboardBlockDelete.Risk,
BaseRoleDelete.Command: BaseRoleDelete.Risk,
}
for command, risk := range cases {
@@ -338,6 +339,79 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
}
}
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantHelp []string
wantTips []string
}{
{
name: "upload attachment",
shortcut: BaseRecordUploadAttachment,
wantHelp: []string{
"repeat to append multiple attachments in one cell",
"max 50 files, max 2GB each",
},
wantTips: []string{
"lark-cli base +record-upload-attachment",
"Repeat --file to append multiple attachments",
"Reuse returned file_token values for download/remove",
},
},
{
name: "download attachment",
shortcut: BaseRecordDownloadAttachment,
wantHelp: []string{
"repeat to download selected files",
"omit to download all attachments in the record",
"with multiple or omitted file tokens this must be an existing directory",
},
wantTips: []string{
"lark-cli base +record-download-attachment",
"Omit --file-token to download every attachment in the record",
"Base attachments should be downloaded with this command",
"other download commands may fail",
},
},
{
name: "remove attachment",
shortcut: BaseRecordRemoveAttachment,
wantHelp: []string{
"remove from the target cell",
"max 50 tokens",
},
wantTips: []string{
"lark-cli base +record-remove-attachment",
"Repeat --file-token",
"requires --yes",
},
},
}
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)
}
}
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 assertHelpOrder(t *testing.T, help string, before string, after string) {
t.Helper()
beforeIndex := strings.Index(help, before)

View File

@@ -8,27 +8,37 @@ import (
"context"
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"mime"
"net/http"
"path/filepath"
"sort"
"strings"
"unicode/utf8"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
const (
baseAttachmentUploadMaxFileSize int64 = 2 * 1024 * 1024 * 1024
baseAttachmentParentType = "bitable_file"
baseAttachmentMaxBatchSize = 50
baseAttachmentGetMaxRecords = 10
)
var BaseRecordUploadAttachment = common.Shortcut{
Service: "base",
Command: "+record-upload-attachment",
Description: "Upload a local file to a Base attachment field and write it into the target record",
Description: "Upload one or more local files and append the returned file_token values to a Base attachment cell",
Risk: "write",
Scopes: []string{"base:record:update", "base:field:read", "docs:document.media:upload"},
AuthTypes: authTypes(),
@@ -37,34 +47,99 @@ var BaseRecordUploadAttachment = common.Shortcut{
tableRefFlag(true),
recordRefFlag(true),
fieldRefFlag(true),
{Name: "file", Desc: "local file path (max 2GB; files > 20MB use multipart upload automatically)", Required: true},
{Name: "name", Desc: "attachment file name (default: local file name)"},
{Name: "file", Type: "string_array", Desc: "local file path; repeat to append multiple attachments in one cell; max 50 files, max 2GB each; files > 20MB use multipart upload automatically", Required: true},
{Name: "name", Desc: "deprecated; attachment names are derived from local file basenames", Hidden: true},
},
Tips: []string{
`Example: lark-cli base +record-upload-attachment --base-token <base_token> --table-id <table_id> --record-id <record_id> --field-id <attachment_field_id> --file ./report.pdf`,
`Repeat --file to append multiple attachments: --file ./report.pdf --file ./screenshot.png`,
`Reuse returned file_token values for download/remove`,
},
DryRun: dryRunRecordUploadAttachment,
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordUploadAttachment(runtime)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordUploadAttachment(runtime)
},
}
var BaseRecordDownloadAttachment = common.Shortcut{
Service: "base",
Command: "+record-download-attachment",
Description: "Download Base record attachments by record-id, optionally filtering by file-token",
Risk: "read",
Scopes: []string{"base:record:read", "docs:document.media:download"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
recordRefFlag(true),
{Name: "file-token", Type: "string_array", Desc: "attachment file_token returned by Base; repeat to download selected files; omit to download all attachments in the record", Required: false},
{Name: "output", Desc: "local save path; with exactly one file token this may be a file path; with multiple or omitted file tokens this must be an existing directory", Required: true},
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
},
Tips: []string{
`Example: lark-cli base +record-download-attachment --base-token <base_token> --table-id <table_id> --record-id <record_id> --file-token <file_token> --output ./downloads/`,
`Omit --file-token to download every attachment in the record.`,
`Base attachments should be downloaded with this command; other download commands may fail for Base attachment files.`,
`With one --file-token, --output may be a file path or directory; with multiple or omitted --file-token values, --output must be an existing directory.`,
},
DryRun: dryRunRecordDownloadAttachment,
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordDownloadAttachment(runtime)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordDownloadAttachment(ctx, runtime)
},
}
var BaseRecordRemoveAttachment = common.Shortcut{
Service: "base",
Command: "+record-remove-attachment",
Description: "Remove one or more file_token values from a Base record attachment cell",
Risk: "high-risk-write",
Scopes: []string{"base:record:update", "base:field:read"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
recordRefFlag(true),
fieldRefFlag(true),
{Name: "file-token", Type: "string_array", Desc: "attachment file_token to remove from the target cell; repeat to remove multiple attachments; max 50 tokens", Required: true},
},
Tips: []string{
`Example: lark-cli base +record-remove-attachment --base-token <base_token> --table-id <table_id> --record-id <record_id> --field-id <attachment_field_id> --file-token <file_token> --yes`,
`Repeat --file-token to remove multiple attachments from the same cell in one call.`,
`This is a high-risk write command and requires --yes.`,
},
DryRun: dryRunRecordRemoveAttachment,
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordRemoveAttachment(runtime)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordRemoveAttachment(runtime)
},
}
func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
filePath := runtime.Str("file")
fileName := strings.TrimSpace(runtime.Str("name"))
if fileName == "" {
files := runtime.StrArray("file")
filePath := "<file>"
fileName := "<local_file_name>"
if len(files) > 0 {
filePath = files[0]
fileName = filepath.Base(filePath)
}
dry := common.NewDryRunAPI().
Desc("4-step orchestration: validate attachment field → read existing record attachments → upload file to Base → patch merged attachment array").
Desc("3-step orchestration: validate attachment field → upload local file(s) to Base → append uploaded file token(s) to the attachment cell").
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
Desc("[1] Read target field and ensure it is an attachment field").
Set("base_token", runtime.Str("base-token")).
Set("table_id", baseTableID(runtime)).
Set("field_id", runtime.Str("field-id")).
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
Desc("[2] Read current record to preserve existing attachments in the target cell").
Set("record_id", runtime.Str("record-id"))
Set("field_id", runtime.Str("field-id"))
if baseAttachmentShouldUseMultipart(runtime.FileIO(), filePath) {
dry.POST("/open-apis/drive/v1/medias/upload_prepare").
Desc("[3a] Initialize multipart attachment upload to the current Base").
Desc("[2a] Initialize multipart attachment upload to the current Base").
Body(map[string]interface{}{
"file_name": fileName,
"parent_type": baseAttachmentParentType,
@@ -72,7 +147,7 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
"size": "<file_size>",
}).
POST("/open-apis/drive/v1/medias/upload_part").
Desc("[3b] Upload attachment parts (repeated)").
Desc("[2b] Upload attachment parts (repeated for each large file)").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"seq": "<chunk_index>",
@@ -80,14 +155,14 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
"file": "<chunk_binary>",
}).
POST("/open-apis/drive/v1/medias/upload_finish").
Desc("[3c] Finalize multipart attachment upload and get file token").
Desc("[2c] Finalize multipart attachment upload and get file token").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"block_num": "<block_num>",
})
} else {
dry.POST("/open-apis/drive/v1/medias/upload_all").
Desc("[3] Upload local file to the current Base as attachment media (multipart/form-data)").
Desc("[2] Upload local file(s) to the current Base as attachment media (multipart/form-data)").
Body(map[string]interface{}{
"file_name": fileName,
"parent_type": baseAttachmentParentType,
@@ -97,46 +172,87 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
})
}
return dry.
PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
Desc("[4] Update the target attachment cell with existing attachments plus the uploaded file token").
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/append_attachments").
Desc("[3] Append uploaded file token(s) to the target attachment cell").
Body(map[string]interface{}{
"<attachment_field_name>": []interface{}{
map[string]interface{}{
"file_token": "<existing_file_token>",
"name": "<existing_file_name>",
"deprecated_set_attachment": true,
},
map[string]interface{}{
"file_token": "<uploaded_file_token>",
"name": fileName,
"mime_type": "<detected_mime_type>",
"size": "<file_size>",
"deprecated_set_attachment": true,
"attachments": map[string]interface{}{
runtime.Str("record-id"): map[string]interface{}{
runtime.Str("field-id"): []interface{}{
map[string]interface{}{
"file_token": "<uploaded_file_token>",
"image_width": "<image_width_if_image>",
"image_height": "<image_height_if_image>",
},
},
},
},
})
}
func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
filePath := runtime.Str("file")
fio := runtime.FileIO()
if fio == nil {
return output.ErrValidation("file operations require a FileIO provider")
}
fileInfo, err := fio.Stat(filePath)
if err != nil {
if errors.Is(err, fileio.ErrPathValidation) {
return output.ErrValidation("unsafe file path: %s", err)
}
return output.ErrValidation("file not accessible: %s: %v", filePath, err)
}
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
return output.ErrValidation("file %s exceeds 2GB limit", common.FormatSize(fileInfo.Size()))
}
func dryRunRecordDownloadAttachment(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("2-step orchestration: read Base attachment metadata → download each requested attachment file").
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/get_attachments").
Desc("[1] Read attachment metadata for the record").
Body(map[string]interface{}{"record_id_list": []string{runtime.Str("record-id")}}).
Set("base_token", runtime.Str("base-token")).
Set("table_id", baseTableID(runtime)).
GET("/open-apis/drive/v1/medias/:file_token/download").
Desc("[2] Download attachment media through the Base attachment flow").
Set("file_token", "<file_token>").
Set("output", runtime.Str("output")).
Params(map[string]interface{}{"extra": "<extra_info_if_present>"})
}
fileName := strings.TrimSpace(runtime.Str("name"))
if fileName == "" {
fileName = filepath.Base(filePath)
func dryRunRecordRemoveAttachment(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := buildSingleCellAttachmentsBody(runtime.Str("record-id"), runtime.Str("field-id"), fileTokenPatchItems(runtime.StrArray("file-token")))
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/remove_attachments").
Desc("Remove attachment file token(s) from the target attachment cell").
Body(body).
Set("base_token", runtime.Str("base-token")).
Set("table_id", baseTableID(runtime))
}
func validateRecordUploadAttachment(runtime *common.RuntimeContext) error {
if runtime.Changed("name") {
return common.FlagErrorf("--name is no longer supported; uploaded attachment names are derived from local file basenames")
}
files, err := normalizeAttachmentFiles(runtime.StrArray("file"))
if err != nil {
return err
}
for _, path := range files {
if _, err := validateAttachmentInputFile(runtime, path); err != nil {
return err
}
}
return nil
}
func validateRecordDownloadAttachment(runtime *common.RuntimeContext) error {
tokens, err := normalizeOptionalDownloadAttachmentFileTokens(runtime.StrArray("file-token"))
if err != nil {
return err
}
if len(tokens) != 1 {
info, statErr := runtime.FileIO().Stat(runtime.Str("output"))
if statErr != nil || !info.IsDir() {
return common.FlagErrorf("--output must be an existing directory when downloading multiple attachments or when --file-token is omitted")
}
}
return nil
}
func validateRecordRemoveAttachment(runtime *common.RuntimeContext) error {
_, err := normalizeAttachmentFileTokens(runtime.StrArray("file-token"))
return err
}
func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
files, err := normalizeAttachmentFiles(runtime.StrArray("file"))
if err != nil {
return err
}
field, err := fetchBaseField(runtime, runtime.Str("base-token"), baseTableID(runtime), runtime.Str("field-id"))
@@ -146,44 +262,172 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
if normalized := normalizeFieldTypeName(fieldTypeName(field)); normalized != "attachment" {
return output.ErrValidation("field %q is type %q, expected attachment", fieldName(field), normalized)
}
resolvedFieldID := fieldID(field)
if resolvedFieldID == "" {
resolvedFieldID = runtime.Str("field-id")
}
record, err := fetchBaseRecord(runtime, runtime.Str("base-token"), baseTableID(runtime), runtime.Str("record-id"))
appendItems := make([]interface{}, 0, len(files))
for _, filePath := range files {
fileInfo, err := validateAttachmentInputFile(runtime, filePath)
if err != nil {
return err
}
fileName := filepath.Base(filePath)
fmt.Fprintf(runtime.IO().ErrOut, "Uploading attachment: %s -> record %s field %s\n", fileName, runtime.Str("record-id"), fieldName(field))
if fileInfo.Size() > common.MaxDriveMediaUploadSinglePartSize {
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
}
attachment, err := uploadAttachmentToBase(runtime, filePath, fileName, runtime.Str("base-token"), fileInfo.Size())
if err != nil {
return err
}
appendItems = append(appendItems, attachmentAppendItem(attachment))
}
body := buildSingleCellAttachmentsBody(runtime.Str("record-id"), resolvedFieldID, appendItems)
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "append_attachments"), nil, body)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Uploading attachment: %s -> record %s field %s\n", fileName, runtime.Str("record-id"), fieldName(field))
if fileInfo.Size() > common.MaxDriveMediaUploadSinglePartSize {
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
}
attachment, err := uploadAttachmentToBase(runtime, filePath, fileName, runtime.Str("base-token"), fileInfo.Size())
if err != nil {
return err
}
attachments, err := mergeRecordAttachments(record, fieldName(field), attachment)
if err != nil {
return err
}
body := map[string]interface{}{
fieldName(field): attachments,
}
data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, body)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{
"record": data,
"attachment": attachment,
"attachments": attachments,
"updated": true,
}, nil)
runtime.Out(data, nil)
return nil
}
func executeRecordRemoveAttachment(runtime *common.RuntimeContext) error {
tokens, err := normalizeAttachmentFileTokens(runtime.StrArray("file-token"))
if err != nil {
return err
}
field, err := fetchBaseField(runtime, runtime.Str("base-token"), baseTableID(runtime), runtime.Str("field-id"))
if err != nil {
return err
}
if normalized := normalizeFieldTypeName(fieldTypeName(field)); normalized != "attachment" {
return output.ErrValidation("field %q is type %q, expected attachment", fieldName(field), normalized)
}
resolvedFieldID := fieldID(field)
if resolvedFieldID == "" {
resolvedFieldID = runtime.Str("field-id")
}
body := buildSingleCellAttachmentsBody(runtime.Str("record-id"), resolvedFieldID, fileTokenPatchItems(tokens))
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "remove_attachments"), nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
}
func executeRecordDownloadAttachment(ctx context.Context, runtime *common.RuntimeContext) error {
tokens, err := normalizeOptionalDownloadAttachmentFileTokens(runtime.StrArray("file-token"))
if err != nil {
return err
}
attachments, err := fetchBaseAttachments(runtime, runtime.Str("base-token"), baseTableID(runtime), []string{runtime.Str("record-id")})
if err != nil {
return err
}
items, err := selectAttachmentDownloadItems(attachments, runtime.Str("record-id"), tokens)
if err != nil {
return err
}
targets, err := planAttachmentDownloadTargets(runtime, items, runtime.Str("output"), len(tokens) != 1 || len(items) > 1, runtime.Bool("overwrite"))
if err != nil {
return err
}
downloaded := make([]map[string]interface{}, 0, len(targets))
for _, target := range targets {
saved, err := downloadBaseAttachment(ctx, runtime, target.Item, target.TargetPath, runtime.Bool("overwrite"))
if err != nil {
failed := attachmentDownloadFailure(target, err)
return attachmentDownloadProgressError(err, downloaded, []map[string]interface{}{failed})
}
downloaded = append(downloaded, saved)
}
runtime.Out(map[string]interface{}{"downloaded": downloaded}, nil)
return nil
}
func validateAttachmentInputFile(runtime *common.RuntimeContext, filePath string) (fileio.FileInfo, error) {
fio := runtime.FileIO()
if fio == nil {
return nil, output.ErrValidation("file operations require a FileIO provider")
}
fileInfo, err := fio.Stat(filePath)
if err != nil {
if errors.Is(err, fileio.ErrPathValidation) {
return nil, output.ErrValidation("unsafe file path: %s", err)
}
return nil, output.ErrValidation("file not accessible: %s: %v", filePath, err)
}
if fileInfo.IsDir() {
return nil, output.ErrValidation("file path is a directory: %s", filePath)
}
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
return nil, output.ErrValidation("file %s exceeds 2GB limit", common.FormatSize(fileInfo.Size()))
}
return fileInfo, nil
}
func normalizeAttachmentFiles(files []string) ([]string, error) {
return normalizeStringList(files, stringListNormalizeOptions{
typeError: "attachment files must be a string array",
emptyError: "provide at least one --file",
itemName: "attachment file",
duplicateName: "attachment file",
limitName: "attachment file count",
max: baseAttachmentMaxBatchSize,
})
}
func normalizeAttachmentFileTokens(tokens []string) ([]string, error) {
return normalizeStringList(tokens, stringListNormalizeOptions{
typeError: "attachment file tokens must be a string array",
emptyError: "provide at least one --file-token",
itemName: "attachment file token",
duplicateName: "attachment file token",
limitName: "attachment file token count",
max: baseAttachmentMaxBatchSize,
})
}
func normalizeOptionalDownloadAttachmentFileTokens(tokens []string) ([]string, error) {
if len(tokens) == 0 {
return nil, nil
}
normalized := make([]string, 0, len(tokens))
for index, token := range tokens {
token = strings.TrimSpace(token)
if token == "" {
return nil, common.FlagErrorf("attachment file token %d must not be empty", index+1)
}
normalized = append(normalized, token)
}
normalized = dedupeStringsPreserveOrder(normalized)
if len(normalized) > baseAttachmentMaxBatchSize {
return nil, common.FlagErrorf("attachment file token count exceeds maximum limit of %d (got %d)", baseAttachmentMaxBatchSize, len(normalized))
}
return normalized, nil
}
func dedupeStringsPreserveOrder(values []string) []string {
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func baseAttachmentShouldUseMultipart(fio fileio.FileIO, filePath string) bool {
if fio == nil {
return false
}
info, err := fio.Stat(filePath)
if err != nil {
return false
@@ -195,57 +439,24 @@ func fetchBaseField(runtime *common.RuntimeContext, baseToken, tableIDValue, fie
return baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, nil)
}
func fetchBaseRecord(runtime *common.RuntimeContext, baseToken, tableIDValue, recordID string) (map[string]interface{}, error) {
return baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "records", recordID), nil, nil)
}
func mergeRecordAttachments(record map[string]interface{}, fieldName string, uploaded map[string]interface{}) ([]interface{}, error) {
fields, _ := record["fields"].(map[string]interface{})
if fields == nil {
return []interface{}{uploaded}, nil
func fetchBaseAttachments(runtime *common.RuntimeContext, baseToken, tableIDValue string, recordIDs []string) (map[string]interface{}, error) {
if len(recordIDs) == 0 {
return nil, output.ErrValidation("provide at least one record id")
}
current, exists := fields[fieldName]
if !exists || util.IsNil(current) {
return []interface{}{uploaded}, nil
if len(recordIDs) > baseAttachmentGetMaxRecords {
return nil, output.ErrValidation("get attachments record selection exceeds maximum limit of %d (got %d)", baseAttachmentGetMaxRecords, len(recordIDs))
}
items, ok := current.([]interface{})
if !ok {
return nil, output.ErrValidation("record field %q has unexpected attachment payload type %T", fieldName, current)
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableIDValue, "get_attachments"), nil, map[string]interface{}{
"record_id_list": recordIDs,
})
if err != nil {
return nil, err
}
merged := make([]interface{}, 0, len(items)+1)
for _, item := range items {
attachment, ok := item.(map[string]interface{})
if !ok {
return nil, output.ErrValidation("record field %q contains unexpected attachment item type %T", fieldName, item)
}
merged = append(merged, normalizeAttachmentForPatch(attachment))
attachments, _ := data["attachments"].(map[string]interface{})
if attachments == nil {
return map[string]interface{}{}, nil
}
merged = append(merged, uploaded)
return merged, nil
}
func normalizeAttachmentForPatch(attachment map[string]interface{}) map[string]interface{} {
normalized := map[string]interface{}{}
if fileToken, _ := attachment["file_token"].(string); fileToken != "" {
normalized["file_token"] = fileToken
}
if name, _ := attachment["name"].(string); name != "" {
normalized["name"] = name
}
if mimeType, _ := attachment["mime_type"].(string); mimeType != "" {
normalized["mime_type"] = mimeType
}
if size, ok := attachment["size"]; ok && !util.IsNil(size) {
normalized["size"] = size
}
if imageWidth, ok := attachment["image_width"]; ok && !util.IsNil(imageWidth) {
normalized["image_width"] = imageWidth
}
if imageHeight, ok := attachment["image_height"]; ok && !util.IsNil(imageHeight) {
normalized["image_height"] = imageHeight
}
normalized["deprecated_set_attachment"] = true
return normalized
return attachments, nil
}
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) {
@@ -280,15 +491,51 @@ func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName,
}
attachment := map[string]interface{}{
"file_token": fileToken,
"name": fileName,
"mime_type": mimeType,
"size": fileSize,
"deprecated_set_attachment": true,
"file_token": fileToken,
"name": fileName,
"mime_type": mimeType,
"size": fileSize,
}
if width, height, ok := detectAttachmentImageDimensions(runtime.FileIO(), filePath, mimeType); ok {
attachment["image_width"] = width
attachment["image_height"] = height
} else if attachmentImageDimensionsWarningEnabled(mimeType) {
fmt.Fprintf(runtime.IO().ErrOut, "Warning: image dimensions unavailable for %s; attachment may display as square\n", fileName)
}
return attachment, nil
}
func attachmentAppendItem(attachment map[string]interface{}) map[string]interface{} {
item := map[string]interface{}{
"file_token": attachment["file_token"],
}
if width, ok := attachment["image_width"]; ok && !util.IsNil(width) {
item["image_width"] = width
}
if height, ok := attachment["image_height"]; ok && !util.IsNil(height) {
item["image_height"] = height
}
return item
}
func fileTokenPatchItems(tokens []string) []interface{} {
items := make([]interface{}, 0, len(tokens))
for _, token := range tokens {
items = append(items, map[string]interface{}{"file_token": token})
}
return items
}
func buildSingleCellAttachmentsBody(recordID, fieldID string, items []interface{}) map[string]interface{} {
return map[string]interface{}{
"attachments": map[string]interface{}{
recordID: map[string]interface{}{
fieldID: items,
},
},
}
}
func detectAttachmentMIMEType(fio fileio.FileIO, filePath, fileName string) (string, error) {
if byExt := strings.TrimSpace(mime.TypeByExtension(strings.ToLower(filepath.Ext(fileName)))); byExt != "" {
return stripMIMEParams(byExt), nil
@@ -311,6 +558,309 @@ func detectAttachmentMIMEType(fio fileio.FileIO, filePath, fileName string) (str
return detectAttachmentMIMEFromContent(buf[:n]), nil
}
func detectAttachmentImageDimensions(fio fileio.FileIO, filePath string, mimeType string) (int, int, bool) {
if fio == nil || !strings.HasPrefix(mimeType, "image/") {
return 0, 0, false
}
f, err := fio.Open(filePath)
if err != nil {
return 0, 0, false
}
defer f.Close()
cfg, _, err := image.DecodeConfig(f)
if err != nil || cfg.Width <= 0 || cfg.Height <= 0 {
return 0, 0, false
}
return cfg.Width, cfg.Height, true
}
func attachmentImageDimensionsWarningEnabled(mimeType string) bool {
switch mimeType {
case "image/gif", "image/jpeg", "image/png":
return true
default:
return false
}
}
type baseAttachmentDownloadItem struct {
RecordID string
FieldID string
FileToken string
Name string
Size interface{}
ExtraInfo string
MimeType string
RawPayload map[string]interface{}
}
type baseAttachmentDownloadTarget struct {
Item baseAttachmentDownloadItem
TargetPath string
ResolvedPath string
}
func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID string, tokens []string) ([]baseAttachmentDownloadItem, error) {
recordRaw, ok := attachments[recordID]
if !ok {
return nil, output.ErrValidation("record %q has no attachment metadata; verify the record-id", recordID)
}
fields, ok := recordRaw.(map[string]interface{})
if !ok {
return nil, output.ErrValidation("record %q attachment metadata has unexpected type %T", recordID, recordRaw)
}
byToken := map[string]baseAttachmentDownloadItem{}
fieldIDs := make([]string, 0, len(fields))
for currentFieldID := range fields {
fieldIDs = append(fieldIDs, currentFieldID)
}
sort.Strings(fieldIDs)
for _, currentFieldID := range fieldIDs {
rawList := fields[currentFieldID]
items, ok := rawList.([]interface{})
if !ok {
return nil, output.ErrValidation("record %q field %q attachment metadata has unexpected type %T", recordID, currentFieldID, rawList)
}
for _, rawItem := range items {
item, ok := rawItem.(map[string]interface{})
if !ok {
return nil, output.ErrValidation("record %q field %q contains unexpected attachment item type %T", recordID, currentFieldID, rawItem)
}
fileToken, _ := item["file_token"].(string)
if fileToken == "" {
continue
}
if _, exists := byToken[fileToken]; exists {
continue
}
name, _ := item["name"].(string)
extraInfo, _ := item["extra_info"].(string)
mimeType, _ := item["mime_type"].(string)
byToken[fileToken] = baseAttachmentDownloadItem{
RecordID: recordID,
FieldID: currentFieldID,
FileToken: fileToken,
Name: name,
Size: item["size"],
ExtraInfo: extraInfo,
MimeType: mimeType,
RawPayload: item,
}
}
}
result := make([]baseAttachmentDownloadItem, 0, len(tokens))
if len(tokens) == 0 {
for _, item := range byToken {
result = append(result, item)
}
if len(result) == 0 {
return nil, output.ErrValidation("record %q has no attachments to download", recordID)
}
sort.SliceStable(result, func(i, j int) bool {
leftName := strings.ToLower(baseAttachmentDownloadName(result[i]))
rightName := strings.ToLower(baseAttachmentDownloadName(result[j]))
if leftName != rightName {
return leftName < rightName
}
return result[i].FileToken < result[j].FileToken
})
return result, nil
}
for _, token := range tokens {
item, ok := byToken[token]
if !ok {
return nil, output.ErrValidation("attachment file_token %q not found in record %q; verify the record-id/file-token pair", token, recordID)
}
result = append(result, item)
}
return result, nil
}
func planAttachmentDownloadTargets(runtime *common.RuntimeContext, items []baseAttachmentDownloadItem, outputPath string, outputIsDir bool, overwrite bool) ([]baseAttachmentDownloadTarget, error) {
names := downloadTargetNames(items, outputIsDir || outputPathLooksDirectory(runtime, outputPath))
targets := make([]baseAttachmentDownloadTarget, 0, len(items))
seen := map[string]baseAttachmentDownloadItem{}
for _, item := range items {
targetName := names[item.FileToken]
targetPath := outputPath
if targetName != "" {
targetPath = filepath.Join(outputPath, targetName)
}
resolved, err := runtime.ResolveSavePath(targetPath)
if err != nil {
return nil, output.ErrValidation("unsafe output path: %s", err)
}
if previous, exists := seen[resolved]; exists {
return nil, output.ErrValidation("multiple attachments resolve to the same output path %q (%s and %s); download them separately or choose a different directory", resolved, previous.FileToken, item.FileToken)
}
seen[resolved] = item
if !overwrite {
if _, statErr := runtime.FileIO().Stat(targetPath); statErr == nil {
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", targetPath)
}
}
targets = append(targets, baseAttachmentDownloadTarget{
Item: item,
TargetPath: targetPath,
ResolvedPath: resolved,
})
}
return targets, nil
}
func downloadTargetNames(items []baseAttachmentDownloadItem, outputIsDir bool) map[string]string {
if !outputIsDir {
return nil
}
nameCounts := make(map[string]int, len(items))
for _, item := range items {
nameCounts[baseAttachmentDownloadName(item)]++
}
names := make(map[string]string, len(items))
for _, item := range items {
name := baseAttachmentDownloadName(item)
if nameCounts[name] > 1 {
name = attachmentNameWithTokenSuffix(name, item.FileToken)
}
names[item.FileToken] = name
}
return names
}
func baseAttachmentDownloadName(item baseAttachmentDownloadItem) string {
name := filepath.Base(strings.TrimSpace(item.Name))
if name == "" || name == "." || name == string(filepath.Separator) {
name = item.FileToken
}
return name
}
func attachmentNameWithTokenSuffix(name, fileToken string) string {
ext := filepath.Ext(name)
stem := strings.TrimSuffix(name, ext)
if stem == "" {
stem = name
}
return stem + "_" + safeAttachmentFileTokenSuffix(fileToken) + ext
}
func safeAttachmentFileTokenSuffix(fileToken string) string {
var b strings.Builder
for _, r := range fileToken {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
b.WriteRune(r)
continue
}
b.WriteByte('_')
}
suffix := strings.Trim(b.String(), "_")
if suffix == "" {
return "file"
}
return suffix
}
func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext, item baseAttachmentDownloadItem, targetPath string, overwrite bool) (map[string]interface{}, error) {
if _, err := runtime.ResolveSavePath(targetPath); err != nil {
return nil, output.ErrValidation("unsafe output path: %s", err)
}
query := larkcore.QueryParams{}
if item.ExtraInfo != "" {
query.Set("extra", item.ExtraInfo)
}
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/download", validate.EncodePathSegment(item.FileToken)),
QueryParams: query,
})
if err != nil {
return nil, output.ErrNetwork("download failed: %v", err)
}
defer resp.Body.Close()
if !overwrite {
if _, statErr := runtime.FileIO().Stat(targetPath); statErr == nil {
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", targetPath)
}
}
result, err := runtime.FileIO().Save(targetPath, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return nil, common.WrapSaveErrorByCategory(err, "io")
}
savedPath, _ := runtime.ResolveSavePath(targetPath)
if savedPath == "" {
savedPath = targetPath
}
return map[string]interface{}{
"record_id": item.RecordID,
"field_id": item.FieldID,
"file_token": item.FileToken,
"name": item.Name,
"size": item.Size,
"saved_path": savedPath,
"size_bytes": result.Size(),
"content_type": resp.Header.Get("Content-Type"),
}, nil
}
func attachmentDownloadFailure(target baseAttachmentDownloadTarget, err error) map[string]interface{} {
return map[string]interface{}{
"record_id": target.Item.RecordID,
"field_id": target.Item.FieldID,
"file_token": target.Item.FileToken,
"name": target.Item.Name,
"target_path": target.TargetPath,
"resolved_path": target.ResolvedPath,
"error": err.Error(),
}
}
func attachmentDownloadProgressError(err error, downloaded []map[string]interface{}, failed []map[string]interface{}) error {
msg := fmt.Sprintf("download failed after %d attachment(s) succeeded and %d failed: %v", len(downloaded), len(failed), err)
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
return &output.ExitError{
Code: exitErr.Code,
Detail: &output.ErrDetail{
Type: exitErr.Detail.Type,
Code: exitErr.Detail.Code,
Message: msg,
Hint: "Some files may already have been saved. Inspect error.detail.downloaded before retrying, or rerun with --overwrite if the failed target now exists.",
Detail: map[string]interface{}{
"downloaded": downloaded,
"failed": failed,
},
},
Err: err,
}
}
return &output.ExitError{
Code: output.ExitInternal,
Detail: &output.ErrDetail{
Type: "io",
Message: msg,
Hint: "Some files may already have been saved. Inspect error.detail.downloaded before retrying, or rerun with --overwrite if the failed target now exists.",
Detail: map[string]interface{}{
"downloaded": downloaded,
"failed": failed,
},
},
Err: err,
}
}
func outputPathLooksDirectory(runtime *common.RuntimeContext, outputPath string) bool {
if strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, string(filepath.Separator)) {
return true
}
info, err := runtime.FileIO().Stat(outputPath)
return err == nil && info.IsDir()
}
func stripMIMEParams(value string) string {
if i := strings.IndexByte(value, ';'); i != -1 {
value = value[:i]

View File

@@ -5,6 +5,9 @@ package base
import (
"bytes"
"image"
"image/color"
"image/png"
"io"
"io/fs"
"os"
@@ -82,6 +85,42 @@ func TestDetectAttachmentMIMETypeFallsBackToContent(t *testing.T) {
}
}
func TestDetectAttachmentImageDimensions(t *testing.T) {
var buf bytes.Buffer
img := image.NewRGBA(image.Rect(0, 0, 4, 3))
img.Set(0, 0, color.RGBA{G: 255, A: 255})
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("png.Encode() error = %v", err)
}
fio := attachmentTestFileIO{openFile: newAttachmentTestFile(buf.Bytes())}
width, height, ok := detectAttachmentImageDimensions(fio, "image.png", "image/png")
if !ok || width != 4 || height != 3 {
t.Fatalf("detectAttachmentImageDimensions() = (%d,%d,%v), want (4,3,true)", width, height, ok)
}
}
func TestAttachmentImageDimensionsWarningEnabled(t *testing.T) {
tests := []struct {
mimeType string
want bool
}{
{mimeType: "image/gif", want: true},
{mimeType: "image/jpeg", want: true},
{mimeType: "image/png", want: true},
{mimeType: "image/webp", want: false},
{mimeType: "application/pdf", want: false},
}
for _, tt := range tests {
t.Run(tt.mimeType, func(t *testing.T) {
if got := attachmentImageDimensionsWarningEnabled(tt.mimeType); got != tt.want {
t.Fatalf("attachmentImageDimensionsWarningEnabled(%q) = %v, want %v", tt.mimeType, got, tt.want)
}
})
}
}
func TestDetectAttachmentMIMETypeWrapsOpenError(t *testing.T) {
fio := attachmentTestFileIO{openErr: os.ErrNotExist}

View File

@@ -44,6 +44,8 @@ func Shortcuts() []common.Shortcut {
BaseRecordBatchUpdate,
BaseRecordShareLinkCreate,
BaseRecordUploadAttachment,
BaseRecordDownloadAttachment,
BaseRecordRemoveAttachment,
BaseRecordDelete,
BaseRecordHistoryList,
BaseBaseGet,

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
// FetchDriveMetaTitle looks up the document title via the drive metas batch_query API.
func FetchDriveMetaTitle(runtime *RuntimeContext, token, docType string) (string, error) {
data, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/metas/batch_query",
nil,
map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": token,
"doc_type": docType,
},
},
},
)
if err != nil {
return "", err
}
metas := GetSlice(data, "metas")
if len(metas) == 0 {
return "", nil
}
meta, _ := metas[0].(map[string]interface{})
return GetString(meta, "title"), nil
}

View File

@@ -0,0 +1,123 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"fmt"
"sync/atomic"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
)
var driveMetaTestSeq atomic.Int64
func TestFetchDriveMetaTitle(t *testing.T) {
t.Run("returns title from batch_query response", func(t *testing.T) {
runtime, reg := newDriveMetaTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"doc_token": "doxcnABC", "doc_type": "docx", "title": "My Document"},
},
},
},
})
title, err := FetchDriveMetaTitle(runtime, "doxcnABC", "docx")
if err != nil {
t.Fatalf("FetchDriveMetaTitle() error: %v", err)
}
if title != "My Document" {
t.Errorf("title = %q, want %q", title, "My Document")
}
})
t.Run("returns empty string when metas is empty", func(t *testing.T) {
runtime, reg := newDriveMetaTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{},
},
},
})
title, err := FetchDriveMetaTitle(runtime, "doxcnABC", "docx")
if err != nil {
t.Fatalf("FetchDriveMetaTitle() error: %v", err)
}
if title != "" {
t.Errorf("title = %q, want empty string", title)
}
})
t.Run("returns empty string when meta has no title", func(t *testing.T) {
runtime, reg := newDriveMetaTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"doc_token": "doxcnABC", "doc_type": "docx"},
},
},
},
})
title, err := FetchDriveMetaTitle(runtime, "doxcnABC", "docx")
if err != nil {
t.Fatalf("FetchDriveMetaTitle() error: %v", err)
}
if title != "" {
t.Errorf("title = %q, want empty string", title)
}
})
t.Run("propagates API error", func(t *testing.T) {
runtime, reg := newDriveMetaTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 99991668,
"msg": "permission denied",
},
})
_, err := FetchDriveMetaTitle(runtime, "doxcnABC", "docx")
if err == nil {
t.Fatal("FetchDriveMetaTitle() expected error, got nil")
}
})
}
func newDriveMetaTestRuntime(t *testing.T) (*RuntimeContext, *httpmock.Registry) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: fmt.Sprintf("drive-meta-test-%d", driveMetaTestSeq.Add(1)), AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, _, _, reg := cmdutil.TestFactory(t, cfg)
runtime := &RuntimeContext{
ctx: context.Background(),
Config: cfg,
Factory: f,
resolvedAs: core.AsBot,
}
return runtime, reg
}

View File

@@ -33,6 +33,26 @@ func GetFloat(m map[string]interface{}, keys ...string) float64 {
return f
}
// GetInt safely extracts an int, accepting both in-memory ints and JSON-style float64 values.
func GetInt(m map[string]interface{}, keys ...string) int {
if len(keys) == 0 {
return 0
}
v := navigate(m, keys[:len(keys)-1])
if v == nil {
return 0
}
switch n := v[keys[len(keys)-1]].(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
}
return 0
}
// GetBool safely extracts a bool.
func GetBool(m map[string]interface{}, keys ...string) bool {
if len(keys) == 0 {

View File

@@ -64,6 +64,32 @@ func TestGetFloat(t *testing.T) {
}
}
func TestGetInt(t *testing.T) {
m := map[string]interface{}{
"count": 42,
"json_count": 7.0,
"data": map[string]interface{}{
"score": int64(99),
},
}
if got := GetInt(m, "count"); got != 42 {
t.Errorf("GetInt(count) = %d, want 42", got)
}
if got := GetInt(m, "json_count"); got != 7 {
t.Errorf("GetInt(json_count) = %d, want 7", got)
}
if got := GetInt(m, "data", "score"); got != 99 {
t.Errorf("GetInt(data.score) = %d, want 99", got)
}
if got := GetInt(m, "missing"); got != 0 {
t.Errorf("GetInt(missing) = %d, want 0", got)
}
if got := GetInt(m); got != 0 {
t.Errorf("GetInt() = %d, want 0", got)
}
}
func TestGetBool(t *testing.T) {
m := map[string]interface{}{
"active": true,

View File

@@ -4,6 +4,7 @@
package common
import (
"net/url"
"strings"
"github.com/larksuite/cli/internal/core"
@@ -55,3 +56,79 @@ func BuildResourceURL(brand core.LarkBrand, kind, token string) string {
return ""
}
}
// ResourceRef holds the parsed type and token from a Lark resource URL.
type ResourceRef struct {
Type string // e.g. "docx", "bitable", "wiki", "sheet", etc.
Token string // the token extracted from the URL path
}
// urlPathToType maps URL path prefixes to resource types.
// Longer prefixes must come first to avoid false matches
// (e.g. "/drive/folder/" before a hypothetical "/drive/").
// Aliases (e.g. "/bitable/" → "bitable") must come after the
// canonical prefix to keep the list deterministic.
var urlPathToType = []struct {
Prefix string
Type string
}{
{"/drive/folder/", "folder"},
{"/docx/", "docx"},
{"/doc/", "doc"},
{"/sheets/", "sheet"},
{"/base/", "bitable"},
{"/bitable/", "bitable"},
{"/wiki/", "wiki"},
{"/file/", "file"},
{"/mindnote/", "mindnote"},
{"/slides/", "slides"},
}
// ParseResourceURL parses a Lark/Feishu URL and extracts the resource type
// and token from the URL path. It is the inverse of BuildResourceURL.
//
// Supported path patterns:
//
// /docx/TOKEN -> {Type: "docx", Token: TOKEN}
// /doc/TOKEN -> {Type: "doc", Token: TOKEN}
// /sheets/TOKEN -> {Type: "sheet", Token: TOKEN}
// /base/TOKEN -> {Type: "bitable", Token: TOKEN}
// /wiki/TOKEN -> {Type: "wiki", Token: TOKEN}
// /file/TOKEN -> {Type: "file", Token: TOKEN}
// /drive/folder/TOKEN -> {Type: "folder", Token: TOKEN}
// /mindnote/TOKEN -> {Type: "mindnote", Token: TOKEN}
// /slides/TOKEN -> {Type: "slides", Token: TOKEN}
//
// Returns (ResourceRef{}, false) when the URL does not match any known pattern.
func ParseResourceURL(rawURL string) (ResourceRef, bool) {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return ResourceRef{}, false
}
u, err := url.Parse(rawURL)
if err != nil {
return ResourceRef{}, false
}
path := u.Path
for _, mapping := range urlPathToType {
if !strings.HasPrefix(path, mapping.Prefix) {
continue
}
token := path[len(mapping.Prefix):]
// Trim trailing slashes and stop at the next path segment boundary.
token = strings.TrimRight(token, "/")
if idx := strings.IndexByte(token, '/'); idx >= 0 {
token = token[:idx]
}
token = strings.TrimSpace(token)
if token == "" {
return ResourceRef{}, false
}
return ResourceRef{Type: mapping.Type, Token: token}, true
}
return ResourceRef{}, false
}

View File

@@ -9,6 +9,102 @@ import (
"github.com/larksuite/cli/internal/core"
)
func TestParseResourceURL(t *testing.T) {
t.Parallel()
tests := []struct {
name string
rawURL string
wantType string
wantToken string
wantOK bool
}{
// All 9 supported types
{"docx", "https://xxx.feishu.cn/docx/doxcnABC", "docx", "doxcnABC", true},
{"doc", "https://xxx.feishu.cn/doc/doccnABC", "doc", "doccnABC", true},
{"sheet", "https://xxx.feishu.cn/sheets/shtcnABC", "sheet", "shtcnABC", true},
{"bitable via /base/", "https://xxx.feishu.cn/base/bascnABC", "bitable", "bascnABC", true},
{"bitable via /bitable/", "https://xxx.feishu.cn/bitable/bascnABC", "bitable", "bascnABC", true},
{"wiki", "https://xxx.feishu.cn/wiki/wikcnABC", "wiki", "wikcnABC", true},
{"file", "https://xxx.feishu.cn/file/boxcnABC", "file", "boxcnABC", true},
{"folder", "https://xxx.feishu.cn/drive/folder/fldcnABC", "folder", "fldcnABC", true},
{"mindnote", "https://xxx.feishu.cn/mindnote/mncnABC", "mindnote", "mncnABC", true},
{"slides", "https://xxx.feishu.cn/slides/slkcnABC", "slides", "slkcnABC", true},
// Lark domain
{"lark docx", "https://xxx.larksuite.com/docx/doxcnABC", "docx", "doxcnABC", true},
{"lark wiki", "https://xxx.larksuite.com/wiki/wikcnABC", "wiki", "wikcnABC", true},
// With query parameters
{"with query", "https://xxx.feishu.cn/docx/doxcnABC?from=wiki", "docx", "doxcnABC", true},
{"with fragment", "https://xxx.feishu.cn/docx/doxcnABC#section", "docx", "doxcnABC", true},
// With trailing slash
{"trailing slash", "https://xxx.feishu.cn/docx/doxcnABC/", "docx", "doxcnABC", true},
// With extra path segments after token
{"extra path", "https://xxx.feishu.cn/docx/doxcnABC/edit", "docx", "doxcnABC", true},
// Non-Lark host with Lark-like path (host validation is the caller's responsibility)
{"non-lark host with lark path", "https://google.com/docx/doxcnABC", "docx", "doxcnABC", true},
// Negative cases
{"unrecognized path", "https://xxx.feishu.cn/calendar/calABC", "", "", false},
{"non-lark host unrecognized path", "https://example.com/page", "", "", false},
{"empty input", "", "", "", false},
{"bare token", "doxcnABC", "", "", false},
{"invalid url parse", "://not-a-valid-url", "", "", false},
{"matching prefix but empty token", "https://xxx.feishu.cn/docx/", "", "", false},
{"matching prefix but whitespace-only token", "https://xxx.feishu.cn/docx/ ", "", "", false},
{"whitespace-only input", " ", "", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ref, ok := ParseResourceURL(tt.rawURL)
if ok != tt.wantOK {
t.Errorf("ParseResourceURL(%q) ok = %v, want %v", tt.rawURL, ok, tt.wantOK)
}
if ok {
if ref.Type != tt.wantType {
t.Errorf("ParseResourceURL(%q) Type = %q, want %q", tt.rawURL, ref.Type, tt.wantType)
}
if ref.Token != tt.wantToken {
t.Errorf("ParseResourceURL(%q) Token = %q, want %q", tt.rawURL, ref.Token, tt.wantToken)
}
}
})
}
}
// TestParseResourceURL_RoundTrip verifies that ParseResourceURL is the inverse
// of BuildResourceURL for all supported types.
func TestParseResourceURL_RoundTrip(t *testing.T) {
t.Parallel()
types := []string{"docx", "doc", "sheet", "bitable", "wiki", "file", "folder", "mindnote", "slides"}
token := "testTOKEN123"
for _, kind := range types {
t.Run(kind, func(t *testing.T) {
built := BuildResourceURL(core.BrandFeishu, kind, token)
if built == "" {
t.Fatalf("BuildResourceURL returned empty for kind %q", kind)
}
ref, ok := ParseResourceURL(built)
if !ok {
t.Fatalf("ParseResourceURL(%q) returned ok=false", built)
}
if ref.Type != kind {
t.Errorf("round-trip type mismatch: got %q, want %q", ref.Type, kind)
}
if ref.Token != token {
t.Errorf("round-trip token mismatch: got %q, want %q", ref.Token, token)
}
})
}
}
func TestBuildResourceURL(t *testing.T) {
t.Parallel()

View File

@@ -103,13 +103,15 @@ func (ctx *RuntimeContext) fetchBotInfo() (*BotInfo, error) {
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("fetch bot info: HTTP %d", resp.StatusCode)
}
// /open-apis/bot/v3/info returns `{code, msg, bot: {...}}` — the bot
// payload is under "bot", not "data" as the newer Lark API convention.
var envelope struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
OpenID string `json:"open_id"`
AppName string `json:"app_name"`
} `json:"data"`
} `json:"bot"`
}
if err := json.Unmarshal(resp.RawBody, &envelope); err != nil {
return nil, fmt.Errorf("fetch bot info: unmarshal: %w", err)

View File

@@ -57,7 +57,7 @@ func TestFetchBotInfo_Success(t *testing.T) {
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"bot": map[string]interface{}{
"open_id": "ou_bot_abc123",
"app_name": "TestBot",
},
@@ -86,7 +86,7 @@ func TestFetchBotInfo_ShortcutHeaders(t *testing.T) {
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"bot": map[string]interface{}{
"open_id": "ou_bot_header",
"app_name": "HeaderBot",
},
@@ -119,7 +119,7 @@ func TestFetchBotInfo_OnceSemantics(t *testing.T) {
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"bot": map[string]interface{}{
"open_id": "ou_bot_once",
"app_name": "OnceBot",
},
@@ -183,7 +183,7 @@ func TestFetchBotInfo_EmptyOpenID(t *testing.T) {
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"bot": map[string]interface{}{
"open_id": "",
"app_name": "EmptyBot",
},

View File

@@ -6,6 +6,7 @@ package doc
import (
"context"
"fmt"
"regexp"
"strings"
"github.com/spf13/cobra"
@@ -118,7 +119,7 @@ func validateUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
}
if needsSelectionV1[mode] && selEllipsis == "" && selTitle == "" {
return common.FlagErrorf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode)
return common.FlagErrorf(selectionRequiredMessageV1(mode))
}
if err := validateSelectionByTitleV1(selTitle); err != nil {
return err
@@ -127,6 +128,14 @@ func validateUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
return nil
}
func selectionRequiredMessageV1(mode string) string {
msg := fmt.Sprintf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode)
if mode == "replace_all" {
msg += ". If you intended to replace the entire document body, use --mode overwrite instead."
}
return msg
}
func validateSelectionByTitleV1(title string) error {
if title == "" {
return nil
@@ -160,6 +169,16 @@ func executeUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
}
// Overwrite replaces the entire document, silently discarding any
// whiteboard or file-attachment blocks that cannot be re-created from
// Markdown. Pre-fetch the current content and warn when such blocks
// are present so the caller can take a backup before proceeding.
if runtime.Str("mode") == "overwrite" {
if w := warnOverwriteResourceBlocks(runtime); w != "" {
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 != "" {
@@ -197,3 +216,74 @@ func buildUpdateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
}
return args
}
// resourceBlockRe matches the opening of a <whiteboard …> or <file …> tag
// (followed by whitespace, > or /) to avoid false positives on tag names like
// <file-view> or prose that merely mentions the word "whiteboard".
var resourceBlockRe = regexp.MustCompile(`<(whiteboard|file)[\s/>]`)
// warnOverwriteResourceBlocks pre-fetches the current document and returns a
// non-empty warning string when the document contains whiteboard or file
// attachment blocks that would be permanently deleted by an overwrite. Returns
// an empty string (no warning) when the document is clean or the fetch fails
// (we never block the overwrite on a best-effort check).
//
// This function is not unit-tested because it depends on an external MCP call
// (fetch-doc). The pure detection logic lives in checkOverwriteResourceBlocks,
// which has full table-driven coverage.
//
// Performance: this adds one extra fetch-doc round-trip to every --mode overwrite
// call, even when the document has no resource blocks. The cost is intentional:
// the guard is best-effort and silent on failure, so the latency is bounded and
// the trade-off is acceptable to avoid silent data loss.
func warnOverwriteResourceBlocks(runtime *common.RuntimeContext) string {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
// skip_task_detail reduces response payload by omitting per-block task
// metadata, making the pre-fetch faster and cheaper.
"skip_task_detail": true,
}
result, err := common.CallMCPTool(runtime, "fetch-doc", args)
if err != nil {
// Fetch failed — silently skip the guard rather than blocking overwrite.
return ""
}
md, _ := result["markdown"].(string)
return checkOverwriteResourceBlocks(md)
}
// checkOverwriteResourceBlocks scans Markdown for resource block tags that
// cannot survive an overwrite: <whiteboard …> and <file …>. Returns a
// warning string listing the counts if any are found, empty string otherwise.
func checkOverwriteResourceBlocks(markdown string) string {
matches := resourceBlockRe.FindAllStringSubmatch(markdown, -1)
whiteboards, files := 0, 0
for _, m := range matches {
switch m[1] {
case "whiteboard":
whiteboards++
case "file":
files++
}
}
var found []string
if whiteboards == 1 {
found = append(found, "1 whiteboard block")
} else if whiteboards > 1 {
found = append(found, fmt.Sprintf("%d whiteboard blocks", whiteboards))
}
if files == 1 {
found = append(found, "1 file attachment block")
} else if files > 1 {
found = append(found, fmt.Sprintf("%d file attachment blocks", files))
}
if len(found) == 0 {
return ""
}
return fmt.Sprintf(
"the document contains %s that cannot be reconstructed from Markdown; "+
"overwrite will permanently delete them. "+
"Consider fetching a backup with `docs +fetch` before overwriting.",
strings.Join(found, " and "),
)
}

View File

@@ -4,6 +4,7 @@ package doc
import (
"reflect"
"strings"
"testing"
)
@@ -32,6 +33,33 @@ func TestValidCommandsV2(t *testing.T) {
// ── V1 tests ──
func TestSelectionRequiredMessageV1ReplaceAllSuggestsOverwrite(t *testing.T) {
t.Parallel()
msg := selectionRequiredMessageV1("replace_all")
for _, needle := range []string{
"--replace_all mode requires --selection-with-ellipsis or --selection-by-title",
"replace the entire document body",
"--mode overwrite",
} {
if !strings.Contains(msg, needle) {
t.Fatalf("message missing %q: %s", needle, msg)
}
}
}
func TestSelectionRequiredMessageV1OtherModesDoNotSuggestOverwrite(t *testing.T) {
t.Parallel()
msg := selectionRequiredMessageV1("replace_range")
if strings.Contains(msg, "--mode overwrite") {
t.Fatalf("replace_range message should not suggest overwrite: %s", msg)
}
if !strings.Contains(msg, "--replace_range mode requires --selection-with-ellipsis or --selection-by-title") {
t.Fatalf("unexpected message: %s", msg)
}
}
func TestIsWhiteboardCreateMarkdown(t *testing.T) {
t.Run("blank whiteboard tags", func(t *testing.T) {
markdown := "<whiteboard type=\"blank\"></whiteboard>\n<whiteboard type=\"blank\"></whiteboard>"
@@ -55,6 +83,72 @@ func TestIsWhiteboardCreateMarkdown(t *testing.T) {
})
}
func TestCheckOverwriteResourceBlocks(t *testing.T) {
t.Parallel()
tests := []struct {
name string
markdown string
wantWarn bool
wantSubs []string
}{
{
name: "empty markdown is clean",
markdown: "",
wantWarn: false,
},
{
name: "plain prose is clean",
markdown: "## Heading\n\nsome text",
wantWarn: false,
},
{
name: "single whiteboard triggers warning",
markdown: `<whiteboard token="abc123"/>`,
wantWarn: true,
wantSubs: []string{"1 whiteboard block", "overwrite"},
},
{
name: "multiple whiteboards counted",
markdown: "<whiteboard token=\"a\"/>\n<whiteboard token=\"b\"/>",
wantWarn: true,
wantSubs: []string{"2 whiteboard blocks"},
},
{
name: "single file attachment triggers warning",
markdown: `<file token="tok" name="report.pdf"/>`,
wantWarn: true,
wantSubs: []string{"1 file attachment block"},
},
{
name: "multiple file attachments counted",
markdown: "<file token=\"a\"/>\n<file token=\"b\"/>\n<file token=\"c\"/>",
wantWarn: true,
wantSubs: []string{"3 file attachment blocks"},
},
{
name: "whiteboard and file together both counted",
markdown: "<whiteboard token=\"wb\"/>\n<file token=\"f\"/>",
wantWarn: true,
wantSubs: []string{"1 whiteboard block", "1 file attachment block"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := checkOverwriteResourceBlocks(tt.markdown)
if (got != "") != tt.wantWarn {
t.Fatalf("checkOverwriteResourceBlocks(%q) = %q, wantWarn=%v", tt.markdown, got, tt.wantWarn)
}
for _, sub := range tt.wantSubs {
if !strings.Contains(got, sub) {
t.Errorf("expected warning to contain %q, got: %s", sub, got)
}
}
})
}
}
func TestNormalizeWhiteboardResult(t *testing.T) {
t.Run("adds empty board_tokens when whiteboard creation response omits it", func(t *testing.T) {
result := map[string]interface{}{
@@ -101,3 +195,35 @@ func TestNormalizeWhiteboardResult(t *testing.T) {
}
})
}
func TestValidateSelectionByTitleV1(t *testing.T) {
t.Parallel()
tests := []struct {
name string
title string
wantErr bool
errSub string
}{
{name: "empty title is valid", title: "", wantErr: false},
{name: "single heading is valid", title: "## Section", wantErr: false},
{name: "h1 heading is valid", title: "# Top", wantErr: false},
{name: "deep heading is valid", title: "### Sub-section", wantErr: false},
{name: "missing hash prefix is invalid", title: "No hash", wantErr: true, errSub: "'#'"},
{name: "multiline title is invalid", title: "## First\n## Second", wantErr: true, errSub: "single"},
{name: "title with embedded carriage return is invalid", title: "## Title\r## Next", wantErr: true, errSub: "single"},
{name: "leading-space heading is valid after trim", title: " ## Section", wantErr: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateSelectionByTitleV1(tt.title)
if (err != nil) != tt.wantErr {
t.Fatalf("validateSelectionByTitleV1(%q) error = %v, wantErr = %v", tt.title, err, tt.wantErr)
}
if tt.wantErr && tt.errSub != "" && !strings.Contains(err.Error(), tt.errSub) {
t.Errorf("expected error to contain %q, got: %v", tt.errSub, err)
}
})
}
}

View File

@@ -12,6 +12,7 @@ import (
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -25,6 +26,7 @@ var DriveExport = common.Shortcut{
Scopes: []string{
"docs:document.content:read",
"docs:document:export",
"docx:document:readonly",
"drive:drive.metadata:readonly",
},
AuthTypes: []string{"user", "bot"},
@@ -52,16 +54,15 @@ var DriveExport = common.Shortcut{
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
}
// Markdown export is a special case: docx markdown comes from docs content
// directly instead of the Drive export task API.
// Markdown export is a special case: docx markdown comes from the V2
// docs_ai fetch API directly instead of the Drive export task API.
if spec.FileExtension == "markdown" {
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
dr := common.NewDryRunAPI().
Desc("2-step orchestration: fetch docx markdown -> write local file").
GET("/open-apis/docs/v1/content").
Params(map[string]interface{}{
"doc_token": spec.Token,
"doc_type": "docx",
"content_type": "markdown",
POST(apiPath).
Body(map[string]interface{}{
"format": "markdown",
}).
Set("output_dir", runtime.Str("output-dir"))
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
@@ -101,28 +102,38 @@ var DriveExport = common.Shortcut{
overwrite := runtime.Bool("overwrite")
// Markdown export bypasses the async export task and writes the fetched
// markdown content directly to disk.
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
// higher-quality Lark-flavored Markdown output.
if spec.FileExtension == "markdown" {
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
data, err := runtime.CallAPI(
"GET",
"/open-apis/docs/v1/content",
map[string]interface{}{
"doc_token": spec.Token,
"doc_type": "docx",
"content_type": "markdown",
},
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
data, err := runtime.DoAPIJSONWithLogID(
"POST",
apiPath,
nil,
map[string]interface{}{
"format": "markdown",
},
)
if err != nil {
return err
}
// Extract content from the V2 response: data.document.content
doc, ok := data["document"].(map[string]interface{})
if !ok {
return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document object")
}
content, ok := doc["content"].(string)
if !ok {
return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document.content")
}
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)
title, err := common.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
@@ -130,7 +141,7 @@ var DriveExport = common.Shortcut{
fileName = title
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(common.GetString(data, "content")), overwrite)
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
if err != nil {
return err
}
@@ -141,7 +152,7 @@ var DriveExport = common.Shortcut{
"file_extension": spec.FileExtension,
"file_name": filepath.Base(savedPath),
"saved_path": savedPath,
"size_bytes": len([]byte(common.GetString(data, "content"))),
"size_bytes": len(content),
}, nil)
return nil
}

View File

@@ -228,34 +228,6 @@ func parseDriveExportStatus(ticket string, data map[string]interface{}) driveExp
return status
}
// fetchDriveMetaTitle looks up the document title so exported files can use a
// human-readable default name when possible.
func fetchDriveMetaTitle(runtime *common.RuntimeContext, token, docType string) (string, error) {
data, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/metas/batch_query",
nil,
map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": token,
"doc_type": docType,
},
},
},
)
if err != nil {
return "", err
}
metas := common.GetSlice(data, "metas")
if len(metas) == 0 {
return "", nil
}
meta, _ := metas[0].(map[string]interface{})
return common.GetString(meta, "title"), nil
}
// saveContentToOutputDir validates the target path, enforces overwrite policy,
// and writes the payload atomically via FileIO.Save.
func saveContentToOutputDir(fio fileio.FileIO, outputDir, fileName string, payload []byte, overwrite bool) (string, error) {

View File

@@ -81,16 +81,19 @@ func TestValidateDriveExportSpec(t *testing.T) {
func TestDriveExportMarkdownWritesFile(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs/v1/content",
fetchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"content": "# hello\n",
"document": map[string]interface{}{
"content": "# hello\n",
},
},
},
})
}
reg.Register(fetchStub)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
@@ -118,6 +121,14 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
var reqBody map[string]interface{}
if err := json.Unmarshal(fetchStub.CapturedBody, &reqBody); err != nil {
t.Fatalf("unmarshal docs_ai fetch body: %v", err)
}
if reqBody["format"] != "markdown" {
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
}
data, err := os.ReadFile(filepath.Join(tmpDir, "Weekly Notes.md"))
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
@@ -132,16 +143,19 @@ 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",
fetchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"content": "# custom\n",
"document": map[string]interface{}{
"content": "# custom\n",
},
},
},
})
}
reg.Register(fetchStub)
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
@@ -158,6 +172,14 @@ func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
var reqBody map[string]interface{}
if err := json.Unmarshal(fetchStub.CapturedBody, &reqBody); err != nil {
t.Fatalf("unmarshal docs_ai fetch body: %v", err)
}
if reqBody["format"] != "markdown" {
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
}
data, err := os.ReadFile(filepath.Join(tmpDir, "custom-notes.md"))
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
@@ -179,7 +201,7 @@ func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
}{
{
name: "markdown",
wantURL: "/open-apis/docs/v1/content",
wantURL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
wantFileName: `"file_name": "notes.md"`,
args: []string{
"+export",
@@ -233,16 +255,19 @@ func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs/v1/content",
fetchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"content": "# fallback\n",
"document": map[string]interface{}{
"content": "# fallback\n",
},
},
},
})
}
reg.Register(fetchStub)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
@@ -267,6 +292,14 @@ func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
var reqBody map[string]interface{}
if err := json.Unmarshal(fetchStub.CapturedBody, &reqBody); err != nil {
t.Fatalf("unmarshal docs_ai fetch body: %v", err)
}
if reqBody["format"] != "markdown" {
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
}
data, err := os.ReadFile(filepath.Join(tmpDir, "docx123.md"))
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
@@ -279,6 +312,76 @@ func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
}
}
func TestDriveExportMarkdownRejectsMissingDocumentObject(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "markdown",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected error for missing document object, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
}
if !strings.Contains(exitErr.Detail.Message, "missing document object") {
t.Fatalf("error message = %q, want mention of missing document object", exitErr.Detail.Message)
}
}
func TestDriveExportMarkdownRejectsMissingDocumentContent(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"document": map[string]interface{}{},
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "markdown",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected error for missing document.content, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
}
if !strings.Contains(exitErr.Detail.Message, "missing document.content") {
t.Fatalf("error message = %q, want mention of missing document.content", exitErr.Detail.Message)
}
}
func TestDriveExportAsyncSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{

View File

@@ -0,0 +1,183 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
var DriveInspect = common.Shortcut{
Service: "drive",
Command: "+inspect",
Description: "Inspect a Lark document URL to get its type, title, and canonical token (with wiki unwrapping)",
Risk: "read",
Scopes: []string{"drive:drive.metadata:readonly"},
ConditionalScopes: []string{"wiki:node:retrieve"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{
Name: "url",
Desc: "Lark/Feishu document URL (docx, doc, sheet, bitable, wiki, file, folder, mindnote, slides)",
Required: true,
},
{
Name: "type",
Desc: "document type (required when --url is a bare token; auto-detected for URLs)",
Enum: []string{"doc", "docx", "sheet", "bitable", "wiki", "file", "folder", "mindnote", "slides"},
},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
raw := strings.TrimSpace(runtime.Str("url"))
if raw == "" {
return output.ErrValidation("--url cannot be empty")
}
_, ok := common.ParseResourceURL(raw)
if !ok {
// Not a recognized URL pattern.
if strings.Contains(raw, "://") {
return output.ErrValidation("unsupported --url %q: use a recognized Lark document URL or a bare token with --type", raw)
}
// Bare token: --type is required.
if strings.TrimSpace(runtime.Str("type")) == "" {
return output.ErrValidation("--type is required when --url is a bare token (allowed: doc, docx, sheet, bitable, wiki, file, folder, mindnote, slides)")
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
raw := strings.TrimSpace(runtime.Str("url"))
ref, ok := common.ParseResourceURL(raw)
if !ok {
ref = common.ResourceRef{
Type: strings.TrimSpace(runtime.Str("type")),
Token: raw,
}
}
dry := common.NewDryRunAPI()
if ref.Type == "wiki" {
dry.Desc("2-step: inspect wiki node, then batch query metadata")
dry.GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Inspect wiki node to get underlying document").
Params(map[string]interface{}{"token": ref.Token})
dry.POST("/open-apis/drive/v1/metas/batch_query").
Desc("[2] Batch query document metadata (title)").
Body(map[string]interface{}{
"request_docs": []map[string]interface{}{
{"doc_token": "<obj_token from step 1>", "doc_type": "<obj_type from step 1>"},
},
})
return dry
}
dry.Desc("1-step: batch query document metadata")
dry.POST("/open-apis/drive/v1/metas/batch_query").
Body(map[string]interface{}{
"request_docs": []map[string]interface{}{
{"doc_token": ref.Token, "doc_type": ref.Type},
},
})
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
raw := strings.TrimSpace(runtime.Str("url"))
// Step 1: Parse URL to extract {type, token}.
ref, ok := common.ParseResourceURL(raw)
if !ok {
// Bare token: use --type.
ref = common.ResourceRef{
Type: strings.TrimSpace(runtime.Str("type")),
Token: raw,
}
}
inputURL := raw
docType := ref.Type
docToken := ref.Token
var wikiNode map[string]interface{}
// Step 2: If type is "wiki", unwrap via get_node API.
if docType == "wiki" {
fmt.Fprintf(runtime.IO().ErrOut, "Inspecting wiki node: %s\n", common.MaskToken(docToken))
data, err := runtime.CallAPI(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": docToken},
nil,
)
if err != nil {
return err
}
node := common.GetMap(data, "node")
objType := common.GetString(node, "obj_type")
objToken := common.GetString(node, "obj_token")
spaceID := common.GetString(node, "space_id")
nodeToken := common.GetString(node, "node_token")
if objType == "" || objToken == "" {
return output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data (obj_type=%q, obj_token=%q)", objType, objToken)
}
wikiNode = map[string]interface{}{
"space_id": spaceID,
"node_token": nodeToken,
"obj_token": objToken,
"obj_type": objType,
}
docType = objType
docToken = objToken
fmt.Fprintf(runtime.IO().ErrOut, "Wiki unwrapped to %s: %s\n", docType, common.MaskToken(docToken))
}
// Step 3: Call batch_query to verify and get title.
title, err := common.FetchDriveMetaTitle(runtime, docToken, docType)
if err != nil {
return err
}
// Step 4: Build the resolved URL.
resolvedURL := common.BuildResourceURL(runtime.Config.Brand, docType, docToken)
// Step 5: Build output.
result := map[string]interface{}{
"input_url": inputURL,
"type": docType,
"title": title,
"token": docToken,
"url": resolvedURL,
}
if wikiNode != nil {
result["wiki_node"] = wikiNode
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Type: %s\n", docType)
if title != "" {
fmt.Fprintf(w, "Title: %s\n", title)
}
fmt.Fprintf(w, "Token: %s\n", docToken)
if resolvedURL != "" {
fmt.Fprintf(w, "URL: %s\n", resolvedURL)
}
if wikiNode != nil {
fmt.Fprintf(w, "Wiki: space_id=%s, node_token=%s\n", wikiNode["space_id"], wikiNode["node_token"])
}
})
return nil
},
}

View File

@@ -0,0 +1,466 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"encoding/json"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// --- Validate tests ---
func TestDriveInspectValidate_EmptyURL(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
cmd.Flags().String("type", "", "")
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
err := DriveInspect.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected error for empty --url, got nil")
}
}
func TestDriveInspectValidate_UnsupportedURL(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
cmd.Flags().String("type", "", "")
_ = cmd.Flags().Set("url", "https://google.com/some/page")
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
err := DriveInspect.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected error for unsupported URL, got nil")
}
}
func TestDriveInspectValidate_NonLarkHostWithLarkPath(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
cmd.Flags().String("type", "", "")
_ = cmd.Flags().Set("url", "https://google.com/docx/doxcnLooksValid")
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
err := DriveInspect.Validate(context.Background(), runtime)
if err != nil {
t.Fatalf("expected no error for non-Lark host with Lark-like path (host validation removed), got %v", err)
}
}
func TestDriveInspectValidate_BareTokenWithoutType(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
cmd.Flags().String("type", "", "")
_ = cmd.Flags().Set("url", "doxcnBareToken")
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
err := DriveInspect.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected error for bare token without --type, got nil")
}
}
func TestDriveInspectValidate_BareTokenWithType(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
cmd.Flags().String("type", "", "")
_ = cmd.Flags().Set("url", "doxcnBareToken")
_ = cmd.Flags().Set("type", "docx")
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
err := DriveInspect.Validate(context.Background(), runtime)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
func TestDriveInspectValidate_ValidDocxURL(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
cmd.Flags().String("type", "", "")
_ = cmd.Flags().Set("url", "https://xxx.feishu.cn/docx/doxcnABC")
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
err := DriveInspect.Validate(context.Background(), runtime)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
func TestDriveInspectValidate_ValidWikiURL(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
cmd.Flags().String("type", "", "")
_ = cmd.Flags().Set("url", "https://xxx.feishu.cn/wiki/wikcnABC")
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
err := DriveInspect.Validate(context.Background(), runtime)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
// --- DryRun tests ---
func TestDriveInspectDryRun_DocxURL(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
cmd.Flags().String("type", "", "")
_ = cmd.Flags().Set("url", "https://xxx.feishu.cn/docx/doxcnABC")
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
dry := DriveInspect.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
URL string `json:"url"`
Method string `json:"method"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run: %v", err)
}
if len(got.API) != 1 {
t.Fatalf("expected 1 API step, got %d", len(got.API))
}
if got.API[0].URL != "/open-apis/drive/v1/metas/batch_query" {
t.Errorf("API URL = %q, want /open-apis/drive/v1/metas/batch_query", got.API[0].URL)
}
// Verify body contains request_docs with the correct token and type.
reqDocs, ok := got.API[0].Body["request_docs"].([]interface{})
if !ok || len(reqDocs) != 1 {
t.Fatalf("expected request_docs with 1 entry, got %v", got.API[0].Body["request_docs"])
}
doc, _ := reqDocs[0].(map[string]interface{})
if doc["doc_token"] != "doxcnABC" {
t.Errorf("doc_token = %v, want doxcnABC", doc["doc_token"])
}
if doc["doc_type"] != "docx" {
t.Errorf("doc_type = %v, want docx", doc["doc_type"])
}
}
func TestDriveInspectDryRun_WikiURL(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
cmd.Flags().String("type", "", "")
_ = cmd.Flags().Set("url", "https://xxx.feishu.cn/wiki/wikcnABC")
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
dry := DriveInspect.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run: %v", err)
}
if len(got.API) != 2 {
t.Fatalf("expected 2 API steps, got %d", len(got.API))
}
if got.API[0].URL != "/open-apis/wiki/v2/spaces/get_node" {
t.Errorf("step 1 URL = %q, want /open-apis/wiki/v2/spaces/get_node", got.API[0].URL)
}
// Verify step 1 params contain the wiki token.
if got.API[0].Params["token"] != "wikcnABC" {
t.Errorf("step 1 params.token = %v, want wikcnABC", got.API[0].Params["token"])
}
if got.API[1].URL != "/open-apis/drive/v1/metas/batch_query" {
t.Errorf("step 2 URL = %q, want /open-apis/drive/v1/metas/batch_query", got.API[1].URL)
}
// Verify step 2 body contains request_docs placeholder.
if got.API[1].Body["request_docs"] == nil {
t.Error("step 2 body should contain request_docs")
}
}
func TestDriveInspectDryRun_BareTokenWithType(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
cmd.Flags().String("type", "", "")
_ = cmd.Flags().Set("url", "doxcnBareToken")
_ = cmd.Flags().Set("type", "docx")
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
dry := DriveInspect.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
URL string `json:"url"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run: %v", err)
}
if len(got.API) != 1 {
t.Fatalf("expected 1 API step, got %d", len(got.API))
}
}
// --- Execute tests ---
func TestDriveInspectExecute_DocxURL(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"doc_token": "doxcnABC", "doc_type": "docx", "title": "Test Doc"},
},
},
},
})
err := mountAndRunDrive(t, DriveInspect, []string{
"+inspect",
"--url", "https://xxx.feishu.cn/docx/doxcnABC",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if data["type"] != "docx" {
t.Errorf("type = %v, want docx", data["type"])
}
if data["token"] != "doxcnABC" {
t.Errorf("token = %v, want doxcnABC", data["token"])
}
if data["title"] != "Test Doc" {
t.Errorf("title = %v, want Test Doc", data["title"])
}
if _, ok := data["wiki_node"]; ok {
t.Error("wiki_node should not be present for non-wiki URL")
}
}
func TestDriveInspectExecute_WikiURL(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "docx",
"obj_token": "doxcnUnwrapped",
"space_id": "space123",
"node_token": "wikcnNodeToken",
"title": "Wiki Doc",
"node_type": "origin",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"doc_token": "doxcnUnwrapped", "doc_type": "docx", "title": "Wiki Doc"},
},
},
},
})
err := mountAndRunDrive(t, DriveInspect, []string{
"+inspect",
"--url", "https://xxx.feishu.cn/wiki/wikcnABC",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if data["type"] != "docx" {
t.Errorf("type = %v, want docx (unwrapped from wiki)", data["type"])
}
if data["token"] != "doxcnUnwrapped" {
t.Errorf("token = %v, want doxcnUnwrapped", data["token"])
}
if data["title"] != "Wiki Doc" {
t.Errorf("title = %v, want Wiki Doc", data["title"])
}
wikiNode, ok := data["wiki_node"].(map[string]interface{})
if !ok {
t.Fatal("wiki_node should be present for wiki URL")
}
if wikiNode["space_id"] != "space123" {
t.Errorf("wiki_node.space_id = %v, want space123", wikiNode["space_id"])
}
}
func TestDriveInspectExecute_WikiGetNodeIncompleteData(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "",
"obj_token": "",
},
},
},
})
err := mountAndRunDrive(t, DriveInspect, []string{
"+inspect",
"--url", "https://xxx.feishu.cn/wiki/wikcnABC",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("expected error for incomplete wiki node data, got nil")
}
}
func TestDriveInspectExecute_BareTokenWithType(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"doc_token": "doxcnBare", "doc_type": "docx", "title": "Bare Doc"},
},
},
},
})
err := mountAndRunDrive(t, DriveInspect, []string{
"+inspect",
"--url", "doxcnBare",
"--type", "docx",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if data["type"] != "docx" {
t.Errorf("type = %v, want docx", data["type"])
}
if data["token"] != "doxcnBare" {
t.Errorf("token = %v, want doxcnBare", data["token"])
}
}
func TestDriveInspectExecute_BatchQueryError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 99991668,
"msg": "permission denied",
},
})
err := mountAndRunDrive(t, DriveInspect, []string{
"+inspect",
"--url", "https://xxx.feishu.cn/docx/doxcnABC",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("expected error for batch_query failure, got nil")
}
}
func TestDriveInspectExecute_PrettyFormat(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"doc_token": "doxcnABC", "doc_type": "docx", "title": "Test Doc"},
},
},
},
})
err := mountAndRunDrive(t, DriveInspect, []string{
"+inspect",
"--url", "https://xxx.feishu.cn/docx/doxcnABC",
"--format", "pretty",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Pretty format outputs to stdout as text, not JSON envelope.
// Just verify it didn't error.
_ = stdout
}

View File

@@ -17,6 +17,8 @@ import (
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// driveStatusScopedTokenResolver returns a token with caller-controlled scopes
@@ -804,3 +806,59 @@ func TestDriveStatusRejectsMalformedFolderToken(t *testing.T) {
t.Fatalf("error must reference --folder-token, got: %v", err)
}
}
func TestWalkLocalForStatusMissingRootReturnsInternalError(t *testing.T) {
missingRoot := filepath.Join(t.TempDir(), "does-not-exist")
_, err := walkLocalForStatus(missingRoot, t.TempDir())
if err == nil {
t.Fatal("expected walkLocalForStatus() to fail for missing root")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected structured ExitError, got %T", err)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "io" {
t.Fatalf("expected io error detail, got %#v", exitErr.Detail)
}
if !strings.Contains(err.Error(), "walk") {
t.Fatalf("expected walk-related error, got: %v", err)
}
}
func TestHashLocalForStatusWrapsOpenError(t *testing.T) {
config := driveTestConfig()
f, _, _, _ := cmdutil.TestFactory(t, config)
runtime := common.TestNewRuntimeContext(&cobra.Command{Use: "drive"}, config)
runtime.Factory = f
_, err := hashLocalForStatus(runtime, "missing.txt")
if err == nil {
t.Fatal("expected hashLocalForStatus() to fail for missing file")
}
if !strings.Contains(err.Error(), "missing.txt") {
t.Fatalf("expected error to mention the missing file, got: %v", err)
}
}
func TestHashRemoteForStatusReturnsNetworkErrorWhenDownloadFails(t *testing.T) {
config := driveTestConfig()
f, _, _, _ := cmdutil.TestFactory(t, config)
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), &cobra.Command{Use: "drive"}, config)
runtime.Factory = f
_, err := hashRemoteForStatus(context.Background(), runtime, "tok_missing")
if err == nil {
t.Fatal("expected hashRemoteForStatus() to fail when the download request has no stub")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected structured ExitError, got %T", err)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
t.Fatalf("expected network detail, got %#v", exitErr.Detail)
}
if !strings.Contains(err.Error(), "download") {
t.Fatalf("expected download-related error, got: %v", err)
}
}

View File

@@ -0,0 +1,650 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
driveSyncOnConflictLocalWins = "local-wins"
driveSyncOnConflictRemoteWins = "remote-wins"
driveSyncOnConflictKeepBoth = "keep-both"
driveSyncOnConflictAsk = "ask"
)
type driveSyncItem struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
Action string `json:"action"`
Direction string `json:"direction,omitempty"` // "pull" or "push"
Error string `json:"error,omitempty"`
}
// DriveSync performs a two-way sync between a local directory and a Drive
// folder. It computes a diff (like +status), then:
// - new_remote → pull (download to local)
// - new_local → push (upload to Drive)
// - modified → resolve by --on-conflict strategy:
// local-wins: push local over remote;
// remote-wins: pull remote over local;
// keep-both: rename the local file with a hash suffix and pull the remote;
// ask: prompt the user per conflict.
var DriveSync = common.Shortcut{
Service: "drive",
Command: "+sync",
Description: "Two-way sync between a local directory and a Drive folder",
Risk: "write",
Scopes: []string{"drive:drive.metadata:readonly"},
ConditionalScopes: []string{
"drive:file:download",
"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: "Drive folder token", Required: true},
{Name: "on-conflict", Desc: "conflict resolution when both sides modified a file", Default: driveSyncOnConflictRemoteWins, Enum: []string{driveSyncOnConflictLocalWins, driveSyncOnConflictRemoteWins, driveSyncOnConflictKeepBoth, driveSyncOnConflictAsk}},
{Name: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}},
{Name: "quick", Type: "bool", Desc: "use best-effort modified_time comparison instead of SHA-256 hash; mismatched timestamps can still trigger real sync writes"},
},
Tips: []string{
"Two-way sync: new remote files are pulled, new local files are pushed, and conflicts (both sides modified) are resolved by --on-conflict.",
"Default --on-conflict=remote-wins pulls the remote version when both sides changed a file. Use local-wins to push instead, keep-both to rename and keep both copies, or ask for interactive resolution.",
"Pass --quick for faster best-effort diff detection using modified_time instead of SHA-256 hash (no remote file downloads needed during diffing).",
"Because +sync acts on the diff, --quick can still pull, overwrite, or rename files when timestamps differ even if file contents are actually unchanged.",
"Only entries with type=file are synced; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
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)
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("Compute diff between --local-dir and --folder-token, then pull new/modified-remote files, push new/modified-local files, and resolve conflicts by --on-conflict strategy.").
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"))
onConflict := strings.TrimSpace(runtime.Str("on-conflict"))
if onConflict == "" {
onConflict = driveSyncOnConflictRemoteWins
}
duplicateRemote := strings.TrimSpace(runtime.Str("on-duplicate-remote"))
if duplicateRemote == "" {
duplicateRemote = driveDuplicateRemoteFail
}
quick := runtime.Bool("quick")
if !quick {
if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil {
return err
}
}
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, err := filepath.Rel(cwdCanonical, safeRoot)
if err != nil {
return output.ErrValidation("--local-dir resolves outside cwd: %s", err)
}
// --- Phase 1: Compute diff (same logic as +status) ---
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
localFiles, err := walkLocalForStatus(safeRoot, cwdCanonical)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "")
if err != nil {
return err
}
if duplicates := blockingRemotePathConflicts(entries, duplicateRemote); len(duplicates) > 0 {
return duplicateRemotePathError(duplicates)
}
// A local regular file at the same rel_path as a remote
// folder/docx/shortcut is a type conflict: +sync would
// classify it as new_local and attempt to upload, which either
// fails at the API or leaves the remote in a broken state
// (same rel_path with mixed types). Detect early and hard-fail.
// Symmetrically, a local directory at the same rel_path as a
// remote file/docx/shortcut would attempt create_folder and
// produce the same broken mixed-type state.
var typeConflicts []string
for _, entry := range entries {
if entry.Type == driveTypeFile {
continue
}
if _, hasLocal := localFiles[entry.RelPath]; hasLocal {
typeConflicts = append(typeConflicts, fmt.Sprintf("%q: local file vs remote %s", entry.RelPath, entry.Type))
}
}
// Check local directories vs remote non-folder entries.
// localDirs is not available yet (walked later), so check
// the filesystem directly for the subset of remote paths
// that are non-folder.
for _, entry := range entries {
if entry.Type == driveTypeFolder {
continue
}
dirPath := filepath.Join(safeRoot, filepath.FromSlash(entry.RelPath))
if info, err := os.Stat(dirPath); err == nil && info.IsDir() { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
typeConflicts = append(typeConflicts, fmt.Sprintf("%q: local directory vs remote %s", entry.RelPath, entry.Type))
}
}
if len(typeConflicts) > 0 {
return output.ErrValidation("+sync cannot proceed: path type conflict — %s; remove the local entry or the remote entry and retry", strings.Join(typeConflicts, "; "))
}
// Build the exact remote-file views that later execution will use so the
// diff phase classifies files against the same duplicate-resolution choice.
pullRemoteFiles, _, err := drivePullRemoteViews(entries, duplicateRemote)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%s", err)
}
remoteEntriesForPush, remoteFolders, _, err := drivePushRemoteViews(entries, duplicateRemote)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%s", err)
}
remoteFiles := driveSyncStatusRemoteFiles(pullRemoteFiles)
paths := mergeStatusPaths(localFiles, remoteFiles)
var newLocal, newRemote, modified []driveStatusEntry
var unchanged []driveStatusEntry
for _, relPath := range paths {
localFile, hasLocal := localFiles[relPath]
remoteFile, hasRemote := remoteFiles[relPath]
switch {
case hasLocal && !hasRemote:
newLocal = append(newLocal, driveStatusEntry{RelPath: relPath})
case !hasLocal && hasRemote:
newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken})
default:
entry := driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken}
if quick {
if driveStatusShouldTreatAsUnchangedQuick(remoteFile.ModifiedTime, localFile.ModTime) {
unchanged = append(unchanged, entry)
} else {
modified = append(modified, entry)
}
continue
}
localHash, err := hashLocalForStatus(runtime, localFile.PathToCwd)
if err != nil {
return err
}
remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteFile.FileToken)
if err != nil {
return err
}
if localHash == remoteHash {
unchanged = append(unchanged, entry)
} else {
modified = append(modified, entry)
}
}
}
detection := driveStatusDetectionExact
if quick {
detection = driveStatusDetectionQuick
}
fmt.Fprintf(runtime.IO().ErrOut, "Diff: %d new_local, %d new_remote, %d modified, %d unchanged (detection=%s)\n",
len(newLocal), len(newRemote), len(modified), len(unchanged), detection)
conflictResolutions := make(map[string]string, len(modified))
if onConflict == driveSyncOnConflictAsk && len(modified) > 0 && runtime.IO().In == nil {
return output.ErrValidation("--on-conflict=ask requires interactive stdin when modified files exist")
}
for _, entry := range modified {
resolved := onConflict
if resolved == driveSyncOnConflictAsk {
resolved, err = driveSyncAskConflict(entry.RelPath, runtime)
if err != nil {
payload := map[string]interface{}{
"detection": detection,
"diff": map[string]interface{}{
"new_local": emptyIfNil(newLocal),
"new_remote": emptyIfNil(newRemote),
"modified": emptyIfNil(modified),
"unchanged": emptyIfNil(unchanged),
},
"summary": map[string]interface{}{
"pulled": 0,
"pushed": 0,
"skipped": 0,
"failed": 1,
},
"items": []driveSyncItem{{
RelPath: entry.RelPath,
FileToken: entry.FileToken,
Action: "failed",
Direction: "conflict",
Error: err.Error(),
}},
}
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "partial_failure",
Message: fmt.Sprintf("cannot collect conflict decisions for +sync: %v", err),
Detail: payload,
},
}
}
}
conflictResolutions[entry.RelPath] = resolved
}
// --- Phase 2: Execute sync operations ---
var pulled, pushed, skipped, failed int
items := make([]driveSyncItem, 0)
if quick && driveSyncNeedsDownloadScope(newRemote, modified, conflictResolutions) {
if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil {
return err
}
}
plannedUploads := driveSyncPlannedUploadPaths(newLocal, modified, conflictResolutions)
if len(plannedUploads) > 0 {
if err := runtime.EnsureScopes([]string{"drive:file:upload"}); err != nil {
return err
}
}
// Build push infrastructure: local walk for push + remote views + folder cache.
folderCache := map[string]string{"": folderToken}
for relDir, entry := range remoteFolders {
folderCache[relDir] = entry.FileToken
}
// Walk local filesystem early so we can include empty directories
// in the scope preflight (they also need space:folder:create).
pushLocalFiles, localDirs, err := drivePushWalkLocal(safeRoot, cwdCanonical)
if err != nil {
return err
}
if driveSyncNeedsCreateScope(plannedUploads, localDirs, folderCache) {
if err := runtime.EnsureScopes([]string{"space:folder:create"}); err != nil {
return err
}
}
// Mirror local directory structure first (same as +push), so
// empty local directories are not silently dropped.
for _, relDir := range localDirs {
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
continue
}
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
items = append(items, driveSyncItem{RelPath: relDir, Action: "failed", Direction: "push", Error: ensureErr.Error()})
failed++
continue
}
items = append(items, driveSyncItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created", Direction: "push"})
pushed++
}
// 2a. Pull new_remote files.
for _, entry := range newRemote {
targetFile, ok := pullRemoteFiles[entry.RelPath]
if !ok {
// Non-file type (doc, shortcut, etc.) — skip.
continue
}
target := filepath.Join(rootRelToCwd, entry.RelPath)
if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: err.Error()})
failed++
continue
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"})
pulled++
}
// 2b. Push new_local files.
for _, entry := range newLocal {
localFile, ok := pushLocalFiles[entry.RelPath]
if !ok {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "push", Error: "local file disappeared during sync"})
skipped++
continue
}
parentRel := drivePushParentRel(entry.RelPath)
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
if ensureErr != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "push", Error: ensureErr.Error()})
failed++
continue
}
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
if upErr != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "push", Error: upErr.Error()})
failed++
continue
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: token, Action: "uploaded", Direction: "push"})
pushed++
}
// 2c. Resolve modified files by --on-conflict strategy.
for _, entry := range modified {
remoteFile := remoteFiles[entry.RelPath]
localFile, hasLocal := pushLocalFiles[entry.RelPath]
if !hasLocal {
// Should not happen — modified means both sides exist.
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "conflict", Error: "local file disappeared during sync"})
skipped++
continue
}
resolved := conflictResolutions[entry.RelPath]
if resolved == "" {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "conflict", Error: "user skipped"})
skipped++
continue
}
switch resolved {
case driveSyncOnConflictRemoteWins:
// Pull remote over local.
targetFile, ok := pullRemoteFiles[entry.RelPath]
if !ok {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "pull", Error: "remote file not found in pull views"})
failed++
continue
}
target := filepath.Join(rootRelToCwd, entry.RelPath)
if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: err.Error()})
failed++
continue
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"})
pulled++
case driveSyncOnConflictLocalWins:
// Push local over remote.
existingToken := remoteFile.FileToken
if existingToken == "" {
if chosen, ok := remoteEntriesForPush[entry.RelPath]; ok {
existingToken = chosen.FileToken
}
}
parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache)
if parentErr != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: existingToken, Action: "failed", Direction: "push", Error: parentErr.Error()})
failed++
continue
}
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, existingToken, parentToken)
if upErr != nil {
// Token contract on overwrite failure (same as +push):
// a partial-success response can return a non-empty
// file_token alongside an error. Prefer the freshly
// returned token when one was produced, fall back to
// existingToken otherwise.
failedToken := token
if failedToken == "" {
failedToken = existingToken
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: failedToken, Action: "failed", Direction: "push", Error: upErr.Error()})
failed++
continue
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: token, Action: "overwritten", Direction: "push"})
pushed++
case driveSyncOnConflictKeepBoth:
// Rename the local file with a hash suffix, then pull the remote.
// Use the remote file token to generate a stable suffix (same
// pattern as +pull --on-duplicate-remote=rename).
occupied := occupiedRemotePaths(entries)
// Add current local paths to occupied set so the renamed
// local file doesn't collide with an existing file or directory.
for p := range pushLocalFiles {
occupied[p] = struct{}{}
}
for _, relDir := range localDirs {
occupied[relDir] = struct{}{}
}
suffixedRel, err := relPathWithUniqueFileTokenSuffix(entry.RelPath, remoteFile.FileToken, occupied)
if err != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "conflict", Error: err.Error()})
failed++
continue
}
// Rename the local file.
oldAbsPath := filepath.Join(safeRoot, filepath.FromSlash(entry.RelPath))
newAbsPath := filepath.Join(safeRoot, filepath.FromSlash(suffixedRel))
if err := os.Rename(oldAbsPath, newAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "conflict", Error: fmt.Sprintf("rename local: %s", err)})
failed++
continue
}
occupied[suffixedRel] = struct{}{}
// Now pull the remote version to the original path.
targetFile, ok := pullRemoteFiles[entry.RelPath]
if !ok {
rollbackErr := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath)
errMsg := "remote file not found in pull views after rename"
if rollbackErr != nil {
errMsg += "; rollback failed: " + rollbackErr.Error()
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "pull", Error: errMsg})
failed++
continue
}
target := filepath.Join(rootRelToCwd, entry.RelPath)
if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil {
rollbackErr := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath)
errMsg := err.Error()
if rollbackErr != nil {
errMsg += "; rollback failed: " + rollbackErr.Error()
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: errMsg})
failed++
continue
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "renamed_local", Direction: "conflict"})
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"})
pulled++
default:
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "conflict", Error: fmt.Sprintf("unknown conflict strategy: %s", resolved)})
skipped++
}
}
payload := map[string]interface{}{
"detection": detection,
"diff": map[string]interface{}{
"new_local": emptyIfNil(newLocal),
"new_remote": emptyIfNil(newRemote),
"modified": emptyIfNil(modified),
"unchanged": emptyIfNil(unchanged),
},
"summary": map[string]interface{}{
"pulled": pulled,
"pushed": pushed,
"skipped": skipped,
"failed": failed,
},
"items": items,
}
if failed > 0 {
msg := fmt.Sprintf("%d item(s) failed during +sync", failed)
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "partial_failure",
Message: msg,
Detail: payload,
},
}
}
runtime.Out(payload, nil)
return nil
},
}
func driveSyncStatusRemoteFiles(pullRemoteFiles map[string]drivePullTarget) map[string]driveStatusRemoteFile {
remoteFiles := make(map[string]driveStatusRemoteFile, len(pullRemoteFiles))
for relPath, target := range pullRemoteFiles {
fileToken := target.ItemFileToken
if fileToken == "" {
fileToken = target.DownloadToken
}
remoteFiles[relPath] = driveStatusRemoteFile{FileToken: fileToken, ModifiedTime: target.ModifiedTime}
}
return remoteFiles
}
// driveSyncAskConflict prompts the user for a conflict resolution strategy
// for a single file. Returns the strategy string, or empty string if the
// user chose to skip.
func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (string, error) {
fmt.Fprintf(runtime.IO().ErrOut, "CONFLICT: both sides modified %q. Choose: [R]emote-wins / [L]ocal-wins / [K]eep-both / [S]kip (default: R): ", relPath)
if runtime.IO().In == nil {
return "", output.ErrValidation("cannot resolve conflict for %q with --on-conflict=ask: stdin is not available", relPath)
}
reader, ok := runtime.IO().In.(*bufio.Reader)
if !ok {
reader = bufio.NewReader(runtime.IO().In)
runtime.IO().In = reader
}
line, err := reader.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", output.ErrValidation("cannot read conflict choice for %q: %s", relPath, err)
}
answer := strings.TrimSpace(strings.ToLower(line))
if answer == "" {
if errors.Is(err, io.EOF) {
return "", output.ErrValidation("cannot resolve conflict for %q with --on-conflict=ask: stdin reached EOF before any choice was provided", relPath)
}
return driveSyncOnConflictRemoteWins, nil
}
switch answer {
case "l", "local", "local-wins":
return driveSyncOnConflictLocalWins, nil
case "k", "keep", "keep-both":
return driveSyncOnConflictKeepBoth, nil
case "s", "skip":
return "", nil
case "r", "remote", "remote-wins":
return driveSyncOnConflictRemoteWins, nil
default:
return "", output.ErrValidation("invalid conflict choice for %q: %q (expected one of remote/local/keep/skip)", relPath, strings.TrimSpace(line))
}
}
func driveSyncNeedsDownloadScope(newRemote, modified []driveStatusEntry, conflictResolutions map[string]string) bool {
if len(newRemote) > 0 {
return true
}
for _, entry := range modified {
switch conflictResolutions[entry.RelPath] {
case driveSyncOnConflictRemoteWins, driveSyncOnConflictKeepBoth:
return true
}
}
return false
}
func driveSyncPlannedUploadPaths(newLocal, modified []driveStatusEntry, conflictResolutions map[string]string) []string {
planned := make([]string, 0, len(newLocal)+len(modified))
for _, entry := range newLocal {
planned = append(planned, entry.RelPath)
}
for _, entry := range modified {
if conflictResolutions[entry.RelPath] == driveSyncOnConflictLocalWins {
planned = append(planned, entry.RelPath)
}
}
return planned
}
func driveSyncNeedsCreateScope(uploadPaths []string, localDirs []string, folderCache map[string]string) bool {
for _, relPath := range uploadPaths {
parentRel := drivePushParentRel(relPath)
if parentRel == "" {
continue
}
if _, ok := folderCache[parentRel]; !ok {
return true
}
}
// Empty local directories also need create_folder if not already on Drive.
for _, relDir := range localDirs {
if _, ok := folderCache[relDir]; !ok {
return true
}
}
return false
}
func driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath string) error {
if info, err := os.Stat(oldAbsPath); err == nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
if info.IsDir() {
return output.Errorf(output.ExitInternal, "rollback", "original path became a directory during rollback: %s", oldAbsPath)
}
if err := os.Remove(oldAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
return output.Errorf(output.ExitInternal, "rollback", "remove partial restored path %q: %s", oldAbsPath, err)
}
} else if !os.IsNotExist(err) {
return output.Errorf(output.ExitInternal, "rollback", "stat original path %q during rollback: %s", oldAbsPath, err)
}
if err := os.Rename(newAbsPath, oldAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
return output.Errorf(output.ExitInternal, "rollback", "restore renamed local file %q: %s", oldAbsPath, err)
}
return nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,7 @@ import (
var DriveTaskResult = common.Shortcut{
Service: "drive",
Command: "+task_result",
Description: "Poll async task result for import, export, drive move/delete, wiki move, or wiki delete-space operations",
Description: "Poll async task result for import, export, drive move/delete, wiki move, wiki delete-space, or wiki delete-node operations",
Risk: "read",
// This shortcut multiplexes multiple backend APIs with different scope
// requirements, so scenario-specific prechecks are handled in Validate.
@@ -28,8 +28,8 @@ var DriveTaskResult = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "ticket", Desc: "async task ticket (for import/export tasks)", Required: false},
{Name: "task-id", Desc: "async task ID (for drive task_check, wiki_move, or wiki_delete_space tasks)", Required: false},
{Name: "scenario", Desc: "task scenario: import, export, task_check, wiki_move, or wiki_delete_space", Required: true},
{Name: "task-id", Desc: "async task ID (for drive task_check, wiki_move, wiki_delete_space, or wiki_delete_node tasks)", Required: false},
{Name: "scenario", Desc: "task scenario: import, export, task_check, wiki_move, wiki_delete_space, or wiki_delete_node", Required: true},
{Name: "file-token", Desc: "source document token used for export task status lookup", Required: false},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -40,9 +40,10 @@ var DriveTaskResult = common.Shortcut{
"task_check": true,
"wiki_move": true,
"wiki_delete_space": true,
"wiki_delete_node": true,
}
if !validScenarios[scenario] {
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space", scenario)
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space, wiki_delete_node", scenario)
}
// Validate required params based on scenario
@@ -54,7 +55,7 @@ var DriveTaskResult = common.Shortcut{
if err := validate.ResourceName(runtime.Str("ticket"), "--ticket"); err != nil {
return output.ErrValidation("%s", err)
}
case "task_check", "wiki_move", "wiki_delete_space":
case "task_check", "wiki_move", "wiki_delete_space", "wiki_delete_node":
if runtime.Str("task-id") == "" {
return output.ErrValidation("--task-id is required for %s scenario", scenario)
}
@@ -108,6 +109,11 @@ var DriveTaskResult = common.Shortcut{
Desc("[1] Query wiki delete-space task result").
Set("task_id", taskID).
Params(map[string]interface{}{"task_type": "delete_space"})
case "wiki_delete_node":
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
Desc("[1] Query wiki delete-node task result").
Set("task_id", taskID).
Params(map[string]interface{}{"task_type": "delete_node"})
}
return dry
@@ -136,6 +142,8 @@ var DriveTaskResult = common.Shortcut{
result, err = queryWikiMoveTask(runtime, taskID)
case "wiki_delete_space":
result, err = queryWikiDeleteSpaceTask(runtime, taskID)
case "wiki_delete_node":
result, err = queryWikiDeleteNodeTask(runtime, taskID)
}
if err != nil {
@@ -236,7 +244,7 @@ func validateDriveTaskResultScopes(ctx context.Context, runtime *common.RuntimeC
switch scenario {
case "import", "export", "task_check":
required = []string{"drive:drive.metadata:readonly"}
case "wiki_move", "wiki_delete_space":
case "wiki_move", "wiki_delete_space", "wiki_delete_node":
required = []string{"wiki:space:read"}
}
@@ -540,3 +548,64 @@ func queryWikiDeleteSpaceTask(runtime *common.RuntimeContext, taskID string) (ma
"status_msg": label,
}, nil
}
// queryWikiDeleteNodeTask returns the normalized status of an async wiki
// delete-node task. For historical reasons the gateway stashes delete-node
// status under the generic `simple_task_result` key (NOT `delete_node_result`),
// and that object only carries `status` — there is no `status_msg`, so the
// label falls back to the status code. Mirrors queryWikiDeleteSpaceTask;
// intentionally duplicated here (rather than importing the wiki package) to
// keep drive from depending on shortcuts/wiki.
func queryWikiDeleteNodeTask(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
return nil, output.ErrValidation("%s", err)
}
data, err := runtime.CallAPI(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
map[string]interface{}{"task_type": "delete_node"},
nil,
)
if err != nil {
return nil, err
}
task := common.GetMap(data, "task")
if task == nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
}
resolvedTaskID := common.GetString(task, "task_id")
if resolvedTaskID == "" {
resolvedTaskID = taskID
}
result := common.GetMap(task, "simple_task_result")
var status string
if result != nil {
status = common.GetString(result, "status")
}
// Keep in sync with wiki.parseWikiAsyncTaskStatus / wikiAsyncTaskStatus
// classification (intentionally duplicated to avoid a drive→wiki import —
// see the doc comment above). If the success/failed/processing rules change
// there, mirror the change here.
lowered := strings.ToLower(strings.TrimSpace(status))
ready := lowered == "success"
failed := lowered == "failure" || lowered == "failed"
resolvedStatus := strings.TrimSpace(status)
if resolvedStatus == "" {
resolvedStatus = "processing"
}
return map[string]interface{}{
"scenario": "wiki_delete_node",
"task_id": resolvedTaskID,
"ready": ready,
"failed": failed,
"status": resolvedStatus,
"status_msg": resolvedStatus,
}, nil
}

View File

@@ -417,10 +417,10 @@ func TestDriveTaskResultWikiMoveIncludesFlattenedNodeFields(t *testing.T) {
func TestValidateDriveTaskResultScopesWikiScenariosRequireWikiScope(t *testing.T) {
t.Parallel()
// wiki_move and wiki_delete_space both read wiki task status, so both must
// require wiki:space:read. A single table keeps this invariant explicit
// without duplicating near-identical test functions per scenario.
for _, scenario := range []string{"wiki_move", "wiki_delete_space"} {
// wiki_move, wiki_delete_space and wiki_delete_node all read wiki task
// status, so all must require wiki:space:read. A single table keeps this
// invariant explicit without duplicating near-identical test functions.
for _, scenario := range []string{"wiki_move", "wiki_delete_space", "wiki_delete_node"} {
t.Run(scenario+"/rejects missing scope", func(t *testing.T) {
t.Parallel()
runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "drive:drive.metadata:readonly")
@@ -518,6 +518,105 @@ func TestDriveTaskResultWikiDeleteSpaceSuccess(t *testing.T) {
}
}
func TestDriveTaskResultDryRunWikiDeleteNodeIncludesTaskTypeParam(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +task_result"}
cmd.Flags().String("scenario", "", "")
cmd.Flags().String("ticket", "", "")
cmd.Flags().String("task-id", "", "")
cmd.Flags().String("file-token", "", "")
if err := cmd.Flags().Set("scenario", "wiki_delete_node"); err != nil {
t.Fatalf("set --scenario: %v", err)
}
if err := cmd.Flags().Set("task-id", "task_del_node_1"); err != nil {
t.Fatalf("set --task-id: %v", err)
}
runtime := common.TestNewRuntimeContext(cmd, nil)
dry := DriveTaskResult.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 1 {
t.Fatalf("expected 1 API call, got %d", len(got.API))
}
if got.API[0].Params["task_type"] != "delete_node" {
t.Fatalf("wiki delete-node params = %#v, want task_type=delete_node", got.API[0].Params)
}
}
func TestDriveTaskResultWikiDeleteNodeSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/tasks/task_del_node_1",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"task": map[string]interface{}{
// Gateway returns delete-node status under the generic
// simple_task_result key (NOT delete_node_result), and it
// carries only `status` (no status_msg).
"simple_task_result": map[string]interface{}{
"status": "success",
},
},
},
},
})
err := mountAndRunDrive(t, DriveTaskResult, []string{
"+task_result",
"--scenario", "wiki_delete_node",
"--task-id", "task_del_node_1",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if data["scenario"] != "wiki_delete_node" || data["task_id"] != "task_del_node_1" {
t.Fatalf("unexpected wiki_delete_node envelope: %#v", data)
}
if data["ready"] != true || data["failed"] != false || data["status"] != "success" {
t.Fatalf("unexpected readiness fields: %#v", data)
}
// simple_task_result has no status_msg; label must fall back to status.
if data["status_msg"] != "success" {
t.Fatalf("status_msg = %#v, want fallback to status", data["status_msg"])
}
}
func TestDriveTaskResultRejectsUnknownScenarioListsWikiDeleteNode(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveTaskResult, []string{
"+task_result",
"--scenario", "bogus",
"--task-id", "task_x",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "wiki_delete_node") {
t.Fatalf("expected unsupported-scenario error listing wiki_delete_node, got %v", err)
}
}
func TestValidateDriveTaskResultScopesDriveScenariosRequireDriveScope(t *testing.T) {
t.Parallel()

View File

@@ -25,8 +25,10 @@ func Shortcuts() []common.Shortcut {
DriveStatus,
DrivePush,
DrivePull,
DriveSync,
DriveTaskResult,
DriveApplyPermission,
DriveSearch,
DriveInspect,
}
}

View File

@@ -28,9 +28,11 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+status",
"+push",
"+pull",
"+sync",
"+task_result",
"+apply-permission",
"+search",
"+inspect",
}
if len(got) != len(want) {

View File

@@ -166,6 +166,7 @@ type DraftProjection struct {
LargeAttachmentsSummary []LargeAttachmentSummary `json:"large_attachments_summary,omitempty"`
InlineSummary []PartSummary `json:"inline_summary,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Priority string `json:"priority"`
}
type Patch struct {

View File

@@ -140,9 +140,53 @@ func Project(snapshot *DraftSnapshot) DraftProjection {
proj.LargeAttachmentsSummary = projectLargeAttachments(snapshot.Headers, htmlBody)
proj.Priority = parsePriorityFromHeaders(snapshot.Headers)
return proj
}
// parsePriorityFromHeaders derives the read-side priority projection from
// EML headers. It mirrors the write-side helper helpers.go:parsePriority
// (which translates --set-priority high|normal|low into set_header /
// remove_header X-Cli-Priority ops). Lookup order is case-insensitive
// via headerValue:
// 1. X-Cli-Priority (CLI/OAPI-specific header recognised by
// mail-data-access headersToPbBodyExtra)
// 2. X-Priority (RFC standard, fallback for IMAP-回灌 historical drafts)
//
// When neither header is present (including after the write-side translates
// --set-priority normal into remove_header X-Cli-Priority), this returns
// "normal" — absence of a priority header is the standard email convention
// for normal priority. Agents cannot distinguish "explicitly normal" from
// "never set" — known limitation.
func parsePriorityFromHeaders(headers []Header) string {
if v := headerValue(headers, "X-Cli-Priority"); v != "" {
return mapPriorityValue(v)
}
if v := headerValue(headers, "X-Priority"); v != "" {
return mapPriorityValue(v)
}
return "normal"
}
// mapPriorityValue normalises a raw priority header value to the projection
// vocabulary {"high","normal","low","unknown"}. The accepted input table is
// kept in sync with backend gopkg/mail_priority.PriorityValueToType so that
// CLI read-side projection observes the same set of values the server
// recognises on write.
func mapPriorityValue(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "high", "1 (highest)":
return "high"
case "3", "normal", "3 (normal)":
return "normal"
case "5", "low", "5 (lowest)":
return "low"
default:
return "unknown"
}
}
// projectLargeAttachments extracts large attachment info from the draft.
// It first tries the server-format header (X-Lark-Large-Attachment) which
// carries filename and size directly. Falls back to merging CLI-format

View File

@@ -178,6 +178,170 @@ func TestSplitAtQuoteFalsePositivePlainText(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// Priority projection (X-Cli-Priority primary, X-Priority fallback)
// ---------------------------------------------------------------------------
func TestProjectPriorityXCliPriorityHigh(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: priority high
From: Alice <alice@example.com>
To: Bob <bob@example.com>
X-Cli-Priority: 1
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
hello
`)
proj := Project(snapshot)
if proj.Priority != "high" {
t.Fatalf("Priority = %q, want %q", proj.Priority, "high")
}
}
func TestProjectPriorityFallbackXPriorityLow(t *testing.T) {
// Only the standard X-Priority header is present (e.g. an IMAP-回灌
// historical draft). The fallback path should kick in.
snapshot := mustParseFixtureDraft(t, `Subject: priority low (fallback)
From: Alice <alice@example.com>
To: Bob <bob@example.com>
X-Priority: 5
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
hello
`)
proj := Project(snapshot)
if proj.Priority != "low" {
t.Fatalf("Priority = %q, want %q", proj.Priority, "low")
}
}
func TestProjectPriorityBothAbsentNormal(t *testing.T) {
// Neither header is present — default priority is normal.
snapshot := mustParseFixtureDraft(t, `Subject: no priority
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
hello
`)
proj := Project(snapshot)
if proj.Priority != "normal" {
t.Fatalf("Priority = %q, want %q", proj.Priority, "normal")
}
}
func TestProjectPriorityXCliPriorityOutlookStyleHigh(t *testing.T) {
// X-Cli-Priority set to the Outlook-style string "high" (any case).
snapshot := mustParseFixtureDraft(t, `Subject: priority high (string)
From: Alice <alice@example.com>
To: Bob <bob@example.com>
X-Cli-Priority: High
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
hello
`)
proj := Project(snapshot)
if proj.Priority != "high" {
t.Fatalf("Priority = %q, want %q", proj.Priority, "high")
}
}
func TestProjectPriorityUnmappedValueUnknown(t *testing.T) {
// Value outside the recognised mapping table (e.g. "urgent") falls
// back to "unknown".
snapshot := mustParseFixtureDraft(t, `Subject: priority urgent
From: Alice <alice@example.com>
To: Bob <bob@example.com>
X-Cli-Priority: urgent
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
hello
`)
proj := Project(snapshot)
if proj.Priority != "unknown" {
t.Fatalf("Priority = %q, want %q", proj.Priority, "unknown")
}
}
func TestProjectPriorityXCliPriorityWinsOverXPriority(t *testing.T) {
// X-Cli-Priority must take precedence over X-Priority when both are
// set (defensive: agent or upstream may write both).
snapshot := mustParseFixtureDraft(t, `Subject: both headers
From: Alice <alice@example.com>
To: Bob <bob@example.com>
X-Cli-Priority: 1
X-Priority: 5
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
hello
`)
proj := Project(snapshot)
if proj.Priority != "high" {
t.Fatalf("Priority = %q, want %q (X-Cli-Priority must win)", proj.Priority, "high")
}
}
func TestProjectPriorityNormalThree(t *testing.T) {
// X-Cli-Priority=3 → "normal" (rare in CLI write path since
// `--set-priority normal` actually removes the header, but this case
// covers e.g. a draft set by another OAPI client that wrote 3).
snapshot := mustParseFixtureDraft(t, `Subject: priority three
From: Alice <alice@example.com>
To: Bob <bob@example.com>
X-Cli-Priority: 3
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
hello
`)
proj := Project(snapshot)
if proj.Priority != "normal" {
t.Fatalf("Priority = %q, want %q", proj.Priority, "normal")
}
}
func TestProjectPriorityFallbackXPriorityNormalString(t *testing.T) {
// IMAP-回灌 / external client writes the RFC-standard `X-Priority: Normal`
// string. The fallback path must project this as "normal" — symmetric with
// how `X-Priority: High` / `Low` are already handled.
snapshot := mustParseFixtureDraft(t, `Subject: priority normal (fallback)
From: Alice <alice@example.com>
To: Bob <bob@example.com>
X-Priority: Normal
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
hello
`)
proj := Project(snapshot)
if proj.Priority != "normal" {
t.Fatalf("Priority = %q, want %q", proj.Priority, "normal")
}
}
func TestProjectPriorityOutlookStyleThreeNormal(t *testing.T) {
// Outlook-style `3 (Normal)` parenthesised form — symmetric with the
// already-supported `1 (Highest)` / `5 (Lowest)`.
snapshot := mustParseFixtureDraft(t, `Subject: priority three (normal)
From: Alice <alice@example.com>
To: Bob <bob@example.com>
X-Priority: 3 (Normal)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
hello
`)
proj := Project(snapshot)
if proj.Priority != "normal" {
t.Fatalf("Priority = %q, want %q", proj.Priority, "normal")
}
}
func TestParseMissingInlineCIDReportedAsProjectionWarning(t *testing.T) {
// Missing CID references should NOT prevent parsing; they are reported
// as warnings in Project() instead.

View File

@@ -2602,3 +2602,14 @@ func buildCalendarBody(runtime *common.RuntimeContext, senderEmail string, toAdd
senderEmail, toAddrs, ccAddrs,
)
}
// validateBotMailboxNotMe rejects the combination of bot identity with --mailbox me.
// bot uses tenant access token; "me" cannot be resolved to a user mailbox under TAT.
func validateBotMailboxNotMe(runtime *common.RuntimeContext) error {
if runtime.IsBot() && runtime.Str("mailbox") == "me" {
return output.ErrValidation(
"--as bot does not support --mailbox me: bot identity uses a tenant token and cannot resolve \"me\" to a user mailbox; " +
"pass an explicit email address, e.g. --mailbox alice@example.com")
}
return nil
}

View File

@@ -293,6 +293,9 @@ func executeDraftInspect(runtime *common.RuntimeContext, mailboxID, draftID stri
if len(projection.Warnings) > 0 {
fmt.Fprintf(w, "warnings: %s\n", sanitizeForTerminal(strings.Join(projection.Warnings, "; ")))
}
if projection.Priority != "" {
fmt.Fprintf(w, "priority: %s\n", sanitizeForTerminal(projection.Priority))
}
})
return nil
}
@@ -553,6 +556,7 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
"`add_inline`/`replace_inline`/`remove_inline` are for CID-based inline images",
"`replace_inline` keeps the original filename and content_type when those fields are omitted",
"protected headers require `allow_protected_header_edits=true`",
"--set-priority high|normal|low controls draft priority via X-Cli-Priority header (CLI/OAPI specific). high → set_header X-Cli-Priority=1; low → set_header X-Cli-Priority=5; normal → remove_header X-Cli-Priority. Backend mail-data-access headersToPbBodyExtra recognizes X-Cli-Priority but not standard X-Priority/Importance for OAPI flow.",
},
"command_example": "lark-cli mail +draft-edit --print-patch-template",
"patch_file_example": "lark-cli mail +draft-edit --draft-id d_xxx --patch-file ./patch.json",

View File

@@ -26,6 +26,9 @@ var MailMessage = common.Shortcut{
{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"},
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateBotMailboxNotMe(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveMailboxID(runtime)
messageID := runtime.Str("message-id")

View File

@@ -34,6 +34,9 @@ var MailMessages = common.Shortcut{
{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"},
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateBotMailboxNotMe(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveMailboxID(runtime)
messageIDs := splitByComma(runtime.Str("message-ids"))

View File

@@ -0,0 +1,130 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
)
// assertValidationError fails the test unless err is a *output.ExitError with
// ExitValidation code whose message contains wantSubstr.
func assertValidationError(t *testing.T, err error, wantSubstr string) {
t.Helper()
if err == nil {
t.Fatal("expected a validation error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("expected exit code %d (ExitValidation), got %d", output.ExitValidation, exitErr.Code)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "validation" {
t.Errorf("expected detail type \"validation\", got %+v", exitErr.Detail)
}
if wantSubstr != "" && !strings.Contains(exitErr.Error(), wantSubstr) {
t.Errorf("expected error message to contain %q, got: %v", wantSubstr, exitErr.Error())
}
}
// assertValidatePasses fails the test if err is a validation error; other
// errors (e.g. API call failures from missing tokens) are acceptable because
// we only care that the Validate callback passed.
func assertValidatePasses(t *testing.T, err error) {
t.Helper()
if err == nil {
return
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Code == output.ExitValidation {
t.Fatalf("Validate callback should have passed but returned validation error: %v", exitErr)
}
// Non-validation errors (auth/API failures) are expected without HTTP mocks.
}
// TC-1: +message --as bot --mailbox me → ErrValidation
func TestMailMessageBotMailboxMeReturnsValidationError(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailMessage, []string{
"+message", "--as", "bot", "--mailbox", "me", "--message-id", "msg_xxx",
}, f, stdout)
assertValidationError(t, err, "does not support --mailbox me")
}
// TC-2: +message --as bot --mailbox explicit → Validate passes
func TestMailMessageBotExplicitMailboxPassesValidation(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailMessage, []string{
"+message", "--as", "bot", "--mailbox", "alice@example.com", "--message-id", "msg_xxx",
}, f, stdout)
assertValidatePasses(t, err)
}
// TC-3: +message --as user --mailbox me → Validate passes
func TestMailMessageUserMailboxMePassesValidation(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailMessage, []string{
"+message", "--as", "user", "--mailbox", "me", "--message-id", "msg_xxx",
}, f, stdout)
assertValidatePasses(t, err)
}
// TC-4: +messages --as bot (default mailbox=me) → ErrValidation
func TestMailMessagesBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailMessages, []string{
"+messages", "--as", "bot", "--message-ids", "msg_xxx",
}, f, stdout)
assertValidationError(t, err, "does not support --mailbox me")
}
// TC-5: +messages --as bot --mailbox explicit → Validate passes
func TestMailMessagesBotExplicitMailboxPassesValidation(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailMessages, []string{
"+messages", "--as", "bot", "--mailbox", "alice@example.com", "--message-ids", "msg_xxx",
}, f, stdout)
assertValidatePasses(t, err)
}
// TC-6: +thread --as bot (default mailbox=me) → ErrValidation
func TestMailThreadBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailThread, []string{
"+thread", "--as", "bot", "--thread-id", "thread_xxx",
}, f, stdout)
assertValidationError(t, err, "does not support --mailbox me")
}
// TC-7: +thread --as bot --mailbox explicit → Validate passes
func TestMailThreadBotExplicitMailboxPassesValidation(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailThread, []string{
"+thread", "--as", "bot", "--mailbox", "alice@example.com", "--thread-id", "thread_xxx",
}, f, stdout)
assertValidatePasses(t, err)
}
// TC-8: +triage --as bot (default mailbox=me) → ErrValidation
func TestMailTriageBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailTriage, []string{
"+triage", "--as", "bot",
}, f, stdout)
assertValidationError(t, err, "does not support --mailbox me")
}
// TC-9: +triage --as bot --mailbox explicit → Validate passes
func TestMailTriageBotExplicitMailboxPassesValidation(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailTriage, []string{
"+triage", "--as", "bot", "--mailbox", "alice@example.com",
}, f, stdout)
assertValidatePasses(t, err)
}

View File

@@ -75,6 +75,9 @@ var MailTemplateCreate = common.Shortcut{
return api
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateBotMailboxNotMe(runtime); err != nil {
return err
}
if strings.TrimSpace(runtime.Str("name")) == "" {
return output.ErrValidation("--name is required")
}

View File

@@ -86,6 +86,9 @@ var MailTemplateUpdate = common.Shortcut{
if runtime.Bool("print-patch-template") {
return nil
}
if err := validateBotMailboxNotMe(runtime); err != nil {
return err
}
if err := validateTemplateID(runtime.Str("template-id")); err != nil {
return err
}

View File

@@ -58,6 +58,9 @@ var MailThread = common.Shortcut{
{Name: "include-spam-trash", Type: "bool", Desc: "Also return messages from SPAM and TRASH folders (excluded by default)"},
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateBotMailboxNotMe(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveMailboxID(runtime)
threadID := runtime.Str("thread-id")

View File

@@ -64,6 +64,9 @@ var MailTriage = common.Shortcut{
{Name: "labels", Type: "bool", Desc: "include label IDs in output"},
{Name: "print-filter-schema", Type: "bool", Desc: "print --filter field reference and exit"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateBotMailboxNotMe(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
mailbox := resolveMailboxID(runtime)
query := runtime.Str("query")

View File

@@ -5,6 +5,7 @@ package markdown
import (
"bytes"
"context"
"errors"
"fmt"
"io"
@@ -112,6 +113,42 @@ func finalMarkdownFileName(spec markdownUploadSpec) string {
return filepath.Base(spec.FilePath)
}
func resolveMarkdownOverwriteFileName(runtime *common.RuntimeContext, spec markdownUploadSpec) (string, error) {
fileName := strings.TrimSpace(spec.FileName)
if fileName == "" && spec.FileSet {
fileName = filepath.Base(spec.FilePath)
}
if fileName == "" {
remoteName, err := fetchMarkdownFileName(runtime, spec.FileToken)
if err != nil {
return "", err
}
fileName = strings.TrimSpace(remoteName)
}
if fileName == "" {
fileName = spec.FileToken + ".md"
}
return fileName, nil
}
func openMarkdownDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken string) (*http.Response, error) {
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return nil, output.ErrNetwork("download failed: %s", err)
}
return resp, nil
}
func validateNonEmptyMarkdownSize(size int64) error {
if size == 0 {
return output.ErrValidation("%s", markdownEmptyContentError)
}
return nil
}
func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec) (int64, error) {
var size int64
if spec.ContentSet {
@@ -127,8 +164,8 @@ func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec)
}
size = info.Size()
}
if size == 0 {
return 0, output.ErrValidation("%s", markdownEmptyContentError)
if err := validateNonEmptyMarkdownSize(size); err != nil {
return 0, err
}
return size, nil
}

View File

@@ -6,7 +6,6 @@ package markdown
import (
"context"
"io"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/output"
@@ -73,19 +72,9 @@ var MarkdownOverwrite = common.Shortcut{
return err
}
fileName := strings.TrimSpace(spec.FileName)
if fileName == "" && spec.FileSet {
fileName = filepath.Base(spec.FilePath)
}
if fileName == "" {
remoteName, err := fetchMarkdownFileName(runtime, fileToken)
if err != nil {
return err
}
fileName = strings.TrimSpace(remoteName)
}
if fileName == "" {
fileName = fileToken + ".md"
fileName, err := resolveMarkdownOverwriteFileName(runtime, spec)
if err != nil {
return err
}
spec.FileName = fileName

View File

@@ -0,0 +1,235 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package markdown
import (
"context"
"fmt"
"io"
"regexp"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
markdownPatchModeLiteral = "literal"
markdownPatchModeRegex = "regex"
)
type markdownPatchSpec struct {
FileToken string
Pattern string
Content string
ContentSet bool
Regex bool
}
var MarkdownPatch = common.Shortcut{
Service: "markdown",
Command: "+patch",
Description: "Patch a Markdown file in Drive via fetch-local-replace-overwrite",
Risk: "write",
Scopes: []string{"drive:file:download", "drive:file:upload", "drive:drive.metadata:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "file-token", Desc: "target Markdown file token", Required: true},
{Name: "pattern", Desc: "literal text or RE2 regex to match", Input: []string{common.File, common.Stdin}},
{Name: "content", Desc: "replacement Markdown content", Input: []string{common.File, common.Stdin}},
{Name: "regex", Type: "bool", Desc: "interpret --pattern as RE2 regular expression"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := newMarkdownPatchSpec(runtime)
if err := validateMarkdownPatchSpec(runtime, spec); err != nil {
return err
}
if spec.Regex {
if _, err := regexp.Compile(spec.Pattern); err != nil {
return output.ErrValidation("invalid --pattern regex: %s", err)
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := newMarkdownPatchSpec(runtime)
mode := markdownPatchModeLiteral
if spec.Regex {
mode = markdownPatchModeRegex
}
sizeThreshold := common.FormatSize(markdownSinglePartSizeLimit)
return common.NewDryRunAPI().
Desc("Download the current Markdown file, apply the replacement locally, and overwrite the file only when matches are found").
GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[1] Download the current Markdown content").
Set("file_token", spec.FileToken).
POST("/open-apis/drive/v1/metas/batch_query").
Desc("[2] Read current file metadata to preserve the existing file name before overwrite").
Body(map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": spec.FileToken,
"doc_type": "file",
},
},
}).
POST("/open-apis/drive/v1/files/upload_all").
Desc("[3a] If the patched Markdown is at most "+sizeThreshold+", overwrite the file with multipart/form-data upload_all").
Body(map[string]interface{}{
"file_name": "<existing_remote_name_or_" + spec.FileToken + ".md>",
"parent_type": "explorer",
"parent_node": "",
"size": "<updated_size_bytes>",
"file": "<patched_markdown_content>",
"file_token": spec.FileToken,
}).
POST("/open-apis/drive/v1/files/upload_prepare").
Desc("[3b] If the patched Markdown exceeds "+sizeThreshold+", initialize multipart overwrite upload").
Body(map[string]interface{}{
"file_name": "<existing_remote_name_or_" + spec.FileToken + ".md>",
"parent_type": "explorer",
"parent_node": "",
"size": "<updated_size_bytes>",
"file_token": spec.FileToken,
}).
POST("/open-apis/drive/v1/files/upload_part").
Desc("[3c] Upload file parts (repeated) when multipart overwrite is required").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"seq": "<chunk_index>",
"size": "<chunk_size>",
"file": "<chunk_binary>",
}).
POST("/open-apis/drive/v1/files/upload_finish").
Desc("[3d] Finalize multipart overwrite upload and return the new version").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"block_num": "<block_num>",
}).
Set("mode", mode)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := newMarkdownPatchSpec(runtime)
resp, err := openMarkdownDownload(ctx, runtime, spec.FileToken)
if err != nil {
return err
}
defer resp.Body.Close()
payload, err := io.ReadAll(resp.Body)
if err != nil {
return output.ErrNetwork("download failed: %s", err)
}
original := string(payload)
patched, matchCount, err := applyMarkdownPatch(original, spec)
if err != nil {
return err
}
mode := markdownPatchModeLiteral
if spec.Regex {
mode = markdownPatchModeRegex
}
out := map[string]interface{}{
"updated": false,
"mode": mode,
"match_count": matchCount,
"version": "",
"size_bytes_before": len(payload),
"size_bytes_after": len(payload),
}
if matchCount == 0 {
runtime.OutFormat(out, nil, func(w io.Writer) {
prettyPrintMarkdownPatch(w, out)
})
return nil
}
patchedPayload := []byte(patched)
if err := validateNonEmptyMarkdownSize(int64(len(patchedPayload))); err != nil {
return err
}
specUpload := markdownUploadSpec{
FileToken: spec.FileToken,
}
fileName, err := resolveMarkdownOverwriteFileName(runtime, specUpload)
if err != nil {
return err
}
specUpload.FileName = fileName
result, err := uploadMarkdownContent(runtime, specUpload, patchedPayload)
if err != nil {
return err
}
out["updated"] = true
out["version"] = result.Version
out["size_bytes_after"] = len(patchedPayload)
runtime.OutFormat(out, nil, func(w io.Writer) {
prettyPrintMarkdownPatch(w, out)
})
return nil
},
}
func newMarkdownPatchSpec(runtime *common.RuntimeContext) markdownPatchSpec {
return markdownPatchSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
Pattern: runtime.Str("pattern"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
Regex: runtime.Bool("regex"),
}
}
func validateMarkdownPatchSpec(runtime *common.RuntimeContext, spec markdownPatchSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
}
if !runtime.Changed("pattern") {
return common.FlagErrorf("--pattern is required")
}
if spec.Pattern == "" {
return output.ErrValidation("--pattern cannot be empty")
}
if !spec.ContentSet {
return common.FlagErrorf("--content is required")
}
return nil
}
func applyMarkdownPatch(original string, spec markdownPatchSpec) (string, int, error) {
if !spec.Regex {
return strings.ReplaceAll(original, spec.Pattern, spec.Content), strings.Count(original, spec.Pattern), nil
}
re, err := regexp.Compile(spec.Pattern)
if err != nil {
return "", 0, output.ErrValidation("invalid --pattern regex: %s", err)
}
matches := re.FindAllStringIndex(original, -1)
return re.ReplaceAllString(original, spec.Content), len(matches), nil
}
func prettyPrintMarkdownPatch(w io.Writer, data map[string]interface{}) {
updated := common.GetBool(data, "updated")
if updated {
io.WriteString(w, "updated: true\n")
} else {
io.WriteString(w, "updated: false\n")
}
io.WriteString(w, "mode: "+common.GetString(data, "mode")+"\n")
fmt.Fprintf(w, "match_count: %d\n", common.GetInt(data, "match_count"))
if version := common.GetString(data, "version"); version != "" {
io.WriteString(w, "version: "+version+"\n")
}
fmt.Fprintf(w, "size_bytes_before: %d\n", common.GetInt(data, "size_bytes_before"))
fmt.Fprintf(w, "size_bytes_after: %d\n", common.GetInt(data, "size_bytes_after"))
}

View File

@@ -0,0 +1,564 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package markdown
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestMarkdownPatchValidation(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
tests := []struct {
name string
args []string
want string
}{
{
name: "pattern is required",
args: []string{
"+patch",
"--file-token", "box_md_patch",
"--content", "DONE",
},
want: "--pattern is required",
},
{
name: "pattern cannot be empty",
args: []string{
"+patch",
"--file-token", "box_md_patch",
"--pattern", "",
"--content", "DONE",
},
want: "--pattern cannot be empty",
},
{
name: "content is required",
args: []string{
"+patch",
"--file-token", "box_md_patch",
"--pattern", "TODO",
},
want: "--content is required",
},
{
name: "invalid regex",
args: []string{
"+patch",
"--file-token", "box_md_patch",
"--regex",
"--pattern", "(",
"--content", "DONE",
},
want: "invalid --pattern regex",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := mountAndRunMarkdown(t, MarkdownPatch, tt.args, f, stdout)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("expected error containing %q, got %v", tt.want, err)
}
})
}
}
func TestMarkdownPatchDryRunLiteral(t *testing.T) {
dry := decodeMarkdownPatchDryRun(t, "box_md_patch", "TODO", "DONE", false)
if got := dry.Mode; got != markdownPatchModeLiteral {
t.Fatalf("mode = %q, want %q", got, markdownPatchModeLiteral)
}
if got := len(dry.API); got != 6 {
t.Fatalf("api steps = %d, want 6", got)
}
if got := dry.API[0].URL; got != "/open-apis/drive/v1/files/box_md_patch/download" {
t.Fatalf("download url = %q", got)
}
if got := dry.API[1].URL; got != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("metas url = %q", got)
}
if got := dry.API[2].URL; got != "/open-apis/drive/v1/files/upload_all" {
t.Fatalf("upload_all url = %q", got)
}
if got := dry.API[3].URL; got != "/open-apis/drive/v1/files/upload_prepare" {
t.Fatalf("upload_prepare url = %q", got)
}
if got := dry.API[4].URL; got != "/open-apis/drive/v1/files/upload_part" {
t.Fatalf("upload_part url = %q", got)
}
if got := dry.API[5].URL; got != "/open-apis/drive/v1/files/upload_finish" {
t.Fatalf("upload_finish url = %q", got)
}
if got := dry.API[2].Body["file_token"]; got != "box_md_patch" {
t.Fatalf("upload_all file_token = %#v", got)
}
if got := dry.API[3].Body["file_token"]; got != "box_md_patch" {
t.Fatalf("upload_prepare file_token = %#v", got)
}
if got := dry.API[2].Body["file"]; got != "<patched_markdown_content>" {
t.Fatalf("upload_all file placeholder = %#v", got)
}
}
func TestMarkdownPatchDryRunRegex(t *testing.T) {
dry := decodeMarkdownPatchDryRun(t, "box_md_patch", `Version: ([0-9]+)`, `Version: $1`, true)
if got := dry.Mode; got != markdownPatchModeRegex {
t.Fatalf("mode = %q, want %q", got, markdownPatchModeRegex)
}
if got := dry.API[0].Desc; !strings.Contains(got, "Download the current Markdown content") {
t.Fatalf("download desc = %q", got)
}
if got := dry.API[3].Desc; !strings.Contains(got, "multipart overwrite upload") {
t.Fatalf("upload_prepare desc = %q", got)
}
if got := dry.API[5].Body["block_num"]; got != "<block_num>" {
t.Fatalf("upload_finish block_num = %#v", got)
}
}
func TestValidateMarkdownPatchSpecRejectsInvalidFileToken(t *testing.T) {
runtime := newMarkdownPatchRuntime(t, "../bad", "TODO", "DONE", false)
err := validateMarkdownPatchSpec(runtime, newMarkdownPatchSpec(runtime))
if err == nil || !strings.Contains(err.Error(), "--file-token must not contain '..' path traversal") {
t.Fatalf("expected invalid file-token error, got %v", err)
}
}
func TestMarkdownPatchReturnsSuccessWhenNothingMatches(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("# hello\n"),
})
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
"+patch",
"--file-token", "box_md_patch",
"--pattern", "TODO",
"--content", "DONE",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeMarkdownEnvelope(t, stdout)
if common.GetBool(data, "updated") {
t.Fatalf("updated = true, want false")
}
if got := common.GetString(data, "mode"); got != markdownPatchModeLiteral {
t.Fatalf("mode = %q, want %q", got, markdownPatchModeLiteral)
}
if got := common.GetInt(data, "match_count"); got != 0 {
t.Fatalf("match_count = %d, want 0", got)
}
if got := common.GetString(data, "version"); got != "" {
t.Fatalf("version = %q, want empty", got)
}
if got := common.GetInt(data, "size_bytes_before"); got != len("# hello\n") {
t.Fatalf("size_bytes_before = %d, want %d", got, len("# hello\n"))
}
if got := common.GetInt(data, "size_bytes_after"); got != len("# hello\n") {
t.Fatalf("size_bytes_after = %d, want %d", got, len("# hello\n"))
}
if strings.Contains(stdout.String(), `"matches"`) {
t.Fatalf("stdout should not include matches field: %s", stdout.String())
}
}
func TestMarkdownPatchPrettyOutputWhenNothingMatches(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("# hello\n"),
})
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
"+patch",
"--file-token", "box_md_patch",
"--pattern", "TODO",
"--content", "DONE",
"--format", "pretty",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{
"updated: false",
"mode: literal",
"match_count: 0",
"size_bytes_before: 8",
"size_bytes_after: 8",
} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "version:") {
t.Fatalf("pretty output should omit version when unchanged:\n%s", out)
}
}
func TestMarkdownPatchLiteralOverwrite(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("# TODO\nTODO\n"),
Headers: map[string][]string{
"Content-Disposition": {`attachment; filename="README.md"`},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"title": "README.md"},
},
},
},
})
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"file_token": "box_md_patch",
"version": "7633658129540910626",
},
},
}
reg.Register(uploadStub)
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
"+patch",
"--file-token", "box_md_patch",
"--pattern", "TODO",
"--content", "DONE",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeCapturedMultipartBody(t, uploadStub)
if got := body.Fields["file_token"]; got != "box_md_patch" {
t.Fatalf("file_token = %q, want box_md_patch", got)
}
if got := body.Fields["file_name"]; got != "README.md" {
t.Fatalf("file_name = %q, want README.md", got)
}
if got := string(body.Files["file"]); got != "# DONE\nDONE\n" {
t.Fatalf("uploaded file content = %q", got)
}
data := decodeMarkdownEnvelope(t, stdout)
if !common.GetBool(data, "updated") {
t.Fatalf("updated = false, want true")
}
if got := common.GetInt(data, "match_count"); got != 2 {
t.Fatalf("match_count = %d, want 2", got)
}
if got := common.GetString(data, "version"); got != "7633658129540910626" {
t.Fatalf("version = %q, want 7633658129540910626", got)
}
if got := common.GetInt(data, "size_bytes_before"); got != len("# TODO\nTODO\n") {
t.Fatalf("size_bytes_before = %d, want %d", got, len("# TODO\nTODO\n"))
}
if got := common.GetInt(data, "size_bytes_after"); got != len("# DONE\nDONE\n") {
t.Fatalf("size_bytes_after = %d, want %d", got, len("# DONE\nDONE\n"))
}
}
func TestMarkdownPatchPrettyOutputWhenUpdated(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("# TODO\n"),
Headers: map[string][]string{
"Content-Disposition": {`attachment; filename="README.md"`},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"title": "README.md"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"file_token": "box_md_patch",
"version": "9001",
},
},
})
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
"+patch",
"--file-token", "box_md_patch",
"--pattern", "TODO",
"--content", "DONE",
"--format", "pretty",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{
"updated: true",
"mode: literal",
"match_count: 1",
"version: 9001",
"size_bytes_before: 7",
"size_bytes_after: 7",
} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q:\n%s", want, out)
}
}
}
func TestMarkdownPatchRegexOverwrite(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("Version: 12\nVersion: 34\n"),
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"title": "version.md"},
},
},
},
})
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"file_token": "box_md_patch",
"version": "7633658129540910627",
},
},
}
reg.Register(uploadStub)
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
"+patch",
"--file-token", "box_md_patch",
"--regex",
"--pattern", `Version: ([0-9]+)`,
"--content", `Version: $1 (patched)`,
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeCapturedMultipartBody(t, uploadStub)
if got := string(body.Files["file"]); got != "Version: 12 (patched)\nVersion: 34 (patched)\n" {
t.Fatalf("uploaded file content = %q", got)
}
data := decodeMarkdownEnvelope(t, stdout)
if got := common.GetString(data, "mode"); got != markdownPatchModeRegex {
t.Fatalf("mode = %q, want %q", got, markdownPatchModeRegex)
}
if got := common.GetInt(data, "match_count"); got != 2 {
t.Fatalf("match_count = %d, want 2", got)
}
}
func TestApplyMarkdownPatchRejectsInvalidRegex(t *testing.T) {
_, _, err := applyMarkdownPatch("hello", markdownPatchSpec{
Pattern: "(",
Content: "DONE",
Regex: true,
})
if err == nil || !strings.Contains(err.Error(), "invalid --pattern regex") {
t.Fatalf("expected invalid regex error, got %v", err)
}
}
func TestMarkdownPatchAllowsEmptyReplacement(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("hello world\n"),
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"title": "hello.md"},
},
},
},
})
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"file_token": "box_md_patch",
"version": "7633658129540910628",
},
},
}
reg.Register(uploadStub)
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
"+patch",
"--file-token", "box_md_patch",
"--pattern", " world",
"--content", "",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeCapturedMultipartBody(t, uploadStub)
if got := string(body.Files["file"]); got != "hello\n" {
t.Fatalf("uploaded file content = %q", got)
}
}
func TestMarkdownPatchRejectsEmptyPatchedContent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("hello\n"),
})
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
"+patch",
"--file-token", "box_md_patch",
"--pattern", "hello\n",
"--content", "",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "empty markdown content is not supported") {
t.Fatalf("expected empty content validation error, got %v", err)
}
}
func decodeMarkdownEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var envelope struct {
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v\nstdout:\n%s", err, stdout.String())
}
return envelope.Data
}
type markdownPatchDryRunOutput struct {
Mode string `json:"mode"`
API []struct {
Desc string `json:"desc"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
func newMarkdownPatchRuntime(t *testing.T, fileToken, pattern, content string, regex bool) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "markdown +patch"}
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("pattern", "", "")
cmd.Flags().String("content", "", "")
cmd.Flags().Bool("regex", false, "")
for name, value := range map[string]string{
"file-token": fileToken,
"pattern": pattern,
"content": content,
} {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
if regex {
if err := cmd.Flags().Set("regex", "true"); err != nil {
t.Fatalf("set --regex: %v", err)
}
}
return common.TestNewRuntimeContext(cmd, markdownTestConfig())
}
func decodeMarkdownPatchDryRun(t *testing.T, fileToken, pattern, content string, regex bool) markdownPatchDryRunOutput {
t.Helper()
runtime := newMarkdownPatchRuntime(t, fileToken, pattern, content, regex)
dry := MarkdownPatch.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry-run json: %v", err)
}
var out markdownPatchDryRunOutput
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal dry-run json: %v\njson=%s", err, string(data))
}
return out
}

View File

@@ -182,7 +182,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
t.Parallel()
got := Shortcuts()
want := []string{"+create", "+fetch", "+overwrite"}
want := []string{"+create", "+fetch", "+patch", "+overwrite"}
if len(got) != len(want) {
t.Fatalf("len(Shortcuts()) = %d, want %d", len(got), len(want))

View File

@@ -10,6 +10,7 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
MarkdownCreate,
MarkdownFetch,
MarkdownPatch,
MarkdownOverwrite,
}
}

View File

@@ -95,7 +95,7 @@ var SheetSetStyle = common.Shortcut{
}
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
var style interface{}
json.Unmarshal([]byte(runtime.Str("style")), &style)
_ = json.Unmarshal([]byte(runtime.Str("style")), &style) // Validate already parses and validates this JSON.
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/style").
Body(map[string]interface{}{
@@ -164,7 +164,7 @@ var SheetBatchSetStyle = common.Shortcut{
token = extractSpreadsheetToken(runtime.Str("url"))
}
var data interface{}
json.Unmarshal([]byte(runtime.Str("data")), &data)
_ = json.Unmarshal([]byte(runtime.Str("data")), &data) // Validate already parses and validates this JSON via validateBatchStyleData().
normalizeBatchStyleRanges(data)
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/styles_batch_update").

View File

@@ -12,7 +12,10 @@ func Shortcuts() []common.Shortcut {
WikiNodeCreate,
WikiDeleteSpace,
WikiSpaceList,
WikiSpaceCreate,
WikiNodeList,
WikiNodeCopy,
WikiNodeGet,
WikiNodeDelete,
}
}

View File

@@ -0,0 +1,207 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// Shared async-task polling for wiki delete operations. The wiki delete
// endpoints (DELETE /spaces/{id}, DELETE /spaces/{id}/nodes/{token}) may
// return either an empty task_id (sync completion) or a task_id that must
// be polled against /wiki/v2/tasks/{task_id}?task_type=<...>.
//
// For historical reasons /wiki/v2/tasks/{task_id} stashes the status under a
// different key per task type: delete-space uses `delete_space_result`, while
// delete-node uses the generic `simple_task_result` (the gateway's reusable
// "future async tasks share this" field). move tasks use `move_result` and are
// handled separately in wiki_move.go. Every key still exposes a `status`, so
// the poll loop / classification is factored out here and the caller passes
// the right result key.
//
// Note: `simple_task_result` only carries `status` (no `status_msg`), so for
// delete-node StatusLabel() falls back to the status code — which is fine.
const (
wikiAsyncStatusSuccess = "success"
wikiAsyncStatusFailure = "failure"
wikiAsyncStatusProcessing = "processing"
wikiAsyncTaskTypeDeleteSpace = "delete_space"
wikiAsyncTaskTypeDeleteNode = "delete_node"
wikiAsyncResultDeleteSpace = "delete_space_result"
// wikiAsyncResultSimpleTask is the generic result key the gateway uses for
// delete-node (and intends to reuse for future async task types). It is
// NOT `delete_node_result` — that key does not exist in the response.
wikiAsyncResultSimpleTask = "simple_task_result"
)
// wikiAsyncTaskStatus is the unified poll-response shape used by every wiki
// delete task. The taskID is captured so error/resume hints can name it.
type wikiAsyncTaskStatus struct {
TaskID string
Status string
StatusMsg string
}
// normalizedStatus collapses whitespace and case so " SUCCESS " classifies
// the same as "success". Ready()/Failed() (control flow) derive from this;
// StatusCode()/StatusLabel() (display) deliberately surface the raw backend
// value instead. For the real status enums (delete-node: processing/success/
// failed; delete-space's documented set) the two agree. They only diverge for
// an undocumented status string, which is intentional — an unrecognized status
// is shown verbatim rather than masked as a hard failure.
func (s wikiAsyncTaskStatus) normalizedStatus() string {
return strings.ToLower(strings.TrimSpace(s.Status))
}
func (s wikiAsyncTaskStatus) Ready() bool {
return s.normalizedStatus() == wikiAsyncStatusSuccess
}
func (s wikiAsyncTaskStatus) Failed() bool {
// The sample protocol only documents "success" as a terminal OK. Treat any
// explicit "failure"/"failed" signal as terminal, and unknown non-success
// values as still-processing so we don't misreport a novel status as a hard
// failure.
lowered := s.normalizedStatus()
return lowered == wikiAsyncStatusFailure || lowered == "failed"
}
// StatusCode returns a never-empty status value for the output envelope. If
// the backend response omits delete_*_result.status (or sends whitespace),
// fall back to "processing" so the documented timeout-shape stays accurate.
func (s wikiAsyncTaskStatus) StatusCode() string {
if status := strings.TrimSpace(s.Status); status != "" {
return status
}
return wikiAsyncStatusProcessing
}
func (s wikiAsyncTaskStatus) StatusLabel() string {
if msg := strings.TrimSpace(s.StatusMsg); msg != "" {
return msg
}
return s.StatusCode()
}
// wikiAsyncTaskFetcher returns the latest status for taskID. Implementations
// translate from runtime.CallAPI responses or test fakes.
type wikiAsyncTaskFetcher func(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error)
// parseWikiAsyncTaskStatus normalizes an /wiki/v2/tasks/{task_id} payload.
// resultKey selects the right shape ("delete_space_result" for delete-space,
// "simple_task_result" for delete-node).
func parseWikiAsyncTaskStatus(taskID string, task map[string]interface{}, resultKey string) (wikiAsyncTaskStatus, error) {
if task == nil {
return wikiAsyncTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
}
result := common.GetMap(task, resultKey)
status := wikiAsyncTaskStatus{
TaskID: common.GetString(task, "task_id"),
}
if status.TaskID == "" {
status.TaskID = taskID
}
if result != nil {
status.Status = common.GetString(result, "status")
status.StatusMsg = common.GetString(result, "status_msg")
}
return status, nil
}
// pollWikiAsyncTask runs the bounded polling loop shared by every wiki delete
// shortcut. label is the human-readable operation name surfaced in stderr
// progress lines ("delete-space" / "delete-node"). nextCommand is the resume
// hint embedded into the wrapped error when every poll fails.
//
// attempts/interval are taken as parameters (instead of consts) so callers
// can keep their per-operation tunable constants for back-compat with the
// existing test hooks.
func pollWikiAsyncTask(
ctx context.Context,
runtime *common.RuntimeContext,
taskID, label string,
attempts int,
interval time.Duration,
fetcher wikiAsyncTaskFetcher,
nextCommand string,
) (wikiAsyncTaskStatus, bool, error) {
lastStatus := wikiAsyncTaskStatus{TaskID: taskID}
var lastErr error
hadSuccessfulPoll := false
// The delete request already succeeded. Treat poll failures as transient
// until every attempt fails, then return a resume hint instead of
// discarding the task identifier.
for attempt := 1; attempt <= attempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return lastStatus, false, ctx.Err()
case <-time.After(interval):
}
}
status, err := fetcher(ctx, taskID)
if err != nil {
lastErr = err
fmt.Fprintf(runtime.IO().ErrOut, "Wiki %s status attempt %d/%d failed: %v\n", label, attempt, attempts, err)
continue
}
lastStatus = status
hadSuccessfulPoll = true
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Wiki %s task completed successfully.\n", label)
return status, true, nil
}
if status.Failed() {
return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki %s task %s failed: %s", label, taskID, status.StatusLabel())
}
fmt.Fprintf(runtime.IO().ErrOut, "Wiki %s status %d/%d: %s\n", label, attempt, attempts, status.StatusLabel())
}
if !hadSuccessfulPoll && lastErr != nil {
hint := fmt.Sprintf(
"the wiki %s task was created but every status poll failed (task_id=%s)\nretry status lookup with: %s",
label, taskID, nextCommand,
)
var exitErr *output.ExitError
if errors.As(lastErr, &exitErr) && exitErr.Detail != nil {
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
hint = exitErr.Detail.Hint + "\n" + hint
}
// ErrWithHint rebuilds the error and drops the upstream Lark
// Detail.Code / ConsoleURL / Risk / nested Detail. Build the
// ExitError by hand so the original API code survives a fully
// failed poll, matching wrapWikiNodeDeleteAPIError.
return lastStatus, false, &output.ExitError{
Code: exitErr.Code,
Detail: &output.ErrDetail{
Type: exitErr.Detail.Type,
Code: exitErr.Detail.Code,
Message: exitErr.Detail.Message,
Hint: hint,
ConsoleURL: exitErr.Detail.ConsoleURL,
Risk: exitErr.Detail.Risk,
Detail: exitErr.Detail.Detail,
},
}
}
return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint)
}
return lastStatus, false, nil
}

View File

@@ -0,0 +1,181 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// pollWikiAsyncTask is shared infrastructure for every wiki delete shortcut,
// so it gets a dedicated test surface here rather than relying only on the
// transitive coverage from the delete-space / delete-node paths.
func TestPollWikiAsyncTaskSuccessFirstPoll(t *testing.T) {
t.Parallel()
runtime, stderr := newWikiNodeDeleteRuntime(t, core.AsUser)
status, ready, err := pollWikiAsyncTask(
context.Background(), runtime, "task_ok", "delete-node", 3, 0,
func(context.Context, string) (wikiAsyncTaskStatus, error) {
return wikiAsyncTaskStatus{Status: "success"}, nil
},
"resume-cmd",
)
if err != nil {
t.Fatalf("pollWikiAsyncTask() error = %v", err)
}
if !ready || !status.Ready() {
t.Fatalf("ready = %v, status = %+v, want ready", ready, status)
}
if !strings.Contains(stderr.String(), "delete-node task completed successfully") {
t.Fatalf("stderr = %q", stderr.String())
}
}
func TestPollWikiAsyncTaskFailureIsTerminal(t *testing.T) {
t.Parallel()
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
_, ready, err := pollWikiAsyncTask(
context.Background(), runtime, "task_x", "delete-node", 3, 0,
func(context.Context, string) (wikiAsyncTaskStatus, error) {
return wikiAsyncTaskStatus{Status: "failure", StatusMsg: "denied"}, nil
},
"resume-cmd",
)
if ready {
t.Fatalf("ready = true, want false on failure")
}
if err == nil || !strings.Contains(err.Error(), "delete-node task task_x failed: denied") {
t.Fatalf("err = %v, want terminal failure with reason", err)
}
}
func TestPollWikiAsyncTaskTimeoutWhenAlwaysProcessing(t *testing.T) {
t.Parallel()
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
status, ready, err := pollWikiAsyncTask(
context.Background(), runtime, "task_slow", "delete-space", 2, 0,
func(context.Context, string) (wikiAsyncTaskStatus, error) {
return wikiAsyncTaskStatus{Status: "processing"}, nil
},
"resume-cmd",
)
// A still-processing task after the bounded window is a soft timeout:
// no error, ready=false, status preserved so the caller can print the
// follow-up command.
if err != nil {
t.Fatalf("pollWikiAsyncTask() error = %v, want nil on timeout", err)
}
if ready {
t.Fatalf("ready = true, want false on timeout")
}
if status.StatusCode() != "processing" {
t.Fatalf("status = %+v, want processing preserved", status)
}
}
func TestPollWikiAsyncTaskAllPollsFailWrapsWithResumeHint(t *testing.T) {
t.Parallel()
runtime, stderr := newWikiNodeDeleteRuntime(t, core.AsUser)
_, ready, err := pollWikiAsyncTask(
context.Background(), runtime, "task_lost", "delete-node", 2, 0,
func(context.Context, string) (wikiAsyncTaskStatus, error) {
return wikiAsyncTaskStatus{}, errors.New("transport boom")
},
"lark-cli drive +task_result --task-id task_lost",
)
if ready {
t.Fatalf("ready = true, want false when every poll failed")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("err = %T %v, want *output.ExitError with detail", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("exit code = %d, want ExitAPI", exitErr.Code)
}
if !strings.Contains(exitErr.Detail.Hint, "every status poll failed (task_id=task_lost)") ||
!strings.Contains(exitErr.Detail.Hint, "lark-cli drive +task_result --task-id task_lost") {
t.Fatalf("hint = %q, want resume guidance naming the task", exitErr.Detail.Hint)
}
if !strings.Contains(stderr.String(), "attempt 2/2 failed") {
t.Fatalf("stderr = %q, want per-attempt progress", stderr.String())
}
}
func TestPollWikiAsyncTaskPrependsUpstreamExitHint(t *testing.T) {
t.Parallel()
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
upstream := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "permission",
Code: 99991663,
Message: "permission denied",
Hint: "grant the wiki:node:retrieve scope",
},
}
_, _, err := pollWikiAsyncTask(
context.Background(), runtime, "task_perm", "delete-node", 1, 0,
func(context.Context, string) (wikiAsyncTaskStatus, error) {
return wikiAsyncTaskStatus{}, upstream
},
"resume-cmd",
)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("err = %T %v, want *output.ExitError", err, err)
}
// The upstream hint must lead so the actionable cause is read first, with
// the resume guidance appended. Type and exit code propagate from upstream.
if !strings.HasPrefix(exitErr.Detail.Hint, "grant the wiki:node:retrieve scope\n") {
t.Fatalf("hint = %q, want upstream hint prepended", exitErr.Detail.Hint)
}
if !strings.Contains(exitErr.Detail.Hint, "resume-cmd") {
t.Fatalf("hint = %q, want resume command appended", exitErr.Detail.Hint)
}
if exitErr.Detail.Type != "permission" || exitErr.Code != output.ExitAPI {
t.Fatalf("exitErr = %+v, want permission/ExitAPI propagated", exitErr)
}
if exitErr.Detail.Message != "permission denied" {
t.Fatalf("message = %q, want upstream message preserved", exitErr.Detail.Message)
}
}
func TestPollWikiAsyncTaskHonoursContextCancellation(t *testing.T) {
t.Parallel()
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
ctx, cancel := context.WithCancel(context.Background())
calls := 0
_, ready, err := pollWikiAsyncTask(
ctx, runtime, "task_cancel", "delete-node", 5, time.Hour,
func(context.Context, string) (wikiAsyncTaskStatus, error) {
calls++
cancel() // cancel before the next attempt's inter-poll wait
return wikiAsyncTaskStatus{Status: "processing"}, nil
},
"resume-cmd",
)
if ready {
t.Fatalf("ready = true, want false on cancellation")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("err = %v, want context.Canceled", err)
}
if calls != 1 {
t.Fatalf("fetcher calls = %d, want 1 (cancelled before second poll)", calls)
}
}

View File

@@ -5,7 +5,6 @@ package wiki
import (
"context"
"errors"
"fmt"
"strings"
"time"
@@ -21,10 +20,12 @@ var (
wikiDeleteSpacePollInterval = 2 * time.Second
)
// Back-compat aliases — the shared async-task helper now owns the strings,
// but tests still reference these names.
const (
wikiDeleteSpaceStatusSuccess = "success"
wikiDeleteSpaceStatusFailure = "failure"
wikiDeleteSpaceStatusProcessing = "processing"
wikiDeleteSpaceStatusSuccess = wikiAsyncStatusSuccess
wikiDeleteSpaceStatusFailure = wikiAsyncStatusFailure
wikiDeleteSpaceStatusProcessing = wikiAsyncStatusProcessing
)
// WikiDeleteSpace deletes a wiki space. The DELETE endpoint may complete
@@ -73,48 +74,10 @@ type wikiDeleteSpaceResponse struct {
TaskID string
}
type wikiDeleteSpaceTaskStatus struct {
TaskID string
Status string
StatusMsg string
}
// normalizedStatus collapses whitespace and case so " SUCCESS " is
// classified the same as "success". Ready / Failed / StatusCode all derive
// from this so classification and the output `status` field can't disagree.
func (s wikiDeleteSpaceTaskStatus) normalizedStatus() string {
return strings.ToLower(strings.TrimSpace(s.Status))
}
func (s wikiDeleteSpaceTaskStatus) Ready() bool {
return s.normalizedStatus() == wikiDeleteSpaceStatusSuccess
}
func (s wikiDeleteSpaceTaskStatus) Failed() bool {
// The sample protocol only documents "success" as a terminal OK. Treat any
// explicit "failure"/"failed" signal as terminal, and unknown non-success
// values as still-processing so we don't misreport a novel status as a hard
// failure.
lowered := s.normalizedStatus()
return lowered == wikiDeleteSpaceStatusFailure || lowered == "failed"
}
// StatusCode returns a never-empty status value for the output envelope. If
// the backend response omits delete_space_result.status (or sends whitespace),
// fall back to "processing" so the documented timeout-shape stays accurate.
func (s wikiDeleteSpaceTaskStatus) StatusCode() string {
if status := strings.TrimSpace(s.Status); status != "" {
return status
}
return wikiDeleteSpaceStatusProcessing
}
func (s wikiDeleteSpaceTaskStatus) StatusLabel() string {
if msg := strings.TrimSpace(s.StatusMsg); msg != "" {
return msg
}
return s.StatusCode()
}
// wikiDeleteSpaceTaskStatus is an alias for the shared wiki async-task shape;
// kept as a named type for the existing test surface. delete-node uses the
// same type directly under its real name (wikiAsyncTaskStatus).
type wikiDeleteSpaceTaskStatus = wikiAsyncTaskStatus
type wikiDeleteSpaceClient interface {
DeleteSpace(ctx context.Context, spaceID string) (*wikiDeleteSpaceResponse, error)
@@ -150,7 +113,7 @@ func (api wikiDeleteSpaceAPI) GetDeleteSpaceTask(ctx context.Context, taskID str
if err != nil {
return wikiDeleteSpaceTaskStatus{}, err
}
return parseWikiDeleteSpaceTaskStatus(taskID, common.GetMap(data, "task"))
return parseWikiAsyncTaskStatus(taskID, common.GetMap(data, "task"), wikiAsyncResultDeleteSpace)
}
func readWikiDeleteSpaceSpec(runtime *common.RuntimeContext) wikiDeleteSpaceSpec {
@@ -237,77 +200,18 @@ func wikiDeleteSpaceTaskResultCommand(taskID string, identity core.Identity) str
}
func pollWikiDeleteSpaceTask(ctx context.Context, client wikiDeleteSpaceClient, runtime *common.RuntimeContext, taskID string) (wikiDeleteSpaceTaskStatus, bool, error) {
lastStatus := wikiDeleteSpaceTaskStatus{TaskID: taskID}
var lastErr error
hadSuccessfulPoll := false
// The delete request already succeeded. Treat poll failures as transient
// until every attempt fails, then return a resume hint instead of discarding
// the task identifier.
for attempt := 1; attempt <= wikiDeleteSpacePollAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return lastStatus, false, ctx.Err()
case <-time.After(wikiDeleteSpacePollInterval):
}
}
status, err := client.GetDeleteSpaceTask(ctx, taskID)
if err != nil {
lastErr = err
fmt.Fprintf(runtime.IO().ErrOut, "Wiki delete-space status attempt %d/%d failed: %v\n", attempt, wikiDeleteSpacePollAttempts, err)
continue
}
lastStatus = status
hadSuccessfulPoll = true
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Wiki delete-space task completed successfully.\n")
return status, true, nil
}
if status.Failed() {
return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki delete-space task %s failed: %s", taskID, status.StatusLabel())
}
fmt.Fprintf(runtime.IO().ErrOut, "Wiki delete-space status %d/%d: %s\n", attempt, wikiDeleteSpacePollAttempts, status.StatusLabel())
}
if !hadSuccessfulPoll && lastErr != nil {
nextCommand := wikiDeleteSpaceTaskResultCommand(taskID, runtime.As())
hint := fmt.Sprintf(
"the wiki delete-space task was created but every status poll failed (task_id=%s)\nretry status lookup with: %s",
taskID,
nextCommand,
)
var exitErr *output.ExitError
if errors.As(lastErr, &exitErr) && exitErr.Detail != nil {
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
hint = exitErr.Detail.Hint + "\n" + hint
}
return lastStatus, false, output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint)
}
return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint)
}
return lastStatus, false, nil
return pollWikiAsyncTask(
ctx, runtime, taskID, "delete-space",
wikiDeleteSpacePollAttempts, wikiDeleteSpacePollInterval,
func(ctx context.Context, id string) (wikiAsyncTaskStatus, error) {
return client.GetDeleteSpaceTask(ctx, id)
},
wikiDeleteSpaceTaskResultCommand(taskID, runtime.As()),
)
}
// parseWikiDeleteSpaceTaskStatus is kept as a thin wrapper for the existing
// test surface; new callers should use parseWikiAsyncTaskStatus directly.
func parseWikiDeleteSpaceTaskStatus(taskID string, task map[string]interface{}) (wikiDeleteSpaceTaskStatus, error) {
if task == nil {
return wikiDeleteSpaceTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
}
result := common.GetMap(task, "delete_space_result")
status := wikiDeleteSpaceTaskStatus{
TaskID: common.GetString(task, "task_id"),
}
if status.TaskID == "" {
status.TaskID = taskID
}
if result != nil {
status.Status = common.GetString(result, "status")
status.StatusMsg = common.GetString(result, "status_msg")
}
return status, nil
return parseWikiAsyncTaskStatus(taskID, task, wikiAsyncResultDeleteSpace)
}

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