Compare commits

..

26 Commits

Author SHA1 Message Date
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
liangshuo-1
e6bc292575 fix(identitydiag): harden verify path and tighten status semantics (#961)
* fix(identitydiag): harden verify path and tighten status semantics

Follow-ups to #957:

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

Change-Id: I581348a65f15b1452a6f48a3e3245d09257314ac

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

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

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

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

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

Change-Id: Ib26dfdd5a0cc37d2d62537ae2bf5e854e67cb83c

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

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

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

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

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

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

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

* refactor: move host validation from ParseResourceURL to +inspect

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

* refactor: remove host validation from +inspect

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

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

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

Change-Id: I0ca5f91b02c24e81d083793e6a8e4f8c966aeec3

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

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

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

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

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

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

sprint: S2

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

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

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

* fix(mail): gofmt mail_template_create.go

* fix(mail): gofmt mail_template_update.go

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

Change-Id: I2a9a928aab2354dfaf103cdf53add435088ff9e2

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

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

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

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

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

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

* fix: handle duplicate base attachment downloads

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

Change-Id: I316f78290cec5bc50f80d629173e3bf2a35dd005
2026-05-19 11:21:54 +08:00
afengzi
583349e572 fix(docs): clarify replace_all selection errors (#954) 2026-05-19 10:54:49 +08:00
Yuxuan Zhao
315e0ab50c test: verify e2e resource cleanup (#949)
Change-Id: I3e04a82f622853549f11ac49cbd6fefa194c7c56
2026-05-18 22:35:10 +08:00
140 changed files with 13014 additions and 1500 deletions

3
.gitignore vendored
View File

@@ -41,3 +41,6 @@ app.log
/sidecar-server-demo
/server-demo
.tmp/
cover*.out
lark-env.sh

View File

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

View File

@@ -2,6 +2,35 @@
All notable changes to this project will be documented in this file.
## [v1.0.34] - 2026-05-19
### Features
- **drive**: Switch markdown export to V2 `docs_ai` fetch API (#948)
- **drive**: Add `+inspect` shortcut for document URL inspection with wiki unwrapping (#947)
- **wiki**: Add `+node-get` / `+node-delete` / `+space-create` shortcuts (#904)
- **base**: Support Base attachment APIs (#887)
- **mail**: Validate `bot` + `mailbox=me` and add dynamic `--as` help tests (#895)
- **mail**: Expose draft priority in `--inspect` projection and document `--set-priority` (#779)
### Bug Fixes
- **identitydiag**: Harden verify path and tighten status semantics (#961)
- **wiki**: Surface real node URL for `+node-create` / `+node-copy` (#960)
- **auth**: Split bot and user identity diagnostics (#957)
- **base**: Address Base attachment review follow-ups (#958)
- **docs**: Clarify `replace_all` selection errors (#954)
### Documentation
- **drive**: Clarify add comment constraints (#967)
- **lark-im**: Clarify message activity search (#865)
### Tests
- Verify e2e resource cleanup (#949)
- **lint**: Exclude `bidichk` from test files (#959)
## [v1.0.33] - 2026-05-18
### Features
@@ -745,6 +774,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.34]: https://github.com/larksuite/cli/releases/tag/v1.0.34
[v1.0.33]: https://github.com/larksuite/cli/releases/tag/v1.0.33
[v1.0.32]: https://github.com/larksuite/cli/releases/tag/v1.0.32
[v1.0.31]: https://github.com/larksuite/cli/releases/tag/v1.0.31

View File

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

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

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

View File

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

View File

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

View File

@@ -536,11 +536,8 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
})
}
// TestSetupNotices_ColdStart_NoNotice verifies that a missing stamp
// produces no skills key in the composed notice. Users who installed
// skills via `npx skills add` (no stamp) must not see the misleading
// "not installed" notice — only `lark-cli update` users opt into the
// drift tracker.
// TestSetupNotices_ColdStart_NoNotice verifies that missing state
// produces no skills key in the composed notice.
func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
clearNoticeEnv(t)
dir := t.TempDir()
@@ -571,13 +568,13 @@ func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
}
}
// TestSetupNotices_InSync verifies that a matching stamp produces no
// TestSetupNotices_InSync verifies that matching state produces no
// skills key in the composed notice.
func TestSetupNotices_InSync(t *testing.T) {
clearNoticeEnv(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
t.Fatal(err)
}
@@ -604,13 +601,13 @@ func TestSetupNotices_InSync(t *testing.T) {
}
}
// TestSetupNotices_Drift verifies a mismatching stamp produces the
// TestSetupNotices_Drift verifies mismatching state produces the
// drift message with both current and target populated.
func TestSetupNotices_Drift(t *testing.T) {
clearNoticeEnv(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
t.Fatal(err)
}
@@ -659,7 +656,7 @@ func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
clearNoticeEnv(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
t.Fatal(err)
}

View File

@@ -31,11 +31,12 @@ var (
currentVersion = func() string { return build.Version }
currentOS = runtime.GOOS
newUpdater = func() *selfupdate.Updater { return selfupdate.New() }
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult { return skillscheck.SyncSkills(opts) }
)
func isWindows() bool { return currentOS == osWindows }
// normalizeVersion canonicalizes a version string for stamp comparison.
// normalizeVersion canonicalizes a version string for state comparison.
// Strips a leading "v" so versions written from Makefile (git describe →
// "v1.0.0") and npm (no prefix → "1.0.0") compare equal.
func normalizeVersion(s string) string {
@@ -121,7 +122,9 @@ func updateRun(opts *UpdateOptions) error {
cur := currentVersion()
updater := newUpdater()
updater.CleanupStaleFiles()
if !opts.Check {
updater.CleanupStaleFiles()
}
output.PendingNotice = nil
// 1. Fetch latest version
@@ -137,13 +140,9 @@ func updateRun(opts *UpdateOptions) error {
// 3. Compare versions
if !opts.Force && !update.IsNewer(latest, cur) {
// Run skills sync before returning — covers the case where the
// binary is already current but skills were never synced.
// Stamp dedup makes this a no-op if skills are already in sync.
// Skip side-effects under --check (pure report path per spec §3.6).
var skillsResult *selfupdate.NpmResult
var skillsResult *skillscheck.SyncResult
if !opts.Check {
skillsResult = runSkillsAndStamp(updater, io, cur, opts.Force)
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
}
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
}
@@ -185,16 +184,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
"message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest),
"url": releaseURL(latest), "changelog": changelogURL(),
}
// skills_status: pure report, no side effect, no stamp write.
// ReadStamp errors are silently swallowed — if we can't read the
// stamp we just omit the block rather than fail the --check.
if stamp, err := skillscheck.ReadStamp(); err == nil {
out["skills_status"] = map[string]interface{}{
"current": stamp,
"target": cur,
"in_sync": stamp == cur,
}
}
applySkillsStatus(out, cur)
output.PrintJson(io.Out, out)
return nil
}
@@ -210,7 +200,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
}
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
skillsResult := runSkillsAndStamp(updater, io, cur, opts.Force)
skillsResult := runSkillsAndState(updater, io, cur, opts.Force)
reason := detect.ManualReason()
if opts.JSON {
@@ -288,10 +278,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
return output.ErrBare(output.ExitAPI)
}
// Skills update (best-effort) — uses runSkillsAndStamp so the
// stamp gets persisted on success and dedup applies if a previous
// run already stamped this version.
skillsResult := runSkillsAndStamp(updater, io, latest, opts.Force)
skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
if opts.JSON {
result := map[string]interface{}{
@@ -328,27 +315,21 @@ func verificationFailureHint(updater *selfupdate.Updater, latest string) string
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
}
// runSkillsAndStamp triggers updater.RunSkillsUpdate and persists the
// stamp on success. Skips the npx invocation when the stamp already
// matches stampVersion (unless force is true). The stamp write failure
// emits a warning to io.ErrOut but does NOT fail the update command —
// best-effort. ReadStamp errors are swallowed (fail-closed: treated as
// out-of-sync, so npx re-runs). Returns nil iff skipped due to stamp
// dedup; otherwise returns the underlying *NpmResult with Err semantics
// from RunSkillsUpdate.
func runSkillsAndStamp(updater *selfupdate.Updater, io *cmdutil.IOStreams, stampVersion string, force bool) *selfupdate.NpmResult {
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool) *skillscheck.SyncResult {
if !force {
if existing, _ := skillscheck.ReadStamp(); normalizeVersion(existing) == normalizeVersion(stampVersion) {
if existing, ok := skillscheck.ReadSyncedVersion(); ok && normalizeVersion(existing) == normalizeVersion(stateVersion) {
return nil
}
}
r := updater.RunSkillsUpdate()
if r.Err == nil {
if err := skillscheck.WriteStamp(stampVersion); err != nil {
fmt.Fprintf(io.ErrOut, "warning: skills synced but stamp not written: %v\n", err)
}
result := syncSkills(skillscheck.SyncOptions{
Version: stateVersion,
Force: force,
Runner: updater,
})
if result.Err != nil && strings.Contains(result.Err.Error(), "state not written") {
fmt.Fprintf(io.ErrOut, "warning: %v\n", result.Err)
}
return r
return result
}
// reportAlreadyUpToDate emits the JSON / pretty output for the
@@ -356,7 +337,7 @@ func runSkillsAndStamp(updater *selfupdate.Updater, io *cmdutil.IOStreams, stamp
// fields derived from skillsResult. When check is true, this is the pure
// report path (spec §3.6): no side-effects, JSON envelope uses
// skills_status (spec §4.2) instead of skills_action.
func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *selfupdate.NpmResult, check bool) error {
func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *skillscheck.SyncResult, check bool) error {
if opts.JSON {
out := map[string]interface{}{
"ok": true, "previous_version": cur, "current_version": cur,
@@ -364,16 +345,7 @@ func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, late
"message": fmt.Sprintf("lark-cli %s is already up to date", cur),
}
if check {
// Pure report — read stamp directly, emit skills_status block.
// ReadStamp errors are silently swallowed — if we can't read
// the stamp we just omit the block rather than fail the --check.
if stamp, err := skillscheck.ReadStamp(); err == nil {
out["skills_status"] = map[string]interface{}{
"current": stamp,
"target": cur,
"in_sync": stamp == cur,
}
}
applySkillsStatus(out, cur)
} else {
applySkillsResult(out, skillsResult)
}
@@ -387,36 +359,70 @@ func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, late
return nil
}
// applySkillsResult mutates the JSON envelope to include skills_action
// (and skills_warning when failed). nil result = "in_sync" (dedup hit).
func applySkillsResult(env map[string]interface{}, r *selfupdate.NpmResult) {
func applySkillsStatus(env map[string]interface{}, target string) {
state, readable, err := skillscheck.ReadState()
if err != nil || !readable || state.Version == "" {
return
}
status := map[string]interface{}{
"current": state.Version,
"target": target,
"in_sync": normalizeVersion(state.Version) == normalizeVersion(target),
}
if len(state.OfficialSkills) > 0 {
status["official"] = len(state.OfficialSkills)
}
if len(state.UpdatedSkills) > 0 {
status["updated"] = len(state.UpdatedSkills)
}
if len(state.SkippedDeletedSkills) > 0 {
status["skipped_deleted"] = state.SkippedDeletedSkills
}
env["skills_status"] = status
}
func applySkillsResult(env map[string]interface{}, r *skillscheck.SyncResult) {
switch {
case r == nil:
env["skills_action"] = "in_sync"
case r.Err != nil:
env["skills_action"] = "failed"
env["skills_warning"] = fmt.Sprintf("skills update failed: %s", r.Err)
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
env["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput)
}
env["skills_summary"] = skillsSummary(r)
default:
env["skills_action"] = "synced"
env["skills_summary"] = skillsSummary(r)
}
}
// emitSkillsTextHints prints human-readable feedback about the skills
// sync result for non-JSON output.
func emitSkillsTextHints(io *cmdutil.IOStreams, r *selfupdate.NpmResult) {
func skillsSummary(r *skillscheck.SyncResult) map[string]interface{} {
summary := map[string]interface{}{
"official": len(r.Official),
"updated": len(r.Updated),
"added": len(r.Added),
"skipped_deleted": len(r.SkippedDeleted),
}
if len(r.Failed) > 0 {
summary["failed"] = r.Failed
}
return summary
}
func emitSkillsTextHints(io *cmdutil.IOStreams, r *skillscheck.SyncResult) {
switch {
case r == nil:
// dedup hit — silent (already up to date)
case r.Err != nil:
fmt.Fprintf(io.ErrOut, "%s Skills update failed: %v\n", symWarn(), r.Err)
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, maxStderrDetail))
if len(r.Failed) > 0 {
fmt.Fprintf(io.ErrOut, " Failed skills: %s\n", strings.Join(r.Failed, ", "))
}
fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n")
fmt.Fprintf(io.ErrOut, " To retry all official skills: lark-cli update --force\n")
case r.Force:
fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills\n", symOK(), len(r.Official))
default:
fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK())
fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted))
if len(r.SkippedDeleted) > 0 {
fmt.Fprintf(io.ErrOut, " To restore all official skills: lark-cli update --force\n")
}
}
}

View File

@@ -8,8 +8,6 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
@@ -28,7 +26,6 @@ func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffe
}
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
// It preserves any existing NpmInstallOverride/SkillsUpdateOverride that may be set later.
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
t.Helper()
origNew := newUpdater
@@ -41,22 +38,34 @@ func mockDetect(t *testing.T, result selfupdate.DetectResult) {
}
// mockDetectAndNpm sets up newUpdater with detect, npm install, and skills overrides all at once.
func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult,
npmFn func(string) *selfupdate.NpmResult,
skillsFn func() *selfupdate.NpmResult) {
func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(string) *selfupdate.NpmResult) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.NpmInstallOverride = npmFn
u.SkillsUpdateOverride = skillsFn
u.VerifyOverride = func(string) error { return nil }
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
return func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
switch strings.Join(args, " ") {
case "-y skills add https://open.feishu.cn --list":
r.Stdout.WriteString("lark-calendar\nlark-mail\n")
case "-y skills ls -g":
r.Stdout.WriteString("lark-calendar\ncustom-skill\n")
default:
}
return r
}
}
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
f, stdout, _ := newTestFactory(t)
@@ -168,9 +177,7 @@ func TestUpdateManual_Human(t *testing.T) {
}
func TestUpdateNpm_JSON(t *testing.T) {
// Isolate config dir: this test mocks fetchLatest="2.0.0" and lets
// runSkillsAndStamp → WriteStamp succeed, which without isolation would
// clobber the real ~/.lark-cli/skills.stamp with "2.0.0".
// Isolate config dir because skills sync writes skills-state.json.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
@@ -186,7 +193,6 @@ func TestUpdateNpm_JSON(t *testing.T) {
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
@@ -216,7 +222,6 @@ func TestUpdateNpm_Human(t *testing.T) {
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
@@ -230,7 +235,7 @@ func TestUpdateNpm_Human(t *testing.T) {
}
func TestUpdateForce_JSON(t *testing.T) {
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
// Same state-isolation rationale as TestUpdateNpm_JSON.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
@@ -246,7 +251,6 @@ func TestUpdateForce_JSON(t *testing.T) {
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
@@ -323,7 +327,7 @@ func TestUpdateInvalidVersion_JSON(t *testing.T) {
}
func TestUpdateDevVersion_JSON(t *testing.T) {
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
// Same state-isolation rationale as TestUpdateNpm_JSON.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
@@ -339,7 +343,6 @@ func TestUpdateDevVersion_JSON(t *testing.T) {
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
@@ -451,8 +454,8 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
u.RestoreAvailableOverride = func() bool { return false }
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
t.Fatal("skills update should not run when binary verification fails")
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
t.Fatal("skills sync should not run when binary verification fails")
return nil
}
return u
@@ -649,7 +652,7 @@ func TestPermissionHint(t *testing.T) {
func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
// With the rename trick, Windows npm installs can now auto-update.
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
// Same state-isolation rationale as TestUpdateNpm_JSON.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
@@ -668,7 +671,6 @@ func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\npm\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
@@ -750,7 +752,6 @@ func TestUpdateNpm_SkillsSuccess_JSON(t *testing.T) {
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
@@ -785,8 +786,7 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil }
// Skills update fails
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found")
r.Err = fmt.Errorf("exit status 127")
@@ -812,8 +812,8 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
if !strings.Contains(out, "skills_warning") {
t.Errorf("expected skills_warning in output, got: %s", out)
}
if !strings.Contains(out, "skills_detail") {
t.Errorf("expected skills_detail in output, got: %s", out)
if !strings.Contains(out, "skills_summary") {
t.Errorf("expected skills_summary in output, got: %s", out)
}
}
@@ -838,7 +838,7 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil }
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found")
r.Err = fmt.Errorf("exit status 127")
@@ -861,100 +861,96 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
if !strings.Contains(out, "Skills update failed") {
t.Errorf("expected skills failure warning, got: %s", out)
}
if !strings.Contains(out, "npx -y skills add") {
t.Errorf("expected manual skills command hint, got: %s", out)
if !strings.Contains(out, "lark-cli update --force") {
t.Errorf("expected force retry hint, got: %s", out)
}
}
// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers, suitable
// for direct calls to internals like runSkillsAndStamp that write to
// io.ErrOut.
// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers.
func newTestIO() *cmdutil.IOStreams {
return cmdutil.NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
}
func TestRunSkillsAndStamp_DedupHit(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
func TestRunSkillsAndState_DedupHit(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
t.Fatal(err)
}
called := false
updater := &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
called = true
return &selfupdate.NpmResult{}
},
}
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
if got != nil {
t.Errorf("runSkillsAndStamp() = %+v, want nil for dedup hit", got)
t.Errorf("runSkillsAndState() = %+v, want nil for dedup hit", got)
}
if called {
t.Error("SkillsUpdateOverride called, want skipped due to dedup")
t.Error("SkillsCommandOverride called, want skipped due to dedup")
}
}
func TestRunSkillsAndStamp_DedupForceBypass(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
t.Fatal(err)
}
called := false
updater := &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
called = true
return &selfupdate.NpmResult{}
return successfulSkillsCommand()(args...)
},
}
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", true)
if got == nil {
t.Fatal("runSkillsAndStamp(force=true) = nil, want non-nil")
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndState(force=true) = %+v, want successful result", got)
}
if !called {
t.Error("SkillsUpdateOverride not called with force=true")
t.Error("SkillsCommandOverride not called with force=true")
}
}
func TestRunSkillsAndStamp_SuccessWritesStamp(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
updater := &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
return &selfupdate.NpmResult{}
},
}
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got)
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
}
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.21" {
t.Errorf("stamp = %q, want \"1.0.21\"", stamp)
state, readable, err := skillscheck.ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.21" {
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
}
}
func TestRunSkillsAndStamp_FailureKeepsOldStamp(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
t.Fatal(err)
}
updater := &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("npx failed")
return r
},
}
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
if got == nil || got.Err == nil {
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with non-nil Err", got)
t.Fatalf("runSkillsAndState() = %+v, want non-nil with non-nil Err", got)
}
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.20" {
t.Errorf("stamp = %q, want \"1.0.20\" (failure must not overwrite)", stamp)
state, readable, err := skillscheck.ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.20" {
t.Errorf("state.Version = %q, want \"1.0.20\" (failure must not overwrite)", state.Version)
}
}
@@ -973,8 +969,7 @@ func TestTruncate(t *testing.T) {
}
func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
origFetch := fetchLatest
origCur := currentVersion
@@ -987,9 +982,9 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return &selfupdate.NpmResult{}
return successfulSkillsCommand()(args...)
},
}
}
@@ -1000,17 +995,19 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
t.Fatalf("updateRun() err = %v, want nil", err)
}
if !skillsCalled {
t.Error("RunSkillsUpdate not called in already-up-to-date branch (cold stamp), want called")
t.Error("skills sync not called in already-up-to-date branch")
}
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.21" {
t.Errorf("stamp = %q, want \"1.0.21\"", stamp)
state, readable, err := skillscheck.ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.21" {
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
}
}
func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
origFetch := fetchLatest
origCur := currentVersion
@@ -1029,9 +1026,9 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
ResolvedPath: "/usr/local/bin/lark-cli",
}
},
SkillsUpdateOverride: func() *selfupdate.NpmResult {
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return &selfupdate.NpmResult{}
return successfulSkillsCommand()(args...)
},
}
}
@@ -1042,17 +1039,19 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
t.Fatalf("updateRun() err = %v, want nil", err)
}
if !skillsCalled {
t.Error("RunSkillsUpdate not called in manual branch, want called")
t.Error("skills sync not called in manual branch")
}
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.21" {
t.Errorf("stamp = %q, want \"1.0.21\" (manual path stamps cur)", stamp)
state, readable, err := skillscheck.ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.21" {
t.Errorf("state.Version = %q, want \"1.0.21\" (manual path records current binary)", state.Version)
}
}
func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
origFetch := fetchLatest
origCur := currentVersion
@@ -1075,9 +1074,9 @@ func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) {
return &selfupdate.NpmResult{}
},
VerifyOverride: func(expectedVersion string) error { return nil },
SkillsUpdateOverride: func() *selfupdate.NpmResult {
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return &selfupdate.NpmResult{}
return successfulSkillsCommand()(args...)
},
}
}
@@ -1088,18 +1087,25 @@ func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) {
t.Fatalf("updateRun() err = %v, want nil", err)
}
if !skillsCalled {
t.Error("RunSkillsUpdate not called in npm branch")
t.Error("skills sync not called in npm branch")
}
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.22" {
t.Errorf("stamp = %q, want \"1.0.22\" (npm path stamps latest)", stamp)
state, readable, err := skillscheck.ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.22" {
t.Errorf("state.Version = %q, want \"1.0.22\" (npm path records latest binary)", state.Version)
}
}
func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := skillscheck.WriteState(skillscheck.SkillsState{
Version: "1.0.20",
OfficialSkills: []string{"lark-calendar", "lark-mail"},
UpdatedSkills: []string{"lark-calendar"},
SkippedDeletedSkills: []string{"lark-mail"},
}); err != nil {
t.Fatal(err)
}
@@ -1117,9 +1123,9 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
},
SkillsUpdateOverride: func() *selfupdate.NpmResult {
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return &selfupdate.NpmResult{}
return successfulSkillsCommand()(args...)
},
}
}
@@ -1130,7 +1136,7 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
t.Fatalf("updateRun(--check) err = %v, want nil", err)
}
if skillsCalled {
t.Error("RunSkillsUpdate called under --check, want skipped (pure report)")
t.Error("skills sync called under --check, want skipped")
}
var env map[string]interface{}
@@ -1144,12 +1150,14 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
if status["current"] != "1.0.20" || status["target"] != "1.0.21" || status["in_sync"] != false {
t.Errorf("skills_status = %+v, want {current:\"1.0.20\", target:\"1.0.21\", in_sync:false}", status)
}
if status["official"] != float64(2) || status["updated"] != float64(1) {
t.Errorf("skills_status counts = %+v, want official:2 updated:1", status)
}
}
func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
t.Fatal(err)
}
@@ -1164,9 +1172,9 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return &selfupdate.NpmResult{}
return successfulSkillsCommand()(args...)
},
}
}
@@ -1177,12 +1185,15 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
t.Fatalf("updateRun(--check, already-latest) err = %v, want nil", err)
}
if skillsCalled {
t.Error("RunSkillsUpdate called under --check (already-latest), want skipped (pure report)")
t.Error("skills sync called under --check (already-latest), want skipped")
}
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.20" {
t.Errorf("stamp mutated to %q under --check, want \"1.0.20\" (pure report must not write stamp)", stamp)
state, readable, err := skillscheck.ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.20" {
t.Errorf("state.Version mutated to %q under --check, want \"1.0.20\"", state.Version)
}
var env map[string]interface{}
@@ -1204,38 +1215,26 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
}
}
// TestRunSkillsAndStamp_StampWriteFailureWarns verifies the stderr warning
// emission when RunSkillsUpdate succeeds but WriteStamp fails.
func TestRunSkillsAndStamp_StampWriteFailureWarns(t *testing.T) {
// Force WriteStamp to fail by pointing config dir at a path that exists
// as a regular file (so MkdirAll fails).
tmp := t.TempDir()
badPath := filepath.Join(tmp, "blocker")
if err := os.WriteFile(badPath, []byte("not-a-dir"), 0o644); err != nil {
t.Fatal(err)
func TestRunSkillsAndState_StateWriteFailureWarns(t *testing.T) {
origSync := syncSkills
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult {
return &skillscheck.SyncResult{Err: fmt.Errorf("skills synced but state not written: denied")}
}
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", badPath)
t.Cleanup(func() { syncSkills = origSync })
f, _, stderr := newTestFactory(t)
updater := &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
return &selfupdate.NpmResult{} // success
},
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false)
if got == nil || got.Err == nil {
t.Fatalf("runSkillsAndState() = %+v, want non-nil with write error", got)
}
got := runSkillsAndStamp(updater, f.IOStreams, "1.0.21", false)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got)
}
if !strings.Contains(stderr.String(), "warning: skills synced but stamp not written") {
if !strings.Contains(stderr.String(), "warning: skills synced but state not written") {
t.Errorf("stderr does not contain warning: %q", stderr.String())
}
}
// TestEmitSkillsTextHints_Success verifies the "Skills updated" success
// message is printed to ErrOut on a successful (Err == nil) result.
func TestEmitSkillsTextHints_Success(t *testing.T) {
f, _, stderr := newTestFactory(t)
emitSkillsTextHints(f.IOStreams, &selfupdate.NpmResult{}) // Err==nil → success
emitSkillsTextHints(f.IOStreams, &skillscheck.SyncResult{Official: []string{"lark-calendar"}, Updated: []string{"lark-calendar"}})
if !strings.Contains(stderr.String(), "Skills updated") {
t.Errorf("stderr does not contain 'Skills updated': %q", stderr.String())
}

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

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

View File

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

View File

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

View File

@@ -84,6 +84,7 @@ type Updater struct {
DetectOverride func() DetectResult
NpmInstallOverride func(version string) *NpmResult
SkillsUpdateOverride func() *NpmResult
SkillsCommandOverride func(args ...string) *NpmResult
VerifyOverride func(expectedVersion string) error
RestoreAvailableOverride func() bool
@@ -166,7 +167,46 @@ func (u *Updater) RunSkillsUpdate() *NpmResult {
return r
}
func (u *Updater) ListOfficialSkills() *NpmResult {
r := u.runSkillsListOfficial("https://open.feishu.cn")
if r.Err != nil {
r = u.runSkillsListOfficial("larksuite/cli")
}
return r
}
func (u *Updater) ListGlobalSkills() *NpmResult {
return u.runSkillsListGlobal()
}
func (u *Updater) InstallSkill(name string) *NpmResult {
r := u.runSkillsInstall("https://open.feishu.cn", name)
if r.Err != nil {
r = u.runSkillsInstall("larksuite/cli", name)
}
return r
}
func (u *Updater) runSkillsAdd(source string) *NpmResult {
return u.runSkillsCommand("-y", "skills", "add", source, "-g", "-y")
}
func (u *Updater) runSkillsListOfficial(source string) *NpmResult {
return u.runSkillsCommand("-y", "skills", "add", source, "--list")
}
func (u *Updater) runSkillsListGlobal() *NpmResult {
return u.runSkillsCommand("-y", "skills", "ls", "-g")
}
func (u *Updater) runSkillsInstall(source string, name string) *NpmResult {
return u.runSkillsCommand("-y", "skills", "add", source, "-s", name, "-g", "-y")
}
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
if u.SkillsCommandOverride != nil {
return u.SkillsCommandOverride(args...)
}
r := &NpmResult{}
npxPath, err := exec.LookPath("npx")
if err != nil {
@@ -175,7 +215,7 @@ func (u *Updater) runSkillsAdd(source string) *NpmResult {
}
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", source, "-g", "-y")
cmd := exec.CommandContext(ctx, npxPath, args...)
cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr
r.Err = cmd.Run()

View File

@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/larksuite/cli/internal/vfs"
@@ -166,3 +167,87 @@ func TestVerifyBinaryEmptyOutput(t *testing.T) {
t.Fatal("VerifyBinary(empty output) expected error, got nil")
}
}
func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
tests := []struct {
name string
run func(*Updater) *NpmResult
want string
}{
{
name: "list official primary",
run: func(u *Updater) *NpmResult {
return u.runSkillsListOfficial("https://open.feishu.cn")
},
want: "-y skills add https://open.feishu.cn --list",
},
{
name: "list global",
run: func(u *Updater) *NpmResult {
return u.runSkillsListGlobal()
},
want: "-y skills ls -g",
},
{
name: "install skill primary",
run: func(u *Updater) *NpmResult {
return u.runSkillsInstall("https://open.feishu.cn", "lark-mail")
},
want: "-y skills add https://open.feishu.cn -s lark-mail -g -y",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell script")
}
dir := t.TempDir()
script := filepath.Join(dir, "npx")
logPath := filepath.Join(dir, "npx.log")
if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \""+logPath+"\"\nexit 0\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
result := tt.run(New())
if result.Err != nil {
t.Fatalf("command err = %v, want nil", result.Err)
}
raw, err := os.ReadFile(logPath)
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(string(raw)) != tt.want {
t.Fatalf("args = %q, want %q", strings.TrimSpace(string(raw)), tt.want)
}
})
}
}
func TestListOfficialSkillsFallsBack(t *testing.T) {
called := []string{}
updater := &Updater{
SkillsCommandOverride: func(args ...string) *NpmResult {
called = append(called, strings.Join(args, " "))
r := &NpmResult{}
if strings.Contains(strings.Join(args, " "), "https://open.feishu.cn") {
r.Err = fmt.Errorf("primary failed")
return r
}
r.Stdout.WriteString("lark-calendar\n")
return r
},
}
result := updater.ListOfficialSkills()
if result.Err != nil {
t.Fatalf("ListOfficialSkills() err = %v, want nil", result.Err)
}
if len(called) != 2 {
t.Fatalf("called %d commands, want 2: %#v", len(called), called)
}
if !strings.Contains(called[1], "larksuite/cli --list") {
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
}
}

