Compare commits

...

46 Commits

Author SHA1 Message Date
liangshuo-1
b25ff1ced5 chore(release): v1.0.40 (#1086)
Change-Id: I6dabe4c03e9b8daf0e0d8c175e0f2a4c4a9bfb0e
2026-05-25 21:31:27 +08:00
fangshuyu-768
aea9f37f58 feat(wiki): add exponential backoff retry for +node-create lock contention (#1012) (#1076)
When creating wiki nodes under the same parent concurrently, the API
returns error code 131009 (lock contention) ~5-15% of the time. This
adds automatic retry with exponential backoff (250ms, 500ms; max 2
retries) so callers no longer need to implement retry logic themselves.

- Retry loop in runWikiNodeCreate: only retries on code 131009, respects
  context cancellation, prints progress to stderr
- wrapWikiNodeCreateRetryError preserves Err/Raw/Detail.Code in ExitError
- 6 unit tests covering retry success, exhaustion, non-contention error,
  single-retry success, context cancellation, no-retry on success
- 8 dry-run E2E tests for wiki +node-create request shape and validation
2026-05-25 20:03:17 +08:00
liujinkun2025
ac06eaa0f4 fix(wiki): rename +node-get --token to --node-token, keep alias (#1074)
Per issue #1049 (third point), wiki +node-get used --token while sibling
commands (+node-delete / +node-copy / +move) use --node-token. The
inconsistency forced humans and AI agents to remember which adjacent
command takes which flag.

Make --node-token the canonical flag and keep --token as a hidden,
deprecated alias so existing scripts continue to work. pflag's
MarkDeprecated prints "Flag --token has been deprecated, use --node-token
instead" to stderr on use, guiding callers to migrate. Conflict between
the two with different values is rejected upfront.

Skills docs (lark-wiki, lark-base) updated to prefer --node-token.

Change-Id: I3415a98f079613c0b1a0b989cf54a09cbb8986fb
2026-05-25 17:28:45 +08:00
fangshuyu-768
282c27784d docs(skills): add 云盘/云存储 alias alongside 云空间 for agent clarity (#1073) 2026-05-25 17:15:50 +08:00
郭立lee
f2a4c95665 fix(output): classify wiki lock-contention error (131009) with retry hint (#1014)
Wiki write-path operations (most commonly `wiki +node-create` against the
same parent) surface code 131009 "lock contention" under concurrent calls.
Currently this falls through to the generic "api_error" classification,
giving users no hint that it is transient and safe to retry.

Mirror the existing `LarkErrDriveResourceContention` (1061045) treatment:
add a named constant, classify as "conflict", and emit a hint that points
the caller toward exponential backoff or serializing sibling-node writes.

Refs: #1012
2026-05-25 15:50:45 +08:00
JackZhao10086
cb5055eb46 feat(auth): add auth qrcode subcommand and update auth docs/hints (#968)
* feat(auth): add auth qrcode subcommand and update auth docs/hints

* refactor(auth/qrcode): improve qrcode command with validation and custom output
2026-05-25 15:34:00 +08:00
WJzz1
9d4233bfe3 fix(contact): add actionable hint when fanout search all-fail with no API code (#1054)
In buildFanoutResponse, when every fanout query fails AND the first failure
has no Lark API code (i.e. transport, parse, panic, or context-cancel),
the returned ExitError was carrying an empty Hint. This is the only
output.ErrWithHint call in shortcuts/ that ships an empty hint.

AGENTS.md states: "every error message you write will be parsed by an AI
to decide its next action. Make errors structured, actionable, and
specific." An empty hint gives the agent nothing to do.

Populate the hint with the actionable next step for this branch — retry,
and if it persists, narrow --queries to a single term to isolate the
failing input. The companion test exercises the no-code path and asserts
the hint is non-empty and mentions "retry".

Co-authored-by: Wang-Yeah623 <Wang-Yeah623@users.noreply.github.com>
2026-05-25 12:08:18 +08:00
zed
708cbc2b31 fix: use ErrValidation instead of fmt.Errorf in Validate paths (#1001)
Replace 8 bare fmt.Errorf calls with output.ErrValidation across 3 files
so validation errors consistently return structured JSON (type: validation,
exit 2) matching the rest of the codebase.

Affected functions: validateExpectedFlag (sheets), validateSendTime,
validateComposeInlineAndAttachments, validateEventFlags (mail),
validateSignatureWithPlainText (mail)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:52:51 +08:00
ZEden0
6d1f9980fa fix: annotate auto-grant permission failures with required_scope and console_url (#1045)
When AutoGrantCurrentUserDrivePermission encounters lark code 99991672/99991679,
extract permission_violations from the underlying ExitError and surface
lark_code, required_scope, and console_url on the result map. Override the
generic fallback hint with one pointing at the developer console — the
concrete next step a user can take.

Refactor extractRequiredScopes / SelectRecommendedScope wrapping / console URL
construction out of cmd/root.go into internal/registry/scope_hint.go so both
the top-level enrichPermissionError path and the best-effort sub-call path in
shortcuts/common share one implementation.

Change-Id: Ida63ed160d1167b7961b6faac5c2cf9b7f971c65
2026-05-25 11:01:01 +08:00
zero-my
6e3e120ec8 Docs/lark task shortcut doc refresh (#1057)
* docs: align lark-task attachment descriptions

* docs: restore lark-task attachment capability summary
2026-05-24 00:32:28 +08:00
liangshuo-1
ce5b4f24e1 chore(release): v1.0.39 (#1052)
Change-Id: I06bca4f3aedec1adee9ecd3d060c333cc6dd301e
2026-05-22 21:10:35 +08:00
MaxHuang22
4b2223194b fix: add 22 new scope entries to scope priorities (#1050)
Change-Id: I2e7bb2e2971bfb071c3976d349b2d2bc4cc485ae
2026-05-22 19:48:08 +08:00
zgz2048
4582dfd281 docs(base): update location full_address guidance (#754) 2026-05-22 18:05:35 +08:00
ethan-zhx
5c01a7f7f0 feat(slides): export slides (#988)
Change-Id: Ice3e8784e78986d427c4c94664e1e5edff2a4fcd
2026-05-22 17:19:49 +08:00
raistlin042
d5d2fee848 chore(apps): refine lark-apps skill description and surface (#1040)
- description: switch from trigger-word enumeration to a general
  principle (any HTML artifact intended to be independently accessible
  falls under this skill; defer the deploy-vs-demo decision to the
  skill body)
- surface apps +access-scope-get in prerequisites list and Shortcuts
  table so agents can find the read side of access-scope
- add "writing HTML hard constraints" section: index.html is the
  required entry filename, --path cannot equal cwd (both are CLI-side
  hard rejects that previously only lived in the html-publish ref)
2026-05-22 16:39:36 +08:00
hGrany
ffcf7781b4 feat(sidecar): support multi-client identity isolation in server-demo (#934)
* feat(sidecar): support multi-client identity isolation in server-demo

When multiple CLI sandbox environments share a single sidecar instance,
user tokens (UAT) were not isolated -- the last user to log in would
overwrite previous users' tokens, causing identity cross-contamination.

This change introduces per-client HMAC key isolation:
- Each client gets a unique client-*.key file for data-plane HMAC signing,
  allowing the sidecar to identify request origin.
- A new auth_bridge.go handles management endpoints (login/poll/status)
  with explicit client-to-feishuOpenId binding.
- User token resolution is strictly bound to the matched client -- no
  fallback to other users' tokens when a client has no mapping.
- The shared proxy.key is reused across restarts instead of regenerated,
  fixing a race condition when multiple sidecar instances start together.

Wire protocol (sidecar package) is unchanged; existing single-client
deployments are fully backward compatible.

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

* fix(sidecar): address review feedback on filesystem and safety

- Replace os.ReadFile/WriteFile/ReadDir with vfs.* equivalents for test
  mockability, consistent with project coding guidelines.
- Limit auth bridge request body to 64KB to prevent memory exhaustion.
- Log errors in saveUserMap instead of silently discarding them.
- Reject client keys that collide with the shared proxy key.
- Reject duplicate client keys instead of silently overwriting.

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

* refactor(sidecar): remove workspace-specific naming and backward compat

- parseClientID: only accept "client_id" field, remove legacy fallback
- loadClientKeys: scan all *.key (excluding proxy.key), no prefix required
- Remove legacy file migration logic in newAuthBridge
- Update flag description to reflect generic key scanning

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

* refactor(sidecar): extract multi-tenant demo and add unit tests

Address review feedback from sang-neo03:

1. Extract multi-client code into sidecar/server-multi-tenant-demo/,
   keeping server-demo as the minimal single-tenant reference.

2. Add unit tests for the isolation guarantee:
   - loadClientKeys: shared-key collision and duplicate keyHex are skipped
   - verifyWithClientKeys: correct client matched, unknown key rejected
   - loadUserMap/saveUserMap: round-trip persistence across restart

3. Cross-link READMEs between server-demo and server-multi-tenant-demo.

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

* docs(sidecar): rewrite multi-tenant demo README with problem statement and client guide

- Explain the multi-app credential isolation problem (app_secret must
  not be exposed to client environments)
- Document typical deployment topology with multiple sidecar instances
- Add complete client setup guide: env vars, multi-app switching, login
  flow, and end-to-end workflow example
- Document design decisions and management endpoint details

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

* fix(sidecar): address CodeRabbit review feedback on tests and docs

- Make TestProxyHandler_AcceptsAllowedAuthHeaders fully offline by using
  httptest.NewTLSServer instead of depending on open.feishu.cn
- Isolate TestRun_RejectsSelfProxy config state with t.Setenv and temp dirs
- Check os.MkdirAll error in test fixture setup
- Add language identifiers to fenced code blocks (MD040)
- Validate user-supplied CLI paths with validate.SafeInputPath

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

---------

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)
2026-05-22 15:25:00 +08:00
liujiashu-shiro
fbe4cc689a feat(im): support Markdown image rendering in post content (#893)
add documentation for sending Markdown images, and align image handling guidance with actual runtime behavior
2026-05-22 10:44:10 +08:00
liangshuo-1
ac85c3e34d chore(release): v1.0.38 (#1026)
- Bump version to 1.0.38
- Update CHANGELOG.md with the apps brand gating change since v1.0.37
- Backfill the [v1.0.38] link reference at the bottom of CHANGELOG.md

Change-Id: I6fd0d1243e2219a1eaa1fae5fae4ff6d8de361da
2026-05-22 03:20:21 +08:00
liangshuo-1
daba3c9afd feat(apps): gate apps domain off on Lark brand (#1025)
* feat(apps): gate apps domain off on Lark brand

The Miaoda apps OpenAPI is Feishu-only. On Lark brand:

- shortcut subtree is registered + hidden, RunE returns a structured
  brand-restriction error so users see a clear message instead of
  cobra's generic "unknown command"
- auth login `--domain apps` is treated as unknown; `--domain all`
  skips apps; help text omits it
- scope collection skips apps shortcuts so spark:* scopes are never
  requested

The leaf-stub pattern mirrors internal/cmdpolicy/apply.go::installDenyStub
(DisableFlagParsing + ArbitraryArgs + leaf-level PersistentPreRunE
override) so cobra can't short-circuit the stub with a missing-flag or
parent-PreRunE detour.

Change-Id: I5817e87ae6fedabdb5faf05d0d32ea988f7effc9
2026-05-22 03:03:41 +08:00
wangweiming-01
e54220ade1 feat: support files in drive +add-comment (#975)
* feat: support markdown files in drive +add-comment

Change-Id: Id9a87706a1e43756d8142637be9ec1e0748d4ddf

* fix: use markdown file comment anchor placeholder

Change-Id: Ifffc4cdd963c13e53f4cad154aebe11ae309df9e

* fix: gate drive file comments by supported extensions

Change-Id: Ie6c7f38dbbea1f87a81600da71180627b53a2355
2026-05-21 21:40:27 +08:00
liangshuo-1
d3fbc88527 chore(release): v1.0.37 (#1021)
Change-Id: Ifcc78649e294d516015846d746bb2bc65b239eb3
2026-05-21 20:44:23 +08:00
liujinkun2025
652e96906c feat(wiki): add +member-add / +member-remove / +member-list shortcuts (#997)
- +member-add: wrap POST /spaces/{id}/members; --member-type / --member-role
  enums, optional --need-notification query (omitted entirely when the flag
  is unset, instead of forcing need_notification=false), my_library
  resolution under --as user, flattened single-member output
- +member-remove: wrap DELETE /spaces/{id}/members/{member_id}; surfaces the
  required member_type + member_role body the API expects, my_library
  resolution, fallback to echoing the caller's inputs when the API omits
  the member echo
- +member-list: wrap GET /spaces/{id}/members; reuses the +space-list /
  +node-list pagination contract (single page by default, --page-all walks
  every page capped by --page-limit, --page-token resumes a cursor)
- All three reject bot identity + my_library upfront with a clear hint and
  declare the narrowest scope the API accepts (wiki:member:create /
  wiki:member:update / wiki:member:retrieve) so tokens carrying only the
  narrow scope are not false-rejected by the exact-string preflight
- skill docs: reference pages for the three new shortcuts + SKILL.md
  shortcuts table; switch the membership flow guidance from raw
  `wiki members create` to the new +member-add path

Change-Id: I158a86aa7f00bb7cecc7a4e99346f3fb151b3c09
2026-05-21 20:40:55 +08:00
raistlin042
6cea6c9af0 feat(apps): add miaoda apps domain (6 shortcuts + dry-run e2e) (#1002)
Adds the apps domain to lark-cli for managing Miaoda (妙搭) applications: 6 shortcuts covering the full lifecycle (+create / +update / +list / +access-scope-set / +access-scope-get / +html-publish). Aligned with the OAPI v2 design — app_type enum (currently HTML), string scope enum (All / Tenant / Range), cursor pagination, in-memory tar.gz multipart publish flow. Namespace registered at /open-apis/spark/v1/ with spark:app.* scopes.

---------

Co-authored-by: wangjiangwen-gif <286006750+wangjiangwen-gif@users.noreply.github.com>
2026-05-21 20:30:42 +08:00
fangshuyu-768
816927f8b8 fix: surface auto-grant failures via stderr and JSON hint (#1015)
When a resource is created with bot identity, the CLI attempts to
auto-grant full_access to the current user. If the user open_id is
missing or the grant API call fails, the result was only written to
the JSON permission_grant field and easily overlooked.

Changes:
- Add stderr warnings when auto-grant is skipped or fails
- Add 'hint' field to permission_grant JSON output with failure reason
  and actionable next step (e.g. auth login, check scope, retry)
- Add end-to-end skipped/failed tests across all affected shortcuts
  (doc, drive, sheets, slides, wiki, markdown, base)

Closes #963
2026-05-21 18:17:24 +08:00
caojie0621
56749e70cb fix(sheets): use FileIO for write-image input (#996) 2026-05-21 15:53:44 +08:00
liangshuo-1
8c700aea00 chore(release): v1.0.36 (#1011)
Change-Id: Ifb0b6bf05d486943d9a689bf63dde2251dcd3500
2026-05-21 12:24:14 +08:00
MaxHuang22
42746d6c9d fix: revert incremental skills sync (#965) (#1008)
Change-Id: Ic95e8a74a0d6fc7f89782dccde867fd794cfcf46
2026-05-21 12:08:27 +08:00
zed
94b103dbf6 fix(auth): return validation error when --scope is empty in auth check (#999)
strings.Fields("") returns an empty slice, causing --scope "" to bypass
validation and return ok: true. Replace the false-positive success path
with an ErrValidation error so callers correctly detect the invalid input.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 11:52:05 +08:00
wangweiming-01
e19e09019c feat: return real tenant URLs for drive +upload and markdown +create (#992)
Change-Id: I6b513eef57a3479c8971b3bb6cbf005cad3f8040
2026-05-21 11:07:37 +08:00
search_zhuhao
3bab9a0692 docs(lark-drive): improve search evidence guidance (#864)
Change-Id: I000c2d56962e6da2a7ef77d986c2eb73ec286546
2026-05-20 20:45:41 +08:00
liangshuo-1
6840bb7415 chore(release): v1.0.35 (#995)
Change-Id: I6ddc8cfc029c684deb5de4f210357e19ade083e1
2026-05-20 19:46:10 +08:00
caojie0621
ce485eb3f5 fix(sheets): declare metadata scope for info shortcut (#994) 2026-05-20 19:43:21 +08:00
YangJunzhou-01
c98a49f2a3 docs(im): clarify media key formats for message media flags (#991)
* docs(im): clarify media path restrictions

* docs(im): clarify file key formats for message file flags

Change-Id: I329ca0db9e7a01b774846d522d1b2a64da74233c

---------

Co-authored-by: mtsui-cmyk <mervyntsui@gmail.com>
2026-05-20 17:39:14 +08:00
wangweiming-01
c02a38f077 feat: support wiki node target in markdown +create (#883)
Change-Id: Idb89464344599571cda3d27d136727553dcf0e7e
2026-05-20 17:03:32 +08:00
zhangheng023
3a3fc31d0b feat: add incremental skills sync (#965)
* feat: add incremental skills sync

* fix: address skills sync review feedback
2026-05-20 16:27:07 +08:00
wangweiming-01
8c73f49e91 docs: add media-preview reference (#990)
Change-Id: I5ba1991874e262fb98f3421e61503b58bb71d861
2026-05-20 15:59:39 +08:00
liujinkun2025
9272b9da99 docs(skills): migrate docs +search to drive +search and fix creator_ids owner semantic (#951)
docs +search is in maintenance and will be removed; cloud-space resource
discovery is consolidated onto drive +search. Two related doc/help fixes:

1. Redirect guidance: docs +search -> drive +search
   - skill-template/domains/{doc,sheets}.md
   - lark-base/SKILL.md: --filter '{"doc_types":["BITABLE"]}' -> --doc-types bitable
   - lark-sheets/SKILL.md: body + frontmatter description, add drive-search ref link
   Same server API, equivalent capability; only flattens the entry from
   nested --filter JSON to flags. reference links repointed to lark-drive.

2. Fix creator_ids/--mine semantic: creator -> owner
   The server matches creator_ids (incl. --mine / --creator-ids) by owner
   (document owner), not original creator, despite the OpenAPI field name.
   - shortcuts/drive/drive_search.go: --help Desc and Tip
   - lark-drive/references/lark-drive-search.md: identity section, params, rules, examples
   - lark-drive/SKILL.md: top-level guidance
   - lark-doc/references/lark-doc-search.md: creator_ids usage note (now self-consistent)
   Wire field name creator_ids kept (aligned with the server).

Docs/help strings only, no logic change; gofmt / go vet / package build pass.

Change-Id: If3ebf5a247b7e38b58050c677dc888a310f1c6b6
2026-05-20 15:08:50 +08:00
wangweiming-01
27a5eeddcc docs: prefer local comments for drive reviews (#981)
* docs: prefer local comments for drive reviews

Change-Id: Ie2eaa54320cd2612b66b2d617750d23b950e38db

* docs: align drive comment fallback guidance

Change-Id: Ia7512babe3656b57374c86068198c8192871ff81
2026-05-20 14:32:18 +08:00
zgz2048
0c4eadd41e docs: add wiki base fast path (#982) 2026-05-20 14:31:45 +08:00
yballul-bytedance
69c34481f5 feat: Product CLI 4no-meego (#759)
Change-Id: If08f236c8ae351f92683f2b861cc999eb6f1d22d
2026-05-20 14:02:03 +08:00
wangweiming-01
fa45e1c7e4 feat: add markdown +diff shortcut (#876)
* feat: add markdown +diff shortcut

Change-Id: I7da27889517707ac6f1d5e8c429e4bdfb49fdcf8

* fix: harden markdown diff downloads

Change-Id: I0020e14ebee780617d790836af1368db851b8cf1

* refactor: address markdown diff review feedback

Change-Id: I0ddb852218ec4784c0f9491896796c3007f04122
2026-05-20 12:20:51 +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
198 changed files with 16849 additions and 1141 deletions

2
.gitignore vendored
View File

@@ -42,3 +42,5 @@ app.log
/server-demo
.tmp/
cover*.out
lark-env.sh

View File

@@ -2,6 +2,121 @@
All notable changes to this project will be documented in this file.
## [v1.0.40] - 2026-05-25
### Features
- **wiki**: Add exponential backoff retry for `+node-create` lock contention (#1012)
- **auth**: Add `auth qrcode` subcommand and update auth docs/hints (#968)
### Bug Fixes
- **wiki**: Rename `+node-get --token` to `--node-token`, keep alias (#1074)
- **output**: Classify wiki lock-contention error (131009) with retry hint (#1014)
- **contact**: Add actionable hint when fanout search all-fail with no API code (#1054)
- **permission**: Annotate auto-grant permission failures with `required_scope` and `console_url` (#1045)
- **validation**: Use `ErrValidation` instead of `fmt.Errorf` in `Validate` paths (#1001)
### Documentation
- **skills**: Add 云盘/云存储 alias alongside 云空间 for agent clarity (#1073)
- **task**: Refresh `lark-task` shortcut docs (#1057)
## [v1.0.39] - 2026-05-22
### Features
- **slides**: Add `+export` shortcut to export slides (#988)
- **sidecar**: Support multi-client identity isolation in `server-demo` via per-client HMAC keys, preventing UAT cross-contamination when multiple CLI sandboxes share one sidecar (#934)
- **im**: Support Markdown image rendering in post content (#893)
### Bug Fixes
- **scope**: Add 22 new scope entries to scope priorities (#1050)
### Documentation
- **base**: Update location `full_address` guidance (#754)
- **apps**: Refine `lark-apps` skill description and surface, document `index.html` / `--path` hard constraints (#1040)
## [v1.0.38] - 2026-05-22
### Features
- **apps**: Gate the Miaoda apps domain off on the Lark brand — the `apps` shortcut subtree returns a structured brand-restriction error, `auth login --domain apps` is rejected, `--domain all` skips it, and `spark:*` scopes are no longer requested (#1025)
## [v1.0.37] - 2026-05-21
### Features
- **apps**: Add miaoda apps domain with 6 shortcuts covering `+create` / `+update` / `+list` / `+access-scope-get` / `+access-scope-set` / `+html-publish` (#1002)
### Bug Fixes
- **permission**: Surface auto-grant skipped/failed cases via stderr warnings and a `hint` field in the `permission_grant` JSON output (#1015)
- **sheets**: Use `FileIO` for `+write-image` input so stdin / `-` works consistently (#996)
## [v1.0.36] - 2026-05-21
### Features
- **drive/markdown**: Return real tenant URLs for `drive +upload` and `markdown +create` (#992)
### Bug Fixes
- **auth**: Return validation error when `--scope` is empty in `auth check` (#999)
### Documentation
- **lark-drive**: Improve search evidence guidance (#864)
## [v1.0.35] - 2026-05-20
### Features
- **markdown**: Support wiki node target in `+create` (#883)
- **markdown**: Add `+diff` shortcut (#876)
- **base**: Add form `+detail` / `+submit` shortcuts (#759)
- **skills**: Add incremental skills sync (#965)
- **doc**: Warn before overwrite when document contains whiteboard or file blocks (#825)
### Documentation
- **im**: Clarify media key formats for message media flags (#991)
- **im**: Add media-preview reference (#990)
- **drive**: Migrate `docs +search` to `drive +search` and fix `creator_ids` owner semantic (#951)
- **drive**: Prefer local comments for drive reviews (#981)
- **wiki**: Add wiki base fast path (#982)
## [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
@@ -745,6 +860,13 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.40]: https://github.com/larksuite/cli/releases/tag/v1.0.40
[v1.0.39]: https://github.com/larksuite/cli/releases/tag/v1.0.39
[v1.0.38]: https://github.com/larksuite/cli/releases/tag/v1.0.38
[v1.0.37]: https://github.com/larksuite/cli/releases/tag/v1.0.37
[v1.0.36]: https://github.com/larksuite/cli/releases/tag/v1.0.36
[v1.0.35]: https://github.com/larksuite/cli/releases/tag/v1.0.35
[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

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 24 AI Agent [Skills](./skills/).
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 26 AI Agent [Skills](./skills/).
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
## Why lark-cli?
- **Agent-Native Design** — 24 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 17 business domains, 200+ curated commands, 24 AI Agent [Skills](./skills/)
- **Wide Coverage** — 18 business domains, 200+ curated commands, 26 AI Agent [Skills](./skills/)
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
@@ -41,6 +41,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
| 🔗 Apps | Develop, deploy HTML, web pages and applications |
## Installation & Quick Start

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 24 个 AI Agent [Skills](./skills/)。
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 26 个 AI Agent [Skills](./skills/)。
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
## 为什么选 lark-cli
- **为 Agent 原生设计** — 24 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 17 大业务域、200+ 精选命令、24 个 AI Agent [Skills](./skills/)
- **为 Agent 原生设计** — 26 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 18 大业务域、200+ 精选命令、26 个 AI Agent [Skills](./skills/)
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
@@ -41,6 +41,7 @@
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| 🎯 OKR | 查询、创建、更新 OKR管理目标、关键结果、对齐、指标和进展记录 |
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
## 安装与快速开始

View File

@@ -43,6 +43,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdAuthScopes(f, nil))
cmd.AddCommand(NewCmdAuthList(f, nil))
cmd.AddCommand(NewCmdAuthCheck(f, nil))
cmd.AddCommand(NewCmdAuthQRCode(f, nil))
return cmd
}

View File

@@ -61,7 +61,7 @@ func TestAuthLoginCmd_HelpGuidesNonStreamingAgentsToSplitFlow(t *testing.T) {
for _, want := range []string{
"only delivers final turn messages",
"--no-wait --json",
"send the verification URL to the user as your final message",
"send the verification URL (or QR code) to the user as your final message",
"run --device-code in a later step",
} {
if !strings.Contains(got, want) {

View File

@@ -47,8 +47,7 @@ func authCheckRun(opts *CheckOptions) error {
required := strings.Fields(opts.Scope)
if len(required) == 0 {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": true, "granted": []string{}, "missing": []string{}})
return nil
return output.ErrValidation("--scope cannot be empty")
}
config, err := f.Config()

View File

@@ -47,9 +47,10 @@ func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.
Long: `Device Flow authorization login.
For AI agents: this command blocks until the user completes authorization in the
browser. If your harness only delivers final turn messages, use --no-wait --json,
send the verification URL to the user as your final message, end the turn, then
run --device-code in a later step after the user confirms authorization.`,
browser. If your harness or agent tool only delivers final turn messages, use --no-wait --json,
send the verification URL (or QR code) to the user as your final message, end the turn, then
run --device-code in a later step after the user confirms authorization. Use 'lark-cli auth qrcode'
to generate QR codes (supports ASCII and PNG formats).`,
RunE: func(cmd *cobra.Command, args []string) error {
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
return output.ErrWithHint(output.ExitValidation, "command_denied",
@@ -68,7 +69,13 @@ run --device-code in a later step after the user confirms authorization.`,
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
available := sortedKnownDomains()
var helpBrand core.LarkBrand
if f != nil && f.Config != nil {
if cfg, err := f.Config(); err == nil && cfg != nil {
helpBrand = cfg.Brand
}
}
available := sortedKnownDomains(helpBrand)
cmd.Flags().StringSliceVar(&opts.Domains, "domain", nil,
fmt.Sprintf("domain (repeatable or comma-separated, e.g. --domain calendar,task)\navailable: %s, all", strings.Join(available, ", ")))
cmd.Flags().StringSliceVar(&opts.Exclude, "exclude", nil,
@@ -139,14 +146,14 @@ func authLoginRun(opts *LoginOptions) error {
// Expand --domain all to all available domains (from_meta projects + shortcut services)
for _, d := range selectedDomains {
if strings.EqualFold(d, "all") {
selectedDomains = sortedKnownDomains()
selectedDomains = sortedKnownDomains(config.Brand)
break
}
}
// Validate domain names and suggest corrections for unknown ones
if len(selectedDomains) > 0 {
knownDomains := allKnownDomains()
knownDomains := allKnownDomains(config.Brand)
for _, d := range selectedDomains {
if !knownDomains[d] {
if suggestion := suggestDomain(d, knownDomains); suggestion != "" {
@@ -170,7 +177,7 @@ func authLoginRun(opts *LoginOptions) error {
if !hasAnyOption {
if !opts.JSON && f.IOStreams.IsTerminal {
result, err := runInteractiveLogin(f.IOStreams, lang, msg)
result, err := runInteractiveLogin(f.IOStreams, lang, msg, config.Brand)
if err != nil {
return err
}
@@ -208,10 +215,10 @@ func authLoginRun(opts *LoginOptions) error {
if len(selectedDomains) > 0 || opts.Recommend {
var candidateScopes []string
if len(selectedDomains) > 0 {
candidateScopes = collectScopesForDomains(selectedDomains, "user")
candidateScopes = collectScopesForDomains(selectedDomains, "user", config.Brand)
} else {
// --recommend without --domain: all domains
candidateScopes = collectScopesForDomains(sortedKnownDomains(), "user")
candidateScopes = collectScopesForDomains(sortedKnownDomains(config.Brand), "user", config.Brand)
}
// Filter to auto-approve scopes if --recommend or interactive "common"
@@ -269,7 +276,7 @@ func authLoginRun(opts *LoginOptions) error {
"verification_url": authResp.VerificationUriComplete,
"device_code": authResp.DeviceCode,
"expires_in": authResp.ExpiresIn,
"hint": fmt.Sprintf("Show verification_url to the user exactly as returned by the CLI and treat it as an opaque string. Do not URL-encode or decode it, do not normalize or rewrite it, do not add %%20, spaces, or punctuation, and do not wrap it as Markdown link text; prefer a fenced code block containing only the raw URL. For agent harnesses that only deliver final turn messages, make the URL the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode),
"hint": fmt.Sprintf("**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL so the user can either scan or copy the link.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation. Prefer a fenced code block containing only the raw URL. For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode),
}
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
@@ -452,6 +459,7 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
return nil
}
// syncLoginUserToProfile persists the logged-in user info into the named profile.
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
multi, err := core.LoadMultiAppConfig()
if err != nil {
@@ -477,6 +485,7 @@ func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
return nil
}
// findProfileByName returns the AppConfig matching profileName, or nil.
func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig {
for i := range multi.Apps {
if multi.Apps[i].ProfileName() == profileName {
@@ -490,7 +499,7 @@ func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.App
// shortcut scopes for the given domain names.
// Domains with auth_domain children are automatically expanded to include
// their children's scopes.
func collectScopesForDomains(domains []string, identity string) []string {
func collectScopesForDomains(domains []string, identity string, brand core.LarkBrand) []string {
scopeSet := make(map[string]bool)
// 1. API scopes from from_meta projects
@@ -509,6 +518,9 @@ func collectScopesForDomains(domains []string, identity string) []string {
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
for _, sc := range shortcuts.AllShortcuts() {
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
continue
}
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
for _, s := range sc.DeclaredScopesForIdentity(identity) {
scopeSet[s] = true
@@ -528,7 +540,7 @@ func collectScopesForDomains(domains []string, identity string) []string {
// allKnownDomains returns all valid auth domain names (from_meta projects +
// shortcut services), excluding domains that have auth_domain set (they are
// folded into their parent domain).
func allKnownDomains() map[string]bool {
func allKnownDomains(brand core.LarkBrand) map[string]bool {
domains := make(map[string]bool)
for _, p := range registry.ListFromMetaProjects() {
if !registry.HasAuthDomain(p) {
@@ -536,6 +548,9 @@ func allKnownDomains() map[string]bool {
}
}
for _, sc := range shortcuts.AllShortcuts() {
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
continue
}
if !registry.HasAuthDomain(sc.Service) {
domains[sc.Service] = true
}
@@ -544,8 +559,8 @@ func allKnownDomains() map[string]bool {
}
// sortedKnownDomains returns all valid domain names sorted alphabetically.
func sortedKnownDomains() []string {
m := allKnownDomains()
func sortedKnownDomains(brand core.LarkBrand) []string {
m := allKnownDomains(brand)
domains := make([]string, 0, len(m))
for d := range m {
domains = append(domains, d)

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestBrandFilter_AppsExcludedOnLark(t *testing.T) {
feishuDomains := allKnownDomains(core.BrandFeishu)
if !feishuDomains["apps"] {
t.Errorf("expected apps domain to be known on Feishu brand")
}
larkDomains := allKnownDomains(core.BrandLark)
if larkDomains["apps"] {
t.Errorf("expected apps domain to be EXCLUDED on Lark brand")
}
feishuScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandFeishu)
if len(feishuScopes) == 0 {
t.Errorf("expected non-empty scopes for apps on Feishu brand, got %d", len(feishuScopes))
}
larkScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandLark)
if len(larkScopes) != 0 {
t.Errorf("expected empty scopes for apps on Lark brand, got %d: %v", len(larkScopes), larkScopes)
}
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
@@ -105,7 +106,7 @@ func buildDomainMeta(name, lang string) domainMeta {
}
// runInteractiveLogin shows an interactive TUI form for domain and permission selection.
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*interactiveResult, error) {
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand) (*interactiveResult, error) {
allDomains := getDomainMetadata(lang)
// Build multi-select options
@@ -165,7 +166,7 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*i
}
// Compute scope summary
scopes := collectScopesForDomains(selectedDomains, "user")
scopes := collectScopesForDomains(selectedDomains, "user", brand)
if permLevel == "common" {
scopes = registry.FilterAutoApproveScopes(scopes)
}

View File

@@ -59,7 +59,7 @@ var loginMsgZh = &loginMsg{
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 只会把最终回复发给用户,请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 和 verification_url把 verification_url 作为本轮最终消息原样发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 `lark-cli auth login --device-code <code>` 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code导致用户授权链接失效。向用户展示授权链接时,必须逐字原样转发 CLI 返回的 URL把它视为不可修改的 opaque string不要做 URL 编码解码,不要补 `%20`、空格或标点,不要改写成 Markdown 链接,建议用包含该 URL 的代码块单独输出。",
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 或 agent 工具只会把最终回复发给用户,请改用 \"lark-cli auth login --no-wait --json\" 拿到 device_code 和 verification_url把 verification_url 作为本轮最终消息发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 \"lark-cli auth login --device-code <code>\" 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code导致用户授权链接失效。**必须生成二维码并展示**: 你必须调用 lark-cli auth qrcode 将 verification_url 转为二维码,这是必须步骤,不要跳过。优先生成 PNG 二维码(--output仅当用户明确要求时才使用 ASCII--ascii。**关键:生成后必须在回复中展示图片**,仅生成文件不算完成,须用 image 标签或内联图片等方式将图片包含在回复中。**展示顺序**: 先输出 URL再将二维码图片置于 URL 下方完整展示,确保用户可扫码或复制链接。**URL 输出规则**: 将 verification_url 视为不可修改的 opaque string不要做任何修改(包括 URL 编码/解码、添加空格或标点),建议用包含该 URL 的代码块单独输出。",
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
LoginSuccess: "授权成功! 用户: %s (%s)",
AuthorizedUser: "当前授权账号: %s (%s)",
@@ -95,7 +95,7 @@ var loginMsgEn = &loginMsg{
OpenURL: "Open this URL in your browser to authenticate:\n\n",
WaitingAuth: "Waiting for user authorization...",
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness only delivers final turn messages, use `lark-cli auth login --no-wait --json` to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run `lark-cli auth login --device-code <code>` in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless. When showing the authorization URL to the user, copy the CLI-returned URL exactly as-is and treat it as an opaque string. Do not URL-encode or decode it, do not add `%20`, spaces, or punctuation, do not rewrite it as Markdown link text, and prefer a fenced code block containing only the raw URL.",
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness or agent tool only delivers final turn messages, use \"lark-cli auth login --no-wait --json\" to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run \"lark-cli auth login --device-code <code>\" in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless.**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL so the user can either scan or copy the link.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation. Prefer a fenced code block containing only the raw URL.",
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
LoginSuccess: "Authorization successful! User: %s (%s)",
AuthorizedUser: "Authorized account: %s (%s)",
@@ -114,6 +114,7 @@ var loginMsgEn = &loginMsg{
HintFooter: " lark-cli auth login --help",
}
// getLoginMsg returns the login message bundle for the given language.
func getLoginMsg(lang string) *loginMsg {
if lang == "en" {
return loginMsgEn
@@ -125,5 +126,5 @@ func getLoginMsg(lang string) *loginMsg {
// (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json.
func getShortcutOnlyDomainNames() []string {
return []string{"base", "contact", "docs", "markdown"}
return []string{"base", "contact", "docs", "markdown", "apps"}
}

View File

@@ -171,7 +171,7 @@ func TestCompleteDomain_CommaSeparated(t *testing.T) {
}
func TestAllKnownDomains(t *testing.T) {
domains := allKnownDomains()
domains := allKnownDomains("")
if len(domains) == 0 {
t.Fatal("expected non-empty known domains")
}
@@ -185,7 +185,7 @@ func TestAllKnownDomains(t *testing.T) {
}
func TestSortedKnownDomains(t *testing.T) {
sorted := sortedKnownDomains()
sorted := sortedKnownDomains("")
if len(sorted) == 0 {
t.Fatal("expected non-empty sorted domains")
}
@@ -195,7 +195,7 @@ func TestSortedKnownDomains(t *testing.T) {
}
// Should match allKnownDomains
known := allKnownDomains()
known := allKnownDomains("")
if len(sorted) != len(known) {
t.Errorf("sorted (%d) and known (%d) length mismatch", len(sorted), len(known))
}
@@ -220,7 +220,7 @@ func TestCollectScopesForDomains(t *testing.T) {
t.Skip("no from_meta data available")
}
scopes := collectScopesForDomains([]string{"calendar"}, "user")
scopes := collectScopesForDomains([]string{"calendar"}, "user", "")
if len(scopes) == 0 {
t.Fatal("expected non-empty scopes for calendar domain")
}
@@ -247,7 +247,7 @@ func TestCollectScopesForDomains(t *testing.T) {
}
func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) {
scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user")
scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user", "")
if len(scopes) != 0 {
t.Errorf("expected empty scopes for nonexistent domain, got %d", len(scopes))
}
@@ -945,12 +945,20 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
}
hint, _ := data["hint"].(string)
for _, want := range []string{
"exactly as returned by the CLI",
"MUST generate QR code AND display it",
"lark-cli auth qrcode",
"Prefer PNG QR code (--output)",
"use ASCII (--ascii) only when the user explicitly requests it",
"This is a required step, do NOT skip it",
"CRITICAL",
"You MUST include the QR image in your response",
"Generating the file alone is NOT enough",
"image tags, inline images, or file attachments",
"Display order",
"place the QR code image below the URL",
"opaque string",
"Do not URL-encode or decode it",
"do not add %20, spaces, or punctuation",
"do not wrap it as Markdown link text",
"fenced code block containing only the raw URL",
"cannot be modified",
"Prefer a fenced code block",
"final message of the turn",
"return control to the user",
"do not block on --device-code in the same turn",
@@ -1054,12 +1062,18 @@ func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t *
"结束本轮",
"用户回复已完成授权",
"不要在同一轮里展示 URL 后立刻阻塞执行 --device-code",
"逐字原样转发 CLI 返回的 URL",
"必须生成二维码并展示",
"lark-cli auth qrcode",
"优先生成 PNG 二维码(--output",
"仅当用户明确要求时才使用 ASCII--ascii",
"生成后必须在回复中展示图片",
"仅生成文件不算完成",
"image 标签或内联图片",
"二维码图片置于 URL 下方完整展示",
"URL 输出规则",
"opaque string",
"不要做 URL 编码或解码",
"不要补 `%20`、空格或标点",
"不要改写成 Markdown 链接",
"只包含该 URL 的代码块单独输出",
"不要做任何修改",
"仅包含该 URL 的代码块",
} {
if !strings.Contains(hint, want) {
t.Fatalf("agent_hint missing %q, got:\n%s", want, hint)
@@ -1077,7 +1091,7 @@ func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
}
func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
domains := allKnownDomains()
domains := allKnownDomains("")
if domains["whiteboard"] {
t.Error("whiteboard should not appear in known auth domains (it has auth_domain=docs)")
}
@@ -1087,7 +1101,7 @@ func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
}
func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) {
scopes := collectScopesForDomains([]string{"docs"}, "user")
scopes := collectScopesForDomains([]string{"docs"}, "user", "")
// docs domain should include whiteboard shortcut scopes (board:whiteboard:*)
found := false
for _, s := range scopes {

142
cmd/auth/qrcode.go Normal file
View File

@@ -0,0 +1,142 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"github.com/skip2/go-qrcode"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// QRCodeOptions holds inputs for auth qrcode command.
type QRCodeOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
URL string
Size int
ASCII bool
Output string
}
// NewCmdAuthQRCode creates the auth qrcode subcommand.
func NewCmdAuthQRCode(f *cmdutil.Factory, runF func(*QRCodeOptions) error) *cobra.Command {
opts := &QRCodeOptions{Factory: f, Size: 256}
cmd := &cobra.Command{
Use: "qrcode <url>",
Short: "Generate QR code for verification URL",
Long: `Generate a QR code image or ASCII representation for a verification URL.
This command is designed for AI agents to generate QR codes for OAuth authorization URLs.
For PNG output, the --output flag is required to specify the output file path (must be a relative path within the current directory).
For ASCII output, the result is printed to stdout with fixed size.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
opts.URL = args[0]
opts.Ctx = cmd.Context()
if runF != nil {
return runF(opts)
}
return runQRCode(opts)
},
}
cmd.Flags().IntVar(&opts.Size, "size", 256, "Size of the QR code image in pixels (default: 256, for PNG mode only)")
cmd.Flags().BoolVar(&opts.ASCII, "ascii", false, "Output ASCII QR code to stdout")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "Output file path for PNG image (relative path within current directory, required for non-ASCII mode)")
return cmd
}
// runQRCode executes the auth qrcode command.
func runQRCode(opts *QRCodeOptions) error {
if opts.URL == "" {
return output.Errorf(output.ExitValidation, "missing_url", "url is required")
}
if opts.ASCII {
var out io.Writer = os.Stdout
if opts.Factory != nil {
out = opts.Factory.IOStreams.Out
}
return generateASCIIQRCode(opts.URL, out)
}
if opts.Output == "" {
return output.Errorf(output.ExitValidation, "missing_output", "output file path is required for PNG mode. Use --output or -o flag to specify the output file path.")
}
if opts.Size < 32 {
return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at least 32, got %d", opts.Size))
}
if opts.Size > 1024 {
return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at most 1024, got %d", opts.Size))
}
safePath, err := validate.SafeOutputPath(opts.Output)
if err != nil {
return output.ErrValidation("unsafe output path: %s", err)
}
if err := generateImageQRCode(opts.URL, opts.Size, safePath); err != nil {
return err
}
result := map[string]interface{}{
"ok": true,
"file_path": safePath,
"hint": "You MUST include the QR image in your response. Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.",
}
var out io.Writer = os.Stdout
if opts.Factory != nil {
out = opts.Factory.IOStreams.Out
}
encoder := json.NewEncoder(out)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(result); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to write output: %v", err)
}
return nil
}
// generateImageQRCode encodes the URL as a PNG QR code and writes it to outputPath.
func generateImageQRCode(url string, size int, outputPath string) error {
png, err := qrcode.Encode(url, qrcode.Medium, size)
if err != nil {
return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to encode QR code: %v", err))
}
err = vfs.WriteFile(outputPath, png, 0644)
if err != nil {
return output.Errorf(output.ExitInternal, "write_error", fmt.Sprintf("failed to write QR code to %s: %v", outputPath, err))
}
return nil
}
// generateASCIIQRCode encodes the URL as an ASCII QR code and prints it to stdout.
func generateASCIIQRCode(url string, w io.Writer) error {
q, err := qrcode.New(url, qrcode.Medium)
if err != nil {
return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to create QR code: %v", err))
}
fmt.Fprint(w, q.ToSmallString(false))
return nil
}

368
cmd/auth/qrcode_test.go Normal file
View File

@@ -0,0 +1,368 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
func TestNewCmdAuthQRCode_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *QRCodeOptions
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"https://example.com", "--output", "qr.png", "--size", "128"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.URL != "https://example.com" {
t.Errorf("URL = %q, want %q", gotOpts.URL, "https://example.com")
}
if gotOpts.Size != 128 {
t.Errorf("Size = %d, want %d", gotOpts.Size, 128)
}
if gotOpts.Output != "qr.png" {
t.Errorf("Output = %q, want %q", gotOpts.Output, "qr.png")
}
if gotOpts.ASCII {
t.Error("ASCII should be false by default")
}
}
func TestNewCmdAuthQRCode_ASCIIFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *QRCodeOptions
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"https://example.com", "--ascii"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !gotOpts.ASCII {
t.Error("ASCII should be true when --ascii is passed")
}
}
func TestNewCmdAuthQRCode_DefaultSize(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *QRCodeOptions
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"https://example.com", "--ascii"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Size != 256 {
t.Errorf("default Size = %d, want 256", gotOpts.Size)
}
}
func TestNewCmdAuthQRCode_ExactOneArg(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdAuthQRCode(f, nil)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{})
if err := cmd.Execute(); err == nil {
t.Fatal("expected error when no URL argument provided")
}
}
func TestNewCmdAuthQRCode_RunE_PNGEndToEnd(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
tmpDir := t.TempDir()
oldWd, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { os.Chdir(oldWd) })
cmd := NewCmdAuthQRCode(f, nil)
cmd.SetArgs([]string{"https://example.com", "--output", "qr.png"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile("qr.png")
if err != nil {
t.Fatalf("output file not created: %v", err)
}
if string(data[:4]) != "\x89PNG" {
t.Errorf("output does not start with PNG magic bytes, got %x", data[:4])
}
var result map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
t.Fatalf("stdout is not valid JSON: %v, got: %s", err, stdout.String())
}
if result["ok"] != true {
t.Errorf("ok = %v, want true", result["ok"])
}
hint, _ := result["hint"].(string)
if hint == "" {
t.Error("hint is empty")
}
if !strings.Contains(hint, "MUST include") {
t.Errorf("hint missing 'MUST include', got: %s", hint)
}
if !strings.Contains(hint, "NOT enough") {
t.Errorf("hint missing 'NOT enough', got: %s", hint)
}
}
func TestNewCmdAuthQRCode_RunE_MissingOutput(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdAuthQRCode(f, nil)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"https://example.com"})
if err := cmd.Execute(); err == nil {
t.Fatal("expected error when --output is missing in PNG mode")
}
}
func TestNewCmdAuthQRCode_HelpText(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdAuthQRCode(f, nil)
cmd.SetOut(stdout)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := stdout.String()
for _, want := range []string{
"qrcode <url>",
"QR code",
"--output",
"--ascii",
"relative path",
} {
if !strings.Contains(got, want) {
t.Errorf("help missing %q", want)
}
}
}
func TestRunQRCode_MissingURL(t *testing.T) {
err := runQRCode(&QRCodeOptions{URL: ""})
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("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail.Type != "missing_url" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_url")
}
}
func TestRunQRCode_MissingOutput(t *testing.T) {
err := runQRCode(&QRCodeOptions{URL: "https://example.com", Size: 256})
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("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail.Type != "missing_output" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_output")
}
}
func TestRunQRCode_InvalidSize(t *testing.T) {
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
Size: 16,
Output: "qr.png",
})
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("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail.Type != "invalid_size" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size")
}
}
func TestRunQRCode_SizeTooLarge(t *testing.T) {
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
Size: 2048,
Output: "qr.png",
})
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("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail.Type != "invalid_size" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size")
}
}
func TestRunQRCode_UnsafeOutputPath(t *testing.T) {
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
Size: 256,
Output: "/etc/passwd",
})
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("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
}
func TestRunQRCode_PNGWritesFile(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
tmpDir := t.TempDir()
oldWd, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { os.Chdir(oldWd) })
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
Size: 256,
Output: "qr.png",
Factory: f,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
info, err := os.Stat("qr.png")
if err != nil {
t.Fatalf("output file not created: %v", err)
}
if info.Size() == 0 {
t.Error("output file is empty")
}
var result map[string]interface{}
if jsonErr := json.Unmarshal(stdout.Bytes(), &result); jsonErr != nil {
t.Fatalf("stdout is not valid JSON: %v, got: %s", jsonErr, stdout.String())
}
if result["ok"] != true {
t.Errorf("ok = %v, want true", result["ok"])
}
}
func TestRunQRCode_ASCIIOutputsToStdout(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
ASCII: true,
Factory: f,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if stdout.Len() == 0 {
t.Error("ASCII QR code produced no output")
}
}
func TestGenerateImageQRCode_Success(t *testing.T) {
tmpDir := t.TempDir()
outputPath := filepath.Join(tmpDir, "test-qr.png")
if err := generateImageQRCode("https://example.com", 256, outputPath); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("failed to read output file: %v", err)
}
if len(data) == 0 {
t.Error("output file is empty")
}
if len(data) < 8 {
t.Error("output too small to be a valid PNG")
}
if string(data[:4]) != "\x89PNG" {
t.Errorf("output does not start with PNG magic bytes, got %x", data[:4])
}
}
func TestGenerateImageQRCode_WriteError(t *testing.T) {
err := generateImageQRCode("https://example.com", 256, "/nonexistent/deep/nested/dir/qr.png")
if err == nil {
t.Fatal("expected error writing to nonexistent directory")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitInternal {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
}
if exitErr.Detail.Type != "write_error" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "write_error")
}
}
func TestGenerateASCIIQRCode_Success(t *testing.T) {
var buf strings.Builder
err := generateASCIIQRCode("https://example.com", &buf)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if buf.Len() == 0 {
t.Error("ASCII QR code produced no output")
}
}
func TestGenerateASCIIQRCode_EmptyString(t *testing.T) {
var buf strings.Builder
err := generateASCIIQRCode("", &buf)
if err == nil {
t.Fatal("expected error for empty string")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Detail.Type != "encode_error" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "encode_error")
}
}

View File

@@ -10,7 +10,6 @@ import (
"errors"
"fmt"
"io"
"net/url"
"os"
"sort"
"strconv"
@@ -389,8 +388,8 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr.Detail == nil || exitErr.Detail.Type != "permission" {
return
}
// Extract required scopes from API error detail
scopes := extractRequiredScopes(exitErr.Detail.Detail)
// Extract required scopes from API error detail (shared helper)
scopes := registry.ExtractRequiredScopes(exitErr.Detail.Detail)
if len(scopes) == 0 {
return
}
@@ -401,21 +400,10 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
}
// Select the recommended (least-privilege) scope
scopeIfaces := make([]interface{}, len(scopes))
for i, s := range scopes {
scopeIfaces[i] = s
}
recommended := registry.SelectRecommendedScope(scopeIfaces, "tenant")
if recommended == "" {
recommended = scopes[0]
}
recommended := registry.SelectRecommendedScopeFromStrings(scopes, "tenant")
// Build admin console URL with the recommended scope
host := "open.feishu.cn"
if cfg.Brand == "lark" {
host = "open.larksuite.com"
}
consoleURL := fmt.Sprintf("https://%s/page/scope-apply?clientID=%s&scopes=%s", host, url.QueryEscape(cfg.AppID), url.QueryEscape(recommended))
consoleURL := registry.BuildConsoleScopeURL(cfg.Brand, cfg.AppID, recommended)
// Clear raw API detail — useful info is now in message/hint/console_url
exitErr.Detail.Detail = nil
@@ -452,26 +440,3 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
exitErr.Detail.ConsoleURL = consoleURL
}
}
// extractRequiredScopes extracts scope names from the API error's permission_violations field.
func extractRequiredScopes(detail interface{}) []string {
m, ok := detail.(map[string]interface{})
if !ok {
return nil
}
violations, ok := m["permission_violations"].([]interface{})
if !ok {
return nil
}
var scopes []string
for _, v := range violations {
vm, ok := v.(map[string]interface{})
if !ok {
continue
}
if subject, ok := vm["subject"].(string); ok {
scopes = append(scopes, subject)
}
}
return scopes
}

3
go.mod
View File

@@ -11,6 +11,7 @@ require (
github.com/google/uuid v1.6.0
github.com/itchyny/gojq v0.12.17
github.com/larksuite/oapi-sdk-go/v3 v3.5.4
github.com/sergi/go-diff v1.4.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/smartystreets/goconvey v1.8.1
github.com/spf13/cobra v1.10.2
@@ -19,6 +20,7 @@ require (
github.com/tidwall/gjson v1.18.0
github.com/zalando/go-keyring v0.2.8
golang.org/x/net v0.33.0
golang.org/x/sync v0.15.0
golang.org/x/sys v0.33.0
golang.org/x/term v0.27.0
golang.org/x/text v0.23.0
@@ -61,5 +63,4 @@ require (
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.15.0 // indirect
)

15
go.sum
View File

@@ -45,6 +45,7 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -73,6 +74,11 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@@ -97,6 +103,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
@@ -107,8 +115,10 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
@@ -163,7 +173,10 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -39,6 +39,10 @@ const (
LarkErrDriveCrossTenantUnit = 1064510 // cross tenant and unit not support
LarkErrDriveCrossBrand = 1064511 // cross brand not support
// Wiki write-path lock contention (e.g. concurrent wiki +node-create under the
// same parent). Server-side write lock; transient, safe to retry with backoff.
LarkErrWikiLockContention = 131009
// Sheets float image: width/height/offset out of range or invalid.
LarkErrSheetsFloatImageInvalidDims = 1310246
@@ -83,6 +87,8 @@ func ClassifyLarkError(code int, msg string) (int, string, string) {
// drive-specific constraints that benefit from actionable hints
case LarkErrDriveResourceContention:
return ExitAPI, "conflict", "please retry later and avoid concurrent duplicate requests"
case LarkErrWikiLockContention:
return ExitAPI, "conflict", "wiki write lock contention on this parent node; retry with exponential backoff or serialize sibling-node writes"
case LarkErrDriveCrossTenantUnit:
return ExitAPI, "cross_tenant_unit", "operate on source and target within the same tenant and region/unit"
case LarkErrDriveCrossBrand:

View File

@@ -90,3 +90,24 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
})
}
}
// TestClassifyLarkError_WikiLockContention verifies the wiki write-lock
// contention error (131009) maps to an actionable retry hint instead of
// a generic "api_error". Surfaces during concurrent wiki +node-create
// against the same parent (see larksuite/cli#1012).
func TestClassifyLarkError_WikiLockContention(t *testing.T) {
t.Parallel()
gotExitCode, gotType, gotHint := ClassifyLarkError(LarkErrWikiLockContention, "raw msg")
if gotExitCode != ExitAPI {
t.Fatalf("exitCode=%d, want %d", gotExitCode, ExitAPI)
}
if gotType != "conflict" {
t.Fatalf("type=%q, want %q", gotType, "conflict")
}
if !strings.Contains(gotHint, "wiki write lock") {
t.Fatalf("hint=%q, want substring %q", gotHint, "wiki write lock")
}
if !strings.Contains(gotHint, "backoff") {
t.Fatalf("hint=%q, want substring %q", gotHint, "backoff")
}
}

View File

@@ -0,0 +1,82 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"fmt"
"net/url"
"github.com/larksuite/cli/internal/core"
)
// ExtractRequiredScopes pulls scope names out of the API error's
// permission_violations field. The detail argument is the raw `error` block
// that the platform returns alongside lark code 99991672 / 99991679 — typically
// shaped as:
//
// { "permission_violations": [ {"subject": "<scope>"}, ... ] }
//
// Returns nil when the structure does not match or no non-empty subjects are
// present, so callers can branch on a simple len() == 0 check.
func ExtractRequiredScopes(detail interface{}) []string {
m, ok := detail.(map[string]interface{})
if !ok {
return nil
}
violations, ok := m["permission_violations"].([]interface{})
if !ok {
return nil
}
scopes := make([]string, 0, len(violations))
for _, v := range violations {
vm, ok := v.(map[string]interface{})
if !ok {
continue
}
if subject, ok := vm["subject"].(string); ok && subject != "" {
scopes = append(scopes, subject)
}
}
if len(scopes) == 0 {
return nil
}
return scopes
}
// SelectRecommendedScopeFromStrings is a string-typed convenience wrapper
// around SelectRecommendedScope. When no scope is recognized by the priority
// table, it falls back to the first input scope so callers always have
// something to surface to users.
func SelectRecommendedScopeFromStrings(scopes []string, identity string) string {
if len(scopes) == 0 {
return ""
}
ifaces := make([]interface{}, len(scopes))
for i, s := range scopes {
ifaces[i] = s
}
if recommended := SelectRecommendedScope(ifaces, identity); recommended != "" {
return recommended
}
return scopes[0]
}
// BuildConsoleScopeURL returns the developer-console "apply scope" URL for the
// given app and scope, branded for feishu / lark. Returns "" when appID or
// scope is empty so callers can omit the field cleanly.
func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string {
if appID == "" || scope == "" {
return ""
}
host := "open.feishu.cn"
if brand == core.BrandLark {
host = "open.larksuite.com"
}
return fmt.Sprintf(
"https://%s/page/scope-apply?clientID=%s&scopes=%s",
host,
url.QueryEscape(appID),
url.QueryEscape(scope),
)
}

View File

@@ -0,0 +1,104 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestExtractRequiredScopes_HappyPath(t *testing.T) {
detail := map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "docs:permission.member:create"},
map[string]interface{}{"subject": "docs:doc"},
map[string]interface{}{"subject": ""}, // empty subject filtered
"not-a-map", // ignored
},
}
got := ExtractRequiredScopes(detail)
want := []string{"docs:permission.member:create", "docs:doc"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("ExtractRequiredScopes mismatch: got %v, want %v", got, want)
}
}
func TestExtractRequiredScopes_NilOrMalformed(t *testing.T) {
cases := []interface{}{
nil,
"plain string",
map[string]interface{}{},
map[string]interface{}{"permission_violations": "not-a-list"},
map[string]interface{}{"permission_violations": []interface{}{}},
map[string]interface{}{"permission_violations": []interface{}{
map[string]interface{}{"subject": ""},
}},
}
for i, in := range cases {
if got := ExtractRequiredScopes(in); got != nil {
t.Errorf("case %d: expected nil, got %v", i, got)
}
}
}
func TestBuildConsoleScopeURL_BrandSpecificHost(t *testing.T) {
got := BuildConsoleScopeURL(core.BrandFeishu, "cli_xxx", "docs:permission.member:create")
if !strings.Contains(got, "open.feishu.cn") {
t.Errorf("feishu brand should use open.feishu.cn host, got %s", got)
}
if !strings.Contains(got, "clientID=cli_xxx") {
t.Errorf("missing app id in url: %s", got)
}
if !strings.Contains(got, "scopes=docs%3Apermission.member%3Acreate") {
t.Errorf("scope not URL-escaped: %s", got)
}
got = BuildConsoleScopeURL(core.BrandLark, "cli_yyy", "drive:drive")
if !strings.Contains(got, "open.larksuite.com") {
t.Errorf("lark brand should use open.larksuite.com host, got %s", got)
}
}
func TestBuildConsoleScopeURL_EmptyInput(t *testing.T) {
if got := BuildConsoleScopeURL(core.BrandFeishu, "", "docs:doc"); got != "" {
t.Errorf("empty appID should yield empty url, got %s", got)
}
if got := BuildConsoleScopeURL(core.BrandFeishu, "cli_xxx", ""); got != "" {
t.Errorf("empty scope should yield empty url, got %s", got)
}
}
func TestSelectRecommendedScopeFromStrings_FallsBackToFirst(t *testing.T) {
ensureFreshRegistry(t)
// Unknown scopes (not in priority table) → fallback to first
got := SelectRecommendedScopeFromStrings([]string{"unknown:foo", "unknown:bar"}, "tenant")
if got != "unknown:foo" {
t.Errorf("expected fallback to first, got %s", got)
}
}
// When at least one scope is recognized by the priority table, the
// recommended scope wins over the fallback (first input).
func TestSelectRecommendedScopeFromStrings_PicksKnownScopeOverFallback(t *testing.T) {
ensureFreshRegistry(t)
// docs:permission.member:create is recommended (recommend=true) in
// scope_priorities.json. Putting an unknown scope first would otherwise
// win via the fallback path; this ensures the priority table is consulted
// before falling back.
got := SelectRecommendedScopeFromStrings([]string{"unknown:foo", "docs:permission.member:create"}, "tenant")
if got != "docs:permission.member:create" {
t.Errorf("expected priority-table winner, got %s", got)
}
}
func TestSelectRecommendedScopeFromStrings_Empty(t *testing.T) {
if got := SelectRecommendedScopeFromStrings(nil, "tenant"); got != "" {
t.Errorf("nil slice should return empty, got %s", got)
}
if got := SelectRecommendedScopeFromStrings([]string{}, "tenant"); got != "" {
t.Errorf("empty slice should return empty, got %s", got)
}
}

View File

@@ -5568,5 +5568,115 @@
"scope_name": "speech_to_text:speech",
"final_score": "70.8755",
"recommend": "true"
},
{
"scope_name": "spark:app:publish",
"final_score": "75.0587",
"recommend": "true"
},
{
"scope_name": "spark:app.access_scope:read",
"final_score": "88.0587",
"recommend": "true"
},
{
"scope_name": "spark:app.access_scope:write",
"final_score": "83.6587",
"recommend": "true"
},
{
"scope_name": "spark:app:read",
"final_score": "88.0587",
"recommend": "true"
},
{
"scope_name": "spark:app:write",
"final_score": "76.7173",
"recommend": "true"
},
{
"scope_name": "docs:secure_label:write_only",
"final_score": "75.0587",
"recommend": "true"
},
{
"scope_name": "corehr:job_change_v2:read",
"final_score": "75.9982",
"recommend": "false"
},
{
"scope_name": "corehr:pre_hire.contract_file_id:read",
"final_score": "80.0587",
"recommend": "false"
},
{
"scope_name": "im:chat.user_setting:read",
"final_score": "88.0587",
"recommend": "true"
},
{
"scope_name": "minutes:minutes.upload:write",
"final_score": "83.6587",
"recommend": "true"
},
{
"scope_name": "im:feed.flag:write",
"final_score": "79.5982",
"recommend": "true"
},
{
"scope_name": "im:feed.flag:read",
"final_score": "88.0587",
"recommend": "true"
},
{
"scope_name": "search:bot",
"final_score": "67.0587",
"recommend": "false"
},
{
"scope_name": "application:bot.basic_info:read",
"final_score": "88.0587",
"recommend": "true"
},
{
"scope_name": "drive:quota_detail:read_one",
"final_score": "75.0587",
"recommend": "true"
},
{
"scope_name": "docs:permission.member:apply",
"final_score": "83.6587",
"recommend": "true"
},
{
"scope_name": "corehr:employment.custom_field:write",
"final_score": "75.6587",
"recommend": "false"
},
{
"scope_name": "im:message.group_at_msg.include_bot:readonly",
"final_score": "88.9982",
"recommend": "true"
},
{
"scope_name": "okr:okr.setting:read",
"final_score": "80.0587",
"recommend": "false"
},
{
"scope_name": "directory:employee.base.leader_id:read",
"final_score": "88.0587",
"recommend": "true"
},
{
"scope_name": "directory:employee.base.dotted_line_leaders:read",
"final_score": "88.0587",
"recommend": "true"
},
{
"scope_name": "directory:employee.base.active_status:read",
"final_score": "80.0587",
"recommend": "false"
}
]

View File

@@ -3,6 +3,10 @@
"en": { "title": "Approval", "description": "Approval instance, and task management" },
"zh": { "title": "审批", "description": "审批实例、审批任务管理" }
},
"apps": {
"en": { "title": "Apps", "description": "Develop, deploy HTML, web pages and applications" },
"zh": { "title": "应用", "description": "开发、部署 HTML、Web 页面和应用" }
},
"base": {
"en": { "title": "Base", "description": "Table, field, record, view, dashboard, workflow, form, role & permission management" },
"zh": { "title": "多维表格", "description": "数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限管理" }

View File

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

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsAccessScopeGet reads the current access scope configuration of a Miaoda app.
// 响应原样透传服务端契约(字符串 scope 枚举 All/Tenant/Range + 拆分的 users/departments/chats 数组)。
var AppsAccessScopeGet = common.Shortcut{
Service: appsService,
Command: "+access-scope-get",
Description: "Get Miaoda app access scope configuration",
Risk: "read",
Scopes: []string{"spark:app.access_scope:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))).
Desc("Get Miaoda app access scope")
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
path := fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))
data, err := rctx.CallAPI("GET", path, nil, nil)
if err != nil {
return err
}
// 原样透传 — 保留服务端字符串枚举 (All/Tenant/Range),不合并 users/departments/chats。
rctx.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "scope: %v\n", data["scope"])
})
return nil
},
}

View File

@@ -0,0 +1,123 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAppsAccessScopeGet_Specific(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"scope": "Range",
"users": []interface{}{"ou_x", "ou_y"},
"departments": []interface{}{"od_z"},
"chats": []interface{}{"oc_g"},
"apply_config": map[string]interface{}{
"enabled": true,
"approvers": []interface{}{"ou_appr"},
},
},
},
})
if err := runAppsShortcut(t, AppsAccessScopeGet,
[]string{"+access-scope-get", "--app-id", "app_x", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"scope": "Range"`) {
t.Fatalf("scope string not preserved (expect raw \"Range\"): %s", got)
}
if !strings.Contains(got, `"ou_x"`) || !strings.Contains(got, `"od_z"`) || !strings.Contains(got, `"oc_g"`) {
t.Fatalf("users/departments/chats fields missing in envelope: %s", got)
}
if !strings.Contains(got, `"ou_appr"`) {
t.Fatalf("apply_config.approvers missing: %s", got)
}
}
func TestAppsAccessScopeGet_Public(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"scope": "All", "require_login": false},
},
})
if err := runAppsShortcut(t, AppsAccessScopeGet,
[]string{"+access-scope-get", "--app-id", "app_x", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"scope": "All"`) {
t.Fatalf("scope=All missing: %s", got)
}
if !strings.Contains(got, `"require_login": false`) {
t.Fatalf("require_login missing: %s", got)
}
}
func TestAppsAccessScopeGet_Tenant(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"scope": "Tenant"},
},
})
if err := runAppsShortcut(t, AppsAccessScopeGet,
[]string{"+access-scope-get", "--app-id", "app_x", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), `"scope": "Tenant"`) {
t.Fatalf("scope=Tenant missing: %s", stdout.String())
}
}
func TestAppsAccessScopeGet_RequiresAppID(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsAccessScopeGet,
[]string{"+access-scope-get", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "app-id") {
t.Fatalf("expected --app-id required, got %v", err)
}
}
func TestAppsAccessScopeGet_TrimsAppIDInPath(t *testing.T) {
// 与 +update 的 D1.2 修复对称URL 拼接前必须 TrimSpace(app-id)
// 否则 " app_x " 会被 EncodePathSegment 编码进 path segment 出现空格转义。
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"scope": "Tenant"},
},
})
if err := runAppsShortcut(t, AppsAccessScopeGet,
[]string{"+access-scope-get", "--app-id", " app_x ", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
}

View File

@@ -0,0 +1,208 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var allowedAccessTargetTypes = map[string]bool{
"user": true,
"department": true,
"chat": true,
}
// AppsAccessScopeSet sets the app's access scope (specific / public / tenant).
var AppsAccessScopeSet = common.Shortcut{
Service: appsService,
Command: "+access-scope-set",
Description: "Set Miaoda app access scope (specific / public / tenant)",
Risk: "write",
Scopes: []string{"spark:app.access_scope:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "scope", Desc: "scope: specific | public | tenant", Required: true, Enum: []string{"specific", "public", "tenant"}},
{Name: "targets", Desc: `targets JSON array: [{"type":"user|department|chat","id":"..."}, ...]`},
{Name: "apply-enabled", Type: "bool", Desc: "allow apply for access (scope=specific)"},
{Name: "approver", Desc: "approver open_id (when --apply-enabled; server allows exactly one)"},
{Name: "require-login", Type: "bool", Desc: "require login (scope=public)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
}
return validateAccessScopeFlags(rctx)
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
dry := common.NewDryRunAPI().
PUT(fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))).
Desc("Set Miaoda app access scope")
body, bodyErr := buildAccessScopeBody(rctx)
if bodyErr != nil {
dry.Set("body_error", bodyErr.Error())
} else {
dry.Body(body)
}
return dry
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
body, err := buildAccessScopeBody(rctx)
if err != nil {
return err
}
appID := strings.TrimSpace(rctx.Str("app-id"))
path := fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))
data, err := rctx.CallAPI("PUT", path, nil, body)
if err != nil {
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "access-scope set: %s\n", rctx.Str("scope"))
})
return nil
},
}
func validateAccessScopeFlags(rctx *common.RuntimeContext) error {
scope := rctx.Str("scope")
targets := strings.TrimSpace(rctx.Str("targets"))
applyEnabled := rctx.Bool("apply-enabled")
approver := strings.TrimSpace(rctx.Str("approver"))
requireLogin := rctx.Bool("require-login")
switch scope {
case "specific":
if targets == "" {
return output.ErrValidation("--targets is required when --scope=specific")
}
if err := validateTargetsJSON(targets); err != nil {
return err
}
if approver != "" && !applyEnabled {
return output.ErrValidation("--approver requires --apply-enabled")
}
if requireLogin {
return output.ErrValidation("--require-login is not allowed when --scope=specific")
}
case "public":
if targets != "" {
return output.ErrValidation("--targets is not allowed when --scope=public")
}
if applyEnabled {
return output.ErrValidation("--apply-enabled is not allowed when --scope=public")
}
if approver != "" {
return output.ErrValidation("--approver is not allowed when --scope=public")
}
if !rctx.Cmd.Flags().Changed("require-login") {
return output.ErrValidation("--require-login is required when --scope=public (pass true or false explicitly; do not rely on the default)")
}
case "tenant":
if targets != "" || applyEnabled || approver != "" || requireLogin {
return output.ErrValidation("no extra flags allowed when --scope=tenant")
}
default:
return output.ErrValidation("--scope must be specific / public / tenant")
}
return nil
}
func validateTargetsJSON(targetsJSON string) error {
var items []map[string]interface{}
if err := json.Unmarshal([]byte(targetsJSON), &items); err != nil {
return output.ErrValidation("--targets is not valid JSON: %v", err)
}
if len(items) == 0 {
return output.ErrValidation("--targets must contain at least one entry; specific scope requires concrete user/department/chat ids")
}
for i, t := range items {
typ, _ := t["type"].(string)
if !allowedAccessTargetTypes[typ] {
return output.ErrValidation("--targets[%d].type %q must be one of: user / department / chat", i, typ)
}
if id, _ := t["id"].(string); strings.TrimSpace(id) == "" {
return output.ErrValidation("--targets[%d].id is empty", i)
}
}
return nil
}
// scopeStringToServerEnum 把 CLI 友好的 scope 字符串映射成后端字符串枚举。
// CLI 用户 / Agent 仍然写 specific / public / tenantbody 里发后端枚举名。
// 后端语义All=互联网公开 / Tenant=组织内 / Range=部分人员。
var scopeStringToServerEnum = map[string]string{
"public": "All",
"tenant": "Tenant",
"specific": "Range",
}
func buildAccessScopeBody(rctx *common.RuntimeContext) (map[string]interface{}, error) {
scope := rctx.Str("scope")
enum, ok := scopeStringToServerEnum[scope]
if !ok {
return nil, output.ErrValidation("--scope must be specific / public / tenant, got %q", scope)
}
body := map[string]interface{}{"scope": enum}
switch scope {
case "specific":
// 用户传统一格式 [{type:user|department|chat, id:...}]body 里拆 3 个并列数组发后端。
var targets []map[string]interface{}
if err := json.Unmarshal([]byte(rctx.Str("targets")), &targets); err != nil {
return nil, output.ErrValidation("--targets is not valid JSON: %v", err)
}
users, departments, chats := splitAccessScopeTargets(targets)
if len(users) > 0 {
body["users"] = users
}
if len(departments) > 0 {
body["departments"] = departments
}
if len(chats) > 0 {
body["chats"] = chats
}
if rctx.Bool("apply-enabled") {
applyConfig := map[string]interface{}{"enabled": true}
if approver := strings.TrimSpace(rctx.Str("approver")); approver != "" {
applyConfig["approvers"] = []string{approver}
}
body["apply_config"] = applyConfig
}
case "public":
body["require_login"] = rctx.Bool("require-login")
}
return body, nil
}
// splitAccessScopeTargets 把统一 [{type,id}] 形态拆成后端要求的 users/departments/chats 三个数组。
func splitAccessScopeTargets(targets []map[string]interface{}) (users, departments, chats []string) {
for _, t := range targets {
typ, _ := t["type"].(string)
id, _ := t["id"].(string)
id = strings.TrimSpace(id)
if id == "" {
continue
}
switch typ {
case "user":
users = append(users, id)
case "department":
departments = append(departments, id)
case "chat":
chats = append(chats, id)
}
}
return
}

View File

@@ -0,0 +1,203 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAppsAccessScopeSet_Specific(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsAccessScopeSet, []string{
"+access-scope-set",
"--app-id", "app_x",
"--scope", "specific",
"--targets", `[{"type":"user","id":"ou_xxx"},{"type":"chat","id":"oc_xxx"}]`,
"--apply-enabled",
"--approver", "ou_yyy",
"--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
// 新协议scope 是 string 枚举 (specific=Range)targets 拆成 users/departments/chats
if got, _ := sent["scope"].(string); got != "Range" {
t.Fatalf("scope = %v, want %q", sent["scope"], "Range")
}
if _, present := sent["targets"]; present {
t.Fatalf("legacy 'targets' field should not be sent: %v", sent)
}
users, _ := sent["users"].([]interface{})
if len(users) != 1 || users[0] != "ou_xxx" {
t.Fatalf("users = %v, want [ou_xxx]", sent["users"])
}
chats, _ := sent["chats"].([]interface{})
if len(chats) != 1 || chats[0] != "oc_xxx" {
t.Fatalf("chats = %v, want [oc_xxx]", sent["chats"])
}
if _, present := sent["departments"]; present {
t.Fatalf("departments should be omitted when empty: %v", sent)
}
}
func TestAppsAccessScopeSet_Public(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
})
if err := runAppsShortcut(t, AppsAccessScopeSet, []string{
"+access-scope-set",
"--app-id", "app_x",
"--scope", "public",
"--require-login=false",
"--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
}
func TestAppsAccessScopeSet_Tenant(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
})
if err := runAppsShortcut(t, AppsAccessScopeSet, []string{
"+access-scope-set",
"--app-id", "app_x",
"--scope", "tenant",
"--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
}
func TestAppsAccessScopeSet_SpecificRequiresTargets(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsAccessScopeSet, []string{
"+access-scope-set", "--app-id", "app_x", "--scope", "specific", "--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "targets") {
t.Fatalf("expected targets required error, got %v", err)
}
}
func TestAppsAccessScopeSet_TenantRejectsExtraFlags(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsAccessScopeSet, []string{
"+access-scope-set", "--app-id", "app_x", "--scope", "tenant",
"--targets", `[]`, "--as", "user",
}, factory, stdout)
if err == nil {
t.Fatalf("expected error when --targets passed with scope=tenant")
}
}
func TestAppsAccessScopeSet_RejectsBadTargetType(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsAccessScopeSet, []string{
"+access-scope-set", "--app-id", "app_x",
"--scope", "specific",
"--targets", `[{"type":"group","id":"oc_xxx"}]`,
"--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "type") {
t.Fatalf("expected bad target type rejected, got %v", err)
}
}
func TestAppsAccessScopeSet_ApproverRequiresApplyEnabled(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsAccessScopeSet, []string{
"+access-scope-set", "--app-id", "app_x",
"--scope", "specific",
"--targets", `[{"type":"user","id":"ou_x"}]`,
"--approver", "ou_y",
"--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "apply-enabled") {
t.Fatalf("expected --approver requires --apply-enabled, got %v", err)
}
}
func TestAppsAccessScopeSet_PublicRejectsApprover(t *testing.T) {
// --approver 只在 specific + apply 流程下有意义public 模式带它当前会被静默丢弃,
// 是真实用户语义 bug。这条测试钉死 Validate 阶段拦截。
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsAccessScopeSet, []string{
"+access-scope-set", "--app-id", "app_x",
"--scope", "public",
"--require-login=false",
"--approver", "ou_y",
"--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "--approver is not allowed when --scope=public") {
t.Fatalf("expected --approver rejected for scope=public, got %v", err)
}
}
func TestAppsAccessScopeSet_PublicRequiresExplicitRequireLogin(t *testing.T) {
// bare --scope public without --require-login defaults silently to
// require_login=false (Internet-public + no auth). Reject so the caller
// has to make an explicit choice; matches SKILL.md "public 必传 --require-login".
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsAccessScopeSet, []string{
"+access-scope-set", "--app-id", "app_x",
"--scope", "public",
"--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "--require-login is required when --scope=public") {
t.Fatalf("expected --require-login required for public, got %v", err)
}
}
func TestAppsAccessScopeSet_SpecificRejectsEmptyTargets(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsAccessScopeSet, []string{
"+access-scope-set", "--app-id", "app_x",
"--scope", "specific",
"--targets", "[]",
"--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "--targets must contain at least one entry") {
t.Fatalf("expected empty --targets rejected, got %v", err)
}
}
func TestAppsAccessScopeSet_TrimsAppIDInPath(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/spark/v1/apps/app_x/access-scope",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
})
if err := runAppsShortcut(t, AppsAccessScopeSet, []string{
"+access-scope-set", "--app-id", " app_x ",
"--scope", "tenant",
"--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
}

View File

@@ -0,0 +1,79 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsCreate creates a new Miaoda app.
var AppsCreate = common.Shortcut{
Service: appsService,
Command: "+create",
Description: "Create a new Miaoda app",
Risk: "write",
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "name", Desc: "app display name", Required: true},
{Name: "app-type", Desc: "app type (currently only: HTML)", Required: true},
{Name: "description", Desc: "app description"},
{Name: "icon-url", Desc: "app icon URL (server uses default if omitted)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("name")) == "" {
return output.ErrValidation("--name is required")
}
appType := strings.TrimSpace(rctx.Str("app-type"))
if appType == "" {
return output.ErrValidation("--app-type is required")
}
if !validAppTypes[appType] {
return output.ErrValidation(fmt.Sprintf("--app-type %q is not supported (allowed: HTML)", appType))
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST(apiBasePath + "/apps").
Desc("Create a Miaoda app").
Body(buildAppsCreateBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPI("POST", apiBasePath+"/apps", nil, buildAppsCreateBody(rctx))
if err != nil {
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "created: %s\n", common.GetString(data, "app_id"))
})
return nil
},
}
// 应用类型枚举。当前只有 HTML未来会扩展SPA、NATIVE、...)。
var validAppTypes = map[string]bool{
"HTML": true,
}
func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
"name": strings.TrimSpace(rctx.Str("name")),
"app_type": strings.TrimSpace(rctx.Str("app-type")),
}
if desc := strings.TrimSpace(rctx.Str("description")); desc != "" {
body["description"] = desc
}
if icon := strings.TrimSpace(rctx.Str("icon-url")); icon != "" {
body["icon_url"] = icon
}
return body
}

View File

@@ -0,0 +1,157 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"encoding/json"
"strings"
"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"
)
// 测试基础设施 —— 后续 Task 2.2-2.4 / Task 3.4 复用
func newAppsExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "test-app-" + strings.ToLower(t.Name()),
AppSecret: "test-secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_test",
}
factory, stdout, _, reg := cmdutil.TestFactory(t, cfg)
return factory, stdout, reg
}
func runAppsShortcut(t *testing.T, sc common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "apps"}
sc.Mount(parent, factory)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.ExecuteContext(context.Background())
}
// +create 测试
func TestAppsCreate_Success(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"app_id": "app_x",
"name": "Demo",
"icon_url": "https://lf3-static.bytednsdoc.com/.../default.svg",
"created_at": "2026-05-18T10:00:00Z",
},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--description", "d", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"app_id": "app_x"`) {
t.Fatalf("stdout missing app_id: %s", got)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if sent["name"] != "Demo" {
t.Fatalf("body.name = %v", sent["name"])
}
if sent["app_type"] != "HTML" {
t.Fatalf("body.app_type = %v (want HTML)", sent["app_type"])
}
if sent["description"] != "d" {
t.Fatalf("body.description = %v", sent["description"])
}
if _, present := sent["icon_url"]; present {
t.Fatalf("icon_url should be omitted when not provided: %v", sent)
}
}
func TestAppsCreate_WithIconURL(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"app_id": "app_x", "name": "Demo"},
},
})
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--icon-url", "https://example.com/icon.svg", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
}
func TestAppsCreate_RequiresName(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsCreate, []string{"+create", "--app-type", "HTML", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "name") {
t.Fatalf("expected name required error, got %v", err)
}
}
func TestAppsCreate_RequiresAppType(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "app-type") {
t.Fatalf("expected --app-type required error, got %v", err)
}
}
func TestAppsCreate_RejectsInvalidAppType(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "spa", "--as", "user"},
factory, stdout)
if err == nil || !strings.Contains(err.Error(), "not supported") {
t.Fatalf("expected unsupported app-type error, got %v", err)
}
}
func TestAppsCreate_DryRun(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "HTML", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "/open-apis/spark/v1/apps") {
t.Fatalf("dry-run missing endpoint: %s", got)
}
if !strings.Contains(got, `"name": "Demo"`) {
t.Fatalf("dry-run missing body: %s", got)
}
if !strings.Contains(got, `"app_type": "HTML"`) {
t.Fatalf("dry-run missing app_type: %s", got)
}
}

View File

@@ -0,0 +1,192 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"path/filepath"
"strings"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsHTMLPublish packs --path as tar.gz and uploads + publishes via one multipart POST.
var AppsHTMLPublish = common.Shortcut{
Service: appsService,
Command: "+html-publish",
Description: "Publish HTML to a Miaoda app (single multipart POST returns the access URL)",
Risk: "write",
Scopes: []string{"spark:app:publish"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
{Name: "path", Desc: "path to HTML file or directory", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
}
path := strings.TrimSpace(rctx.Str("path"))
if path == "" {
return output.ErrValidation("--path is required")
}
// Reject --path equal to the current working directory. Publishing
// cwd recursively packs .git/ / .env / node_modules / .aws/credentials
// alongside the intended HTML, and combined with --scope public puts
// those on an internet-reachable URL.
if filepath.Clean(path) == "." {
return output.ErrWithHint(output.ExitValidation, "validation",
"--path 不能指向当前工作目录(避免误把整个工程一并发布出去)",
"改成具体的子目录或文件,如 './dist' / './public' / './index.html'")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
path := strings.TrimSpace(rctx.Str("path"))
dry := common.NewDryRunAPI()
dry.Desc("Upload tar.gz + publish HTML (multipart, returns url)")
dry.POST(fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID))).
Set("content_type", "multipart/form-data")
candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), path)
if err != nil {
dry.Set("path_error", err.Error())
return dry
}
if err := ensureIndexHTML(candidates); err != nil {
// Surface the same failure Execute would hit, but as a structured
// envelope field so dry-run still exits 0 (matches repo convention
// for dry-run "advisory preview" semantics).
dry.Set("validation_error", err.Error())
}
dry.Set("file_count", len(candidates))
var totalSize int64
names := make([]string, 0, len(candidates))
for _, c := range candidates {
totalSize += c.Size
names = append(names, c.RelPath)
}
dry.Set("total_size_bytes", totalSize)
dry.Set("files", names)
// Advisory scan: surface paths matching well-known secret / credential
// patterns so the caller can review before going public. Dry-run still
// exits 0; this is non-blocking by design (legit doc sites may ship
// example .env files).
var warnings []string
for _, c := range candidates {
if isSensitiveRelPath(c.RelPath) {
warnings = append(warnings, c.RelPath)
}
}
if len(warnings) > 0 {
dry.Set("warnings", warnings)
dry.Set("warning_summary", fmt.Sprintf("manifest contains %d sensitive path(s); review before publishing", len(warnings)))
}
return dry
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
spec := appsHTMLPublishSpec{
AppID: strings.TrimSpace(rctx.Str("app-id")),
Path: strings.TrimSpace(rctx.Str("path")),
}
client := appsHTMLPublishAPI{runtime: rctx}
out, err := runHTMLPublish(ctx, rctx.FileIO(), client, spec)
if err != nil {
return err
}
rctx.OutFormat(out, nil, func(w io.Writer) {
if url, ok := out["url"].(string); ok && url != "" {
fmt.Fprintf(w, "url: %s\n", url)
}
})
return nil
},
}
type appsHTMLPublishSpec struct {
AppID string
Path string
}
// maxHTMLPublishTarballBytes 是 client 端 tar.gz 包体上限,对齐 OAPI 设计 20MB 约束。
// 用 var 而非 const便于单测调小覆盖拦截路径。
var maxHTMLPublishTarballBytes int64 = 20 * 1024 * 1024
// maxHTMLPublishRawBytes caps the total UNCOMPRESSED candidate size before
// tar+gzip writes them into the in-memory buffer. Defends against
// highly-compressible "decompression bomb" inputs (e.g. 50GB of zeros)
// that would balloon process memory before the gzip-after check fires.
// 200MB is much higher than any plausible legitimate HTML/static-site
// payload but low enough to stay well under typical container memory.
// Mutable for tests.
var maxHTMLPublishRawBytes int64 = 200 * 1024 * 1024
// ensureIndexHTML 要求 walker 抓到的 candidates 里必须含 index.html。
// 目录形态:根目录下必须有 index.html。
// 单文件形态:文件名必须就是 index.html。
// 妙搭服务端用 index.html 作为应用入口。
func ensureIndexHTML(candidates []htmlPublishCandidate) error {
for _, c := range candidates {
if c.RelPath == "index.html" {
return nil
}
}
return output.ErrWithHint(output.ExitAPI, "validation",
"--path 中缺少 index.html",
"妙搭以 index.html 作为应用入口;目录形态把首页放在根目录命名 index.html单文件形态把文件命名为 index.html")
}
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, client appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
// Defense in depth: callers reaching runHTMLPublish bypass the shortcut's
// Validate closure. Re-check that --path is not cwd before walking.
if filepath.Clean(spec.Path) == "." {
return nil, output.ErrWithHint(output.ExitValidation, "validation",
"--path 不能指向当前工作目录(避免误把整个工程一并发布出去)",
"改成具体的子目录或文件,如 './dist' / './public' / './index.html'")
}
candidates, err := walkHTMLPublishCandidates(fio, spec.Path)
if err != nil {
return nil, output.Errorf(output.ExitAPI, "io", "scan --path %s: %v", spec.Path, err)
}
if err := ensureIndexHTML(candidates); err != nil {
return nil, err
}
var rawTotal int64
for _, c := range candidates {
rawTotal += c.Size
}
if rawTotal > maxHTMLPublishRawBytes {
return nil, output.ErrWithHint(output.ExitAPI, "validation",
fmt.Sprintf("--path total raw bytes %d exceeds %d bytes limit (uncompressed pre-pack cap)", rawTotal, maxHTMLPublishRawBytes),
"在 tar+gzip 进入内存前拦截,避免 OOM精简 --path 内容或选择更小的子目录")
}
tarball, err := buildHTMLPublishTarball(fio, candidates)
if err != nil {
return nil, output.Errorf(output.ExitAPI, "io", "pack: %v", err)
}
if tarball.Size > maxHTMLPublishTarballBytes {
return nil, output.ErrWithHint(output.ExitAPI, "validation",
fmt.Sprintf("packed tar.gz size %d bytes exceeds %d bytes limit", tarball.Size, maxHTMLPublishTarballBytes),
"请精简 --path 目录(去掉无关大文件 / 压缩资源)后重试;本期接口上限 20MB")
}
resp, err := client.HTMLPublish(ctx, spec.AppID, tarball)
if err != nil {
return nil, err
}
out := map[string]interface{}{}
if resp.URL != "" {
out["url"] = resp.URL
}
return out, nil
}

View File

@@ -0,0 +1,338 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
)
type fakeAppsHTMLPublishClient struct {
resp *htmlPublishResponse
err error
calls []string
}
func (f *fakeAppsHTMLPublishClient) HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error) {
f.calls = append(f.calls, appID)
if f.err != nil {
return nil, f.err
}
return f.resp, nil
}
func writeAppsSampleSite(t *testing.T) string {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
return dir
}
func TestRunHTMLPublish_HappyPath(t *testing.T) {
site := writeAppsSampleSite(t)
fake := &fakeAppsHTMLPublishClient{
resp: &htmlPublishResponse{URL: "https://miaoda/app_x"},
}
out, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
if err != nil {
t.Fatalf("err=%v", err)
}
if out["url"] != "https://miaoda/app_x" {
t.Fatalf("url=%v", out["url"])
}
if len(fake.calls) != 1 || fake.calls[0] != "app_x" {
t.Fatalf("calls=%v", fake.calls)
}
}
func TestRunHTMLPublish_OnlyURLInEnvelope(t *testing.T) {
// Pin 概要设计 §5.3 不变量 4 "同步语义不会变成异步":
// envelope 只含 url未来若有人加 status / release_id 字段会被这个测试拦截。
site := writeAppsSampleSite(t)
fake := &fakeAppsHTMLPublishClient{
resp: &htmlPublishResponse{URL: "https://miaoda/app_x"},
}
out, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
if err != nil {
t.Fatalf("err=%v", err)
}
if len(out) != 1 {
t.Fatalf("envelope should only contain 'url', got %d keys: %v", len(out), out)
}
if _, ok := out["url"]; !ok {
t.Fatalf("envelope missing 'url': %v", out)
}
}
func TestRunHTMLPublish_ClientErrorPropagated(t *testing.T) {
site := writeAppsSampleSite(t)
wantErr := errors.New("server timeout")
fake := &fakeAppsHTMLPublishClient{err: wantErr}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
if !errors.Is(err, wantErr) {
t.Fatalf("err=%v", err)
}
}
func TestRunHTMLPublish_PathNotFound(t *testing.T) {
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: "/nonexistent"})
if err == nil {
t.Fatalf("expected error")
}
if len(fake.calls) != 0 {
t.Fatalf("client should not be called when path invalid")
}
}
func TestRunHTMLPublish_DirRequiresIndexHTML(t *testing.T) {
// 目录形态:缺 index.html 应该被拦
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "foo.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
if err == nil {
t.Fatalf("expected error for missing index.html")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with detail, got %v", err)
}
if exitErr.Detail.Type != "validation" {
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, "index.html") {
t.Fatalf("message missing 'index.html': %v", exitErr.Detail.Message)
}
if exitErr.Detail.Hint == "" {
t.Fatalf("expected non-empty hint")
}
if len(fake.calls) != 0 {
t.Fatalf("client should not be called when index.html missing")
}
}
func TestRunHTMLPublish_DirWithIndexHTMLPasses(t *testing.T) {
// 目录含 index.html 应该正常走完
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "extra.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
t.Fatalf("err=%v", err)
}
if len(fake.calls) != 1 {
t.Fatalf("client should be called when index.html present")
}
}
func TestRunHTMLPublish_SingleFileRejectedIfNotNamedIndex(t *testing.T) {
// 单文件形态:文件名不是 index.html 也要拦
dir := t.TempDir()
single := filepath.Join(dir, "foo.html")
if err := os.WriteFile(single, []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: single})
if err == nil {
t.Fatalf("single-file path 'foo.html' should be rejected (not named index.html)")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "validation" {
t.Fatalf("expected ExitError type=validation, got %v", err)
}
if len(fake.calls) != 0 {
t.Fatalf("client must not be called when index.html missing")
}
}
func TestRunHTMLPublish_SingleFileNamedIndexPasses(t *testing.T) {
// 单文件形态:文件名恰好就是 index.html → 放行
dir := t.TempDir()
single := filepath.Join(dir, "index.html")
if err := os.WriteFile(single, []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: single}); err != nil {
t.Fatalf("err=%v", err)
}
if len(fake.calls) != 1 {
t.Fatalf("client should be called for single index.html")
}
}
func TestRunHTMLPublish_RejectsOversizeTarball(t *testing.T) {
// 把上限调到 100 字节验证拦截defer 恢复原值避免污染其它测试。
orig := maxHTMLPublishTarballBytes
maxHTMLPublishTarballBytes = 100
defer func() { maxHTMLPublishTarballBytes = orig }()
dir := t.TempDir()
// 写 index.html满足新加的 index 校验)+ 大文件超 100 字节上限。
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "big.html"),
[]byte(strings.Repeat("x", 4096)), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
if err == nil {
t.Fatalf("expected oversize error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with detail, got %v", err)
}
if exitErr.Detail.Type != "validation" {
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, "exceeds") {
t.Fatalf("message missing 'exceeds': %v", exitErr.Detail.Message)
}
if exitErr.Detail.Hint == "" {
t.Fatalf("expected non-empty hint")
}
if len(fake.calls) != 0 {
t.Fatalf("client should not be called when tarball oversize")
}
}
func TestMaxHTMLPublishTarballBytes_Default(t *testing.T) {
// Pin 20MB 常量值typo 到 20*1000*1024 之类会被拦截。
if maxHTMLPublishTarballBytes != 20*1024*1024 {
t.Fatalf("default = %d, want %d (20MiB)", maxHTMLPublishTarballBytes, 20*1024*1024)
}
}
func TestAppsHTMLPublish_RequiresAppID(t *testing.T) {
site := writeAppsSampleSite(t)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsHTMLPublish,
[]string{"+html-publish", "--path", site}, factory, stdout)
// cobra Required:true may report flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "app-id") {
t.Fatalf("expected --app-id required, got %v", err)
}
}
func TestAppsHTMLPublish_RequiresPath(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsHTMLPublish,
[]string{"+html-publish", "--app-id", "app_x"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "path") {
t.Fatalf("expected --path required, got %v", err)
}
}
func TestAppsHTMLPublish_DryRunPrintsManifest(t *testing.T) {
// 这个用例走真实 shortcut → 真实 LocalFileIOcwd-bounded
// 必须 chdir 进 tmp 用相对路径,否则 SafeInputPath 会拒绝绝对 --path。
// --path "." 被 Validate 拒绝,因此改为在 tmp 下建 dist 子目录并传 ./dist。
dir := t.TempDir()
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
if err := os.MkdirAll(filepath.Join(dir, "dist"), 0o755); err != nil {
t.Fatalf("mkdir dist: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsHTMLPublish,
[]string{"+html-publish", "--app-id", "app_x", "--path", "./dist", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code") {
t.Fatalf("dry-run missing endpoint: %s", got)
}
if !strings.Contains(got, "index.html") {
t.Fatalf("dry-run missing file list: %s", got)
}
}
func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
orig := maxHTMLPublishRawBytes
maxHTMLPublishRawBytes = 100
defer func() { maxHTMLPublishRawBytes = orig }()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "big.html"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake,
appsHTMLPublishSpec{AppID: "app_x", Path: dir})
if err == nil {
t.Fatalf("expected raw-size cap to fire")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with detail, got %v", err)
}
if exitErr.Detail.Type != "validation" {
t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, "raw") || !strings.Contains(exitErr.Detail.Message, "bytes") {
t.Fatalf("expected message to explain raw-byte cap, got %q", exitErr.Detail.Message)
}
if len(fake.calls) != 0 {
t.Fatalf("client must not be called when raw cap hit")
}
}
func TestRunHTMLPublish_RejectsCurrentDirectoryPath(t *testing.T) {
// Publishing the entire current working directory is the canonical
// secrets-exfiltration footgun (.git/.env/node_modules all end up in the
// tarball). Reject --path "." (and Clean equivalents) at runHTMLPublish
// entry so any direct caller cannot accidentally trigger it. (Validate
// also rejects at flag layer; this is defense in depth.)
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake,
appsHTMLPublishSpec{AppID: "app_x", Path: "."})
if err == nil {
t.Fatalf("expected --path '.' to be rejected")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "validation" {
t.Fatalf("expected ExitError type=validation, got %v", err)
}
if !strings.Contains(exitErr.Detail.Message, "当前工作目录") {
t.Fatalf("error message should explain cwd is forbidden, got %q", exitErr.Detail.Message)
}
if len(fake.calls) != 0 {
t.Fatalf("client must not be called when --path is cwd")
}
}

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsList lists Miaoda apps owned by the calling user (cursor pagination).
//
// Hidden from --help / tab completion (Hidden: true) so agents do not discover it
// as a way to enumerate / search applications. Direct invocation still works for
// humans who know the command. When agents need an existing app_id, they should
// ask the user to provide either the Miaoda app URL (extract app_id from the
// path segment after /app/) or the app_id string directly; see lark-apps SKILL.md.
var AppsList = common.Shortcut{
Service: appsService,
Command: "+list",
Description: "List Miaoda apps owned by the calling user (cursor pagination)",
Risk: "read",
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Hidden: true,
Flags: []common.Flag{
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET(apiBasePath + "/apps").
Desc("List Miaoda apps").
Params(buildAppsListParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPI("GET", apiBasePath+"/apps", buildAppsListParams(rctx), nil)
if err != nil {
return err
}
items, _ := data["items"].([]interface{})
rctx.OutFormat(data, nil, func(w io.Writer) {
// Table view (--format table) intentionally shows only the columns
// most useful for visual scanning: app_id (to copy-paste downstream),
// name (to match what the user sees in the UI), and updated_at (to
// pick the most recent variant). description / icon_url / created_at
// stay in the underlying JSON (--format json) but would make the
// table too wide for a terminal.
rows := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
rows = append(rows, map[string]interface{}{
"app_id": m["app_id"],
"name": m["name"],
"updated_at": m["updated_at"],
})
}
output.PrintTable(w, rows)
})
return nil
},
}
func buildAppsListParams(rctx *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{
"page_size": rctx.Int("page-size"),
}
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
params["page_token"] = token
}
return params
}

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAppsList_FirstPage(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps?page_size=20",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"app_id": "app_a", "name": "Alpha", "updated_at": "2026-05-18T10:00:00Z"},
map[string]interface{}{"app_id": "app_b", "name": "Beta", "updated_at": "2026-05-18T09:00:00Z"},
},
"page_token": "next_cursor",
"has_more": true,
},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsList, []string{"+list", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "app_a") || !strings.Contains(got, "app_b") {
t.Fatalf("output missing items: %s", got)
}
if !strings.Contains(got, "Alpha") || !strings.Contains(got, "Beta") {
t.Fatalf("output missing item names: %s", got)
}
}
func TestAppsList_WithPageToken(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps?page_size=50&page_token=cursor_abc",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{},
"has_more": false,
},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsList,
[]string{"+list", "--page-size", "50", "--page-token", "cursor_abc", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
}
func TestAppsList_DryRun(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsList,
[]string{"+list", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "/open-apis/spark/v1/apps") {
t.Fatalf("dry-run missing endpoint: %s", got)
}
if !strings.Contains(got, "page_size") {
t.Fatalf("dry-run missing page_size param: %s", got)
}
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsUpdate partially updates a Miaoda app's name / description.
var AppsUpdate = common.Shortcut{
Service: appsService,
Command: "+update",
Description: "Partially update a Miaoda app (only provided fields are sent)",
Risk: "write",
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "name", Desc: "new app display name"},
{Name: "description", Desc: "new app description"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return output.ErrValidation("--app-id is required")
}
body := buildAppsUpdateBody(rctx)
if len(body) == 0 {
return output.ErrValidation("provide at least one of --name or --description")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
PATCH(fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))).
Desc("Update a Miaoda app").
Body(buildAppsUpdateBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))
data, err := rctx.CallAPI("PATCH", path, nil, buildAppsUpdateBody(rctx))
if err != nil {
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "updated: %s\n", common.GetString(data, "app_id"))
})
return nil
},
}
func buildAppsUpdateBody(rctx *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{}
if v := strings.TrimSpace(rctx.Str("name")); v != "" {
body["name"] = v
}
if v := strings.TrimSpace(rctx.Str("description")); v != "" {
body["description"] = v
}
return body
}

View File

@@ -0,0 +1,86 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAppsUpdate_PartialFields(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/spark/v1/apps/app_x",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"app_id": "app_x",
"name": "renamed",
"updated_at": "2026-05-18T10:05:00Z",
},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsUpdate,
[]string{"+update", "--app-id", "app_x", "--name", "renamed", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if sent["name"] != "renamed" {
t.Fatalf("body.name = %v", sent["name"])
}
if _, present := sent["description"]; present {
t.Fatalf("description should not be in body when not provided: %v", sent)
}
}
func TestAppsUpdate_RequiresAppID(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsUpdate,
[]string{"+update", "--name", "renamed", "--as", "user"}, factory, stdout)
// cobra Required:true may match "app-id" instead of "--app-id"
if err == nil || !strings.Contains(err.Error(), "app-id") {
t.Fatalf("expected --app-id required, got %v", err)
}
}
func TestAppsUpdate_RequiresAtLeastOneField(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsUpdate,
[]string{"+update", "--app-id", "app_x", "--as", "user"}, factory, stdout)
if err == nil {
t.Fatalf("expected error when no field provided")
}
}
func TestAppsUpdate_TrimsAppIDInPath(t *testing.T) {
// 钉死 --app-id 在拼进 URL 前要先 TrimSpace —— 与 create / access-scope-* 等保持一致,
// 避免 " app_x " 这种取值被原样 EncodePathSegment 编进 path 出现空格转义。
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/spark/v1/apps/app_x",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"app_id": "app_x"},
},
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsUpdate,
[]string{"+update", "--app-id", " app_x ", "--name", "renamed", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
}

10
shortcuts/apps/common.go Normal file
View File

@@ -0,0 +1,10 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
// appsService 是 CLI 命令的 service 前缀lark-cli apps ...)。
const appsService = "apps"
// apiBasePath is the registered OAPI prefix for the Miaoda apps domain.
const apiBasePath = "/open-apis/spark/v1"

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type htmlPublishResponse struct {
URL string
}
type appsHTMLPublishClient interface {
HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error)
}
type appsHTMLPublishAPI struct {
runtime *common.RuntimeContext
}
func (api appsHTMLPublishAPI) HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error) {
fd := larkcore.NewFormdata()
fd.AddFile("file", bytes.NewReader(tarball.Body))
apiResp, err := api.runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID)),
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return nil, err
}
return parseHTMLPublishResponse(apiResp.RawBody)
}
func parseHTMLPublishResponse(raw []byte) (*htmlPublishResponse, error) {
var envelope struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
URL string `json:"url"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return nil, fmt.Errorf("decode html-publish response: %w", err)
}
if envelope.Code != 0 {
return nil, output.ErrWithHint(output.ExitAPI, "api_error",
fmt.Sprintf("html-publish failed (code=%d): %s", envelope.Code, envelope.Msg),
buildHTMLPublishFailureHint(envelope.Code))
}
return &htmlPublishResponse{URL: envelope.Data.URL}, nil
}
// OAPI business error codes returned by the Miaoda
// /apps/{id}/upload_and_release_html_code endpoint. Owned by the backend
// service; update when new codes are documented in the OAPI spec.
const (
errCodeBuildFailed = 90001 // tar.gz uploaded but server-side build failed
errCodeAppNotFound = 90002 // app_id unknown or caller lacks permission
)
func buildHTMLPublishFailureHint(code int) string {
switch code {
case errCodeBuildFailed:
return "构建失败:用 `lark-cli apps +html-publish --app-id <your-app-id> --path <path> --dry-run` 检查打包文件清单"
case errCodeAppNotFound:
return "应用不存在或无权访问;请用户确认 app_id从妙搭应用链接 https://miaoda.feishu.cn/app/app_xxx 的 /app/ 后面提取,或直接给 app_xxx 字符串)"
default:
return ""
}
}

View File

@@ -0,0 +1,139 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"errors"
"mime"
"mime/multipart"
"strings"
"testing"
"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"
)
func newAppsClientRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
t.Helper()
cfg := &core.CliConfig{
AppID: "test-app-" + strings.ToLower(t.Name()),
AppSecret: "test-secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_test",
}
factory, _, _, reg := cmdutil.TestFactory(t, cfg)
rctx := common.TestNewRuntimeContextForAPI(context.Background(), nil, cfg, factory, core.AsUser)
return rctx, reg
}
func TestAppsHTMLPublishAPI_Success(t *testing.T) {
rctx, reg := newAppsClientRuntime(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"url": "https://miaoda.feishu.cn/app/app_x",
},
},
}
reg.Register(stub)
api := appsHTMLPublishAPI{runtime: rctx}
tarball := &htmlPublishTarball{Body: []byte("fake"), Size: 4, SHA256: "abc"}
resp, err := api.HTMLPublish(context.Background(), "app_x", tarball)
if err != nil {
t.Fatalf("err=%v", err)
}
if resp.URL != "https://miaoda.feishu.cn/app/app_x" {
t.Fatalf("url=%q", resp.URL)
}
ct := stub.CapturedHeaders.Get("Content-Type")
mt, params, err := mime.ParseMediaType(ct)
if err != nil || mt != "multipart/form-data" {
t.Fatalf("content type %q wrong", ct)
}
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
saw := false
for {
p, err := mr.NextPart()
if err != nil {
break
}
if p.FormName() == "file" {
saw = true
}
}
if !saw {
t.Fatalf("multipart missing 'file' part")
}
}
func TestAppsHTMLPublishAPI_BusinessErrorHasHint(t *testing.T) {
rctx, reg := newAppsClientRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
Body: map[string]interface{}{
"code": 90001,
"msg": "build failed: dependency conflict",
},
})
api := appsHTMLPublishAPI{runtime: rctx}
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
if err == nil {
t.Fatalf("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with detail, got %v", err)
}
if exitErr.Detail.Hint == "" {
t.Fatalf("expected non-empty hint on code 90001")
}
if !strings.Contains(exitErr.Detail.Message, "build failed") {
t.Fatalf("missing failure message: %v", exitErr.Detail.Message)
}
}
func TestBuildHTMLPublishFailureHint_UnknownCodeReturnsEmpty(t *testing.T) {
// 默认分支:未识别的 code 返回空 hint让 Agent 用 message 兜底。
if hint := buildHTMLPublishFailureHint(99999); hint != "" {
t.Fatalf("unknown code should return empty hint, got %q", hint)
}
if hint := buildHTMLPublishFailureHint(0); hint != "" {
t.Fatalf("zero code should return empty hint, got %q", hint)
}
}
func TestBuildHTMLPublishFailureHint_KnownCodes(t *testing.T) {
if hint := buildHTMLPublishFailureHint(90001); hint == "" {
t.Fatalf("code 90001 should return non-empty hint")
}
if hint := buildHTMLPublishFailureHint(90002); hint == "" {
t.Fatalf("code 90002 should return non-empty hint")
}
}
func TestBuildHTMLPublishFailureHint_NotFoundHintNoLongerMentionsList(t *testing.T) {
hint := buildHTMLPublishFailureHint(90002)
if hint == "" {
t.Fatalf("code 90002 should return non-empty hint")
}
if strings.Contains(hint, "+list") {
t.Fatalf("hint must not point at hidden +list command, got: %q", hint)
}
if !strings.Contains(hint, "app_id") {
t.Fatalf("hint should reference app_id, got: %q", hint)
}
}

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"github.com/larksuite/cli/extension/fileio"
)
// htmlPublishTarball is the in-memory packed tar.gz ready for multipart upload.
// Body is bounded by maxHTMLPublishTarballBytes (20MiB) — see runHTMLPublish.
type htmlPublishTarball struct {
Body []byte
Size int64
SHA256 string
}
func buildHTMLPublishTarball(fio fileio.FileIO, candidates []htmlPublishCandidate) (*htmlPublishTarball, error) {
if len(candidates) == 0 {
return nil, errors.New("no files to pack")
}
var buf bytes.Buffer
hasher := sha256.New()
multi := io.MultiWriter(&buf, hasher)
gz := gzip.NewWriter(multi)
tw := tar.NewWriter(gz)
for _, c := range candidates {
if err := writeHTMLPublishTarEntry(fio, tw, c); err != nil {
_ = tw.Close()
_ = gz.Close()
return nil, err
}
}
if err := tw.Close(); err != nil {
_ = gz.Close()
return nil, fmt.Errorf("tar close: %w", err)
}
if err := gz.Close(); err != nil {
return nil, fmt.Errorf("gzip close: %w", err)
}
return &htmlPublishTarball{
Body: buf.Bytes(),
Size: int64(buf.Len()),
SHA256: hex.EncodeToString(hasher.Sum(nil)),
}, nil
}
func writeHTMLPublishTarEntry(fio fileio.FileIO, tw *tar.Writer, c htmlPublishCandidate) error {
if isUnsafeRelPath(c.RelPath) {
return fmt.Errorf("invalid tar entry name %q", c.RelPath)
}
src, err := fio.Open(c.AbsPath)
if err != nil {
return fmt.Errorf("open %s: %w", c.AbsPath, err)
}
defer src.Close()
hdr := &tar.Header{
Name: c.RelPath,
Size: c.Size,
Mode: 0o644,
Typeflag: tar.TypeReg,
}
if err := tw.WriteHeader(hdr); err != nil {
return fmt.Errorf("write header %s: %w", c.RelPath, err)
}
if _, err := io.Copy(tw, src); err != nil {
return fmt.Errorf("copy %s: %w", c.RelPath, err)
}
return nil
}

View File

@@ -0,0 +1,193 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"archive/tar"
"bytes"
"compress/gzip"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/extension/fileio"
)
// readFailingFIO opens a File whose Read always returns the configured error,
// letting tests exercise the io.Copy failure branch without filesystem games.
type readFailingFIO struct{ readErr error }
func (f readFailingFIO) Open(string) (fileio.File, error) {
return &readFailingFile{err: f.readErr}, nil
}
func (f readFailingFIO) Stat(string) (fileio.FileInfo, error) {
return nil, errors.New("Stat not used")
}
func (readFailingFIO) ResolvePath(p string) (string, error) { return p, nil }
func (readFailingFIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) {
return nil, errors.New("Save not used")
}
type readFailingFile struct{ err error }
func (f *readFailingFile) Read([]byte) (int, error) { return 0, f.err }
func (f *readFailingFile) ReadAt([]byte, int64) (int, error) { return 0, f.err }
func (f *readFailingFile) Close() error { return nil }
func TestBuildHTMLPublishTarball_RoundTrip(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
fio := newTestFIO()
candidates, err := walkHTMLPublishCandidates(fio, dir)
if err != nil {
t.Fatalf("walk: %v", err)
}
tarball, err := buildHTMLPublishTarball(fio, candidates)
if err != nil {
t.Fatalf("build: %v", err)
}
if len(tarball.SHA256) != 64 {
t.Fatalf("SHA256 wrong len: %d", len(tarball.SHA256))
}
if tarball.Size <= 0 || int64(len(tarball.Body)) != tarball.Size {
t.Fatalf("size=%d body=%d", tarball.Size, len(tarball.Body))
}
gz, err := gzip.NewReader(bytes.NewReader(tarball.Body))
if err != nil {
t.Fatalf("gzip: %v", err)
}
tr := tar.NewReader(gz)
hdr, err := tr.Next()
if err != nil {
t.Fatalf("tar.Next: %v", err)
}
if hdr.Name != "index.html" {
t.Fatalf("entry name = %q, want index.html", hdr.Name)
}
body, err := io.ReadAll(tr)
if err != nil || string(body) != "<html></html>" {
t.Fatalf("body=%q err=%v", body, err)
}
}
func TestBuildHTMLPublishTarball_EmptyCandidates(t *testing.T) {
if _, err := buildHTMLPublishTarball(newTestFIO(), nil); err == nil {
t.Fatalf("expected error")
}
}
func TestWriteHTMLPublishTarEntry_OpenFailure(t *testing.T) {
// candidate 指向不存在文件 → fio.Open 失败 → 错误返回
tw := tar.NewWriter(io.Discard)
defer tw.Close()
err := writeHTMLPublishTarEntry(newTestFIO(), tw, htmlPublishCandidate{
RelPath: "x.html",
AbsPath: "/nonexistent-path-for-test/x.html",
Size: 0,
})
if err == nil {
t.Fatalf("expected error for nonexistent abs path")
}
if !strings.Contains(err.Error(), "open") {
t.Fatalf("expected open error, got %v", err)
}
}
func TestWriteHTMLPublishTarEntry_WriteHeaderFailure(t *testing.T) {
// 在已 close 的 tar.Writer 上写 header → WriteHeader 失败
dir := t.TempDir()
file := filepath.Join(dir, "x.html")
if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
tw := tar.NewWriter(io.Discard)
_ = tw.Close() // 先 close下次 WriteHeader 必失败
err := writeHTMLPublishTarEntry(newTestFIO(), tw, htmlPublishCandidate{
RelPath: "x.html",
AbsPath: file,
Size: 1,
})
if err == nil {
t.Fatalf("expected error when writing to closed tar.Writer")
}
if !strings.Contains(err.Error(), "write header") {
t.Fatalf("expected 'write header' error, got %v", err)
}
}
func TestWriteHTMLPublishTarEntry_CopyFailure(t *testing.T) {
// 注入一个 Read 必失败的 fileio.File让 io.Copy 在 tar 写入阶段出错。
// 避免 chmod 0o000 的跨平台 / root 用户 flake。
fio := readFailingFIO{readErr: errors.New("synthetic read failure")}
tw := tar.NewWriter(io.Discard)
defer tw.Close()
err := writeHTMLPublishTarEntry(fio, tw, htmlPublishCandidate{
RelPath: "x.html",
AbsPath: "fixtures/x.html", // 任意路径Open 由 stub 接管
Size: 7,
})
if err == nil {
t.Fatalf("expected error when underlying Read fails")
}
if !strings.Contains(err.Error(), "copy") {
t.Fatalf("expected copy-stage error, got %v", err)
}
}
func TestBuildHTMLPublishTarball_EntryWriteFailureReturnsError(t *testing.T) {
// candidate 指向不存在文件 → writeHTMLPublishTarEntry 失败
// → buildHTMLPublishTarball 返回 nil tarball + error。
candidates := []htmlPublishCandidate{
{RelPath: "x.html", AbsPath: "/nonexistent-path-for-test/x.html", Size: 0},
}
tarball, err := buildHTMLPublishTarball(newTestFIO(), candidates)
if err == nil {
t.Fatalf("expected error, got tarball=%+v", tarball)
}
if tarball != nil {
t.Fatalf("expected nil tarball on error, got %+v", tarball)
}
}
func TestWriteHTMLPublishTarEntry_RejectsPathTraversal(t *testing.T) {
tw := tar.NewWriter(io.Discard)
defer tw.Close()
cases := []struct {
name string
rel string
}{
{"parent traversal", "../etc/passwd"},
{"absolute path", "/etc/passwd"},
{"embedded traversal", "a/../../etc/passwd"},
{"null byte", "evil\x00.html"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := writeHTMLPublishTarEntry(newTestFIO(), tw, htmlPublishCandidate{
RelPath: c.rel,
AbsPath: "fixtures/whatever",
Size: 0,
})
if err == nil {
t.Fatalf("expected error for RelPath=%q", c.rel)
}
if !strings.Contains(err.Error(), "invalid tar entry name") {
t.Fatalf("expected 'invalid tar entry name' error, got %v", err)
}
})
}
}

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import "strings"
// isSensitiveRelPath reports whether a relative path inside the candidate
// manifest looks like something that should not ship to a public-internet
// share URL — secrets, credentials, SCM internals, SSH keys. The check is
// path-element-wise (each "/"-delimited segment is inspected) so secrets
// nested under arbitrary subdirectories are still caught.
//
// Used by +html-publish dry-run to populate a "warnings" field; the
// caller still proceeds (this is advisory, not a hard block) so legit
// edge cases (e.g. a documentation site that has a .env example file
// on purpose) are not gated, but the user/agent sees the list.
func isSensitiveRelPath(rel string) bool {
if rel == "" {
return false
}
parts := strings.Split(rel, "/")
for i, p := range parts {
switch {
case p == ".git":
return true
case p == ".env" || strings.HasPrefix(p, ".env."):
return true
case p == ".npmrc" || p == ".netrc":
return true
case p == "credentials" || p == "config":
if i > 0 {
parent := parts[i-1]
if parent == ".aws" || parent == ".docker" || parent == ".gcloud" || parent == ".kube" {
return true
}
}
case strings.HasPrefix(p, "id_rsa") || strings.HasPrefix(p, "id_ed25519") || strings.HasPrefix(p, "id_ecdsa") || strings.HasPrefix(p, "id_dsa"):
return true
case strings.HasSuffix(p, ".pem") || strings.HasSuffix(p, ".key"):
return true
case strings.HasSuffix(p, ".json") && p == "config.json" && i > 0 && parts[i-1] == ".docker":
return true
}
}
return false
}

View File

@@ -0,0 +1,50 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import "testing"
func TestIsSensitiveRelPath(t *testing.T) {
cases := []struct {
rel string
want bool
}{
// dotfiles and well-known secret stores
{".env", true},
{".env.local", true},
{".env.production", true},
{"backend/.env", true},
{".npmrc", true},
{"sub/.npmrc", true},
{".netrc", true},
// .git tree
{".git/config", true},
{".git/HEAD", true},
{"subdir/.git/config", true},
{".gitignore", false}, // NOT sensitive (intended to be committed)
// SSH keys
{".ssh/id_rsa", true},
{".ssh/id_ed25519", true},
{"backup/id_rsa.pub", true}, // pub also flagged (often near private)
// Cloud creds
{".aws/credentials", true},
{".aws/config", true},
{".docker/config.json", true},
// Generic crypto
{"server.pem", true},
{"certs/private.key", true},
{"path/to/whatever.pem", true},
// Benign
{"index.html", false},
{"dist/main.js", false},
{"assets/logo.svg", false},
{"README.md", false},
{"package.json", false},
}
for _, c := range cases {
if got := isSensitiveRelPath(c.rel); got != c.want {
t.Errorf("isSensitiveRelPath(%q) = %v, want %v", c.rel, got, c.want)
}
}
}

View File

@@ -0,0 +1,18 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all apps domain shortcuts.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
AppsCreate,
AppsUpdate,
AppsList,
AppsAccessScopeSet,
AppsAccessScopeGet,
AppsHTMLPublish,
}
}

View File

@@ -0,0 +1,14 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import "testing"
// 钉死域内 shortcut 数量。少一条(漏挂)或多一条(误加)都会被这个测试拦截。
func TestAppsShortcuts_Returns6(t *testing.T) {
got := Shortcuts()
if len(got) != 6 {
t.Fatalf("Shortcuts() returned %d entries, want 6", len(got))
}
}

View File

@@ -0,0 +1,91 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"fmt"
"io/fs"
"path/filepath"
"strings"
"github.com/larksuite/cli/extension/fileio"
)
type htmlPublishCandidate struct {
RelPath string
AbsPath string
Size int64
}
// isUnsafeRelPath reports whether a forward-slash relative path contains
// anything that should never be written into a tar header or treated as
// inside-root: leading slash (absolute), .. as a path component (start /
// middle / end / whole), or an embedded null byte. Component-aware so it
// does not false-positive on legitimate filenames that contain ".." as a
// substring (e.g. "archive.tar..bak").
func isUnsafeRelPath(rel string) bool {
return strings.HasPrefix(rel, "/") ||
rel == ".." ||
strings.HasPrefix(rel, "../") ||
strings.Contains(rel, "/../") ||
strings.HasSuffix(rel, "/..") ||
strings.ContainsRune(rel, 0)
}
// walkHTMLPublishCandidates walks rootPath and returns each regular file as a
// candidate. Stat goes through fileio so SafeInputPath validation runs on the
// root; the directory walk itself uses filepath.WalkDir because runtime.FileIO
// has no WalkDir equivalent today.
func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublishCandidate, error) {
stat, err := fio.Stat(rootPath)
if err != nil {
return nil, fmt.Errorf("stat %s: %w", rootPath, err)
}
if !stat.IsDir() {
return []htmlPublishCandidate{{
RelPath: filepath.Base(rootPath),
AbsPath: rootPath,
Size: stat.Size(),
}}, nil
}
var out []htmlPublishCandidate
//nolint:forbidigo // fileio has no WalkDir; rootPath is already validated above via fio.Stat -> SafeInputPath.
err = filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
// 只接受 regular file —— symlink / device / pipe / socket 都跳过。
// symlink 不跟随是设计决策(避免 loop + out-of-root 引用),且 fio.Open 也会拒非 regular。
if !info.Mode().IsRegular() {
return nil
}
rel, err := filepath.Rel(rootPath, path)
if err != nil {
return err
}
relSlash := filepath.ToSlash(rel)
// Defense in depth: WalkDir + Rel inside rootPath should never yield a
// path with .. components, but a future logic change or unusual
// filesystem layout shouldn't be able to inject one into RelPath.
// Mirrors the same guard at tar entry write time.
if isUnsafeRelPath(relSlash) {
return fmt.Errorf("walker produced unsafe relative path %q for %s", relSlash, path)
}
out = append(out, htmlPublishCandidate{
RelPath: relSlash,
AbsPath: path,
Size: info.Size(),
})
return nil
})
return out, err
}

View File

@@ -0,0 +1,140 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"io"
"os"
"path/filepath"
"sort"
"testing"
"github.com/larksuite/cli/extension/fileio"
)
// permissiveFIO is a test-only fileio that delegates to os without
// SafeInputPath validation. Unit tests use it so we can drive the walker
// and tarball algorithms with absolute t.TempDir paths; production code
// goes through LocalFileIO which is cwd-bounded.
type permissiveFIO struct{}
func (permissiveFIO) Open(name string) (fileio.File, error) { return os.Open(name) }
func (permissiveFIO) Stat(name string) (fileio.FileInfo, error) { return os.Stat(name) }
func (permissiveFIO) ResolvePath(p string) (string, error) { return p, nil }
func (permissiveFIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) {
panic("Save not used in apps unit tests")
}
func newTestFIO() fileio.FileIO { return permissiveFIO{} }
func TestWalkHTMLPublishCandidates_SingleFile(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "index.html")
if err := os.WriteFile(file, []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
got, err := walkHTMLPublishCandidates(newTestFIO(), file)
if err != nil {
t.Fatalf("err=%v", err)
}
if len(got) != 1 || got[0].RelPath != "index.html" || got[0].Size != 13 {
t.Fatalf("got=%+v", got)
}
}
func TestWalkHTMLPublishCandidates_Directory(t *testing.T) {
dir := t.TempDir()
files := map[string]string{
"index.html": "<html></html>",
"css/main.css": "body{}",
"assets/logo.svg": "<svg/>",
}
for rel, content := range files {
full := filepath.Join(dir, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
}
got, err := walkHTMLPublishCandidates(newTestFIO(), dir)
if err != nil {
t.Fatalf("err=%v", err)
}
if len(got) != 3 {
t.Fatalf("got %d candidates, want 3", len(got))
}
rels := make([]string, 3)
for i, c := range got {
rels[i] = c.RelPath
}
sort.Strings(rels)
want := []string{"assets/logo.svg", "css/main.css", "index.html"}
for i, w := range want {
if rels[i] != w {
t.Fatalf("rel[%d]=%q want %q", i, rels[i], w)
}
}
}
func TestWalkHTMLPublishCandidates_NotFound(t *testing.T) {
if _, err := walkHTMLPublishCandidates(newTestFIO(), "/nonexistent/xyz"); err == nil {
t.Fatalf("expected error")
}
}
func TestIsUnsafeRelPath(t *testing.T) {
cases := []struct {
rel string
want bool
}{
{"index.html", false},
{"assets/logo.svg", false},
{"deep/nested/path/file.html", false},
{"archive.tar..bak", false},
{"version.1..2.html", false},
{"..config", false},
{"", false},
{"/etc/passwd", true},
{"..", true},
{"../etc/passwd", true},
{"a/../../etc/passwd", true},
{"a/..", true},
{"evil\x00.html", true},
}
for _, c := range cases {
if got := isUnsafeRelPath(c.rel); got != c.want {
t.Errorf("isUnsafeRelPath(%q) = %v, want %v", c.rel, got, c.want)
}
}
}
func TestWalkHTMLPublishCandidates_SymlinkSkipped(t *testing.T) {
// Walker 只接受 regular file —— symlink 跳过(避免 loop + out-of-root 引用,
// 且 fio.Open 对 symlink 行为不一致。real.html 仍然被收link.html 不在结果里。
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "real.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.Symlink(filepath.Join(dir, "real.html"), filepath.Join(dir, "link.html")); err != nil {
t.Skipf("symlink not supported on this filesystem: %v", err)
}
got, err := walkHTMLPublishCandidates(newTestFIO(), dir)
if err != nil {
t.Fatalf("err=%v", err)
}
rels := make(map[string]bool)
for _, c := range got {
rels[c.RelPath] = true
}
if !rels["real.html"] {
t.Fatalf("expected real.html (regular file) in candidates, got %+v", got)
}
if rels["link.html"] {
t.Fatalf("symlink link.html should NOT appear in candidates, got %+v", got)
}
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var BaseFormDetail = common.Shortcut{
Service: "base",
Command: "+form-detail",
Description: "Get form detail by share token",
Risk: "read",
Scopes: []string{"base:form:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "share-token", Desc: "Form share token (share_token)", Required: true},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/tables/forms/detail").
Body(map[string]interface{}{
"share_token": runtime.Str("share-token"),
})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body := map[string]interface{}{
"share_token": runtime.Str("share-token"),
}
data, err := baseV3Call(runtime, "POST",
baseV3Path("bases", "tables", "forms", "detail"), nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,334 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"sync"
"golang.org/x/sync/errgroup"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
uploadAttachConcurrency = 5
)
var BaseFormSubmit = common.Shortcut{
Service: "base",
Command: "+form-submit",
Description: "Submit a form (fill and submit form data)",
Risk: "write",
Scopes: []string{"base:form:update", "docs:document.media:upload"},
AuthTypes: authTypes(),
HasFormat: true,
Flags: []common.Flag{
{Name: "share-token", Desc: "Form share token (required), extracted from the form share link", Required: true},
{Name: "base-token", Desc: "Base token (required when --json contains attachments, used for uploading attachments to Base Drive Media)"},
{Name: "json", Desc: `JSON object containing "fields" (field values) and "attachments" (attachment file paths). Example: '{"fields":{"Rating":5,"Review":"Good"},"attachments":{"Attachment":["./a.pdf","./b.png"]}}'`, Required: true},
},
Tips: []string{
`Example (no attachments): --share-token shrXXXX --json '{"fields":{"Service Rating":5,"Review":"Good service"}}'`,
`Example (with attachments): --share-token shrXXXX --base-token basXXX --json '{"fields":{"Service Rating":5},"attachments":{"Attachment":["./report.pdf"]}}'`,
`Cell values in "fields" follow lark-base-cell-value.md conventions; "attachments" maps field names to local file path arrays — the CLI uploads them in parallel and merges them into the submission.`,
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateFormSubmit(runtime)
},
DryRun: dryRunFormSubmit,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeFormSubmit(runtime)
},
}
func validateFormSubmit(runtime *common.RuntimeContext) error {
// 校验 --json 结构:提取 "fields" 和 "attachments"
pc := newParseCtx(runtime)
raw, err := parseJSONObject(pc, runtime.Str("json"), "json")
if err != nil {
return err
}
fields, _ := raw["fields"].(map[string]interface{})
attachments, hasAttachments := raw["attachments"]
if !hasAttachments && fields == nil {
return common.FlagErrorf("--json must contain at least \"fields\" or \"attachments\"")
}
if hasAttachments {
// 有附件时 --base-token 必填(上传附件到 Base Drive Media 需要)
if runtime.Str("base-token") == "" {
return common.FlagErrorf("--base-token is required when --json contains \"attachments\"")
}
attMap, ok := attachments.(map[string]interface{})
if !ok {
return common.FlagErrorf("--json.attachments must be a JSON object mapping field names to file path arrays")
}
for fieldName, value := range attMap {
paths, ok := value.([]interface{})
if !ok {
return common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
}
for i, item := range paths {
if _, ok := item.(string); !ok {
return common.FlagErrorf("--json.attachments.%q[%d] must be a file path string, got %T", fieldName, i, item)
}
}
if len(paths) == 0 {
return common.FlagErrorf("--json.attachments.%q must not be empty; remove it or provide at least one file path", fieldName)
}
}
}
return nil
}
// parseFormSubmitJSON 将 --json 解析为字段和附件映射。
func parseFormSubmitJSON(runtime *common.RuntimeContext) (map[string]interface{}, map[string][]string, error) {
pc := newParseCtx(runtime)
raw, err := parseJSONObject(pc, runtime.Str("json"), "json")
if err != nil {
return nil, nil, err
}
fields, _ := raw["fields"].(map[string]interface{})
if fields == nil {
fields = make(map[string]interface{})
}
var attMap map[string][]string
if attachments, ok := raw["attachments"]; ok {
attObj, ok := attachments.(map[string]interface{})
if !ok {
return nil, nil, common.FlagErrorf(`--json.attachments must be a JSON object mapping field names to file path arrays`)
}
if len(attObj) > 0 {
attMap = make(map[string][]string, len(attObj))
for fieldName, value := range attObj {
paths, ok := value.([]interface{})
if !ok {
return nil, nil, common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
}
filePaths := make([]string, 0, len(paths))
for _, item := range paths {
if s, ok := item.(string); ok {
filePaths = append(filePaths, s)
} else {
return nil, nil, common.FlagErrorf("--json.attachments.%q must contain file path strings only, got %T", fieldName, item)
}
}
if len(filePaths) > 0 {
attMap[fieldName] = filePaths
}
}
}
}
return fields, attMap, nil
}
func dryRunFormSubmit(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
fields, attachmentMap, err := parseFormSubmitJSON(runtime)
if err != nil {
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
}
if len(attachmentMap) > 0 {
dry := common.NewDryRunAPI().
Desc("Form submit with attachments: upload local files per field → merge with fields → submit")
for fieldName, filePaths := range attachmentMap {
for _, p := range filePaths {
fileName := filepath.Base(p)
dry = dry.POST("/open-apis/drive/v1/medias/upload_all").
Desc(fmt.Sprintf("Upload attachment for field %q: %s", fieldName, fileName)).
Body(map[string]interface{}{
"file_name": fileName,
"parent_type": baseFormAttachmentParentType,
"parent_node": runtime.Str("base-token"),
"extra": baseFormAttachmentExtra(runtime.Str("share-token")),
"file": "@" + p,
"size": "<file_size>",
})
}
}
body := buildFormSubmitBody(runtime, fields)
dry = dry.POST("/open-apis/base/v3/bases/tables/forms/submit").
Body(body).
Desc("Submit form with uploaded attachment tokens merged with fields")
return dry
}
body := buildFormSubmitBody(runtime, fields)
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/tables/forms/submit").
Body(body)
}
func buildFormSubmitBody(runtime *common.RuntimeContext, content map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"share_token": runtime.Str("share-token"),
"content": content,
}
}
func executeFormSubmit(runtime *common.RuntimeContext) error {
fields, attachmentMap, err := parseFormSubmitJSON(runtime)
if err != nil {
return err
}
// 上传附件并合并到字段中
if len(attachmentMap) > 0 {
baseToken := runtime.Str("base-token")
fio := runtime.FileIO()
if fio == nil {
return output.ErrValidation("file operations require a FileIO provider (needed for attachments in --json)")
}
// Step 1: 收集所有唯一路径(跨字段去重)
allPaths := collectUniquePaths(attachmentMap)
if len(allPaths) == 0 {
return common.FlagErrorf("attachments in --json contains no valid file paths")
}
// Step 2: 前置校验所有文件路径安全性与可访问性,同时收集文件大小供上传使用
sizeMap := make(map[string]int64, len(allPaths))
for _, filePath := range allPaths {
if _, err := validate.SafeInputPath(filePath); err != nil {
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
}
fileInfo, err := fio.Stat(filePath)
if err != nil {
if errors.Is(err, fileio.ErrPathValidation) {
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
}
return output.ErrValidation("attachment file not accessible: %s: %v", filePath, err)
}
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
return output.ErrValidation("attachment file %s exceeds 2GB limit", filePath)
}
if !fileInfo.Mode().IsRegular() {
return output.ErrValidation("attachment file %s is not a regular file", filePath)
}
sizeMap[filePath] = fileInfo.Size()
}
// Step 3: 并行上传,构建路径 → 附件结果映射
fmt.Fprintf(runtime.IO().ErrOut, "Uploading %d unique attachment(s)...\n", len(allPaths))
resultMap, err := uploadAttachmentsParallel(runtime, allPaths, baseFormAttachmentUploadTarget(baseToken, runtime.Str("share-token")), sizeMap)
if err != nil {
return err
}
// Step 4: 根据共享结果映射,按字段组装单元格
for fieldName, filePaths := range attachmentMap {
cell := make([]interface{}, 0, len(filePaths))
for _, p := range filePaths {
if att, ok := resultMap[p]; ok {
cell = append(cell, att)
}
}
fields[fieldName] = cell
}
fmt.Fprintf(runtime.IO().ErrOut, "Uploaded %d unique file(s) into %d field(s)\n", len(resultMap), len(attachmentMap))
}
body := buildFormSubmitBody(runtime, fields)
data, err := baseV3Call(runtime, "POST",
baseV3Path("bases", "tables", "forms", "submit"),
nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
}
// collectUniquePaths 收集所有字段中的文件路径,返回去重后的有序列表。
func collectUniquePaths(attachmentMap map[string][]string) []string {
seen := make(map[string]bool, len(attachmentMap)*4)
var order []string
for _, filePaths := range attachmentMap {
for _, p := range filePaths {
if !seen[p] {
seen[p] = true
order = append(order, p)
}
}
}
return order
}
func baseFormAttachmentUploadTarget(baseToken, shareToken string) baseAttachmentUploadTarget {
return baseAttachmentUploadTarget{
ParentType: baseFormAttachmentParentType,
ParentNode: baseToken,
Extra: baseFormAttachmentExtra(shareToken),
}
}
func baseFormAttachmentExtra(shareToken string) string {
extra, err := json.Marshal(map[string]string{"share_token": shareToken})
if err != nil {
return ""
}
return string(extra)
}
// uploadAttachmentsParallel 并发上传文件,返回路径 → 附件对象的映射。
func uploadAttachmentsParallel(runtime *common.RuntimeContext, paths []string, target baseAttachmentUploadTarget, sizeMap map[string]int64) (map[string]interface{}, error) {
var (
mu sync.Mutex
resultMap = make(map[string]interface{}, len(paths))
)
g, _ := errgroup.WithContext(runtime.Ctx())
g.SetLimit(uploadAttachConcurrency) // 限制并发数
for _, filePath := range paths {
fp := filePath // 捕获循环变量
g.Go(func() error {
fileName := filepath.Base(fp)
fmt.Fprintf(runtime.IO().ErrOut, " Uploading: %s\n", fileName)
att, err := uploadSingleAttachment(runtime, fp, fileName, sizeMap[fp], target)
if err != nil {
return err
}
mu.Lock()
resultMap[fp] = att
mu.Unlock()
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return resultMap, nil
}
// uploadSingleAttachment 上传单个文件,返回附件单元格项。
// 前置条件:文件已通过校验(存在、常规文件、大小在限制内)。
func uploadSingleAttachment(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, target baseAttachmentUploadTarget) (interface{}, error) {
att, err := uploadAttachmentToBase(runtime, filePath, fileName, fileSize, target)
if err != nil {
return nil, fmt.Errorf("failed to upload attachment %s: %w", filePath, err)
}
return att, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -31,10 +31,17 @@ import (
const (
baseAttachmentUploadMaxFileSize int64 = 2 * 1024 * 1024 * 1024
baseAttachmentParentType = "bitable_file"
baseFormAttachmentParentType = "bitable_tmp_point"
baseAttachmentMaxBatchSize = 50
baseAttachmentGetMaxRecords = 10
)
type baseAttachmentUploadTarget struct {
ParentType string
ParentNode string
Extra string
}
var BaseRecordUploadAttachment = common.Shortcut{
Service: "base",
Command: "+record-upload-attachment",
@@ -278,7 +285,10 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
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())
attachment, err := uploadAttachmentToBase(runtime, filePath, fileName, fileInfo.Size(), baseAttachmentUploadTarget{
ParentType: baseAttachmentParentType,
ParentNode: runtime.Str("base-token"),
})
if err != nil {
return err
}
@@ -459,31 +469,33 @@ func fetchBaseAttachments(runtime *common.RuntimeContext, baseToken, tableIDValu
return attachments, nil
}
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) {
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, target baseAttachmentUploadTarget) (map[string]interface{}, error) {
mimeType, err := detectAttachmentMIMEType(runtime.FileIO(), filePath, fileName)
if err != nil {
return nil, err
}
parentNode := baseToken
var (
fileToken string
)
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
parentNode := target.ParentNode
fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: baseAttachmentParentType,
ParentType: target.ParentType,
ParentNode: &parentNode,
Extra: target.Extra,
})
} else {
fileToken, err = common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: baseAttachmentParentType,
ParentNode: parentNode,
ParentType: target.ParentType,
ParentNode: target.ParentNode,
Extra: target.Extra,
})
}
if err != nil {

View File

@@ -70,10 +70,12 @@ func Shortcuts() []common.Shortcut {
BaseFormsList,
BaseFormUpdate,
BaseFormGet,
BaseFormDetail,
BaseFormQuestionsCreate,
BaseFormQuestionsDelete,
BaseFormQuestionsUpdate,
BaseFormQuestionsList,
BaseFormSubmit,
BaseDashboardList,
BaseDashboardGet,
BaseDashboardCreate,

View File

@@ -3,29 +3,61 @@
package common
// FetchDriveMetaTitle looks up the document title via the drive metas batch_query API.
func FetchDriveMetaTitle(runtime *RuntimeContext, token, docType string) (string, error) {
// DriveMeta is the subset of drive metas/batch_query fields used by shortcuts.
type DriveMeta struct {
Title string
URL string
}
// FetchDriveMeta looks up document metadata via the drive metas batch_query API.
func FetchDriveMeta(runtime *RuntimeContext, token, docType string, withURL bool) (DriveMeta, error) {
body := map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": token,
"doc_type": docType,
},
},
}
if withURL {
body["with_url"] = true
}
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,
},
},
},
body,
)
if err != nil {
return "", err
return DriveMeta{}, err
}
metas := GetSlice(data, "metas")
if len(metas) == 0 {
return "", nil
return DriveMeta{}, nil
}
meta, _ := metas[0].(map[string]interface{})
return GetString(meta, "title"), nil
return DriveMeta{
Title: GetString(meta, "title"),
URL: GetString(meta, "url"),
}, nil
}
// FetchDriveMetaTitle looks up the document title via the drive metas batch_query API.
func FetchDriveMetaTitle(runtime *RuntimeContext, token, docType string) (string, error) {
meta, err := FetchDriveMeta(runtime, token, docType, false)
if err != nil {
return "", err
}
return meta.Title, nil
}
// FetchDriveMetaURL looks up the document access URL via the drive metas batch_query API.
func FetchDriveMetaURL(runtime *RuntimeContext, token, docType string) (string, error) {
meta, err := FetchDriveMeta(runtime, token, docType, true)
if err != nil {
return "", err
}
return meta.URL, nil
}

View File

@@ -5,6 +5,7 @@ package common
import (
"context"
"encoding/json"
"fmt"
"sync/atomic"
"testing"
@@ -105,6 +106,44 @@ func TestFetchDriveMetaTitle(t *testing.T) {
})
}
func TestFetchDriveMetaURL(t *testing.T) {
runtime, reg := newDriveMetaTestRuntime(t)
stub := &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": "boxcnABC",
"doc_type": "file",
"title": "report.pdf",
"url": "https://tenant.example.com/file/boxcnABC",
},
},
},
},
}
reg.Register(stub)
got, err := FetchDriveMetaURL(runtime, "boxcnABC", "file")
if err != nil {
t.Fatalf("FetchDriveMetaURL() error: %v", err)
}
if got != "https://tenant.example.com/file/boxcnABC" {
t.Fatalf("url = %q, want tenant URL", got)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode captured body: %v", err)
}
if body["with_url"] != true {
t.Fatalf("with_url = %#v, want true", body["with_url"])
}
}
func newDriveMetaTestRuntime(t *testing.T) (*RuntimeContext, *httpmock.Registry) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())

View File

@@ -4,9 +4,12 @@
package common
import (
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/validate"
)
@@ -34,6 +37,7 @@ func AutoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourc
PermissionGrantSkipped,
"",
fmt.Sprintf("The operation did not return a permission target (missing token/type), so current user %s was not granted. You can retry later or continue using bot identity.", permissionGrantPermMessage()),
"No permission target (missing token or type) returned by the operation.",
)
}
@@ -43,11 +47,14 @@ func AutoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourc
func autoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourceType string) map[string]interface{} {
userOpenID := strings.TrimSpace(runtime.UserOpenId())
if userOpenID == "" {
return buildPermissionGrantResult(
result := buildPermissionGrantResult(
PermissionGrantSkipped,
"",
fmt.Sprintf("Resource was created with bot identity, but no current CLI user open_id is configured, so current user %s was not granted. You can retry later or continue using bot identity.", permissionGrantPermMessage()),
"No current user identity (not logged in or session expired).",
)
fmt.Fprintf(runtime.IO().ErrOut, "Warning: resource was created with bot identity, but no current user open_id is configured, so auto-grant was skipped. Run `lark-cli auth login` and retry, or grant permission manually.\n")
return result
}
body := map[string]interface{}{
@@ -70,21 +77,32 @@ func autoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourc
body,
)
if err != nil {
return buildPermissionGrantResult(
errMsg := compactPermissionGrantError(err)
result := buildPermissionGrantResult(
PermissionGrantFailed,
userOpenID,
fmt.Sprintf("Resource was created, but granting current user %s failed: %s. You can retry later or continue using bot identity.", permissionGrantPermMessage(), compactPermissionGrantError(err)),
fmt.Sprintf("Resource was created, but granting current user %s failed: %s. You can retry later or continue using bot identity.", permissionGrantPermMessage(), errMsg),
fmt.Sprintf("Auto-grant failed: %s. The app may lack the required scope or the resource restricts permission changes.", errMsg),
)
// Best-effort: when the underlying error is a structured permission
// ExitError (lark code 99991672/99991679), surface lark_code,
// required_scope and console_url so agents can guide users straight
// to the dev console. Overrides the generic hint with a more
// actionable one when console_url is available.
annotateGrantPermissionError(runtime, result, err)
fmt.Fprintf(runtime.IO().ErrOut, "Warning: resource was created, but auto-grant failed: %s. Retry later or grant permission manually.\n", errMsg)
return result
}
return buildPermissionGrantResult(
PermissionGrantGranted,
userOpenID,
fmt.Sprintf("Granted the current CLI user %s on the new %s.", permissionGrantPermMessage(), permissionTargetLabel(resourceType)),
"",
)
}
func buildPermissionGrantResult(status, userOpenID, message string) map[string]interface{} {
func buildPermissionGrantResult(status, userOpenID, message, reason string) map[string]interface{} {
result := map[string]interface{}{
"status": status,
"perm": permissionGrantPerm,
@@ -94,6 +112,11 @@ func buildPermissionGrantResult(status, userOpenID, message string) map[string]i
result["user_open_id"] = userOpenID
result["member_type"] = "openid"
}
if status == PermissionGrantSkipped {
result["hint"] = reason + " Run `lark-cli auth login` and retry, or grant permission manually via the Lark document UI."
} else if status == PermissionGrantFailed {
result["hint"] = reason + " Retry later or grant permission manually via the Lark document UI."
}
return result
}
@@ -137,3 +160,54 @@ func compactPermissionGrantError(err error) string {
}
return strings.Join(strings.Fields(err.Error()), " ")
}
// annotateGrantPermissionError enriches a failed permission_grant result with
// structured fields (lark_code / required_scope / console_url) when the
// underlying error is a permission-class *output.ExitError. The CLI's main
// permission-error path (cmd/root.go::enrichPermissionError) handles the same
// case for top-level failures; this helper covers best-effort sub-calls whose
// error is folded into a result map instead of propagated as ExitError.
//
// When console_url is available, the existing generic hint is overridden with
// a more actionable one pointing at the developer console — that's the
// concrete next step a user can take.
func annotateGrantPermissionError(runtime *RuntimeContext, result map[string]interface{}, err error) {
if runtime == nil || result == nil || err == nil {
return
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return
}
if exitErr.Detail.Type != "permission" {
return
}
if exitErr.Detail.Code != 0 {
result["lark_code"] = exitErr.Detail.Code
}
scopes := registry.ExtractRequiredScopes(exitErr.Detail.Detail)
if len(scopes) == 0 {
return
}
recommended := registry.SelectRecommendedScopeFromStrings(scopes, "tenant")
if recommended == "" {
return
}
result["required_scope"] = recommended
if runtime.Config == nil || runtime.Config.AppID == "" {
return
}
consoleURL := registry.BuildConsoleScopeURL(runtime.Config.Brand, runtime.Config.AppID, recommended)
if consoleURL == "" {
return
}
result["console_url"] = consoleURL
// Override the generic hint: pointing at the dev console is more actionable
// than the generic "retry later" fallback set by buildPermissionGrantResult.
result["hint"] = fmt.Sprintf(
"App is missing the %q scope; enable it in the developer console (see console_url), then retry.",
recommended,
)
}

View File

@@ -0,0 +1,311 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestAutoGrantStderrWarning_SkippedNoUser(t *testing.T) {
config := &core.CliConfig{
AppID: "perm-grant-test-skip",
AppSecret: "perm-grant-test-secret-skip",
Brand: core.BrandFeishu,
}
f, _, stderr, _ := cmdutil.TestFactory(t, config)
ctx := cmdutil.ContextWithShortcut(context.Background(), "test:shortcut", "exec-1")
runtime := &RuntimeContext{
ctx: ctx,
Config: config,
Factory: f,
resolvedAs: core.AsBot,
}
result := AutoGrantCurrentUserDrivePermission(runtime, "tkn_doc", "docx")
if result == nil {
t.Fatal("expected non-nil result for bot mode with empty user open_id")
}
if result["status"] != PermissionGrantSkipped {
t.Fatalf("status = %v, want %q", result["status"], PermissionGrantSkipped)
}
if !strings.Contains(stderr.String(), "auto-grant was skipped") {
t.Fatalf("stderr missing auto-grant skipped warning; got:\n%s", stderr.String())
}
if hint, ok := result["hint"].(string); !ok || !strings.Contains(hint, "auth login") {
t.Fatalf("hint = %#v, want string containing 'auth login'", result["hint"])
}
if hint, ok := result["hint"].(string); !ok || !strings.Contains(hint, "not logged in") {
t.Fatalf("hint = %#v, want string containing 'not logged in'", result["hint"])
}
}
func TestAutoGrantStderrWarning_GrantFailed(t *testing.T) {
config := &core.CliConfig{
AppID: "perm-grant-test-fail",
AppSecret: "perm-grant-test-secret-fail",
Brand: core.BrandFeishu,
UserOpenId: "ou_test_user",
}
f, _, stderr, reg := cmdutil.TestFactory(t, config)
// Register a stub that returns an error code so CallAPI returns an error.
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/tkn_doc/members",
Body: map[string]interface{}{
"code": 230001,
"msg": "no permission",
},
})
ctx := cmdutil.ContextWithShortcut(context.Background(), "test:shortcut", "exec-2")
runtime := &RuntimeContext{
ctx: ctx,
Config: config,
Factory: f,
resolvedAs: core.AsBot,
}
result := AutoGrantCurrentUserDrivePermission(runtime, "tkn_doc", "docx")
if result == nil {
t.Fatal("expected non-nil result for bot mode with grant failure")
}
if result["status"] != PermissionGrantFailed {
t.Fatalf("status = %v, want %q", result["status"], PermissionGrantFailed)
}
if !strings.Contains(stderr.String(), "auto-grant failed") {
t.Fatalf("stderr missing auto-grant failed warning; got:\n%s", stderr.String())
}
if hint, ok := result["hint"].(string); !ok || !strings.Contains(hint, "Retry later") {
t.Fatalf("hint = %#v, want string containing 'Retry later'", result["hint"])
}
if hint, ok := result["hint"].(string); !ok || !strings.Contains(hint, "scope") {
t.Fatalf("hint = %#v, want string containing 'scope'", result["hint"])
}
if hint, ok := result["hint"].(string); !ok || !strings.Contains(hint, "permission changes") {
t.Fatalf("hint = %#v, want string containing 'permission changes'", result["hint"])
}
}
// ── annotateGrantPermissionError unit tests ────────────────────────────────
func newAnnotateRuntime(brand core.LarkBrand, appID string) *RuntimeContext {
return &RuntimeContext{
Config: &core.CliConfig{
AppID: appID,
Brand: brand,
},
}
}
// permission_violations subjects must surface as required_scope, and the
// console_url must be brand-specific. The hint should be overridden to point
// at the developer console.
func TestAnnotateGrantPermissionError_AppScopeNotEnabled(t *testing.T) {
rt := newAnnotateRuntime(core.BrandFeishu, "cli_demo")
result := map[string]interface{}{
"hint": "generic fallback hint",
}
err := output.ErrAPI(99991672, "Permission denied [99991672]", map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "docs:permission.member:create"},
},
})
annotateGrantPermissionError(rt, result, err)
if got := result["lark_code"]; got != 99991672 {
t.Errorf("expected lark_code=99991672, got %v", got)
}
if got, _ := result["required_scope"].(string); got != "docs:permission.member:create" {
t.Errorf("required_scope mismatch: got %v", got)
}
consoleURL, _ := result["console_url"].(string)
if !strings.HasPrefix(consoleURL, "https://open.feishu.cn/page/scope-apply") {
t.Errorf("console_url should target open.feishu.cn, got %s", consoleURL)
}
if !strings.Contains(consoleURL, "clientID=cli_demo") {
t.Errorf("console_url missing clientID, got %s", consoleURL)
}
hint, _ := result["hint"].(string)
if !strings.Contains(hint, "console_url") {
t.Errorf("hint should reference console_url, got %s", hint)
}
if !strings.Contains(hint, "docs:permission.member:create") {
t.Errorf("hint should mention required scope, got %s", hint)
}
}
func TestAnnotateGrantPermissionError_LarkBrand(t *testing.T) {
rt := newAnnotateRuntime(core.BrandLark, "cli_demo")
result := map[string]interface{}{}
err := output.ErrAPI(99991679, "Permission denied [99991679]", map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "docs:permission.member:create"},
},
})
annotateGrantPermissionError(rt, result, err)
if u, _ := result["console_url"].(string); !strings.Contains(u, "open.larksuite.com") {
t.Errorf("lark brand should yield larksuite host, got %s", u)
}
}
// Non-permission errors (network, validation, plain errors) must not be
// annotated — keep the existing generic hint untouched.
func TestAnnotateGrantPermissionError_NonPermissionErrorNoOp(t *testing.T) {
rt := newAnnotateRuntime(core.BrandFeishu, "cli_demo")
cases := []error{
errors.New("plain error"),
output.ErrNetwork("connection reset"),
output.ErrValidation("bad request"),
// Non-permission API errors (e.g. 230001) — type is "api_error" not "permission"
output.ErrAPI(230001, "no permission", map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "docs:doc"},
},
}),
}
for i, e := range cases {
result := map[string]interface{}{
"hint": "untouched hint",
}
annotateGrantPermissionError(rt, result, e)
if _, ok := result["lark_code"]; ok {
t.Errorf("case %d: expected no lark_code, got %v", i, result["lark_code"])
}
if _, ok := result["console_url"]; ok {
t.Errorf("case %d: expected no console_url, got %v", i, result["console_url"])
}
if got, _ := result["hint"].(string); got != "untouched hint" {
t.Errorf("case %d: hint should be unchanged, got %s", i, got)
}
}
}
// permission_violations missing → only lark_code is annotated; no console_url
// and the existing hint stays as-is (caller's generic fallback wins).
func TestAnnotateGrantPermissionError_NoViolations(t *testing.T) {
rt := newAnnotateRuntime(core.BrandFeishu, "cli_demo")
result := map[string]interface{}{
"hint": "untouched fallback",
}
err := output.ErrAPI(99991672, "Permission denied [99991672]", nil)
annotateGrantPermissionError(rt, result, err)
if got := result["lark_code"]; got != 99991672 {
t.Errorf("expected lark_code captured, got %v", got)
}
if _, ok := result["console_url"]; ok {
t.Errorf("console_url must not be set when violations are absent")
}
if got, _ := result["hint"].(string); got != "untouched fallback" {
t.Errorf("hint should remain fallback when no console_url, got %s", got)
}
}
// AppID empty → no console_url even when violations exist.
func TestAnnotateGrantPermissionError_EmptyAppID(t *testing.T) {
rt := newAnnotateRuntime(core.BrandFeishu, "")
result := map[string]interface{}{}
err := output.ErrAPI(99991672, "Permission denied", map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "docs:doc"},
},
})
annotateGrantPermissionError(rt, result, err)
if _, ok := result["console_url"]; ok {
t.Errorf("console_url must not be set when appID is empty")
}
if got, _ := result["required_scope"].(string); got != "docs:doc" {
t.Errorf("required_scope should still be set when appID is empty, got %s", got)
}
}
// Defensive: nil/empty arguments must be safe no-ops.
func TestAnnotateGrantPermissionError_NilArgsSafe(t *testing.T) {
rt := newAnnotateRuntime(core.BrandFeishu, "cli_demo")
annotateGrantPermissionError(nil, map[string]interface{}{}, nil)
annotateGrantPermissionError(rt, nil, nil)
annotateGrantPermissionError(rt, map[string]interface{}{}, nil)
annotateGrantPermissionError(rt, map[string]interface{}{}, errors.New(""))
}
// Integration-style: end-to-end through AutoGrantCurrentUserDrivePermission
// with a mocked 99991672 response — verifies the annotated fields show up
// in the JSON result that callers downstream consume.
func TestAutoGrantStderrWarning_GrantFailed_AppScopeNotEnabled_Annotated(t *testing.T) {
config := &core.CliConfig{
AppID: "cli_app_demo",
AppSecret: "secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_test_user",
}
f, _, _, reg := cmdutil.TestFactory(t, config)
// Stub the permission member create endpoint with a 99991672 response that
// includes permission_violations — what the platform returns when the app
// has not enabled the API scope.
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/tkn_doc/members",
Body: map[string]interface{}{
"code": 99991672,
"msg": "App scope not enabled",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "docs:permission.member:create"},
},
},
},
})
ctx := cmdutil.ContextWithShortcut(context.Background(), "test:shortcut", "exec-3")
runtime := &RuntimeContext{
ctx: ctx,
Config: config,
Factory: f,
resolvedAs: core.AsBot,
}
result := AutoGrantCurrentUserDrivePermission(runtime, "tkn_doc", "docx")
if result == nil {
t.Fatal("expected non-nil result")
}
if result["status"] != PermissionGrantFailed {
t.Fatalf("status = %v, want failed", result["status"])
}
if result["lark_code"] != 99991672 {
t.Errorf("lark_code = %v, want 99991672", result["lark_code"])
}
if got, _ := result["required_scope"].(string); got != "docs:permission.member:create" {
t.Errorf("required_scope = %v, want docs:permission.member:create", got)
}
consoleURL, _ := result["console_url"].(string)
if !strings.Contains(consoleURL, "open.feishu.cn/page/scope-apply") {
t.Errorf("console_url missing or wrong host: %s", consoleURL)
}
if !strings.Contains(consoleURL, "scopes=docs%3Apermission.member%3Acreate") {
t.Errorf("console_url missing escaped scope: %s", consoleURL)
}
hint, _ := result["hint"].(string)
if !strings.Contains(hint, "console_url") {
t.Errorf("hint should be overridden to mention console_url, got %s", hint)
}
}

View File

@@ -176,7 +176,11 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
if firstErrCode != 0 {
return nil, output.ErrAPI(firstErrCode, msg, "")
}
return nil, output.ErrWithHint(output.ExitInternal, "fanout", msg, "")
// No structured API code — the failure was transport, parse, panic, or
// cancellation. Suggest the actionable next step rather than shipping
// an empty hint that would leave the calling agent with nothing to do.
return nil, output.ErrWithHint(output.ExitInternal, "fanout", msg,
"retry the command; if it persists, narrow --queries to a single term to isolate the failing input")
}
return out, nil
}

View File

@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
@@ -18,6 +19,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"
)
@@ -1133,6 +1135,33 @@ func TestFanoutAssemble_AllFailed_ReturnsError(t *testing.T) {
}
}
// When all queries fail with no structured Lark API code (transport, parse,
// panic, ctx-canceled), the returned ExitError must carry an actionable
// hint so the calling agent has a next step to try instead of giving up.
func TestFanoutAssemble_AllFailed_NoCode_HasActionableHint(t *testing.T) {
results := []fanoutResult{
{Index: 0, Query: "alice", ErrMsg: "transport: connection refused"},
{Index: 1, Query: "bob", ErrMsg: "transport: timeout"},
}
_, err := buildFanoutResponse([]string{"alice", "bob"}, results)
if err == nil {
t.Fatalf("expected error when all queries failed")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail == nil {
t.Fatalf("expected Detail, got nil")
}
if exitErr.Detail.Hint == "" {
t.Errorf("expected non-empty Hint so agents have a next step; got empty")
}
if !strings.Contains(exitErr.Detail.Hint, "retry") {
t.Errorf("hint should suggest retry as the first action; got %q", exitErr.Detail.Hint)
}
}
// Codes from the first failure must propagate through output.ErrAPI so the
// CLI's exit-code classifier sees the real signal (e.g., 99991663 rate limit)
// instead of 0, which would mean "success" in the Lark protocol.

View File

@@ -80,7 +80,7 @@ func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
func TestDocsCreateV2BotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
f, stdout, stderr, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
@@ -107,6 +107,9 @@ func TestDocsCreateV2BotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
if _, ok := grant["user_open_id"]; ok {
t.Fatalf("did not expect user_open_id when current user is missing: %#v", grant)
}
if !strings.Contains(stderr.String(), "auto-grant was skipped") {
t.Fatalf("stderr missing auto-grant skipped warning; got:\n%s", stderr.String())
}
}
func TestDocsCreateV2UserSkipsPermissionGrantAugmentation(t *testing.T) {
@@ -140,7 +143,7 @@ func TestDocsCreateV2UserSkipsPermissionGrantAugmentation(t *testing.T) {
func TestDocsCreateV2BotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
f, stdout, stderr, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
@@ -180,6 +183,9 @@ func TestDocsCreateV2BotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
if !strings.Contains(grant["message"].(string), "retry later") {
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
}
if !strings.Contains(stderr.String(), "auto-grant failed") {
t.Fatalf("stderr missing auto-grant failed warning; got:\n%s", stderr.String())
}
}
func TestDocsCreateV2FallbackURLWhenBackendOmitsIt(t *testing.T) {

View File

@@ -6,6 +6,7 @@ package doc
import (
"context"
"fmt"
"regexp"
"strings"
"github.com/spf13/cobra"
@@ -168,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 != "" {
@@ -205,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

@@ -83,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{}{
@@ -129,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

@@ -47,6 +47,34 @@ const defaultLocateDocLimit = 10
// with `drive file.comments create_v2` against a fresh docx.
const maxCommentTotalRunes = 10000
// The file comment API treats supported Drive file comments as full-file
// comments in the UI, but currently rejects an empty anchor.block_id for file
// targets. TODO: remove this placeholder after the API accepts omitting
// anchor.block_id for file full comments.
const fileFullCommentAnchorBlockID = "test"
// File comments are enabled only for extensions verified to render correctly in
// the Lark file preview comment UI. Keep this list conservative: PDF, docx, and
// xlsx currently accept the API request but display poorly in the page.
var supportedFileCommentExtensions = []string{
".md",
".txt",
".json",
".csv",
".go",
".js",
".py",
".pptx",
".png",
".jpg",
".jpeg",
".zip",
".mp3",
".mp4",
}
var supportedFileCommentExtensionSet = newSupportedFileCommentExtensionSet(supportedFileCommentExtensions)
type commentDocRef struct {
Kind string
Token string
@@ -93,17 +121,18 @@ const (
var DriveAddComment = common.Shortcut{
Service: "drive",
Command: "+add-comment",
Description: "Add a comment to doc/docx/sheet/slides, also supports wiki URL resolving to doc/docx/sheet/slides",
Description: "Add a comment to doc/docx/file/sheet/slides; file targets support selected extensions and full comments only",
Risk: "write",
Scopes: []string{
"drive:drive.metadata:readonly",
"docx:document:readonly",
"docs:document.comment:create",
"docs:document.comment:write_only",
},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "doc", Desc: "document URL/token, sheet/slides URL, or wiki URL that resolves to doc/docx/sheet/slides", Required: true},
{Name: "type", Desc: "document type: doc, docx, sheet, slides (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "sheet", "slides"}},
{Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides URL, or wiki URL that resolves to doc/docx/file/sheet/slides", Required: true},
{Name: "type", Desc: "document type: doc, docx, file, sheet, slides (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "file", "sheet", "slides"}},
{Name: "content", Desc: "reply_elements JSON string", Required: true},
{Name: "full-comment", Type: "bool", Desc: "create a full-document comment; also the default when no location is provided"},
{Name: "selection-with-ellipsis", Desc: "target content locator (plain text or 'start...end')"},
@@ -145,7 +174,6 @@ var DriveAddComment = common.Shortcut{
}
return nil
}
selection := runtime.Str("selection-with-ellipsis")
blockID := strings.TrimSpace(runtime.Str("block-id"))
if strings.TrimSpace(selection) != "" && blockID != "" {
@@ -156,6 +184,9 @@ var DriveAddComment = common.Shortcut{
}
mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID)
if docRef.Kind == "file" {
return validateFileCommentMode(mode, "")
}
if mode == commentModeLocal && docRef.Kind == "doc" {
return output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments")
}
@@ -217,6 +248,33 @@ var DriveAddComment = common.Shortcut{
Body(commentBody).
Set("file_token", resolvedToken)
}
if resolvedKind == "file" {
commentBody := buildCommentCreateV2Request("file", "", "", replyElements, nil)
desc := "2-step orchestration: verify supported file metadata -> create file comment"
verifyStep := "[1]"
createStep := "[2]"
if isWiki {
desc = "3-step orchestration: resolve wiki -> verify supported file metadata -> create file comment"
verifyStep = "[2]"
createStep = "[3]"
}
return common.NewDryRunAPI().
Desc(desc).
POST("/open-apis/drive/v1/metas/batch_query").
Desc(verifyStep+" Read file metadata and verify the title extension is supported").
Body(map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": resolvedToken,
"doc_type": "file",
},
},
}).
POST("/open-apis/drive/v1/files/:file_token/new_comments").
Desc(createStep+" Create file full comment").
Body(commentBody).
Set("file_token", resolvedToken)
}
// Doc/docx comment dry-run.
createPath := "/open-apis/drive/v1/files/:file_token/new_comments"
@@ -317,6 +375,9 @@ var DriveAddComment = common.Shortcut{
if target.FileType == "slides" {
return executeSlidesComment(runtime, commentDocRef{Kind: "slides", Token: target.FileToken})
}
if target.FileType == "file" {
return executeFileComment(runtime, target)
}
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
if err != nil {
@@ -421,6 +482,9 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
if token, ok := extractURLToken(raw, "/sheets/"); ok {
return commentDocRef{Kind: "sheet", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/file/"); ok {
return commentDocRef{Kind: "file", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/slides/"); ok {
return commentDocRef{Kind: "slides", Token: token}, nil
}
@@ -431,7 +495,7 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
return commentDocRef{Kind: "doc", Token: token}, nil
}
if strings.Contains(raw, "://") {
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/sheet/slides", raw)
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx/file/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides", raw)
}
if strings.ContainsAny(raw, "/?#") {
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a token with --type, or a wiki URL", raw)
@@ -440,7 +504,7 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
// Bare token: --type is required.
docType = strings.TrimSpace(docType)
if docType == "" {
return commentDocRef{}, output.ErrValidation("--type is required when --doc is a bare token (allowed values: doc, docx, sheet, slides)")
return commentDocRef{}, output.ErrValidation("--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides)")
}
return commentDocRef{Kind: docType, Token: raw}, nil
}
@@ -451,9 +515,16 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
return resolvedCommentTarget{}, err
}
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "sheet" || docRef.Kind == "slides" {
if mode == commentModeLocal && docRef.Kind != "docx" && docRef.Kind != "sheet" && docRef.Kind != "slides" {
return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments")
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "file" || docRef.Kind == "sheet" || docRef.Kind == "slides" {
if mode == commentModeLocal {
switch docRef.Kind {
case "doc":
return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments")
case "file":
if err := validateFileCommentMode(mode, ""); err != nil {
return resolvedCommentTarget{}, err
}
}
}
return resolvedCommentTarget{
DocID: docRef.Token,
@@ -507,11 +578,24 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
WikiToken: docRef.Token,
}, nil
}
if objType == "file" {
if err := validateFileCommentMode(mode, objType); err != nil {
return resolvedCommentTarget{}, err
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
return resolvedCommentTarget{
DocID: objToken,
FileToken: objToken,
FileType: "file",
ResolvedBy: "wiki",
WikiToken: docRef.Token,
}, nil
}
if mode == commentModeLocal && objType != "docx" {
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but local comments only support docx, sheet, and slides; for sheet use --block-id <sheetId>!<cell>, for slides use --block-id <slide-block-type>!<xml-id>", objType)
}
if mode == commentModeFull && objType != "docx" && objType != "doc" {
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/sheet/slides", objType)
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/file/sheet/slides", objType)
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
@@ -718,6 +802,10 @@ func buildCommentCreateV2Request(fileType, blockID, slideBlockType string, reply
"sheet_col": sheet.Col,
"sheet_row": sheet.Row,
}
} else if fileType == "file" {
body["anchor"] = map[string]interface{}{
"block_id": fileFullCommentAnchorBlockID,
}
} else if strings.TrimSpace(blockID) != "" {
body["anchor"] = map[string]interface{}{
"block_id": blockID,
@@ -809,6 +897,107 @@ func parseSheetCellRef(input string) (*sheetAnchor, error) {
return &sheetAnchor{SheetID: sheetID, Col: col, Row: row}, nil
}
func fetchCommentTargetFileTitle(runtime *common.RuntimeContext, fileToken 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": fileToken,
"doc_type": "file",
},
},
},
)
if err != nil {
return "", err
}
metas := common.GetSlice(data, "metas")
if len(metas) == 0 {
return "", output.Errorf(output.ExitAPI, "api_error", "drive metas.batch_query returned no metadata for file %s", common.MaskToken(fileToken))
}
meta, ok := metas[0].(map[string]interface{})
if !ok {
return "", output.Errorf(output.ExitAPI, "api_error", "drive metas.batch_query returned unexpected metadata format for file %s", common.MaskToken(fileToken))
}
return common.GetString(meta, "title"), nil
}
func ensureSupportedFileCommentTarget(runtime *common.RuntimeContext, fileToken string) (string, string, error) {
title, err := fetchCommentTargetFileTitle(runtime, fileToken)
if err != nil {
return "", "", err
}
extension := fileCommentExtension(title)
if isSupportedFileCommentExtension(extension) {
return title, extension, nil
}
if strings.TrimSpace(title) == "" {
return "", "", output.ErrWithHint(
output.ExitValidation,
"unsupported_file_comment_type",
"drive +add-comment does not support comments for this Drive file type yet; the file metadata did not return a title",
"file comments currently support full comments only for these extensions: "+supportedFileCommentExtensionsText(),
)
}
extensionLabel := extension
if extensionLabel == "" {
extensionLabel = "no extension"
}
return "", "", output.ErrWithHint(
output.ExitValidation,
"unsupported_file_comment_type",
fmt.Sprintf("drive +add-comment does not support comments for this Drive file type yet; got %q (%s)", title, extensionLabel),
"file comments currently support full comments only for these extensions: "+supportedFileCommentExtensionsText(),
)
}
func fileCommentExtension(title string) string {
title = strings.TrimSpace(title)
idx := strings.LastIndex(title, ".")
if idx == 0 {
extension := strings.ToLower(title)
if isSupportedFileCommentExtension(extension) {
return extension
}
return ""
}
if idx < 0 || idx == len(title)-1 {
return ""
}
return strings.ToLower(title[idx:])
}
func isSupportedFileCommentExtension(extension string) bool {
_, ok := supportedFileCommentExtensionSet[strings.TrimSpace(extension)]
return ok
}
func supportedFileCommentExtensionsText() string {
return strings.Join(supportedFileCommentExtensions, ", ")
}
func newSupportedFileCommentExtensionSet(extensions []string) map[string]struct{} {
set := make(map[string]struct{}, len(extensions))
for _, extension := range extensions {
set[extension] = struct{}{}
}
return set
}
func validateFileCommentMode(mode commentMode, resolvedObjType string) error {
if mode != commentModeLocal {
return nil
}
if resolvedObjType != "" {
return output.ErrValidation("wiki resolved to %q, but file comments only support full comments; omit --block-id and --selection-with-ellipsis", resolvedObjType)
}
return output.ErrValidation("file comments only support full comments; omit --block-id and --selection-with-ellipsis")
}
func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) error {
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
if err != nil {
@@ -849,6 +1038,48 @@ func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) e
return nil
}
func executeFileComment(runtime *common.RuntimeContext, target resolvedCommentTarget) error {
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
if err != nil {
return err
}
title, extension, err := ensureSupportedFileCommentTarget(runtime, target.FileToken)
if err != nil {
return err
}
requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(target.FileToken))
requestBody := buildCommentCreateV2Request("file", "", "", replyElements, nil)
fmt.Fprintf(runtime.IO().ErrOut, "Creating file comment in %s (%s)\n", common.MaskToken(target.FileToken), extension)
data, err := runtime.CallAPI("POST", requestPath, nil, requestBody)
if err != nil {
return err
}
out := map[string]interface{}{
"comment_id": data["comment_id"],
"doc_id": target.DocID,
"file_token": target.FileToken,
"file_type": "file",
"file_name": title,
"file_extension": extension,
"resolved_by": target.ResolvedBy,
"comment_mode": string(commentModeFull),
}
if createdAt := firstPresentValue(data, "created_at", "create_time"); createdAt != nil {
out["created_at"] = createdAt
}
if target.WikiToken != "" {
out["wiki_token"] = target.WikiToken
}
runtime.Out(out, nil)
return nil
}
func executeSlidesComment(runtime *common.RuntimeContext, docRef commentDocRef) error {
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
if err != nil {

View File

@@ -105,6 +105,13 @@ func TestParseCommentDocRef(t *testing.T) {
wantKind: "doc",
wantToken: "docToken",
},
{
name: "raw token with type file",
input: "fileToken",
docType: "file",
wantKind: "file",
wantToken: "fileToken",
},
{
name: "raw token without type",
input: "xxxxxx",
@@ -122,6 +129,12 @@ func TestParseCommentDocRef(t *testing.T) {
wantKind: "slides",
wantToken: "pres_123",
},
{
name: "file url",
input: "https://example.larksuite.com/file/boxcn123?from=share",
wantKind: "file",
wantToken: "boxcn123",
},
{
name: "unsupported url",
input: "https://example.com/not-a-doc",
@@ -545,6 +558,29 @@ func TestBuildCommentCreateV2RequestFull(t *testing.T) {
}
}
func TestBuildCommentCreateV2RequestFile(t *testing.T) {
t.Parallel()
replyElements := []map[string]interface{}{
{
"type": "text",
"text": "README comment",
},
}
got := buildCommentCreateV2Request("file", "", "", replyElements, nil)
if got["file_type"] != "file" {
t.Fatalf("expected file_type file, got %#v", got["file_type"])
}
anchor, ok := got["anchor"].(map[string]interface{})
if !ok {
t.Fatalf("expected anchor map, got %#v", got["anchor"])
}
if blockID, ok := anchor["block_id"].(string); !ok || blockID != fileFullCommentAnchorBlockID {
t.Fatalf("expected file anchor.block_id %q, got %#v", fileFullCommentAnchorBlockID, anchor["block_id"])
}
}
func TestBuildCommentCreateV2RequestLocal(t *testing.T) {
t.Parallel()
@@ -906,6 +942,34 @@ func TestSlidesCommentValidateCompoundBlockID(t *testing.T) {
}
}
func TestFileCommentValidateRejectsBlockID(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/file/fileToken",
"--content", `[{"type":"text","text":"test"}]`,
"--block-id", "blk_123",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "file comments only support full comments") {
t.Fatalf("expected file local-comment rejection, got: %v", err)
}
}
func TestFileCommentValidateRejectsSelectionWithEllipsis(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/file/fileToken",
"--content", `[{"type":"text","text":"test"}]`,
"--selection-with-ellipsis", "something",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "file comments only support full comments") {
t.Fatalf("expected file local-comment rejection, got: %v", err)
}
}
// ── Slides comment execute tests ────────────────────────────────────────────
func TestSlidesCommentExecuteSuccess(t *testing.T) {
@@ -1116,6 +1180,146 @@ func TestSheetCommentViaWikiMissingBlockID(t *testing.T) {
}
}
func TestFileCommentExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"metas": []interface{}{
map[string]interface{}{"title": "README.txt"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/drive/v1/files/fileToken/new_comments",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{"comment_id": "fileComment123", "created_at": 1700000000},
},
})
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/file/fileToken",
"--content", `[{"type":"text","text":"请补充 README 示例"}]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "fileComment123") {
t.Fatalf("stdout missing comment_id: %s", stdout.String())
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "file" {
t.Fatalf("stdout file_type = %q, want file\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, data, "file_name", "data.file_name"); got != "README.txt" {
t.Fatalf("stdout file_name = %q, want README.txt\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, data, "file_extension", "data.file_extension"); got != ".txt" {
t.Fatalf("stdout file_extension = %q, want .txt\nstdout:\n%s", got, stdout.String())
}
}
func TestFileCommentExecuteRejectsUnsupportedFileType(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"metas": []interface{}{
map[string]interface{}{"title": "notes.pdf"},
},
},
},
})
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/file/fileToken",
"--content", `[{"type":"text","text":"test"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "does not support comments for this Drive file type yet") {
t.Fatalf("expected unsupported file comment type error, got: %v", err)
}
if !strings.Contains(err.Error(), "notes.pdf") {
t.Fatalf("expected error to mention unsupported title, got: %v", err)
}
}
func TestFileCommentExecuteRejectsUnexpectedMetadataFormat(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"metas": []interface{}{"unexpected"},
},
},
})
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/file/fileToken",
"--content", `[{"type":"text","text":"test"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "unexpected metadata format") {
t.Fatalf("expected unexpected metadata format error, got: %v", err)
}
}
func TestFileCommentSupportedExtensions(t *testing.T) {
t.Parallel()
supported := []string{
"README.md",
"notes.TXT",
"data.json",
"table.csv",
"main.go",
"app.js",
"script.py",
"slides.pptx",
"image.png",
"photo.jpg",
"photo.jpeg",
".md",
"archive.zip",
"audio.mp3",
"video.mp4",
}
for _, title := range supported {
extension := fileCommentExtension(title)
if !isSupportedFileCommentExtension(extension) {
t.Fatalf("%s extension %q should be supported", title, extension)
}
}
unsupported := []string{
"report.pdf",
"word.docx",
"sheet.xlsx",
"unknown.bin",
"no-extension",
".gitignore",
}
for _, title := range unsupported {
extension := fileCommentExtension(title)
if isSupportedFileCommentExtension(extension) {
t.Fatalf("%s extension %q should not be supported", title, extension)
}
}
if extension := fileCommentExtension(".gitignore"); extension != "" {
t.Fatalf("dotfile extension = %q, want empty", extension)
}
}
// ── DryRun coverage ─────────────────────────────────────────────────────────
func TestDryRunSheetDirectURL(t *testing.T) {
@@ -1346,6 +1550,43 @@ func TestDryRunDocxFullComment(t *testing.T) {
}
}
func TestDryRunFileDirectURL(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/file/fileToken",
"--content", `[{"type":"text","text":"test"}]`,
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "verify supported file metadata") {
t.Fatalf("dry-run output missing supported file metadata verification step: %s", stdout.String())
}
out := decodeJSONMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "api")
if len(api) != 2 {
t.Fatalf("expected 2 dry-run api calls, got %d\nstdout:\n%s", len(api), stdout.String())
}
verifyCall := mustMapValue(t, api[0], "api[0]")
createCall := mustMapValue(t, api[1], "api[1]")
verifyBody := mustMapValue(t, verifyCall["body"], "api[0].body")
createBody := mustMapValue(t, createCall["body"], "api[1].body")
requestDocs := mustSliceValue(t, verifyBody["request_docs"], "api[0].body.request_docs")
requestDoc := mustMapValue(t, requestDocs[0], "api[0].body.request_docs[0]")
if got := mustStringField(t, requestDoc, "doc_type", "api[0].body.request_docs[0].doc_type"); got != "file" {
t.Fatalf("metadata query doc_type = %q, want file\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, createBody, "file_type", "api[1].body.file_type"); got != "file" {
t.Fatalf("comment create file_type = %q, want file\nstdout:\n%s", got, stdout.String())
}
anchor := mustMapValue(t, createBody["anchor"], "api[1].body.anchor")
if got := mustStringField(t, anchor, "block_id", "api[1].body.anchor.block_id"); got != fileFullCommentAnchorBlockID {
t.Fatalf("comment create anchor.block_id = %q, want %q\nstdout:\n%s", got, fileFullCommentAnchorBlockID, stdout.String())
}
}
// ── resolveCommentTarget coverage ───────────────────────────────────────────
func TestResolveWikiToDocxFullComment(t *testing.T) {
@@ -1397,7 +1638,7 @@ func TestResolveWikiToUnsupportedType(t *testing.T) {
"--content", `[{"type":"text","text":"test"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "only support doc/docx/sheet/slides") {
if err == nil || !strings.Contains(err.Error(), "only support doc/docx/file/sheet/slides") {
t.Fatalf("expected unsupported type error, got: %v", 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"
)
@@ -20,18 +21,19 @@ import (
var DriveExport = common.Shortcut{
Service: "drive",
Command: "+export",
Description: "Export a doc/docx/sheet/bitable to a local file with limited polling",
Description: "Export a doc/docx/sheet/bitable/slides to a local file with limited polling",
Risk: "read",
Scopes: []string{
"docs:document.content:read",
"docs:document:export",
"docx:document:readonly",
"drive:drive.metadata:readonly",
},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "token", Desc: "source document token", Required: true},
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable"}},
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base"}},
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable | slides", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable", "slides"}},
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only) | pptx (slides only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx"}},
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
{Name: "file-name", Desc: "preferred output filename (optional)"},
{Name: "output-dir", Default: ".", Desc: "local output directory (default: current directory)"},
@@ -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,23 +102,33 @@ 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
@@ -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

@@ -131,15 +131,15 @@ func validateDriveExportSpec(spec driveExportSpec) error {
}
switch spec.DocType {
case "doc", "docx", "sheet", "bitable":
case "doc", "docx", "sheet", "bitable", "slides":
default:
return output.ErrValidation("invalid --doc-type %q: allowed values are doc, docx, sheet, bitable", spec.DocType)
return output.ErrValidation("invalid --doc-type %q: allowed values are doc, docx, sheet, bitable, slides", spec.DocType)
}
switch spec.FileExtension {
case "docx", "pdf", "xlsx", "csv", "markdown", "base":
case "docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx":
default:
return output.ErrValidation("invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base", spec.FileExtension)
return output.ErrValidation("invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base, pptx", spec.FileExtension)
}
if spec.FileExtension == "markdown" && spec.DocType != "docx" {
@@ -150,6 +150,14 @@ func validateDriveExportSpec(spec driveExportSpec) error {
return output.ErrValidation("--file-extension base only supports --doc-type bitable")
}
if spec.FileExtension == "pptx" && spec.DocType != "slides" {
return output.ErrValidation("--file-extension pptx only supports --doc-type slides")
}
if spec.DocType == "slides" && spec.FileExtension != "pptx" && spec.FileExtension != "pdf" {
return output.ErrValidation("--doc-type slides only supports --file-extension pptx or pdf")
}
if strings.TrimSpace(spec.SubID) != "" {
if spec.FileExtension != "csv" || (spec.DocType != "sheet" && spec.DocType != "bitable") {
return output.ErrValidation("--sub-id is only used when exporting sheet/bitable as csv")
@@ -345,6 +353,8 @@ func exportFileSuffix(fileExtension string) string {
return ".csv"
case "base":
return ".base"
case "pptx":
return ".pptx"
default:
return ""
}

View File

@@ -70,4 +70,10 @@ func TestSanitizeExportFileNameAndEnsureExtension(t *testing.T) {
if got := exportFileSuffix("base"); got != ".base" {
t.Fatalf("exportFileSuffix(base) = %q, want %q", got, ".base")
}
if got := ensureExportFileExtension("report", "pptx"); got != "report.pptx" {
t.Fatalf("ensureExportFileExtension() = %q, want %q", got, "report.pptx")
}
if got := ensureExportFileExtension("report.pptx", "pptx"); got != "report.pptx" {
t.Fatalf("ensureExportFileExtension() should preserve suffix, got %q", got)
}
}

View File

@@ -50,11 +50,34 @@ func TestValidateDriveExportSpec(t *testing.T) {
name: "base bitable ok",
spec: driveExportSpec{Token: "base123", DocType: "bitable", FileExtension: "base"},
},
{
name: "slides pptx ok",
spec: driveExportSpec{Token: "slides123", DocType: "slides", FileExtension: "pptx"},
},
{
name: "slides pdf ok",
spec: driveExportSpec{Token: "slides123", DocType: "slides", FileExtension: "pdf"},
},
{
name: "base non bitable rejected",
spec: driveExportSpec{Token: "sheet123", DocType: "sheet", FileExtension: "base"},
wantErr: "only supports --doc-type bitable",
},
{
name: "pptx non slides rejected",
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "pptx"},
wantErr: "only supports --doc-type slides",
},
{
name: "slides csv rejected",
spec: driveExportSpec{Token: "slides123", DocType: "slides", FileExtension: "csv"},
wantErr: "slides only supports",
},
{
name: "unknown doc type rejected",
spec: driveExportSpec{Token: "docx123", DocType: "unknown", FileExtension: "pdf"},
wantErr: "invalid --doc-type",
},
{
name: "unknown file extension rejected",
spec: driveExportSpec{Token: "docx123", DocType: "docx", FileExtension: "rtf"},
@@ -81,16 +104,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 +144,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 +166,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 +195,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 +224,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 +278,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 +315,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 +335,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

@@ -31,6 +31,7 @@ var DriveImport = common.Shortcut{
{Name: "type", Desc: "target document type (docx, sheet, bitable)", Required: true},
{Name: "folder-token", Desc: "target folder token (omit for root folder; API accepts empty mount_key as root)"},
{Name: "name", Desc: "imported file name (default: local file name without extension)"},
{Name: "target-token", Desc: "existing token to import data into (only for type=bitable); when set, data is mounted into this bitable instead of creating a new one"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveImportSpec(driveImportSpec{
@@ -38,6 +39,7 @@ var DriveImport = common.Shortcut{
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
})
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -46,11 +48,15 @@ var DriveImport = common.Shortcut{
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
}
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if valErr := validateDriveImportSpec(spec); valErr != nil {
return common.NewDryRunAPI().Set("error", valErr.Error())
}
dry := common.NewDryRunAPI()
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
@@ -76,6 +82,7 @@ var DriveImport = common.Shortcut{
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
}
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
return err

View File

@@ -51,6 +51,7 @@ type driveImportSpec struct {
DocType string
FolderToken string
Name string
TargetToken string // existing bitable token to import data into (only for type=bitable)
}
func (s driveImportSpec) FileExtension() string {
@@ -67,7 +68,7 @@ func (s driveImportSpec) TargetFileName() string {
// CreateTaskBody builds the request body expected by /drive/v1/import_tasks.
func (s driveImportSpec) CreateTaskBody(fileToken string) map[string]interface{} {
return map[string]interface{}{
body := map[string]interface{}{
"file_extension": s.FileExtension(),
"file_token": fileToken,
"type": s.DocType,
@@ -79,6 +80,12 @@ func (s driveImportSpec) CreateTaskBody(fileToken string) map[string]interface{}
"mount_key": s.FolderToken,
},
}
if s.DocType == "bitable" && s.TargetToken != "" {
body["token"] = s.TargetToken
}
return body
}
// uploadMediaForImport uploads the source file to the temporary import media
@@ -232,6 +239,15 @@ func validateDriveImportSpec(spec driveImportSpec) error {
}
}
if strings.TrimSpace(spec.TargetToken) != "" {
if spec.DocType != "bitable" {
return output.ErrValidation("--target-token is only supported when --type is bitable")
}
if err := validate.ResourceName(spec.TargetToken, "--target-token"); err != nil {
return output.ErrValidation("%s", err)
}
}
return nil
}

View File

@@ -45,6 +45,19 @@ func TestValidateDriveImportSpec(t *testing.T) {
spec: driveImportSpec{FilePath: "./data.rtf", DocType: "docx"},
wantErr: "unsupported file extension",
},
{
name: "target-token rejected for non-bitable type",
spec: driveImportSpec{FilePath: "./data.xlsx", DocType: "sheet", TargetToken: "bascnxxx"},
wantErr: "--target-token is only supported when --type is bitable",
},
{
name: "target-token accepted for bitable",
spec: driveImportSpec{FilePath: "./data.xlsx", DocType: "bitable", TargetToken: "bascnxxx"},
},
{
name: "target-token empty for bitable still ok",
spec: driveImportSpec{FilePath: "./data.xlsx", DocType: "bitable"},
},
}
for _, tt := range tests {

View File

@@ -84,6 +84,7 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("name", "", "")
cmd.Flags().String("target-token", "", "")
if err := cmd.Flags().Set("file", "./base-import.xlsx"); err != nil {
t.Fatalf("set --file: %v", err)
}
@@ -148,6 +149,7 @@ func TestDriveImportDryRunShowsMultipartUploadForLargeFile(t *testing.T) {
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("name", "", "")
cmd.Flags().String("target-token", "", "")
if err := cmd.Flags().Set("file", "./large.xlsx"); err != nil {
t.Fatalf("set --file: %v", err)
}
@@ -197,6 +199,7 @@ func TestDriveImportDryRunReturnsErrorForUnsafePath(t *testing.T) {
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("name", "", "")
cmd.Flags().String("target-token", "", "")
if err := cmd.Flags().Set("file", "../outside.md"); err != nil {
t.Fatalf("set --file: %v", err)
}
@@ -250,6 +253,7 @@ func TestDriveImportDryRunReturnsErrorForOversizedMarkdown(t *testing.T) {
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("name", "", "")
cmd.Flags().String("target-token", "", "")
if err := cmd.Flags().Set("file", "./large.md"); err != nil {
t.Fatalf("set --file: %v", err)
}
@@ -296,6 +300,7 @@ func TestDriveImportDryRunReturnsErrorForDirectoryInput(t *testing.T) {
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("name", "", "")
cmd.Flags().String("target-token", "", "")
if err := cmd.Flags().Set("file", "./folder-input"); err != nil {
t.Fatalf("set --file: %v", err)
}
@@ -366,6 +371,165 @@ func TestDriveImportCreateTaskBodyKeepsEmptyMountKeyForRoot(t *testing.T) {
}
}
func TestDriveImportCreateTaskBodyWithTargetToken(t *testing.T) {
t.Parallel()
spec := driveImportSpec{
FilePath: "/tmp/data.xlsx",
DocType: "bitable",
TargetToken: "bascnxxxxx",
}
body := spec.CreateTaskBody("file_token_test")
// point stays the same as default (mount_type=1)
point, ok := body["point"].(map[string]interface{})
if !ok {
t.Fatalf("point = %#v, want map", body["point"])
}
if mt := point["mount_type"]; mt != float64(1) && mt != 1 {
t.Fatalf("mount_type = %v (%T), want 1", mt, mt)
}
// token is injected at body top-level
if tt, _ := body["token"].(string); tt != "bascnxxxxx" {
t.Fatalf("token = %q, want %q", tt, "bascnxxxxx")
}
}
func TestDriveImportCreateTaskBodyTargetTokenIgnoredForNonBitable(t *testing.T) {
t.Parallel()
spec := driveImportSpec{
FilePath: "/tmp/data.xlsx",
DocType: "sheet",
TargetToken: "bascnxxxxx",
FolderToken: "fld_test",
}
body := spec.CreateTaskBody("file_token_test")
point, ok := body["point"].(map[string]interface{})
if !ok {
t.Fatalf("point = %#v, want map", body["point"])
}
// Non-bitable should use default folder mount (type=1), ignoring TargetToken
if mt := point["mount_type"]; mt != float64(1) && mt != 1 {
t.Fatalf("mount_type = %v (%T), want 1 (folder mount)", mt, mt)
}
if _, exists := point["target_token"]; exists {
t.Fatal("target_token should not be present for non-bitable type")
}
}
func TestDriveImportDryRunWithTargetToken(t *testing.T) {
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
cmd := &cobra.Command{Use: "drive +import"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("name", "", "")
cmd.Flags().String("target-token", "", "")
if err := cmd.Flags().Set("file", "./data.xlsx"); err != nil {
t.Fatalf("set --file: %v", err)
}
if err := cmd.Flags().Set("type", "bitable"); err != nil {
t.Fatalf("set --type: %v", err)
}
if err := cmd.Flags().Set("target-token", "bascntarget123"); err != nil {
t.Fatalf("set --target-token: %v", err)
}
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
dry := DriveImport.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"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 3 {
t.Fatalf("expected 3 API calls, got %d", len(got.API))
}
// The import task body (API[1]) should contain target_token in point
importTaskBody := got.API[1].Body
point, ok := importTaskBody["point"].(map[string]interface{})
if !ok {
t.Fatalf("point = %#v, want map", importTaskBody["point"])
}
if mt := point["mount_type"]; mt != float64(1) && mt != 1 {
t.Fatalf("dry-run mount_type = %v (%T), want 1 (unchanged)", mt, mt)
}
if tt, _ := importTaskBody["token"].(string); tt != "bascntarget123" {
t.Fatalf("dry-run token = %q, want %q", tt, "bascntarget123")
}
}
func TestDriveImportDryRunTargetTokenRejectedForSheet(t *testing.T) {
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
cmd := &cobra.Command{Use: "drive +import"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
cmd.Flags().String("name", "", "")
cmd.Flags().String("target-token", "", "")
if err := cmd.Flags().Set("file", "./data.xlsx"); err != nil {
t.Fatalf("set --file: %v", err)
}
if err := cmd.Flags().Set("type", "sheet"); err != nil {
t.Fatalf("set --type: %v", err)
}
if err := cmd.Flags().Set("target-token", "bascnxxx"); err != nil {
t.Fatalf("set --target-token: %v", err)
}
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
dry := DriveImport.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 {
Error string `json:"error"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.Error == "" || !strings.Contains(got.Error, "--target-token is only supported when --type is bitable") {
t.Fatalf("dry-run error = %q, want target-token validation error", got.Error)
}
}
// driveImportMockEnv mounts the three stubs needed for a full +import run:
// media upload_all -> import_tasks (create) -> import_tasks/<ticket> (poll).
// Returns nothing; caller asserts on stdout via decodeDriveEnvelope.

View File

@@ -604,9 +604,9 @@ func TestDriveUploadSmallFileToWiki(t *testing.T) {
}
}
func TestDriveUploadFallbackURLForExplorerParent(t *testing.T) {
func TestDriveUploadUsesMetaURLForExplorerParent(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-explorer-fallback-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
AppID: "drive-upload-explorer-meta-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
@@ -615,12 +615,21 @@ func TestDriveUploadFallbackURLForExplorerParent(t *testing.T) {
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
// upload_all only ever returns file_token; url is never present —
// this exercises the fallback path unconditionally for explorer
// parents.
"data": map[string]interface{}{"file_token": "file_explorer_small"},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"doc_token": "file_explorer_small", "doc_type": "file", "url": "https://tenant.example.com/file/file_explorer_small"},
},
},
},
})
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
@@ -641,14 +650,14 @@ func TestDriveUploadFallbackURLForExplorerParent(t *testing.T) {
}
data := decodeDriveEnvelope(t, stdout)
if got, want := data["url"], "https://www.feishu.cn/file/file_explorer_small"; got != want {
t.Fatalf("data.url = %#v, want %q (brand-standard fallback)", got, want)
if got, want := data["url"], "https://tenant.example.com/file/file_explorer_small"; got != want {
t.Fatalf("data.url = %#v, want %q (metadata URL)", got, want)
}
}
func TestDriveUploadOmitsURLForWikiParent(t *testing.T) {
func TestDriveUploadUsesMetaURLForWikiParent(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-wiki-no-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
AppID: "drive-upload-wiki-meta-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
@@ -660,6 +669,18 @@ func TestDriveUploadOmitsURLForWikiParent(t *testing.T) {
"data": map[string]interface{}{"file_token": "file_wiki_small"},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"doc_token": "file_wiki_small", "doc_type": "file", "url": "https://tenant.example.com/file/file_wiki_small"},
},
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
@@ -677,8 +698,8 @@ func TestDriveUploadOmitsURLForWikiParent(t *testing.T) {
}
data := decodeDriveEnvelope(t, stdout)
if _, ok := data["url"]; ok {
t.Fatalf("data.url should be omitted for wiki-hosted files (no standalone URL); got %#v", data["url"])
if got, want := data["url"], "https://tenant.example.com/file/file_wiki_small"; got != want {
t.Fatalf("data.url = %#v, want %q (metadata URL)", got, want)
}
}
@@ -1078,14 +1099,15 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
var got struct {
API []struct {
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `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 len(got.API) != 2 {
t.Fatalf("expected 2 API calls, got %d", len(got.API))
}
if got.API[0].Body["parent_type"] != driveUploadParentTypeWiki {
t.Fatalf("parent_type = %#v, want %q", got.API[0].Body["parent_type"], driveUploadParentTypeWiki)
@@ -1093,6 +1115,12 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
if got.API[0].Body["parent_node"] != "wikcn_dryrun_upload_target" {
t.Fatalf("parent_node = %#v, want %q", got.API[0].Body["parent_node"], "wikcn_dryrun_upload_target")
}
if got.API[1].URL != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("metadata URL = %q, want metas/batch_query", got.API[1].URL)
}
if got.API[1].Body["with_url"] != true {
t.Fatalf("metadata with_url = %#v, want true", got.API[1].Body["with_url"])
}
}
func TestNewDriveUploadSpecPreservesPathAndName(t *testing.T) {
@@ -1168,18 +1196,25 @@ func TestDriveUploadDryRunIncludesFileToken(t *testing.T) {
var got struct {
API []struct {
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `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 len(got.API) != 2 {
t.Fatalf("expected 2 API calls, got %d", len(got.API))
}
if got.API[0].Body["file_token"] != "boxcn_dryrun_overwrite" {
t.Fatalf("file_token = %#v, want %q", got.API[0].Body["file_token"], "boxcn_dryrun_overwrite")
}
if got.API[1].URL != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("metadata URL = %q, want metas/batch_query", got.API[1].URL)
}
if got.API[1].Body["with_url"] != true {
t.Fatalf("metadata with_url = %#v, want true", got.API[1].Body["with_url"])
}
}
func TestDriveUploadDryRunBotOverwriteSkipsPermissionGrantHint(t *testing.T) {
@@ -1222,8 +1257,8 @@ func TestDriveUploadDryRunBotOverwriteSkipsPermissionGrantHint(t *testing.T) {
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 len(got.API) != 2 {
t.Fatalf("expected 2 API calls, got %d", len(got.API))
}
if got.API[0].Body["file_token"] != "boxcn_dryrun_overwrite" {
t.Fatalf("file_token = %#v, want %q", got.API[0].Body["file_token"], "boxcn_dryrun_overwrite")

View File

@@ -284,3 +284,94 @@ func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interf
}
return body
}
func TestDriveUploadBotAutoGrantSkippedNoUser(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, ""))
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"file_token": "file_skipped",
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("report.pdf", []byte("pdf"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveUpload, []string{
"+upload",
"--file", "report.pdf",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantSkipped {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
}
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "auth login") {
t.Fatalf("hint = %#v, want string containing 'auth login'", grant["hint"])
}
}
func TestDriveUploadBotAutoGrantFailed(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user"))
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"file_token": "file_grant_fail",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/file_grant_fail/members",
Body: map[string]interface{}{
"code": 230001,
"msg": "no permission",
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("report.pdf", []byte("pdf"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveUpload, []string{
"+upload",
"--file", "report.pdf",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "Retry later") {
t.Fatalf("hint = %#v, want string containing 'Retry later'", grant["hint"])
}
}

View File

@@ -77,8 +77,8 @@ var DriveSearch = common.Shortcut{
Flags: []common.Flag{
{Name: "query", Desc: "search keyword (may be empty to browse by filter only)"},
{Name: "mine", Type: "bool", Desc: "restrict to docs I created (uses current user's open_id)"},
{Name: "creator-ids", Desc: "comma-separated creator open_ids; mutually exclusive with --mine"},
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},
{Name: "edited-since", Desc: "start of [my edited] time window (e.g. 7d, 1m, 1y, 2026-04-01, RFC3339, unix seconds)"},
{Name: "edited-until", Desc: "end of [my edited] time window"},
@@ -108,7 +108,7 @@ var DriveSearch = common.Shortcut{
Tips: []string{
"Time flags accept relative (e.g. 7d, 1m, 1y), absolute (2026-04-01, RFC3339), or unix seconds.",
"my_edit_time and my_comment_time are hour-aggregated server-side; sub-hour inputs are snapped and a notice is printed to stderr.",
"Use --mine for a quick \"docs I created\" filter. For other people, use --creator-ids ou_xxx,ou_yyy.",
"Use --mine for a quick \"docs I own\" filter (owner semantic, not original creator). For other people, use --creator-ids ou_xxx,ou_yyy.",
"--folder-tokens limits to doc-only search; --space-ids limits to wiki-only. They cannot be combined.",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {

View File

@@ -92,7 +92,7 @@ var DriveUpload = common.Shortcut{
Command: "+upload",
Description: "Upload a local file to Drive",
Risk: "write",
Scopes: []string{"drive:file:upload"},
Scopes: []string{"drive:file:upload", "drive:drive.metadata:readonly"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
@@ -124,11 +124,22 @@ var DriveUpload = common.Shortcut{
body["file_token"] = spec.FileToken
}
d := common.NewDryRunAPI().
Desc("multipart/form-data upload (files > 20MB use chunked 3-step upload)").
Desc("multipart/form-data upload (files > 20MB use chunked 3-step upload), then fetch the real Drive URL via metadata").
POST("/open-apis/drive/v1/files/upload_all").
Body(body)
d.POST("/open-apis/drive/v1/metas/batch_query").
Desc("Fetch the uploaded file's real access URL").
Body(map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": "<file_token from upload response>",
"doc_type": "file",
},
},
"with_url": true,
})
if runtime.IsBot() && !isOverwrite {
d.Desc("After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new file.")
d.Set("post_upload_note", "After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new file.")
}
return d
},
@@ -165,13 +176,10 @@ var DriveUpload = common.Shortcut{
if uploadResult.Version != "" {
out["version"] = uploadResult.Version
}
// wiki-hosted files have no standalone /file/<token> URL — only the
// wiki node URL, which the upload response doesn't carry. Skip the
// fallback for parent_type=wiki rather than emit a link that 404s.
if target.ParentType == driveUploadParentTypeExplorer {
if u := common.BuildResourceURL(runtime.Config.Brand, "file", uploadResult.FileToken); u != "" {
out["url"] = u
}
if u, metaErr := common.FetchDriveMetaURL(runtime, uploadResult.FileToken, "file"); metaErr == nil && strings.TrimSpace(u) != "" {
out["url"] = u
} else if metaErr != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: uploaded file URL lookup failed: %v\n", metaErr)
}
if !isOverwrite {
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, uploadResult.FileToken, "file"); grant != nil {

View File

@@ -911,12 +911,16 @@ func marshalMarkdownPostContent(content [][]map[string]interface{}) string {
"content": content,
},
}
return marshalJSONNoEscape(payload)
data, _ := json.Marshal(payload)
return string(data)
}
func buildSingleMDPost(markdown string) string {
return marshalMarkdownPostContent([][]map[string]interface{}{
buildPostElementNodes(optimizeMarkdownStyle(markdown)),
{{
"tag": "md",
"text": optimizeMarkdownStyle(markdown),
}},
})
}
@@ -940,7 +944,10 @@ func buildSegmentedPost(markdown string) string {
if optimized == "" {
continue
}
content = append(content, buildPostElementNodes(optimized))
content = append(content, []map[string]interface{}{{
"tag": "md",
"text": optimized,
}})
}
if len(content) == 0 {
return buildSingleMDPost(markdown)
@@ -955,186 +962,8 @@ func buildMarkdownPostContent(markdown string) string {
return buildSingleMDPost(markdown)
}
// buildPostElementNodes splits optimized markdown text into Feishu post inline
// elements. It tokenizes markdown links/images and bare http(s) URLs:
// - markdown links are kept verbatim inside a {"tag":"md"} segment
// - bare URLs become {"tag":"a"} elements rendered natively by Feishu,
// avoiding the md renderer misinterpreting underscores as italic markers
//
// Fenced code blocks are protected before tokenization so their content remains
// a single md segment, and bare URLs support balanced parentheses in the path.
func buildPostElementNodes(text string) []map[string]interface{} {
protected, codeBlocks := protectMarkdownCodeBlocks(text)
if protected == "" {
return []map[string]interface{}{{
"tag": "md",
"text": text,
}}
}
elems := make([]map[string]interface{}, 0, 4)
prev := 0
for i := 0; i < len(protected); {
end, kind, ok := scanPostToken(protected, i)
if !ok {
i++
continue
}
if i > prev {
elems = appendMDPostNode(elems, restoreMarkdownCodeBlocks(protected[prev:i], codeBlocks))
}
token := protected[i:end]
if kind == postTokenMarkdown {
elems = appendMDPostNode(elems, restoreMarkdownCodeBlocks(token, codeBlocks))
} else {
url := trimBareURLToken(token)
if url == "" {
url = token
}
elems = append(elems, map[string]interface{}{
"tag": "a",
"text": url,
"href": url,
})
elems = appendMDPostNode(elems, restoreMarkdownCodeBlocks(token[len(url):], codeBlocks))
}
prev = end
i = end
}
if prev < len(protected) {
elems = appendMDPostNode(elems, restoreMarkdownCodeBlocks(protected[prev:], codeBlocks))
}
if len(elems) == 0 {
return []map[string]interface{}{{
"tag": "md",
"text": text,
}}
}
return elems
}
func trimBareURLToken(token string) string {
trimmed := strings.TrimRight(token, ".,;:!?")
for strings.HasSuffix(trimmed, ")") && strings.Count(trimmed, "(") < strings.Count(trimmed, ")") {
trimmed = strings.TrimSuffix(trimmed, ")")
}
return trimmed
}
type postTokenKind int
const (
postTokenMarkdown postTokenKind = iota
postTokenURL
)
func appendMDPostNode(elems []map[string]interface{}, text string) []map[string]interface{} {
if text == "" {
return elems
}
return append(elems, map[string]interface{}{
"tag": "md",
"text": text,
})
}
func scanPostToken(text string, start int) (end int, kind postTokenKind, ok bool) {
if end, ok = scanMarkdownLinkToken(text, start); ok {
return end, postTokenMarkdown, true
}
if end, ok = scanBareURLToken(text, start); ok {
return end, postTokenURL, true
}
return 0, 0, false
}
func scanMarkdownLinkToken(text string, start int) (int, bool) {
openBracket := start
if text[start] == '!' {
if start+1 >= len(text) || text[start+1] != '[' {
return 0, false
}
openBracket = start + 1
} else if text[start] != '[' {
return 0, false
}
closeBracket := strings.IndexByte(text[openBracket+1:], ']')
if closeBracket < 0 {
return 0, false
}
closeBracket += openBracket + 1
if closeBracket+1 >= len(text) || text[closeBracket+1] != '(' {
return 0, false
}
return scanBalancedParenToken(text, closeBracket+1)
}
func scanBareURLToken(text string, start int) (int, bool) {
if !strings.HasPrefix(text[start:], "http://") && !strings.HasPrefix(text[start:], "https://") {
return 0, false
}
depth := 0
for i := start; i < len(text); i++ {
switch text[i] {
case ' ', '\t', '\n', '\r', '<', '>', '"', '[', ']':
return i, i > start
case '(':
depth++
case ')':
if depth == 0 {
return i, i > start
}
depth--
}
}
return len(text), true
}
func scanBalancedParenToken(text string, openParen int) (int, bool) {
if openParen >= len(text) || text[openParen] != '(' {
return 0, false
}
depth := 0
for i := openParen; i < len(text); i++ {
switch text[i] {
case '(':
depth++
case ')':
depth--
if depth == 0 {
return i + 1, true
}
}
}
return 0, false
}
func buildPostElements(text string) string {
return marshalJSONNoEscape(buildPostElementNodes(text))
}
func marshalJSONNoEscape(v interface{}) string {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
_ = enc.Encode(v)
return strings.TrimSuffix(buf.String(), "\n")
}
// marshalStringNoEscape serializes a string to JSON without HTML-escaping
// special characters like &, <, >. Go's json.Marshal escapes them to \u0026
// etc. by default, which breaks URLs containing & in Feishu's md renderer.
func marshalStringNoEscape(s string) string {
return marshalJSONNoEscape(s)
}
// wrapMarkdownAsPost wraps markdown text into Feishu post format JSON (no network).
// Used by DryRun. Output may include md/text paragraphs when blank-line separators are present.
// Bare URLs are emitted as {"tag":"a"} elements to avoid Feishu's md renderer
// misinterpreting underscores in URLs as italic markers.
func wrapMarkdownAsPost(markdown string) string {
return buildMarkdownPostContent(markdown)
}

View File

@@ -373,171 +373,19 @@ func TestOptimizeMarkdownStyle(t *testing.T) {
}
}
func TestMarshalStringNoEscape(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{name: "ampersand not escaped", input: "a=1&b=2", want: `"a=1&b=2"`},
{name: "angle brackets not escaped", input: "<tag>", want: `"<tag>"`},
{name: "regular string", input: "hello world", want: `"hello world"`},
{name: "url with ampersand", input: "https://example.com?a=1&b=2", want: `"https://example.com?a=1&b=2"`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := marshalStringNoEscape(tt.input)
if got != tt.want {
t.Errorf("marshalStringNoEscape(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestBuildPostElements(t *testing.T) {
tests := []struct {
name string
input string
wantSubs []string // substrings that must appear
wantNsubs []string // substrings that must NOT appear
}{
{
name: "plain text no URL",
input: "hello **world**",
wantSubs: []string{`"tag":"md"`, `hello **world**`},
},
{
name: "bare URL only",
input: "https://example.com/path",
wantSubs: []string{`"tag":"a"`, `"text":"https://example.com/path"`, `"href":"https://example.com/path"`},
},
{
name: "bare URL with underscores",
input: "https://example.com/flow_id=abc_def",
wantSubs: []string{`"tag":"a"`, `flow_id=abc_def`},
},
{
name: "bare URL with ampersand not escaped",
input: "https://example.com?a=1&b=2",
wantSubs: []string{`"tag":"a"`, `a=1&b=2`},
},
{
name: "text before and after URL",
input: "click here: https://example.com/path ok?",
wantSubs: []string{`"tag":"md"`, `click here: `, `"tag":"a"`, `https://example.com/path`, ` ok?`},
},
{
name: "markdown link kept in md segment",
input: "[click here](https://example.com/path_with_underscore)",
wantSubs: []string{`"tag":"md"`, `[click here](https://example.com/path_with_underscore)`},
},
{
name: "markdown link not promoted to a tag",
input: "[text](https://example.com)",
wantSubs: []string{`"tag":"md"`},
wantNsubs: []string{`"tag":"a"`},
},
{
name: "multiple bare URLs",
input: "https://a.com/x_y and https://b.com/p_q",
wantSubs: []string{
`"tag":"a"`, `https://a.com/x_y`,
`https://b.com/p_q`,
`"tag":"md"`, ` and `,
},
},
{
name: "mixed markdown and bare URL",
input: "**bold** https://example.com/foo_bar [link](https://example.com) end",
wantSubs: []string{`"tag":"md"`, `**bold**`, `"tag":"a"`, `foo_bar`, `[link](https://example.com)`},
},
{
name: "empty string",
input: "",
wantSubs: []string{`"tag":"md"`, `"text":""`},
},
{
name: "URL followed by comma",
input: "visit https://example.com/path, then click",
wantSubs: []string{`"tag":"a"`, `"href":"https://example.com/path"`},
wantNsubs: []string{`https://example.com/path,`},
},
{
name: "URL followed by period",
input: "see https://example.com/foo.",
wantSubs: []string{`"tag":"a"`, `https://example.com/foo`},
wantNsubs: []string{`https://example.com/foo."`},
},
{
name: "URL with no trailing punctuation unchanged",
input: "https://example.com/foo_bar",
wantSubs: []string{`"href":"https://example.com/foo_bar"`},
},
{
name: "URL with balanced parentheses preserved",
input: "https://en.wikipedia.org/wiki/Foo_(bar)",
wantSubs: []string{`"href":"https://en.wikipedia.org/wiki/Foo_(bar)"`},
wantNsubs: []string{`"href":"https://en.wikipedia.org/wiki/Foo_"`},
},
{
name: "code block URL stays markdown",
input: "```bash\ncurl https://example.com/foo_bar\n```",
wantSubs: []string{`"tag":"md"`, "```bash\\ncurl https://example.com/foo_bar\\n```"},
wantNsubs: []string{`"tag":"a"`},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := buildPostElements(tt.input)
for _, sub := range tt.wantSubs {
if !strings.Contains(got, sub) {
t.Errorf("buildPostElements(%q)\n got: %s\n missing: %q", tt.input, got, sub)
}
}
for _, sub := range tt.wantNsubs {
if strings.Contains(got, sub) {
t.Errorf("buildPostElements(%q)\n got: %s\n should not contain: %q", tt.input, got, sub)
}
}
})
}
}
func TestWrapMarkdownAsPost(t *testing.T) {
t.Run("plain markdown", func(t *testing.T) {
got := wrapMarkdownAsPost("hello **world**")
content := decodePostContentForTest(t, got)
if len(content) != 1 {
t.Fatalf("wrapMarkdownAsPost() content len = %d, want 1", len(content))
}
node := decodePostParagraphForTest(t, got, 0)
if node["tag"] != "md" {
t.Fatalf("wrapMarkdownAsPost() tag = %#v, want md", node["tag"])
}
if node["text"] != "hello **world**" {
t.Fatalf("wrapMarkdownAsPost() text = %#v, want %q", node["text"], "hello **world**")
}
})
t.Run("bare URL becomes a tag", func(t *testing.T) {
got := wrapMarkdownAsPost("see https://example.com/flow_id=abc_def done")
if !strings.Contains(got, `"tag":"a"`) {
t.Fatalf("wrapMarkdownAsPost() bare URL should produce a tag: %s", got)
}
if !strings.Contains(got, `flow_id=abc_def`) {
t.Fatalf("wrapMarkdownAsPost() URL content missing: %s", got)
}
})
t.Run("code block URL stays md", func(t *testing.T) {
got := wrapMarkdownAsPost("```bash\ncurl https://example.com/foo_bar\n```")
if strings.Contains(got, `"tag":"a"`) {
t.Fatalf("wrapMarkdownAsPost() code block URL should stay markdown: %s", got)
}
if !strings.Contains(got, "```bash\\ncurl https://example.com/foo_bar\\n```") {
t.Fatalf("wrapMarkdownAsPost() code block content missing: %s", got)
}
})
got := wrapMarkdownAsPost("hello **world**")
content := decodePostContentForTest(t, got)
if len(content) != 1 {
t.Fatalf("wrapMarkdownAsPost() content len = %d, want 1", len(content))
}
node := decodePostParagraphForTest(t, got, 0)
if node["tag"] != "md" {
t.Fatalf("wrapMarkdownAsPost() tag = %#v, want md", node["tag"])
}
if node["text"] != "hello **world**" {
t.Fatalf("wrapMarkdownAsPost() text = %#v, want %q", node["text"], "hello **world**")
}
}
func TestShouldUseSegmentedPost(t *testing.T) {

View File

@@ -29,11 +29,11 @@ var ImMessagesReply = common.Shortcut{
{Name: "content", Desc: "(one of --content/--text/--markdown/--image/--file/--video/--audio required) message content JSON"},
{Name: "text", Desc: "plain text message (auto-wrapped as JSON)"},
{Name: "markdown", Desc: "markdown text (auto-wrapped as post format with style optimization; image URLs auto-resolved)"},
{Name: "image", Desc: "image_key, local file path"},
{Name: "file", Desc: "file_key, local file path"},
{Name: "video", Desc: "video file_key, local file path; must be used together with --video-cover"},
{Name: "video-cover", Desc: "video cover image_key, local file path; required when using --video"},
{Name: "audio", Desc: "audio file_key, local file path"},
{Name: "image", Desc: "image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
{Name: "file", Desc: "file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
{Name: "video", Desc: "video file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); must be used together with --video-cover"},
{Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"},
{Name: "audio", Desc: "audio file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
{Name: "reply-in-thread", Type: "bool", Desc: "reply in thread (message appears in thread stream instead of main chat)"},
{Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"},
},

View File

@@ -33,11 +33,11 @@ var ImMessagesSend = common.Shortcut{
{Name: "text", Desc: "plain text message (auto-wrapped as JSON)"},
{Name: "markdown", Desc: "markdown text (auto-wrapped as post format with style optimization; image URLs auto-resolved)"},
{Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"},
{Name: "image", Desc: "image_key, local file path"},
{Name: "file", Desc: "file_key, local file path"},
{Name: "video", Desc: "video file_key, local file path; must be used together with --video-cover"},
{Name: "video-cover", Desc: "video cover image_key, local file path; required when using --video"},
{Name: "audio", Desc: "audio file_key, local file path"},
{Name: "image", Desc: "image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
{Name: "file", Desc: "file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
{Name: "video", Desc: "video file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); must be used together with --video-cover"},
{Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"},
{Name: "audio", Desc: "audio file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
chatFlag := runtime.Str("chat-id")

View File

@@ -6,9 +6,11 @@ package im
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/vfs/localfileio"
"github.com/larksuite/cli/shortcuts/common"
)
func TestValidateMediaFlagPath(t *testing.T) {
@@ -49,3 +51,37 @@ func TestValidateMediaFlagPath(t *testing.T) {
})
}
}
func TestIMMediaFlagDescriptionsDocumentPathRestrictions(t *testing.T) {
shortcuts := []struct {
name string
flags []common.Flag
}{
{name: "messages-send", flags: ImMessagesSend.Flags},
{name: "messages-reply", flags: ImMessagesReply.Flags},
}
mediaFlags := []string{"image", "file", "video", "video-cover", "audio"}
for _, sc := range shortcuts {
for _, flagName := range mediaFlags {
t.Run(sc.name+"/"+flagName, func(t *testing.T) {
desc := findFlagDesc(t, sc.flags, flagName)
for _, want := range []string{"URL", "cwd-relative local path", "absolute paths", ".. are rejected"} {
if !strings.Contains(desc, want) {
t.Fatalf("%s --%s description = %q, want it to mention %q", sc.name, flagName, desc, want)
}
}
})
}
}
}
func findFlagDesc(t *testing.T, flags []common.Flag, name string) string {
t.Helper()
for _, flag := range flags {
if flag.Name == name {
return flag.Desc
}
}
t.Fatalf("flag %q not found", name)
return ""
}

View File

@@ -2331,15 +2331,15 @@ func validateSendTime(runtime *common.RuntimeContext) error {
return nil
}
if !runtime.Bool("confirm-send") {
return fmt.Errorf("--send-time requires --confirm-send to be set")
return output.ErrValidation("--send-time requires --confirm-send to be set")
}
ts, err := strconv.ParseInt(sendTime, 10, 64)
if err != nil {
return fmt.Errorf("--send-time must be a valid Unix timestamp in seconds, got %q", sendTime)
return output.ErrValidation("--send-time must be a valid Unix timestamp in seconds, got %q", sendTime)
}
minTime := time.Now().Unix() + 5*60
if ts < minTime {
return fmt.Errorf("--send-time must be at least 5 minutes in the future (minimum: %d, got: %d)", minTime, ts)
return output.ErrValidation("--send-time must be at least 5 minutes in the future (minimum: %d, got: %d)", minTime, ts)
}
return nil
}
@@ -2444,10 +2444,10 @@ func validateRecipientCount(to, cc, bcc string) error {
func validateComposeInlineAndAttachments(fio fileio.FileIO, attachFlag, inlineFlag string, plainText bool, body string) error {
if strings.TrimSpace(inlineFlag) != "" {
if plainText {
return fmt.Errorf("--inline is not supported with --plain-text (inline images require HTML body)")
return output.ErrValidation("--inline is not supported with --plain-text (inline images require HTML body)")
}
if body != "" && !bodyIsHTML(body) {
return fmt.Errorf("--inline requires an HTML body (the provided body appears to be plain text; add HTML tags or remove --inline)")
return output.ErrValidation("--inline requires an HTML body (the provided body appears to be plain text; add HTML tags or remove --inline)")
}
}
inlineSpecs, err := parseInlineSpecs(inlineFlag)
@@ -2529,7 +2529,7 @@ func validateEventFlags(runtime *common.RuntimeContext) error {
hasAll := summary != "" && start != "" && end != ""
if hasAny && !hasAll {
return fmt.Errorf("--event-summary, --event-start, and --event-end must all be provided together")
return output.ErrValidation("--event-summary, --event-start, and --event-end must all be provided together")
}
if summary == "" {
return nil

View File

@@ -13,6 +13,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
@@ -241,7 +242,7 @@ func signatureCIDs(sig *signatureResult) []string {
// validateSignatureWithPlainText returns an error if both --plain-text and --signature-id are set.
func validateSignatureWithPlainText(plainText bool, signatureID string) error {
if plainText && signatureID != "" {
return fmt.Errorf("--plain-text and --signature-id are mutually exclusive: signatures require HTML mode")
return output.ErrValidation("--plain-text and --signature-id are mutually exclusive: signatures require HTML mode")
}
return nil
}

View File

@@ -24,10 +24,16 @@ import (
const markdownSinglePartSizeLimit = common.MaxDriveMediaUploadSinglePartSize
const markdownEmptyContentError = "empty markdown content is not supported; cannot create or overwrite an empty file"
const (
markdownUploadParentTypeExplorer = "explorer"
markdownUploadParentTypeWiki = "wiki"
)
type markdownUploadSpec struct {
FileToken string
FileName string
FolderToken string
WikiToken string
FilePath string
Content string
ContentSet bool
@@ -45,6 +51,25 @@ type markdownMultipartSession struct {
BlockNum int
}
type markdownUploadTarget struct {
ParentType string
ParentNode string
}
func (spec markdownUploadSpec) Target() markdownUploadTarget {
if spec.WikiToken != "" {
return markdownUploadTarget{
ParentType: markdownUploadParentTypeWiki,
ParentNode: spec.WikiToken,
}
}
// An empty explorer parent node uploads to the user's Drive root folder.
return markdownUploadTarget{
ParentType: markdownUploadParentTypeExplorer,
ParentNode: spec.FolderToken,
}
}
func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpec, requireName bool) error {
switch {
case spec.ContentSet && spec.FileSet:
@@ -53,14 +78,32 @@ func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpe
return common.FlagErrorf("specify exactly one of --content or --file")
}
if runtime.Changed("folder-token") && strings.TrimSpace(spec.FolderToken) == "" {
if markdownFlagExplicitlyEmpty(runtime, "folder-token") {
return common.FlagErrorf("--folder-token cannot be empty; omit it to upload into Drive root folder")
}
if markdownFlagExplicitlyEmpty(runtime, "wiki-token") {
return common.FlagErrorf("--wiki-token cannot be empty; provide a valid wiki node token or omit the flag entirely")
}
targets := 0
if spec.FolderToken != "" {
targets++
}
if spec.WikiToken != "" {
targets++
}
if targets > 1 {
return common.FlagErrorf("--folder-token and --wiki-token are mutually exclusive")
}
if spec.FolderToken != "" {
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
}
}
if spec.WikiToken != "" {
if err := validate.ResourceName(spec.WikiToken, "--wiki-token"); err != nil {
return output.ErrValidation("%s", err)
}
}
if requireName && spec.ContentSet {
if strings.TrimSpace(spec.FileName) == "" {
@@ -92,6 +135,10 @@ func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpe
return nil
}
func markdownFlagExplicitlyEmpty(runtime *common.RuntimeContext, flagName string) bool {
return runtime.Changed(flagName) && strings.TrimSpace(runtime.Str(flagName)) == ""
}
func validateMarkdownFileName(name, flagName string) error {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
@@ -137,11 +184,19 @@ func openMarkdownDownload(ctx context.Context, runtime *common.RuntimeContext, f
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 nil, wrapMarkdownDownloadError(err)
}
return resp, nil
}
func wrapMarkdownDownloadError(err error) error {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
}
return output.ErrNetwork("download failed: %s", err)
}
func validateNonEmptyMarkdownSize(size int64) error {
if size == 0 {
return output.ErrValidation("%s", markdownEmptyContentError)
@@ -170,6 +225,24 @@ func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec)
return size, nil
}
func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (*http.Response, string, error) {
req := &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
}
if strings.TrimSpace(version) != "" {
req.QueryParams = larkcore.QueryParams{
"version": []string{strings.TrimSpace(version)},
}
}
resp, err := runtime.DoAPIStream(ctx, req)
if err != nil {
return nil, "", wrapMarkdownDownloadError(err)
}
return resp, fileNameFromDownloadHeader(resp.Header, fileToken+".md"), nil
}
func markdownDryRunFileField(spec markdownUploadSpec) string {
if spec.FilePath != "" {
return "@" + spec.FilePath
@@ -179,12 +252,13 @@ func markdownDryRunFileField(spec markdownUploadSpec) string {
func markdownUploadDryRun(spec markdownUploadSpec, fileSize int64, multipart bool) *common.DryRunAPI {
fileName := finalMarkdownFileName(spec)
target := spec.Target()
if !multipart {
body := map[string]interface{}{
"file_name": fileName,
"parent_type": "explorer",
"parent_node": spec.FolderToken,
"parent_type": target.ParentType,
"parent_node": target.ParentNode,
"size": fileSize,
"file": markdownDryRunFileField(spec),
}
@@ -205,8 +279,8 @@ func markdownUploadDryRun(spec markdownUploadSpec, fileSize int64, multipart boo
prepareBody := map[string]interface{}{
"file_name": fileName,
"parent_type": "explorer",
"parent_node": spec.FolderToken,
"parent_type": target.ParentType,
"parent_node": target.ParentNode,
"size": fileSize,
}
if spec.FileToken != "" {
@@ -241,6 +315,7 @@ func markdownUploadDryRun(spec markdownUploadSpec, fileSize int64, multipart boo
func markdownOverwriteDryRun(spec markdownUploadSpec, fileSize int64, multipart bool) *common.DryRunAPI {
fileName := strings.TrimSpace(spec.FileName)
target := spec.Target()
if fileName == "" && spec.FileSet {
fileName = finalMarkdownFileName(spec)
}
@@ -267,8 +342,8 @@ func markdownOverwriteDryRun(spec markdownUploadSpec, fileSize int64, multipart
Desc("[2] Overwrite file contents with multipart/form-data upload").
Body(map[string]interface{}{
"file_name": spec.FileName,
"parent_type": "explorer",
"parent_node": spec.FolderToken,
"parent_type": target.ParentType,
"parent_node": target.ParentNode,
"size": fileSize,
"file": markdownDryRunFileField(spec),
"file_token": spec.FileToken,
@@ -280,8 +355,8 @@ func markdownOverwriteDryRun(spec markdownUploadSpec, fileSize int64, multipart
Desc("[2] Initialize multipart overwrite upload").
Body(map[string]interface{}{
"file_name": spec.FileName,
"parent_type": "explorer",
"parent_node": spec.FolderToken,
"parent_type": target.ParentType,
"parent_node": target.ParentNode,
"size": fileSize,
"file_token": spec.FileToken,
}).
@@ -326,10 +401,11 @@ func uploadMarkdownLocalFile(runtime *common.RuntimeContext, spec markdownUpload
}
func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
target := spec.Target()
fd := larkcore.NewFormdata()
fd.AddField("file_name", fileName)
fd.AddField("parent_type", "explorer")
fd.AddField("parent_node", spec.FolderToken)
fd.AddField("parent_type", target.ParentType)
fd.AddField("parent_node", target.ParentNode)
fd.AddField("size", fmt.Sprintf("%d", fileSize))
if spec.FileToken != "" {
fd.AddField("file_token", spec.FileToken)
@@ -357,10 +433,11 @@ func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSp
}
func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
target := spec.Target()
prepareBody := map[string]interface{}{
"file_name": fileName,
"parent_type": "explorer",
"parent_node": spec.FolderToken,
"parent_type": target.ParentType,
"parent_node": target.ParentNode,
"size": fileSize,
}
if spec.FileToken != "" {

View File

@@ -5,6 +5,7 @@ package markdown
import (
"context"
"fmt"
"io"
"strings"
@@ -16,19 +17,25 @@ var MarkdownCreate = common.Shortcut{
Command: "+create",
Description: "Create a Markdown file in Drive",
Risk: "write",
Scopes: []string{"drive:file:upload"},
Scopes: []string{"drive:file:upload", "drive:drive.metadata:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "folder-token", Desc: "target Drive folder token (default: root folder)"},
{Name: "folder-token", Desc: "target Drive folder token (default: root folder; mutually exclusive with --wiki-token)"},
{Name: "wiki-token", Desc: "target wiki node token (uploads under that wiki node; mutually exclusive with --folder-token)"},
{Name: "name", Desc: "file name with .md suffix; required with --content, optional with --file"},
{Name: "content", Desc: "Markdown content", Input: []string{common.File, common.Stdin}},
{Name: "file", Desc: "local .md file path"},
},
Tips: []string{
"Omit both --folder-token and --wiki-token to create the Markdown file in the caller's Drive root folder.",
"Use --wiki-token <wiki_node_token> to create the Markdown file under a wiki node; the shortcut maps this to parent_type=wiki automatically.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateMarkdownSpec(runtime, markdownUploadSpec{
FileName: strings.TrimSpace(runtime.Str("name")),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
@@ -39,6 +46,7 @@ var MarkdownCreate = common.Shortcut{
spec := markdownUploadSpec{
FileName: strings.TrimSpace(runtime.Str("name")),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
@@ -48,12 +56,25 @@ var MarkdownCreate = common.Shortcut{
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return markdownUploadDryRun(spec, fileSize, fileSize > markdownSinglePartSizeLimit)
dry := markdownUploadDryRun(spec, fileSize, fileSize > markdownSinglePartSizeLimit)
dry.POST("/open-apis/drive/v1/metas/batch_query").
Desc("Fetch the created Markdown file's real access URL").
Body(map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": "<file_token from upload response>",
"doc_type": "file",
},
},
"with_url": true,
})
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := markdownUploadSpec{
FileName: strings.TrimSpace(runtime.Str("name")),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
@@ -79,8 +100,10 @@ var MarkdownCreate = common.Shortcut{
"file_name": finalMarkdownFileName(spec),
"size_bytes": fileSize,
}
if u := common.BuildResourceURL(runtime.Config.Brand, "file", result.FileToken); u != "" {
if u, metaErr := common.FetchDriveMetaURL(runtime, result.FileToken, "file"); metaErr == nil && strings.TrimSpace(u) != "" {
out["url"] = u
} else if metaErr != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: created Markdown file URL lookup failed: %v\n", metaErr)
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, result.FileToken, "file"); grant != nil {
out["permission_grant"] = grant

View File

@@ -0,0 +1,540 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package markdown
import (
"context"
"errors"
"fmt"
"io"
"regexp"
"strings"
"time"
"github.com/sergi/go-diff/diffmatchpatch"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
markdownDiffModeRemoteVsRemote = "remote_vs_remote"
markdownDiffModeRemoteVsLocal = "remote_vs_local"
markdownDiffMaxContentBytes = 10 * 1024 * 1024
markdownDiffTimeout = 30 * time.Second
)
var markdownDiffVersionRe = regexp.MustCompile(`^\d{1,19}$`)
type markdownDiffSpec struct {
FileToken string
FromVersion string
ToVersion string
FilePath string
ContextLines int
Format string
}
type markdownDiffHunk struct {
Header string `json:"header"`
OldStart int `json:"old_start"`
OldLines int `json:"old_lines"`
NewStart int `json:"new_start"`
NewLines int `json:"new_lines"`
}
type markdownDiffLineKind int
const (
markdownDiffLineEqual markdownDiffLineKind = iota
markdownDiffLineDelete
markdownDiffLineInsert
)
type markdownDiffLineOp struct {
Kind markdownDiffLineKind
Content string
}
type markdownDiffHunkRange struct {
Start int
End int
}
func validateMarkdownDiffSpec(runtime *common.RuntimeContext, spec markdownDiffSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
}
if spec.FromVersion != "" {
if err := validateMarkdownDiffVersionValue(spec.FromVersion, "--from-version"); err != nil {
return err
}
}
if spec.ToVersion != "" {
if err := validateMarkdownDiffVersionValue(spec.ToVersion, "--to-version"); err != nil {
return err
}
}
if spec.FilePath != "" {
if _, err := validate.SafeInputPath(spec.FilePath); err != nil {
return output.ErrValidation("unsafe file path: %s", err)
}
if err := validateMarkdownFileName(spec.FilePath, "--file"); err != nil {
return err
}
}
if spec.ContextLines < 0 {
return output.ErrValidation("--context-lines must be >= 0")
}
if spec.Format != "" && spec.Format != "json" && spec.Format != "pretty" {
return output.ErrValidation("markdown +diff only supports --format json or pretty")
}
if spec.FilePath == "" {
if spec.FromVersion == "" && spec.ToVersion == "" {
return common.FlagErrorf("specify --from-version, or both --from-version and --to-version, or use --file for remote vs local diff")
}
if spec.FromVersion == "" && spec.ToVersion != "" {
return common.FlagErrorf("--to-version requires --from-version")
}
return nil
}
if spec.ToVersion != "" {
return common.FlagErrorf("--to-version is not supported together with --file")
}
return nil
}
func validateMarkdownDiffVersionValue(value, flagName string) error {
value = strings.TrimSpace(value)
if value == "" {
return output.ErrValidation("%s cannot be empty", flagName)
}
if !markdownDiffVersionRe.MatchString(value) {
return output.ErrValidation("%s must be a numeric version string", flagName)
}
return nil
}
func markdownDiffMode(spec markdownDiffSpec) string {
if spec.FilePath != "" {
return markdownDiffModeRemoteVsLocal
}
return markdownDiffModeRemoteVsRemote
}
func markdownDiffDryRun(spec markdownDiffSpec) *common.DryRunAPI {
dry := common.NewDryRunAPI().Desc("Download the requested Markdown content, compute a unified diff locally, and print the result without modifying the remote file")
switch markdownDiffMode(spec) {
case markdownDiffModeRemoteVsLocal:
if spec.FromVersion != "" {
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[1] Download the specified remote Markdown version").
Set("file_token", spec.FileToken).
Params(map[string]interface{}{"version": spec.FromVersion})
} else {
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[1] Download the latest remote Markdown version").
Set("file_token", spec.FileToken)
}
dry.Set("local_file", spec.FilePath)
dry.Set("mode", markdownDiffModeRemoteVsLocal)
default:
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[1] Download the base remote Markdown version").
Set("file_token", spec.FileToken).
Params(map[string]interface{}{"version": spec.FromVersion})
if spec.ToVersion != "" {
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[2] Download the target remote Markdown version").
Set("file_token", spec.FileToken).
Params(map[string]interface{}{"version": spec.ToVersion})
} else {
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[2] Download the latest remote Markdown version").
Set("file_token", spec.FileToken)
}
dry.Set("mode", markdownDiffModeRemoteVsRemote)
}
dry.Set("context_lines", spec.ContextLines)
return dry
}
func downloadMarkdownContent(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (string, string, error) {
resp, fileName, err := openMarkdownDownloadVersion(ctx, runtime, fileToken, version)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
payload, err := readMarkdownDiffPayload(resp.Body, "remote Markdown content")
if err != nil {
return "", "", wrapMarkdownDownloadError(err)
}
return fileName, string(payload), nil
}
func readMarkdownLocalFile(runtime *common.RuntimeContext, filePath string) (string, error) {
f, err := runtime.FileIO().Open(filePath)
if err != nil {
return "", common.WrapInputStatError(err)
}
defer f.Close()
payload, err := readMarkdownDiffPayload(f, "local Markdown file")
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return "", err
}
return "", output.ErrValidation("cannot read file: %s", err)
}
return string(payload), nil
}
func readMarkdownDiffPayload(r io.Reader, source string) ([]byte, error) {
payload, err := io.ReadAll(io.LimitReader(r, markdownDiffMaxContentBytes+1))
if err != nil {
return nil, err
}
if len(payload) > markdownDiffMaxContentBytes {
return nil, output.ErrValidation("%s exceeds %s markdown +diff content limit", source, common.FormatSize(markdownDiffMaxContentBytes))
}
return payload, nil
}
func splitMarkdownDiffLines(text string) []string {
if text == "" {
return nil
}
lines := strings.SplitAfter(text, "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
return lines
}
func markdownDiffLineOps(fromContent, toContent string) []markdownDiffLineOp {
dmp := diffmatchpatch.New()
dmp.DiffTimeout = markdownDiffTimeout
before, after, lineArray := dmp.DiffLinesToRunes(fromContent, toContent)
diffs := dmp.DiffMainRunes(before, after, false)
// Keep the diff line-based. Running cleanup after hydrating real text
// would re-split replacements into word-level edits.
diffs = dmp.DiffCharsToLines(diffs, lineArray)
ops := make([]markdownDiffLineOp, 0, len(diffs))
for _, diff := range diffs {
lines := splitMarkdownDiffLines(diff.Text)
for _, line := range lines {
switch diff.Type {
case diffmatchpatch.DiffDelete:
ops = append(ops, markdownDiffLineOp{Kind: markdownDiffLineDelete, Content: line})
case diffmatchpatch.DiffInsert:
ops = append(ops, markdownDiffLineOp{Kind: markdownDiffLineInsert, Content: line})
default:
ops = append(ops, markdownDiffLineOp{Kind: markdownDiffLineEqual, Content: line})
}
}
}
return ops
}
func markdownDiffSummary(ops []markdownDiffLineOp) (bool, int, int) {
added := 0
deleted := 0
changed := false
for _, op := range ops {
switch op.Kind {
case markdownDiffLineDelete:
changed = true
deleted++
case markdownDiffLineInsert:
changed = true
added++
}
}
return changed, added, deleted
}
func markdownDiffHunkRanges(ops []markdownDiffLineOp, contextLines int) []markdownDiffHunkRange {
if len(ops) == 0 {
return nil
}
changedLines := make([]int, 0)
for i, op := range ops {
if op.Kind != markdownDiffLineEqual {
changedLines = append(changedLines, i)
}
}
if len(changedLines) == 0 {
return nil
}
ranges := make([]markdownDiffHunkRange, 0, len(changedLines))
current := markdownDiffHunkRange{
Start: max(0, changedLines[0]-contextLines),
End: min(len(ops), changedLines[0]+contextLines+1),
}
for _, idx := range changedLines[1:] {
next := markdownDiffHunkRange{
Start: max(0, idx-contextLines),
End: min(len(ops), idx+contextLines+1),
}
if next.Start <= current.End {
if next.End > current.End {
current.End = next.End
}
continue
}
ranges = append(ranges, current)
current = next
}
ranges = append(ranges, current)
return ranges
}
func markdownDiffHunkAt(ops []markdownDiffLineOp, r markdownDiffHunkRange) markdownDiffHunk {
oldBefore := 0
newBefore := 0
for _, op := range ops[:r.Start] {
if op.Kind != markdownDiffLineInsert {
oldBefore++
}
if op.Kind != markdownDiffLineDelete {
newBefore++
}
}
oldLines := 0
newLines := 0
for _, op := range ops[r.Start:r.End] {
if op.Kind != markdownDiffLineInsert {
oldLines++
}
if op.Kind != markdownDiffLineDelete {
newLines++
}
}
oldStart := oldBefore + 1
newStart := newBefore + 1
if oldLines == 0 {
oldStart = oldBefore
}
if newLines == 0 {
newStart = newBefore
}
return markdownDiffHunk{
Header: fmt.Sprintf("@@ -%d,%d +%d,%d @@", oldStart, oldLines, newStart, newLines),
OldStart: oldStart,
OldLines: oldLines,
NewStart: newStart,
NewLines: newLines,
}
}
func buildMarkdownUnifiedDiff(fromLabel, toLabel string, ops []markdownDiffLineOp, ranges []markdownDiffHunkRange) string {
if len(ranges) == 0 {
return ""
}
var b strings.Builder
fmt.Fprintf(&b, "--- %s\n", fromLabel)
fmt.Fprintf(&b, "+++ %s\n", toLabel)
for _, r := range ranges {
hunk := markdownDiffHunkAt(ops, r)
b.WriteString(hunk.Header)
b.WriteByte('\n')
for _, op := range ops[r.Start:r.End] {
prefix := ' '
switch op.Kind {
case markdownDiffLineDelete:
prefix = '-'
case markdownDiffLineInsert:
prefix = '+'
}
b.WriteByte(byte(prefix))
b.WriteString(op.Content)
if !strings.HasSuffix(op.Content, "\n") {
b.WriteByte('\n')
b.WriteString(`\ No newline at end of file`)
b.WriteByte('\n')
}
}
}
return b.String()
}
func summarizeMarkdownDiff(fromLabel, toLabel, fromContent, toContent string, contextLines int) (string, bool, int, int, []markdownDiffHunk) {
ops := markdownDiffLineOps(fromContent, toContent)
changed, added, deleted := markdownDiffSummary(ops)
ranges := markdownDiffHunkRanges(ops, contextLines)
hunks := make([]markdownDiffHunk, 0, len(ranges))
for _, r := range ranges {
hunks = append(hunks, markdownDiffHunkAt(ops, r))
}
return buildMarkdownUnifiedDiff(fromLabel, toLabel, ops, ranges), changed, added, deleted, hunks
}
func colorizeUnifiedDiff(diffText string) string {
if diffText == "" {
return ""
}
lines := strings.SplitAfter(diffText, "\n")
var b strings.Builder
for _, line := range lines {
trimmed := strings.TrimRight(line, "\n")
suffix := ""
if strings.HasSuffix(line, "\n") {
suffix = "\n"
}
switch {
case strings.HasPrefix(trimmed, "@@"):
b.WriteString(output.Cyan)
b.WriteString(trimmed)
b.WriteString(output.Reset)
case strings.HasPrefix(trimmed, "+++"), strings.HasPrefix(trimmed, "---"):
b.WriteString(output.Bold)
b.WriteString(trimmed)
b.WriteString(output.Reset)
case strings.HasPrefix(trimmed, "+") && !strings.HasPrefix(trimmed, "+++"):
b.WriteString(output.Green)
b.WriteString(trimmed)
b.WriteString(output.Reset)
case strings.HasPrefix(trimmed, "-") && !strings.HasPrefix(trimmed, "---"):
b.WriteString(output.Red)
b.WriteString(trimmed)
b.WriteString(output.Reset)
default:
b.WriteString(trimmed)
}
b.WriteString(suffix)
}
return b.String()
}
func prettyPrintMarkdownDiff(w io.Writer, data map[string]interface{}) {
if !common.GetBool(data, "changed") {
io.WriteString(w, "No differences.\n")
return
}
io.WriteString(w, colorizeUnifiedDiff(common.GetString(data, "diff")))
}
var MarkdownDiff = common.Shortcut{
Service: "markdown",
Command: "+diff",
Description: "Compare remote Markdown versions or compare remote Markdown against a local file",
Risk: "read",
Scopes: []string{"drive:file:download"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "file-token", Desc: "target Markdown file token", Required: true},
{Name: "from-version", Desc: "base remote version; when --to-version is omitted, compare this version to the latest remote version"},
{Name: "to-version", Desc: "target remote version; requires --from-version"},
{Name: "file", Desc: "local .md file path to compare against the remote content"},
{Name: "context-lines", Desc: "number of unchanged context lines to include around each diff hunk", Type: "int", Default: "3"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateMarkdownDiffSpec(runtime, markdownDiffSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
FilePath: strings.TrimSpace(runtime.Str("file")),
ContextLines: runtime.Int("context-lines"),
Format: runtime.Format,
})
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return markdownDiffDryRun(markdownDiffSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
FilePath: strings.TrimSpace(runtime.Str("file")),
ContextLines: runtime.Int("context-lines"),
})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := markdownDiffSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
FilePath: strings.TrimSpace(runtime.Str("file")),
ContextLines: runtime.Int("context-lines"),
}
var (
fromLabel string
toLabel string
fromContent string
toContent string
err error
)
switch markdownDiffMode(spec) {
case markdownDiffModeRemoteVsLocal:
fromLabel = "a/" + spec.FileToken
if spec.FromVersion != "" {
fromLabel += "@version:" + spec.FromVersion
} else {
fromLabel += "@latest"
}
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion)
if err != nil {
return err
}
toLabel = "b/" + spec.FilePath
toContent, err = readMarkdownLocalFile(runtime, spec.FilePath)
if err != nil {
return err
}
default:
fromLabel = "a/" + spec.FileToken + "@version:" + spec.FromVersion
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion)
if err != nil {
return err
}
if spec.ToVersion != "" {
toLabel = "b/" + spec.FileToken + "@version:" + spec.ToVersion
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.ToVersion)
} else {
toLabel = "b/" + spec.FileToken + "@latest"
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, "")
}
if err != nil {
return err
}
}
diffText, changed, addedLines, deletedLines, hunks := summarizeMarkdownDiff(fromLabel, toLabel, fromContent, toContent, spec.ContextLines)
out := map[string]interface{}{
"changed": changed,
"mode": markdownDiffMode(spec),
"file_token": spec.FileToken,
"from_version": spec.FromVersion,
"to_version": spec.ToVersion,
"from_label": fromLabel,
"to_label": toLabel,
"added_lines": addedLines,
"deleted_lines": deletedLines,
"context_lines": spec.ContextLines,
"hunks": hunks,
"diff": diffText,
}
if spec.FilePath != "" {
out["local_file"] = spec.FilePath
}
runtime.OutFormatRaw(out, nil, func(w io.Writer) {
prettyPrintMarkdownDiff(w, out)
})
return nil
},
}

View File

@@ -0,0 +1,379 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package markdown
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestMarkdownDiffRejectsUnsupportedFormat(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
"+diff",
"--file-token", "box_md_diff",
"--from-version", "7633658129540910621",
"--format", "table",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "only supports --format json or pretty") {
t.Fatalf("expected format validation error, got %v", err)
}
}
func TestMarkdownDiffRejectsToVersionWithoutFromVersion(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
"+diff",
"--file-token", "box_md_diff",
"--to-version", "7633658129540910628",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "--to-version requires --from-version") {
t.Fatalf("expected version validation error, got %v", err)
}
}
func TestMarkdownDiffRemoteVsRemoteJSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
Status: 200,
RawBody: []byte("# Title\n\n- alpha\n- beta\n"),
Headers: http.Header{
"Content-Disposition": []string{`attachment; filename="README.md"`},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628",
Status: 200,
RawBody: []byte("# Title\n\n- alpha\n- beta updated\n- gamma\n"),
Headers: http.Header{
"Content-Disposition": []string{`attachment; filename="README.md"`},
},
})
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
"+diff",
"--file-token", "box_md_diff",
"--from-version", "7633658129540910621",
"--to-version", "7633658129540910628",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var env struct {
OK bool `json:"ok"`
Data struct {
Changed bool `json:"changed"`
Mode string `json:"mode"`
FromVersion string `json:"from_version"`
ToVersion string `json:"to_version"`
AddedLines int `json:"added_lines"`
DeletedLines int `json:"deleted_lines"`
Diff string `json:"diff"`
Hunks []markdownDiffHunk `json:"hunks"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("json unmarshal error: %v\n%s", err, stdout.String())
}
if !env.OK {
t.Fatalf("expected ok=true, got false: %s", stdout.String())
}
if !env.Data.Changed {
t.Fatalf("expected changed=true: %s", stdout.String())
}
if env.Data.Mode != markdownDiffModeRemoteVsRemote {
t.Fatalf("mode = %q, want %q", env.Data.Mode, markdownDiffModeRemoteVsRemote)
}
if env.Data.FromVersion != "7633658129540910621" || env.Data.ToVersion != "7633658129540910628" {
t.Fatalf("versions = %q -> %q", env.Data.FromVersion, env.Data.ToVersion)
}
if env.Data.AddedLines != 2 || env.Data.DeletedLines != 1 {
t.Fatalf("added/deleted = %d/%d, want 2/1", env.Data.AddedLines, env.Data.DeletedLines)
}
if len(env.Data.Hunks) != 1 {
t.Fatalf("len(hunks) = %d, want 1", len(env.Data.Hunks))
}
if !strings.Contains(env.Data.Diff, "@@") || !strings.Contains(env.Data.Diff, "+- gamma") {
t.Fatalf("diff missing expected content: %s", env.Data.Diff)
}
}
func TestMarkdownDiffRemoteVsLocalPretty(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download",
Status: 200,
RawBody: []byte("# Title\n\nhello old\n"),
Headers: http.Header{
"Content-Disposition": []string{`attachment; filename="README.md"`},
},
})
tmpDir := t.TempDir()
withMarkdownWorkingDir(t, tmpDir)
if err := os.WriteFile("local.md", []byte("# Title\n\nhello new\n"), 0o644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
"+diff",
"--file-token", "box_md_diff",
"--file", "./local.md",
"--format", "pretty",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "@@") {
t.Fatalf("pretty output missing hunk header: %s", stdout.String())
}
if !strings.Contains(stdout.String(), output.Red+"-hello old"+output.Reset) {
t.Fatalf("pretty output missing removed line color: %q", stdout.String())
}
if !strings.Contains(stdout.String(), output.Green+"+hello new"+output.Reset) {
t.Fatalf("pretty output missing added line color: %q", stdout.String())
}
}
func TestMarkdownDiffRejectsOversizedRemoteContent(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download",
Status: 200,
RawBody: bytes.Repeat([]byte("x"), markdownDiffMaxContentBytes+1),
})
tmpDir := t.TempDir()
withMarkdownWorkingDir(t, tmpDir)
if err := os.WriteFile("local.md", []byte("# Title\n"), 0o644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
"+diff",
"--file-token", "box_md_diff",
"--file", "./local.md",
"--as", "bot",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "remote Markdown content exceeds 10.0 MB markdown +diff content limit") {
t.Fatalf("expected remote content size error, got %v", err)
}
}
func TestMarkdownDiffRejectsOversizedLocalContent(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download",
Status: 200,
RawBody: []byte("# Title\n"),
})
tmpDir := t.TempDir()
withMarkdownWorkingDir(t, tmpDir)
if err := os.WriteFile("local.md", bytes.Repeat([]byte("x"), markdownDiffMaxContentBytes+1), 0o644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
"+diff",
"--file-token", "box_md_diff",
"--file", "./local.md",
"--as", "bot",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "local Markdown file exceeds 10.0 MB markdown +diff content limit") {
t.Fatalf("expected local content size error, got %v", err)
}
}
func TestMarkdownDownloadErrorPreservesStructuredErrors(t *testing.T) {
apiErr := output.ErrAPI(99991663, "permission denied", map[string]interface{}{"permission": "drive:file:download"})
if got := wrapMarkdownDownloadError(apiErr); got != apiErr {
t.Fatalf("wrapMarkdownDownloadError() = %v, want original API error", got)
}
got := wrapMarkdownDownloadError(errors.New("dial tcp timeout"))
var exitErr *output.ExitError
if !errors.As(got, &exitErr) {
t.Fatalf("wrapMarkdownDownloadError() = %T, want *output.ExitError", got)
}
if exitErr.Code != output.ExitNetwork {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitNetwork)
}
if !strings.Contains(got.Error(), "download failed: dial tcp timeout") {
t.Fatalf("wrapped error = %q", got.Error())
}
}
func TestMarkdownDiffIncludesNoNewlineMarker(t *testing.T) {
diffText, changed, added, deleted, hunks := summarizeMarkdownDiff(
"a/test.md",
"b/test.md",
"# Title\n\nhello old",
"# Title\n\nhello new",
3,
)
if !changed {
t.Fatalf("expected changed=true")
}
if added != 1 || deleted != 1 {
t.Fatalf("added/deleted = %d/%d, want 1/1", added, deleted)
}
if len(hunks) != 1 {
t.Fatalf("len(hunks) = %d, want 1", len(hunks))
}
if strings.Count(diffText, "\\ No newline at end of file") != 2 {
t.Fatalf("diff should contain two no-newline markers: %q", diffText)
}
if !strings.Contains(diffText, "-hello old\n\\ No newline at end of file\n+hello new\n\\ No newline at end of file\n") {
t.Fatalf("diff missing expected no-newline marker sequence: %q", diffText)
}
}
func TestMarkdownDiffRemoteVsRemoteJSONMultipleHunks(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
Status: 200,
RawBody: []byte("line1\nline2\nline3\nline4\nline5\nline6\n"),
Headers: http.Header{
"Content-Disposition": []string{`attachment; filename="README.md"`},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628",
Status: 200,
RawBody: []byte("line1\nline2 changed\nline3\nline4\nline5 changed\nline6\n"),
Headers: http.Header{
"Content-Disposition": []string{`attachment; filename="README.md"`},
},
})
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
"+diff",
"--file-token", "box_md_diff",
"--from-version", "7633658129540910621",
"--to-version", "7633658129540910628",
"--context-lines", "0",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var env struct {
OK bool `json:"ok"`
Data struct {
Changed bool `json:"changed"`
AddedLines int `json:"added_lines"`
DeletedLines int `json:"deleted_lines"`
Hunks []markdownDiffHunk `json:"hunks"`
Diff string `json:"diff"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("json unmarshal error: %v\n%s", err, stdout.String())
}
if !env.OK || !env.Data.Changed {
t.Fatalf("expected changed=true: %s", stdout.String())
}
if env.Data.AddedLines != 2 || env.Data.DeletedLines != 2 {
t.Fatalf("added/deleted = %d/%d, want 2/2", env.Data.AddedLines, env.Data.DeletedLines)
}
if len(env.Data.Hunks) != 2 {
t.Fatalf("len(hunks) = %d, want 2", len(env.Data.Hunks))
}
if !strings.Contains(env.Data.Diff, "-line2") || !strings.Contains(env.Data.Diff, "+line5 changed") {
t.Fatalf("diff missing expected content: %s", env.Data.Diff)
}
}
func TestMarkdownDiffNoChangesPretty(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
Status: 200,
RawBody: []byte("# Title\n"),
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download",
Status: 200,
RawBody: []byte("# Title\n"),
})
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
"+diff",
"--file-token", "box_md_diff",
"--from-version", "7633658129540910621",
"--format", "pretty",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := strings.TrimSpace(stdout.String()); got != "No differences." {
t.Fatalf("pretty no-change output = %q, want %q", got, "No differences.")
}
}
func TestMarkdownDiffDryRunRemoteVsLocal(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
tmpDir := t.TempDir()
withMarkdownWorkingDir(t, tmpDir)
localPath := filepath.Join(".", "local.md")
if err := os.WriteFile(localPath, []byte("# local\n"), 0o644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunMarkdown(t, MarkdownDiff, []string{
"+diff",
"--file-token", "box_md_diff",
"--file", localPath,
"--dry-run",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/:file_token/download") && !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/box_md_diff/download") {
t.Fatalf("dry-run missing download call: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `"local_file": "local.md"`) && !strings.Contains(stdout.String(), `"local_file": "./local.md"`) {
t.Fatalf("dry-run missing local file metadata: %s", stdout.String())
}
}

View File

@@ -33,6 +33,13 @@ func markdownTestConfig() *core.CliConfig {
}
}
func markdownPermissionTestConfig(userOpenID string) *core.CliConfig {
return &core.CliConfig{
AppID: "markdown-perm-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
UserOpenId: userOpenID,
}
}
func mountAndRunMarkdown(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "markdown"}
@@ -182,7 +189,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
t.Parallel()
got := Shortcuts()
want := []string{"+create", "+fetch", "+patch", "+overwrite"}
want := []string{"+create", "+diff", "+fetch", "+patch", "+overwrite"}
if len(got) != len(want) {
t.Fatalf("len(Shortcuts()) = %d, want %d", len(got), len(want))
@@ -269,6 +276,27 @@ func TestMarkdownCreateValidationBranches(t *testing.T) {
},
want: "--folder-token cannot be empty",
},
{
name: "wiki token cannot be empty",
args: []string{
"+create",
"--name", "README.md",
"--content", "# hello",
"--wiki-token=",
},
want: "--wiki-token cannot be empty",
},
{
name: "folder and wiki tokens are mutually exclusive",
args: []string{
"+create",
"--name", "README.md",
"--content", "# hello",
"--folder-token", "fld_target",
"--wiki-token", "wikcn_target",
},
want: "--folder-token and --wiki-token are mutually exclusive",
},
{
name: "folder token must be valid",
args: []string{
@@ -279,6 +307,16 @@ func TestMarkdownCreateValidationBranches(t *testing.T) {
},
want: "--folder-token",
},
{
name: "wiki token must be valid",
args: []string{
"+create",
"--name", "README.md",
"--content", "# hello",
"--wiki-token", "../bad",
},
want: "--wiki-token",
},
{
name: "content mode still validates markdown file name",
args: []string{
@@ -372,11 +410,40 @@ func TestMarkdownCreateDryRunWithInlineContent(t *testing.T) {
if !strings.Contains(out, "/open-apis/drive/v1/files/upload_all") {
t.Fatalf("dry-run missing upload_all: %s", out)
}
if !strings.Contains(out, "/open-apis/drive/v1/metas/batch_query") || !strings.Contains(out, `"with_url": true`) {
t.Fatalf("dry-run missing metadata URL lookup: %s", out)
}
if !strings.Contains(out, "markdown content") {
t.Fatalf("dry-run missing content marker: %s", out)
}
}
func TestMarkdownCreateDryRunWithWikiToken(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
"--name", "README.md",
"--content", "# hello",
"--wiki-token", "wikcn_markdown_dryrun_target",
"--dry-run",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"parent_type": "wiki"`) {
t.Fatalf("dry-run missing wiki parent_type: %s", out)
}
if !strings.Contains(out, `"parent_node": "wikcn_markdown_dryrun_target"`) {
t.Fatalf("dry-run missing wiki parent_node: %s", out)
}
if !strings.Contains(out, "/open-apis/drive/v1/metas/batch_query") || !strings.Contains(out, `"with_url": true`) {
t.Fatalf("dry-run missing metadata URL lookup: %s", out)
}
}
func TestMarkdownCreateDryRunReportsSourceFileError(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
@@ -416,6 +483,9 @@ func TestMarkdownCreateDryRunWithFileUsesStatOnly(t *testing.T) {
if !strings.Contains(out, "/open-apis/drive/v1/files/upload_prepare") {
t.Fatalf("dry-run missing multipart prepare step: %s", out)
}
if !strings.Contains(out, "/open-apis/drive/v1/metas/batch_query") || !strings.Contains(out, `"with_url": true`) {
t.Fatalf("dry-run missing metadata URL lookup: %s", out)
}
if strings.Contains(out, "open should not be called in dry-run") {
t.Fatalf("dry-run unexpectedly tried to open the source file: %s", out)
}
@@ -435,6 +505,18 @@ func TestMarkdownCreateSuccessUploadAll(t *testing.T) {
},
}
reg.Register(uploadStub)
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": "box_md_create", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_create"},
},
},
},
})
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
@@ -467,11 +549,60 @@ func TestMarkdownCreateSuccessUploadAll(t *testing.T) {
if !strings.Contains(stdout.String(), `"file_name": "README.md"`) {
t.Fatalf("stdout missing file_name: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `"url": "https://www.feishu.cn/file/box_md_create"`) {
if !strings.Contains(stdout.String(), `"url": "https://tenant.example.com/file/box_md_create"`) {
t.Fatalf("stdout missing url: %s", stdout.String())
}
}
func TestMarkdownCreateSuccessUploadAllToWikiReturnsMetaURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
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_create_wiki",
"version": "1002",
},
},
}
reg.Register(uploadStub)
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": "box_md_create_wiki", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_create_wiki"},
},
},
},
})
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
"--name", "README.md",
"--content", "# hello\n",
"--wiki-token", "wikcn_markdown_create_target",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeCapturedMultipartBody(t, uploadStub)
if got := body.Fields["parent_type"]; got != markdownUploadParentTypeWiki {
t.Fatalf("parent_type = %q, want %q", got, markdownUploadParentTypeWiki)
}
if got := body.Fields["parent_node"]; got != "wikcn_markdown_create_target" {
t.Fatalf("parent_node = %q, want %q", got, "wikcn_markdown_create_target")
}
if !strings.Contains(stdout.String(), `"url": "https://tenant.example.com/file/box_md_create_wiki"`) {
t.Fatalf("stdout missing metadata url for wiki-hosted markdown file: %s", stdout.String())
}
}
func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
@@ -484,6 +615,18 @@ func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.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": "box_md_create_pretty", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_create_pretty"},
},
},
},
})
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
@@ -500,7 +643,7 @@ func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.T) {
if !strings.Contains(out, "file_token: box_md_create_pretty") {
t.Fatalf("pretty output missing file_token: %s", out)
}
if !strings.Contains(out, "url: https://www.feishu.cn/file/box_md_create_pretty") {
if !strings.Contains(out, "url: https://tenant.example.com/file/box_md_create_pretty") {
t.Fatalf("pretty output missing url: %s", out)
}
if !strings.Contains(out, "permission_grant.status: skipped") {
@@ -511,6 +654,114 @@ func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.T) {
}
}
func TestMarkdownCreateBotAutoGrantSkippedNoUser(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownPermissionTestConfig(""))
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_skipped",
},
},
})
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": "box_md_skipped", "doc_type": "file", "url": "https://example.feishu.cn/file/box_md_skipped"},
},
},
},
})
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
"--name", "README.md",
"--content", "# hello\n",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var envelope struct {
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal: %v", err)
}
grant, _ := envelope.Data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantSkipped {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
}
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "auth login") {
t.Fatalf("hint = %#v, want string containing 'auth login'", grant["hint"])
}
}
func TestMarkdownCreateBotAutoGrantFailed(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownPermissionTestConfig("ou_current_user"))
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_grant_fail",
},
},
})
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": "box_md_grant_fail", "doc_type": "file", "url": "https://example.feishu.cn/file/box_md_grant_fail"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/box_md_grant_fail/members",
Body: map[string]interface{}{
"code": 230001,
"msg": "no permission",
},
})
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
"--name", "README.md",
"--content", "# hello\n",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var envelope struct {
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal: %v", err)
}
grant, _ := envelope.Data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "Retry later") {
t.Fatalf("hint = %#v, want string containing 'Retry later'", grant["hint"])
}
}
func TestMarkdownCreateMissingFileReturnsReadError(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
@@ -558,6 +809,18 @@ func TestMarkdownCreateMultipartUploadSuccess(t *testing.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": "box_md_multipart", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_multipart"},
},
},
},
})
tmpDir := t.TempDir()
withMarkdownWorkingDir(t, tmpDir)
@@ -586,6 +849,96 @@ func TestMarkdownCreateMultipartUploadSuccess(t *testing.T) {
if !strings.Contains(stdout.String(), `"file_token": "box_md_multipart"`) {
t.Fatalf("stdout missing multipart file_token: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `"url": "https://tenant.example.com/file/box_md_multipart"`) {
t.Fatalf("stdout missing multipart metadata url: %s", stdout.String())
}
}
func TestMarkdownCreateMultipartUploadToWikiUsesWikiParent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
prepareStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_markdown_wiki_ok",
"block_size": float64(markdownSinglePartSizeLimit),
"block_num": float64(2),
},
},
}
reg.Register(prepareStub)
uploadPartStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_part",
Reusable: true,
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
},
}
reg.Register(uploadPartStub)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_finish",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"file_token": "box_md_multipart_wiki",
"version": "1005",
},
},
})
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": "box_md_multipart_wiki", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_multipart_wiki"},
},
},
},
})
tmpDir := t.TempDir()
withMarkdownWorkingDir(t, tmpDir)
fh, err := os.Create("large.md")
if err != nil {
t.Fatalf("Create() error: %v", err)
}
if err := fh.Truncate(markdownSinglePartSizeLimit + 1); err != nil {
fh.Close()
t.Fatalf("Truncate() error: %v", err)
}
if err := fh.Close(); err != nil {
t.Fatalf("Close() error: %v", err)
}
err = mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
"--file", "large.md",
"--wiki-token", "wikcn_markdown_multipart_target",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(prepareStub.CapturedBody, &body); err != nil {
t.Fatalf("decode upload_prepare body: %v\nraw=%s", err, string(prepareStub.CapturedBody))
}
if got := body["parent_type"]; got != markdownUploadParentTypeWiki {
t.Fatalf("parent_type = %#v, want %q", got, markdownUploadParentTypeWiki)
}
if got := body["parent_node"]; got != "wikcn_markdown_multipart_target" {
t.Fatalf("parent_node = %#v, want %q", got, "wikcn_markdown_multipart_target")
}
if !strings.Contains(stdout.String(), `"url": "https://tenant.example.com/file/box_md_multipart_wiki"`) {
t.Fatalf("stdout missing metadata url for wiki-hosted multipart markdown file: %s", stdout.String())
}
}
func TestMarkdownCreateFailsWhenMultipartPlanIsTooSmall(t *testing.T) {

View File

@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
MarkdownCreate,
MarkdownDiff,
MarkdownFetch,
MarkdownPatch,
MarkdownOverwrite,

View File

@@ -5,13 +5,18 @@ package shortcuts
import (
"context"
"fmt"
"slices"
"github.com/larksuite/cli/shortcuts/okr"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts/apps"
"github.com/larksuite/cli/shortcuts/base"
"github.com/larksuite/cli/shortcuts/calendar"
"github.com/larksuite/cli/shortcuts/common"
@@ -31,10 +36,28 @@ import (
"github.com/larksuite/cli/shortcuts/wiki"
)
// Empty brand (no config loaded) is treated as no-restriction so bootstrap
// paths and tests without config still see the full service list.
var brandRestrictedServices = map[string][]core.LarkBrand{
"apps": {core.BrandFeishu},
}
func IsShortcutServiceAvailable(service string, brand core.LarkBrand) bool {
allowed, ok := brandRestrictedServices[service]
if !ok {
return true
}
if brand == "" {
return true
}
return slices.Contains(allowed, brand)
}
// allShortcuts aggregates shortcuts from all domain packages.
var allShortcuts []common.Shortcut
func init() {
allShortcuts = append(allShortcuts, apps.Shortcuts()...)
allShortcuts = append(allShortcuts, calendar.Shortcuts()...)
allShortcuts = append(allShortcuts, doc.Shortcuts()...)
allShortcuts = append(allShortcuts, drive.Shortcuts()...)
@@ -67,6 +90,14 @@ func RegisterShortcuts(program *cobra.Command, f *cmdutil.Factory) {
}
func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f *cmdutil.Factory) {
// Factory.Config may be nil in tests that pass a zero-value factory.
var brand core.LarkBrand
if f != nil && f.Config != nil {
if cfg, err := f.Config(); err == nil && cfg != nil {
brand = cfg.Brand
}
}
// Group by service
byService := make(map[string][]common.Shortcut)
for _, s := range allShortcuts {
@@ -115,5 +146,46 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
if service == "mail" {
mail.InstallOnMail(svc)
}
if !IsShortcutServiceAvailable(service, brand) {
installBrandRestrictionGuard(svc, service, brand)
}
}
}
// Mirrors internal/cmdpolicy/apply.go::installDenyStub: DisableFlagParsing +
// ArbitraryArgs keep cobra from short-circuiting with "missing required flag"
// before our RunE runs; leaf-level PersistentPreRunE defeats cobra's "first
// PreRunE wins" walk-up that would otherwise shadow the stub.
func installBrandRestrictionGuard(svc *cobra.Command, service string, brand core.LarkBrand) {
stub := func(c *cobra.Command, _ []string) error {
c.SilenceUsage = true
return output.ErrValidation(
"the %q feature is not yet supported on the %s brand",
service, brand,
)
}
noopPreRun := func(c *cobra.Command, _ []string) error {
c.SilenceUsage = true
return nil
}
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
c.Hidden = true
c.DisableFlagParsing = true
c.Args = cobra.ArbitraryArgs
c.PreRunE = nil
c.PreRun = nil
c.PersistentPreRunE = noopPreRun
c.PersistentPreRun = nil
c.RunE = stub
c.Run = nil
for _, child := range c.Commands() {
walk(child)
}
}
walk(svc)
// --help bypasses RunE, so surface the restriction in Long too.
svc.Long = fmt.Sprintf("The %q feature is not yet supported on the %s brand.", service, brand)
}

View File

@@ -0,0 +1,122 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package shortcuts
import (
"context"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
func newFactoryWithBrand(brand core.LarkBrand) *cmdutil.Factory {
return &cmdutil.Factory{
Config: func() (*core.CliConfig, error) {
return &core.CliConfig{Brand: brand}, nil
},
}
}
func findChild(root *cobra.Command, name string) *cobra.Command {
for _, c := range root.Commands() {
if c.Name() == name {
return c
}
}
return nil
}
func TestBrandGuard_AppsStaysRegisteredOnLark(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newFactoryWithBrand(core.BrandLark))
apps := findChild(program, "apps")
if apps == nil {
t.Fatal("apps service command should be registered on Lark brand (so users see a clear brand error, not 'unknown command')")
}
if !apps.Hidden {
t.Error("apps service command should be Hidden on Lark brand")
}
if len(apps.Commands()) == 0 {
t.Error("apps subcommands should still be mounted (so children also hit the brand-restriction stub)")
}
for _, child := range apps.Commands() {
if !child.Hidden {
t.Errorf("apps child %q should be Hidden on Lark brand", child.Name())
}
}
}
func TestBrandGuard_AppsExecuteReturnsBrandError(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newFactoryWithBrand(core.BrandLark))
apps := findChild(program, "apps")
if apps == nil {
t.Fatal("apps should be registered")
}
create := findChild(apps, "+create")
if create == nil {
t.Fatal("apps +create should be registered")
}
err := create.RunE(create, []string{"--name", "x"})
if err == nil {
t.Fatal("expected brand-restriction error, got nil")
}
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("expected ExitValidation (%d), got %d", output.ExitValidation, exitErr.Code)
}
if !strings.Contains(exitErr.Error(), "apps") || !strings.Contains(exitErr.Error(), "lark") {
t.Errorf("expected error to mention apps + lark, got: %s", exitErr.Error())
}
}
func TestBrandGuard_AppsExecutableOnFeishu(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newFactoryWithBrand(core.BrandFeishu))
apps := findChild(program, "apps")
if apps == nil {
t.Fatal("apps should be registered on Feishu brand")
}
if apps.Hidden {
t.Error("apps should NOT be Hidden on Feishu brand")
}
create := findChild(apps, "+create")
if create == nil {
t.Fatal("apps +create should be registered on Feishu brand")
}
if create.DisableFlagParsing {
t.Error("apps +create should not have DisableFlagParsing on Feishu (the guard must not have run)")
}
}
func TestBrandGuard_DispatchHitsStubViaCobra(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newFactoryWithBrand(core.BrandLark))
program.SetArgs([]string{"apps", "+create", "--name", "x", "--app-type", "HTML"})
program.SetContext(context.Background())
err := program.Execute()
if err == nil {
t.Fatal("expected error from dispatching apps +create on Lark brand")
}
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError from cobra dispatch, got %T: %v", err, err)
}
if !strings.Contains(exitErr.Error(), "lark") {
t.Errorf("dispatched error should mention lark brand, got: %s", exitErr.Error())
}
}

View File

@@ -16,6 +16,7 @@ func TestRegisterShortcutsMountsMarkdownCommands(t *testing.T) {
for _, path := range [][]string{
{"markdown", "+create"},
{"markdown", "+diff"},
{"markdown", "+fetch"},
{"markdown", "+overwrite"},
} {

View File

@@ -5,13 +5,15 @@ package sheets
import (
"context"
"errors"
"fmt"
"io/fs"
"io"
"os"
"path/filepath"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -44,10 +46,6 @@ var SheetWriteImage = common.Shortcut{
if err := validateSingleCellRange(runtime.Str("range")); err != nil {
return err
}
_, _, err := validateSheetWriteImageFile(runtime.Str("image"))
if err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -79,12 +77,19 @@ var SheetWriteImage = common.Shortcut{
pointRange := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
imagePath := runtime.Str("image")
safePath, stat, err := validateSheetWriteImageFile(imagePath)
fio := runtime.FileIO()
stat, err := validateSheetWriteImageFile(fio, imagePath)
if err != nil {
return err
}
imageBytes, err := vfs.ReadFile(safePath)
imageFile, err := fio.Open(imagePath)
if err != nil {
return wrapSheetWriteImageOpenError(err)
}
defer imageFile.Close()
imageBytes, err := io.ReadAll(imageFile)
if err != nil {
return output.ErrValidation("cannot read image file: %s", err)
}
@@ -109,21 +114,37 @@ var SheetWriteImage = common.Shortcut{
},
}
func validateSheetWriteImageFile(imagePath string) (string, fs.FileInfo, error) {
safePath, err := validate.SafeInputPath(imagePath)
if err != nil {
return "", nil, output.ErrValidation("unsafe image path: %s", err)
func validateSheetWriteImageFile(fio fileio.FileIO, imagePath string) (fileio.FileInfo, error) {
if fio == nil {
return nil, output.ErrValidation("no file I/O provider registered")
}
stat, err := vfs.Stat(safePath)
stat, err := fio.Stat(imagePath)
if err != nil {
return "", nil, output.ErrValidation("image file not found: %s", imagePath)
return nil, wrapSheetWriteImageStatError(err, imagePath)
}
if !stat.Mode().IsRegular() {
return "", nil, output.ErrValidation("image must be a regular file: %s", imagePath)
if stat.IsDir() || !stat.Mode().IsRegular() {
return nil, output.ErrValidation("image must be a regular file: %s", imagePath)
}
const maxImageSize int64 = 20 * 1024 * 1024
if stat.Size() > maxImageSize {
return "", nil, output.ErrValidation("image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024)
return nil, output.ErrValidation("image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024)
}
return safePath, stat, nil
return stat, nil
}
func wrapSheetWriteImageStatError(err error, imagePath string) error {
if errors.Is(err, fileio.ErrPathValidation) {
return output.ErrValidation("unsafe image path: %s", err)
}
if os.IsNotExist(err) {
return output.ErrValidation("image file not found: %s", imagePath)
}
return output.ErrValidation("cannot stat image file: %s", err)
}
func wrapSheetWriteImageOpenError(err error) error {
if errors.Is(err, fileio.ErrPathValidation) {
return output.ErrValidation("unsafe image path: %s", err)
}
return output.ErrValidation("cannot read image file: %s", err)
}

View File

@@ -9,6 +9,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -463,7 +464,7 @@ func validateExpectedFlag(s string) error {
}
var arr []interface{}
if err := json.Unmarshal([]byte(s), &arr); err != nil {
return fmt.Errorf("--expected must be a JSON array (e.g. [\"6\"]), got: %s", s)
return output.ErrValidation("--expected must be a JSON array (e.g. [\"6\"]), got: %s", s)
}
return nil
}

View File

@@ -304,3 +304,88 @@ func decodeSheetCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]in
}
return data
}
func TestSheetCreateBotAutoGrantSkippedNoUser(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"spreadsheet": map[string]interface{}{
"spreadsheet_token": "shtcn_skipped",
"url": "https://example.feishu.cn/sheets/shtcn_skipped",
},
},
},
})
err := runSheetCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "No User Sheet",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSheetCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantSkipped {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
}
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "auth login") {
t.Fatalf("hint = %#v, want string containing 'auth login'", grant["hint"])
}
}
func TestSheetCreateBotAutoGrantFailed(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "ou_current_user"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"spreadsheet": map[string]interface{}{
"spreadsheet_token": "shtcn_grant_fail",
"url": "https://example.feishu.cn/sheets/shtcn_grant_fail",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/shtcn_grant_fail/members",
Body: map[string]interface{}{
"code": 230001,
"msg": "no permission",
},
})
err := runSheetCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "Grant Fail Sheet",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSheetCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "Retry later") {
t.Fatalf("hint = %#v, want string containing 'Retry later'", grant["hint"])
}
}

View File

@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
@@ -29,6 +30,15 @@ func TestSheetCreateSheetValidateMissingToken(t *testing.T) {
}
}
func TestSheetInfoRequiresSpreadsheetMetaAndReadScopes(t *testing.T) {
t.Parallel()
want := []string{"sheets:spreadsheet.meta:read", "sheets:spreadsheet:read"}
if !reflect.DeepEqual(SheetInfo.Scopes, want) {
t.Fatalf("SheetInfo scopes = %v, want %v", SheetInfo.Scopes, want)
}
}
func TestSheetManageValidateRejectsURLAndTokenTogether(t *testing.T) {
t.Parallel()

View File

@@ -7,12 +7,15 @@ import (
"bytes"
"context"
"encoding/json"
"io"
"io/fs"
"os"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -38,6 +41,56 @@ func mountAndRunSheets(t *testing.T, s common.Shortcut, args []string, f *cmduti
return parent.Execute()
}
type sheetWriteImageStaticFileIOProvider struct {
fio fileio.FileIO
}
func (p *sheetWriteImageStaticFileIOProvider) Name() string { return "sheet-write-image-static" }
func (p *sheetWriteImageStaticFileIOProvider) ResolveFileIO(context.Context) fileio.FileIO {
return p.fio
}
type sheetWriteImageMemoryFileIO struct {
files map[string][]byte
}
func (f *sheetWriteImageMemoryFileIO) Open(name string) (fileio.File, error) {
data, ok := f.files[name]
if !ok {
return nil, os.ErrNotExist
}
return sheetWriteImageMemoryFile{Reader: bytes.NewReader(data)}, nil
}
func (f *sheetWriteImageMemoryFileIO) Stat(name string) (fileio.FileInfo, error) {
data, ok := f.files[name]
if !ok {
return nil, os.ErrNotExist
}
return sheetWriteImageFileInfo{size: int64(len(data))}, nil
}
func (f *sheetWriteImageMemoryFileIO) ResolvePath(path string) (string, error) { return path, nil }
func (f *sheetWriteImageMemoryFileIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) {
return nil, nil
}
type sheetWriteImageMemoryFile struct {
*bytes.Reader
}
func (sheetWriteImageMemoryFile) Close() error { return nil }
type sheetWriteImageFileInfo struct {
size int64
}
func (i sheetWriteImageFileInfo) Size() int64 { return i.size }
func (i sheetWriteImageFileInfo) IsDir() bool { return false }
func (i sheetWriteImageFileInfo) Mode() fs.FileMode { return 0 }
const existingWriteImageTestFile = "./lark_sheets_cell_images.go"
// ── Validate ─────────────────────────────────────────────────────────────────
@@ -221,80 +274,20 @@ func TestSheetWriteImageDryRunWithSheetID(t *testing.T) {
}
}
func TestSheetWriteImageDryRunRejectsMissingFile(t *testing.T) {
func TestSheetWriteImageDryRunDoesNotValidateImageFile(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetWriteImage, []string{
"+write-image",
"--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:A1",
"--image", "./missing.png",
"--image", "/__bridge_url__/qKrk1wSAtS",
"--dry-run", "--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "image file not found") {
t.Fatalf("expected file-not-found error before dry-run planning, got: %v", err)
}
}
func TestSheetWriteImageDryRunRejectsDirectory(t *testing.T) {
tmpDir := t.TempDir()
cmdutil.TestChdir(t, tmpDir)
if err := os.Mkdir("imgdir", 0o755); err != nil {
t.Fatalf("Mkdir() error: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetWriteImage, []string{
"+write-image",
"--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:A1",
"--image", "./imgdir",
"--dry-run", "--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "regular file") {
t.Fatalf("expected regular-file error before dry-run planning, got: %v", err)
}
}
func TestSheetWriteImageDryRunRejectsAbsolutePath(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetWriteImage, []string{
"+write-image",
"--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:A1",
"--image", "/etc/passwd",
"--dry-run", "--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "unsafe image path") {
t.Fatalf("expected unsafe-path error before dry-run planning, got: %v", err)
}
}
func TestSheetWriteImageDryRunRejectsOversizedFile(t *testing.T) {
tmpDir := t.TempDir()
cmdutil.TestChdir(t, tmpDir)
fh, err := os.Create("huge.png")
if err != nil {
t.Fatalf("Create() error: %v", err)
t.Fatalf("dry-run should not stat or open image files, got: %v", err)
}
if err := fh.Truncate(20*1024*1024 + 1); err != nil {
fh.Close()
t.Fatalf("Truncate() error: %v", err)
}
if err := fh.Close(); err != nil {
t.Fatalf("Close() error: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err = mountAndRunSheets(t, SheetWriteImage, []string{
"+write-image",
"--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:A1",
"--image", "./huge.png",
"--dry-run", "--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "exceeds 20MB limit") {
t.Fatalf("expected size error before dry-run planning, got: %v", err)
if !strings.Contains(stdout.String(), "/__bridge_url__/qKrk1wSAtS") {
t.Fatalf("dry-run output should preserve image path: %s", stdout.String())
}
}
@@ -368,6 +361,55 @@ func TestSheetWriteImageExecuteSendsJSON(t *testing.T) {
}
}
func TestSheetWriteImageExecuteUsesFileIOForBridgeSentinelPath(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
imagePath := "/__bridge_url__/qKrk1wSAtS"
imageData := []byte{0x89, 0x50, 0x4E, 0x47}
f.FileIOProvider = &sheetWriteImageStaticFileIOProvider{
fio: &sheetWriteImageMemoryFileIO{
files: map[string][]byte{imagePath: imageData},
},
}
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/values_image",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"spreadsheetToken": "shtTOKEN",
"revision": float64(5),
"updateRange": "sheet1!A1:A1",
},
},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetWriteImage, []string{
"+write-image",
"--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:A1",
"--image", imagePath,
"--name", "bridge.png",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("request body is not valid JSON: %v", err)
}
if body["name"] != "bridge.png" {
t.Fatalf("body name = %v, want bridge.png", body["name"])
}
if body["image"] == nil {
t.Fatal("body image field is nil")
}
}
func TestSheetWriteImageExecuteRejectsNonexistentFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())

View File

@@ -22,9 +22,9 @@ import (
var SheetInfo = common.Shortcut{
Service: "sheets",
Command: "+info",
Description: "View spreadsheet and sheet information",
Description: "View spreadsheet metadata and sheet information",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
Scopes: []string{"sheets:spreadsheet.meta:read", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},

View File

@@ -147,6 +147,55 @@ func TestSlidesCreateBotSkippedWithoutCurrentUser(t *testing.T) {
if grant["status"] != common.PermissionGrantSkipped {
t.Fatalf("permission_grant.status = %v, want %q", grant["status"], common.PermissionGrantSkipped)
}
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "auth login") {
t.Fatalf("hint = %#v, want string containing 'auth login'", grant["hint"])
}
}
func TestSlidesCreateBotAutoGrantFailed(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "ou_current_user"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"xml_presentation_id": "pres_grant_fail",
"revision_id": 1,
},
},
})
registerBatchQueryStub(reg, "pres_grant_fail", "https://example.feishu.cn/slides/pres_grant_fail")
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/pres_grant_fail/members",
Body: map[string]interface{}{
"code": 230001,
"msg": "no permission",
},
})
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "Grant Fail PPT",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %v, want %q", grant["status"], common.PermissionGrantFailed)
}
if hint, ok := grant["hint"].(string); !ok || !strings.Contains(hint, "Retry later") {
t.Fatalf("hint = %#v, want string containing 'Retry later'", grant["hint"])
}
}
// TestSlidesCreateDryRunDefaultTitle verifies that dry-run also normalizes an empty title to "Untitled".

View File

@@ -38,7 +38,7 @@ const defaultTaskAttachmentResourceType = "task"
var UploadAttachmentTask = common.Shortcut{
Service: "task",
Command: "+upload-attachment",
Description: "upload a local file as an attachment to a task; use --resource-type=task_delivery when uploading to task agents",
Description: "upload a local file as an attachment to a task",
Risk: "write",
Scopes: []string{"task:attachment:write"},
AuthTypes: []string{"user", "bot"},

View File

@@ -17,5 +17,8 @@ func Shortcuts() []common.Shortcut {
WikiNodeCopy,
WikiNodeGet,
WikiNodeDelete,
WikiMemberAdd,
WikiMemberRemove,
WikiMemberList,
}
}

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