View File

@@ -3,46 +3,29 @@
package skillscheck
// Init runs the synchronous skills version check. Stores a StaleNotice
// when the local stamp records a version that does not match
// currentVersion. Safe to call from cmd/root.go before rootCmd.Execute();
// zero network, zero subprocess — only a local stamp file read.
import "strings"
// Init runs the synchronous skills version check. Stores a StaleNotice when
// the local skills state records a version that does not match currentVersion.
// Safe to call from cmd/root.go before rootCmd.Execute(); zero network, zero
// subprocess — only a local state file read.
//
// Skip rules: see shouldSkip (CI envs, DEV builds, non-release semver,
// LARKSUITE_CLI_NO_SKILLS_NOTIFIER opt-out).
//
// Failure modes (all → no notice, no nag):
// - shouldSkip rule met
// - ReadStamp returns an I/O error other than ENOENT
// - Stamp matches currentVersion (in-sync)
// - Stamp is missing (cold start) — only users who ran `lark-cli update`
// opt into drift tracking; npx-only installs are intentionally silent.
func Init(currentVersion string) {
// Clear any stale notice from a prior call so early returns below
// (skip rules / read errors / cold start / in-sync) leave pending == nil
// instead of preserving a stale value from a previous Init invocation.
SetPending(nil)
if shouldSkip(currentVersion) {
return
}
stamp, err := ReadStamp()
if err != nil {
// Fail closed — don't nag for a transient FS problem.
version, ok := ReadSyncedVersion()
if !ok {
return
}
if stamp == "" {
// Cold start: the stamp is written exclusively by `lark-cli update`
// (runSkillsAndStamp). Users who installed skills via
// `npx skills add larksuite/cli -g` have no stamp yet — they must
// not be nagged with "skills not installed", since the on-disk
// skills directory may already be fully populated.
return
}
if stamp == currentVersion {
if strings.TrimPrefix(strings.TrimPrefix(version, "v"), "V") == strings.TrimPrefix(strings.TrimPrefix(currentVersion, "v"), "V") {
return
}
SetPending(&StaleNotice{
Current: stamp, // guaranteed non-empty under the new contract
Current: version,
Target: currentVersion,
})
}

View File

@@ -18,9 +18,8 @@ func resetPending(t *testing.T) {
func TestInit_InSync_NoNotice(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.21"); err != nil {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := WriteState(SkillsState{Version: "1.0.21"}); err != nil {
t.Fatal(err)
}
Init("1.0.21")
@@ -39,12 +38,24 @@ func TestInit_ColdStart_NoNotice(t *testing.T) {
}
}
func TestInit_Drift_NoticeWithStampVersion(t *testing.T) {
func TestInit_NormalizedVersion_NoNotice(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.20"); err != nil {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := WriteState(SkillsState{Version: "1.0.21"}); err != nil {
t.Fatal(err)
}
Init("v1.0.21")
if got := GetPending(); got != nil {
t.Errorf("GetPending() = %+v, want nil (normalized versions are in-sync)", got)
}
}
func TestInit_Drift_NoticeWithStateVersion(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := WriteState(SkillsState{Version: "1.0.20"}); err != nil {
t.Fatal(err)
}
Init("1.0.21")
@@ -61,22 +72,18 @@ func TestInit_Skipped_NoNotice(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
// Even with an empty config dir (no stamp), DEV version should skip
// the check entirely and never emit a notice.
Init("DEV")
if got := GetPending(); got != nil {
t.Errorf("GetPending() = %+v, want nil (skip rules met)", got)
}
}
func TestInit_ReadStampError_FailsClosed(t *testing.T) {
func TestInit_ReadStateError_FailsClosed(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
// Make the stamp path a directory so vfs.ReadFile returns a
// non-ENOENT I/O error.
if err := os.MkdirAll(filepath.Join(dir, "skills.stamp"), 0o755); err != nil {
if err := os.MkdirAll(filepath.Join(dir, "skills-state.json"), 0o755); err != nil {
t.Fatal(err)
}
Init("1.0.21")

View File

@@ -3,9 +3,8 @@
// Package skillscheck verifies that the locally installed lark-cli
// skills are in sync with the running binary version, by comparing
// the current binary version against a stamp file written when skills
// are last synced (by `lark-cli update`). On mismatch it stores a
// notice for injection into JSON envelopes via output.PendingNotice.
// the current binary version against skills-state.json. On mismatch it
// stores a notice for injection into JSON envelopes via output.PendingNotice.
package skillscheck
import (
@@ -26,8 +25,7 @@ type StaleNotice struct {
// Message returns a single-line, AI-agent-parseable description of the
// drift plus the canonical fix command. Mirrors internal/update.UpdateInfo.Message
// in style ("..., run: lark-cli update" suffix). Current is guaranteed
// non-empty because Init only emits a StaleNotice for the drift case
// (stamp present and != binary version).
// non-empty because Init only emits a StaleNotice for the drift case.
func (s *StaleNotice) Message() string {
return fmt.Sprintf(
"lark-cli skills %s out of sync with binary %s, run: lark-cli update",

View File

@@ -1,49 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"errors"
"io/fs"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
const stampFile = "skills.stamp"
// stampPath returns ~/.lark-cli/skills.stamp.
// Uses the BASE config dir (not workspace-aware) because skills install
// globally via `npx -g`; per-workspace tracking would produce false
// drift signals when switching workspaces.
func stampPath() string {
return filepath.Join(core.GetBaseConfigDir(), stampFile)
}
// ReadStamp returns the version recorded in the stamp file. Returns
// ("", nil) when the file does not exist (interpreted as "never synced").
// Other I/O errors are returned as-is so callers can fail closed.
func ReadStamp() (string, error) {
data, err := vfs.ReadFile(stampPath())
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return "", nil
}
return "", err
}
return strings.TrimSpace(string(data)), nil
}
// WriteStamp records `version` as the last successfully synced skills
// version. Atomic via tmp + rename (validate.AtomicWrite). Creates
// the base config directory if it does not exist.
func WriteStamp(version string) error {
if err := vfs.MkdirAll(core.GetBaseConfigDir(), 0o700); err != nil {
return err
}
return validate.AtomicWrite(stampPath(), []byte(version), 0o644)
}

View File

@@ -1,113 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"os"
"path/filepath"
"testing"
)
func TestReadStamp_Missing(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
got, err := ReadStamp()
if err != nil {
t.Fatalf("ReadStamp() err = %v, want nil for ENOENT", err)
}
if got != "" {
t.Errorf("ReadStamp() = %q, want \"\" for missing file", got)
}
}
func TestReadStamp_Normal(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte("1.0.21"), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadStamp()
if err != nil || got != "1.0.21" {
t.Errorf("ReadStamp() = (%q, %v), want (\"1.0.21\", nil)", got, err)
}
}
func TestReadStamp_TrailingNewlineTolerated(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte("1.0.21\n"), 0o644); err != nil {
t.Fatal(err)
}
got, _ := ReadStamp()
if got != "1.0.21" {
t.Errorf("ReadStamp() = %q, want \"1.0.21\" (newline trimmed)", got)
}
}
func TestReadStamp_EmptyFile(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte(""), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadStamp()
if err != nil || got != "" {
t.Errorf("ReadStamp() = (%q, %v), want (\"\", nil)", got, err)
}
}
func TestWriteStamp_CreatesDir(t *testing.T) {
dir := filepath.Join(t.TempDir(), "nested")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.21"); err != nil {
t.Fatalf("WriteStamp() = %v, want nil", err)
}
got, _ := os.ReadFile(filepath.Join(dir, "skills.stamp"))
if string(got) != "1.0.21" {
t.Errorf("file content = %q, want \"1.0.21\"", string(got))
}
}
func TestWriteStamp_OverwritesExisting(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.20"); err != nil {
t.Fatal(err)
}
if err := WriteStamp("1.0.21"); err != nil {
t.Fatal(err)
}
got, _ := ReadStamp()
if got != "1.0.21" {
t.Errorf("ReadStamp() after overwrite = %q, want \"1.0.21\"", got)
}
}
func TestWriteStamp_NoTrailingNewline(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.21"); err != nil {
t.Fatal(err)
}
raw, _ := os.ReadFile(filepath.Join(dir, "skills.stamp"))
if string(raw) != "1.0.21" {
t.Errorf("raw file = %q, want exactly \"1.0.21\" (no newline)", string(raw))
}
}
// TestWriteStamp_MkdirAllFailure verifies WriteStamp returns the mkdir error
// when the base config dir cannot be created (parent path is a regular file).
func TestWriteStamp_MkdirAllFailure(t *testing.T) {
tmp := t.TempDir()
blocker := filepath.Join(tmp, "blocker")
// Create a regular file where MkdirAll wants to create a directory.
if err := os.WriteFile(blocker, []byte("not-a-dir"), 0o644); err != nil {
t.Fatal(err)
}
// Point the config dir at a path UNDER the regular file — MkdirAll must fail.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(blocker, "child"))
if err := WriteStamp("1.0.21"); err == nil {
t.Fatal("WriteStamp() = nil, want non-nil error from MkdirAll failure")
}
}

View File

@@ -0,0 +1,90 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"encoding/json"
"errors"
"io/fs"
"path/filepath"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
const (
stateFile = "skills-state.json"
stateSchemaVersion = 1
)
type SkillsState struct {
SchemaVersion int `json:"schema_version"`
Version string `json:"version"`
OfficialSkills []string `json:"official_skills"`
UpdatedSkills []string `json:"updated_skills"`
AddedSkills []string `json:"added_skills"`
SkippedDeletedSkills []string `json:"skipped_deleted_skills"`
UpdatedAt string `json:"updated_at"`
}
func statePath() string {
return filepath.Join(core.GetBaseConfigDir(), stateFile)
}
func ReadState() (*SkillsState, bool, error) {
data, err := vfs.ReadFile(statePath())
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, false, nil
}
return nil, false, err
}
var state SkillsState
if json.Unmarshal(data, &state) != nil {
state = SkillsState{}
}
if state.SchemaVersion != stateSchemaVersion {
return nil, false, nil
}
return &state, true, nil
}
func WriteState(state SkillsState) error {
state.SchemaVersion = stateSchemaVersion
state.ensureNonNilSlices()
if err := vfs.MkdirAll(core.GetBaseConfigDir(), 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
return validate.AtomicWrite(statePath(), append(data, '\n'), 0o644)
}
func ReadSyncedVersion() (string, bool) {
state, ok, err := ReadState()
if err != nil || !ok || state.Version == "" {
return "", false
}
return state.Version, true
}
func (s *SkillsState) ensureNonNilSlices() {
if s.OfficialSkills == nil {
s.OfficialSkills = []string{}
}
if s.UpdatedSkills == nil {
s.UpdatedSkills = []string{}
}
if s.AddedSkills == nil {
s.AddedSkills = []string{}
}
if s.SkippedDeletedSkills == nil {
s.SkippedDeletedSkills = []string{}
}
}

View File

@@ -0,0 +1,153 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"testing"
)
func TestReadState_Missing(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
state, ok, err := ReadState()
if err != nil {
t.Fatalf("ReadState() err = %v, want nil for missing file", err)
}
if ok {
t.Fatal("ReadState() ok = true, want false for missing file")
}
if state != nil {
t.Fatalf("ReadState() state = %#v, want nil for missing file", state)
}
}
func TestReadState_Valid(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
want := SkillsState{
SchemaVersion: 1,
Version: "1.2.3",
OfficialSkills: []string{"lark-doc", "lark-im"},
UpdatedSkills: []string{"lark-doc"},
AddedSkills: []string{"lark-task"},
SkippedDeletedSkills: []string{"custom-skill"},
UpdatedAt: "2026-05-18T10:00:00Z",
}
data, err := json.Marshal(want)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, stateFile), data, 0o644); err != nil {
t.Fatal(err)
}
got, ok, err := ReadState()
if err != nil {
t.Fatalf("ReadState() err = %v, want nil", err)
}
if !ok {
t.Fatal("ReadState() ok = false, want true")
}
if got == nil {
t.Fatal("ReadState() state = nil, want state")
}
if !reflect.DeepEqual(*got, want) {
t.Fatalf("ReadState() state = %#v, want %#v", *got, want)
}
}
func TestReadState_CorruptOrUnknownSchemaUnreadable(t *testing.T) {
tests := []struct {
name string
data []byte
}{
{name: "corrupt json", data: []byte(`{"schema_version":`)},
{name: "unknown schema", data: []byte(`{"schema_version":2,"version":"1.2.3"}`)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, stateFile), tt.data, 0o644); err != nil {
t.Fatal(err)
}
state, ok, err := ReadState()
if err != nil {
t.Fatalf("ReadState() err = %v, want nil", err)
}
if ok {
t.Fatal("ReadState() ok = true, want false")
}
if state != nil {
t.Fatalf("ReadState() state = %#v, want nil", state)
}
})
}
}
func TestWriteState_CreatesDirAndWritesState(t *testing.T) {
dir := filepath.Join(t.TempDir(), "nested")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
state := SkillsState{
Version: "1.2.3",
UpdatedAt: "2026-05-18T10:00:00Z",
}
if err := WriteState(state); err != nil {
t.Fatalf("WriteState() err = %v, want nil", err)
}
raw, err := os.ReadFile(filepath.Join(dir, stateFile))
if err != nil {
t.Fatal(err)
}
var got SkillsState
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("written state is invalid JSON: %v", err)
}
if got.SchemaVersion != 1 {
t.Fatalf("schema_version = %d, want 1", got.SchemaVersion)
}
if got.Version != state.Version {
t.Fatalf("version = %q, want %q", got.Version, state.Version)
}
if got.OfficialSkills == nil {
t.Fatal("official_skills decoded as nil, want empty slice")
}
if got.UpdatedSkills == nil {
t.Fatal("updated_skills decoded as nil, want empty slice")
}
if got.AddedSkills == nil {
t.Fatal("added_skills decoded as nil, want empty slice")
}
if got.SkippedDeletedSkills == nil {
t.Fatal("skipped_deleted_skills decoded as nil, want empty slice")
}
}
func TestReadSyncedVersionFromState(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if got, ok := ReadSyncedVersion(); ok || got != "" {
t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"\", false) for missing state", got, ok)
}
if err := WriteState(SkillsState{Version: "1.2.3"}); err != nil {
t.Fatal(err)
}
if got, ok := ReadSyncedVersion(); !ok || got != "1.2.3" {
t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"1.2.3\", true)", got, ok)
}
if err := WriteState(SkillsState{}); err != nil {
t.Fatal(err)
}
if got, ok := ReadSyncedVersion(); ok || got != "" {
t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"\", false) for empty version", got, ok)
}
}

View File

@@ -0,0 +1,265 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"fmt"
"regexp"
"sort"
"strings"
"time"
"github.com/larksuite/cli/internal/selfupdate"
)
var skillNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_:-]*(@[^\s]+)?$`)
type SyncInput struct {
Version string
OfficialSkills []string
LocalSkills []string
PreviousState *SkillsState
StateReadable bool
Force bool
}
type SyncPlan struct {
Version string
OfficialSkills []string
ToUpdate []string
Added []string
SkippedDeleted []string
}
func ParseSkillsList(text string) []string {
seen := map[string]bool{}
for _, line := range strings.Split(text, "\n") {
token := strings.TrimSpace(line)
token = strings.TrimPrefix(token, "-")
token = strings.TrimSpace(token)
if token == "" || strings.Contains(token, " ") || strings.HasSuffix(token, ":") {
continue
}
if !skillNamePattern.MatchString(token) {
continue
}
if at := strings.Index(token, "@"); at > 0 {
token = token[:at]
}
seen[token] = true
}
return sortedKeys(seen)
}
func PlanSync(input SyncInput) SyncPlan {
official := uniqueSorted(input.OfficialSkills)
if input.Force {
return SyncPlan{
Version: input.Version,
OfficialSkills: official,
ToUpdate: official,
Added: []string{},
SkippedDeleted: []string{},
}
}
officialSet := toSet(official)
localOfficial := intersection(input.LocalSkills, officialSet)
previousOfficial := []string{}
if input.StateReadable && input.PreviousState != nil {
previousOfficial = input.PreviousState.OfficialSkills
}
previousSet := toSet(previousOfficial)
newOfficial := []string{}
for _, skill := range official {
if !previousSet[skill] {
newOfficial = append(newOfficial, skill)
}
}
updateSet := toSet(localOfficial)
for _, skill := range newOfficial {
updateSet[skill] = true
}
toUpdate := sortedKeys(updateSet)
updateSet = toSet(toUpdate)
skipped := []string{}
for _, skill := range official {
if !updateSet[skill] {
skipped = append(skipped, skill)
}
}
return SyncPlan{
Version: input.Version,
OfficialSkills: official,
ToUpdate: toUpdate,
Added: uniqueSorted(newOfficial),
SkippedDeleted: skipped,
}
}
type SkillsRunner interface {
ListOfficialSkills() *selfupdate.NpmResult
ListGlobalSkills() *selfupdate.NpmResult
InstallSkill(name string) *selfupdate.NpmResult
}
type SyncOptions struct {
Version string
Force bool
Runner SkillsRunner
Now func() time.Time
}
type SyncResult struct {
Action string
Official []string
Updated []string
Added []string
SkippedDeleted []string
Failed []string
Err error
Detail string
Force bool
}
func SyncSkills(opts SyncOptions) *SyncResult {
if opts.Now == nil {
opts.Now = time.Now
}
if opts.Runner == nil {
return &SyncResult{Action: "failed", Err: fmt.Errorf("skills runner is nil")}
}
officialResult := opts.Runner.ListOfficialSkills()
if officialResult == nil {
return &SyncResult{Action: "failed", Err: fmt.Errorf("failed to list official skills: empty result")}
}
if officialResult.Err != nil {
return &SyncResult{Action: "failed", Err: fmt.Errorf("failed to list official skills: %w", officialResult.Err), Detail: resultDetail(officialResult)}
}
official := ParseSkillsList(officialResult.Stdout.String())
localResult := opts.Runner.ListGlobalSkills()
if localResult == nil {
return &SyncResult{Action: "failed", Official: official, Err: fmt.Errorf("failed to list installed skills: empty result")}
}
if localResult.Err != nil {
return &SyncResult{Action: "failed", Official: official, Err: fmt.Errorf("failed to list installed skills: %w", localResult.Err), Detail: resultDetail(localResult)}
}
local := ParseSkillsList(localResult.Stdout.String())
previous, readable, err := ReadState()
if err != nil {
return &SyncResult{Action: "failed", Official: official, Err: fmt.Errorf("failed to read skills state: %w", err)}
}
plan := PlanSync(SyncInput{
Version: opts.Version,
OfficialSkills: official,
LocalSkills: local,
PreviousState: previous,
StateReadable: readable,
Force: opts.Force,
})
result := &SyncResult{
Action: "synced",
Official: plan.OfficialSkills,
Updated: plan.ToUpdate,
Added: plan.Added,
SkippedDeleted: plan.SkippedDeleted,
Force: opts.Force,
}
failed := []string{}
var details []string
for _, skill := range plan.ToUpdate {
installResult := opts.Runner.InstallSkill(skill)
if installResult == nil {
failed = append(failed, skill)
details = append(details, skill+": empty result")
continue
}
if installResult.Err != nil {
failed = append(failed, skill)
details = append(details, skill+": "+resultDetail(installResult))
}
}
if len(failed) > 0 {
result.Action = "failed"
result.Failed = failed
result.Err = fmt.Errorf("%d skill(s) failed", len(failed))
result.Detail = strings.Join(details, "\n")
return result
}
state := SkillsState{
Version: opts.Version,
OfficialSkills: plan.OfficialSkills,
UpdatedSkills: plan.ToUpdate,
AddedSkills: plan.Added,
SkippedDeletedSkills: plan.SkippedDeleted,
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
}
if err := WriteState(state); err != nil {
result.Action = "failed"
result.Err = fmt.Errorf("skills synced but state not written: %w", err)
return result
}
return result
}
func resultDetail(result *selfupdate.NpmResult) string {
if result == nil {
return ""
}
parts := []string{}
if output := strings.TrimSpace(result.CombinedOutput()); output != "" {
parts = append(parts, output)
}
if result.Err != nil {
parts = append(parts, result.Err.Error())
}
return strings.Join(parts, "\n")
}
func uniqueSorted(values []string) []string {
return sortedKeys(toSet(values))
}
func toSet(values []string) map[string]bool {
out := map[string]bool{}
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
out[value] = true
}
}
return out
}
func intersection(values []string, allowed map[string]bool) []string {
out := map[string]bool{}
for _, value := range values {
if allowed[value] {
out[value] = true
}
}
return sortedKeys(out)
}
func sortedKeys(values map[string]bool) []string {
out := make([]string, 0, len(values))
for value := range values {
out = append(out, value)
}
sort.Strings(out)
return out
}

View File

@@ -0,0 +1,222 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/selfupdate"
)
func TestParseSkillsList(t *testing.T) {
input := `Installed skills:
- lark-calendar
- lark-mail
lark-im
custom-skill
lark-base@1.0.0
lark-cli-harness:dev@0.1.0
`
got := ParseSkillsList(input)
want := []string{"custom-skill", "lark-base", "lark-calendar", "lark-cli-harness:dev", "lark-im", "lark-mail"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseSkillsList() = %#v, want %#v", got, want)
}
}
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
got := PlanSync(SyncInput{
Version: "1.0.33",
OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"},
LocalSkills: []string{"lark-calendar", "lark-custom"},
PreviousState: previous,
StateReadable: true,
Force: false,
})
assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-new"})
assertStrings(t, got.Added, []string{"lark-new"})
assertStrings(t, got.SkippedDeleted, []string{"lark-mail"})
}
func TestPlanNormal_MissingStateInstallsAllOfficial(t *testing.T) {
got := PlanSync(SyncInput{
Version: "1.0.33",
OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"},
LocalSkills: []string{"lark-calendar"},
StateReadable: false,
Force: false,
})
assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-mail", "lark-new"})
assertStrings(t, got.Added, []string{"lark-calendar", "lark-mail", "lark-new"})
assertStrings(t, got.SkippedDeleted, []string{})
}
func TestPlanForceRestoresAllOfficial(t *testing.T) {
got := PlanSync(SyncInput{
Version: "1.0.33",
OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"},
LocalSkills: []string{"lark-calendar"},
PreviousState: &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}},
StateReadable: true,
Force: true,
})
assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-mail", "lark-new"})
assertStrings(t, got.Added, []string{})
assertStrings(t, got.SkippedDeleted, []string{})
}
type fakeSkillsRunner struct {
officialOut string
globalOut string
officialErr error
globalErr error
installErr map[string]error
installed []string
}
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.officialOut)
r.Err = f.officialErr
return r
}
func (f *fakeSkillsRunner) ListGlobalSkills() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.globalOut)
r.Err = f.globalErr
return r
}
func (f *fakeSkillsRunner) InstallSkill(name string) *selfupdate.NpmResult {
f.installed = append(f.installed, name)
r := &selfupdate.NpmResult{}
if f.installErr != nil {
r.Err = f.installErr[name]
}
return r
}
func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteState(SkillsState{
Version: "1.0.30",
OfficialSkills: []string{"lark-calendar", "lark-mail"},
UpdatedAt: "2026-05-18T00:00:00Z",
}); err != nil {
t.Fatal(err)
}
runner := &fakeSkillsRunner{
officialOut: "lark-calendar\nlark-mail\nlark-new\n",
globalOut: "lark-calendar\nlark-custom\n",
}
result := SyncSkills(SyncOptions{
Version: "1.0.33",
Runner: runner,
Now: func() time.Time { return time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC) },
})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
assertStrings(t, runner.installed, []string{"lark-calendar", "lark-new"})
state, readable, err := ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
assertStrings(t, state.OfficialSkills, []string{"lark-calendar", "lark-mail", "lark-new"})
assertStrings(t, state.UpdatedSkills, []string{"lark-calendar", "lark-new"})
assertStrings(t, state.AddedSkills, []string{"lark-new"})
assertStrings(t, state.SkippedDeletedSkills, []string{"lark-mail"})
if _, err := os.Stat(filepath.Join(dir, "skills.stamp")); !os.IsNotExist(err) {
t.Fatalf("skills.stamp exists or stat failed with unexpected err: %v", err)
}
}
func TestSyncSkills_ListFailureDoesNotInstallOrWriteState(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{officialErr: fmt.Errorf("list failed")}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err == nil || !strings.Contains(result.Err.Error(), "failed to list official skills") {
t.Fatalf("SyncSkills() err = %v, want official list failure", result.Err)
}
if len(runner.installed) != 0 {
t.Fatalf("installed = %#v, want none", runner.installed)
}
if _, readable, err := ReadState(); err != nil || readable {
t.Fatalf("ReadState() = (_, %v, %v), want unreadable missing state", readable, err)
}
}
func TestSyncSkills_GlobalListFailureDoesNotInstallOrWriteState(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: "lark-calendar\nlark-mail\n",
globalErr: fmt.Errorf("global list failed"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err == nil || !strings.Contains(result.Err.Error(), "failed to list installed skills") {
t.Fatalf("SyncSkills() err = %v, want installed list failure", result.Err)
}
if len(runner.installed) != 0 {
t.Fatalf("installed = %#v, want none", runner.installed)
}
if _, readable, err := ReadState(); err != nil || readable {
t.Fatalf("ReadState() = (_, %v, %v), want unreadable missing state", readable, err)
}
}
func TestSyncSkills_InstallFailureContinuesAndDoesNotWriteState(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: "lark-calendar\nlark-mail\n",
globalOut: "lark-calendar\nlark-mail\n",
installErr: map[string]error{"lark-calendar": fmt.Errorf("boom")},
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err == nil || !strings.Contains(result.Err.Error(), "1 skill(s) failed") {
t.Fatalf("SyncSkills() err = %v, want install failure", result.Err)
}
assertStrings(t, runner.installed, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.Failed, []string{"lark-calendar"})
if !strings.Contains(result.Detail, "boom") {
t.Fatalf("SyncSkills() detail = %q, want install error text", result.Detail)
}
if _, readable, err := ReadState(); err != nil || readable {
t.Fatalf("ReadState() = (_, %v, %v), want no success state", readable, err)
}
}
func TestSyncSkills_NilRunnerFails(t *testing.T) {
result := SyncSkills(SyncOptions{Version: "1.0.33", Now: time.Now})
if result.Err == nil || !strings.Contains(result.Err.Error(), "skills runner is nil") {
t.Fatalf("SyncSkills() err = %v, want nil runner failure", result.Err)
}
}
func assertStrings(t *testing.T, got, want []string) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v, want %#v", got, want)
}
}

View File

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

View File

@@ -10,6 +10,8 @@ const p = require("@clack/prompts");
const PKG = "@larksuite/cli";
const SKILLS_REPO = "https://open.feishu.cn";
const SKILLS_REPO_FALLBACK = "larksuite/cli";
const CONFIG_DIR = process.env.LARKSUITE_CLI_CONFIG_DIR || path.join(process.env.HOME || process.env.USERPROFILE || "", ".lark-cli");
const SKILLS_STATE_FILE = path.join(CONFIG_DIR, "skills-state.json");
const isWindows = process.platform === "win32";
// ---------------------------------------------------------------------------
@@ -236,7 +238,7 @@ async function stepInstallGlobally(msg) {
if (installedVer && !needsUpgrade) {
p.log.info(fmt(msg.step1Skip, installedVer));
return false;
return installedVer;
}
const s = p.spinner();
@@ -248,41 +250,111 @@ async function stepInstallGlobally(msg) {
try {
await runSilentAsync("npm", ["install", "-g", PKG], { timeout: 120000 });
s.stop(needsUpgrade ? fmt(msg.step1Upgraded, latestVer) : msg.step1Done);
return needsUpgrade;
return latestVer || getGloballyInstalledVersion() || installedVer || null;
} catch (_) {
s.stop(fmt(msg.step1Fail, PKG));
process.exit(1);
}
}
async function skillsAlreadyInstalled() {
function parseSkillsList(text) {
const seen = new Set();
for (const rawLine of text.split("\n")) {
let token = rawLine.trim();
if (token.startsWith("-")) token = token.slice(1).trim();
if (!token || token.includes(" ") || token.endsWith(":")) continue;
if (!/^[A-Za-z0-9][A-Za-z0-9_:-]*(?:@\S+)?$/.test(token)) continue;
const at = token.indexOf("@");
if (at > 0) token = token.slice(0, at);
seen.add(token);
}
return [...seen].sort();
}
function readSkillsState() {
try {
const out = await runSilentAsync("npx", ["-y", "skills", "ls", "-g"], {
timeout: 120000,
});
return /^lark-/m.test(out.toString());
const state = JSON.parse(fs.readFileSync(SKILLS_STATE_FILE, "utf8"));
if (state.schema_version !== 1 || !Array.isArray(state.official_skills)) return null;
return state;
} catch (_) {
return false;
return null;
}
}
async function stepInstallSkills(msg) {
function writeSkillsState(version, official, updated, added, skipped) {
if (!CONFIG_DIR) return;
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
fs.writeFileSync(SKILLS_STATE_FILE, JSON.stringify({
schema_version: 1,
version,
official_skills: official,
updated_skills: updated,
added_skills: added,
skipped_deleted_skills: skipped,
updated_at: new Date().toISOString(),
}, null, 2) + "\n");
}
async function listOfficialSkills() {
try {
return parseSkillsList(await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO, "--list"], { timeout: 120000 }));
} catch (_) {
return parseSkillsList(await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO_FALLBACK, "--list"], { timeout: 120000 }));
}
}
async function listGlobalSkills() {
return parseSkillsList(await runSilentAsync("npx", ["-y", "skills", "ls", "-g"], { timeout: 120000 }));
}
function planSkillsSync(version, official, local, previousState) {
const officialSet = new Set(official);
const previousSet = new Set(previousState ? previousState.official_skills : []);
const localOfficial = local.filter((skill) => officialSet.has(skill));
const added = official.filter((skill) => !previousSet.has(skill));
const updateSet = new Set([...localOfficial, ...added]);
const updated = official.filter((skill) => updateSet.has(skill));
return {
version,
official,
updated,
added,
skipped: official.filter((skill) => !updateSet.has(skill)),
};
}
async function installSkill(name) {
try {
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO, "-s", name, "-g", "-y"], { timeout: 120000 });
} catch (_) {
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO_FALLBACK, "-s", name, "-g", "-y"], { timeout: 120000 });
}
}
async function stepInstallSkills(msg, cliVersion) {
const s = p.spinner();
s.start(msg.step2Spinner);
try {
if (await skillsAlreadyInstalled()) {
const official = await listOfficialSkills();
const local = await listGlobalSkills();
const plan = planSkillsSync(cliVersion || "unknown", official, local, readSkillsState());
if (plan.updated.length === 0) {
writeSkillsState(plan.version, plan.official, plan.updated, plan.added, plan.skipped);
s.stop(msg.step2Skip);
return;
}
try {
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO, "-y", "-g"], {
timeout: 120000,
});
} catch (_) {
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO_FALLBACK, "-y", "-g"], {
timeout: 120000,
});
const failed = [];
for (const skill of plan.updated) {
try {
await installSkill(skill);
} catch (_) {
failed.push(skill);
}
}
if (failed.length > 0) {
throw new Error(`${failed.length} skill(s) failed: ${failed.join(", ")}`);
}
writeSkillsState(plan.version, plan.official, plan.updated, plan.added, plan.skipped);
s.stop(msg.step2Done);
} catch (_) {
s.stop(fmt(msg.step2Fail, SKILLS_REPO_FALLBACK));
@@ -361,15 +433,15 @@ async function main() {
if (isInteractive) {
p.intro(msg.setup);
await stepInstallGlobally(msg);
await stepInstallSkills(msg);
const cliVersion = await stepInstallGlobally(msg);
await stepInstallSkills(msg, cliVersion);
await stepConfigInit(msg, lang);
await stepAuthLogin(msg);
p.outro(msg.done);
} else {
console.log(msg.setup);
await stepInstallGlobally(msg);
await stepInstallSkills(msg);
const cliVersion = await stepInstallGlobally(msg);
await stepInstallSkills(msg, cliVersion);
console.log(msg.nonTtyHint);
}
}

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -44,6 +44,8 @@ func Shortcuts() []common.Shortcut {
BaseRecordBatchUpdate,
BaseRecordShareLinkCreate,
BaseRecordUploadAttachment,
BaseRecordDownloadAttachment,
BaseRecordRemoveAttachment,
BaseRecordDelete,
BaseRecordHistoryList,
BaseBaseGet,
@@ -68,10 +70,12 @@ func Shortcuts() []common.Shortcut {
BaseFormsList,
BaseFormUpdate,
BaseFormGet,
BaseFormDetail,
BaseFormQuestionsCreate,
BaseFormQuestionsDelete,
BaseFormQuestionsUpdate,
BaseFormQuestionsList,
BaseFormSubmit,
BaseDashboardList,
BaseDashboardGet,
BaseDashboardCreate,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

@@ -29,5 +29,6 @@ func Shortcuts() []common.Shortcut {
DriveTaskResult,
DriveApplyPermission,
DriveSearch,
DriveInspect,
}
}

View File

@@ -32,6 +32,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+task_result",
"+apply-permission",
"+search",
"+inspect",
}
if len(got) != len(want) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -20,15 +20,21 @@ var MarkdownCreate = common.Shortcut{
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 +45,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"),
@@ -54,6 +61,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"),
@@ -79,8 +87,10 @@ var MarkdownCreate = common.Shortcut{
"file_name": finalMarkdownFileName(spec),
"size_bytes": fileSize,
}
if u := common.BuildResourceURL(runtime.Config.Brand, "file", result.FileToken); u != "" {
out["url"] = u
if target := spec.Target(); target.ParentType == markdownUploadParentTypeExplorer {
if u := common.BuildResourceURL(runtime.Config.Brand, "file", result.FileToken); u != "" {
out["url"] = u
}
}
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

@@ -182,7 +182,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 +269,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 +300,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{
@@ -377,6 +408,29 @@ func TestMarkdownCreateDryRunWithInlineContent(t *testing.T) {
}
}
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)
}
}
func TestMarkdownCreateDryRunReportsSourceFileError(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
@@ -472,6 +526,43 @@ func TestMarkdownCreateSuccessUploadAll(t *testing.T) {
}
}
func TestMarkdownCreateSuccessUploadAllToWikiOmitsURL(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)
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":`) {
t.Fatalf("stdout should omit url for wiki-hosted markdown files: %s", stdout.String())
}
}
func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
@@ -588,6 +679,81 @@ func TestMarkdownCreateMultipartUploadSuccess(t *testing.T) {
}
}
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",
},
},
})
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":`) {
t.Fatalf("stdout should omit url for wiki-hosted multipart markdown files: %s", stdout.String())
}
}
func TestMarkdownCreateFailsWhenMultipartPlanIsTooSmall(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -266,8 +266,19 @@ func TestPollWikiDeleteSpaceTaskWrapsPollFailuresWithHint(t *testing.T) {
withSingleWikiDeleteSpacePoll(t)
runtime, stderr := newWikiDeleteSpaceRuntimeWithScopes(t, core.AsUser, "")
// Seed an error that carries an upstream Lark Detail.Code so the test
// pins that structured fields survive a fully failed poll (not just the
// hint). ErrWithHint drops Detail.Code, which is exactly what we fixed.
client := &fakeWikiDeleteSpaceClient{
taskErrs: []error{output.ErrWithHint(output.ExitAPI, "api_error", "poll failed", "retry original")},
taskErrs: []error{&output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "api_error",
Code: 131006,
Message: "poll failed",
Hint: "retry original",
},
}},
}
status, ready, err := pollWikiDeleteSpaceTask(context.Background(), client, runtime, "task_123")
@@ -287,6 +298,9 @@ func TestPollWikiDeleteSpaceTaskWrapsPollFailuresWithHint(t *testing.T) {
if !strings.Contains(exitErr.Detail.Hint, "retry original") || !strings.Contains(exitErr.Detail.Hint, wikiDeleteSpaceTaskResultCommand("task_123", core.AsUser)) {
t.Fatalf("hint = %q, want original hint and resume command", exitErr.Detail.Hint)
}
if exitErr.Detail.Code != 131006 {
t.Fatalf("Detail.Code = %d, want 131006 preserved through poll exhaustion", exitErr.Detail.Code)
}
if !strings.Contains(stderr.String(), "Wiki delete-space status attempt 1/1 failed") {
t.Fatalf("stderr = %q, want poll failure log", stderr.String())
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"strings"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/shortcuts/common"
)
// wikiNodeURL returns the user-facing link for a wiki node. The create/copy
// OpenAPI responses carry a real `url` (undocumented in the server-docs schema
// but present in practice); prefer it so the CLI surfaces the canonical link.
// Fall back to BuildResourceURL synthesis only when the response omits it.
//
// Shared by +node-create and +node-copy, hence kept here rather than in either
// command's file.
func wikiNodeURL(brand core.LarkBrand, node *wikiNodeRecord) string {
if node == nil {
return ""
}
if u := strings.TrimSpace(node.URL); u != "" {
return u
}
return common.BuildResourceURL(brand, "wiki", node.NodeToken)
}

View File

@@ -415,6 +415,7 @@ func TestWikiNodeCopyCopiesNodeToTargetSpace(t *testing.T) {
"node_type": "origin",
"title": "Architecture (Copy)",
"has_child": false,
"url": "https://abc.feishu.cn/wiki/wik_copied_real",
},
},
"msg": "success",
@@ -451,6 +452,9 @@ func TestWikiNodeCopyCopiesNodeToTargetSpace(t *testing.T) {
if envelope.Data["space_id"] != "space_dst" {
t.Fatalf("space_id = %v, want %q", envelope.Data["space_id"], "space_dst")
}
if got, want := envelope.Data["url"], "https://abc.feishu.cn/wiki/wik_copied_real"; got != want {
t.Fatalf("url = %#v, want %q (copy must surface the response url)", got, want)
}
var captured map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil {

View File

@@ -89,6 +89,9 @@ var WikiNodeCopy = common.Shortcut{
fmt.Fprintf(runtime.IO().ErrOut, "Copied to node %s in space %s\n",
common.MaskToken(node.NodeToken), common.MaskToken(node.SpaceID))
out := wikiNodeCopyOutput(node)
if u := wikiNodeURL(runtime.Config.Brand, node); u != "" {
out["url"] = u
}
runtime.OutFormat(out, nil, func(w io.Writer) {
renderWikiNodeCopyPretty(w, out)
})
@@ -106,6 +109,9 @@ func renderWikiNodeCopyPretty(w io.Writer, out map[string]interface{}) {
if parent, _ := out["parent_node_token"].(string); parent != "" {
fmt.Fprintf(w, " parent_node_token: %s\n", parent)
}
if url, _ := out["url"].(string); url != "" {
fmt.Fprintf(w, " url: %s\n", url)
}
}
func buildNodeCopyBody(runtime *common.RuntimeContext) map[string]interface{} {

View File

@@ -118,6 +118,7 @@ type wikiNodeRecord struct {
OriginNodeToken string
Title string
HasChild bool
URL string
}
// wikiSpaceRecord contains the response fields used when resolving spaces.
@@ -456,6 +457,7 @@ func parseWikiNodeRecord(node map[string]interface{}) (*wikiNodeRecord, error) {
OriginNodeToken: common.GetString(node, "origin_node_token"),
Title: common.GetString(node, "title"),
HasChild: common.GetBool(node, "has_child"),
URL: common.GetString(node, "url"),
}, nil
}
@@ -498,7 +500,7 @@ func augmentWikiNodeCreateOutput(runtime *common.RuntimeContext, execution *wiki
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, execution.Node.NodeToken, "wiki"); grant != nil {
out["permission_grant"] = grant
}
if u := common.BuildResourceURL(runtime.Config.Brand, "wiki", execution.Node.NodeToken); u != "" {
if u := wikiNodeURL(runtime.Config.Brand, execution.Node); u != "" {
out["url"] = u
}
return out

View File

@@ -107,24 +107,6 @@ func mountAndRunWiki(t *testing.T, shortcut common.Shortcut, args []string, fact
return parent.Execute()
}
func TestWikiShortcutsIncludeAllCommands(t *testing.T) {
t.Parallel()
shortcuts := Shortcuts()
if len(shortcuts) != 6 {
t.Fatalf("len(Shortcuts()) = %d, want 6", len(shortcuts))
}
if shortcuts[0].Command != "+move" {
t.Fatalf("shortcuts[0].Command = %q, want %q", shortcuts[0].Command, "+move")
}
if shortcuts[1].Command != "+node-create" {
t.Fatalf("shortcuts[1].Command = %q, want %q", shortcuts[1].Command, "+node-create")
}
if shortcuts[2].Command != "+delete-space" {
t.Fatalf("shortcuts[2].Command = %q, want %q", shortcuts[2].Command, "+delete-space")
}
}
func TestValidateWikiNodeCreateSpecRejectsShortcutWithoutOriginNodeToken(t *testing.T) {
t.Parallel()
@@ -469,6 +451,7 @@ func TestWikiNodeCreateMountedExecuteWithExplicitSpaceID(t *testing.T) {
"origin_node_token": "",
"title": "Wiki Node",
"has_child": false,
"url": "https://abc.feishu.cn/wiki/wik_created_real",
},
},
"msg": "success",
@@ -502,8 +485,8 @@ func TestWikiNodeCreateMountedExecuteWithExplicitSpaceID(t *testing.T) {
if envelope.Data["node_token"] != "wik_created" {
t.Fatalf("node_token = %#v, want %q", envelope.Data["node_token"], "wik_created")
}
if got, want := envelope.Data["url"], "https://www.feishu.cn/wiki/wik_created"; got != want {
t.Fatalf("url = %#v, want %q", got, want)
if got, want := envelope.Data["url"], "https://abc.feishu.cn/wiki/wik_created_real"; got != want {
t.Fatalf("url = %#v, want %q (response url must win over synthesized fallback)", got, want)
}
var captured map[string]interface{}
@@ -646,3 +629,47 @@ func TestAugmentWikiNodeCreateOutputReturnsEmptyMapForNilInput(t *testing.T) {
t.Fatalf("augmentWikiNodeCreateOutput(nil, empty execution) = %#v, want empty map", got)
}
}
func TestWikiNodeURL(t *testing.T) {
t.Parallel()
tests := []struct {
name string
node *wikiNodeRecord
want string
}{
{
name: "prefers response url over synthesized fallback",
node: &wikiNodeRecord{NodeToken: "wik_token", URL: "https://abc.feishu.cn/wiki/wik_real"},
want: "https://abc.feishu.cn/wiki/wik_real",
},
{
name: "falls back to synthesized url when response omits it",
node: &wikiNodeRecord{NodeToken: "wik_token"},
want: "https://www.feishu.cn/wiki/wik_token",
},
{
name: "blank response url is treated as absent",
node: &wikiNodeRecord{NodeToken: "wik_token", URL: " "},
want: "https://www.feishu.cn/wiki/wik_token",
},
{
name: "nil node yields empty string",
node: nil,
want: "",
},
{
name: "no token and no url yields empty string",
node: &wikiNodeRecord{},
want: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := wikiNodeURL(core.BrandFeishu, tc.node); got != tc.want {
t.Fatalf("wikiNodeURL() = %q, want %q", got, tc.want)
}
})
}
}

View File

@@ -0,0 +1,440 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// wikiNodeDeleteObjTypes is the set of obj_type values the delete-node API
// accepts. Unlike wikiNodeGetObjTypeEnum this includes "wiki" — for
// delete-node, obj_type="wiki" means the token is a wiki node_token, whereas
// the get_node API omits obj_type for node_tokens.
var wikiNodeDeleteObjTypes = []string{
"wiki", "doc", "docx", "sheet", "bitable", "mindnote", "slides", "file",
}
var (
wikiDeleteNodePollAttempts = 30
wikiDeleteNodePollInterval = 2 * time.Second
)
// Lark wiki API error codes the delete-node API surfaces with actionable
// CLI workarounds. The full list is in the OpenAPI spec; we only special-case
// the codes whose remediation is non-obvious (UI approval, subtree size).
const (
wikiDeleteNodeErrCodeApprovalRequired = 131011
wikiDeleteNodeErrCodeSubtreeTooLarge = 131003
)
// WikiNodeDelete deletes a wiki node (or pulls a cloud doc out of Wiki). The
// API mirrors +delete-space — synchronous on small deletes, async with a
// task_id for cascade deletes — so this shortcut shares the async-polling
// helper. Space ID is optional: when omitted, +node-delete first looks up the
// node via get_node to resolve the space ID so callers do not have to chain
// commands.
var WikiNodeDelete = common.Shortcut{
Service: "wiki",
Command: "+node-delete",
Description: "Delete a wiki node, polling the async delete task when needed",
Risk: "high-risk-write",
// API spec lists wiki:node:create as the only declared scope for the
// delete endpoint. Naming is unfortunate, but the scope-preflight needs
// the literal string.
Scopes: []string{"wiki:node:create"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "node-token", Desc: "wiki node_token, cloud-doc obj_token, or a Lark URL embedding one of them", Required: true},
// Not Required at the cobra level: URL inputs auto-infer obj_type
// from the path, and the parser enforces explicit obj_type for raw
// tokens. Forcing Cobra Required here breaks the URL ergonomic.
{Name: "obj-type", Desc: "token kind; no default — pass explicitly when --node-token is a raw token (URL inputs auto-infer)", Enum: wikiNodeDeleteObjTypes},
{Name: "space-id", Desc: "wiki space ID; auto-resolved via get_node when omitted"},
{Name: "include-children", Type: "bool", Default: "true", Desc: "cascade delete the subtree (default); pass --include-children=false to lift direct children up to the parent"},
},
Tips: []string{
"Deletion is irreversible; double-check --node-token and --obj-type before running.",
"This is a high-risk-write command; pass --yes to confirm the deletion.",
"--node-token accepts a raw token (wikcnXXX, docxXXX, ...) or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>; URL paths also imply --obj-type.",
"Run +node-get first to confirm space_id / obj_type when in doubt.",
"Auto-resolving space_id (when --space-id is omitted) also calls get_node, which needs the wiki:node:retrieve scope; pass --space-id to skip that lookup if your token only carries wiki:node:create.",
"Async deletes return a task_id; this command polls for a bounded window and then prints a follow-up drive +task_result command.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readWikiNodeDeleteSpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readWikiNodeDeleteSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildWikiNodeDeleteDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readWikiNodeDeleteSpec(runtime)
if err != nil {
return err
}
out, err := runWikiNodeDelete(ctx, wikiNodeDeleteAPI{runtime: runtime}, runtime, spec)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
// wikiNodeDeleteSpec is the normalized input for the shortcut. Token / ObjType
// reconcile URL inputs with the explicit flags; SourceKind is purely for the
// dry-run description string.
type wikiNodeDeleteSpec struct {
NodeToken string
ObjType string
SpaceID string
IncludeChildren bool
SourceKind string // "raw" | "url"
}
// RequestBody builds the JSON body for DELETE /spaces/{id}/nodes/{token}.
func (spec wikiNodeDeleteSpec) RequestBody() map[string]interface{} {
return map[string]interface{}{
"obj_type": spec.ObjType,
"include_children": spec.IncludeChildren,
}
}
// wikiNodeDeleteClient isolates the network operations so business logic can
// be unit-tested without real HTTP calls. Mirrors wikiDeleteSpaceClient.
type wikiNodeDeleteClient interface {
ResolveNode(ctx context.Context, token, objType string) (*wikiNodeRecord, error)
DeleteNode(ctx context.Context, spaceID string, spec wikiNodeDeleteSpec) (string, error)
GetDeleteNodeTask(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error)
}
type wikiNodeDeleteAPI struct {
runtime *common.RuntimeContext
}
func (api wikiNodeDeleteAPI) ResolveNode(ctx context.Context, token, objType string) (*wikiNodeRecord, error) {
params := map[string]interface{}{"token": token}
// get_node takes obj_type only when the token is an obj_token. For
// wiki node_tokens the API rejects an obj_type kwarg, so omit it.
if objType != "" && objType != "wiki" {
params["obj_type"] = objType
}
data, err := api.runtime.CallAPI("GET", "/open-apis/wiki/v2/spaces/get_node", params, nil)
if err != nil {
return nil, err
}
return parseWikiNodeRecord(common.GetMap(data, "node"))
}
func (api wikiNodeDeleteAPI) DeleteNode(ctx context.Context, spaceID string, spec wikiNodeDeleteSpec) (string, error) {
data, err := api.runtime.CallAPI(
"DELETE",
fmt.Sprintf(
"/open-apis/wiki/v2/spaces/%s/nodes/%s",
validate.EncodePathSegment(spaceID),
validate.EncodePathSegment(spec.NodeToken),
),
nil,
spec.RequestBody(),
)
if err != nil {
return "", wrapWikiNodeDeleteAPIError(err)
}
return common.GetString(data, "task_id"), nil
}
func (api wikiNodeDeleteAPI) GetDeleteNodeTask(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error) {
data, err := api.runtime.CallAPI(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
map[string]interface{}{"task_type": wikiAsyncTaskTypeDeleteNode},
nil,
)
if err != nil {
return wikiAsyncTaskStatus{}, err
}
return parseWikiAsyncTaskStatus(taskID, common.GetMap(data, "task"), wikiAsyncResultSimpleTask)
}
func readWikiNodeDeleteSpec(runtime *common.RuntimeContext) (wikiNodeDeleteSpec, error) {
return parseWikiNodeDeleteSpec(
runtime.Str("node-token"),
runtime.Str("obj-type"),
runtime.Str("space-id"),
runtime.Bool("include-children"),
)
}
// parseWikiNodeDeleteSpec normalizes the raw flag values: extracts a token
// from a URL when provided, reconciles URL-implied obj_type against the
// explicit flag, and validates that the resulting obj_type is one the delete
// API accepts.
func parseWikiNodeDeleteSpec(rawToken, rawObjType, rawSpaceID string, includeChildren bool) (wikiNodeDeleteSpec, error) {
tokenInput := strings.TrimSpace(rawToken)
if tokenInput == "" {
return wikiNodeDeleteSpec{}, output.ErrValidation("--node-token is required")
}
spec := wikiNodeDeleteSpec{
ObjType: strings.ToLower(strings.TrimSpace(rawObjType)),
SpaceID: strings.TrimSpace(rawSpaceID),
IncludeChildren: includeChildren,
}
if strings.Contains(tokenInput, "://") {
u, err := url.Parse(tokenInput)
if err != nil || u.Path == "" {
return wikiNodeDeleteSpec{}, output.ErrValidation("--node-token URL is malformed: %q", tokenInput)
}
token, urlObjType, ok := tokenAndObjTypeFromWikiURL(u.Path)
if !ok {
return wikiNodeDeleteSpec{}, output.ErrValidation(
"unsupported --node-token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token",
u.Path,
)
}
spec.NodeToken = token
spec.SourceKind = "url"
// /wiki/<token> implies node_token → obj_type=wiki for the delete API.
// Cloud doc paths (/docx/, /sheets/, ...) already give us a concrete type.
inferred := urlObjType
if inferred == "" {
inferred = "wiki"
}
switch {
case spec.ObjType == "":
spec.ObjType = inferred
case spec.ObjType != inferred:
return wikiNodeDeleteSpec{}, output.ErrValidation(
"--obj-type %q does not match the obj_type %q implied by the URL path; pass only one",
spec.ObjType, inferred,
)
}
} else if strings.ContainsAny(tokenInput, "/?#") {
return wikiNodeDeleteSpec{}, output.ErrValidation(
"--node-token must be a raw token or a full URL; partial paths are not accepted: %q",
tokenInput,
)
} else {
spec.NodeToken = tokenInput
spec.SourceKind = "raw"
}
if spec.ObjType == "" {
return wikiNodeDeleteSpec{}, output.ErrValidation(
"--obj-type is required (one of: %s)",
strings.Join(wikiNodeDeleteObjTypes, ", "),
)
}
if !isValidWikiDeleteObjType(spec.ObjType) {
return wikiNodeDeleteSpec{}, output.ErrValidation(
"--obj-type %q is not valid; pick one of: %s",
spec.ObjType, strings.Join(wikiNodeDeleteObjTypes, ", "),
)
}
if err := validateOptionalResourceName(spec.NodeToken, "--node-token"); err != nil {
return wikiNodeDeleteSpec{}, err
}
if err := validateOptionalResourceName(spec.SpaceID, "--space-id"); err != nil {
return wikiNodeDeleteSpec{}, err
}
return spec, nil
}
func isValidWikiDeleteObjType(v string) bool {
for _, t := range wikiNodeDeleteObjTypes {
if v == t {
return true
}
}
return false
}
func buildWikiNodeDeleteDryRun(spec wikiNodeDeleteSpec) *common.DryRunAPI {
dry := common.NewDryRunAPI().Desc(
"async-aware: delete wiki node -> poll wiki delete-node task when task_id is returned (auto-resolves space_id via get_node when --space-id is omitted)",
)
if spec.SpaceID == "" {
params := map[string]interface{}{"token": spec.NodeToken}
if spec.ObjType != "" && spec.ObjType != "wiki" {
params["obj_type"] = spec.ObjType
}
dry.GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve space_id via get_node").
Params(params)
dry.DELETE(fmt.Sprintf(
"/open-apis/wiki/v2/spaces/%s/nodes/%s",
"<resolved_space_id>",
validate.EncodePathSegment(spec.NodeToken),
)).
Desc("[2] Delete wiki node").
Body(spec.RequestBody())
} else {
dry.DELETE(fmt.Sprintf(
"/open-apis/wiki/v2/spaces/%s/nodes/%s",
validate.EncodePathSegment(spec.SpaceID),
validate.EncodePathSegment(spec.NodeToken),
)).
Desc("[1] Delete wiki node").
Body(spec.RequestBody())
}
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
Desc("[N] Poll wiki delete-node task result when async").
Set("task_id", "<task_id>").
Params(map[string]interface{}{"task_type": wikiAsyncTaskTypeDeleteNode})
return dry
}
func runWikiNodeDelete(ctx context.Context, client wikiNodeDeleteClient, runtime *common.RuntimeContext, spec wikiNodeDeleteSpec) (map[string]interface{}, error) {
spaceID, err := resolveWikiNodeDeleteSpaceID(ctx, client, runtime, spec)
if err != nil {
return nil, err
}
fmt.Fprintf(runtime.IO().ErrOut, "Deleting wiki node %s in space %s (obj_type=%s, include_children=%t)...\n",
common.MaskToken(spec.NodeToken), common.MaskToken(spaceID), spec.ObjType, spec.IncludeChildren)
taskID, err := client.DeleteNode(ctx, spaceID, spec)
if err != nil {
return nil, err
}
out := map[string]interface{}{
"space_id": spaceID,
"node_token": spec.NodeToken,
"obj_type": spec.ObjType,
"include_children": spec.IncludeChildren,
}
// Empty task_id means the delete completed synchronously. Match the
// shape used by +delete-space so downstream scripts can read `status`
// uniformly regardless of which branch fired.
if taskID == "" {
out["ready"] = true
out["failed"] = false
out["status"] = wikiAsyncStatusSuccess
out["status_msg"] = wikiAsyncStatusSuccess
return out, nil
}
fmt.Fprintf(runtime.IO().ErrOut, "Wiki node delete is async, polling task %s...\n", taskID)
nextCommand := wikiDeleteNodeTaskResultCommand(taskID, runtime.As())
status, ready, err := pollWikiAsyncTask(
ctx, runtime, taskID, "delete-node",
wikiDeleteNodePollAttempts, wikiDeleteNodePollInterval,
func(ctx context.Context, id string) (wikiAsyncTaskStatus, error) {
return client.GetDeleteNodeTask(ctx, id)
},
nextCommand,
)
if err != nil {
return nil, err
}
out["task_id"] = taskID
out["ready"] = ready
out["failed"] = status.Failed()
out["status"] = status.StatusCode()
out["status_msg"] = status.StatusLabel()
if !ready {
fmt.Fprintf(runtime.IO().ErrOut, "Wiki delete-node task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
return out, nil
}
// resolveWikiNodeDeleteSpaceID returns the explicit space_id when the caller
// supplied one, otherwise resolves it via get_node. The latter saves callers
// from running +node-get first when they only have a node_token.
func resolveWikiNodeDeleteSpaceID(ctx context.Context, client wikiNodeDeleteClient, runtime *common.RuntimeContext, spec wikiNodeDeleteSpec) (string, error) {
if spec.SpaceID != "" {
return spec.SpaceID, nil
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolving space_id via get_node for token %s...\n", common.MaskToken(spec.NodeToken))
node, err := client.ResolveNode(ctx, spec.NodeToken, spec.ObjType)
if err != nil {
return "", err
}
spaceID, err := requireWikiNodeSpaceID(node)
if err != nil {
return "", err
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved to space %s\n", common.MaskToken(spaceID))
return spaceID, nil
}
func wikiDeleteNodeTaskResultCommand(taskID string, identity core.Identity) string {
asFlag := string(identity)
if asFlag == "" {
asFlag = "user"
}
return fmt.Sprintf("lark-cli drive +task_result --scenario wiki_delete_node --task-id %s --as %s", taskID, asFlag)
}
// wrapWikiNodeDeleteAPIError attaches actionable hints to the two Lark error
// codes whose remediation lives outside the CLI:
// - 131011: approval required (deletion gated by Wiki UI approval flow)
// - 131003: subtree too large to cascade-delete (must split or use
// include_children=false)
//
// Other codes pass through untouched so the generic error envelope still
// surfaces the original code+message.
func wrapWikiNodeDeleteAPIError(err error) error {
if err == nil {
return nil
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return err
}
var hint string
switch exitErr.Detail.Code {
case wikiDeleteNodeErrCodeApprovalRequired:
hint = "this wiki node has delete-approval enabled; ask the user to apply via the Wiki UI (CLI cannot bypass approval)"
case wikiDeleteNodeErrCodeSubtreeTooLarge:
hint = "the subtree is too large to cascade-delete in one call; pass --include-children=false to keep the children (they will be moved up to the parent), or delete sub-trees first"
}
if hint == "" {
return err
}
if existing := strings.TrimSpace(exitErr.Detail.Hint); existing != "" {
hint = existing + "\n" + hint
}
// ErrWithHint drops the upstream Detail.Code / Detail / Risk fields; build
// the ExitError by hand so the Lark error code stays available to logs and
// downstream pivots.
return &output.ExitError{
Code: exitErr.Code,
Detail: &output.ErrDetail{
Type: exitErr.Detail.Type,
Code: exitErr.Detail.Code,
Message: exitErr.Detail.Message,
Hint: hint,
ConsoleURL: exitErr.Detail.ConsoleURL,
Risk: exitErr.Detail.Risk,
Detail: exitErr.Detail.Detail,
},
}
}

View File

@@ -0,0 +1,611 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"reflect"
"strings"
"sync"
"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/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// ── parseWikiNodeDeleteSpec ─────────────────────────────────────────────────
func TestParseWikiNodeDeleteSpecAcceptsRawWikiToken(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeDeleteSpec("wikcnABC", "wiki", "", true)
if err != nil {
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
}
if spec.NodeToken != "wikcnABC" || spec.ObjType != "wiki" || spec.SourceKind != "raw" || !spec.IncludeChildren {
t.Fatalf("spec = %+v", spec)
}
body := spec.RequestBody()
if body["obj_type"] != "wiki" || body["include_children"] != true {
t.Fatalf("RequestBody = %#v", body)
}
}
func TestParseWikiNodeDeleteSpecRejectsMissingObjType(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeDeleteSpec("wikcnABC", "", "", true)
if err == nil || !strings.Contains(err.Error(), "--obj-type is required") {
t.Fatalf("expected obj-type required error, got %v", err)
}
}
func TestParseWikiNodeDeleteSpecRejectsInvalidObjType(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeDeleteSpec("wikcnABC", "comment", "", true)
if err == nil || !strings.Contains(err.Error(), "is not valid") {
t.Fatalf("expected invalid obj-type error, got %v", err)
}
}
func TestParseWikiNodeDeleteSpecRejectsEmptyToken(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeDeleteSpec(" ", "wiki", "", true)
if err == nil || !strings.Contains(err.Error(), "--node-token is required") {
t.Fatalf("expected token required error, got %v", err)
}
}
func TestParseWikiNodeDeleteSpecExtractsTokenFromWikiURL(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeDeleteSpec("https://feishu.cn/wiki/wikcnABC", "", "", true)
if err != nil {
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
}
if spec.NodeToken != "wikcnABC" || spec.ObjType != "wiki" || spec.SourceKind != "url" {
t.Fatalf("spec = %+v, want url-extracted node_token + obj_type=wiki", spec)
}
}
func TestParseWikiNodeDeleteSpecInfersObjTypeFromDocxURL(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeDeleteSpec("https://feishu.cn/docx/docxXYZ", "", "", false)
if err != nil {
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
}
if spec.NodeToken != "docxXYZ" || spec.ObjType != "docx" || spec.IncludeChildren {
t.Fatalf("spec = %+v, want docxXYZ obj_type=docx include_children=false", spec)
}
}
func TestParseWikiNodeDeleteSpecRejectsURLObjTypeMismatch(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeDeleteSpec("https://feishu.cn/docx/docxXYZ", "wiki", "", true)
if err == nil || !strings.Contains(err.Error(), "does not match the obj_type") {
t.Fatalf("expected obj-type mismatch error, got %v", err)
}
}
func TestParseWikiNodeDeleteSpecRejectsPartialPath(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeDeleteSpec("/wiki/wikcnABC", "wiki", "", true)
if err == nil || !strings.Contains(err.Error(), "partial paths are not accepted") {
t.Fatalf("expected partial-path rejection, got %v", err)
}
}
// ── DryRun ──────────────────────────────────────────────────────────────────
func TestBuildWikiNodeDeleteDryRunWithoutSpaceIDShowsResolve(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeDeleteSpec("https://feishu.cn/docx/docxXYZ", "", "", true)
if err != nil {
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
}
dry := buildWikiNodeDeleteDryRun(spec)
got := decodeDryRunAPIs(t, dry)
if len(got) != 3 {
t.Fatalf("len(dry.api) = %d, want 3 (get_node, delete, task poll)", len(got))
}
if got[0].URL != "/open-apis/wiki/v2/spaces/get_node" {
t.Fatalf("step[0].URL = %q, want get_node", got[0].URL)
}
if got[0].Params["obj_type"] != "docx" || got[0].Params["token"] != "docxXYZ" {
t.Fatalf("step[0].params = %#v", got[0].Params)
}
if got[1].URL != "/open-apis/wiki/v2/spaces/<resolved_space_id>/nodes/docxXYZ" {
t.Fatalf("step[1].URL = %q, want delete with placeholder", got[1].URL)
}
if got[1].Body["obj_type"] != "docx" || got[1].Body["include_children"] != true {
t.Fatalf("step[1].body = %#v", got[1].Body)
}
if got[2].Params["task_type"] != "delete_node" {
t.Fatalf("step[2].params task_type = %#v, want delete_node", got[2].Params)
}
}
func TestBuildWikiNodeDeleteDryRunWithSpaceIDOmitsResolve(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeDeleteSpec("wikcnABC", "wiki", "7629741305993170448", false)
if err != nil {
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
}
dry := buildWikiNodeDeleteDryRun(spec)
got := decodeDryRunAPIs(t, dry)
if len(got) != 2 {
t.Fatalf("len(dry.api) = %d, want 2 (delete + task poll) when --space-id supplied", len(got))
}
if got[0].Method != "DELETE" || got[0].URL != "/open-apis/wiki/v2/spaces/7629741305993170448/nodes/wikcnABC" {
t.Fatalf("step[0] = %+v", got[0])
}
if got[0].Body["include_children"] != false {
t.Fatalf("body include_children = %#v", got[0].Body["include_children"])
}
}
// ── runWikiNodeDelete unit ──────────────────────────────────────────────────
type fakeWikiNodeDeleteClient struct {
resolveErr error
resolveNode *wikiNodeRecord
resolveCalls []string
deleteErr error
deleteTaskID string
deleteCalls []struct {
SpaceID string
Spec wikiNodeDeleteSpec
}
taskStatuses []wikiAsyncTaskStatus
taskErrs []error
taskCallArgs []string
}
func (fake *fakeWikiNodeDeleteClient) ResolveNode(ctx context.Context, token, objType string) (*wikiNodeRecord, error) {
fake.resolveCalls = append(fake.resolveCalls, token)
if fake.resolveErr != nil {
return nil, fake.resolveErr
}
return fake.resolveNode, nil
}
func (fake *fakeWikiNodeDeleteClient) DeleteNode(ctx context.Context, spaceID string, spec wikiNodeDeleteSpec) (string, error) {
fake.deleteCalls = append(fake.deleteCalls, struct {
SpaceID string
Spec wikiNodeDeleteSpec
}{SpaceID: spaceID, Spec: spec})
if fake.deleteErr != nil {
return "", fake.deleteErr
}
return fake.deleteTaskID, nil
}
func (fake *fakeWikiNodeDeleteClient) GetDeleteNodeTask(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error) {
idx := len(fake.taskCallArgs)
fake.taskCallArgs = append(fake.taskCallArgs, taskID)
if idx < len(fake.taskErrs) && fake.taskErrs[idx] != nil {
return wikiAsyncTaskStatus{TaskID: taskID}, fake.taskErrs[idx]
}
if idx < len(fake.taskStatuses) {
status := fake.taskStatuses[idx]
if status.TaskID == "" {
status.TaskID = taskID
}
return status, nil
}
return wikiAsyncTaskStatus{TaskID: taskID}, nil
}
var wikiDeleteNodePollMu sync.Mutex
func withSingleWikiDeleteNodePoll(t *testing.T) {
t.Helper()
wikiDeleteNodePollMu.Lock()
prevAttempts, prevInterval := wikiDeleteNodePollAttempts, wikiDeleteNodePollInterval
wikiDeleteNodePollAttempts, wikiDeleteNodePollInterval = 1, 0
t.Cleanup(func() {
wikiDeleteNodePollAttempts, wikiDeleteNodePollInterval = prevAttempts, prevInterval
wikiDeleteNodePollMu.Unlock()
})
}
func newWikiNodeDeleteRuntime(t *testing.T, as core.Identity) (*common.RuntimeContext, *bytes.Buffer) {
t.Helper()
cfg := wikiTestConfig()
factory, _, stderr, _ := cmdutil.TestFactory(t, cfg)
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "wiki +node-delete"}, cfg, as)
runtime.Factory = factory
return runtime, stderr
}
func TestRunWikiNodeDeleteResolvesSpaceWhenMissing(t *testing.T) {
t.Parallel()
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
client := &fakeWikiNodeDeleteClient{
resolveNode: &wikiNodeRecord{SpaceID: "space_resolved"},
}
out, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
NodeToken: "wikcnABC",
ObjType: "wiki",
IncludeChildren: true,
})
if err != nil {
t.Fatalf("runWikiNodeDelete() error = %v", err)
}
if len(client.resolveCalls) != 1 || client.resolveCalls[0] != "wikcnABC" {
t.Fatalf("resolve calls = %v", client.resolveCalls)
}
if len(client.deleteCalls) != 1 || client.deleteCalls[0].SpaceID != "space_resolved" {
t.Fatalf("delete calls = %+v", client.deleteCalls)
}
if out["space_id"] != "space_resolved" || out["ready"] != true || out["status"] != "success" {
t.Fatalf("sync output = %#v", out)
}
}
func TestRunWikiNodeDeleteSkipsResolveWhenSpaceProvided(t *testing.T) {
t.Parallel()
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
client := &fakeWikiNodeDeleteClient{}
_, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
NodeToken: "wikcnABC", ObjType: "wiki", SpaceID: "space_explicit",
})
if err != nil {
t.Fatalf("runWikiNodeDelete() error = %v", err)
}
if len(client.resolveCalls) != 0 {
t.Fatalf("resolveCalls should be empty when --space-id supplied, got %v", client.resolveCalls)
}
if client.deleteCalls[0].SpaceID != "space_explicit" {
t.Fatalf("delete used wrong space: %+v", client.deleteCalls)
}
}
func TestRunWikiNodeDeleteAsyncReadyShape(t *testing.T) {
withSingleWikiDeleteNodePoll(t)
runtime, stderr := newWikiNodeDeleteRuntime(t, core.AsUser)
client := &fakeWikiNodeDeleteClient{
deleteTaskID: "task_async_node",
taskStatuses: []wikiAsyncTaskStatus{{Status: "success"}},
}
out, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
NodeToken: "wikcnABC", ObjType: "wiki", SpaceID: "space_123", IncludeChildren: true,
})
if err != nil {
t.Fatalf("runWikiNodeDelete() error = %v", err)
}
if out["task_id"] != "task_async_node" || out["ready"] != true || out["failed"] != false {
t.Fatalf("async-ready output = %#v", out)
}
if !strings.Contains(stderr.String(), "async, polling task") || !strings.Contains(stderr.String(), "delete-node task completed successfully") {
t.Fatalf("stderr = %q", stderr.String())
}
}
func TestRunWikiNodeDeleteAsyncTimeoutReturnsNextCommand(t *testing.T) {
withSingleWikiDeleteNodePoll(t)
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
client := &fakeWikiNodeDeleteClient{
deleteTaskID: "task_async_node",
taskStatuses: []wikiAsyncTaskStatus{{Status: "processing"}},
}
out, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
NodeToken: "wikcnABC", ObjType: "wiki", SpaceID: "space_123",
})
if err != nil {
t.Fatalf("runWikiNodeDelete() error = %v", err)
}
wantNext := wikiDeleteNodeTaskResultCommand("task_async_node", core.AsUser)
if out["ready"] != false || out["timed_out"] != true || out["next_command"] != wantNext {
t.Fatalf("timeout output = %#v", out)
}
if !strings.Contains(wantNext, "wiki_delete_node") {
t.Fatalf("next command should scope wiki_delete_node, got %q", wantNext)
}
}
func TestRunWikiNodeDeleteAsyncFailureSurfacesReason(t *testing.T) {
withSingleWikiDeleteNodePoll(t)
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
client := &fakeWikiNodeDeleteClient{
deleteTaskID: "task_async_node",
taskStatuses: []wikiAsyncTaskStatus{{Status: "failure", StatusMsg: "permission denied"}},
}
_, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
NodeToken: "wikcnABC", ObjType: "wiki", SpaceID: "space_123",
})
if err == nil || !strings.Contains(err.Error(), "delete-node task task_async_node failed: permission denied") {
t.Fatalf("expected async failure error, got %v", err)
}
}
// ── error code hint mapping ─────────────────────────────────────────────────
func TestWrapWikiNodeDeleteAPIErrorAddsApprovalHint(t *testing.T) {
t.Parallel()
in := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "api_error",
Code: wikiDeleteNodeErrCodeApprovalRequired,
Message: "node requires delete approval",
},
}
got := wrapWikiNodeDeleteAPIError(in)
var exitErr *output.ExitError
if !errors.As(got, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError, got %T %v", got, got)
}
if !strings.Contains(exitErr.Detail.Hint, "delete-approval enabled") || !strings.Contains(exitErr.Detail.Hint, "Wiki UI") {
t.Fatalf("hint = %q, want approval guidance", exitErr.Detail.Hint)
}
// Original code/message must be preserved so logs and dashboards still
// pivot on the upstream error code.
if exitErr.Detail.Code != wikiDeleteNodeErrCodeApprovalRequired {
t.Fatalf("hint wrapper lost the original code: %d", exitErr.Detail.Code)
}
if exitErr.Detail.Message != "node requires delete approval" {
t.Fatalf("message changed unexpectedly: %q", exitErr.Detail.Message)
}
}
func TestWrapWikiNodeDeleteAPIErrorAddsSubtreeHint(t *testing.T) {
t.Parallel()
in := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "api_error",
Code: wikiDeleteNodeErrCodeSubtreeTooLarge,
Message: "subtree too large",
},
}
got := wrapWikiNodeDeleteAPIError(in)
var exitErr *output.ExitError
if !errors.As(got, &exitErr) {
t.Fatalf("expected ExitError, got %T %v", got, got)
}
if !strings.Contains(exitErr.Detail.Hint, "--include-children=false") {
t.Fatalf("hint = %q, want subtree-too-large guidance", exitErr.Detail.Hint)
}
}
func TestWrapWikiNodeDeleteAPIErrorPassesThroughUnknownCodes(t *testing.T) {
t.Parallel()
in := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "api_error", Code: 131005, Message: "node not found"},
}
got := wrapWikiNodeDeleteAPIError(in)
if !reflect.DeepEqual(got, in) {
t.Fatalf("unknown code should pass through; got %#v", got)
}
}
func TestWrapWikiNodeDeleteAPIErrorIgnoresNonExit(t *testing.T) {
t.Parallel()
in := errors.New("transport boom")
if got := wrapWikiNodeDeleteAPIError(in); got != in {
t.Fatalf("non-ExitError should pass through, got %T %v", got, got)
}
if got := wrapWikiNodeDeleteAPIError(nil); got != nil {
t.Fatalf("nil should pass through, got %v", got)
}
}
// ── Mounted execute (httpmock) ──────────────────────────────────────────────
func TestWikiNodeDeleteExecuteRequiresYesConfirmation(t *testing.T) {
factory, stdout, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiNodeDelete, []string{
"+node-delete",
"--node-token", "wikcnABC",
"--obj-type", "wiki",
"--space-id", "space_123",
"--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
t.Fatalf("expected high-risk confirmation error, got %v", err)
}
}
func TestWikiNodeDeleteExecuteSync(t *testing.T) {
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
deleteStub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes/wikcnABC",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
"msg": "success",
},
}
reg.Register(deleteStub)
err := mountAndRunWiki(t, WikiNodeDelete, []string{
"+node-delete",
"--node-token", "wikcnABC",
"--obj-type", "wiki",
"--space-id", "space_123",
"--yes",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
if data["ready"] != true || data["failed"] != false || data["space_id"] != "space_123" {
t.Fatalf("sync output = %#v", data)
}
if data["obj_type"] != "wiki" || data["include_children"] != true {
t.Fatalf("obj_type/include_children = %#v / %#v", data["obj_type"], data["include_children"])
}
var captured map[string]interface{}
if err := json.Unmarshal(deleteStub.CapturedBody, &captured); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if captured["obj_type"] != "wiki" || captured["include_children"] != true {
t.Fatalf("captured DELETE body = %#v", captured)
}
}
func TestWikiNodeDeleteExecuteResolvesSpaceIDFromURL(t *testing.T) {
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
resolveStub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_resolved",
"node_token": "wikcnABC",
"obj_token": "docxXYZ",
"obj_type": "docx",
},
},
},
}
var resolveQuery string
resolveStub.OnMatch = func(req *http.Request) { resolveQuery = req.URL.RawQuery }
reg.Register(resolveStub)
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/wiki/v2/spaces/space_resolved/nodes/docxXYZ",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
},
})
err := mountAndRunWiki(t, WikiNodeDelete, []string{
"+node-delete",
"--node-token", "https://feishu.cn/docx/docxXYZ",
"--yes",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
if !strings.Contains(resolveQuery, "token=docxXYZ") || !strings.Contains(resolveQuery, "obj_type=docx") {
t.Fatalf("resolve query = %q, want token+obj_type", resolveQuery)
}
data := decodeWikiEnvelope(t, stdout)
if data["space_id"] != "space_resolved" || data["obj_type"] != "docx" {
t.Fatalf("output = %#v", data)
}
}
func TestWikiNodeDeleteExecuteAsyncSuccess(t *testing.T) {
withSingleWikiDeleteNodePoll(t)
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes/wikcnABC",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task_id": "task_async_node"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/tasks/task_async_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"task": map[string]interface{}{
// Gateway returns delete-node status under the generic
// simple_task_result key (NOT delete_node_result).
"simple_task_result": map[string]interface{}{
"status": "success",
},
},
},
},
})
err := mountAndRunWiki(t, WikiNodeDelete, []string{
"+node-delete",
"--node-token", "wikcnABC",
"--obj-type", "wiki",
"--space-id", "space_123",
"--yes",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
if data["task_id"] != "task_async_node" || data["ready"] != true || data["failed"] != false {
t.Fatalf("async-success output = %#v", data)
}
}
// ── helpers ─────────────────────────────────────────────────────────────────
type dryRunStep struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
Params map[string]interface{} `json:"params"`
}
func decodeDryRunAPIs(t *testing.T, dry *common.DryRunAPI) []dryRunStep {
t.Helper()
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []dryRunStep `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run: %v", err)
}
return got.API
}

View File

@@ -0,0 +1,370 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"fmt"
"io"
"net/url"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// wikiNodeGetURLObjTypes maps a Lark URL path prefix (slash-bounded) to the
// obj_type the wiki get_node API expects when the token is an obj_token.
// /wiki/ is handled separately because node_tokens take no obj_type.
//
// INVARIANT: the prefixes must be mutually exclusive (no prefix may be a
// prefix of another). tokenAndObjTypeFromWikiURL ranges this map, and Go map
// iteration order is randomized — overlapping prefixes would make the match
// non-deterministic. The trailing slash keeps them disjoint today (e.g.
// "/docx/" does not start with "/doc/"); preserve that when adding entries.
var wikiNodeGetURLObjTypes = map[string]string{
"/docx/": "docx",
"/doc/": "doc",
"/sheets/": "sheet",
"/base/": "bitable",
"/mindnote/": "mindnote",
"/slides/": "slides",
"/file/": "file",
}
// wikiNodeGetObjTypeEnum is the union of obj_types accepted by the upstream
// API. It is a superset of the create / move enums so this shortcut can look
// up legacy `doc` nodes too.
var wikiNodeGetObjTypeEnum = []string{
"doc", "docx", "sheet", "bitable", "mindnote", "slides", "file",
}
// WikiNodeGet wraps wiki.spaces.get_node so callers can resolve a node by
// node_token, obj_token, or a Lark URL without hand-rolling a
// `wiki spaces get_node --params ...` invocation. The shortcut prints a
// formatted view of the node (title / obj_type / obj_token / parent /
// creator / updated_at) and is intended as the "what am I about to
// touch?" step before +move / +node-copy / +delete-space.
var WikiNodeGet = common.Shortcut{
Service: "wiki",
Command: "+node-get",
Description: "Get wiki node details by node_token, obj_token, or Lark URL",
Risk: "read",
Scopes: []string{"wiki:node:retrieve"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "token", Desc: "wiki node_token, obj_token, or a Lark URL embedding one of them", Required: true},
{Name: "obj-type", Desc: "obj_type when --token is an obj_token; auto-inferred from URL path when omitted", Enum: wikiNodeGetObjTypeEnum},
{Name: "space-id", Desc: "optional: assert the resolved node lives in this space"},
},
Tips: []string{
"--token accepts a raw token (wikcnXXX, docxXXX, ...) or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>.",
"For raw obj_tokens (not starting with wik), pass --obj-type so the API knows how to resolve them; URL inputs infer it from the path.",
"Pair with +move / +node-copy / +delete-space to confirm space_id, obj_type, and parent before mutating.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readWikiNodeGetSpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readWikiNodeGetSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildWikiNodeGetDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readWikiNodeGetSpec(runtime)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Fetching wiki node %s...\n", common.MaskToken(spec.Token))
data, err := runtime.CallAPI("GET", "/open-apis/wiki/v2/spaces/get_node", spec.RequestParams(), nil)
if err != nil {
return err
}
raw := common.GetMap(data, "node")
node, err := parseWikiNodeRecord(raw)
if err != nil {
return err
}
if spec.SpaceID != "" && node.SpaceID != "" && spec.SpaceID != node.SpaceID {
return output.ErrValidation(
"--space-id %q does not match the resolved node space %q (node_token=%s)",
spec.SpaceID, node.SpaceID, node.NodeToken,
)
}
if spec.SpaceID != "" && node.SpaceID == "" {
// The cross-check was requested but get_node returned no space_id,
// so it silently passed. Surface that the assertion was a no-op
// rather than letting the caller assume it was verified.
fmt.Fprintf(runtime.IO().ErrOut,
"Warning: --space-id %q could not be verified; the resolved node carries no space_id.\n",
spec.SpaceID)
}
out := wikiNodeGetOutput(node, raw)
runtime.OutFormat(out, nil, func(w io.Writer) {
renderWikiNodeGetPretty(w, out)
})
return nil
},
}
// wikiNodeGetSpec is the normalized input for the shortcut.
type wikiNodeGetSpec struct {
// Token is the resolved token (after URL extraction) to send to the API.
Token string
// ObjType is the resolved obj_type. Empty for node_tokens (the API does
// not need obj_type for `wik`-prefixed tokens).
ObjType string
// SpaceID is an optional cross-check; when set, the response space_id must match.
SpaceID string
// SourceKind records how Token was derived for the dry-run description:
// "url-wiki", "url-obj", "raw-node", "raw-obj".
SourceKind string
}
// RequestParams returns the query params for GET /wiki/v2/spaces/get_node.
func (spec wikiNodeGetSpec) RequestParams() map[string]interface{} {
params := map[string]interface{}{"token": spec.Token}
if spec.ObjType != "" {
params["obj_type"] = spec.ObjType
}
return params
}
func readWikiNodeGetSpec(runtime *common.RuntimeContext) (wikiNodeGetSpec, error) {
return parseWikiNodeGetSpec(
runtime.Str("token"),
runtime.Str("obj-type"),
runtime.Str("space-id"),
)
}
// parseWikiNodeGetSpec normalizes the raw flag values: extracts a token from a
// URL when needed, picks the obj_type (URL path > explicit flag > none for
// node_tokens), and validates the token shape.
func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetSpec, error) {
tokenInput := strings.TrimSpace(rawToken)
if tokenInput == "" {
return wikiNodeGetSpec{}, output.ErrValidation("--token is required")
}
spec := wikiNodeGetSpec{
ObjType: strings.ToLower(strings.TrimSpace(rawObjType)),
SpaceID: strings.TrimSpace(rawSpaceID),
}
if strings.Contains(tokenInput, "://") {
u, err := url.Parse(tokenInput)
if err != nil || u.Path == "" {
return wikiNodeGetSpec{}, output.ErrValidation("--token URL is malformed: %q", tokenInput)
}
token, urlObjType, ok := tokenAndObjTypeFromWikiURL(u.Path)
if !ok {
return wikiNodeGetSpec{}, output.ErrValidation(
"unsupported --token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token",
u.Path,
)
}
spec.Token = token
if urlObjType == "" {
spec.SourceKind = "url-wiki"
} else {
spec.SourceKind = "url-obj"
}
switch {
case spec.ObjType == "" && urlObjType != "":
spec.ObjType = urlObjType
case spec.ObjType != "" && urlObjType != "" && spec.ObjType != urlObjType:
return wikiNodeGetSpec{}, output.ErrValidation(
"--obj-type %q does not match the obj_type %q implied by the URL path; pass only one",
spec.ObjType, urlObjType,
)
}
} else if strings.ContainsAny(tokenInput, "/?#") {
return wikiNodeGetSpec{}, output.ErrValidation(
"--token must be a raw token or a full URL; partial paths are not accepted: %q",
tokenInput,
)
} else {
spec.Token = tokenInput
if looksLikeWikiNodeToken(spec.Token) {
spec.SourceKind = "raw-node"
// node_tokens take no obj_type; reject a conflicting flag rather
// than silently passing it (the API would just ignore it, but the
// mismatch signals caller confusion).
if spec.ObjType != "" {
return wikiNodeGetSpec{}, output.ErrValidation(
"--obj-type is only valid for obj_tokens; %q looks like a node_token",
spec.Token,
)
}
} else {
spec.SourceKind = "raw-obj"
// A raw obj_token needs an explicit obj_type: get_node would
// otherwise default to "doc" and fail confusingly for docx /
// sheet / bitable / ... Fail fast with the same upfront contract
// as +node-delete instead of deferring to an opaque API error.
if spec.ObjType == "" {
return wikiNodeGetSpec{}, output.ErrValidation(
"--obj-type is required for a raw obj_token %q (one of: %s); or pass a typed Lark URL (e.g. /docx/<token>) so it can be inferred",
spec.Token, strings.Join(wikiNodeGetObjTypeEnum, ", "),
)
}
}
}
if err := validateOptionalResourceName(spec.Token, "--token"); err != nil {
return wikiNodeGetSpec{}, err
}
if err := validateOptionalResourceName(spec.SpaceID, "--space-id"); err != nil {
return wikiNodeGetSpec{}, err
}
return spec, nil
}
// looksLikeWikiNodeToken returns true when the token has the `wik` prefix used
// for node_tokens. Lark wiki tokens are case-insensitive in practice; callers
// pass `wikcn`/`wikus`/`Wik...` interchangeably, so normalize for the check.
//
// This is a heuristic based on the current Lark token-naming convention, not a
// guaranteed invariant: if Lark ever introduces a non-node token type that
// also starts with `wik`, it would be misclassified. Worst case is a
// confusing API error (no data risk); revisit if the token scheme changes.
func looksLikeWikiNodeToken(token string) bool {
return strings.HasPrefix(strings.ToLower(token), "wik")
}
// tokenAndObjTypeFromWikiURL extracts the token and inferred obj_type from a
// Lark URL path. The wiki path returns an empty obj_type because node_tokens
// don't need one.
func tokenAndObjTypeFromWikiURL(path string) (token, objType string, ok bool) {
if t, found := wikiPathSegmentAfter(path, "/wiki/"); found {
return t, "", true
}
for prefix, ot := range wikiNodeGetURLObjTypes {
if t, found := wikiPathSegmentAfter(path, prefix); found {
return t, ot, true
}
}
return "", "", false
}
// wikiPathSegmentAfter returns the first path segment after prefix, or ("",
// false) when path doesn't start with prefix or the segment is empty.
func wikiPathSegmentAfter(path, prefix string) (string, bool) {
if !strings.HasPrefix(path, prefix) {
return "", false
}
rest := path[len(prefix):]
if i := strings.IndexByte(rest, '/'); i >= 0 {
rest = rest[:i]
}
rest = strings.TrimSpace(rest)
if rest == "" {
return "", false
}
return rest, true
}
func buildWikiNodeGetDryRun(spec wikiNodeGetSpec) *common.DryRunAPI {
dry := common.NewDryRunAPI()
switch spec.SourceKind {
case "url-wiki":
dry.Desc("Resolve wiki node from /wiki/ URL")
case "url-obj":
dry.Desc("Resolve wiki node from Lark document URL (obj_type inferred from path)")
case "raw-node":
dry.Desc("Look up wiki node by node_token")
case "raw-obj":
dry.Desc("Look up wiki node by obj_token")
}
return dry.GET("/open-apis/wiki/v2/spaces/get_node").Params(spec.RequestParams())
}
// wikiNodeGetOutput shapes the structured output. It carries the formatted
// values (title/obj_type/obj_token/parent_node_token/creator/updated_at)
// the user asked for, plus enough raw fields (node_type, has_child, owner,
// timestamps) that callers can pipe into +move / +node-copy without rerunning
// get_node.
//
// No synthesized `url` is emitted: get_node returns none, and a
// BuildResourceURL fallback (www.feishu.cn/wiki/<node_token>) is a
// non-canonical link that misleads in a read/confirm command. Sibling read
// shortcuts (+node-list, +node-copy) likewise omit it; node_token/obj_token
// are the precise identifiers.
func wikiNodeGetOutput(node *wikiNodeRecord, raw map[string]interface{}) map[string]interface{} {
out := map[string]interface{}{
"space_id": node.SpaceID,
"node_token": node.NodeToken,
"obj_token": node.ObjToken,
"obj_type": node.ObjType,
"node_type": node.NodeType,
"parent_node_token": node.ParentNodeToken,
"origin_node_token": node.OriginNodeToken,
"title": node.Title,
"has_child": node.HasChild,
}
creator := strings.TrimSpace(common.GetString(raw, "node_creator"))
if creator == "" {
creator = strings.TrimSpace(common.GetString(raw, "creator"))
}
out["creator"] = creator
out["owner"] = common.GetString(raw, "owner")
objEditRaw := common.GetString(raw, "obj_edit_time")
out["obj_edit_time"] = objEditRaw
out["obj_create_time"] = common.GetString(raw, "obj_create_time")
out["node_create_time"] = common.GetString(raw, "node_create_time")
out["updated_at"] = formatWikiTimestamp(objEditRaw)
return out
}
// formatWikiTimestamp turns a Lark unix-seconds string (the format used by
// wiki.spaces.get_node) into a UTC RFC3339 string. UTC (not the host's local
// zone) keeps the output stable regardless of where the CLI runs. Returns ""
// when the input is empty or not numeric so the pretty renderer falls back
// to "-".
func formatWikiTimestamp(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
secs, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return ""
}
return time.Unix(secs, 0).UTC().Format(time.RFC3339)
}
func renderWikiNodeGetPretty(w io.Writer, out map[string]interface{}) {
fmt.Fprintln(w, "Wiki node:")
fmt.Fprintf(w, " title: %s\n", valueOrDash(out["title"]))
fmt.Fprintf(w, " obj_type: %s\n", valueOrDash(out["obj_type"]))
fmt.Fprintf(w, " obj_token: %s\n", valueOrDash(out["obj_token"]))
fmt.Fprintf(w, " node_token: %s\n", valueOrDash(out["node_token"]))
fmt.Fprintf(w, " space_id: %s\n", valueOrDash(out["space_id"]))
fmt.Fprintf(w, " parent_node_token: %s\n", valueOrDash(out["parent_node_token"]))
fmt.Fprintf(w, " node_type: %s\n", valueOrDash(out["node_type"]))
if origin, _ := out["origin_node_token"].(string); origin != "" {
fmt.Fprintf(w, " origin_node_token: %s\n", origin)
}
hasChild, _ := out["has_child"].(bool)
fmt.Fprintf(w, " has_child: %t\n", hasChild)
fmt.Fprintf(w, " creator: %s\n", valueOrDash(out["creator"]))
if owner, _ := out["owner"].(string); owner != "" {
fmt.Fprintf(w, " owner: %s\n", owner)
}
fmt.Fprintf(w, " updated_at: %s\n", valueOrDash(out["updated_at"]))
}

View File

@@ -0,0 +1,321 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"encoding/json"
"net/http"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestParseWikiNodeGetSpecRawNodeToken(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeGetSpec("wikcnABC", "", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
if spec.Token != "wikcnABC" || spec.ObjType != "" || spec.SourceKind != "raw-node" {
t.Fatalf("spec = %+v, want raw-node wikcnABC with no obj_type", spec)
}
if got := spec.RequestParams(); !reflect.DeepEqual(got, map[string]interface{}{"token": "wikcnABC"}) {
t.Fatalf("RequestParams() = %v, want {token: wikcnABC}", got)
}
}
func TestParseWikiNodeGetSpecRawObjTokenWithExplicitObjType(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeGetSpec("docxXYZ", "docx", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
if spec.Token != "docxXYZ" || spec.ObjType != "docx" || spec.SourceKind != "raw-obj" {
t.Fatalf("spec = %+v, want raw-obj docxXYZ obj_type=docx", spec)
}
}
func TestParseWikiNodeGetSpecRejectsRawObjTokenWithoutObjType(t *testing.T) {
t.Parallel()
// Mirrors +node-delete: a raw obj_token with no --obj-type must fail
// upfront instead of defaulting to "doc" and hitting an opaque API error.
_, err := parseWikiNodeGetSpec("bascnXYZ", "", "")
if err == nil || !strings.Contains(err.Error(), "--obj-type is required for a raw obj_token") {
t.Fatalf("expected raw obj_token obj-type-required error, got %v", err)
}
}
func TestParseWikiNodeGetSpecRejectsObjTypeOnNodeToken(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeGetSpec("wikcnABC", "docx", "")
if err == nil || !strings.Contains(err.Error(), "only valid for obj_tokens") {
t.Fatalf("expected node_token + obj_type rejection, got %v", err)
}
}
func TestParseWikiNodeGetSpecExtractsTokenFromWikiURL(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeGetSpec("https://feishu.cn/wiki/wikcnABC?foo=bar", "", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
if spec.Token != "wikcnABC" || spec.ObjType != "" || spec.SourceKind != "url-wiki" {
t.Fatalf("spec = %+v, want url-wiki wikcnABC", spec)
}
}
func TestParseWikiNodeGetSpecExtractsTokenAndObjTypeFromDocxURL(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeGetSpec("https://feishu.cn/docx/docxXYZ", "", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
if spec.Token != "docxXYZ" || spec.ObjType != "docx" || spec.SourceKind != "url-obj" {
t.Fatalf("spec = %+v, want url-obj docxXYZ", spec)
}
}
func TestParseWikiNodeGetSpecRejectsURLObjTypeMismatch(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeGetSpec("https://feishu.cn/sheets/shtXYZ", "docx", "")
if err == nil || !strings.Contains(err.Error(), "does not match the obj_type") {
t.Fatalf("expected URL/obj-type mismatch error, got %v", err)
}
}
func TestParseWikiNodeGetSpecRejectsUnsupportedURLPath(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeGetSpec("https://feishu.cn/im/chat/oc_123", "", "")
if err == nil || !strings.Contains(err.Error(), "unsupported --token URL path") {
t.Fatalf("expected unsupported URL path error, got %v", err)
}
}
func TestParseWikiNodeGetSpecRejectsPartialPath(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeGetSpec("/wiki/wikcnABC", "", "")
if err == nil || !strings.Contains(err.Error(), "partial paths are not accepted") {
t.Fatalf("expected partial-path rejection, got %v", err)
}
}
func TestParseWikiNodeGetSpecRejectsEmptyToken(t *testing.T) {
t.Parallel()
if _, err := parseWikiNodeGetSpec(" ", "", ""); err == nil || !strings.Contains(err.Error(), "--token is required") {
t.Fatalf("expected required-token error, got %v", err)
}
}
func TestBuildWikiNodeGetDryRunSendsObjType(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeGetSpec("https://feishu.cn/docx/docxXYZ", "", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
dry := buildWikiNodeGetDryRun(spec)
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run: %v", err)
}
if len(got.API) != 1 || got.API[0].URL != "/open-apis/wiki/v2/spaces/get_node" {
t.Fatalf("dry-run api = %#v, want single get_node call", got.API)
}
if got.API[0].Params["token"] != "docxXYZ" || got.API[0].Params["obj_type"] != "docx" {
t.Fatalf("dry-run params = %#v", got.API[0].Params)
}
}
func TestFormatWikiTimestamp(t *testing.T) {
t.Parallel()
if got := formatWikiTimestamp(""); got != "" {
t.Fatalf("formatWikiTimestamp(empty) = %q, want empty", got)
}
if got := formatWikiTimestamp("not-a-number"); got != "" {
t.Fatalf("formatWikiTimestamp(non-numeric) = %q, want empty", got)
}
// Output is UTC, so it is deterministic regardless of host timezone.
if got := formatWikiTimestamp("1700000000"); got != "2023-11-14T22:13:20Z" {
t.Fatalf("formatWikiTimestamp(1700000000) = %q, want 2023-11-14T22:13:20Z (UTC)", got)
}
}
func TestWikiNodeGetMountedExecuteParsesURLAndFormatsOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, stderr, reg := cmdutil.TestFactory(t, wikiTestConfig())
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_123",
"node_token": "wikcnABC",
"obj_token": "docxXYZ",
"obj_type": "docx",
"parent_node_token": "wikcnPARENT",
"node_type": "origin",
"title": "Design Spec",
"has_child": true,
"node_creator": "ou_creator",
"owner": "ou_owner",
"obj_edit_time": "1700000000",
"obj_create_time": "1690000000",
"node_create_time": "1690000001",
},
},
"msg": "success",
},
}
var capturedQuery string
stub.OnMatch = func(req *http.Request) {
capturedQuery = req.URL.RawQuery
}
reg.Register(stub)
err := mountAndRunWiki(t, WikiNodeGet, []string{
"+node-get",
"--token", "https://feishu.cn/docx/docxXYZ",
"--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
if !strings.Contains(capturedQuery, "token=docxXYZ") || !strings.Contains(capturedQuery, "obj_type=docx") {
t.Fatalf("captured query = %q, want token=docxXYZ and obj_type=docx", capturedQuery)
}
data := decodeWikiEnvelope(t, stdout)
if data["title"] != "Design Spec" {
t.Fatalf("title = %#v, want Design Spec", data["title"])
}
if data["obj_type"] != "docx" || data["obj_token"] != "docxXYZ" {
t.Fatalf("obj_type/obj_token = %#v / %#v", data["obj_type"], data["obj_token"])
}
if data["parent_node_token"] != "wikcnPARENT" {
t.Fatalf("parent_node_token = %#v", data["parent_node_token"])
}
if data["creator"] != "ou_creator" {
t.Fatalf("creator = %#v, want ou_creator", data["creator"])
}
if data["owner"] != "ou_owner" {
t.Fatalf("owner = %#v, want ou_owner", data["owner"])
}
if got, _ := data["updated_at"].(string); got != "2023-11-14T22:13:20Z" {
t.Fatalf("updated_at = %#v, want 2023-11-14T22:13:20Z (UTC)", data["updated_at"])
}
// +node-get deliberately does not synthesize a url (get_node returns none;
// a BuildResourceURL fallback would be a non-canonical, misleading link in
// a read/confirm command).
if _, ok := data["url"]; ok {
t.Fatalf("did not expect a url field in +node-get output, got %#v", data["url"])
}
if got := stderr.String(); !strings.Contains(got, "Fetching wiki node") {
t.Fatalf("stderr = %q, want fetching message", got)
}
}
func TestWikiNodeGetFallsBackToCreatorWhenNodeCreatorMissing(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_123",
"node_token": "wikcnABC",
"obj_token": "docxXYZ",
"obj_type": "docx",
"node_type": "origin",
"title": "Fallback Creator",
"creator": "ou_legacy_creator",
},
},
"msg": "success",
},
})
err := mountAndRunWiki(t, WikiNodeGet, []string{
"+node-get",
"--token", "wikcnABC",
"--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
if data["creator"] != "ou_legacy_creator" {
t.Fatalf("creator = %#v, want fallback to creator field", data["creator"])
}
}
func TestWikiNodeGetRejectsSpaceIDMismatch(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_actual",
"node_token": "wikcnABC",
"obj_token": "docxXYZ",
"obj_type": "docx",
"node_type": "origin",
"title": "Mismatch",
},
},
"msg": "success",
},
})
err := mountAndRunWiki(t, WikiNodeGet, []string{
"+node-get",
"--token", "wikcnABC",
"--space-id", "space_expected",
"--as", "bot",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "does not match the resolved node space") {
t.Fatalf("expected space mismatch error, got %v", err)
}
}

View File

@@ -0,0 +1,120 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// WikiSpaceCreate wraps wiki.spaces.create. The raw API only takes two
// optional string fields, so the shortcut's value is flag ergonomics
// (no hand-written --params JSON), output flattening (data.space.* lifted
// to the top level), and a dry-run preview.
//
// The API only accepts a user access token (no tenant/bot), so AuthTypes is
// user-only — the framework's CheckIdentity rejects --as bot for us.
var WikiSpaceCreate = common.Shortcut{
Service: "wiki",
Command: "+space-create",
Description: "Create a wiki space",
Risk: "write",
// The API accepts wiki:wiki or wiki:space:write_only. The framework's
// scope preflight does exact-string matching (see +space-list), so
// declare the narrowest form the API takes to avoid false-rejecting
// tokens that only carry wiki:space:write_only.
Scopes: []string{"wiki:space:write_only"},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "name", Desc: "wiki space name", Required: true},
{Name: "description", Desc: "wiki space description"},
},
Tips: []string{
"Only --as user is supported; the create API does not accept a tenant/bot token.",
"The underlying spaces.create API is flagged danger in the schema browser; a space is recoverable via `wiki +delete-space` if created by mistake.",
"--name is required: an unnamed space is almost always an accident and is hard to find later.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readWikiSpaceCreateSpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readWikiSpaceCreateSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST(wikiSpacesAPIPath).
Body(spec.RequestBody())
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readWikiSpaceCreateSpec(runtime)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Creating wiki space %q...\n", spec.Name)
data, err := runtime.CallAPI("POST", wikiSpacesAPIPath, nil, spec.RequestBody())
if err != nil {
return err
}
raw := common.GetMap(data, "space")
if raw == nil {
return output.Errorf(output.ExitAPI, "api_error", "wiki space create returned no space")
}
out := wikiSpaceCreateOutput(raw)
fmt.Fprintf(runtime.IO().ErrOut, "Created wiki space %s\n", common.MaskToken(common.GetString(out, "space_id")))
runtime.Out(out, nil)
return nil
},
}
// wikiSpaceCreateSpec is the normalized CLI input.
type wikiSpaceCreateSpec struct {
Name string
Description string
}
// RequestBody converts the normalized input into the OpenAPI payload. Both
// fields are optional per the API, but Validate enforces a non-empty name,
// so name is always present here.
func (spec wikiSpaceCreateSpec) RequestBody() map[string]interface{} {
body := map[string]interface{}{"name": spec.Name}
if spec.Description != "" {
body["description"] = spec.Description
}
return body
}
func readWikiSpaceCreateSpec(runtime *common.RuntimeContext) (wikiSpaceCreateSpec, error) {
spec := wikiSpaceCreateSpec{
Name: strings.TrimSpace(runtime.Str("name")),
Description: strings.TrimSpace(runtime.Str("description")),
}
if spec.Name == "" {
return wikiSpaceCreateSpec{}, output.ErrValidation("--name is required and cannot be blank")
}
return spec, nil
}
// wikiSpaceCreateOutput flattens data.space into the top-level envelope. It
// reads the raw map (rather than parseWikiSpaceRecord) so the description
// the caller just set round-trips back in the output.
func wikiSpaceCreateOutput(raw map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"space_id": common.GetString(raw, "space_id"),
"name": common.GetString(raw, "name"),
"description": common.GetString(raw, "description"),
"space_type": common.GetString(raw, "space_type"),
"visibility": common.GetString(raw, "visibility"),
"open_sharing": common.GetString(raw, "open_sharing"),
}
}

View File

@@ -0,0 +1,207 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestWikiSpaceCreateDeclaredContract(t *testing.T) {
t.Parallel()
if WikiSpaceCreate.Command != "+space-create" {
t.Fatalf("Command = %q, want +space-create", WikiSpaceCreate.Command)
}
if WikiSpaceCreate.Risk != "write" {
t.Fatalf("Risk = %q, want write", WikiSpaceCreate.Risk)
}
if !reflect.DeepEqual(WikiSpaceCreate.AuthTypes, []string{"user"}) {
t.Fatalf("AuthTypes = %v, want [user]", WikiSpaceCreate.AuthTypes)
}
if !reflect.DeepEqual(WikiSpaceCreate.Scopes, []string{"wiki:space:write_only"}) {
t.Fatalf("Scopes = %v, want [wiki:space:write_only]", WikiSpaceCreate.Scopes)
}
}
func TestReadWikiSpaceCreateSpecRejectsBlankName(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "wiki +space-create"}
cmd.Flags().String("name", " ", "")
cmd.Flags().String("description", "", "")
runtime := common.TestNewRuntimeContext(cmd, nil)
if _, err := readWikiSpaceCreateSpec(runtime); err == nil || !strings.Contains(err.Error(), "--name is required") {
t.Fatalf("expected blank-name rejection, got %v", err)
}
}
func TestWikiSpaceCreateRequestBody(t *testing.T) {
t.Parallel()
nameOnly := wikiSpaceCreateSpec{Name: "Eng Wiki"}.RequestBody()
if !reflect.DeepEqual(nameOnly, map[string]interface{}{"name": "Eng Wiki"}) {
t.Fatalf("name-only body = %#v", nameOnly)
}
full := wikiSpaceCreateSpec{Name: "Eng Wiki", Description: "team docs"}.RequestBody()
if full["name"] != "Eng Wiki" || full["description"] != "team docs" {
t.Fatalf("full body = %#v", full)
}
}
func TestWikiSpaceCreateDryRun(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "wiki +space-create"}
cmd.Flags().String("name", "Eng Wiki", "")
cmd.Flags().String("description", "team docs", "")
runtime := common.TestNewRuntimeContext(cmd, nil)
dry := WikiSpaceCreate.DryRun(nil, runtime)
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Method string `json:"method"`
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: %v", err)
}
if len(got.API) != 1 || got.API[0].Method != "POST" || got.API[0].URL != "/open-apis/wiki/v2/spaces" {
t.Fatalf("dry-run api = %#v", got.API)
}
if got.API[0].Body["name"] != "Eng Wiki" || got.API[0].Body["description"] != "team docs" {
t.Fatalf("dry-run body = %#v", got.API[0].Body)
}
}
func TestWikiSpaceCreateDryRunBlankNameSurfacesError(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "wiki +space-create"}
cmd.Flags().String("name", "", "")
cmd.Flags().String("description", "", "")
runtime := common.TestNewRuntimeContext(cmd, nil)
dry := WikiSpaceCreate.DryRun(nil, runtime)
data, _ := json.Marshal(dry)
if !strings.Contains(string(data), "--name is required") {
t.Fatalf("dry-run should surface validation error, got %s", data)
}
}
func TestWikiSpaceCreateMountedExecuteFlattensSpace(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, stderr, reg := cmdutil.TestFactory(t, wikiTestConfig())
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"space": map[string]interface{}{
"space_id": "7160145948494381236",
"name": "Eng Wiki",
"description": "team docs",
"space_type": "team",
"visibility": "private",
"open_sharing": "closed",
},
},
"msg": "success",
},
}
reg.Register(createStub)
err := mountAndRunWiki(t, WikiSpaceCreate, []string{
"+space-create",
"--name", "Eng Wiki",
"--description", "team docs",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
if data["space_id"] != "7160145948494381236" {
t.Fatalf("space_id = %#v", data["space_id"])
}
if data["name"] != "Eng Wiki" || data["description"] != "team docs" {
t.Fatalf("name/description = %#v / %#v", data["name"], data["description"])
}
if data["space_type"] != "team" || data["visibility"] != "private" || data["open_sharing"] != "closed" {
t.Fatalf("space_type/visibility/open_sharing = %#v", data)
}
if _, ok := data["url"]; ok {
t.Fatalf("output must not include a url field, got %#v", data["url"])
}
var captured map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &captured); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if captured["name"] != "Eng Wiki" || captured["description"] != "team docs" {
t.Fatalf("captured request body = %#v", captured)
}
if !strings.Contains(stderr.String(), "Created wiki space") {
t.Fatalf("stderr = %q, want creation log", stderr.String())
}
}
func TestWikiSpaceCreateRejectsBotIdentity(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiSpaceCreate, []string{
"+space-create",
"--name", "Eng Wiki",
"--as", "bot",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "only supports: user") {
t.Fatalf("expected bot identity rejection, got %v", err)
}
}
func TestWikiSpaceCreateErrorsWhenNoSpaceReturned(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
"msg": "success",
},
})
err := mountAndRunWiki(t, WikiSpaceCreate, []string{
"+space-create",
"--name", "Eng Wiki",
"--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "returned no space") {
t.Fatalf("expected missing-space error, got %v", err)
}
}

View File

@@ -15,7 +15,7 @@ import (
)
const (
wikiSpaceListAPIPath = "/open-apis/wiki/v2/spaces"
wikiSpacesAPIPath = "/open-apis/wiki/v2/spaces"
wikiSpaceListDefaultPageSize = 50
wikiSpaceListMaxPageSize = 50
)
@@ -59,7 +59,7 @@ var WikiSpaceList = common.Shortcut{
if wikiListShouldAutoPaginate(runtime) {
dry.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
}
return dry.GET(wikiSpaceListAPIPath).Params(params)
return dry.GET(wikiSpacesAPIPath).Params(params)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
warnIfConflictingPagingFlags(runtime)
@@ -103,7 +103,7 @@ func fetchWikiSpaces(runtime *common.RuntimeContext) ([]map[string]interface{},
if pageToken != "" {
params["page_token"] = pageToken
}
data, err := runtime.CallAPI("GET", wikiSpaceListAPIPath, params, nil)
data, err := runtime.CallAPI("GET", wikiSpacesAPIPath, params, nil)
if err != nil {
return nil, false, "", err
}

View File

@@ -109,10 +109,5 @@ Drive Folder (云空间文件夹)
- 用户说“看一下文档里的图片/附件/素材”“预览素材”,优先用 `lark-cli docs +media-preview`。
- 用户明确说“下载素材”,再用 `lark-cli docs +media-download`。
- 如果目标明确是画板 / whiteboard / 画板缩略图,只能用 `lark-cli docs +media-download --type whiteboard`,不要用 `+media-preview`。
- 用户说“找一个表格”“按名称搜电子表格”“找报表”“最近打开的表格”,先用 `lark-cli docs +search` 做资源发现。
- `docs +search` 不是只搜文档 / Wiki结果里会直接返回 `SHEET` 等云空间对象。
- 拿到 spreadsheet URL / token 后,再切到 `lark-sheets` 做对象内部读取、筛选、写入等操作。
- 用户说“给文档加评论”“查看评论”“回复评论”“给评论加表情 / reaction”“删除评论表情 / reaction”**不要留在 `lark-doc`**,直接切到 `lark-drive` 处理。
## 补充说明
`docs +search` 除了搜索文档 / Wiki也承担“先定位云空间对象再切回对应业务 skill 操作”的资源发现入口角色;当用户口头说“表格 / 报表”时,也优先从这里开始。

View File

@@ -34,6 +34,16 @@
#### 处理流程
**推荐方式:使用 `drive +inspect` 自动解包**
```bash
lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
```
返回结果包含 `type`(底层文档类型)、`token`(真实 file_token`title``url` 等字段,直接用于后续操作。
**手动方式:使用 `wiki.spaces.get_node` 查询节点信息**
1. **使用 `wiki.spaces.get_node` 查询节点信息**
```bash
lark-cli wiki spaces get_node --params '{"token":"wiki_token"}'

View File

@@ -1,7 +1,5 @@
## 快速决策
- 按标题或关键词找云空间里的表格文件,先用 `lark-cli docs +search`
- `docs +search` 会直接返回 `SHEET` 结果,不要把它误解成只能搜文档 / Wiki。
- 已知 spreadsheet URL / token 后,再进入 `sheets +info``sheets +read``sheets +find` 等对象内部操作。
## 核心概念

View File

@@ -13,7 +13,7 @@ metadata:
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。
> **执行前必做:** 执行任何 `base` 命令前,必须先阅读对应命令的 reference 文档,再调用命令。
> **查询类任务必做:** 涉及筛选、排序、Top/Bottom N、聚合、多表关联、查询后写入或判断全局结论时必须先阅读 [`references/lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md),再选择 `record / view / data-query` 路径。
> **命名约定:** Base 业务命令仅使用 `lark-cli base +...` 形式;如需先解析 Wiki 链接,可先调用 `lark-cli wiki ...`。
> **命名约定:** Base 业务命令仅使用 `lark-cli base +...` 形式;解析 Wiki 链接使用 `lark-cli wiki +node-get`。
> **分流规则:** 如果用户要“把本地文件导入成 Base / 多维表格 / bitable”第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作。
## 1. 何时使用本 Skill
@@ -39,11 +39,12 @@ metadata:
### 1.2 前置约束
1. 先阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。
2. Base 业务命令仅使用 `lark-cli base +...` 形式的 shortcut 命令;如果输入是 Wiki 链接,可先调用 `lark-cli wiki spaces get_node` 解析真实 token
3. 定位到命令后,先读该命令对应的 reference再执行命令
4. 如果用户要把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作
5. 不要在 Base 场景改走 `lark-cli api /open-apis/bitable/v1/...`
6. 如果用户只给 Base 名称、关键词,或说“帮我找一个多维表格”,先通过 `lark-cli docs +search --query <keyword> --filter '{"doc_types":["BITABLE"]}'` 搜索 `BITABLE` 资源;拿到 Base URL 后再使用本 skill 的 `base +...` 命令。复杂搜索再读 [`../lark-doc/references/lark-doc-search.md`](../lark-doc/references/lark-doc-search.md):标题精确匹配、限定创建者/群/文件夹/时间范围、只搜标题/评论、分页/全量搜索
2. Base 业务命令仅使用 `lark-cli base +...` 形式的 shortcut 命令。
3. 如果输入是 Wiki 链接或 Wiki token并且用户想读取/操作其中的 Base先执行 `lark-cli wiki +node-get --token <wiki_url_or_token>`;当返回 `data.obj_type=bitable` 时,把 `data.obj_token` 当作 `--base-token`。不要把 URL 里的 `/wiki/{token}` 当成 Base token
4. 定位到命令后,先读该命令对应的 reference再执行命令
5. 如果用户要把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作
6. 不要在 Base 场景改走 `lark-cli api /open-apis/bitable/v1/...`
7. 如果用户只给 Base 名称、关键词,或说“帮我找一个多维表格”,先通过 `lark-cli drive +search --query <keyword> --doc-types bitable` 搜索 Base / 多维表格资源;拿到 Base URL 后再使用本 skill 的 `base +...` 命令。复杂搜索再读 [`../lark-drive/references/lark-drive-search.md`](../lark-drive/references/lark-drive-search.md):标题精确匹配、限定 owner`--mine` / `--creator-ids`owner 语义非"最初创建人"/群/文件夹/时间范围、只搜标题/评论、分页/全量搜索。
## 2. 模块与命令导航
@@ -69,7 +70,7 @@ metadata:
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|------|------------------|----------------|----------|
| `lark-cli docs +search --query <keyword> --filter '{"doc_types":["BITABLE"]}'` | 按名称、关键词查找 Base / 多维表格 / bitable | 复杂搜索再读 [`../lark-doc/references/lark-doc-search.md`](../lark-doc/references/lark-doc-search.md) | 先定位资源,再回到 `base +...` 操作表内数据 |
| `lark-cli drive +search --query <keyword> --doc-types bitable` | 按名称、关键词查找 Base / 多维表格 / bitable | 复杂搜索再读 [`../lark-drive/references/lark-drive-search.md`](../lark-drive/references/lark-drive-search.md) | 先定位资源,再回到 `base +...` 操作表内数据 |
| `+base-create` | 创建新的 Base | [`lark-base-base-create.md`](references/lark-base-base-create.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 写入操作;执行前先读 reference`--folder-token``--time-zone` 都是可选项 |
| `+base-get` | 获取 Base 信息 | [`lark-base-base-get.md`](references/lark-base-base-get.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 适合确认 Base 本体信息,不替代表/字段结构读取 |
| `+base-copy` | 复制已有 Base | [`lark-base-base-copy.md`](references/lark-base-base-copy.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 写入操作;执行前先读 reference复制成功后应主动返回新 Base 标识信息 |
@@ -107,8 +108,9 @@ metadata:
|------|------------------|----------------|----------|
| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或按 ID 获取一条或多条记录 | [`lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md) | 记录读取统一先读 data analysis SOP已知 `record_id``+record-get`;明确关键词用 `+record-search`;普通明细用 `+record-list`;明确筛选 / 排序 / Top N 用临时视图投影后 `+record-list --view-id`;统计聚合才分流到 `+data-query``+record-get` 支持重复 `--record-id``--json` 读取多条记录 |
| `+record-upsert / +record-batch-create / +record-batch-update` | 创建、更新或批量写入记录 | [`lark-base-record-upsert.md`](references/lark-base-record-upsert.md)、[`lark-base-record-batch-create.md`](references/lark-base-record-batch-create.md)、[`lark-base-record-batch-update.md`](references/lark-base-record-batch-update.md)、[`lark-base-cell-value.md`](references/lark-base-cell-value.md) | 写前先 `+field-list`;只写存储字段;`+record-batch-update` 为同值更新(同一 patch 应用到多条记录);批量单次不超过 `200` 条;附件不要走这里 |
| `+record-upload-attachment` | 给已有记录上传附件 | [`lark-base-record-upload-attachment.md`](references/lark-base-record-upload-attachment.md) | 附件上传专用链路,不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 |
| `lark-cli docs +media-download` | 下载 Base 附件文件到本地 | [`../lark-doc/references/lark-doc-media-download.md`](../lark-doc/references/lark-doc-media-download.md) | Base 附件的 `file_token``+record-get` 返回的附件字段数组里取;**不要用 `lark-cli drive +download`**(对 Base 附件返回 403 |
| `+record-upload-attachment` | 给已有记录上传一个或多个附件 | `lark-cli base +record-upload-attachment --help` | 附件上传专用链路,不要用 `+record-upsert` / `+record-batch-*` 伪造附件值;不支持 `--name` |
| `+record-download-attachment` | 下载一个或多个 Base 附件到本地 | `lark-cli base +record-download-attachment --help` | Base 附件必须用这个命令下载;用其他下载入口可能失败 |
| `+record-remove-attachment` | 删除附件字段里的一个或多个附件 | 看 `lark-cli base +record-remove-attachment --help` | 删除操作;确认目标后带 `--yes` |
| `+record-delete` | 删除一条或多条记录 | [`lark-base-record-delete.md`](references/lark-base-record-delete.md) | 删除多条时重复传 `--record-id` 指定多个记录;用户已明确目标可直接执行并带 `--yes` |
| `+record-history-list` | 查询指定记录的变更历史 | [`lark-base-record-history-list.md`](references/lark-base-record-history-list.md) | 按 `table-id + record-id` 查询,不支持整表扫描;`+record-history-list` 只能串行执行 |
| `+record-share-link-create` | 为一条或多条记录生成分享链接 | [`lark-base-record-share-link-create.md`](references/lark-base-record-share-link-create.md) | 单次最多 100 条;重复 record_id 会自动去重;适合分享单条记录或批量分享场景 |
@@ -187,6 +189,8 @@ metadata:
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|------|------------------|----------------|----------|
| `+form-list / +form-get` | 列出表单,或获取单个表单 | [`lark-base-form-list.md`](references/lark-base-form-list.md)、[`lark-base-form-get.md`](references/lark-base-form-get.md) | `+form-list` 可用来获取 `form-id``+form-get` 适合查看已有表单配置 |
| `+form-detail` | 通过表单分享链接获取表单详情(含题目列表、字段类型、校验规则) | [`lark-base-form-detail.md`](references/lark-base-form-detail.md) | 只读;仅需 `--share-token`(从分享链接提取),不需要 base-token/table-id/form-id返回的 `questions` 可直接用于 `+form-submit` 构造参数 |
| `+form-submit` | 通过表单分享链接填写并提交表单(支持普通字段 + 附件上传) | [`lark-base-form-submit.md`](references/lark-base-form-submit.md) | 写入操作;仅支持 share_token 模式;**当 `--json` 包含 attachments 时必须额外提供 `--base-token`**(附件上传到 Base Drive Media 需要);附件通过 `--json.attachments` 传入本地路径CLI 自动并行上传 |
| `+form-create / +form-update / +form-delete` | 创建、更新或删除表单 | [`lark-base-form-create.md`](references/lark-base-form-create.md)、[`lark-base-form-update.md`](references/lark-base-form-update.md)、[`lark-base-form-delete.md`](references/lark-base-form-delete.md) | 创建后可继续进入表单问题相关操作;更新或删除前先确认目标表单 |
| `+form-questions-list` | 列出表单题目 | [`lark-base-form-questions-list.md`](references/lark-base-form-questions-list.md) | 适合查看已有题目结构 |
| `+form-questions-create / +form-questions-update / +form-questions-delete` | 创建、更新或删除题目 | [`lark-base-form-questions-create.md`](references/lark-base-form-questions-create.md)、[`lark-base-form-questions-update.md`](references/lark-base-form-questions-update.md)、[`lark-base-form-questions-delete.md`](references/lark-base-form-questions-delete.md) | 先确认 `form-id`;更新或删除前先确认题目目标 |
@@ -211,7 +215,7 @@ metadata:
| 字段类型 | 含义 | 能否直接作为 `+record-upsert / +record-batch-create / +record-batch-update` 写入目标 | 说明 |
|----------|------|-----------------------------------------------------------|------|
| 存储字段 | 真实存用户输入的数据 | 可以 | 常见如文本、数字、日期、单选、多选、人员、关联 |
| 附件字段 | 存储文件附件 | 不应直接按普通字段写 | 上传附件走 `+record-upload-attachment`;下载附件走 `lark-cli docs +media-download` |
| 附件字段 | 存储文件附件 | 不应直接按普通字段写 | 上传附件走 `+record-upload-attachment`;下载附件走 `+record-download-attachment`;删除附件走 `+record-remove-attachment` |
| 系统字段 | 平台自动维护 | 不可以 | 常见如创建时间、更新时间、创建人、修改人、自动编号 |
| `formula` 字段 | 通过表达式计算 | 不可以 | 只读字段 |
| `lookup` 字段 | 通过跨表规则查找引用 | 不可以 | 只读字段 |
@@ -225,7 +229,7 @@ metadata:
| 用户明确要求 lookup或天然是固定查找配置 | `lookup` 字段 | 不要默认先上 lookup先判断 formula 是否更合适 |
| 读取原始记录明细 / 关键词检索 / 导出 | `+record-search / +record-list / +record-get` | 不要拿 `+data-query` 当取数命令 |
| 上传附件到记录 | `+record-upload-attachment` | 不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 |
| 下载记录里的附件文件 | `lark-cli docs +media-download --token <file_token> --output <path>` | `file_token``+record-get` 返回的附件字段里取;用法见 [`../lark-doc/references/lark-doc-media-download.md`](../lark-doc/references/lark-doc-media-download.md) |
| 下载记录里的附件文件 | `+record-download-attachment --record-id <record_id> --output <dir>`,可加 `--file-token <file_token>` 只下指定附件 | Base 附件必须用这个命令下载;用其他下载入口可能失败 |
| 基于视图做筛选读取 | `+view-set-filter` + `+record-list` | 不要跳过视图筛选直接猜条件 |
| 本地 Excel / CSV / `.base` 导入为 Base | `lark-cli drive +import --type bitable` | 不要误走 `+base-create``+table-create``+record-upsert` |
@@ -253,11 +257,17 @@ metadata:
| 输入类型 | 正确处理方式 | 说明 |
|---------|--------------|------|
| 直接 Base 链接 `/base/{token}` | 直接提取 token 作为 `--base-token` | 不要把完整 URL 直接作为 `--base-token` |
| Wiki 链接 `/wiki/{token}` | 先 `wiki.spaces.get_node`,再取 `node.obj_token` | 不要把 `wiki_token` 直接当 `--base-token` |
| Wiki 链接 `/wiki/{token}` | 先用下方 fast path 解析 `data.obj_token` | 不要把 `wiki_token` 直接当 `--base-token`;如果这一步失败,再看 [`lark-wiki-node-get.md`](../lark-wiki/references/lark-wiki-node-get.md) |
| URL 中的 `?table={id}` | 先按前缀判断对象类型 | `tbl` 开头表示数据表 `table-id`,可作为 `--table-id``blk` 开头表示仪表盘 `dashboard-ID``wkf` 开头表示 `workflow-ID``ldx` 开头表示内嵌文档,不要一律当成 `--table-id` |
| URL 中的 `?view={id}` | 提取为 `--view-id` | 适合直接定位视图 |
| `lark-cli wiki spaces get_node` 返回的 `obj_type` | 后续路线 | 说明 |
Wiki Base fast path:
```bash
BASE_TOKEN="$(lark-cli wiki +node-get --as user --token "<wiki_url_or_token>" --jq '.data | select(.obj_type == "bitable") | .obj_token')"
```
| `lark-cli wiki +node-get` 返回的 `data.obj_type` | 后续路线 | 说明 |
|-----------------------------------------------|----------|------|
| `bitable` | 优先走 `lark-cli base +...` | 如果 shortcut 不覆盖,再用 `lark-cli base <resource> <method>`;不要改走 `lark-cli api /open-apis/bitable/v1/...` |
| `docx` | 转到文档 / Drive 相关 skill | 不继续使用本 skill 的 Base 命令 |
@@ -340,7 +350,7 @@ lark-cli auth login --domain base
| `1254066` | 人员字段错误 | `[{ "id": "ou_xxx" }]` |
| `1254045` | 字段名不存在 | 检查字段名(含空格、大小写) |
| `1254015` | 字段值类型不匹配 | 先 `+field-list`,再按类型构造 |
| `param baseToken is invalid` / `base_token invalid` | 把 wiki token、workspace token 或其他 token 当成了 `base_token` | 如果输入来自 `/wiki/...`,先用 `lark-cli wiki spaces get_node` 取真实 `obj_token`;当 `obj_type=bitable` 时,用 `node.obj_token` 作为 `--base-token` 重试,不要改走 `bitable/v1` |
| `param baseToken is invalid` / `base_token invalid` | 把 wiki token、workspace token 或其他 token 当成了 `base_token` | 如果输入来自 `/wiki/...`,先用 `lark-cli wiki +node-get --token <wiki_url_or_token>` 取真实 `data.obj_token`;当 `data.obj_type=bitable` 时,用 `data.obj_token` 作为 `--base-token` 重试,不要改走 `bitable/v1` |
| `not found` 且用户给的是 wiki 链接 | 常见于把 wiki token 当成 base token | 优先回退检查 wiki 解析,而不是改走 `bitable/v1` |
| formula / lookup 创建失败 | 指南未读或结构不合法 | 先读 `formula-field-guide.md` / `lookup-field-guide.md`,再按 guide 重建请求 |
| `ignored_fields` / `READONLY` | 只读字段被当成可写字段常见于系统字段、formula、lookup | 移除只读字段,只写存储字段;计算结果交给 formula / lookup / 系统字段自动产出 |

View File

@@ -119,9 +119,9 @@
### 2.9 attachment不作为普通 CellValue 写入)
用户要把本地文件加到记录里时,必须使用 `lark-cli base +record-upload-attachment --file <path>` 上传到已有记录。不能用普通记录操作接口来上传附件。
`+record-get` 返回的附件字段单元格包含 `file_token` 和文件名,可以把 `file_token` 交给 `lark-cli docs +media-download` 进行附件下载
- 追加附件:使用 `lark-cli base +record-upload-attachment --record-id <record_id> --field-id <field_id> --file <path>`;可重复 `--file` 一次追加多个附件,不能用普通记录操作接口附件
- 删除附件:使用 `lark-cli base +record-remove-attachment --record-id <record_id> --field-id <field_id> --file-token <file_token> --yes`;可重复 `--file-token` 一次删除同一单元格里的多个附件。
- 下载附件:使用 `lark-cli base +record-download-attachment --record-id <record_id> --file-token <file_token> --output <dir>`;不传 `--file-token` 时下载整行所有附件,也可重复 `--file-token` 只下载指定附件。Base 附件必须用这个命令下载,用其他下载入口可能失败
## 3. 只读字段(不要写)

View File

@@ -0,0 +1,319 @@
# base +form-detail
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
通过表单分享 Token 获取表单详情(含表单元信息、题目详情)。只读操作,不修改任何数据。
`+form-get` 的区别:`+form-get` 需要 `base-token` + `table-id` + `form-id`(从 Base 内部获取);`+form-detail` 仅需 `share-token`(从分享链接获取,无需知道 Base/表信息)。
## 命令
```bash
# 通过 share_token 获取表单详情
lark-cli base +form-detail \
--share-token <share_token>
# 以 pretty 格式展示(适合阅读 questions 结构)
lark-cli base +form-detail \
--share-token <share_token> \
--format pretty
# 使用 jq 过滤只看题目列表
lark-cli base +form-detail \
--share-token <share_token> \
--jq '.data.questions'
# 预览 API 调用(不执行)
lark-cli base +form-detail \
--share-token <share_token> \
--dry-run
# 使用应用身份bot
lark-cli base +form-detail \
--share-token <share_token> \
--as bot
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--share-token <token>` | 是 | 表单分享 Token从表单分享链接中提取 |
| `--format` | 否 | 输出格式json默认\| pretty \| table \| ndjson \| csv |
| `--as` | 否 | 身份user默认\| bot |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
| `--jq <expr>` | 否 | 用 jq 表达式过滤 JSON 输出 |
### 从分享链接提取 share-token
用户提供形如以下格式的表单分享链接时:
```text
https://bitable-test.feishu-boe.cn/share/base/form/shrbcvST8eZy0vk8zjVZ1CAXNye
```
**提取方式:** 取 URL 路径最后一段作为 `--share-token`
以上述链接为例:
- `share-token` = `shrbcvST8eZy0vk8zjVZ1CAXNye`
```bash
lark-cli base +form-detail \
--share-token shrbcvST8eZy0vk8zjVZ1CAXNye
```
## 输出格式
| 字段 | 类型 | 说明 |
|------|------|------|
| `base_token` | string | 所属多维表格 Base token |
| `name` | string | 表单名称 |
| `description` | string | 表单描述 |
| `questions[]` | array | 题目列表(含 id / title / type / required / description / filter |
### questions 中每个题目的字段
#### 固定字段(所有题目共有)
| 字段 | 类型 | 是否必填 | 说明 |
|------|------|----------|------|
| `id` | string | 是 | 题目标识(对应 field_id |
| `title` | string | 是 | 题目标题 |
| `type` | string | 是 | 字段类型(见下方类型对照表,与 [`lark-base-shortcut-field-properties.md`](lark-base-shortcut-field-properties.md) 对齐) |
| `required` | bool | 是 | 是否必填 |
| `description` | string | 否 | 题目描述 |
| `filter` | object | 否 | 题目显示条件(详见下方 filter 结构说明) |
#### 动态字段(按 type 不同而不同,直接平铺在 question 中)
除上述固定字段外,每种 `type` 还会携带该类型特有的配置字段(与 [`lark-base-shortcut-field-properties.md`](lark-base-shortcut-field-properties.md) 中的「常见补充字段」对应),例如:
- **text** → `style`(含 `style.type`: plain / phone / url / email / barcode
- **number** → `style`(含 `style.type`: plain / currency / progress / rating 及其子配置)
- **select** → `multiple`bool`options`(选项列表)或 `dynamic_options_source`
- **datetime / created_at / updated_at** → `style.format`
- **user / group_chat** → `multiple`
- **link** → `link_table``bidirectional``bidirectional_link_field_name`
- **formula** → `expression`
- **lookup** → `from``select``where``aggregate`
- **auto_number** → `style.rules`
- **attachment / location / checkbox / stage / created_by / updated_by** → 无额外动态字段
### filter 结构说明
`filter` 控制题目在表单中的显示/隐藏逻辑,由 `conjunction`(逻辑关系)和 `conditions`(条件列表)组成。
以下以一个「活动报名」表单为例,其中「紧急联系人」题目的 filter 配置:
```json
{
"conjunction": "and",
"conditions": [
{"field_name": "是否携带家属", "operator": "is", "value": ["是"]},
{"field_name": "参与人数", "operator": "isGreater", "value": [1]}
]
}
```
> 以上述 JSON 为例:当题目「是否携带家属」的值为「是」**并且**题目「参与人数」大于 1 时,「紧急联系人」才会展示(`conjunction: "and"` 表示全部条件需同时满足;若为 `"or"` 则任一条件满足即显示)。
另一个常见场景——用 `or` 控制可选填的补充信息:
```json
{
"conjunction": "or",
"conditions": [
{"field_name": "满意度评分", "operator": "isLessEqual", "value": [3]},
{"field_name": "是否愿意回访", "operator": "is", "value": ["是"]}
]
}
```
> 即:评分 ≤ 3 **或** 愿意接受回访时,才展示「改进建议」文本框。
#### filter 字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `conjunction` | string | 条件间逻辑关系:`and`(全部满足) / `or`(任一满足) |
| `conditions[]` | array | 条件列表 |
#### conditions 中每个条件项的字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `field_name` | string | 所依赖的题目标题(引用其他题目的 title |
| `operator` | string | 过滤操作符(见下方 operator 可选值) |
| `value` | array | 过滤值数组(部分 operator 不需要,如 `isEmpty` / `isNotEmpty` |
#### operator 可选值
| operator | 含义 | 适用类型 |
|----------|------|----------|
| `is` | 等于 | 除附件外全部 |
| `isNot` | 不等于 | 除附件外全部 |
| `contains` | 包含 | 文本、选项、人员、群聊、地理位置 |
| `doesNotContain` | 不包含 | 文本、选项、人员、群聊、地理位置 |
| `isEmpty` | 为空 | 全部 |
| `isNotEmpty` | 不为空 | 全部 |
| `isGreater` | 大于 | 数字、日期时间 |
| `isGreaterEqual` | 大于等于 | 数字、日期时间 |
| `isLess` | 小于 | 数字、日期时间 |
| `isLessEqual` | 小于等于 | 数字、日期时间 |
> **附件attachment特殊说明** 仅支持 `isEmpty` 和 `isNotEmpty`,不支持 `is` / `isNot` / `contains` 及比较操作符。
#### value 的格式(按所依赖题目的类型区分)
| 所依赖题目类型 | value 格式 | 示例 |
|----------------|-----------|------|
| 文本类text / phone / email / url 等) | 字符串数组 | `["1", "2"]` |
| 数字类number | 数字数组 | `[1, 2]` |
| 选项类select / multi_select | 选项名称数组 | `["选项A", "选项B"]` |
| 人员类user | open_id 数组 | `["ou_d57864434a537020cf7a4a681d393e2d"]` |
| 群聊类group_chat | open_id 数组 | `["oc_f62478de5cc958583191e778db972603"]` |
| 地理位置location | 地点名称数组 | `["北京总部"]` |
| 日期时间类datetime | 时间字符串数组,固定格式 `yyyy-MM-dd HH:mm:ss` | `["2026-05-07 14:30:00"]` |
| 关联link / duplexlink | 记录 ID 数组 | `["recxxxxxxx", "recyyyyyyy"]` |
### type 可选值
与 [`lark-base-shortcut-field-properties.md`](lark-base-shortcut-field-properties.md) 中的字段类型完全对齐。
| type 值 | 含义 | 常见动态字段 |
|----------|------|-------------|
| `text` | 文本(含电话/邮箱/链接/条码等子类型) | `style` |
| `number` | 数字(含货币/进度/评分等子类型) | `style` |
| `select` | 选项(单选/多选由 `multiple` 区分) | `multiple``options` / `dynamic_options_source` |
| `datetime` | 日期时间 | `style.format` |
| `user` | 人员 | `multiple` |
| `group_chat` | 群组 | `multiple` |
| `attachment` | 附件 | 无 |
| `location` | 地理位置 | 无 |
| `checkbox` | 复选框 | 无 |
| `link` | 关联 | `link_table``bidirectional``bidirectional_link_field_name` |
| `formula` | 公式 | `expression` |
| `lookup` | 引用 | `from``select``where``aggregate` |
| `auto_number` | 自动编号 | `style.rules` |
| `created_at` | 创建时间 | `style.format` |
| `updated_at` | 更新时间 | `style.format` |
| `created_by` | 创建人 | 无 |
| `updated_by` | 更新人 | 无 |
| `stage` | 阶段 | 无 |
```json
{
"ok": true,
"data": {
"base_token": "DBALKJKLHDLJ",
"name": "2026 年度技术大会报名",
"description": "请填写参会信息,带 * 为必填项",
"questions": [
{
"id": "fldzaYFpb6",
"required": true,
"title": "姓名",
"type": "text"
},
{
"id": "fldCoBpOlx",
"required": true,
"title": "手机号",
"type": "text",
"style": { "type": "phone" }
},
{
"id": "fldmmhZFCs",
"required": false,
"title": "公司邮箱",
"type": "text",
"style": { "type": "email" }
},
{
"id": "fldhqmqCj8",
"required": true,
"title": "参会日期",
"type": "datetime",
"style": { "format": "yyyy-MM-dd" }
},
{
"id": "fldlyRrfrN",
"required": true,
"title": "参与人数",
"type": "number"
},
{
"id": "fldRakYky3",
"required": false,
"title": "是否携带家属",
"type": "select",
"multiple": false,
"options": [
{ "name": "是", "hue": "Green", "lightness": "Lighter" },
{ "name": "否", "hue": "Gray", "lightness": "Lighter" }
]
},
{
"id": "fldyrOO0X4",
"required": false,
"title": "紧急联系人",
"type": "text",
"filter": {
"conjunction": "and",
"conditions": [
{"field_name": "是否携带家属", "operator": "is", "value": ["是"]},
{"field_name": "参与人数", "operator": "isGreater", "value": [1]}
]
}
},
{
"id": "fldM9AsRc2",
"required": false,
"title": "上传简历",
"type": "attachment",
"filter": {
"conjunction": "or",
"conditions": [
{"field_name": "是否携带家属", "operator": "isNotEmpty"}
]
}
},
{
"id": "fldN7PsWx1",
"required": true,
"title": "所属部门",
"type": "user",
"multiple": false
},
{
"id": "fldKq3mTz8",
"required": true,
"title": "参会主题",
"type": "select",
"multiple": true,
"options": [
{ "name": "AI 与大模型", "hue": "Purple", "lightness": "Lighter" },
{ "name": "云原生", "hue": "Blue", "lightness": "Lighter" },
{ "name": "工程效能", "hue": "Orange", "lightness": "Lighter" },
{ "name": "前端技术", "hue": "Carmine", "lightness": "Lighter" }
]
}
]
}
}
```
## 提示
- `share_token` 从表单分享链接中提取,格式通常为 `shr` + 随机字符串(如 `shrbcvST8eZy0vk8zjVZ1CAXNye`
- 返回的 `questions` 列表可直接用于构造 `+form-submit``--json.fields` 参数
- `questions[].title` 对应题目标题,可用于 `+form-submit` 的字段名映射
- 如果需要通过 Base 内部路径操作表单,使用 `+form-get`(需要 base-token / table-id / form-id
- 权限要求:`base:form:read`
## 参考
- [lark-base](../SKILL.md) — 多维表格全部命令
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
- [lark-base-form-submit](lark-base-form-submit.md) — 获取详情后可用 submit 填写提交

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