Compare commits

...

16 Commits

Author SHA1 Message Date
梁硕
af1e28a565 chore: bump version to v1.0.10 and update changelog
Change-Id: I6f8f6b474e2bcedec4646c69b35235c52906c74e
2026-04-13 22:53:30 +08:00
chenxingtong-bytedance
06e7ae267c (im) support im oapi range download large file (#283)
Add range download support for IM OAPI resources so lark-cli can reliably download large files. This improves stability for large payloads and network interruptions.

Change-Id: I38e6f6f9cf8b8711dc40650d19c77503f4e44989
2026-04-13 22:02:34 +08:00
caojie0621
74f7de386a feat(sheets): add filter view and condition shortcuts (#422)
Add 10 new sheet shortcuts for filter view management:

Filter views:
- +create-filter-view, +update-filter-view, +list-filter-views
- +get-filter-view, +delete-filter-view

Filter view conditions:
- +create-filter-view-condition, +update-filter-view-condition
- +list-filter-view-conditions, +get-filter-view-condition
- +delete-filter-view-condition

Includes unit tests (39 cases, 88-93% coverage) and skill reference docs.
2026-04-13 21:41:28 +08:00
yaozhen00
c2b132945e feat(test): optimize cli-e2e-testcase-writer skill (#447)
* feat(test): optimize cli-e2e-testcase-writer skill add coverage.md

* feat(test): test report show
2026-04-13 21:10:11 +08:00
liujinkun2025
88fd3bdab8 feat(wiki): add wiki move shortcut with async task polling (#436)
Change-Id: I58400054e6c3c3c8e7b0cf72b874602b22fa287d
2026-04-13 19:33:53 +08:00
kongenpei
c70c3fdce2 fix: support large base attachment uploads (#441)
Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-13 19:32:05 +08:00
MaxHuang22
c13f240b9b fix(config): clarify init copy for TTY, preserve original for AI (#448)
The interactive `config init` flow showed a QR code and verification
link without indicating their relationship, leaving users unsure
which to act on first and whether the link was still needed after
scanning.

Split the message strings on TTY vs non-TTY:
- TTY: header above QR ("使用飞书 / Lark 扫码配置应用"), "或打开链接"
  framing to mark the link as an alternative, and an active waiting
  indicator.
- Non-TTY (AI / piped callers via --new): keep the original copy
  verbatim so existing parsers and prompts are unaffected.

QR is still rendered in both branches.

Change-Id: I9b753f044ebefaedbb4b095cabf7beff4669eb2e
2026-04-13 18:51:38 +08:00
wittam-01
88bf7fc1cd feat: add drive files patch metaapi (#444)
Change-Id: Ieb5b11f004c6007813f48d4312a7d6e476bd6d79
2026-04-13 18:51:30 +08:00
haozhenghua-code
25534d72b5 fix(im): reject --user-id under bot identity for chat-messages-list (#340)
The chat_p2p/batch_query endpoint that resolves a user's p2p chat_id
requires user identity. Calling +chat-messages-list with --user-id
under bot identity previously failed silently or returned wrong
results.

- Validate: reject --user-id when runtime.IsBot(), with a hint to
  pass --as user or use --chat-id instead
- resolveP2PChatID: add defensive guard for the same condition in
  case the helper is reached via another path
- Update --user-id flag description and the lark-im skill reference
  to note the user-identity requirement
- Tests: add bot-rejection cases for Validate and resolveP2PChatID,
  switch p2p happy-path tests to a user-identity runtime helper
2026-04-13 17:54:10 +08:00
chenhuang
815db0c866 fix(mail): add missing scopes for mail +watch shortcut (#357)
* fix(mail): add missing event scope for mail watch

The mail +watch shortcut requires scope
mail:user_mailbox.event.mail_address:read to receive the mail_address
field in WebSocket event payloads, but this scope was neither declared
in the shortcut's Scopes list nor included in the auto-approve
(recommend.allow) set.

Without this scope, +watch events arrive without the mail_address field,
which breaks mailbox filtering and fetch-mailbox resolution.

- Add scope to mail +watch Scopes declaration
- Add scope to scope_overrides.json recommend.allow list so that
  auth login --recommend requests it automatically

* fix(mail): add missing mailbox profile scope for mail watch

The +watch shortcut calls fetchMailboxPrimaryEmail (GET
user_mailboxes/me/profile) to resolve the mailbox address for event
filtering, which requires scope mail:user_mailbox:readonly. All other
mail shortcuts that call this API (send, reply, forward, draft-create,
draft-edit) already declare this scope, but +watch did not.

* fix(mail): remove event scope from scope_overrides.json

The mail:user_mailbox.event.mail_address:read scope only needs to be
declared in the +watch shortcut's Scopes list, not in the global
recommend.allow set.
2026-04-13 17:22:28 +08:00
liujinkun2025
bb7957245b docs: add wiki member operations to lark-wiki skill (#417)
Change-Id: I5f8d930c25a650e26e7250269add2809b2b7f343
2026-04-13 14:33:14 +08:00
Tsai_Hui
3917b77e91 feat: add drive create-shortcut shortcut (#432) 2026-04-13 11:54:31 +08:00
wangzhengkui
dc0d92708b fix(mail): restrict --output-dir to current working directory (#376)
* fix(mail): restrict --output-dir to current working directory

Previously, mail +watch --output-dir accepted absolute paths (e.g.
/etc, /tmp) and home directory paths (~/), allowing writes to arbitrary
locations. Since mail content is sender-controlled, this posed a risk
of writing attacker-influenced data to sensitive system directories.

Now all --output-dir values go through validate.SafeOutputPath which:
- Rejects absolute paths and ~ expansion
- Resolves .. and symlinks
- Enforces the result stays under CWD

* fix(mail): reject tilde paths in --output-dir explicitly

SafeOutputPath treats ~/x as a literal relative path, silently creating
a directory named "~" under CWD. Reject ~ prefixed paths with a clear
error message instead.

* fix(mail): reject all tilde-prefixed paths and use ErrValidation

- Broaden ~ check from "~ || ~/" to "~" prefix, covering ~user/path forms
- Use output.ErrValidation for consistent error type (exit code 2)

* fix(mail): add post-mkdir EvalSymlinks + CWD re-verification (TOCTOU)

SafeOutputPath validates before MkdirAll, but an attacker could replace
the newly created directory with a symlink between mkdir and the first
write. Add EvalSymlinks after MkdirAll and re-verify the resolved path
is still under CWD.

Also broaden ~ rejection to all tilde-prefixed paths (~user/path) and
use output.ErrValidation for consistent error types.

* fix(mail): use validate.SafeOutputPath for post-mkdir TOCTOU check

Replace direct os.Getwd and filepath.EvalSymlinks calls with a second
SafeOutputPath call after MkdirAll. This satisfies the forbidigo lint
rule (no direct os/filepath calls in shortcuts/) while maintaining the
same TOCTOU protection.

* fix(mail): use original relative path for post-mkdir re-validation

SafeOutputPath rejects absolute paths, but after the first call
outputDir was already resolved to an absolute path. Pass the original
relative path to the second SafeOutputPath call so it can properly
re-validate after MkdirAll.

* fix(mail): remove redundant post-mkdir SafeOutputPath call

The second SafeOutputPath call after MkdirAll provided no real TOCTOU
protection: mail +watch is long-running, so the directory could be
replaced at any point during the session, not just between mkdir and
the check. The first SafeOutputPath already validates and resolves
the path; one call is sufficient.
2026-04-13 10:53:08 +08:00
Yuxuan Zhao
085ffd87f3 feat: add stable cli e2e tests (#401)
* feat: add stable bot-only cli e2e subset

Change-Id: I62edf59d179e407954f65f82e94cf5dcf4938080

* fix: address review comments on stable cli e2e tests

Change-Id: I4436100c30adf2694cd06953961f8d77f576fc1e

* fix: reduce flakiness in drive and im e2e helpers

Change-Id: I51e77d857f1fd9aec5ee34adf5045cc695239f21

* fix: document missing drive cleanup support

Change-Id: I3d4f034145bd69fb7640e707fcda05146b8754c7

* style: unify e2e cleanup comments

Change-Id: I40d906c9168754ad71ef9fb770ff4c340fc19beb

* test: update e2e assertions

Change-Id: I73c21b4b38d4ced7ea27cb327075957ec2b9a2a2

* test: stabilize cli e2e bot-only coverage

Change-Id: Ied897c37c4f42e446d55d110461aa34ae198195d
2026-04-12 16:52:41 +08:00
zero-my
f6b8091843 Feat/task section updates (#430)
* docs(task): document sections API resources and add URL parsing reminder

* feat(task): support --section-guid flag in tasklist-task-add shortcut

* docs(task): document sections API resources, permissions, and URL parsing
2026-04-12 16:12:16 +08:00
OwenYWT
0e7f507efb docs(lark-doc): clarify when markdown escaping is needed (#312)
* docs(lark-doc): clarify when markdown escaping is needed

* docs(lark-doc): fix escaped special character code span
2026-04-11 23:56:07 +08:00
88 changed files with 7848 additions and 460 deletions

View File

@@ -25,6 +25,8 @@ on:
permissions:
contents: read
actions: read
checks: write
jobs:
cli-e2e:
@@ -65,71 +67,17 @@ jobs:
echo "No CLI E2E packages to test after exclusions."
exit 1
fi
go run gotest.tools/gotestsum@v1.12.3 --format testname --junitfile cli-e2e-report.xml -- -count=1 -v $packages
# gotestsum requires --packages when --rerun-fails is combined with go test args after --.
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
- name: Summarize CLI E2E test report
- name: Publish CLI E2E test report
if: ${{ !cancelled() }}
run: |
python3 - <<'PY'
import os
import xml.etree.ElementTree as ET
report_path = "cli-e2e-report.xml"
summary_path = os.environ["GITHUB_STEP_SUMMARY"]
root = ET.parse(report_path).getroot()
suites = [root] if root.tag == "testsuite" else root.findall("testsuite")
tests = failures = errors = skipped = 0
failed_cases = []
skipped_cases = []
for suite in suites:
tests += int(suite.attrib.get("tests", 0))
failures += int(suite.attrib.get("failures", 0))
errors += int(suite.attrib.get("errors", 0))
skipped += int(suite.attrib.get("skipped", 0))
for case in suite.findall("testcase"):
classname = case.attrib.get("classname", "")
name = case.attrib.get("name", "")
label = f"{classname}.{name}" if classname else name
failure = case.find("failure")
error = case.find("error")
skipped_node = case.find("skipped")
if failure is not None or error is not None:
message = ""
node = failure if failure is not None else error
if node is not None:
message = node.attrib.get("message", "") or (node.text or "").strip()
failed_cases.append((label, message))
elif skipped_node is not None:
message = skipped_node.attrib.get("message", "") or (skipped_node.text or "").strip()
skipped_cases.append((label, message))
passed = tests - failures - errors - skipped
with open(summary_path, "a", encoding="utf-8") as f:
f.write("## CLI E2E Test Report\n\n")
f.write(f"- Total: {tests}\n")
f.write(f"- Passed: {passed}\n")
f.write(f"- Failed: {failures}\n")
f.write(f"- Errors: {errors}\n")
f.write(f"- Skipped: {skipped}\n\n")
if failed_cases:
f.write("### Failed Tests\n\n")
for label, message in failed_cases:
detail = f" - {message}" if message else ""
f.write(f"- `{label}`{detail}\n")
f.write("\n")
if skipped_cases:
f.write("### Skipped Tests\n\n")
for label, message in skipped_cases:
detail = f" - {message}" if message else ""
f.write(f"- `{label}`{detail}\n")
f.write("\n")
PY
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: CLI E2E Tests
path: cli-e2e-report.xml
reporter: java-junit
use-actions-summary: true
list-suites: all
list-tests: all

1
.gitignore vendored
View File

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

View File

@@ -2,6 +2,31 @@
All notable changes to this project will be documented in this file.
## [v1.0.10] - 2026-04-13
### Features
- **im**: Support im oapi range download for large files (#283)
- **sheets**: Add filter view and condition shortcuts (#422)
- **wiki**: Add wiki move shortcut with async task polling (#436)
- **drive**: Add drive `+create-shortcut` shortcut (#432)
- **drive**: Add drive files patch metadata API (#444)
- **task**: Support `--section-guid` flag in tasklist-task-add shortcut (#430)
### Bug Fixes
- **base**: Support large base attachment uploads (#441)
- **config**: Clarify init copy for TTY, preserve original for AI (#448)
- **im**: Reject `--user-id` under bot identity for chat-messages-list (#340)
- **mail**: Add missing scopes for mail `+watch` shortcut (#357)
- **mail**: Restrict `--output-dir` to current working directory (#376)
### Documentation
- **wiki**: Add wiki member operations to lark-wiki skill (#417)
- **task**: Document sections API resources, permissions, and URL parsing (#430)
- **doc**: Clarify when markdown escaping is needed (#312)
## [v1.0.9] - 2026-04-11
### Features
@@ -303,6 +328,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.10]: https://github.com/larksuite/cli/releases/tag/v1.0.10
[v1.0.9]: https://github.com/larksuite/cli/releases/tag/v1.0.9
[v1.0.8]: https://github.com/larksuite/cli/releases/tag/v1.0.8
[v1.0.7]: https://github.com/larksuite/cli/releases/tag/v1.0.7

View File

@@ -177,17 +177,26 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
// Step 2: Build and display verification URL + QR code
verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version)
// Show QR code in terminal
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
if qrErr == nil {
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
// Branch on TTY: human-friendly copy in interactive terminals,
// preserve original copy for AI / non-interactive callers.
if f.IOStreams.IsTerminal {
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanQRCode)
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
if qrErr == nil {
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
}
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanOrOpenLink)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScan)
} else {
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
if qrErr == nil {
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
}
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.OpenLinkNonTTY)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScanNonTTY)
}
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanOrOpenLink)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
// Step 3: Poll for result
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScan)
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if err != nil {
return nil, output.ErrAuth("%v", err)

View File

@@ -16,11 +16,16 @@ type initMsg struct {
Platform string
SelectPlatform string
Feishu string
ScanOrOpenLink string
WaitingForScan string
DetectedLarkTenant string
AppCreated string
ConfigSaved string
// TTY (interactive) variants
ScanQRCode string // header shown above QR code
ScanOrOpenLink string // post-QR alt link prompt ("or open...")
WaitingForScan string // active polling indicator
// Non-TTY (AI / non-interactive) variants — preserve original copy
OpenLinkNonTTY string // primary link prompt
WaitingForScanNonTTY string // passive waiting indicator
DetectedLarkTenant string
AppCreated string
ConfigSaved string
}
var initMsgZh = &initMsg{
@@ -29,12 +34,15 @@ var initMsgZh = &initMsg{
ConfigExistingApp: "手动输入应用凭证",
Platform: "平台",
SelectPlatform: "选择平台",
Feishu: "飞书",
ScanOrOpenLink: "\n打开以下链接配置应用:\n\n",
WaitingForScan: "等待配置应用...",
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
AppCreated: "应用配置成功! App ID: %s",
ConfigSaved: "应用配置成功! App ID: %s",
Feishu: "飞书",
ScanQRCode: "\n使用飞书 / Lark 扫码配置应用\n\n",
ScanOrOpenLink: "\n或打开以下链接完成配置\n",
WaitingForScan: "正在获取你的应用配置结果...",
OpenLinkNonTTY: "\n打开以下链接配置应用:\n\n",
WaitingForScanNonTTY: "等待配置应用...",
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
AppCreated: "应用配置成功! App ID: %s",
ConfigSaved: "应用配置成功! App ID: %s",
}
var initMsgEn = &initMsg{
@@ -43,12 +51,15 @@ var initMsgEn = &initMsg{
ConfigExistingApp: "Enter app credentials yourself",
Platform: "Platform",
SelectPlatform: "Select platform",
Feishu: "Feishu",
ScanOrOpenLink: "\nOpen the link below to configure app:\n\n",
WaitingForScan: "Waiting for app configuration...",
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
AppCreated: "App configured! App ID: %s",
ConfigSaved: "App configured! App ID: %s",
Feishu: "Feishu",
ScanQRCode: "\nScan the QR code with Feishu/Lark:\n\n",
ScanOrOpenLink: "\nOr open the link below in your browser:\n",
WaitingForScan: "Fetching configuration results...",
OpenLinkNonTTY: "\nOpen the link below to configure app:\n\n",
WaitingForScanNonTTY: "Waiting for app configuration...",
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
AppCreated: "App configured! App ID: %s",
ConfigSaved: "App configured! App ID: %s",
}
func getInitMsg(lang string) *initMsg {

View File

@@ -54,11 +54,14 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
"Platform": msg.Platform,
"SelectPlatform": msg.SelectPlatform,
"Feishu": msg.Feishu,
"ScanOrOpenLink": msg.ScanOrOpenLink,
"WaitingForScan": msg.WaitingForScan,
"DetectedLarkTenant": msg.DetectedLarkTenant,
"AppCreated": msg.AppCreated,
"ConfigSaved": msg.ConfigSaved,
"ScanQRCode": msg.ScanQRCode,
"ScanOrOpenLink": msg.ScanOrOpenLink,
"WaitingForScan": msg.WaitingForScan,
"OpenLinkNonTTY": msg.OpenLinkNonTTY,
"WaitingForScanNonTTY": msg.WaitingForScanNonTTY,
"DetectedLarkTenant": msg.DetectedLarkTenant,
"AppCreated": msg.AppCreated,
"ConfigSaved": msg.ConfigSaved,
}
for name, val := range fields {
if val == "" {

View File

@@ -33,6 +33,11 @@ const (
LarkErrRefreshRevoked = 20064 // refresh_token revoked
LarkErrRefreshAlreadyUsed = 20073 // refresh_token already consumed (single-use rotation)
LarkErrRefreshServerError = 20050 // refresh endpoint server-side error, retryable
// Drive shortcut / cross-space constraints.
LarkErrDriveResourceContention = 1061045 // resource contention occurred, please retry
LarkErrDriveCrossTenantUnit = 1064510 // cross tenant and unit not support
LarkErrDriveCrossBrand = 1064511 // cross brand not support
)
// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
@@ -60,6 +65,14 @@ func ClassifyLarkError(code int, msg string) (int, string, string) {
// rate limit
case LarkErrRateLimit:
return ExitAPI, "rate_limit", "please try again later"
// drive-specific constraints that benefit from actionable hints
case LarkErrDriveResourceContention:
return ExitAPI, "conflict", "please retry later and avoid concurrent duplicate requests"
case LarkErrDriveCrossTenantUnit:
return ExitAPI, "cross_tenant_unit", "operate on source and target within the same tenant and region/unit"
case LarkErrDriveCrossBrand:
return ExitAPI, "cross_brand", "operate on source and target within the same brand environment"
}
return ExitAPI, "api_error", ""

View File

@@ -0,0 +1,64 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"strings"
"testing"
)
// TestClassifyLarkError_DriveCreateShortcutConstraints verifies known Drive shortcut errors map to actionable hints.
func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
t.Parallel()
tests := []struct {
name string
code int
wantExitCode int
wantType string
wantHint string
}{
{
name: "resource contention",
code: LarkErrDriveResourceContention,
wantExitCode: ExitAPI,
wantType: "conflict",
wantHint: "avoid concurrent duplicate requests",
},
{
name: "cross tenant unit",
code: LarkErrDriveCrossTenantUnit,
wantExitCode: ExitAPI,
wantType: "cross_tenant_unit",
wantHint: "same tenant and region/unit",
},
{
name: "cross brand",
code: LarkErrDriveCrossBrand,
wantExitCode: ExitAPI,
wantType: "cross_brand",
wantHint: "same brand environment",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotExitCode, gotType, gotHint := ClassifyLarkError(tt.code, "raw msg")
if gotExitCode != tt.wantExitCode {
t.Fatalf("exitCode=%d, want %d", gotExitCode, tt.wantExitCode)
}
if gotType != tt.wantType {
t.Fatalf("type=%q, want %q", gotType, tt.wantType)
}
if gotHint == "" {
t.Fatal("expected non-empty hint")
}
if !strings.Contains(gotHint, tt.wantHint) {
t.Fatalf("hint=%q, want substring %q", gotHint, tt.wantHint)
}
})
}
}

View File

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

View File

@@ -865,6 +865,157 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("upload attachment uses multipart for large file", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
tmpFile, err := os.CreateTemp(t.TempDir(), "base-attachment-large-*.bin")
if err != nil {
t.Fatalf("CreateTemp() err=%v", err)
}
if err := tmpFile.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
t.Fatalf("Truncate() err=%v", err)
}
if err := tmpFile.Close(); err != nil {
t.Fatalf("Close() err=%v", err)
}
withBaseWorkingDir(t, filepath.Dir(tmpFile.Name()))
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"},
},
})
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",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_big_1",
"block_size": float64(8 * 1024 * 1024),
"block_num": float64(3),
},
},
}
reg.Register(prepareStub)
partStubs := make([]*httpmock.Stub, 0, 3)
for i := 0; i < 3; i++ {
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
}
partStubs = append(partStubs, stub)
reg.Register(stub)
}
finishStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_finish",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_tok_big"},
},
}
reg.Register(finishStub)
updateStub := &httpmock.Stub{
Method: "PATCH",
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": "file_tok_big",
"name": "large-report.bin",
"deprecated_set_attachment": true,
},
},
},
},
},
}
reg.Register(updateStub)
if 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", "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"`) {
t.Fatalf("stdout=%s", got)
}
prepareBody := string(prepareStub.CapturedBody)
if !strings.Contains(prepareBody, `"file_name":"large-report.bin"`) ||
!strings.Contains(prepareBody, `"parent_type":"bitable_file"`) ||
!strings.Contains(prepareBody, `"parent_node":"app_x"`) ||
!strings.Contains(prepareBody, `"size":20971521`) {
t.Fatalf("prepare body=%s", prepareBody)
}
firstPartBody := string(partStubs[0].CapturedBody)
if !strings.Contains(firstPartBody, `name="upload_id"`) ||
!strings.Contains(firstPartBody, "upload_big_1") ||
!strings.Contains(firstPartBody, `name="seq"`) ||
!strings.Contains(firstPartBody, "\r\n0\r\n") ||
!strings.Contains(firstPartBody, `name="size"`) ||
!strings.Contains(firstPartBody, "8388608") {
t.Fatalf("first part body=%s", firstPartBody)
}
lastPartBody := string(partStubs[2].CapturedBody)
if !strings.Contains(lastPartBody, `name="seq"`) ||
!strings.Contains(lastPartBody, "\r\n2\r\n") ||
!strings.Contains(lastPartBody, `name="size"`) ||
!strings.Contains(lastPartBody, "4194305") {
t.Fatalf("last part body=%s", lastPartBody)
}
finishBody := string(finishStub.CapturedBody)
if !strings.Contains(finishBody, `"upload_id":"upload_big_1"`) ||
!strings.Contains(finishBody, `"block_num":3`) {
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, `"deprecated_set_attachment":true`) {
t.Fatalf("update body=%s", updateBody)
}
})
t.Run("upload attachment rejects non-attachment field", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -904,6 +1055,37 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
t.Fatalf("err=%v", err)
}
})
t.Run("upload attachment rejects file larger than 2GB", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
tmpFile, err := os.CreateTemp(t.TempDir(), "base-too-large-*.bin")
if err != nil {
t.Fatalf("CreateTemp() err=%v", err)
}
if err := tmpFile.Truncate(2*1024*1024*1024 + 1); err != nil {
t.Fatalf("Truncate() 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()),
}, factory, stdout)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), "exceeds 2GB limit") {
t.Fatalf("err=%v", err)
}
})
}
func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) {

View File

@@ -5,15 +5,11 @@ package base
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"path/filepath"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
@@ -21,8 +17,8 @@ import (
)
const (
baseAttachmentUploadMaxFileSize = 20 * 1024 * 1024
baseAttachmentParentType = "bitable_file"
baseAttachmentUploadMaxFileSize int64 = 2 * 1024 * 1024 * 1024
baseAttachmentParentType = "bitable_file"
)
var BaseRecordUploadAttachment = common.Shortcut{
@@ -37,7 +33,7 @@ var BaseRecordUploadAttachment = common.Shortcut{
tableRefFlag(true),
recordRefFlag(true),
fieldRefFlag(true),
{Name: "file", Desc: "local file path (max 20MB)", Required: 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)"},
},
DryRun: dryRunRecordUploadAttachment,
@@ -52,7 +48,7 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
if fileName == "" {
fileName = filepath.Base(filePath)
}
return common.NewDryRunAPI().
dry := common.NewDryRunAPI().
Desc("4-step orchestration: validate attachment field → read existing record attachments → upload file to Base → patch merged attachment array").
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").
@@ -61,15 +57,42 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
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")).
POST("/open-apis/drive/v1/medias/upload_all").
Desc("[3] Upload local file to the current Base as attachment media (multipart/form-data)").
Body(map[string]interface{}{
"file_name": fileName,
"parent_type": baseAttachmentParentType,
"parent_node": runtime.Str("base-token"),
"file": "@" + filePath,
}).
Set("record_id", runtime.Str("record-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").
Body(map[string]interface{}{
"file_name": fileName,
"parent_type": baseAttachmentParentType,
"parent_node": runtime.Str("base-token"),
"size": "<file_size>",
}).
POST("/open-apis/drive/v1/medias/upload_part").
Desc("[3b] Upload attachment parts (repeated)").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"seq": "<chunk_index>",
"size": "<chunk_size>",
"file": "<chunk_binary>",
}).
POST("/open-apis/drive/v1/medias/upload_finish").
Desc("[3c] 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)").
Body(map[string]interface{}{
"file_name": fileName,
"parent_type": baseAttachmentParentType,
"parent_node": runtime.Str("base-token"),
"file": "@" + filePath,
"size": "<file_size>",
})
}
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").
Body(map[string]interface{}{
@@ -102,7 +125,7 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
return output.ErrValidation("file not accessible: %s: %v", filePath, err)
}
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
return output.ErrValidation("file %.1fMB exceeds 20MB limit", float64(fileInfo.Size())/1024/1024)
return output.ErrValidation("file %s exceeds 2GB limit", common.FormatSize(fileInfo.Size()))
}
fileName := strings.TrimSpace(runtime.Str("name"))
@@ -124,6 +147,9 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
}
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 {
@@ -151,6 +177,14 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
return nil
}
func baseAttachmentShouldUseMultipart(fio fileio.FileIO, filePath string) bool {
info, err := fio.Stat(filePath)
if err != nil {
return false
}
return info.Mode().IsRegular() && info.Size() > common.MaxDriveMediaUploadSinglePartSize
}
func fetchBaseField(runtime *common.RuntimeContext, baseToken, tableIDValue, fieldRef string) (map[string]interface{}, error) {
return baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, nil)
}
@@ -209,47 +243,30 @@ func normalizeAttachmentForPatch(attachment map[string]interface{}) map[string]i
}
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) {
f, err := runtime.FileIO().Open(filePath)
parentNode := baseToken
var (
fileToken string
err error
)
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: baseAttachmentParentType,
ParentNode: &parentNode,
})
} else {
fileToken, err = common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: baseAttachmentParentType,
ParentNode: parentNode,
})
}
if err != nil {
return nil, output.ErrValidation("cannot open file: %v", err)
}
defer f.Close()
fd := larkcore.NewFormdata()
fd.AddField("file_name", fileName)
fd.AddField("parent_type", baseAttachmentParentType)
fd.AddField("parent_node", baseToken)
fd.AddField("size", fmt.Sprintf("%d", fileSize))
fd.AddFile("file", f)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/medias/upload_all",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return nil, err
}
return nil, output.ErrNetwork("upload failed: %v", err)
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
}
code, _ := util.ToFloat64(result["code"])
if code != 0 {
msg, _ := result["msg"].(string)
return nil, output.ErrAPI(int(code), fmt.Sprintf("upload failed: [%d] %s", int(code), msg), result["error"])
}
data, _ := result["data"].(map[string]interface{})
fileToken, _ := data["file_token"].(string)
if fileToken == "" {
return nil, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
return nil, err
}
attachment := map[string]interface{}{

View File

@@ -0,0 +1,136 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var driveCreateShortcutAllowedTypes = map[string]bool{
"file": true,
"docx": true,
"bitable": true,
"doc": true,
"sheet": true,
"mindnote": true,
"slides": true,
}
type driveCreateShortcutSpec struct {
FileToken string
FileType string
FolderToken string
}
func newDriveCreateShortcutSpec(runtime *common.RuntimeContext) driveCreateShortcutSpec {
return driveCreateShortcutSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FileType: strings.ToLower(strings.TrimSpace(runtime.Str("type"))),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
}
}
// RequestBody builds the create_shortcut API payload from the shortcut spec.
func (s driveCreateShortcutSpec) RequestBody() map[string]interface{} {
return map[string]interface{}{
"parent_token": s.FolderToken,
"refer_entity": map[string]interface{}{
"refer_token": s.FileToken,
"refer_type": s.FileType,
},
}
}
// DriveCreateShortcut creates a Drive shortcut for an existing file in another folder.
var DriveCreateShortcut = common.Shortcut{
Service: "drive",
Command: "+create-shortcut",
Description: "Create a Drive shortcut in another folder",
Risk: "write",
Scopes: []string{"space:document:shortcut"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file-token", Desc: "source file token to reference", Required: true},
{Name: "type", Desc: "source file type (file, docx, bitable, doc, sheet, mindnote, slides)", Required: true},
{Name: "folder-token", Desc: "target folder token for the new shortcut", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveCreateShortcutSpec(newDriveCreateShortcutSpec(runtime))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := newDriveCreateShortcutSpec(runtime)
return common.NewDryRunAPI().
Desc("Create a Drive shortcut").
POST("/open-apis/drive/v1/files/create_shortcut").
Desc("[1] Create shortcut").
Body(spec.RequestBody())
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := newDriveCreateShortcutSpec(runtime)
fmt.Fprintf(
runtime.IO().ErrOut,
"Creating shortcut for %s %s in folder %s...\n",
spec.FileType,
common.MaskToken(spec.FileToken),
common.MaskToken(spec.FolderToken),
)
data, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/files/create_shortcut",
nil,
spec.RequestBody(),
)
if err != nil {
return err
}
out := map[string]interface{}{
"created": true,
"source_file_token": spec.FileToken,
"source_type": spec.FileType,
"folder_token": spec.FolderToken,
}
if shortcutToken := common.GetString(data, "succ_shortcut_node", "token"); shortcutToken != "" {
out["shortcut_token"] = shortcutToken
}
if url := common.GetString(data, "succ_shortcut_node", "url"); url != "" {
out["url"] = url
}
if title := common.GetString(data, "succ_shortcut_node", "name"); title != "" {
out["title"] = title
}
runtime.Out(out, nil)
return nil
},
}
// validateDriveCreateShortcutSpec validates shortcut creation inputs before API execution.
func validateDriveCreateShortcutSpec(spec driveCreateShortcutSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
}
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
}
if spec.FileType == "wiki" {
return output.ErrValidation("unsupported file type: wiki. This shortcut only supports Drive file tokens; wiki documents must be resolved to their underlying file token first")
}
if spec.FileType == "folder" {
return output.ErrValidation("unsupported file type: folder. The create_shortcut API only supports Drive files, not folders")
}
if !driveCreateShortcutAllowedTypes[spec.FileType] {
return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, slides", spec.FileType)
}
return nil
}

View File

@@ -0,0 +1,336 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// TestValidateDriveCreateShortcutSpecRejectsUnsupportedTypes verifies unsupported source types are rejected early.
func TestValidateDriveCreateShortcutSpecRejectsUnsupportedTypes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
spec driveCreateShortcutSpec
wantErr string
}{
{
name: "wiki",
spec: driveCreateShortcutSpec{
FileToken: "wiki_token_test",
FileType: "wiki",
FolderToken: "target_folder_token_test",
},
wantErr: "underlying file token first",
},
{
name: "folder",
spec: driveCreateShortcutSpec{
FileToken: "folder_token_test",
FileType: "folder",
FolderToken: "target_folder_token_test",
},
wantErr: "not folders",
},
{
name: "shortcut",
spec: driveCreateShortcutSpec{
FileToken: "shortcut_token_test",
FileType: "shortcut",
FolderToken: "target_folder_token_test",
},
wantErr: "Supported types",
},
{
name: "missing folder token",
spec: driveCreateShortcutSpec{
FileToken: "file_token_test",
FileType: "docx",
},
wantErr: "--folder-token must not be empty",
},
{
name: "unknown",
spec: driveCreateShortcutSpec{
FileToken: "file_token_test",
FileType: "unknown",
FolderToken: "target_folder_token_test",
},
wantErr: "Supported types",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateDriveCreateShortcutSpec(tt.spec)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr)
}
})
}
}
// TestDriveCreateShortcutDryRunIncludesSingleCreateRequest verifies dry-run only previews the create request.
func TestDriveCreateShortcutDryRunIncludesSingleCreateRequest(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +create-shortcut"}
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
if err := cmd.Flags().Set("file-token", " doc_token_test "); err != nil {
t.Fatalf("set --file-token: %v", err)
}
if err := cmd.Flags().Set("type", " DOCX "); err != nil {
t.Fatalf("set --type: %v", err)
}
if err := cmd.Flags().Set("folder-token", " folder_target_token_test "); err != nil {
t.Fatalf("set --folder-token: %v", err)
}
runtime := common.TestNewRuntimeContext(cmd, nil)
dry := DriveCreateShortcut.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 {
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 json: %v", err)
}
if len(got.API) != 1 {
t.Fatalf("expected 1 API call, got %d", len(got.API))
}
if got.API[0].Method != "POST" {
t.Fatalf("first method = %q, want POST", got.API[0].Method)
}
if got.API[0].Body["parent_token"] != "folder_target_token_test" {
t.Fatalf("parent_token = %#v, want folder_target_token_test", got.API[0].Body["parent_token"])
}
referEntity, _ := got.API[0].Body["refer_entity"].(map[string]interface{})
if referEntity["refer_token"] != "doc_token_test" || referEntity["refer_type"] != "docx" {
t.Fatalf("unexpected refer_entity: %#v", referEntity)
}
}
// TestDriveCreateShortcutUsesProvidedFolderToken verifies execution uses the explicit target folder token.
func TestDriveCreateShortcutUsesProvidedFolderToken(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/create_shortcut",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"succ_shortcut_node": map[string]interface{}{
"token": "shortcut_token_test",
"name": "shortcut_name_test",
"type": "docx",
"parent_token": "folder_target_token_test",
"url": "https://example.feishu.cn/docx/shortcut_token_test",
"shortcut_info": map[string]interface{}{
"target_type": "docx",
"target_token": "doc_token_test",
},
},
},
},
}
reg.Register(createStub)
err := mountAndRunDrive(t, DriveCreateShortcut, []string{
"+create-shortcut",
"--file-token", " doc_token_test ",
"--type", " DOCX ",
"--folder-token", " folder_target_token_test ",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeCapturedJSONBody(t, createStub)
if body["parent_token"] != "folder_target_token_test" {
t.Fatalf("parent_token = %#v, want folder_target_token_test", body["parent_token"])
}
referEntity, _ := body["refer_entity"].(map[string]interface{})
if referEntity["refer_token"] != "doc_token_test" || referEntity["refer_type"] != "docx" {
t.Fatalf("unexpected refer_entity: %#v", referEntity)
}
data := decodeDriveEnvelope(t, stdout)
if data["shortcut_token"] != "shortcut_token_test" {
t.Fatalf("shortcut_token = %#v, want shortcut_token_test", data["shortcut_token"])
}
if data["folder_token"] != "folder_target_token_test" {
t.Fatalf("folder_token = %#v, want folder_target_token_test", data["folder_token"])
}
if data["source_file_token"] != "doc_token_test" {
t.Fatalf("source_file_token = %#v, want doc_token_test", data["source_file_token"])
}
if data["title"] != "shortcut_name_test" {
t.Fatalf("title = %#v, want shortcut_name_test", data["title"])
}
if data["url"] != "https://example.feishu.cn/docx/shortcut_token_test" {
t.Fatalf("url = %#v, want https://example.feishu.cn/docx/shortcut_token_test", data["url"])
}
if data["created"] != true {
t.Fatalf("created = %#v, want true", data["created"])
}
}
// TestDriveCreateShortcutValidateRequiresFolderToken verifies folder-token is mandatory.
func TestDriveCreateShortcutValidateRequiresFolderToken(t *testing.T) {
err := validateDriveCreateShortcutSpec(driveCreateShortcutSpec{
FileToken: "doc_token_test",
FileType: "docx",
})
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), "--folder-token must not be empty") {
t.Fatalf("unexpected error: %v", err)
}
}
// TestDriveCreateShortcutValidateRejectsWhitespaceOnlyFolderToken verifies runtime normalization rejects blank folder tokens.
func TestDriveCreateShortcutValidateRejectsWhitespaceOnlyFolderToken(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +create-shortcut"}
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
if err := cmd.Flags().Set("file-token", "doc_token_test"); err != nil {
t.Fatalf("set --file-token: %v", err)
}
if err := cmd.Flags().Set("type", " DOCX "); err != nil {
t.Fatalf("set --type: %v", err)
}
if err := cmd.Flags().Set("folder-token", " "); err != nil {
t.Fatalf("set --folder-token: %v", err)
}
runtime := common.TestNewRuntimeContext(cmd, nil)
err := DriveCreateShortcut.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), "--folder-token must not be empty") {
t.Fatalf("unexpected error: %v", err)
}
}
// TestDriveCreateShortcutClassifiesKnownAPIConstraints verifies known API constraints surface as structured errors.
func TestDriveCreateShortcutClassifiesKnownAPIConstraints(t *testing.T) {
t.Parallel()
tests := []struct {
name string
code int
msg string
wantType string
wantHint string
wantMsgPart string
}{
{
name: "resource contention",
code: output.LarkErrDriveResourceContention,
msg: "resource contention occurred, please retry",
wantType: "conflict",
wantHint: "avoid concurrent duplicate requests",
wantMsgPart: "resource contention occurred",
},
{
name: "cross tenant and unit",
code: output.LarkErrDriveCrossTenantUnit,
msg: "cross tenant and unit not support",
wantType: "cross_tenant_unit",
wantHint: "same tenant and region/unit",
wantMsgPart: "cross tenant and unit not support",
},
{
name: "cross brand",
code: output.LarkErrDriveCrossBrand,
msg: "cross brand not support",
wantType: "cross_brand",
wantHint: "same brand environment",
wantMsgPart: "cross brand not support",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/create_shortcut",
Body: map[string]interface{}{
"code": float64(tt.code),
"msg": tt.msg,
},
})
err := mountAndRunDrive(t, DriveCreateShortcut, []string{
"+create-shortcut",
"--file-token", "doc_token_test",
"--type", "docx",
"--folder-token", "folder_token_test",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected API error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAPI)
}
if exitErr.Detail.Type != tt.wantType {
t.Fatalf("type = %q, want %q", exitErr.Detail.Type, tt.wantType)
}
if exitErr.Detail.Code != tt.code {
t.Fatalf("detail code = %d, want %d", exitErr.Detail.Code, tt.code)
}
if !strings.Contains(exitErr.Detail.Message, tt.wantMsgPart) {
t.Fatalf("message = %q, want substring %q", exitErr.Detail.Message, tt.wantMsgPart)
}
if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) {
t.Fatalf("hint = %q, want substring %q", exitErr.Detail.Hint, tt.wantHint)
}
})
}
}

View File

@@ -5,27 +5,31 @@ package drive
import (
"context"
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// DriveTaskResult exposes a unified read path for the async task types produced
// by Drive import, export, and folder move flows.
// by Drive import, export, folder move/delete, and wiki move flows.
var DriveTaskResult = common.Shortcut{
Service: "drive",
Command: "+task_result",
Description: "Poll async task result for import, export, move, or delete operations",
Description: "Poll async task result for import, export, drive move/delete, or wiki move operations",
Risk: "read",
Scopes: []string{"drive:drive.metadata:readonly"},
AuthTypes: []string{"user", "bot"},
// This shortcut multiplexes multiple backend APIs with different scope
// requirements, so scenario-specific prechecks are handled in Validate.
Scopes: []string{},
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 move/delete folder tasks)", Required: false},
{Name: "scenario", Desc: "task scenario: import, export, or task_check", Required: true},
{Name: "task-id", Desc: "async task ID (for drive task_check or wiki_move tasks)", Required: false},
{Name: "scenario", Desc: "task scenario: import, export, task_check, or wiki_move", 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 {
@@ -34,9 +38,10 @@ var DriveTaskResult = common.Shortcut{
"import": true,
"export": true,
"task_check": true,
"wiki_move": true,
}
if !validScenarios[scenario] {
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check", scenario)
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move", scenario)
}
// Validate required params based on scenario
@@ -48,9 +53,9 @@ var DriveTaskResult = common.Shortcut{
if err := validate.ResourceName(runtime.Str("ticket"), "--ticket"); err != nil {
return output.ErrValidation("%s", err)
}
case "task_check":
case "task_check", "wiki_move":
if runtime.Str("task-id") == "" {
return output.ErrValidation("--task-id is required for task_check scenario")
return output.ErrValidation("--task-id is required for %s scenario", scenario)
}
if err := validate.ResourceName(runtime.Str("task-id"), "--task-id"); err != nil {
return output.ErrValidation("%s", err)
@@ -67,7 +72,7 @@ var DriveTaskResult = common.Shortcut{
}
}
return nil
return validateDriveTaskResultScopes(ctx, runtime, scenario)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
scenario := strings.ToLower(runtime.Str("scenario"))
@@ -92,6 +97,11 @@ var DriveTaskResult = common.Shortcut{
dry.GET("/open-apis/drive/v1/files/task_check").
Desc("[1] Query move/delete folder task status").
Params(driveTaskCheckParams(taskID))
case "wiki_move":
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
Desc("[1] Query wiki move task result").
Set("task_id", taskID).
Params(map[string]interface{}{"task_type": "move"})
}
return dry
@@ -116,6 +126,8 @@ var DriveTaskResult = common.Shortcut{
result, err = queryExportTask(runtime, ticket, fileToken)
case "task_check":
result, err = queryTaskCheck(runtime, taskID)
case "wiki_move":
result, err = queryWikiMoveTask(runtime, taskID)
}
if err != nil {
@@ -196,3 +208,263 @@ func queryTaskCheck(runtime *common.RuntimeContext, taskID string) (map[string]i
"failed": status.Failed(),
}, nil
}
func validateDriveTaskResultScopes(ctx context.Context, runtime *common.RuntimeContext, scenario string) error {
result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
if err != nil {
// Propagate cancellation/timeout so callers stop instead of falling through
// to the API call. Other token errors are non-fatal here: the API call will
// surface a clearer permission error.
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
return nil
}
if result == nil || result.Scopes == "" {
return nil
}
var required []string
switch scenario {
case "import", "export", "task_check":
required = []string{"drive:drive.metadata:readonly"}
case "wiki_move":
required = []string{"wiki:space:read"}
}
return requireDriveScopes(result.Scopes, required)
}
func requireDriveScopes(storedScopes string, required []string) error {
if len(required) == 0 {
return nil
}
missing := missingDriveScopes(storedScopes, required)
if len(missing) == 0 {
return nil
}
return output.ErrWithHint(output.ExitAuth, "missing_scope",
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")))
}
func missingDriveScopes(storedScopes string, required []string) []string {
granted := make(map[string]bool)
for _, scope := range strings.Fields(storedScopes) {
granted[scope] = true
}
missing := make([]string, 0, len(required))
for _, scope := range required {
if !granted[scope] {
missing = append(missing, scope)
}
}
return missing
}
type wikiMoveTaskResultStatus struct {
Node map[string]interface{}
Status int
StatusMsg string
}
type wikiMoveTaskQueryStatus struct {
TaskID string
MoveResults []wikiMoveTaskResultStatus
}
func (s wikiMoveTaskQueryStatus) Ready() bool {
if len(s.MoveResults) == 0 {
return false
}
for _, result := range s.MoveResults {
if result.Status != 0 {
return false
}
}
return true
}
func (s wikiMoveTaskQueryStatus) Failed() bool {
for _, result := range s.MoveResults {
if result.Status < 0 {
return true
}
}
return false
}
func (s wikiMoveTaskQueryStatus) FirstResult() *wikiMoveTaskResultStatus {
if len(s.MoveResults) == 0 {
return nil
}
return &s.MoveResults[0]
}
// primaryResult picks the most informative move_result for top-level status
// surfacing: prefer a failing entry so multi-doc tasks don't mask failures
// behind an earlier success, then a still-processing entry, and finally fall
// back to the first entry.
func (s wikiMoveTaskQueryStatus) primaryResult() *wikiMoveTaskResultStatus {
for i := range s.MoveResults {
if s.MoveResults[i].Status < 0 {
return &s.MoveResults[i]
}
}
for i := range s.MoveResults {
if s.MoveResults[i].Status > 0 {
return &s.MoveResults[i]
}
}
return s.FirstResult()
}
func (s wikiMoveTaskQueryStatus) PrimaryStatusCode() int {
if r := s.primaryResult(); r != nil {
return r.Status
}
return 1
}
func (s wikiMoveTaskQueryStatus) PrimaryStatusLabel() string {
if r := s.primaryResult(); r != nil {
if msg := strings.TrimSpace(r.StatusMsg); msg != "" {
return msg
}
}
switch {
case s.Ready():
return "success"
case s.Failed():
return "failure"
default:
return "processing"
}
}
func queryWikiMoveTask(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
status, err := getWikiMoveTaskStatus(runtime, taskID)
if err != nil {
return nil, err
}
out := map[string]interface{}{
"scenario": "wiki_move",
"task_id": status.TaskID,
"ready": status.Ready(),
"failed": status.Failed(),
"status": status.PrimaryStatusCode(),
"status_msg": status.PrimaryStatusLabel(),
}
moveResults := make([]map[string]interface{}, 0, len(status.MoveResults))
for _, result := range status.MoveResults {
item := map[string]interface{}{
"status": result.Status,
"status_msg": result.StatusMsg,
}
if result.Node != nil {
item["node"] = result.Node
}
moveResults = append(moveResults, item)
}
if len(moveResults) > 0 {
out["move_results"] = moveResults
}
if first := status.FirstResult(); first != nil {
// Mirror the first moved node at the top level so follow-up commands can
// reuse a stable field set without digging into move_results[0].node.
if first.Node != nil {
out["node"] = first.Node
appendWikiMoveNodeFields(out, first.Node)
if token := common.GetString(first.Node, "node_token"); token != "" {
out["wiki_token"] = token
}
}
}
return out, nil
}
func getWikiMoveTaskStatus(runtime *common.RuntimeContext, taskID string) (wikiMoveTaskQueryStatus, error) {
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
return wikiMoveTaskQueryStatus{}, 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": "move"},
nil,
)
if err != nil {
return wikiMoveTaskQueryStatus{}, err
}
return parseWikiMoveTaskQueryStatus(taskID, common.GetMap(data, "task"))
}
func parseWikiMoveTaskQueryStatus(taskID string, task map[string]interface{}) (wikiMoveTaskQueryStatus, error) {
if task == nil {
return wikiMoveTaskQueryStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
}
status := wikiMoveTaskQueryStatus{
TaskID: common.GetString(task, "task_id"),
}
if status.TaskID == "" {
status.TaskID = taskID
}
for _, item := range common.GetSlice(task, "move_result") {
resultMap, ok := item.(map[string]interface{})
if !ok {
continue
}
status.MoveResults = append(status.MoveResults, wikiMoveTaskResultStatus{
Node: parseWikiMoveTaskNode(common.GetMap(resultMap, "node")),
Status: int(common.GetFloat(resultMap, "status")),
StatusMsg: common.GetString(resultMap, "status_msg"),
})
}
return status, nil
}
func parseWikiMoveTaskNode(node map[string]interface{}) map[string]interface{} {
if node == nil {
return nil
}
return map[string]interface{}{
"space_id": common.GetString(node, "space_id"),
"node_token": common.GetString(node, "node_token"),
"obj_token": common.GetString(node, "obj_token"),
"obj_type": common.GetString(node, "obj_type"),
"parent_node_token": common.GetString(node, "parent_node_token"),
"node_type": common.GetString(node, "node_type"),
"origin_node_token": common.GetString(node, "origin_node_token"),
"title": common.GetString(node, "title"),
"has_child": common.GetBool(node, "has_child"),
}
}
func appendWikiMoveNodeFields(out, node map[string]interface{}) {
if out == nil || node == nil {
return
}
out["space_id"] = common.GetString(node, "space_id")
out["node_token"] = common.GetString(node, "node_token")
out["obj_token"] = common.GetString(node, "obj_token")
out["obj_type"] = common.GetString(node, "obj_type")
out["parent_node_token"] = common.GetString(node, "parent_node_token")
out["node_type"] = common.GetString(node, "node_type")
out["origin_node_token"] = common.GetString(node, "origin_node_token")
out["title"] = common.GetString(node, "title")
out["has_child"] = common.GetBool(node, "has_child")
}

View File

@@ -7,12 +7,15 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -54,6 +57,13 @@ func TestDriveTaskResultValidateErrorsByScenario(t *testing.T) {
},
wantErr: "--task-id is required",
},
{
name: "wiki move missing task id",
flags: map[string]string{
"scenario": "wiki_move",
},
wantErr: "--task-id is required",
},
}
for _, tt := range tests {
@@ -277,3 +287,259 @@ func TestDriveTaskResultTaskCheckTreatsFailAsFailed(t *testing.T) {
t.Fatalf("stdout missing ready=false: %s", stdout.String())
}
}
type mockDriveTaskResultTokenResolver struct {
token string
scopes string
err error
}
func (m *mockDriveTaskResultTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
if m.err != nil {
return nil, m.err
}
token := m.token
if token == "" {
token = "test-token"
}
return &credential.TokenResult{Token: token, Scopes: m.scopes}, nil
}
func newDriveTaskResultRuntimeWithScopes(t *testing.T, as core.Identity, scopes string) *common.RuntimeContext {
t.Helper()
cfg := driveTestConfig()
factory, _, _, _ := cmdutil.TestFactory(t, cfg)
factory.Credential = credential.NewCredentialProvider(nil, nil, &mockDriveTaskResultTokenResolver{scopes: scopes}, nil)
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "drive +task_result"}, cfg, as)
runtime.Factory = factory
return runtime
}
func TestDriveTaskResultDryRunWikiMoveIncludesTaskTypeParam(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_move"); err != nil {
t.Fatalf("set --scenario: %v", err)
}
if err := cmd.Flags().Set("task-id", "task_123"); 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"] != "move" {
t.Fatalf("wiki move params = %#v, want task_type=move", got.API[0].Params)
}
}
func TestDriveTaskResultWikiMoveIncludesFlattenedNodeFields(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/tasks/task_123",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"task": map[string]interface{}{
"task_id": "task_123",
"move_result": []interface{}{
map[string]interface{}{
"status": 0,
"status_msg": "success",
"node": map[string]interface{}{
"space_id": "space_dst",
"node_token": "wik_done",
"obj_token": "sheet_token",
"obj_type": "sheet",
"node_type": "origin",
"title": "Roadmap",
},
},
},
},
},
},
})
err := mountAndRunDrive(t, DriveTaskResult, []string{
"+task_result",
"--scenario", "wiki_move",
"--task-id", "task_123",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if data["scenario"] != "wiki_move" || data["task_id"] != "task_123" {
t.Fatalf("unexpected wiki_move envelope: %#v", data)
}
if data["ready"] != true || data["failed"] != false || data["wiki_token"] != "wik_done" {
t.Fatalf("unexpected readiness fields: %#v", data)
}
if data["title"] != "Roadmap" || data["obj_type"] != "sheet" || data["space_id"] != "space_dst" {
t.Fatalf("flattened node fields missing: %#v", data)
}
moveResults, ok := data["move_results"].([]interface{})
if !ok || len(moveResults) != 1 {
t.Fatalf("move_results = %#v, want one result", data["move_results"])
}
}
func TestValidateDriveTaskResultScopesWikiMoveRequiresWikiScope(t *testing.T) {
t.Parallel()
runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "drive:drive.metadata:readonly")
err := validateDriveTaskResultScopes(context.Background(), runtime, "wiki_move")
if err == nil || !strings.Contains(err.Error(), "missing required scope(s): wiki:space:read") {
t.Fatalf("expected missing wiki scope error, got %v", err)
}
}
func TestValidateDriveTaskResultScopesWikiMoveAcceptsWikiScope(t *testing.T) {
t.Parallel()
runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "wiki:space:read")
err := validateDriveTaskResultScopes(context.Background(), runtime, "wiki_move")
if err != nil {
t.Fatalf("validateDriveTaskResultScopes() error = %v", err)
}
}
func TestValidateDriveTaskResultScopesDriveScenariosRequireDriveScope(t *testing.T) {
t.Parallel()
runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "wiki:space:read")
err := validateDriveTaskResultScopes(context.Background(), runtime, "import")
if err == nil || !strings.Contains(err.Error(), "missing required scope(s): drive:drive.metadata:readonly") {
t.Fatalf("expected missing drive scope error, got %v", err)
}
}
func TestParseWikiMoveTaskQueryStatusFallbackTaskIDAndNode(t *testing.T) {
t.Parallel()
status, err := parseWikiMoveTaskQueryStatus("task_fallback", map[string]interface{}{
"move_result": []interface{}{
map[string]interface{}{
"status": 0,
"status_msg": "success",
"node": map[string]interface{}{
"space_id": "space_dst",
"node_token": "wik_done",
"obj_token": "sheet_token",
"obj_type": "sheet",
"title": "Roadmap",
},
},
},
})
if err != nil {
t.Fatalf("parseWikiMoveTaskQueryStatus() error = %v", err)
}
if status.TaskID != "task_fallback" || !status.Ready() || status.PrimaryStatusLabel() != "success" {
t.Fatalf("unexpected parsed status: %+v", status)
}
if first := status.FirstResult(); first == nil || first.Node == nil || first.Node["node_token"] != "wik_done" {
t.Fatalf("parsed node = %+v", first)
}
}
func TestParseWikiMoveTaskQueryStatusRejectsMissingTask(t *testing.T) {
t.Parallel()
_, err := parseWikiMoveTaskQueryStatus("task_123", nil)
if err == nil || !strings.Contains(err.Error(), "missing task") {
t.Fatalf("expected missing task error, got %v", err)
}
}
func TestWikiMoveTaskQueryStatusPrimarySurfacesFailureOverEarlierSuccess(t *testing.T) {
t.Parallel()
status := wikiMoveTaskQueryStatus{
MoveResults: []wikiMoveTaskResultStatus{
{Status: 0, StatusMsg: "success"},
{Status: -3, StatusMsg: "permission denied"},
{Status: 1, StatusMsg: "processing"},
},
}
if got := status.PrimaryStatusCode(); got != -3 {
t.Fatalf("PrimaryStatusCode = %d, want -3", got)
}
if got := status.PrimaryStatusLabel(); got != "permission denied" {
t.Fatalf("PrimaryStatusLabel = %q, want permission denied", got)
}
// FirstResult must keep its literal "first entry" semantics for callers
// that flatten node fields from the first move_result.
if first := status.FirstResult(); first == nil || first.StatusMsg != "success" {
t.Fatalf("FirstResult = %+v, want first success entry", first)
}
}
func TestWikiMoveTaskQueryStatusPrimaryPrefersProcessingOverFirstSuccess(t *testing.T) {
t.Parallel()
status := wikiMoveTaskQueryStatus{
MoveResults: []wikiMoveTaskResultStatus{
{Status: 0, StatusMsg: "success"},
{Status: 1, StatusMsg: "processing"},
},
}
if got := status.PrimaryStatusCode(); got != 1 {
t.Fatalf("PrimaryStatusCode = %d, want 1", got)
}
if got := status.PrimaryStatusLabel(); got != "processing" {
t.Fatalf("PrimaryStatusLabel = %q, want processing", got)
}
}
type cancelingTokenResolver struct{}
func (cancelingTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
return nil, context.Canceled
}
func TestValidateDriveTaskResultScopesPropagatesContextCancellation(t *testing.T) {
t.Parallel()
cfg := driveTestConfig()
factory, _, _, _ := cmdutil.TestFactory(t, cfg)
factory.Credential = credential.NewCredentialProvider(nil, nil, cancelingTokenResolver{}, nil)
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "drive +task_result"}, cfg, core.AsUser)
runtime.Factory = factory
err := validateDriveTaskResultScopes(context.Background(), runtime, "wiki_move")
if err == nil || !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled, got %v", err)
}
}

View File

@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
DriveUpload,
DriveCreateShortcut,
DriveDownload,
DriveAddComment,
DriveExport,

View File

@@ -5,12 +5,14 @@ package drive
import "testing"
// TestShortcutsIncludesExpectedCommands verifies the drive shortcut registry contains the expected commands.
func TestShortcutsIncludesExpectedCommands(t *testing.T) {
t.Parallel()
got := Shortcuts()
want := []string{
"+upload",
"+create-shortcut",
"+download",
"+add-comment",
"+export",

View File

@@ -10,6 +10,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
@@ -395,6 +396,28 @@ func TestShortcutValidateBranches(t *testing.T) {
}
})
t.Run("ImChatMessageList rejects both targets", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_abc",
"user-id": "ou_123",
}, nil)
err := ImChatMessageList.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("ImChatMessageList.Validate() error = %v, want mutually exclusive", err)
}
})
t.Run("ImChatMessageList rejects user target for bot identity", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"user-id": "ou_123",
}, nil)
setRuntimeField(t, runtime, "resolvedAs", core.AsBot)
err := ImChatMessageList.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "requires user identity") {
t.Fatalf("ImChatMessageList.Validate() error = %v, want requires user identity", err)
}
})
t.Run("ImMessagesMGet empty ids", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"message-ids": " , ",

View File

@@ -273,7 +273,7 @@ func TestResolveChatIDForMessagesList(t *testing.T) {
})
t.Run("user resolved through p2p lookup", func(t *testing.T) {
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
runtime := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v1/chat_p2p/batch_query"):
return shortcutJSONResponse(200, map[string]interface{}{
@@ -303,6 +303,23 @@ func TestResolveChatIDForMessagesList(t *testing.T) {
t.Fatalf("resolveChatIDForMessagesList() = %q, want %q", got, "oc_resolved")
}
})
t.Run("user target rejected for bot identity", func(t *testing.T) {
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}))
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("user-id", "", "")
if err := cmd.Flags().Set("user-id", "ou_123"); err != nil {
t.Fatalf("Flags().Set() error = %v", err)
}
runtime.Cmd = cmd
_, err := resolveChatIDForMessagesList(runtime, false)
if err == nil || !strings.Contains(err.Error(), "requires user identity") {
t.Fatalf("resolveChatIDForMessagesList() error = %v, want requires user identity", err)
}
})
}
func TestBuildMessagesSearchRequest(t *testing.T) {

View File

@@ -377,6 +377,9 @@ func mediaFallbackOrError(originalValue, mediaType string, uploadErr error) (str
// resolveP2PChatID resolves user open_id to P2P chat_id.
func resolveP2PChatID(runtime *common.RuntimeContext, openID string) (string, error) {
if runtime.IsBot() {
return "", output.Errorf(output.ExitValidation, "validation", "--user-id requires user identity (--as user); use --chat-id when calling with bot identity")
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/im/v1/chat_p2p/batch_query",

View File

@@ -6,6 +6,7 @@ package im
import (
"bytes"
"context"
"crypto/md5"
"encoding/json"
"fmt"
"io"
@@ -13,6 +14,7 @@ import (
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
"unsafe"
@@ -107,12 +109,17 @@ func newBotShortcutRuntime(t *testing.T, rt http.RoundTripper) *common.RuntimeCo
return runtime
}
func newUserShortcutRuntime(t *testing.T, rt http.RoundTripper) *common.RuntimeContext {
t.Helper()
runtime := newBotShortcutRuntime(t, rt)
setRuntimeField(t, runtime, "resolvedAs", core.AsUser)
return runtime
}
func TestResolveP2PChatID(t *testing.T) {
var gotAuth string
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
runtime := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v1/chat_p2p/batch_query"):
gotAuth = req.Header.Get("Authorization")
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
@@ -133,13 +140,10 @@ func TestResolveP2PChatID(t *testing.T) {
if got != "oc_123" {
t.Fatalf("resolveP2PChatID() = %q, want %q", got, "oc_123")
}
if gotAuth != "Bearer tenant-token" {
t.Fatalf("Authorization header = %q, want %q", gotAuth, "Bearer tenant-token")
}
}
func TestResolveP2PChatIDNotFound(t *testing.T) {
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
runtime := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v1/chat_p2p/batch_query"):
return shortcutJSONResponse(200, map[string]interface{}{
@@ -159,6 +163,17 @@ func TestResolveP2PChatIDNotFound(t *testing.T) {
}
}
func TestResolveP2PChatIDRejectsBot(t *testing.T) {
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}))
_, err := resolveP2PChatID(runtime, "ou_123")
if err == nil || !strings.Contains(err.Error(), "requires user identity") {
t.Fatalf("resolveP2PChatID() error = %v, want requires user identity", err)
}
}
func TestResolveThreadID(t *testing.T) {
t.Run("thread id passthrough", func(t *testing.T) {
got, err := resolveThreadID(newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
@@ -273,6 +288,46 @@ func TestDownloadIMResourceToPathSuccess(t *testing.T) {
if gotHeaders.Get(cmdutil.HeaderExecutionId) != "exec-123" {
t.Fatalf("%s = %q, want %q", cmdutil.HeaderExecutionId, gotHeaders.Get(cmdutil.HeaderExecutionId), "exec-123")
}
if gotHeaders.Get("Range") != fmt.Sprintf("bytes=0-%d", probeChunkSize-1) {
t.Fatalf("Range header = %q, want %q", gotHeaders.Get("Range"), fmt.Sprintf("bytes=0-%d", probeChunkSize-1))
}
}
func TestDownloadIMResourceToPathImageUsesSingleRequestWithoutRange(t *testing.T) {
var gotHeaders http.Header
payload := []byte("image download")
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_img/resources/img_123"):
gotHeaders = req.Header.Clone()
return shortcutRawResponse(200, payload, http.Header{"Content-Type": []string{"image/png"}}), nil
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
cmdutil.TestChdir(t, t.TempDir())
gotPath, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_img", "img_123", "image", "image")
if err != nil {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
if size != int64(len(payload)) {
t.Fatalf("downloadIMResourceToPath() size = %d, want %d", size, len(payload))
}
if gotHeaders.Get("Range") != "" {
t.Fatalf("Range header = %q, want empty", gotHeaders.Get("Range"))
}
if !strings.HasSuffix(gotPath, "image.png") {
t.Fatalf("saved path = %q, want suffix %q", gotPath, "image.png")
}
data, err := os.ReadFile("image.png")
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(data) != string(payload) {
t.Fatalf("downloaded payload = %q, want %q", string(data), string(payload))
}
}
func TestDownloadIMResourceToPathHTTPErrorBody(t *testing.T) {
@@ -293,6 +348,348 @@ func TestDownloadIMResourceToPathHTTPErrorBody(t *testing.T) {
}
}
func TestDownloadIMResourceToPathRetriesNetworkError(t *testing.T) {
attempts := 0
payload := []byte("retry success")
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "tenant_access_token"):
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"tenant_access_token": "tenant-token",
"expire": 7200,
}), nil
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_retry/resources/file_retry"):
attempts++
if attempts < 3 {
return nil, fmt.Errorf("temporary network failure")
}
return shortcutRawResponse(200, payload, http.Header{"Content-Type": []string{"application/octet-stream"}}), nil
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
cmdutil.TestChdir(t, t.TempDir())
target := "out.bin"
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_retry", "file_retry", "file", target)
if err != nil {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
if attempts != 3 {
t.Fatalf("download attempts = %d, want 3", attempts)
}
if size != int64(len(payload)) {
t.Fatalf("downloadIMResourceToPath() size = %d, want %d", size, len(payload))
}
}
func TestDownloadIMResourceToPathRetrySecondAttemptSuccess(t *testing.T) {
attempts := 0
payload := []byte("second retry success")
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "tenant_access_token"):
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"tenant_access_token": "tenant-token",
"expire": 7200,
}), nil
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_retry2/resources/file_retry2"):
attempts++
if attempts < 2 {
return nil, fmt.Errorf("temporary network failure")
}
return shortcutRawResponse(200, payload, http.Header{"Content-Type": []string{"application/octet-stream"}}), nil
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
cmdutil.TestChdir(t, t.TempDir())
target := "out.bin"
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_retry2", "file_retry2", "file", target)
if err != nil {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
if attempts != 2 {
t.Fatalf("download attempts = %d, want 2", attempts)
}
if size != int64(len(payload)) {
t.Fatalf("downloadIMResourceToPath() size = %d, want %d", size, len(payload))
}
}
func TestDownloadIMResourceToPathRetryContextCanceled(t *testing.T) {
attempts := 0
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "tenant_access_token"):
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"tenant_access_token": "tenant-token",
"expire": 7200,
}), nil
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_cancel/resources/file_cancel"):
attempts++
return nil, fmt.Errorf("temporary network failure")
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
ctx, cancel := context.WithCancel(context.Background())
// Cancel context immediately to trigger context error on first retry
cancel()
cmdutil.TestChdir(t, t.TempDir())
target := "out.bin"
_, _, err := downloadIMResourceToPath(ctx, runtime, "om_cancel", "file_cancel", "file", target)
if err != context.Canceled {
t.Fatalf("downloadIMResourceToPath() error = %v, want context.Canceled", err)
}
// First attempt is made, then retry checks ctx.Err() and returns
if attempts != 1 {
t.Fatalf("download attempts = %d, want 1", attempts)
}
}
func TestDownloadIMResourceToPathRangeDownload(t *testing.T) {
cases := []struct {
name string
payloadLen int64
wantRanges []string
}{
{
name: "single small chunk",
payloadLen: 16,
wantRanges: []string{"bytes=0-131071"},
},
{
name: "exact probe chunk",
payloadLen: probeChunkSize,
wantRanges: []string{"bytes=0-131071"},
},
{
name: "multiple chunks with tail",
payloadLen: probeChunkSize + normalChunkSize + 1234,
wantRanges: []string{
"bytes=0-131071",
fmt.Sprintf("bytes=%d-%d", probeChunkSize, probeChunkSize+normalChunkSize-1),
fmt.Sprintf("bytes=%d-%d", probeChunkSize+normalChunkSize, probeChunkSize+normalChunkSize+1233),
},
},
{
name: "multiple chunks exact 8mb tail",
payloadLen: probeChunkSize + 2*normalChunkSize,
wantRanges: []string{
"bytes=0-131071",
fmt.Sprintf("bytes=%d-%d", probeChunkSize, probeChunkSize+normalChunkSize-1),
fmt.Sprintf("bytes=%d-%d", probeChunkSize+normalChunkSize, probeChunkSize+2*normalChunkSize-1),
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
payload := bytes.Repeat([]byte("range-download-"), int(tt.payloadLen/15)+1)
payload = payload[:tt.payloadLen]
var gotRanges []string
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "tenant_access_token"):
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"tenant_access_token": "tenant-token",
"expire": 7200,
}), nil
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_range/resources/file_range"):
rangeHeader := req.Header.Get("Range")
gotRanges = append(gotRanges, rangeHeader)
if req.Header.Get("Authorization") != "Bearer tenant-token" {
return nil, fmt.Errorf("missing authorization header")
}
start, end, err := parseRangeHeader(rangeHeader, int64(len(payload)))
if err != nil {
return nil, err
}
return shortcutRawResponse(http.StatusPartialContent, payload[start:end+1], http.Header{
"Content-Type": []string{"application/octet-stream"},
"Content-Range": []string{fmt.Sprintf("bytes %d-%d/%d", start, end, len(payload))},
}), nil
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
cmdutil.TestChdir(t, t.TempDir())
target := filepath.Join("nested", "resource.bin")
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_range", "file_range", "file", target)
if err != nil {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
if size != int64(len(payload)) {
t.Fatalf("downloadIMResourceToPath() size = %d, want %d", size, len(payload))
}
if !reflect.DeepEqual(gotRanges, tt.wantRanges) {
t.Fatalf("Range requests = %#v, want %#v", gotRanges, tt.wantRanges)
}
got, err := os.ReadFile(target)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if md5.Sum(got) != md5.Sum(payload) {
t.Fatalf("downloaded payload MD5 = %x, want %x", md5.Sum(got), md5.Sum(payload))
}
})
}
}
func TestDownloadIMResourceToPathInvalidContentRange(t *testing.T) {
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "tenant_access_token"):
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"tenant_access_token": "tenant-token",
"expire": 7200,
}), nil
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_bad/resources/file_bad"):
return shortcutRawResponse(http.StatusPartialContent, []byte("bad"), http.Header{
"Content-Type": []string{"application/octet-stream"},
"Content-Range": []string{"bytes 0-2/not-a-number"},
}), nil
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
cmdutil.TestChdir(t, t.TempDir())
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_bad", "file_bad", "file", "out.bin")
if err == nil || !strings.Contains(err.Error(), "invalid Content-Range header") {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
}
func TestDownloadIMResourceToPathRangeChunkFailureCleansOutput(t *testing.T) {
payload := bytes.Repeat([]byte("range-download-"), int((probeChunkSize+1024)/15)+1)
payload = payload[:probeChunkSize+1024]
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_miderr/resources/file_miderr"):
rangeHeader := req.Header.Get("Range")
if rangeHeader == fmt.Sprintf("bytes=0-%d", probeChunkSize-1) {
return shortcutRawResponse(http.StatusPartialContent, payload[:probeChunkSize], http.Header{
"Content-Type": []string{"application/octet-stream"},
"Content-Range": []string{fmt.Sprintf("bytes 0-%d/%d", probeChunkSize-1, len(payload))},
}), nil
}
return shortcutRawResponse(http.StatusInternalServerError, []byte("chunk failed"), http.Header{"Content-Type": []string{"text/plain"}}), nil
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
cmdutil.TestChdir(t, t.TempDir())
target := "out.bin"
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_miderr", "file_miderr", "file", target)
if err == nil || !strings.Contains(err.Error(), "HTTP 500: chunk failed") {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
t.Fatalf("output file exists after failed download, stat error = %v", statErr)
}
}
func TestDownloadIMResourceToPathRangeOverflowCleansOutput(t *testing.T) {
payload := []byte("overflow-payload")
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_overflow/resources/file_overflow"):
return shortcutRawResponse(http.StatusPartialContent, payload, http.Header{
"Content-Type": []string{"application/octet-stream"},
"Content-Range": []string{"bytes 0-3/4"},
}), nil
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
cmdutil.TestChdir(t, t.TempDir())
target := "out.bin"
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_overflow", "file_overflow", "file", target)
if err == nil || !strings.Contains(err.Error(), "chunk overflow") {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
t.Fatalf("output file exists after overflow, stat error = %v", statErr)
}
}
func TestDownloadIMResourceToPathRangeShortChunkSizeMismatch(t *testing.T) {
payload := bytes.Repeat([]byte("range-download-"), int((probeChunkSize+1024)/15)+1)
payload = payload[:probeChunkSize+1024]
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_short/resources/file_short"):
rangeHeader := req.Header.Get("Range")
start, end, err := parseRangeHeader(rangeHeader, int64(len(payload)))
if err != nil {
return nil, err
}
body := payload[start : end+1]
if start == probeChunkSize {
body = body[:len(body)-10]
}
return shortcutRawResponse(http.StatusPartialContent, body, http.Header{
"Content-Type": []string{"application/octet-stream"},
"Content-Range": []string{fmt.Sprintf("bytes %d-%d/%d", start, end, len(payload))},
}), nil
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
cmdutil.TestChdir(t, t.TempDir())
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_short", "file_short", "file", "out.bin")
if err == nil || !strings.Contains(err.Error(), "file size mismatch") {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
}
func parseRangeHeader(header string, totalSize int64) (int64, int64, error) {
if !strings.HasPrefix(header, "bytes=") {
return 0, 0, fmt.Errorf("unexpected range header: %q", header)
}
parts := strings.SplitN(strings.TrimPrefix(header, "bytes="), "-", 2)
if len(parts) != 2 {
return 0, 0, fmt.Errorf("unexpected range header: %q", header)
}
start, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return 0, 0, fmt.Errorf("parse start: %w", err)
}
end, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return 0, 0, fmt.Errorf("parse end: %w", err)
}
if start < 0 || end < start || start >= totalSize {
return 0, 0, fmt.Errorf("invalid range bounds: %d-%d for size %d", start, end, totalSize)
}
if end >= totalSize {
end = totalSize - 1
}
return start, end, nil
}
func TestUploadImageToIMSuccess(t *testing.T) {
var gotBody string
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {

View File

@@ -599,6 +599,44 @@ func TestDownloadIMResourceToPathHTTPClientError(t *testing.T) {
}
}
func TestParseTotalSize(t *testing.T) {
tests := []struct {
name string
contentRange string
want int64
wantErr string
}{
{name: "normal", contentRange: "bytes 0-131071/104857600", want: 104857600},
{name: "single probe chunk", contentRange: "bytes 0-131071/131072", want: 131072},
{name: "single small chunk", contentRange: "bytes 0-15/16", want: 16},
{name: "empty", contentRange: "", wantErr: "content-range is empty"},
{name: "invalid prefix", contentRange: "items 0-15/16", wantErr: `unsupported content-range: "items 0-15/16"`},
{name: "missing total", contentRange: "bytes 0-15/", wantErr: `unsupported content-range: "bytes 0-15/"`},
{name: "wildcard", contentRange: "bytes */16", wantErr: `unsupported content-range: "bytes */16"`},
{name: "unknown total size", contentRange: "bytes 0-99/*", wantErr: `unknown total size in content-range: "bytes 0-99/*"`},
{name: "invalid total", contentRange: "bytes 0-15/not-a-number", wantErr: "parse total size:"},
{name: "zero total size", contentRange: "bytes 0-0/0", wantErr: "invalid total size: 0"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseTotalSize(tt.contentRange)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("parseTotalSize() error = %v, want substring %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("parseTotalSize() unexpected error = %v", err)
}
if got != tt.want {
t.Fatalf("parseTotalSize() = %d, want %d", got, tt.want)
}
})
}
}
func TestShortcuts(t *testing.T) {
var commands []string
for _, shortcut := range Shortcuts() {

View File

@@ -28,7 +28,7 @@ var ImChatMessageList = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
{Name: "chat-id", Desc: "(required, mutually exclusive with --user-id) chat ID (oc_xxx)"},
{Name: "user-id", Desc: "(required, mutually exclusive with --chat-id) user open_id (ou_xxx)"},
{Name: "user-id", Desc: "(required, mutually exclusive with --chat-id; user identity only) user open_id (ou_xxx)"},
{Name: "start", Desc: "start time (ISO 8601)"},
{Name: "end", Desc: "end time (ISO 8601)"},
{Name: "sort", Default: "desc", Desc: "sort order", Enum: []string{"asc", "desc"}},
@@ -57,11 +57,21 @@ var ImChatMessageList = common.Shortcut{
return d.GET("/open-apis/im/v1/messages").Params(dryParams)
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := common.ExactlyOne(runtime, "chat-id", "user-id"); err != nil {
if runtime.Str("chat-id") == "" && runtime.Str("user-id") == "" {
return common.FlagErrorf("specify at least one of --chat-id or --user-id")
// Under bot identity, --user-id is not supported; require --chat-id only.
if runtime.IsBot() {
if runtime.Str("user-id") != "" {
return common.FlagErrorf("--user-id requires user identity (--as user); use --chat-id when calling with bot identity")
}
if runtime.Str("chat-id") == "" {
return common.FlagErrorf("specify --chat-id (bot identity does not support --user-id)")
}
} else {
if err := common.ExactlyOne(runtime, "chat-id", "user-id"); err != nil {
if runtime.Str("chat-id") == "" && runtime.Str("user-id") == "" {
return common.FlagErrorf("specify at least one of --chat-id or --user-id")
}
return err
}
return err
}
// Validate ID formats

View File

@@ -9,6 +9,7 @@ import (
"io"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
@@ -67,6 +68,9 @@ var ImMessagesResourcesDownload = common.Shortcut{
if err != nil {
return output.ErrValidation("invalid output path: %s", err)
}
if _, err := runtime.ResolveSavePath(relPath); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
}
finalPath, sizeBytes, err := downloadIMResourceToPath(ctx, runtime, messageId, fileKey, fileType, relPath)
if err != nil {
@@ -102,7 +106,13 @@ func normalizeDownloadOutputPath(fileKey, outputPath string) (string, error) {
return outputPath, nil
}
const defaultIMResourceDownloadTimeout = 120 * time.Second
const (
defaultIMResourceDownloadTimeout = 120 * time.Second
probeChunkSize = int64(128 * 1024)
normalChunkSize = int64(8 * 1024 * 1024)
imDownloadRequestRetries = 2
imDownloadRetryDelay = 300 * time.Millisecond
)
var imMimeToExt = map[string]string{
"image/png": ".png",
@@ -135,10 +145,199 @@ var imMimeToExt = map[string]string{
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
}
func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContext, messageID, fileKey, fileType, safePath string) (string, int64, error) {
type rangeChunkReader struct {
ctx context.Context
runtime *common.RuntimeContext
messageID string
fileKey string
fileType string
totalSize int64
delivered int64
current io.ReadCloser
nextOffset int64
}
func newRangeChunkReader(
ctx context.Context,
runtime *common.RuntimeContext,
messageID, fileKey, fileType string,
probeBody io.ReadCloser,
totalSize int64,
) *rangeChunkReader {
return &rangeChunkReader{
ctx: ctx,
runtime: runtime,
messageID: messageID,
fileKey: fileKey,
fileType: fileType,
totalSize: totalSize,
current: probeBody,
nextOffset: probeChunkSize,
}
}
func (r *rangeChunkReader) Read(p []byte) (int, error) {
for {
if r.current != nil {
n, err := r.current.Read(p)
r.delivered += int64(n)
if r.delivered > r.totalSize {
if err == io.EOF {
closeErr := r.current.Close()
r.current = nil
if closeErr != nil {
return 0, closeErr
}
}
return 0, output.ErrNetwork("chunk overflow: delivered %d, expected %d", r.delivered, r.totalSize)
}
switch err {
case nil:
return n, nil
case io.EOF:
closeErr := r.current.Close()
r.current = nil
if closeErr != nil {
return n, closeErr
}
if r.delivered == r.totalSize {
if n > 0 {
return n, nil
}
return 0, io.EOF
}
if n > 0 {
return n, nil
}
default:
return n, err
}
}
if r.nextOffset >= r.totalSize {
if r.delivered == r.totalSize {
return 0, io.EOF
}
return 0, output.ErrNetwork("file size mismatch: expected %d, got %d", r.totalSize, r.delivered)
}
end := min(r.nextOffset+normalChunkSize-1, r.totalSize-1)
resp, err := doIMResourceDownloadRequest(r.ctx, r.runtime, r.messageID, r.fileKey, r.fileType, map[string]string{
"Range": fmt.Sprintf("bytes=%d-%d", r.nextOffset, end),
})
if err != nil {
return 0, err
}
if resp.StatusCode >= 400 {
defer resp.Body.Close()
return 0, downloadResponseError(resp)
}
if resp.StatusCode != http.StatusPartialContent {
resp.Body.Close()
return 0, output.ErrNetwork("unexpected status code: %d", resp.StatusCode)
}
r.current = resp.Body
r.nextOffset = end + 1
}
}
func (r *rangeChunkReader) Close() error {
if r.current == nil {
return nil
}
err := r.current.Close()
r.current = nil
return err
}
func initialIMResourceDownloadHeaders(fileType string) map[string]string {
if fileType != "file" {
return nil
}
return map[string]string{
"Range": fmt.Sprintf("bytes=0-%d", probeChunkSize-1),
}
}
func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContext, messageID, fileKey, fileType, outputPath string) (string, int64, error) {
downloadResp, err := doIMResourceDownloadRequest(ctx, runtime, messageID, fileKey, fileType, initialIMResourceDownloadHeaders(fileType))
if err != nil {
return "", 0, err
}
if downloadResp.StatusCode >= 400 {
defer downloadResp.Body.Close()
return "", 0, downloadResponseError(downloadResp)
}
finalPath := resolveIMResourceDownloadPath(outputPath, downloadResp.Header.Get("Content-Type"))
var (
body io.ReadCloser
sizeBytes int64
)
switch downloadResp.StatusCode {
case http.StatusPartialContent:
totalSize, err := parseTotalSize(downloadResp.Header.Get("Content-Range"))
if err != nil {
downloadResp.Body.Close()
return "", 0, output.ErrNetwork("invalid Content-Range header on range response: %s", err)
}
body = newRangeChunkReader(ctx, runtime, messageID, fileKey, fileType, downloadResp.Body, totalSize)
sizeBytes = totalSize
case http.StatusOK:
body = downloadResp.Body
sizeBytes = downloadResp.ContentLength
default:
downloadResp.Body.Close()
return "", 0, output.ErrNetwork("unexpected status code: %d", downloadResp.StatusCode)
}
defer body.Close()
result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
ContentType: downloadResp.Header.Get("Content-Type"),
ContentLength: sizeBytes,
}, body)
if err != nil {
return "", 0, common.WrapSaveErrorByCategory(err, "api_error")
}
if sizeBytes >= 0 && result.Size() != sizeBytes {
return "", 0, output.ErrNetwork("file size mismatch: expected %d, got %d", sizeBytes, result.Size())
}
savedPath, resolveErr := runtime.ResolveSavePath(finalPath)
if resolveErr != nil || savedPath == "" {
savedPath = finalPath
}
return savedPath, result.Size(), nil
}
func resolveIMResourceDownloadPath(safePath, contentType string) string {
if filepath.Ext(safePath) != "" {
return safePath
}
mimeType := strings.Split(contentType, ";")[0]
mimeType = strings.TrimSpace(mimeType)
if ext, ok := imMimeToExt[mimeType]; ok {
return safePath + ext
}
return safePath
}
func doIMResourceDownloadRequest(ctx context.Context, runtime *common.RuntimeContext, messageID, fileKey, fileType string, headers map[string]string) (*http.Response, error) {
query := larkcore.QueryParams{}
query.Set("type", fileType)
downloadResp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
headerValues := make(http.Header, len(headers))
for key, value := range headers {
headerValues.Set(key, value)
}
req := &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/im/v1/messages/:message_id/resources/:file_key",
PathParams: larkcore.PathParams{
@@ -146,44 +345,73 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex
"file_key": fileKey,
},
QueryParams: query,
}, client.WithTimeout(defaultIMResourceDownloadTimeout))
if err != nil {
return "", 0, err
}
defer downloadResp.Body.Close()
if downloadResp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(downloadResp.Body, 4096))
if len(body) > 0 {
return "", 0, output.ErrNetwork("download failed: HTTP %d: %s", downloadResp.StatusCode, strings.TrimSpace(string(body)))
var lastErr error
for attempt := 0; attempt <= imDownloadRequestRetries; attempt++ {
resp, err := runtime.DoAPIStream(ctx, req, client.WithTimeout(defaultIMResourceDownloadTimeout), client.WithHeaders(headerValues))
if err == nil {
return resp, nil
}
return "", 0, output.ErrNetwork("download failed: HTTP %d", downloadResp.StatusCode)
}
// Auto-detect extension from Content-Type if missing
finalPath := safePath
if filepath.Ext(safePath) == "" {
contentType := downloadResp.Header.Get("Content-Type")
mimeType := strings.Split(contentType, ";")[0]
mimeType = strings.TrimSpace(mimeType)
if ext, ok := imMimeToExt[mimeType]; ok {
finalPath = safePath + ext
if ctx.Err() != nil {
return nil, ctx.Err()
}
lastErr = err
if attempt == imDownloadRequestRetries {
break
}
sleepIMDownloadRetry(ctx, attempt)
}
result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
ContentType: downloadResp.Header.Get("Content-Type"),
ContentLength: downloadResp.ContentLength,
}, downloadResp.Body)
if err != nil {
return "", 0, output.Errorf(output.ExitInternal, "api_error", "%s",
common.WrapSaveError(err, "unsafe output path", "cannot create parent directory", "cannot create file"))
if lastErr != nil {
return nil, lastErr
}
savedPath, resolveErr := runtime.ResolveSavePath(finalPath)
if resolveErr != nil {
// Save succeeded — file is on disk. Fall back to the relative path
// rather than returning an error for a successfully written file.
savedPath = finalPath
}
return savedPath, result.Size(), nil
return nil, output.ErrNetwork("download request failed")
}
func sleepIMDownloadRetry(ctx context.Context, attempt int) {
delay := imDownloadRetryDelay * (1 << uint(attempt))
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
case <-timer.C:
}
}
func downloadResponseError(resp *http.Response) error {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if len(body) > 0 {
return output.ErrNetwork("download failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return output.ErrNetwork("download failed: HTTP %d", resp.StatusCode)
}
func parseTotalSize(contentRange string) (int64, error) {
contentRange = strings.TrimSpace(contentRange)
if contentRange == "" {
return 0, fmt.Errorf("content-range is empty")
}
if !strings.HasPrefix(contentRange, "bytes ") {
return 0, fmt.Errorf("unsupported content-range: %q", contentRange)
}
parts := strings.SplitN(strings.TrimPrefix(contentRange, "bytes "), "/", 2)
if len(parts) != 2 || parts[1] == "" {
return 0, fmt.Errorf("unsupported content-range: %q", contentRange)
}
if parts[0] == "*" {
return 0, fmt.Errorf("unsupported content-range: %q", contentRange)
}
if parts[1] == "*" {
return 0, fmt.Errorf("unknown total size in content-range: %q", contentRange)
}
totalSize, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return 0, fmt.Errorf("parse total size: %w", err)
}
if totalSize <= 0 {
return 0, fmt.Errorf("invalid total size: %d", totalSize)
}
return totalSize, nil
}

View File

@@ -95,7 +95,7 @@ var MailWatch = common.Shortcut{
Command: "+watch",
Description: "Watch for incoming mail events via WebSocket (requires scope mail:event and bot event mail.user_mailbox.event.message_received_v1 added). Run with --print-output-schema to see per-format field reference before parsing output.",
Risk: "read",
Scopes: []string{"mail:event", "mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
Scopes: []string{"mail:event", "mail:user_mailbox.event.mail_address:read", "mail:user_mailbox:readonly", "mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "format", Default: "data", Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
@@ -192,36 +192,23 @@ var MailWatch = common.Shortcut{
msgFormat := runtime.Str("msg-format")
outputDir := runtime.Str("output-dir")
if outputDir != "" {
if outputDir == "~" || strings.HasPrefix(outputDir, "~/") {
home, err := vfs.UserHomeDir()
if err != nil {
return fmt.Errorf("cannot expand ~: %w", err)
}
if outputDir == "~" {
outputDir = home
} else {
outputDir = filepath.Join(home, outputDir[2:])
}
} else if filepath.IsAbs(outputDir) {
outputDir = filepath.Clean(outputDir)
} else {
safePath, err := validate.SafeOutputPath(outputDir)
if err != nil {
return err
}
outputDir = safePath
// Reject all tilde-prefixed paths — SafeOutputPath treats "~/x" as a
// literal relative path (creating a directory named "~"), which is
// confusing. This also covers ~user/path forms.
if strings.HasPrefix(outputDir, "~") {
return output.ErrValidation("--output-dir does not support ~ expansion; use a relative path like ./output instead")
}
// Resolve symlinks on the output directory so all writes use the real
// filesystem path. This prevents a symlink from redirecting writes to
// an unintended location (TOCTOU mitigation).
// Enforce CWD containment: reject absolute paths, path traversal,
// and symlink escapes. SafeOutputPath returns a resolved absolute path
// under CWD, preventing writes to arbitrary system directories.
safePath, err := validate.SafeOutputPath(outputDir)
if err != nil {
return err
}
outputDir = safePath
if err := vfs.MkdirAll(outputDir, 0700); err != nil {
return fmt.Errorf("cannot create output directory %q: %w", outputDir, err)
}
resolved, err := filepath.EvalSymlinks(outputDir)
if err != nil {
return fmt.Errorf("cannot resolve output directory: %w", err)
}
outputDir = resolved
}
labelIDsInput := runtime.Str("label-ids")
folderIDsInput := runtime.Str("folder-ids")

View File

@@ -0,0 +1,239 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
func filterViewBasePath(token, sheetID string) string {
return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/filter_views",
validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID))
}
func filterViewItemPath(token, sheetID, filterViewID string) string {
return fmt.Sprintf("%s/%s", filterViewBasePath(token, sheetID), validate.EncodePathSegment(filterViewID))
}
func validateFilterViewToken(runtime *common.RuntimeContext) (string, error) {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
}
return token, nil
}
var SheetCreateFilterView = common.Shortcut{
Service: "sheets",
Command: "+create-filter-view",
Description: "Create a filter view",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "range", Desc: "filter range (e.g. sheetId!A1:H14)", Required: true},
{Name: "filter-view-name", Desc: "display name (max 100 chars)"},
{Name: "filter-view-id", Desc: "custom 10-char alphanumeric ID (auto-generated if omitted)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFilterViewToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
body := map[string]interface{}{"range": runtime.Str("range")}
if s := runtime.Str("filter-view-name"); s != "" {
body["filter_view_name"] = s
}
if s := runtime.Str("filter-view-id"); s != "" {
body["filter_view_id"] = s
}
return common.NewDryRunAPI().
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views").
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
body := map[string]interface{}{"range": runtime.Str("range")}
if s := runtime.Str("filter-view-name"); s != "" {
body["filter_view_name"] = s
}
if s := runtime.Str("filter-view-id"); s != "" {
body["filter_view_id"] = s
}
data, err := runtime.CallAPI("POST", filterViewBasePath(token, runtime.Str("sheet-id")), nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetUpdateFilterView = common.Shortcut{
Service: "sheets",
Command: "+update-filter-view",
Description: "Update a filter view",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
{Name: "range", Desc: "new filter range"},
{Name: "filter-view-name", Desc: "new display name (max 100 chars)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateFilterViewToken(runtime); err != nil {
return err
}
if !runtime.Cmd.Flags().Changed("range") &&
!runtime.Cmd.Flags().Changed("filter-view-name") {
return common.FlagErrorf("specify at least one of --range or --filter-view-name")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
body := map[string]interface{}{}
if s := runtime.Str("range"); s != "" {
body["range"] = s
}
if s := runtime.Str("filter-view-name"); s != "" {
body["filter_view_name"] = s
}
return common.NewDryRunAPI().
PATCH("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id").
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
body := map[string]interface{}{}
if s := runtime.Str("range"); s != "" {
body["range"] = s
}
if s := runtime.Str("filter-view-name"); s != "" {
body["filter_view_name"] = s
}
data, err := runtime.CallAPI("PATCH", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetListFilterViews = common.Shortcut{
Service: "sheets",
Command: "+list-filter-views",
Description: "List all filter views in a sheet",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFilterViewToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/query").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("GET", filterViewBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetGetFilterView = common.Shortcut{
Service: "sheets",
Command: "+get-filter-view",
Description: "Get a filter view by ID",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFilterViewToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("GET", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetDeleteFilterView = common.Shortcut{
Service: "sheets",
Command: "+delete-filter-view",
Description: "Delete a filter view",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFilterViewToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
return common.NewDryRunAPI().
DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("DELETE", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,261 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
func filterViewConditionBasePath(token, sheetID, filterViewID string) string {
return fmt.Sprintf("%s/conditions", filterViewItemPath(token, sheetID, filterViewID))
}
func filterViewConditionItemPath(token, sheetID, filterViewID, conditionID string) string {
return fmt.Sprintf("%s/%s", filterViewConditionBasePath(token, sheetID, filterViewID), validate.EncodePathSegment(conditionID))
}
var SheetCreateFilterViewCondition = common.Shortcut{
Service: "sheets",
Command: "+create-filter-view-condition",
Description: "Create a filter condition on a filter view",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
{Name: "filter-type", Desc: "filter type: hiddenValue, number, text, color", Required: true},
{Name: "compare-type", Desc: "comparison operator (e.g. less, beginsWith, between)"},
{Name: "expected", Desc: "filter values JSON array (e.g. [\"6\"])", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateFilterViewToken(runtime); err != nil {
return err
}
return validateExpectedFlag(runtime.Str("expected"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
body := buildConditionBody(runtime, true)
return common.NewDryRunAPI().
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions").
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
body := buildConditionBody(runtime, true)
data, err := runtime.CallAPI("POST", filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetUpdateFilterViewCondition = common.Shortcut{
Service: "sheets",
Command: "+update-filter-view-condition",
Description: "Update a filter condition on a filter view",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
{Name: "filter-type", Desc: "filter type: hiddenValue, number, text, color"},
{Name: "compare-type", Desc: "comparison operator"},
{Name: "expected", Desc: "filter values JSON array"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateFilterViewToken(runtime); err != nil {
return err
}
if !runtime.Cmd.Flags().Changed("filter-type") &&
!runtime.Cmd.Flags().Changed("compare-type") &&
!runtime.Cmd.Flags().Changed("expected") {
return common.FlagErrorf("specify at least one of --filter-type, --compare-type, or --expected")
}
if s := runtime.Str("expected"); s != "" {
return validateExpectedFlag(s)
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
body := buildConditionBody(runtime, false)
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/:condition_id").
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).
Set("filter_view_id", runtime.Str("filter-view-id")).Set("condition_id", runtime.Str("condition-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
body := buildConditionBody(runtime, false)
data, err := runtime.CallAPI("PUT",
filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")),
nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetListFilterViewConditions = common.Shortcut{
Service: "sheets",
Command: "+list-filter-view-conditions",
Description: "List all filter conditions of a filter view",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFilterViewToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/query").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("GET",
filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"))+"/query",
nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetGetFilterViewCondition = common.Shortcut{
Service: "sheets",
Command: "+get-filter-view-condition",
Description: "Get a filter condition by column",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFilterViewToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/:condition_id").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).
Set("filter_view_id", runtime.Str("filter-view-id")).Set("condition_id", runtime.Str("condition-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("GET",
filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")),
nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetDeleteFilterViewCondition = common.Shortcut{
Service: "sheets",
Command: "+delete-filter-view-condition",
Description: "Delete a filter condition from a filter view",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFilterViewToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
return common.NewDryRunAPI().
DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/:condition_id").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).
Set("filter_view_id", runtime.Str("filter-view-id")).Set("condition_id", runtime.Str("condition-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("DELETE",
filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")),
nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// validateExpectedFlag checks that --expected is a valid JSON array.
func validateExpectedFlag(s string) error {
if s == "" {
return nil
}
var arr []interface{}
if err := json.Unmarshal([]byte(s), &arr); err != nil {
return fmt.Errorf("--expected must be a JSON array (e.g. [\"6\"]), got: %s", s)
}
return nil
}
// buildConditionBody constructs the request body for condition create/update.
func buildConditionBody(runtime *common.RuntimeContext, includeConditionID bool) map[string]interface{} {
body := map[string]interface{}{}
if includeConditionID {
body["condition_id"] = runtime.Str("condition-id")
}
if s := runtime.Str("filter-type"); s != "" {
body["filter_type"] = s
}
if s := runtime.Str("compare-type"); s != "" {
body["compare_type"] = s
}
if s := runtime.Str("expected"); s != "" {
var arr []interface{}
// Validate already ensures this is a valid JSON array.
_ = json.Unmarshal([]byte(s), &arr)
body["expected"] = arr
}
return body
}

View File

@@ -0,0 +1,628 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
// ── CreateFilterView ─────────────────────────────────────────────────────────
func TestCreateFilterViewValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "sheet-id": "s1", "range": "s1!A1:H14",
"filter-view-name": "", "filter-view-id": "",
}, nil)
err := SheetCreateFilterView.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestCreateFilterViewValidateSuccess(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "range": "s1!A1:H14",
"filter-view-name": "", "filter-view-id": "",
}, nil)
if err := SheetCreateFilterView.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCreateFilterViewDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "range": "sheet1!A1:H14",
"filter-view-name": "my view", "filter-view-id": "",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetCreateFilterView.DryRun(context.Background(), rt))
if !strings.Contains(got, `filter_views`) {
t.Fatalf("DryRun URL missing filter_views: %s", got)
}
if !strings.Contains(got, `"filter_view_name":"my view"`) {
t.Fatalf("DryRun missing name: %s", got)
}
}
func TestCreateFilterViewExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"filter_view": map[string]interface{}{"filter_view_id": "pH9hbVcCXA", "range": "sheet1!A1:H14"},
}},
})
err := mountAndRunSheets(t, SheetCreateFilterView, []string{
"+create-filter-view", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--range", "sheet1!A1:H14", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "filter_view_id") {
t.Fatalf("stdout missing filter_view_id: %s", stdout.String())
}
}
func TestCreateFilterViewExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetCreateFilterView, []string{
"+create-filter-view", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--range", "sheet1!A1:H14", "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}
// ── UpdateFilterView ─────────────────────────────────────────────────────────
func TestUpdateFilterViewDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
"filter-view-id": "pH9hbVcCXA", "range": "sheet1!A1:J20", "filter-view-name": "",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetUpdateFilterView.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"PATCH"`) {
t.Fatalf("DryRun should use PATCH: %s", got)
}
if !strings.Contains(got, `pH9hbVcCXA`) {
t.Fatalf("DryRun missing filter_view_id: %s", got)
}
}
func TestUpdateFilterViewExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"filter_view": map[string]interface{}{"filter_view_id": "fv123", "range": "sheet1!A1:J20"},
}},
})
err := mountAndRunSheets(t, SheetUpdateFilterView, []string{
"+update-filter-view", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv123", "--range", "sheet1!A1:J20", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestUpdateFilterViewRejectsNoFields(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetUpdateFilterView, []string{
"+update-filter-view", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
"--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected validation error when no update fields provided, got nil")
}
if !strings.Contains(err.Error(), "at least one") {
t.Fatalf("unexpected error message: %v", err)
}
}
// ── ListFilterViews ──────────────────────────────────────────────────────────
func TestListFilterViewsDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetListFilterViews.DryRun(context.Background(), rt))
if !strings.Contains(got, `filter_views/query`) {
t.Fatalf("DryRun URL missing query: %s", got)
}
}
func TestListFilterViewsExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/query",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"filter_view_id": "fv1"}},
}},
})
err := mountAndRunSheets(t, SheetListFilterViews, []string{
"+list-filter-views", "--spreadsheet-token", "shtTOKEN", "--sheet-id", "sheet1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "fv1") {
t.Fatalf("stdout missing fv1: %s", stdout.String())
}
}
// ── GetFilterView ────────────────────────────────────────────────────────────
func TestGetFilterViewDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv123",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetGetFilterView.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"GET"`) {
t.Fatalf("DryRun should use GET: %s", got)
}
if !strings.Contains(got, `fv123`) {
t.Fatalf("DryRun missing filter_view_id: %s", got)
}
}
func TestGetFilterViewExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"filter_view": map[string]interface{}{"filter_view_id": "fv123"},
}},
})
err := mountAndRunSheets(t, SheetGetFilterView, []string{
"+get-filter-view", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv123", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── DeleteFilterView ─────────────────────────────────────────────────────────
func TestDeleteFilterViewDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv123",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetDeleteFilterView.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"DELETE"`) {
t.Fatalf("DryRun should use DELETE: %s", got)
}
}
func TestDeleteFilterViewExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetDeleteFilterView, []string{
"+delete-filter-view", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv123", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── CreateFilterViewCondition ────────────────────────────────────────────────
func TestCreateFilterViewConditionValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "sheet-id": "s1", "filter-view-id": "fv1",
"condition-id": "E", "filter-type": "number", "compare-type": "less", "expected": `["6"]`,
}, nil)
err := SheetCreateFilterViewCondition.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestCreateFilterViewConditionDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv1",
"condition-id": "E", "filter-type": "number", "compare-type": "less", "expected": `["6"]`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetCreateFilterViewCondition.DryRun(context.Background(), rt))
if !strings.Contains(got, `conditions`) {
t.Fatalf("DryRun URL missing conditions: %s", got)
}
if !strings.Contains(got, `"condition_id":"E"`) {
t.Fatalf("DryRun missing condition_id: %s", got)
}
if !strings.Contains(got, `"filter_type":"number"`) {
t.Fatalf("DryRun missing filter_type: %s", got)
}
}
func TestCreateFilterViewConditionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"condition": map[string]interface{}{"condition_id": "E", "filter_type": "number"},
}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{
"+create-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
"--condition-id", "E", "--filter-type", "number", "--compare-type", "less",
"--expected", `["6"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
if body["condition_id"] != "E" {
t.Fatalf("unexpected condition_id: %v", body["condition_id"])
}
}
// ── UpdateFilterViewCondition ────────────────────────────────────────────────
func TestUpdateFilterViewConditionDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv1",
"condition-id": "E", "filter-type": "number", "compare-type": "between", "expected": `["2","10"]`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetUpdateFilterViewCondition.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"PUT"`) {
t.Fatalf("DryRun should use PUT: %s", got)
}
if !strings.Contains(got, `"compare_type":"between"`) {
t.Fatalf("DryRun missing compare_type: %s", got)
}
}
func TestUpdateFilterViewConditionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions/E",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"condition": map[string]interface{}{"condition_id": "E"},
}},
})
err := mountAndRunSheets(t, SheetUpdateFilterViewCondition, []string{
"+update-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E",
"--filter-type", "number", "--compare-type", "between", "--expected", `["2","10"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestUpdateFilterViewConditionRejectsNoFields(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetUpdateFilterViewCondition, []string{
"+update-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E",
"--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected validation error when no update fields provided, got nil")
}
if !strings.Contains(err.Error(), "at least one") {
t.Fatalf("unexpected error message: %v", err)
}
}
// ── ListFilterViewConditions ─────────────────────────────────────────────────
func TestListFilterViewConditionsDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv1",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetListFilterViewConditions.DryRun(context.Background(), rt))
if !strings.Contains(got, `conditions/query`) {
t.Fatalf("DryRun URL missing conditions/query: %s", got)
}
}
func TestListFilterViewConditionsExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions/query",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"condition_id": "E"}},
}},
})
err := mountAndRunSheets(t, SheetListFilterViewConditions, []string{
"+list-filter-view-conditions", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── GetFilterViewCondition ───────────────────────────────────────────────────
func TestGetFilterViewConditionDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
"filter-view-id": "fv1", "condition-id": "E",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetGetFilterViewCondition.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"GET"`) {
t.Fatalf("DryRun should use GET: %s", got)
}
}
func TestGetFilterViewConditionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions/E",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"condition": map[string]interface{}{"condition_id": "E", "filter_type": "number"},
}},
})
err := mountAndRunSheets(t, SheetGetFilterViewCondition, []string{
"+get-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── DeleteFilterViewCondition ────────────────────────────────────────────────
func TestDeleteFilterViewConditionDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
"filter-view-id": "fv1", "condition-id": "E",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetDeleteFilterViewCondition.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"DELETE"`) {
t.Fatalf("DryRun should use DELETE: %s", got)
}
}
func TestDeleteFilterViewConditionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions/E",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetDeleteFilterViewCondition, []string{
"+delete-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── URL flag coverage ────────────────────────────────────────────────────────
func TestCreateFilterViewWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"filter_view": map[string]interface{}{"filter_view_id": "fv1"},
}},
})
err := mountAndRunSheets(t, SheetCreateFilterView, []string{
"+create-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--range", "sheet1!A1:H14", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestListFilterViewsWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/query",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"items": []interface{}{}}},
})
err := mountAndRunSheets(t, SheetListFilterViews, []string{
"+list-filter-views", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestGetFilterViewWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"filter_view": map[string]interface{}{"filter_view_id": "fv1"},
}},
})
err := mountAndRunSheets(t, SheetGetFilterView, []string{
"+get-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestUpdateFilterViewWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"filter_view": map[string]interface{}{"filter_view_id": "fv1"},
}},
})
err := mountAndRunSheets(t, SheetUpdateFilterView, []string{
"+update-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--range", "sheet1!A1:J20", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDeleteFilterViewWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetDeleteFilterView, []string{
"+delete-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCreateFilterViewConditionWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"condition": map[string]interface{}{"condition_id": "E"},
}},
})
err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{
"+create-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
"--condition-id", "E", "--filter-type", "number", "--compare-type", "less",
"--expected", `["6"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestUpdateFilterViewConditionWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions/E",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"condition": map[string]interface{}{"condition_id": "E"},
}},
})
err := mountAndRunSheets(t, SheetUpdateFilterViewCondition, []string{
"+update-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E",
"--filter-type", "number", "--expected", `["5"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestListFilterViewConditionsWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions/query",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"items": []interface{}{}}},
})
err := mountAndRunSheets(t, SheetListFilterViewConditions, []string{
"+list-filter-view-conditions", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestGetFilterViewConditionWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions/E",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"condition": map[string]interface{}{"condition_id": "E"},
}},
})
err := mountAndRunSheets(t, SheetGetFilterViewCondition, []string{
"+get-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDeleteFilterViewConditionWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions/E",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetDeleteFilterViewCondition, []string{
"+delete-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── --expected validation rejects non-array input ────────────────────────────
func TestCreateFilterViewConditionRejectsNonArrayExpected(t *testing.T) {
cases := []struct {
name string
expected string
}{
{"plain string", "hello"},
{"JSON object", `{"key":"val"}`},
{"JSON number", "42"},
{"JSON string", `"hello"`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{
"+create-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
"--condition-id", "A", "--filter-type", "text", "--compare-type", "contains",
"--expected", tc.expected, "--as", "user",
}, f, stdout)
if err == nil {
t.Fatalf("expected validation error for --expected=%q, got nil", tc.expected)
}
if !strings.Contains(err.Error(), "--expected must be a JSON array") {
t.Fatalf("unexpected error message: %v", err)
}
})
}
}

View File

@@ -26,5 +26,15 @@ func Shortcuts() []common.Shortcut {
SheetUpdateDimension,
SheetMoveDimension,
SheetDeleteDimension,
SheetCreateFilterView,
SheetUpdateFilterView,
SheetListFilterViews,
SheetGetFilterView,
SheetDeleteFilterView,
SheetCreateFilterViewCondition,
SheetUpdateFilterViewCondition,
SheetListFilterViewConditions,
SheetGetFilterViewCondition,
SheetDeleteFilterViewCondition,
}
}

View File

@@ -30,6 +30,7 @@ var AddTaskToTasklist = common.Shortcut{
Flags: []common.Flag{
{Name: "tasklist-id", Desc: "tasklist id", Required: true},
{Name: "task-id", Desc: "task id (comma-separated for multiple)", Required: true},
{Name: "section-guid", Desc: "section guid"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -40,6 +41,10 @@ var AddTaskToTasklist = common.Shortcut{
"tasklist_guid": extractTasklistGuid(runtime.Str("tasklist-id")),
}
if sectionGuid := strings.TrimSpace(runtime.Str("section-guid")); sectionGuid != "" {
body["section_guid"] = sectionGuid
}
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasks/" + taskId + "/add_tasklist").
Params(map[string]interface{}{"user_id_type": "open_id"}).
@@ -57,6 +62,10 @@ var AddTaskToTasklist = common.Shortcut{
"tasklist_guid": tasklistGuid,
}
if sectionGuid := strings.TrimSpace(runtime.Str("section-guid")); sectionGuid != "" {
body["section_guid"] = sectionGuid
}
var successful []map[string]interface{}
var failed []map[string]interface{}

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAddTaskToTasklist_Success(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-1/add_tasklist",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{
"guid": "task-1",
},
},
},
})
s := AddTaskToTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-task-add", "--tasklist-id", "tl-123", "--task-id", "task-1", "--section-guid", "sec-456", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"tasklist_guid":"tl-123"`) && !strings.Contains(out, `"tasklist_guid": "tl-123"`) {
t.Errorf("expected tasklist_guid in output, got: %s", out)
}
}

View File

@@ -8,6 +8,7 @@ import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all wiki shortcuts.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
WikiMove,
WikiNodeCreate,
}
}

671
shortcuts/wiki/wiki_move.go Normal file
View File

@@ -0,0 +1,671 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"errors"
"fmt"
"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"
)
var (
wikiMovePollAttempts = 30
wikiMovePollInterval = 2 * time.Second
)
const (
wikiMoveModeNode = "node"
wikiMoveModeDocsToWiki = "docs_to_wiki"
)
var wikiMoveObjectTypes = []string{
"doc",
"sheet",
"bitable",
"mindnote",
"docx",
"file",
"slides",
}
// WikiMove moves an existing wiki node inside Wiki or migrates a Drive
// document into Wiki with bounded polling for async task completion.
var WikiMove = common.Shortcut{
Service: "wiki",
Command: "+move",
Description: "Move a wiki node, or move a Drive document into Wiki",
Risk: "write",
Scopes: []string{"wiki:node:move", "wiki:node:read", "wiki:space:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "node-token", Desc: "wiki node token to move inside Wiki"},
{Name: "source-space-id", Desc: "source wiki space ID for --node-token; if omitted, it is resolved from the node token"},
{Name: "target-space-id", Desc: "target wiki space ID; required for docs-to-wiki, optional for node move when --target-parent-token is set"},
{Name: "target-parent-token", Desc: "target parent wiki node token; if omitted for docs-to-wiki, the document is moved to the target space root"},
{Name: "obj-type", Desc: "Drive document type for docs-to-wiki mode", Enum: wikiMoveObjectTypes},
{Name: "obj-token", Desc: "Drive document token for docs-to-wiki mode"},
{Name: "apply", Type: "bool", Desc: "submit a move request when the caller lacks permission to move the document immediately"},
},
Tips: []string{
"Use --node-token to move an existing wiki node inside or across wiki spaces.",
"Use --obj-type and --obj-token to move a Drive document into Wiki.",
"If docs-to-wiki returns a long-running task, 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 {
spec := readWikiMoveSpec(runtime)
// `my_library` is a per-user personal-library alias; it has no meaning
// for a tenant_access_token (--as bot), so reject early with a clear
// hint instead of letting the API return a confusing error.
if runtime.As().IsBot() && spec.TargetSpaceID == wikiMyLibrarySpaceID {
return output.ErrValidation("--target-space-id my_library is a per-user personal library alias and cannot be used with --as bot; resolve it to a real space_id first via `lark-cli wiki spaces get --params '{\"space_id\":\"my_library\"}' --as user`")
}
return validateWikiMoveSpec(spec)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return buildWikiMoveDryRun(readWikiMoveSpec(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := readWikiMoveSpec(runtime)
fmt.Fprintf(runtime.IO().ErrOut, "Running wiki move (%s)...\n", spec.Mode())
out, err := runWikiMove(ctx, wikiMoveAPI{runtime: runtime}, runtime, spec)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
type wikiMoveSpec struct {
NodeToken string
SourceSpaceID string
TargetSpaceID string
TargetParentToken string
ObjType string
ObjToken string
Apply bool
}
func (spec wikiMoveSpec) Mode() string {
if spec.NodeToken != "" {
return wikiMoveModeNode
}
return wikiMoveModeDocsToWiki
}
func (spec wikiMoveSpec) NodeMoveBody() map[string]interface{} {
body := map[string]interface{}{}
if spec.TargetParentToken != "" {
body["target_parent_token"] = spec.TargetParentToken
}
if spec.TargetSpaceID != "" {
body["target_space_id"] = spec.TargetSpaceID
}
return body
}
func (spec wikiMoveSpec) DocsToWikiBody() map[string]interface{} {
body := map[string]interface{}{
"obj_type": spec.ObjType,
"obj_token": spec.ObjToken,
}
if spec.TargetParentToken != "" {
body["parent_wiki_token"] = spec.TargetParentToken
}
if spec.Apply {
body["apply"] = true
}
return body
}
type wikiMoveTaskResult struct {
Node *wikiNodeRecord
Status int
StatusMsg string
}
type wikiMoveTaskStatus struct {
TaskID string
MoveResults []wikiMoveTaskResult
}
func (s wikiMoveTaskStatus) Ready() bool {
if len(s.MoveResults) == 0 {
return false
}
for _, result := range s.MoveResults {
if result.Status != 0 {
return false
}
}
return true
}
func (s wikiMoveTaskStatus) Failed() bool {
for _, result := range s.MoveResults {
if result.Status < 0 {
return true
}
}
return false
}
func (s wikiMoveTaskStatus) Pending() bool {
return !s.Ready() && !s.Failed()
}
func (s wikiMoveTaskStatus) FirstResult() *wikiMoveTaskResult {
if len(s.MoveResults) == 0 {
return nil
}
return &s.MoveResults[0]
}
// primaryResult picks the most informative move_result for top-level status
// surfacing: prefer a failing entry so multi-doc tasks don't mask failures
// behind an earlier success, then a still-processing entry, and finally fall
// back to the first entry.
func (s wikiMoveTaskStatus) primaryResult() *wikiMoveTaskResult {
for i := range s.MoveResults {
if s.MoveResults[i].Status < 0 {
return &s.MoveResults[i]
}
}
for i := range s.MoveResults {
if s.MoveResults[i].Status > 0 {
return &s.MoveResults[i]
}
}
return s.FirstResult()
}
func (s wikiMoveTaskStatus) PrimaryStatusCode() int {
if r := s.primaryResult(); r != nil {
return r.Status
}
return 1
}
func (s wikiMoveTaskStatus) PrimaryStatusLabel() string {
if r := s.primaryResult(); r != nil {
if msg := strings.TrimSpace(r.StatusMsg); msg != "" {
return msg
}
}
switch {
case s.Ready():
return "success"
case s.Failed():
return "failure"
default:
return "processing"
}
}
type wikiMoveDocsResponse struct {
WikiToken string
TaskID string
Applied bool
}
type wikiMoveClient interface {
GetNode(ctx context.Context, token string) (*wikiNodeRecord, error)
MoveNode(ctx context.Context, sourceSpaceID string, spec wikiMoveSpec) (*wikiNodeRecord, error)
MoveDocsToWiki(ctx context.Context, targetSpaceID string, spec wikiMoveSpec) (*wikiMoveDocsResponse, error)
GetMoveTask(ctx context.Context, taskID string) (wikiMoveTaskStatus, error)
}
type wikiMoveAPI struct {
runtime *common.RuntimeContext
}
func (api wikiMoveAPI) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) {
data, err := api.runtime.CallAPI(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": token},
nil,
)
if err != nil {
return nil, err
}
return parseWikiNodeRecord(common.GetMap(data, "node"))
}
func (api wikiMoveAPI) MoveNode(ctx context.Context, sourceSpaceID string, spec wikiMoveSpec) (*wikiNodeRecord, error) {
data, err := api.runtime.CallAPI(
"POST",
fmt.Sprintf(
"/open-apis/wiki/v2/spaces/%s/nodes/%s/move",
validate.EncodePathSegment(sourceSpaceID),
validate.EncodePathSegment(spec.NodeToken),
),
nil,
spec.NodeMoveBody(),
)
if err != nil {
return nil, err
}
return parseWikiNodeRecord(common.GetMap(data, "node"))
}
func (api wikiMoveAPI) MoveDocsToWiki(ctx context.Context, targetSpaceID string, spec wikiMoveSpec) (*wikiMoveDocsResponse, error) {
data, err := api.runtime.CallAPI(
"POST",
fmt.Sprintf(
"/open-apis/wiki/v2/spaces/%s/nodes/move_docs_to_wiki",
validate.EncodePathSegment(targetSpaceID),
),
nil,
spec.DocsToWikiBody(),
)
if err != nil {
return nil, err
}
return &wikiMoveDocsResponse{
WikiToken: common.GetString(data, "wiki_token"),
TaskID: common.GetString(data, "task_id"),
Applied: common.GetBool(data, "applied"),
}, nil
}
func (api wikiMoveAPI) GetMoveTask(ctx context.Context, taskID string) (wikiMoveTaskStatus, error) {
data, err := api.runtime.CallAPI(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
map[string]interface{}{"task_type": "move"},
nil,
)
if err != nil {
return wikiMoveTaskStatus{}, err
}
return parseWikiMoveTaskStatus(taskID, common.GetMap(data, "task"))
}
func readWikiMoveSpec(runtime *common.RuntimeContext) wikiMoveSpec {
return wikiMoveSpec{
NodeToken: strings.TrimSpace(runtime.Str("node-token")),
SourceSpaceID: strings.TrimSpace(runtime.Str("source-space-id")),
TargetSpaceID: strings.TrimSpace(runtime.Str("target-space-id")),
TargetParentToken: strings.TrimSpace(runtime.Str("target-parent-token")),
ObjType: strings.ToLower(strings.TrimSpace(runtime.Str("obj-type"))),
ObjToken: strings.TrimSpace(runtime.Str("obj-token")),
Apply: runtime.Bool("apply"),
}
}
func validateWikiMoveSpec(spec wikiMoveSpec) error {
if err := validateOptionalResourceName(spec.NodeToken, "--node-token"); err != nil {
return err
}
if err := validateOptionalResourceName(spec.SourceSpaceID, "--source-space-id"); err != nil {
return err
}
if err := validateOptionalResourceName(spec.TargetSpaceID, "--target-space-id"); err != nil {
return err
}
if err := validateOptionalResourceName(spec.TargetParentToken, "--target-parent-token"); err != nil {
return err
}
if err := validateOptionalResourceName(spec.ObjToken, "--obj-token"); err != nil {
return err
}
if spec.NodeToken != "" {
if spec.ObjType != "" || spec.ObjToken != "" || spec.Apply {
return output.ErrValidation("--node-token cannot be combined with --obj-type, --obj-token, or --apply")
}
if spec.TargetParentToken == "" && spec.TargetSpaceID == "" {
return output.ErrValidation("--target-parent-token and --target-space-id cannot both be empty for wiki node move")
}
return nil
}
if spec.SourceSpaceID != "" {
return output.ErrValidation("--source-space-id can only be used with --node-token")
}
if spec.ObjType == "" && spec.ObjToken == "" && !spec.Apply {
return output.ErrValidation("provide --node-token for wiki node move, or provide --obj-type and --obj-token for docs-to-wiki move")
}
if spec.ObjType == "" {
return output.ErrValidation("--obj-type is required for docs-to-wiki move")
}
if spec.ObjToken == "" {
return output.ErrValidation("--obj-token is required for docs-to-wiki move")
}
if spec.TargetSpaceID == "" {
return output.ErrValidation("--target-space-id is required for docs-to-wiki move")
}
return nil
}
func buildWikiMoveDryRun(spec wikiMoveSpec) *common.DryRunAPI {
dry := common.NewDryRunAPI()
switch spec.Mode() {
case wikiMoveModeNode:
step := 1
switch {
case spec.SourceSpaceID == "" && spec.TargetParentToken != "":
dry.Desc("3-step orchestration: resolve source node -> resolve target parent -> move wiki node")
case spec.SourceSpaceID == "":
dry.Desc("2-step orchestration: resolve source node -> move wiki node")
case spec.TargetParentToken != "":
dry.Desc("2-step orchestration: resolve target parent -> move wiki node")
default:
dry.Desc("1-step request: move wiki node")
}
if spec.SourceSpaceID == "" {
dry.GET("/open-apis/wiki/v2/spaces/get_node").
Desc(fmt.Sprintf("[%d] Resolve source space from node token", step)).
Params(map[string]interface{}{"token": spec.NodeToken})
step++
}
if spec.TargetParentToken != "" {
dry.GET("/open-apis/wiki/v2/spaces/get_node").
Desc(fmt.Sprintf("[%d] Resolve target parent node", step)).
Params(map[string]interface{}{"token": spec.TargetParentToken})
step++
}
dry.POST(fmt.Sprintf(
"/open-apis/wiki/v2/spaces/%s/nodes/%s/move",
dryRunWikiMoveSourceSpaceID(spec),
validate.EncodePathSegment(spec.NodeToken),
)).
Desc(fmt.Sprintf("[%d] Move wiki node", step)).
Body(spec.NodeMoveBody())
case wikiMoveModeDocsToWiki:
dry.Desc("2-step orchestration: move Drive document into Wiki -> poll wiki task result when task_id is returned")
dry.POST(fmt.Sprintf(
"/open-apis/wiki/v2/spaces/%s/nodes/move_docs_to_wiki",
dryRunWikiMoveTargetSpaceID(spec),
)).
Desc("[1] Move Drive document into Wiki").
Body(spec.DocsToWikiBody())
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
Desc("[2] Poll wiki move task result when async").
Set("task_id", "<task_id>").
Params(map[string]interface{}{"task_type": "move"})
default:
dry.Set("error", "unknown wiki move mode")
}
return dry
}
func dryRunWikiMoveSourceSpaceID(spec wikiMoveSpec) string {
if spec.SourceSpaceID != "" {
return validate.EncodePathSegment(spec.SourceSpaceID)
}
return "<resolved_source_space_id>"
}
func dryRunWikiMoveTargetSpaceID(spec wikiMoveSpec) string {
if spec.TargetSpaceID != "" {
return validate.EncodePathSegment(spec.TargetSpaceID)
}
return "<target_space_id>"
}
func runWikiMove(ctx context.Context, client wikiMoveClient, runtime *common.RuntimeContext, spec wikiMoveSpec) (map[string]interface{}, error) {
switch spec.Mode() {
case wikiMoveModeNode:
return runWikiNodeMove(ctx, client, spec)
case wikiMoveModeDocsToWiki:
return runWikiDocsToWikiMove(ctx, client, runtime, spec)
default:
return nil, output.ErrValidation("unknown wiki move mode")
}
}
func runWikiNodeMove(ctx context.Context, client wikiMoveClient, spec wikiMoveSpec) (map[string]interface{}, error) {
sourceSpaceID, targetSpaceID, err := resolveWikiNodeMoveSpaces(ctx, client, spec)
if err != nil {
return nil, err
}
node, err := client.MoveNode(ctx, sourceSpaceID, spec)
if err != nil {
return nil, err
}
out := map[string]interface{}{
"mode": wikiMoveModeNode,
"source_space_id": sourceSpaceID,
"target_space_id": targetSpaceID,
}
appendWikiNodeOutput(out, node)
return out, nil
}
func resolveWikiNodeMoveSpaces(ctx context.Context, client wikiMoveClient, spec wikiMoveSpec) (string, string, error) {
// Node move requests may start from just a node token and/or a target parent.
// Resolve both ends up front so we can fail on space mismatches before sending
// the mutation request.
sourceSpaceID := spec.SourceSpaceID
if sourceSpaceID == "" {
sourceNode, err := client.GetNode(ctx, spec.NodeToken)
if err != nil {
return "", "", err
}
sourceSpaceID, err = requireWikiNodeSpaceID(sourceNode)
if err != nil {
return "", "", err
}
}
targetSpaceID := spec.TargetSpaceID
if spec.TargetParentToken != "" {
targetParent, err := client.GetNode(ctx, spec.TargetParentToken)
if err != nil {
return "", "", err
}
parentSpaceID, err := requireWikiNodeSpaceID(targetParent)
if err != nil {
return "", "", err
}
if targetSpaceID == "" {
targetSpaceID = parentSpaceID
} else if targetSpaceID != parentSpaceID {
return "", "", output.ErrValidation(
"--target-space-id %q does not match target parent node space %q",
spec.TargetSpaceID,
parentSpaceID,
)
}
}
if targetSpaceID == "" {
targetSpaceID = sourceSpaceID
}
return sourceSpaceID, targetSpaceID, nil
}
func runWikiDocsToWikiMove(ctx context.Context, client wikiMoveClient, runtime *common.RuntimeContext, spec wikiMoveSpec) (map[string]interface{}, error) {
response, err := client.MoveDocsToWiki(ctx, spec.TargetSpaceID, spec)
if err != nil {
return nil, err
}
out := map[string]interface{}{
"mode": wikiMoveModeDocsToWiki,
"obj_type": spec.ObjType,
"obj_token": spec.ObjToken,
"target_space_id": spec.TargetSpaceID,
"target_parent_token": spec.TargetParentToken,
}
// move_docs_to_wiki has three success-shaped responses: immediate completion,
// approval-request submission, or an async task that must be polled.
switch {
case response.WikiToken != "":
out["ready"] = true
out["failed"] = false
out["wiki_token"] = response.WikiToken
out["node_token"] = response.WikiToken
return out, nil
case response.Applied:
out["ready"] = false
out["failed"] = false
out["applied"] = true
out["status_msg"] = "move request submitted for approval"
return out, nil
case response.TaskID != "":
fmt.Fprintf(runtime.IO().ErrOut, "Docs-to-wiki move is async, polling task %s...\n", response.TaskID)
status, ready, err := pollWikiMoveTask(ctx, client, runtime, response.TaskID)
if err != nil {
return nil, err
}
out["task_id"] = response.TaskID
out["ready"] = ready
out["failed"] = status.Failed()
out["status"] = status.PrimaryStatusCode()
out["status_msg"] = status.PrimaryStatusLabel()
if first := status.FirstResult(); first != nil {
appendWikiNodeOutput(out, first.Node)
if first.Node != nil && first.Node.NodeToken != "" {
out["wiki_token"] = first.Node.NodeToken
}
}
if !ready {
nextCommand := wikiMoveTaskResultCommand(response.TaskID, runtime.As())
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
return out, nil
default:
return nil, output.Errorf(output.ExitAPI, "api_error", "move_docs_to_wiki returned neither wiki_token, task_id, nor applied result")
}
}
func wikiMoveTaskResultCommand(taskID string, identity core.Identity) string {
asFlag := string(identity)
if asFlag == "" {
asFlag = "user"
}
return fmt.Sprintf("lark-cli drive +task_result --scenario wiki_move --task-id %s --as %s", taskID, asFlag)
}
func pollWikiMoveTask(ctx context.Context, client wikiMoveClient, runtime *common.RuntimeContext, taskID string) (wikiMoveTaskStatus, bool, error) {
lastStatus := wikiMoveTaskStatus{TaskID: taskID}
var lastErr error
hadSuccessfulPoll := false
// The move request itself 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 <= wikiMovePollAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return lastStatus, false, ctx.Err()
case <-time.After(wikiMovePollInterval):
}
}
status, err := client.GetMoveTask(ctx, taskID)
if err != nil {
lastErr = err
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move status attempt %d/%d failed: %v\n", attempt, wikiMovePollAttempts, err)
continue
}
lastStatus = status
hadSuccessfulPoll = true
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move task completed successfully.\n")
return status, true, nil
}
if status.Failed() {
return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki move task failed: %s", status.PrimaryStatusLabel())
}
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move status %d/%d: %s\n", attempt, wikiMovePollAttempts, status.PrimaryStatusLabel())
}
if !hadSuccessfulPoll && lastErr != nil {
nextCommand := wikiMoveTaskResultCommand(taskID, runtime.As())
hint := fmt.Sprintf(
"the wiki move 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
}
func parseWikiMoveTaskStatus(taskID string, task map[string]interface{}) (wikiMoveTaskStatus, error) {
if task == nil {
return wikiMoveTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
}
status := wikiMoveTaskStatus{
TaskID: common.GetString(task, "task_id"),
}
if status.TaskID == "" {
status.TaskID = taskID
}
for _, item := range common.GetSlice(task, "move_result") {
resultMap, ok := item.(map[string]interface{})
if !ok {
continue
}
var node *wikiNodeRecord
if nodeMap := common.GetMap(resultMap, "node"); nodeMap != nil {
parsedNode, err := parseWikiNodeRecord(nodeMap)
if err != nil {
return wikiMoveTaskStatus{}, err
}
node = parsedNode
}
status.MoveResults = append(status.MoveResults, wikiMoveTaskResult{
Node: node,
Status: int(common.GetFloat(resultMap, "status")),
StatusMsg: common.GetString(resultMap, "status_msg"),
})
}
return status, nil
}
func appendWikiNodeOutput(out map[string]interface{}, node *wikiNodeRecord) {
if out == nil || node == nil {
return
}
out["space_id"] = node.SpaceID
out["node_token"] = node.NodeToken
out["obj_token"] = node.ObjToken
out["obj_type"] = node.ObjType
out["parent_node_token"] = node.ParentNodeToken
out["node_type"] = node.NodeType
out["origin_node_token"] = node.OriginNodeToken
out["title"] = node.Title
out["has_child"] = node.HasChild
}

View File

@@ -0,0 +1,905 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"bytes"
"context"
"encoding/json"
"errors"
"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/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
type fakeWikiMoveNodeCall struct {
SourceSpaceID string
Spec wikiMoveSpec
}
type fakeWikiDocsToWikiMoveCall struct {
TargetSpaceID string
Spec wikiMoveSpec
}
type fakeWikiMoveClient struct {
nodes map[string]*wikiNodeRecord
getNodeErr error
moveNode *wikiNodeRecord
moveNodeErr error
docsResp *wikiMoveDocsResponse
docsErr error
taskStatuses []wikiMoveTaskStatus
taskErrs []error
getNodeCalls []string
moveNodeCalls []fakeWikiMoveNodeCall
docsToWikiCalls []fakeWikiDocsToWikiMoveCall
moveTaskCallArgs []string
}
func (fake *fakeWikiMoveClient) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) {
fake.getNodeCalls = append(fake.getNodeCalls, token)
if fake.getNodeErr != nil {
return nil, fake.getNodeErr
}
if node, ok := fake.nodes[token]; ok {
return node, nil
}
return &wikiNodeRecord{}, nil
}
func (fake *fakeWikiMoveClient) MoveNode(ctx context.Context, sourceSpaceID string, spec wikiMoveSpec) (*wikiNodeRecord, error) {
fake.moveNodeCalls = append(fake.moveNodeCalls, fakeWikiMoveNodeCall{SourceSpaceID: sourceSpaceID, Spec: spec})
if fake.moveNodeErr != nil {
return nil, fake.moveNodeErr
}
if fake.moveNode != nil {
return fake.moveNode, nil
}
return &wikiNodeRecord{SpaceID: sourceSpaceID, NodeToken: spec.NodeToken}, nil
}
func (fake *fakeWikiMoveClient) MoveDocsToWiki(ctx context.Context, targetSpaceID string, spec wikiMoveSpec) (*wikiMoveDocsResponse, error) {
fake.docsToWikiCalls = append(fake.docsToWikiCalls, fakeWikiDocsToWikiMoveCall{TargetSpaceID: targetSpaceID, Spec: spec})
if fake.docsErr != nil {
return nil, fake.docsErr
}
if fake.docsResp != nil {
return fake.docsResp, nil
}
return &wikiMoveDocsResponse{}, nil
}
func (fake *fakeWikiMoveClient) GetMoveTask(ctx context.Context, taskID string) (wikiMoveTaskStatus, error) {
idx := len(fake.moveTaskCallArgs)
fake.moveTaskCallArgs = append(fake.moveTaskCallArgs, taskID)
if idx < len(fake.taskErrs) && fake.taskErrs[idx] != nil {
return wikiMoveTaskStatus{TaskID: taskID}, fake.taskErrs[idx]
}
if idx < len(fake.taskStatuses) {
status := fake.taskStatuses[idx]
if status.TaskID == "" {
status.TaskID = taskID
}
return status, nil
}
return wikiMoveTaskStatus{TaskID: taskID}, nil
}
type mockWikiMoveTokenResolver struct {
token string
scopes string
err error
}
type wikiMoveAccountResolver struct {
cfg *core.CliConfig
}
func (r *wikiMoveAccountResolver) ResolveAccount(ctx context.Context) (*credential.Account, error) {
return credential.AccountFromCliConfig(r.cfg), nil
}
func (m *mockWikiMoveTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
if m.err != nil {
return nil, m.err
}
token := m.token
if token == "" {
token = "test-token"
}
return &credential.TokenResult{Token: token, Scopes: m.scopes}, nil
}
var wikiMovePollMu sync.Mutex
func withSingleWikiMovePoll(t *testing.T) {
t.Helper()
wikiMovePollMu.Lock()
prevAttempts, prevInterval := wikiMovePollAttempts, wikiMovePollInterval
wikiMovePollAttempts, wikiMovePollInterval = 1, 0
t.Cleanup(func() {
wikiMovePollAttempts, wikiMovePollInterval = prevAttempts, prevInterval
wikiMovePollMu.Unlock()
})
}
func newWikiMoveRuntimeWithScopes(t *testing.T, as core.Identity, scopes string) (*common.RuntimeContext, *bytes.Buffer) {
t.Helper()
cfg := wikiTestConfig()
factory, _, stderr, _ := cmdutil.TestFactory(t, cfg)
factory.Credential = credential.NewCredentialProvider(nil, nil, &mockWikiMoveTokenResolver{scopes: scopes}, nil)
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "wiki +move"}, cfg, as)
runtime.Factory = factory
return runtime, stderr
}
func decodeWikiEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var env struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("unmarshal wiki envelope: %v\nstdout=%s", err, stdout.String())
}
if !env.OK {
t.Fatalf("expected ok=true envelope, got stdout=%s", stdout.String())
}
return env.Data
}
func decodeWikiCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interface{} {
t.Helper()
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v\nraw=%s", err, string(stub.CapturedBody))
}
return body
}
func TestValidateWikiMoveSpecRejectsInvalidCombinations(t *testing.T) {
t.Parallel()
tests := []struct {
name string
spec wikiMoveSpec
wantErr string
}{
{
name: "node move rejects docs flags",
spec: wikiMoveSpec{NodeToken: "wik_node", ObjType: "sheet", TargetSpaceID: "space_dst"},
wantErr: "cannot be combined",
},
{
name: "node move requires target",
spec: wikiMoveSpec{NodeToken: "wik_node"},
wantErr: "cannot both be empty",
},
{
name: "source space requires node token",
spec: wikiMoveSpec{SourceSpaceID: "space_src", ObjType: "sheet", ObjToken: "sheet_token", TargetSpaceID: "space_dst"},
wantErr: "can only be used with --node-token",
},
{
name: "docs to wiki requires obj type",
spec: wikiMoveSpec{ObjToken: "sheet_token", TargetSpaceID: "space_dst"},
wantErr: "--obj-type is required",
},
{
name: "docs to wiki requires obj token",
spec: wikiMoveSpec{ObjType: "sheet", TargetSpaceID: "space_dst"},
wantErr: "--obj-token is required",
},
{
name: "docs to wiki requires target space",
spec: wikiMoveSpec{ObjType: "sheet", ObjToken: "sheet_token"},
wantErr: "--target-space-id is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateWikiMoveSpec(tt.spec)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
})
}
}
func TestValidateWikiMoveSpecAcceptsValidModes(t *testing.T) {
t.Parallel()
for _, spec := range []wikiMoveSpec{
{NodeToken: "wik_node", TargetSpaceID: "space_dst"},
{ObjType: "sheet", ObjToken: "sheet_token", TargetSpaceID: "space_dst", TargetParentToken: "wik_parent", Apply: true},
} {
if err := validateWikiMoveSpec(spec); err != nil {
t.Fatalf("validateWikiMoveSpec(%+v) error = %v", spec, err)
}
}
}
func TestWikiMoveDeclaredScopes(t *testing.T) {
t.Parallel()
want := []string{"wiki:node:move", "wiki:node:read", "wiki:space:read"}
if !reflect.DeepEqual(WikiMove.Scopes, want) {
t.Fatalf("WikiMove.Scopes = %v, want %v", WikiMove.Scopes, want)
}
}
func TestWikiMoveShortcutMissingDeclaredScope(t *testing.T) {
cfg := wikiTestConfig()
factory, stdout, _, _ := cmdutil.TestFactory(t, cfg)
factory.Credential = credential.NewCredentialProvider(nil, &wikiMoveAccountResolver{cfg: cfg}, &mockWikiMoveTokenResolver{scopes: "wiki:node:read"}, nil)
err := mountAndRunWiki(t, WikiMove, []string{
"+move",
"--node-token", "wik_node",
"--target-space-id", "space_dst",
"--as", "user",
}, factory, stdout)
if err == nil {
t.Fatal("expected missing scope error, got nil")
}
if !strings.Contains(err.Error(), "missing required scope(s): wiki:node:move") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestWikiMoveTaskStatusPendingAndFallbackLabels(t *testing.T) {
t.Parallel()
pending := wikiMoveTaskStatus{}
if !pending.Pending() || pending.PrimaryStatusLabel() != "processing" {
t.Fatalf("pending status = %+v", pending)
}
ready := wikiMoveTaskStatus{MoveResults: []wikiMoveTaskResult{{Status: 0}}}
if !ready.Ready() || ready.PrimaryStatusLabel() != "success" {
t.Fatalf("ready status = %+v", ready)
}
failed := wikiMoveTaskStatus{MoveResults: []wikiMoveTaskResult{{Status: -1}}}
if !failed.Failed() || failed.PrimaryStatusLabel() != "failure" {
t.Fatalf("failed status = %+v", failed)
}
}
func TestWikiMoveTaskStatusPrimarySurfacesFailureOverEarlierSuccess(t *testing.T) {
t.Parallel()
status := wikiMoveTaskStatus{
MoveResults: []wikiMoveTaskResult{
{Status: 0, StatusMsg: "success"},
{Status: -3, StatusMsg: "permission denied"},
{Status: 1, StatusMsg: "processing"},
},
}
if got := status.PrimaryStatusCode(); got != -3 {
t.Fatalf("PrimaryStatusCode = %d, want -3", got)
}
if got := status.PrimaryStatusLabel(); got != "permission denied" {
t.Fatalf("PrimaryStatusLabel = %q, want permission denied", got)
}
// FirstResult must keep its literal "first entry" semantics for callers
// that flatten node fields from the first move_result.
if first := status.FirstResult(); first == nil || first.StatusMsg != "success" {
t.Fatalf("FirstResult = %+v, want first success entry", first)
}
}
func TestWikiMoveTaskStatusPrimaryPrefersProcessingOverFirstSuccess(t *testing.T) {
t.Parallel()
status := wikiMoveTaskStatus{
MoveResults: []wikiMoveTaskResult{
{Status: 0, StatusMsg: "success"},
{Status: 1, StatusMsg: "processing"},
},
}
if got := status.PrimaryStatusCode(); got != 1 {
t.Fatalf("PrimaryStatusCode = %d, want 1", got)
}
if got := status.PrimaryStatusLabel(); got != "processing" {
t.Fatalf("PrimaryStatusLabel = %q, want processing", got)
}
}
func TestWikiMoveValidateRejectsBotMyLibrary(t *testing.T) {
cfg := wikiTestConfig()
factory, stdout, _, _ := cmdutil.TestFactory(t, cfg)
err := mountAndRunWiki(t, WikiMove, []string{
"+move",
"--obj-type", "docx",
"--obj-token", "doccnXXX",
"--target-space-id", "my_library",
"--as", "bot",
}, factory, stdout)
if err == nil {
t.Fatal("expected validation error for bot + my_library, got nil")
}
if !strings.Contains(err.Error(), "my_library") || !strings.Contains(err.Error(), "--as bot") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestWikiMoveValidateAllowsUserMyLibrary(t *testing.T) {
t.Parallel()
// Bot guard must not affect user identity. We only assert the my_library
// validation path doesn't trip; an empty obj-token still fails downstream
// for unrelated reasons, so we check the error does not mention my_library.
if err := validateWikiMoveSpec(wikiMoveSpec{
ObjType: "docx",
ObjToken: "doccnXXX",
TargetSpaceID: "my_library",
}); err != nil {
t.Fatalf("validateWikiMoveSpec(user my_library) = %v, want nil", err)
}
}
func TestWikiMoveDryRunNodeMoveIncludesResolutionSteps(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "wiki +move"}
cmd.Flags().String("node-token", "", "")
cmd.Flags().String("source-space-id", "", "")
cmd.Flags().String("target-space-id", "", "")
cmd.Flags().String("target-parent-token", "", "")
cmd.Flags().String("obj-type", "", "")
cmd.Flags().String("obj-token", "", "")
cmd.Flags().Bool("apply", false, "")
if err := cmd.Flags().Set("node-token", "wik_node"); err != nil {
t.Fatalf("set --node-token: %v", err)
}
if err := cmd.Flags().Set("target-parent-token", "wik_parent"); err != nil {
t.Fatalf("set --target-parent-token: %v", err)
}
runtime := common.TestNewRuntimeContext(cmd, nil)
dry := WikiMove.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)
}
if !bytes.Contains(data, []byte(`"description":"3-step orchestration:`)) {
t.Fatalf("dry run missing 3-step description: %s", string(data))
}
if !bytes.Contains(data, []byte(`"target_parent_token":"wik_parent"`)) {
t.Fatalf("dry run missing target_parent_token body: %s", string(data))
}
if !bytes.Contains(data, []byte(`/open-apis/wiki/v2/spaces/\u003cresolved_source_space_id\u003e/nodes/wik_node/move`)) {
t.Fatalf("dry run missing resolved source placeholder: %s", string(data))
}
}
func TestWikiMoveDryRunDocsToWikiIncludesTaskPoll(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "wiki +move"}
cmd.Flags().String("node-token", "", "")
cmd.Flags().String("source-space-id", "", "")
cmd.Flags().String("target-space-id", "", "")
cmd.Flags().String("target-parent-token", "", "")
cmd.Flags().String("obj-type", "", "")
cmd.Flags().String("obj-token", "", "")
cmd.Flags().Bool("apply", false, "")
if err := cmd.Flags().Set("obj-type", "sheet"); err != nil {
t.Fatalf("set --obj-type: %v", err)
}
if err := cmd.Flags().Set("obj-token", "sheet_token"); err != nil {
t.Fatalf("set --obj-token: %v", err)
}
if err := cmd.Flags().Set("target-space-id", "space_dst"); err != nil {
t.Fatalf("set --target-space-id: %v", err)
}
if err := cmd.Flags().Set("apply", "true"); err != nil {
t.Fatalf("set --apply: %v", err)
}
runtime := common.TestNewRuntimeContext(cmd, nil)
dry := WikiMove.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)
}
if !bytes.Contains(data, []byte(`"obj_type":"sheet"`)) || !bytes.Contains(data, []byte(`"apply":true`)) {
t.Fatalf("dry run missing docs-to-wiki body: %s", string(data))
}
if !bytes.Contains(data, []byte(`"task_type":"move"`)) {
t.Fatalf("dry run missing task polling params: %s", string(data))
}
}
func TestResolveWikiNodeMoveSpacesUsesSourceAndTargetLookups(t *testing.T) {
t.Parallel()
client := &fakeWikiMoveClient{
nodes: map[string]*wikiNodeRecord{
"wik_node": {SpaceID: "space_src"},
"wik_parent": {SpaceID: "space_dst"},
},
}
sourceSpaceID, targetSpaceID, err := resolveWikiNodeMoveSpaces(context.Background(), client, wikiMoveSpec{
NodeToken: "wik_node",
TargetParentToken: "wik_parent",
})
if err != nil {
t.Fatalf("resolveWikiNodeMoveSpaces() error = %v", err)
}
if sourceSpaceID != "space_src" || targetSpaceID != "space_dst" {
t.Fatalf("resolved spaces = (%q, %q), want (%q, %q)", sourceSpaceID, targetSpaceID, "space_src", "space_dst")
}
if strings.Join(client.getNodeCalls, ",") != "wik_node,wik_parent" {
t.Fatalf("getNodeCalls = %v, want source and target-parent lookups", client.getNodeCalls)
}
}
func TestResolveWikiNodeMoveSpacesRejectsTargetSpaceMismatch(t *testing.T) {
t.Parallel()
client := &fakeWikiMoveClient{
nodes: map[string]*wikiNodeRecord{
"wik_parent": {SpaceID: "space_parent"},
},
}
_, _, err := resolveWikiNodeMoveSpaces(context.Background(), client, wikiMoveSpec{
NodeToken: "wik_node",
SourceSpaceID: "space_src",
TargetSpaceID: "space_other",
TargetParentToken: "wik_parent",
})
if err == nil || !strings.Contains(err.Error(), "does not match") {
t.Fatalf("expected mismatch error, got %v", err)
}
}
func TestRunWikiNodeMoveReturnsResolvedMetadata(t *testing.T) {
t.Parallel()
client := &fakeWikiMoveClient{
nodes: map[string]*wikiNodeRecord{
"wik_node": {SpaceID: "space_src"},
"wik_parent": {SpaceID: "space_dst"},
},
moveNode: &wikiNodeRecord{
SpaceID: "space_dst",
NodeToken: "wik_moved",
ObjToken: "sheet_token",
ObjType: "sheet",
ParentNodeToken: "wik_parent",
NodeType: wikiNodeTypeOrigin,
Title: "Roadmap",
},
}
out, err := runWikiNodeMove(context.Background(), client, wikiMoveSpec{
NodeToken: "wik_node",
TargetParentToken: "wik_parent",
})
if err != nil {
t.Fatalf("runWikiNodeMove() error = %v", err)
}
if len(client.moveNodeCalls) != 1 {
t.Fatalf("MoveNode called %d times, want 1", len(client.moveNodeCalls))
}
if client.moveNodeCalls[0].SourceSpaceID != "space_src" {
t.Fatalf("source space = %q, want %q", client.moveNodeCalls[0].SourceSpaceID, "space_src")
}
if out["mode"] != wikiMoveModeNode || out["source_space_id"] != "space_src" || out["target_space_id"] != "space_dst" {
t.Fatalf("unexpected node move output: %#v", out)
}
if out["node_token"] != "wik_moved" || out["title"] != "Roadmap" {
t.Fatalf("node fields not propagated: %#v", out)
}
}
func TestRunWikiMoveDispatchesByMode(t *testing.T) {
t.Parallel()
runtime, _ := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
client := &fakeWikiMoveClient{
docsResp: &wikiMoveDocsResponse{WikiToken: "wik_ready"},
moveNode: &wikiNodeRecord{SpaceID: "space_dst", NodeToken: "wik_node"},
}
nodeOut, err := runWikiMove(context.Background(), client, runtime, wikiMoveSpec{
NodeToken: "wik_node",
SourceSpaceID: "space_src",
TargetSpaceID: "space_dst",
})
if err != nil {
t.Fatalf("runWikiMove(node) error = %v", err)
}
if nodeOut["mode"] != wikiMoveModeNode {
t.Fatalf("node mode output = %#v", nodeOut)
}
docsOut, err := runWikiMove(context.Background(), client, runtime, wikiMoveSpec{
ObjType: "sheet",
ObjToken: "sheet_token",
TargetSpaceID: "space_dst",
})
if err != nil {
t.Fatalf("runWikiMove(docs_to_wiki) error = %v", err)
}
if docsOut["mode"] != wikiMoveModeDocsToWiki {
t.Fatalf("docs-to-wiki output = %#v", docsOut)
}
}
func TestRunWikiDocsToWikiMoveSyncReady(t *testing.T) {
t.Parallel()
runtime, _ := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
client := &fakeWikiMoveClient{
docsResp: &wikiMoveDocsResponse{WikiToken: "wik_ready"},
}
out, err := runWikiDocsToWikiMove(context.Background(), client, runtime, wikiMoveSpec{
ObjType: "sheet",
ObjToken: "sheet_token",
TargetSpaceID: "space_dst",
})
if err != nil {
t.Fatalf("runWikiDocsToWikiMove() error = %v", err)
}
if out["ready"] != true || out["failed"] != false {
t.Fatalf("expected ready sync result, got %#v", out)
}
if out["wiki_token"] != "wik_ready" || out["node_token"] != "wik_ready" {
t.Fatalf("wiki token fields = %#v", out)
}
if len(client.docsToWikiCalls) != 1 || client.docsToWikiCalls[0].TargetSpaceID != "space_dst" {
t.Fatalf("unexpected docs-to-wiki calls: %#v", client.docsToWikiCalls)
}
}
func TestRunWikiDocsToWikiMoveApplied(t *testing.T) {
t.Parallel()
runtime, _ := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
client := &fakeWikiMoveClient{
docsResp: &wikiMoveDocsResponse{Applied: true},
}
out, err := runWikiDocsToWikiMove(context.Background(), client, runtime, wikiMoveSpec{
ObjType: "sheet",
ObjToken: "sheet_token",
TargetSpaceID: "space_dst",
})
if err != nil {
t.Fatalf("runWikiDocsToWikiMove() error = %v", err)
}
if out["applied"] != true || out["ready"] != false || out["failed"] != false {
t.Fatalf("expected applied response, got %#v", out)
}
if out["status_msg"] != "move request submitted for approval" {
t.Fatalf("status_msg = %#v", out["status_msg"])
}
}
func TestRunWikiDocsToWikiMoveAsyncReady(t *testing.T) {
withSingleWikiMovePoll(t)
runtime, stderr := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
client := &fakeWikiMoveClient{
docsResp: &wikiMoveDocsResponse{TaskID: "task_123"},
taskStatuses: []wikiMoveTaskStatus{{
MoveResults: []wikiMoveTaskResult{{
Status: 0,
StatusMsg: "success",
Node: &wikiNodeRecord{
SpaceID: "space_dst",
NodeToken: "wik_done",
ObjToken: "sheet_token",
ObjType: "sheet",
NodeType: wikiNodeTypeOrigin,
Title: "Roadmap",
},
}},
}},
}
out, err := runWikiDocsToWikiMove(context.Background(), client, runtime, wikiMoveSpec{
ObjType: "sheet",
ObjToken: "sheet_token",
TargetSpaceID: "space_dst",
})
if err != nil {
t.Fatalf("runWikiDocsToWikiMove() error = %v", err)
}
if out["task_id"] != "task_123" || out["ready"] != true || out["failed"] != false {
t.Fatalf("unexpected async-ready output: %#v", out)
}
if out["wiki_token"] != "wik_done" || out["title"] != "Roadmap" || out["status_msg"] != "success" {
t.Fatalf("async-ready output missing flattened fields: %#v", out)
}
if !strings.Contains(stderr.String(), "Docs-to-wiki move is async") || !strings.Contains(stderr.String(), "completed successfully") {
t.Fatalf("stderr = %q, want async progress logs", stderr.String())
}
}
func TestRunWikiDocsToWikiMoveAsyncTimeoutReturnsNextCommand(t *testing.T) {
withSingleWikiMovePoll(t)
runtime, stderr := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
client := &fakeWikiMoveClient{
docsResp: &wikiMoveDocsResponse{TaskID: "task_123"},
taskStatuses: []wikiMoveTaskStatus{{
MoveResults: []wikiMoveTaskResult{{Status: 1, StatusMsg: "processing"}},
}},
}
out, err := runWikiDocsToWikiMove(context.Background(), client, runtime, wikiMoveSpec{
ObjType: "sheet",
ObjToken: "sheet_token",
TargetSpaceID: "space_dst",
})
if err != nil {
t.Fatalf("runWikiDocsToWikiMove() error = %v", err)
}
if out["ready"] != false || out["timed_out"] != true || out["next_command"] != wikiMoveTaskResultCommand("task_123", core.AsUser) {
t.Fatalf("expected timeout response, got %#v", out)
}
if out["status_msg"] != "processing" {
t.Fatalf("status_msg = %#v, want processing", out["status_msg"])
}
if !strings.Contains(stderr.String(), "Continue with") {
t.Fatalf("stderr = %q, want continuation hint", stderr.String())
}
}
func TestRunWikiDocsToWikiMoveAsyncFailureReturnsStructuredError(t *testing.T) {
withSingleWikiMovePoll(t)
runtime, _ := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
client := &fakeWikiMoveClient{
docsResp: &wikiMoveDocsResponse{TaskID: "task_123"},
taskStatuses: []wikiMoveTaskStatus{{
MoveResults: []wikiMoveTaskResult{{Status: -1, StatusMsg: "approval rejected"}},
}},
}
_, err := runWikiDocsToWikiMove(context.Background(), client, runtime, wikiMoveSpec{
ObjType: "sheet",
ObjToken: "sheet_token",
TargetSpaceID: "space_dst",
})
if err == nil || !strings.Contains(err.Error(), "wiki move task failed: approval rejected") {
t.Fatalf("expected async failure error, got %v", err)
}
}
func TestWikiMoveExecuteNodeShortcut(t *testing.T) {
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_src"},
},
},
})
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_dst"},
},
},
})
moveStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces/space_src/nodes/wik_node/move",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_dst",
"node_token": "wik_moved",
"obj_token": "sheet_token",
"obj_type": "sheet",
"parent_node_token": "wik_parent",
"node_type": "origin",
"title": "Roadmap",
},
},
},
}
reg.Register(moveStub)
err := mountAndRunWiki(t, WikiMove, []string{
"+move",
"--node-token", "wik_node",
"--target-parent-token", "wik_parent",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
if data["mode"] != wikiMoveModeNode || data["source_space_id"] != "space_src" || data["target_space_id"] != "space_dst" {
t.Fatalf("unexpected node shortcut output: %#v", data)
}
body := decodeWikiCapturedJSONBody(t, moveStub)
if body["target_parent_token"] != "wik_parent" {
t.Fatalf("move body = %#v, want target_parent_token", body)
}
}
func TestWikiMoveExecuteDocsToWikiShortcutAsyncSuccess(t *testing.T) {
withSingleWikiMovePoll(t)
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
docsStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces/space_dst/nodes/move_docs_to_wiki",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"task_id": "task_123",
},
},
}
reg.Register(docsStub)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/tasks/task_123",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"task": map[string]interface{}{
"task_id": "task_123",
"move_result": []interface{}{
map[string]interface{}{
"status": 0,
"status_msg": "success",
"node": map[string]interface{}{
"space_id": "space_dst",
"node_token": "wik_done",
"obj_token": "sheet_token",
"obj_type": "sheet",
"node_type": "origin",
"title": "Roadmap",
},
},
},
},
},
},
})
err := mountAndRunWiki(t, WikiMove, []string{
"+move",
"--obj-type", "sheet",
"--obj-token", "sheet_token",
"--target-space-id", "space_dst",
"--apply",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
if data["mode"] != wikiMoveModeDocsToWiki || data["ready"] != true || data["wiki_token"] != "wik_done" {
t.Fatalf("unexpected docs-to-wiki shortcut output: %#v", data)
}
body := decodeWikiCapturedJSONBody(t, docsStub)
if body["obj_type"] != "sheet" || body["obj_token"] != "sheet_token" || body["apply"] != true {
t.Fatalf("docs-to-wiki body = %#v", body)
}
}
func TestPollWikiMoveTaskWrapsRepeatedPollFailuresWithHint(t *testing.T) {
withSingleWikiMovePoll(t)
runtime, stderr := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
client := &fakeWikiMoveClient{
taskErrs: []error{output.ErrWithHint(output.ExitAPI, "api_error", "poll failed", "retry original")},
}
status, ready, err := pollWikiMoveTask(context.Background(), client, runtime, "task_123")
if err == nil {
t.Fatal("expected pollWikiMoveTask() error, got nil")
}
if ready {
t.Fatal("expected ready=false when every poll fails")
}
if status.TaskID != "task_123" {
t.Fatalf("status.TaskID = %q, want %q", status.TaskID, "task_123")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %T %v", err, err)
}
if !strings.Contains(exitErr.Detail.Hint, "retry original") || !strings.Contains(exitErr.Detail.Hint, wikiMoveTaskResultCommand("task_123", core.AsUser)) {
t.Fatalf("hint = %q, want original hint and resume command", exitErr.Detail.Hint)
}
if !strings.Contains(stderr.String(), "Wiki move status attempt 1/1 failed") {
t.Fatalf("stderr = %q, want poll failure log", stderr.String())
}
}
func TestParseWikiMoveTaskStatusFallbackTaskIDAndNode(t *testing.T) {
t.Parallel()
status, err := parseWikiMoveTaskStatus("task_fallback", map[string]interface{}{
"move_result": []interface{}{
map[string]interface{}{
"status": 0,
"status_msg": "success",
"node": map[string]interface{}{
"space_id": "space_dst",
"node_token": "wik_done",
"obj_token": "sheet_token",
"obj_type": "sheet",
"node_type": wikiNodeTypeOrigin,
"title": "Roadmap",
},
},
},
})
if err != nil {
t.Fatalf("parseWikiMoveTaskStatus() error = %v", err)
}
if status.TaskID != "task_fallback" {
t.Fatalf("TaskID = %q, want %q", status.TaskID, "task_fallback")
}
if !status.Ready() || status.PrimaryStatusLabel() != "success" {
t.Fatalf("unexpected parsed status: %+v", status)
}
if first := status.FirstResult(); first == nil || first.Node == nil || first.Node.NodeToken != "wik_done" {
t.Fatalf("parsed node = %+v", first)
}
}
func TestParseWikiMoveTaskStatusRejectsMissingTask(t *testing.T) {
t.Parallel()
_, err := parseWikiMoveTaskStatus("task_123", nil)
if err == nil || !strings.Contains(err.Error(), "missing task") {
t.Fatalf("expected missing task error, got %v", err)
}
}

View File

@@ -94,15 +94,18 @@ func mountAndRunWiki(t *testing.T, shortcut common.Shortcut, args []string, fact
return parent.Execute()
}
func TestWikiShortcutsIncludesNodeCreate(t *testing.T) {
func TestWikiShortcutsIncludeMoveAndNodeCreate(t *testing.T) {
t.Parallel()
shortcuts := Shortcuts()
if len(shortcuts) != 1 {
t.Fatalf("len(Shortcuts()) = %d, want 1", len(shortcuts))
if len(shortcuts) != 2 {
t.Fatalf("len(Shortcuts()) = %d, want 2", len(shortcuts))
}
if shortcuts[0].Command != "+node-create" {
t.Fatalf("shortcut command = %q, want %q", shortcuts[0].Command, "+node-create")
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")
}
}

View File

@@ -8,6 +8,9 @@
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`
## 修改标题
- 使用 `drive files patch` 命令通过new_title字段可以修改标题支持 docx、sheet、bitable、file、wiki、folder 类型
## 核心概念
### 文档类型与 Token

View File

@@ -31,7 +31,7 @@ lark-cli base +record-upload-attachment \
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
| `--record-id <id>` | 是 | 记录 ID |
| `--field-id <id_or_name>` | 是 | 附件字段 ID 或字段名 |
| `--file <path>` | 是 | 本地文件路径,最大 20MB |
| `--file <path>` | 是 | 本地文件路径,最大 2GB |
| `--name <name>` | 否 | 写入附件字段时显示的文件名,默认使用本地文件名 |
@@ -43,6 +43,7 @@ lark-cli base +record-upload-attachment \
## 坑点
- ⚠️ 目标字段必须是 `attachment` 字段。
- ⚠️ 记录里的附件 `file_token` 属于 Drive media token下载时不要走 `lark-cli drive +download`,应使用 `lark-cli docs +media-download --token <file_token> --output <path>`
## 参考

View File

@@ -134,7 +134,8 @@ lark-cli docs +create --title "产品需求" --markdown '<callout emoji="💡" b
- 使用标准 Markdown 语法作为基础
- 使用自定义 XML 标签实现飞书特有功能(具体标签见各功能章节)
- 需要显示特殊字符时使用反斜杠转义:`* ~ ` $ [ ] < > { } | ^`
- 只有当字符会被解释为 Markdown / Lark 富文本语法时,才需要使用反斜杠转义:``* ~ ` $ [ ] < > { } | ^``
- 普通文本中的孤立字符不要过度转义。例如 `5 * 3``version~1.0``final_trajectory` 通常应保持原样,只有像 `*斜体*``**粗体**``~~删除线~~` 这种会触发格式化的写法,想按字面量显示时才需要转义
---
@@ -657,7 +658,7 @@ $$
## 最佳实践
- **空行分隔**:不同块类型之间用空行分隔
- **转义字符**特殊字符用 `\` 转义:`\*` `\~` `\``
- **转义字符**只有在字符会触发格式化时才用 `\` 转义。例如想输出字面量 `*斜体*` 时写成 `\*斜体\*`;但 `5 * 3``version~1.0``final_trajectory` 这类普通文本通常不需要转义
- **图片**:使用 URL系统自动下载上传
- **分栏**:列宽总和必须为 100
- **表格选择**:简单数据用 Markdown复杂嵌套用 `<lark-table>`

View File

@@ -1,7 +1,7 @@
---
name: lark-drive
version: 1.0.0
description: "飞书云空间:管理云空间中的文件和文件夹。上传和下载文件、创建文件夹、复制/移动/删除文件、查看文件元数据、管理文档评论、管理文档权限、订阅用户评论变更事件;也负责把本地 Word/Markdown/Excel/CSV 导入为飞书在线云文档docx、sheet、bitable。当用户需要上传或下载文件、整理云空间目录、查看文件详情、管理评论、管理文档权限、订阅用户评论变更事件或要把本地文件导入成新版文档、电子表格、多维表格/Base 时使用。"
description: "飞书云空间:管理云空间中的文件和文件夹。上传和下载文件、创建文件夹、复制/移动/删除文件、查看文件元数据、管理文档评论、管理文档权限、订阅用户评论变更事件、修改文件标题docx、sheet、bitable、file、folder、wiki;也负责把本地 Word/Markdown/Excel/CSV 导入为飞书在线云文档docx、sheet、bitable。当用户需要上传或下载文件、整理云空间目录、查看文件详情、管理评论、管理文档权限、修改文件标题、订阅用户评论变更事件,或要把本地文件导入成新版文档、电子表格、多维表格/Base 时使用。"
metadata:
requires:
bins: ["lark-cli"]
@@ -21,6 +21,9 @@ metadata:
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`
## 修改标题
- 使用 `drive files patch` 命令通过new_title字段可以修改标题支持 docx、sheet、bitable、file、wiki、folder 类型
## 核心概念
### 文档类型与 Token
@@ -156,7 +159,7 @@ Drive Folder (云空间文件夹)
- 使用 `drive file.comments batch_query` 是**已知评论 ID 后**的批量查询,需要传入具体的评论 ID 列表。
- 使用 `drive file.comments list` 用于分页获取评论列表,适合统计评论总数、遍历所有评论,或获取"最新/最后 N 条评论"等场景。
#### Reaction 场景
#### Reaction / 表情场景
- 遇到评论 / 回复上的 reaction表情、各表情数量、谁点了什么、添加/删除表情)相关问题时,**先阅读 [lark-drive-reactions.md](../../skills/lark-drive/references/lark-drive-reactions.md) 了解如何使用**。
### 典型错误与解决方案
@@ -194,6 +197,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
|----------|------|
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to Drive |
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder |
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a full-document comment, or a local comment to selected docx text (also supports wiki URL resolving to doc/docx) |
| [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling |
| [`+export-download`](references/lark-drive-export-download.md) | Download an exported file by file_token |
@@ -216,6 +220,7 @@ lark-cli drive <resource> <method> [flags] # 调用 API
- `copy` — 复制文件
- `create_folder` — 新建文件夹
- `list` — 获取文件夹下的清单
- `patch` — 修改文件标题
### file.comments
@@ -266,6 +271,7 @@ lark-cli drive <resource> <method> [flags] # 调用 API
| `files.copy` | `docs:document:copy` |
| `files.create_folder` | `space:folder:create` |
| `files.list` | `space:document:retrieve` |
| `files.patch` | `docx:document:write_only` |
| `file.comments.batch_query` | `docs:document.comment:read` |
| `file.comments.create_v2` | `docs:document.comment:create` |
| `file.comments.list` | `docs:document.comment:read` |

View File

@@ -0,0 +1,103 @@
# drive +create-shortcut
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
在目标文件夹中为一个现有 Drive 文件创建快捷方式。
## 命令
```bash
# 为普通文件创建快捷方式
lark-cli drive +create-shortcut \
--folder-token <TARGET_FOLDER_TOKEN> \
--file-token <FILE_TOKEN> \
--type file
# 为新版文档创建快捷方式
lark-cli drive +create-shortcut \
--folder-token <TARGET_FOLDER_TOKEN> \
--file-token <DOCX_TOKEN> \
--type docx
# 为电子表格创建快捷方式
lark-cli drive +create-shortcut \
--folder-token <TARGET_FOLDER_TOKEN> \
--file-token <SHEET_TOKEN> \
--type sheet
# 仅预览即将发起的请求,不真正执行
lark-cli drive +create-shortcut \
--folder-token <TARGET_FOLDER_TOKEN> \
--file-token <DOCX_TOKEN> \
--type docx \
--dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--folder-token` | 是 | 目标父文件夹 token |
| `--file-token` | 是 | 源文件 token表示被引用的原始文件 |
| `--type` | 是 | 源文件类型,推荐值:`file``docx``doc``sheet``bitable``mindnote``slides` |
## 输入规则
- 该 shortcut 的最小输入是 `--folder-token` + `--file-token` + `--type`
- CLI 层会把 `--file-token``--type` 组装为底层 API 所需的 `refer_entity`
- `--file-token` 必须是 Drive 文件 token不要直接传 wiki 节点 token
- 如果来源是 `/wiki/...` 链接,必须先按 [`lark-drive`](../SKILL.md) 中的 wiki 解析流程拿到真实 `obj_token`,再创建快捷方式
- 目标位置必须是云空间文件夹;这个 shortcut 不是“复制文件内容”,而是“在另一个文件夹里挂一个引用入口”
## 类型说明
| 类型 | 说明 |
|------|------|
| `file` | 普通文件 |
| `docx` | 新版云文档 |
| `doc` | 旧版云文档 |
| `sheet` | 电子表格 |
| `bitable` | 多维表格 |
| `mindnote` | 思维笔记 |
| `slides` | 幻灯片 |
## 行为说明
- 成功时会调用 `POST /open-apis/drive/v1/files/create_shortcut`
- 该 shortcut 继承通用能力,可配合 `--as user|bot|auto``--format``--jq``--dry-run` 使用
- `--dry-run` 只输出请求方法、路径、身份和请求体预览,不会真正创建快捷方式
- 这是写入操作;执行前应确认目标文件夹和源文件都准确无误
## 限制
- 该接口不支持并发调用
- 调用频率上限为 5 QPS且 10000 次/天
- 不支持跨租户、跨地域创建快捷方式
- 不支持跨品牌创建快捷方式
- 如果目标父文件夹单层挂载数量超过限制,会返回 `1062507`
## 权限要求
- 当前调用身份需要能访问源文件
- 当前调用身份需要对目标文件夹有编辑权限
- 如果权限不足,常见表现为 `1061004 forbidden`
## 常见错误
| 错误码 / 错误信息 | 原因 | 处理建议 |
|------|------|------|
| `1061002 params error` | 缺少必填参数,或 `--file-token` / `--type` 组合无法构成有效源文件信息 | 检查 `--file-token``--type` 是否完整且匹配;如显式传了 `--folder-token`,再确认其值有效 |
| `1061003 not found` | 源文件或目标文件夹不存在 | 重新确认 token 是否正确、资源是否已删除 |
| `1061004 forbidden` | 对源文件没有访问权限,或对目标文件夹没有编辑权限 | 切换到有权限的身份,或先授予文档 / 文件夹权限 |
| `1061005 auth failed` | 身份类型或 access token 不正确 | 检查 `--as` 使用的身份及当前登录态 |
| `1061007 file has been delete` | 源文件已删除 | 确认原文件仍存在,再重新执行 |
| `1062507 parent node out of sibling num` | 目标文件夹单层挂载数超过上限 | 清理目标目录,或换一个父文件夹 |
| `1061045 resource contention occurred, please retry` | 平台内部资源争抢 | 稍后重试,不要并发重复调用 |
| `1064510 cross tenant and unit not support` | 跨租户或跨地域请求 | 改为在同租户、同地域范围内操作 |
| `1064511 cross brand not support` | 跨品牌请求 | 改为在同品牌环境内操作 |
## 参考
- [lark-drive](../SKILL.md) -- 云空间全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -5,6 +5,33 @@
将文件或文件夹移动到用户云空间的其他位置。
## 与 `wiki +move` 的区别
- `drive +move` 只处理 **Drive 文件夹树内部** 的位置调整,目标位置用 `--folder-token` 表示
- `wiki +move` 处理的是 **Wiki 知识空间 / 页面层级**:要么移动已有 Wiki 节点,要么把 Drive 文档迁入 Wiki
- 如果用户说“移动到某个文件夹”“移动到我的空间根目录”,应使用 `drive +move`
- 如果用户说“移动到某个知识库 / 页面下”“迁入 Wiki / 知识空间”,应使用 `wiki +move`
- 如果用户说“移动到我的文档库 / 我的知识库 / 个人知识库 / my_library”不要使用 `drive +move`;先按 Wiki 目标处理
- `我的文档库` 不是 Drive root folder也不是 `--folder-token` 省略后的默认目的地
- `drive +move` 不支持 wiki 文档;如果目标是 Wiki不要尝试用 `drive +move` 代替
## 不要误用到 `我的文档库`
下面几种说法都**不应该**触发 `drive +move`
- `移动到我的文档库`
- `放到我的知识库`
- `迁入个人知识库`
- `move to My Document Library`
这些目标都应该先走 Wiki 解析流程:
```bash
lark-cli wiki spaces get --params '{"space_id":"my_library"}'
```
拿到真实 `space_id` 后,再改用 `wiki +move`。不要因为 `drive +move` 可以省略 `--folder-token` 就把它当作“我的文档库”的近似目标。
## 命令
```bash
@@ -60,6 +87,7 @@ lark-cli drive +move \
- **轮询超时不是失败**:文件夹移动内置最多轮询 30 次、每次间隔 2 秒;如果轮询结束任务仍未完成,会返回 `task_id``status``ready=false``timed_out=true``next_command`
- **继续查询**:当看到 `next_command` 时,改用 `lark-cli drive +task_result --scenario task_check --task-id <TASK_ID>` 继续查询
- **目标文件夹**:如果不指定 `--folder-token`,文件将被移动到用户的根文件夹("我的空间"
- **不要混淆产品概念**:这里的“根文件夹 / 我的空间”仅属于 Drive 文件夹树,不等于 Wiki 的“我的文档库”
- **权限要求**:需要被移动文件的可管理权限、被移动文件所在位置的编辑权限、目标位置的编辑权限
## 推荐续跑方式

View File

@@ -3,7 +3,7 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
查询异步任务结果。该 shortcut 聚合了导入、导出、移动/删除文件夹等多种异步任务的结果查询,统一接口方便调用。
查询异步任务结果。该 shortcut 聚合了导入、导出、移动/删除文件夹、Wiki 节点 / 文档迁入 Wiki 等多种异步任务的结果查询,统一接口方便调用。
> [!IMPORTANT]
> 对于 `import` 场景,如果使用 `--as bot` 且这次查询**已经拿到最终在线文档目标**`ready=true` 且返回了最终 `token` / `url`CLI 会**再次尝试为当前 CLI 用户自动授予该资源的 `full_access`(可管理权限)**。
@@ -35,15 +35,20 @@ lark-cli drive +task_result \
lark-cli drive +task_result \
--scenario task_check \
--task-id <TASK_ID>
# 查询 Wiki 移动任务结果wiki +move 异步超时后的续跑)
lark-cli drive +task_result \
--scenario wiki_move \
--task-id <TASK_ID>
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--scenario` | 是 | 任务场景,可选值:`import` (导入任务)、`export` (导出任务)、`task_check` (移动/删除文件夹任务) |
| `--scenario` | 是 | 任务场景,可选值:`import` (导入任务)、`export` (导出任务)、`task_check` (移动/删除文件夹任务)`wiki_move` (Wiki 移动任务) |
| `--ticket` | 条件必填 | 异步任务 ticket**import/export 场景必填** |
| `--task-id` | 条件必填 | 异步任务 ID**task_check 场景必填** |
| `--task-id` | 条件必填 | 异步任务 ID**task_check / wiki_move 场景必填** |
| `--file-token` | 条件必填 | 导出任务对应的源文档 token**export 场景必填** |
## 场景说明
@@ -53,6 +58,7 @@ lark-cli drive +task_result \
| `import` | 文档导入任务(如将本地文件导入为云文档) | `--ticket` |
| `export` | 文档导出任务(如云文档导出为 PDF/Word | `--ticket``--file-token` |
| `task_check` | 文件夹移动/删除任务 | `--task-id` |
| `wiki_move` | Wiki 移动任务(`wiki +move` 的 docs-to-wiki 异步流程,超时后续跑用) | `--task-id` |
## 返回结果
@@ -135,6 +141,55 @@ lark-cli drive +task_result \
- `ready`: 是否已经完成
- `failed`: 是否已经失败
### Wiki_move 场景返回
```json
{
"scenario": "wiki_move",
"task_id": "<TASK_ID>",
"ready": true,
"failed": false,
"status": 0,
"status_msg": "success",
"wiki_token": "wikcnXXX",
"node_token": "wikcnXXX",
"space_id": "<TARGET_SPACE_ID>",
"obj_token": "<OBJ_TOKEN>",
"obj_type": "docx",
"parent_node_token": "",
"node_type": "origin",
"origin_node_token": "",
"title": "项目计划",
"has_child": false,
"node": {
"space_id": "<TARGET_SPACE_ID>",
"node_token": "wikcnXXX",
"obj_token": "<OBJ_TOKEN>",
"obj_type": "docx",
"parent_node_token": "",
"node_type": "origin",
"origin_node_token": "",
"title": "项目计划",
"has_child": false
},
"move_results": [
{
"status": 0,
"status_msg": "success",
"node": { "...": "同上" }
}
]
}
```
**字段说明:**
- `ready`: 所有 `move_results[].status` 都为 `0` 时为 `true`
- `failed`: 任一 `move_results[].status` 小于 `0` 时为 `true`
- `status` / `status_msg`: 第一个 move_result 的状态码 / 标签(无结果时回退为 `1` / `processing`
- `wiki_token` / `node_token`: 移入 Wiki 后的目标节点 token首个结果有 `node.node_token` 时镜像到顶层,便于下游脚本使用)
- `space_id``obj_token``obj_type``title` 等:从首个 `move_results[0].node` 平铺到顶层,方便直接引用
- `move_results`: 保留完整列表(适用于一次任务移动多个文档的场景)
## 使用场景
### 配合 +import 使用
@@ -162,6 +217,20 @@ lark-cli drive +move --file-token <FOLDER_TOKEN> --type folder --folder-token <T
lark-cli drive +task_result --scenario task_check --task-id <TASK_ID>
```
### 配合 wiki +move 使用
```bash
# 1. 把 Drive 文档迁入 Wiki异步任务可能返回 task_id
lark-cli wiki +move --obj-type docx --obj-token <DOC_TOKEN> --target-space-id <TARGET_SPACE_ID>
# 若内置轮询窗口内完成:直接返回 ready=true 和 wiki_token
# 若轮询窗口结束仍未完成:返回 ready=false、task_id、timed_out=true 和 next_command
# 2. 续跑查询 Wiki 移动结果next_command 即下面这条)
lark-cli drive +task_result --scenario wiki_move --task-id <TASK_ID> --as user
```
> **身份保持一致**:续跑命令的 `--as` 必须与原 `wiki +move` 调用一致;`wiki +move` 的 `next_command` 已自动带上正确的 `--as`。
### 配合 +export 使用
```bash
@@ -184,6 +253,7 @@ lark-cli drive +export-download --file-token <EXPORTED_FILE_TOKEN>
| import | `drive:drive.metadata:readonly` |
| export | `drive:drive.metadata:readonly` |
| task_check | `drive:drive.metadata:readonly` |
| wiki_move | `wiki:space:read` |
> [!NOTE]
> `import` 场景在 `--as bot` 且任务最终就绪时,还可能额外尝试一次协作者授权;如果 `permission_grant.status = failed`,请根据失败信息检查应用是否具备相应的文档协作者授权能力。

View File

@@ -1,7 +1,7 @@
---
name: lark-im
version: 1.0.0
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员时使用。"
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员时使用。"
metadata:
requires:
bins: ["lark-cli"]
@@ -62,7 +62,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |
| [`+messages-mget`](references/lark-im-messages-mget.md) | Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies |
| [`+messages-reply`](references/lark-im-messages-reply.md) | Reply to a message (supports thread replies); user/bot; supports text/markdown/post/media replies, reply-in-thread, idempotency key |
| [`+messages-resources-download`](references/lark-im-messages-resources-download.md) | Download images/files from a message; user/bot; downloads image/file resources by message-id and file-key to a safe relative output path |
| [`+messages-resources-download`](references/lark-im-messages-resources-download.md) | Download images/files from a message; user/bot; supports automatic chunked download for large files (8MB chunks), auto-detects file extension from Content-Type |
| [`+messages-search`](references/lark-im-messages-search.md) | Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, supports auto-pagination via `--page-all` / `--page-limit`, enriches results via batched mget and chats batch_query |
| [`+messages-send`](references/lark-im-messages-send.md) | Send a message to a chat or direct message; user/bot; sends to chat-id or user-id with text/markdown/post/media, supports idempotency key |
| [`+threads-messages-list`](references/lark-im-threads-messages-list.md) | List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination |

View File

@@ -36,7 +36,7 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --format json
| Parameter | Required | Description |
|------|------|------|
| `--chat-id <id>` | One of two | Specify the conversation by its chat_id directly (e.g., group chat `oc_xxx`) |
| `--user-id <id>` | One of two | Specify a DM conversation by the other user's open_id (`ou_xxx`); p2p chat_id is resolved automatically |
| `--user-id <id>` | One of two | Specify a DM conversation by the other user's open_id (`ou_xxx`); p2p chat_id is resolved automatically. Requires user identity (`--as user`); not supported with bot identity |
| `--start <time>` | No | Start time (ISO 8601 or date only) |
| `--end <time>` | No | End time (ISO 8601 or date only) |
| `--sort <order>` | No | Sort order: `asc` / `desc` (default `desc`) |
@@ -116,6 +116,7 @@ lark-cli api GET /open-apis/im/v1/messages \
|---------|---------|---------|
| `specify --chat-id <chat_id> or --user-id <open_id>` | Neither `--chat-id` nor `--user-id` was provided | You must provide exactly one |
| `--chat-id and --user-id cannot be specified together` | Both parameters were provided | Use only one |
| `--user-id requires user identity (--as user); use --chat-id when calling with bot identity` | `--user-id` was used with bot identity | The p2p resolution endpoint requires user identity. Either pass `--as user` or look up the p2p `chat_id` separately and pass it via `--chat-id` |
| `P2P chat not found for this user` | `--user-id` was used but no p2p chat exists for the current identity and that user | Confirm the target direct-message relationship exists for the current identity |
| `--start: invalid time format` | Invalid time format | Use ISO 8601 or date-only format such as `2026-03-10` |
| Permission denied | Message read permissions are missing | Ensure the app has `im:message:readonly` and `im:chat:read` enabled |
@@ -130,7 +131,7 @@ lark-cli api GET /open-apis/im/v1/messages \
```
**Do not use `im chats search` or `im chats list` — always use the `+chat-search` shortcut.**
2. **Prefer `--chat-id` when available:** if the chat_id is already known, use it directly to avoid extra API calls.
3. **For direct messages:** use `--user-id` to resolve the p2p chat automatically instead of looking it up manually.
3. **For direct messages:** use `--user-id` to resolve the p2p chat automatically instead of looking it up manually. This requires user identity (`--as user`); with bot identity, resolve the p2p `chat_id` yourself and pass it via `--chat-id`.
4. **For time ranges:** both ISO 8601 and date-only inputs are supported. Date-only is usually simpler.
5. **For full content:** table output truncates content. Use `--format json` when you need the complete message body.
6. **For sender info:** the command already resolves sender names, so you do not need a separate lookup.

View File

@@ -2,7 +2,7 @@
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
Download image or file resources from a message. Resources are identified by the combination of `message_id` + `file_key`, both of which come directly from message content returned by `im +chat-messages-list`.
Download image or file resources from a message. Supports **automatic chunked download for large files** using HTTP Range requests. Resources are identified by the combination of `message_id` + `file_key`, both of which come directly from message content returned by `im +chat-messages-list`.
> **Note:** read-only message commands render resource keys in message content, but they do not download binaries automatically. Use this command whenever you need to fetch the actual image/file bytes or save them to a specific path.
@@ -34,10 +34,26 @@ lark-cli im +messages-resources-download --message-id om_xxx --file-key img_v3_x
| `--message-id <id>` | Yes | Message ID (`om_xxx` format) |
| `--file-key <key>` | Yes | Resource key (`img_xxx` or `file_xxx`) |
| `--type <type>` | Yes | Resource type: `image` or `file` |
| `--output <path>` | No | Output path (relative paths only; `..` traversal is not allowed; defaults to `file_key` as the file name) |
| `--output <path>` | No | Output path (relative paths only; `..` traversal is not allowed; defaults to `file_key` as the file name). File extension is automatically added based on Content-Type if not provided |
| `--as <identity>` | No | Identity type: `user` (default) or `bot` |
| `--dry-run` | No | Print the request only, do not execute it |
## Large File Download (Auto Chunking)
When downloading large files, the command automatically uses **HTTP Range requests** for reliable chunked downloading:
| Behavior | Details |
|----------|---------|
| Probe chunk | First 128 KB to detect file size and Content-Type |
| Chunk size | 8 MB per subsequent request |
| Workers | Single-threaded sequential download (ensures reliability) |
| Retries | Up to 2 retries for transient request failures, with exponential backoff |
**Benefits:**
- Reduces the impact of transient request failures during large downloads
- Automatically detects and appends correct file extension from Content-Type
- Validates file size integrity after download completion
## `file_key` Sources
Different resource markers in message content correspond to different `file_key` and `type` values:
@@ -69,7 +85,8 @@ lark-cli im +messages-resources-download --message-id om_xxx --file-key img_v3_x
| Download failed | `file_key` does not match the `message_id` | Make sure the `file_key` came from that message's content |
| Hit error code 234002 or 14005 | No permission, **not** missing API scope | no access to this chat or file was deleted — do not retry, return the error to the user |
| Permission denied | `im:message:readonly` is not authorized | Run `auth login --scope "im:message:readonly"` |
| File too large | Over the 100 MB limit | This is a Feishu API limitation and cannot be bypassed with this endpoint |
| File size mismatch | Chunked download integrity check failed | Network instability during download; retry the command |
| Content-Range error | Server returned invalid range header | Transient API issue; retry the command |
## References

View File

@@ -165,6 +165,16 @@ Shortcut 是对常用操作的高级封装(`lark-cli sheets +<verb> [flags]`
| [`+update-dimension`](references/lark-sheets-update-dimension.md) | Update row or column properties (visibility, size) |
| [`+move-dimension`](references/lark-sheets-move-dimension.md) | Move rows or columns to a new position |
| [`+delete-dimension`](references/lark-sheets-delete-dimension.md) | Delete rows or columns |
| [`+create-filter-view`](references/lark-sheets-create-filter-view.md) | Create a filter view |
| [`+update-filter-view`](references/lark-sheets-update-filter-view.md) | Update a filter view |
| [`+list-filter-views`](references/lark-sheets-list-filter-views.md) | List all filter views in a sheet |
| [`+get-filter-view`](references/lark-sheets-get-filter-view.md) | Get a filter view by ID |
| [`+delete-filter-view`](references/lark-sheets-delete-filter-view.md) | Delete a filter view |
| [`+create-filter-view-condition`](references/lark-sheets-create-filter-view-condition.md) | Create a filter condition on a filter view |
| [`+update-filter-view-condition`](references/lark-sheets-update-filter-view-condition.md) | Update a filter condition |
| [`+list-filter-view-conditions`](references/lark-sheets-list-filter-view-conditions.md) | List all filter conditions of a filter view |
| [`+get-filter-view-condition`](references/lark-sheets-get-filter-view-condition.md) | Get a filter condition by column |
| [`+delete-filter-view-condition`](references/lark-sheets-delete-filter-view-condition.md) | Delete a filter condition |
## API Resources

View File

@@ -0,0 +1,42 @@
# sheets +create-filter-view-condition创建筛选条件
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +create-filter-view-condition`
为筛选视图的指定列创建筛选条件。
> [!CAUTION]
> 这是**写入操作** —— 执行前必须确认用户意图。
## 命令
```bash
# 数值筛选E 列 < 6
lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --filter-view-id "<fvId>" \
--condition-id "E" --filter-type "number" --compare-type "less" --expected '["6"]'
# 文本筛选G 列以 a 开头
lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --filter-view-id "<fvId>" \
--condition-id "G" --filter-type "text" --compare-type "beginsWith" --expected '["a"]'
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--filter-view-id` | 是 | 筛选视图 ID |
| `--condition-id` | 是 | 列字母(如 `E` |
| `--filter-type` | 是 | 筛选类型:`hiddenValue``number``text``color` |
| `--compare-type` | 否 | 比较运算符(如 `less``beginsWith``between` |
| `--expected` | 是 | 筛选值 JSON 数组(如 `["6"]``["2","10"]` |
## 输出
JSON包含 `condition`condition_id, filter_type, compare_type, expected

View File

@@ -0,0 +1,42 @@
# sheets +create-filter-view创建筛选视图
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +create-filter-view`
在工作表中创建筛选视图,每个工作表最多 150 个筛选视图。
> [!CAUTION]
> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。
## 命令
```bash
lark-cli sheets +create-filter-view --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --range "<sheetId>!A1:H14"
# 指定名称
lark-cli sheets +create-filter-view --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --range "<sheetId>!A1:H14" --filter-view-name "我的筛选"
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--range` | 是 | 筛选范围(如 `sheetId!A1:H14` |
| `--filter-view-name` | 否 | 显示名称(最多 100 字符) |
| `--filter-view-id` | 否 | 自定义 10 位字母数字 ID不传则自动生成 |
## 输出
JSON包含 `filter_view`filter_view_id, filter_view_name, range
## 参考
- [lark-sheets-list-filter-views](lark-sheets-list-filter-views.md) — 查询所有筛选视图
- [lark-sheets-create-filter-view-condition](lark-sheets-create-filter-view-condition.md) — 添加筛选条件

View File

@@ -0,0 +1,26 @@
# sheets +delete-filter-view-condition删除筛选条件
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +delete-filter-view-condition`
> [!CAUTION]
> 这是**破坏性写入操作** —— 删除后不可恢复。
## 命令
```bash
lark-cli sheets +delete-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --filter-view-id "<fvId>" --condition-id "E"
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--filter-view-id` | 是 | 筛选视图 ID |
| `--condition-id` | 是 | 列字母(如 `E` |

View File

@@ -0,0 +1,25 @@
# sheets +delete-filter-view删除筛选视图
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +delete-filter-view`
> [!CAUTION]
> 这是**破坏性写入操作** —— 删除后不可恢复。执行前必须确认用户意图。
## 命令
```bash
lark-cli sheets +delete-filter-view --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --filter-view-id "<fvId>"
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--filter-view-id` | 是 | 筛选视图 ID |

View File

@@ -0,0 +1,27 @@
# sheets +get-filter-view-condition获取筛选条件
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +get-filter-view-condition`
## 命令
```bash
lark-cli sheets +get-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --filter-view-id "<fvId>" --condition-id "E"
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--filter-view-id` | 是 | 筛选视图 ID |
| `--condition-id` | 是 | 列字母(如 `E` |
## 输出
JSON包含 `condition`condition_id, filter_type, compare_type, expected

View File

@@ -0,0 +1,26 @@
# sheets +get-filter-view获取筛选视图
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +get-filter-view`
## 命令
```bash
lark-cli sheets +get-filter-view --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --filter-view-id "<fvId>"
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--filter-view-id` | 是 | 筛选视图 ID |
## 输出
JSON包含 `filter_view`filter_view_id, filter_view_name, range

View File

@@ -0,0 +1,28 @@
# sheets +list-filter-view-conditions查询筛选条件
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +list-filter-view-conditions`
查询筛选视图的所有筛选条件。
## 命令
```bash
lark-cli sheets +list-filter-view-conditions --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --filter-view-id "<fvId>"
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--filter-view-id` | 是 | 筛选视图 ID |
## 输出
JSON包含 `items[]`condition_id, filter_type, compare_type, expected

View File

@@ -0,0 +1,26 @@
# sheets +list-filter-views查询筛选视图
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +list-filter-views`
查询工作表中的所有筛选视图,返回视图 ID、名称和范围。
## 命令
```bash
lark-cli sheets +list-filter-views --spreadsheet-token "shtxxxxxxxx" --sheet-id "<sheetId>"
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
## 输出
JSON包含 `items[]`filter_view_id, filter_view_name, range

View File

@@ -0,0 +1,27 @@
# sheets +update-filter-view-condition更新筛选条件
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +update-filter-view-condition`
## 命令
```bash
lark-cli sheets +update-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --filter-view-id "<fvId>" --condition-id "E" \
--filter-type "number" --compare-type "between" --expected '["2","10"]'
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--filter-view-id` | 是 | 筛选视图 ID |
| `--condition-id` | 是 | 列字母(如 `E` |
| `--filter-type` | 否 | 筛选类型 |
| `--compare-type` | 否 | 比较运算符 |
| `--expected` | 否 | 筛选值 JSON 数组 |

View File

@@ -0,0 +1,24 @@
# sheets +update-filter-view更新筛选视图
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
本 skill 对应 shortcut`lark-cli sheets +update-filter-view`
## 命令
```bash
lark-cli sheets +update-filter-view --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --filter-view-id "<fvId>" --range "<sheetId>!A1:J20"
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--filter-view-id` | 是 | 筛选视图 ID |
| `--range` | 否 | 新的筛选范围 |
| `--filter-view-name` | 否 | 新的显示名称 |

View File

@@ -83,6 +83,15 @@ lark-cli task <resource> <method> [flags] # 调用 API
- `add` — 添加任务成员
- `remove` — 移除任务成员
### sections
- `create` — 创建自定义分组
- `delete` — 删除自定义分组
- `get` — 获取自定义分组详情
- `list` — 获取自定义分组列表
- `patch` — 更新自定义分组
- `tasks` — 获取自定义分组任务列表
## 权限表
| 方法 | 所需 scope |
@@ -104,3 +113,9 @@ lark-cli task <resource> <method> [flags] # 调用 API
| `subtasks.list` | `task:task:read` |
| `members.add` | `task:task:write` |
| `members.remove` | `task:task:write` |
| `sections.create` | `task:section:write` |
| `sections.delete` | `task:section:write` |
| `sections.get` | `task:section:read` |
| `sections.list` | `task:section:read` |
| `sections.patch` | `task:section:write` |
| `sections.tasks` | `task:section:read` |

View File

@@ -12,6 +12,12 @@ lark-cli task +tasklist-task-add --tasklist-id "<tasklist_guid>" --task-id "<tas
# Add multiple tasks to a tasklist
lark-cli task +tasklist-task-add --tasklist-id "<tasklist_guid>" --task-id "<task_guid>,<another_task_guid>,<third_task_guid>"
# Add a task to a specific section in the tasklist
lark-cli task +tasklist-task-add \
--tasklist-id "<tasklist_guid>" \
--task-id "<task_guid>" \
--section-guid "<section_guid>"
```
## Parameters
@@ -20,6 +26,7 @@ lark-cli task +tasklist-task-add --tasklist-id "<tasklist_guid>" --task-id "<tas
|-----------|----------|-------------|
| `--tasklist-id <guid>` | Yes | The GUID of the tasklist, or a full AppLink URL. |
| `--task-id <guids>` | Yes | Comma-separated list of task GUIDs to add to the tasklist. For Feishu task applinks, use each task's `guid` query parameter, not the `suite_entity_num` / display task ID like `t104121`. |
| `--section-guid <guid>` | No | The GUID of the custom section to add the tasks to. If omitted, tasks will be added to the default section. |
## Workflow

View File

@@ -1,7 +1,7 @@
---
name: lark-wiki
version: 1.0.0
description: "飞书知识库:管理知识空间和文档节点。创建和查询知识空间、管理节点层级结构、在知识库中组织文档和快捷方式。当用户需要在知识库中查找或创建文档、浏览知识空间结构、移动或复制节点时使用。"
description: "飞书知识库:管理知识空间、空间成员和文档节点。创建和查询知识空间、查看和管理空间成员、管理节点层级结构、在知识库中组织文档和快捷方式。当用户需要在知识库中查找或创建文档、浏览知识空间结构、查看或管理空间成员、移动或复制节点时使用。"
metadata:
requires:
bins: ["lark-cli"]
@@ -12,14 +12,44 @@ metadata:
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
> **成员管理硬限制:**
> - 如果目标是“部门”,先判断身份,再决定是否继续。
> - `--as bot` 对应 `tenant_access_token`。官方限制:这种身份下不能使用部门 ID (`opendepartmentid`) 添加知识空间成员。
> - 遇到“部门 + --as bot”时禁止先调用 `lark-cli wiki members create` 试错;直接说明该路径不可行。
> - 如果用户明确要求“以 bot 身份运行”,且目标是部门,必须停下说明 bot 路径无法完成,不要静默切到 `--as user`。
## 快速决策
- 用户给的是知识库 URL`.../wiki/<token>`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'` 获取 `space_id`,后续成员接口统一使用 `space_id`
- 用户说“给知识库添加成员/管理员”:先把目标解析成“用户 / 群 / 部门”三类之一,再决定 `member_type`,不要先调 `wiki members create` 再根据报错反推类型。
- 用户说“部门 + bot”这是已知不支持路径。不要继续尝试 `wiki members create --as bot`;直接提示必须改成 `--as user`,或明确告知当前要求无法完成。
- 用户说“用户 / 群 + 添加成员”:先解析对应 ID再执行 `wiki members create`
## 成员添加流程
- 调用 `lark-cli wiki members create` 前,先把自然语言里的“人 / 群 / 部门”解析成正确的 `member_id`,不要猜格式。
- 用户场景默认优先 `member_type=openid`:用 `lark-cli contact +search-user --query "<姓名/邮箱/手机号>" --format json` 获取 `open_id`
- 群组场景使用 `member_type=openchat`:用 `lark-cli im +chat-search --query "<群名关键词>" --format json` 获取 `chat_id`
- `userid` / `unionid` 只在下游明确要求时才使用;先拿到 `open_id`,再调用 `lark-cli api GET /open-apis/contact/v3/users/<open_id> --params '{"user_id_type":"open_id"}' --format json` 读取 `user_id` / `union_id`
- 部门场景使用 `member_type=opendepartmentid`:当前 CLI 没有 shortcut需调用 `lark-cli api POST /open-apis/contact/v3/departments/search --as user --params '{"department_id_type":"open_department_id"}' --data '{"query":"<部门名>"}'` 获取 `open_department_id`
- 只有在目标类型和身份都已确认可行后,才调用 `lark-cli wiki members create`。对于部门场景,这意味着必须是 `--as user`
## Shortcuts推荐优先使用
Shortcut 是对常用操作的高级封装(`lark-cli wiki +<verb> [flags]`)。有 Shortcut 的操作优先使用。
| Shortcut | 说明 |
|----------|------|
| [`+move`](references/lark-wiki-move.md) | Move a wiki node, or move a Drive document into Wiki |
| [`+node-create`](references/lark-wiki-node-create.md) | Create a wiki node with automatic space resolution |
## 目标语义约束
- `我的文档库` / `My Document Library` / `我的知识库` / `个人知识库` / `my_library` 都应视为 **Wiki personal library**,不是 Drive 根目录
- 处理这类目标时,先解析 `my_library` 对应的真实 `space_id`,再执行 `wiki +move``wiki +node-create` 或其他 Wiki 写操作
- 不要因为缺少显式 `space_id` 就退化成 `drive +move`
- 如果用户明确说的是 Drive 文件夹、云空间根目录、`我的空间`,才进入 Drive 域处理
## API Resources
```bash
@@ -35,6 +65,12 @@ lark-cli wiki <resource> <method> [flags] # 调用 API
- `get_node` — 获取知识空间节点信息
- `list` — 获取知识空间列表
### members
- `create` — 添加知识空间成员
- `delete` — 删除知识空间成员
- `list` — 获取知识空间成员列表
### nodes
- `copy` — 创建知识空间节点副本
@@ -48,6 +84,9 @@ lark-cli wiki <resource> <method> [flags] # 调用 API
| `spaces.get` | `wiki:space:read` |
| `spaces.get_node` | `wiki:node:read` |
| `spaces.list` | `wiki:space:retrieve` |
| `members.create` | `wiki:member:create` |
| `members.delete` | `wiki:member:update` |
| `members.list` | `wiki:member:retrieve` |
| `nodes.copy` | `wiki:node:copy` |
| `nodes.create` | `wiki:node:create` |
| `nodes.list` | `wiki:node:retrieve` |

View File

@@ -0,0 +1,183 @@
# wiki +move
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
在飞书知识库中移动已有 Wiki 节点,或将 Drive 文档迁入 Wiki。这个 shortcut 统一封装了两类流程:
- `node` 模式:移动已有 Wiki 节点,可同空间移动,也可跨空间移动
- `docs_to_wiki` 模式:把 Drive 文档迁入目标知识空间;必要时可提交移动申请,并在异步任务场景下自动有限轮询
`docs_to_wiki` 返回 `task_id`shortcut 会先轮询一小段时间;如果轮询窗口内仍未完成,会返回 `next_command`,让调用方继续执行 `lark-cli drive +task_result --scenario wiki_move --task-id <TASK_ID>`
## 与 `drive +move` 的区别
- `wiki +move` 的目标是 **知识空间或 Wiki 父节点**,使用 `--target-space-id` / `--target-parent-token`
- `drive +move` 的目标是 **Drive 文件夹**,使用 `--folder-token`
- 如果源对象已经是 Wiki 节点,必须使用 `wiki +move`,而不是 `drive +move`
- 如果源对象还是 Drive 文档,但用户要“迁入知识库”“挂到某个 Wiki 页面下”,也应使用 `wiki +move`
- 如果用户只是想整理云空间文件夹,把文件/文件夹挪到另一个 Drive 文件夹,应使用 `drive +move`
## 口语目标识别
- 当用户说“移动到某个知识库”“挂到某个页面下”“迁入 Wiki”时**Wiki 目标** 处理,优先使用 `wiki +move`
- 当用户说“移动到某个文件夹”“移动到云空间根目录”时,按 **Drive 文件夹目标** 处理,优先使用 `drive +move`
- 当用户说“移动到我的文档库”“移动到我的知识库”“放到个人知识库”时,应先按 **Wiki 个人知识库目标** 理解,而不是直接退化成 `drive +move`
- 遇到“我的文档库”这类表述时,可以把它理解成:先用 `my_library` 去查询用户个人知识库,再拿到真实 `space_id`
- 推荐做法是先执行 `lark-cli wiki spaces get --params '{"space_id":"my_library"}'`,取回真实知识库 `space_id`,再把这个 `space_id` 用到 `wiki +move`
- 当前 `wiki +move` 文档的主示例仍以显式 `--target-space-id` / `--target-parent-token` 为主;如果调用方只有自然语言目标,不要因为目标暂时不明确就改走 `drive +move`
## 命令
```bash
# 将已有 wiki 节点移动到另一个父节点下
lark-cli wiki +move \
--node-token <NODE_TOKEN> \
--target-parent-token <TARGET_PARENT_TOKEN>
# 将已有 wiki 节点移动到另一个知识空间根目录
lark-cli wiki +move \
--node-token <NODE_TOKEN> \
--target-space-id <TARGET_SPACE_ID>
# 将 Drive 文档迁入某个知识空间根目录
lark-cli wiki +move \
--obj-type docx \
--obj-token <DOC_TOKEN> \
--target-space-id <TARGET_SPACE_ID>
# 将 Drive 文档迁入某个父节点下;如果当前没有直接移动权限,则提交申请
lark-cli wiki +move \
--obj-type sheet \
--obj-token <SHEET_TOKEN> \
--target-space-id <TARGET_SPACE_ID> \
--target-parent-token <TARGET_PARENT_TOKEN> \
--apply
# 预览底层调用链
lark-cli wiki +move \
--obj-type docx \
--obj-token <DOC_TOKEN> \
--target-space-id <TARGET_SPACE_ID> \
--dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--node-token` | 条件必填 | 要移动的 Wiki 节点 token。传入后命令进入 `node` 模式 |
| `--source-space-id` | 否 | 源知识空间 ID`node` 模式可用;不传时会根据 `--node-token` 自动解析 |
| `--target-space-id` | 条件必填 | 目标知识空间 ID。`docs_to_wiki` 模式必填;`node` 模式下如果不传,则必须传 `--target-parent-token` |
| `--target-parent-token` | 否 | 目标父节点 token。`docs_to_wiki` 不传时表示迁入目标知识空间根目录 |
| `--obj-type` | 条件必填 | Drive 文档类型,仅 `docs_to_wiki` 模式可用。可选值:`doc``sheet``bitable``mindnote``docx``file``slides` |
| `--obj-token` | 条件必填 | Drive 文档 token`docs_to_wiki` 模式可用 |
| `--apply` | 否 | 仅 `docs_to_wiki` 模式可用;当当前调用方不能直接移动文档时,提交一个 move request |
## 模式选择与校验规则
- **`node` 模式**:只要传了 `--node-token`,就会按“移动已有 Wiki 节点”执行
- **`docs_to_wiki` 模式**:未传 `--node-token` 时,按“把 Drive 文档迁入 Wiki”执行
- `node` 模式下,`--node-token` 不能与 `--obj-type``--obj-token``--apply` 同时使用
- `node` 模式下,`--target-parent-token``--target-space-id` 不能同时为空
- `docs_to_wiki` 模式下,必须同时提供 `--obj-type``--obj-token``--target-space-id`
- `docs_to_wiki` 模式下,`--source-space-id` 非法,只能用于 `node` 模式
## 空间解析与一致性校验
### `node` 模式
- **源空间解析**:如果未传 `--source-space-id`shortcut 会先调用 `GET /open-apis/wiki/v2/spaces/get_node` 查询 `--node-token`,再读取其 `space_id`
- **目标父节点解析**:如果传了 `--target-parent-token`shortcut 会先解析该父节点所属的 `space_id`
- **一致性校验**:如果同时传了 `--target-space-id``--target-parent-token`shortcut 会校验两者是否属于同一个知识空间;不一致时直接返回验证错误
- **移动到空间根目录**:如果只传 `--target-space-id`,则表示移动到该知识空间根目录
### `docs_to_wiki` 模式
- `--target-space-id` 始终必填
- `--target-parent-token` 可选;不传时表示移动到目标知识空间根目录
- 请求体会自动映射成 `obj_type``obj_token``parent_wiki_token``apply`
## 行为说明
- **`node` 模式是同步操作**:请求成功后直接返回移动后的节点信息
- **`docs_to_wiki` 可能是同步,也可能是异步**
- 如果接口直接返回 `wiki_token`shortcut 会立刻返回 `ready=true`
- 如果接口返回 `applied=true`shortcut 会返回 `ready=false``failed=false``applied=true``status_msg="move request submitted for approval"`
- 如果接口返回 `task_id`shortcut 会先进入有限轮询
- **有限轮询窗口**:固定最多轮询 `30` 次,每次间隔 `2`
- **轮询超时不是失败**:如果轮询窗口结束任务仍在处理中,会返回 `task_id``status``status_msg``ready=false``timed_out=true``next_command`
- **继续查询**:看到 `next_command` 后,改用 `lark-cli drive +task_result --scenario wiki_move --task-id <TASK_ID>` 继续查
- **任务失败直接报错**如果轮询期间任务进入失败态shortcut 会直接返回错误,不会再输出 `ready=false` 结果
- **轮询请求全部失败时也直接报错**如果任务已创建但后续每一次状态查询都失败shortcut 会返回带 hint 的错误,并给出继续查询命令
## 返回结果
### `node` 模式典型返回
```json
{
"mode": "node",
"source_space_id": "space_src",
"target_space_id": "space_dst",
"space_id": "space_dst",
"node_token": "wikcnode_xxx",
"obj_token": "doccn_xxx",
"obj_type": "docx",
"parent_node_token": "wikcparent_xxx",
"node_type": "origin",
"origin_node_token": "",
"title": "项目计划",
"has_child": false
}
```
### `docs_to_wiki` 异步超时返回
```json
{
"mode": "docs_to_wiki",
"obj_type": "docx",
"obj_token": "doccn_xxx",
"target_space_id": "space_xxx",
"target_parent_token": "wikcparent_xxx",
"task_id": "7500000000000000001",
"ready": false,
"failed": false,
"status": 1,
"status_msg": "processing",
"timed_out": true,
"next_command": "lark-cli drive +task_result --scenario wiki_move --task-id 7500000000000000001"
}
```
**输出字段说明:**
- `mode`:当前执行模式,值为 `node``docs_to_wiki`
- `ready`:任务是否已经完成并可直接继续使用结果
- `failed`:任务是否已失败
- `task_id`:异步任务 ID仅异步场景返回
- `status` / `status_msg`:异步任务的主状态码和可读状态
- `wiki_token`docs-to-wiki 成功后返回的 Wiki 节点 token同时也会镜像到 `node_token`
- `space_id``node_token``obj_token``obj_type``parent_node_token``title` 等:成功拿到节点信息时返回,方便下游继续调用
## dry-run 编排
- `node` 模式下dry-run 会根据是否需要解析源节点 / 目标父节点,展示 1 到 3 步的调用链
- `docs_to_wiki` 模式下dry-run 会展示两步:
1. `POST /open-apis/wiki/v2/spaces/{target_space_id}/nodes/move_docs_to_wiki`
2. `GET /open-apis/wiki/v2/tasks/{task_id}?task_type=move`
## 权限说明
CLI 会在执行前做本地 scope 预检查;当前 shortcut 声明的权限为 `wiki:node:move``wiki:node:read``wiki:space:read`(分别覆盖 move 写操作、节点解析读操作、以及异步任务轮询读操作)。如果本地 token 已记录 scopes 且缺失任一权限,命令会直接提示重新执行 `lark-cli auth login --scope ...`
当异步任务超时后,后续 `lark-cli drive +task_result --scenario wiki_move --task-id <TASK_ID>` 只需要 `wiki:space:read` 权限。
> [!CAUTION]
> `wiki +move` 是**写入操作**。执行前必须确认用户意图,以及目标节点 / 目标知识空间是否明确。
## 参考
- [lark-wiki](../SKILL.md) -- 知识库全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
- [drive +task_result](../../lark-drive/references/lark-drive-task-result.md) -- docs-to-wiki 异步任务的续跑查询命令

View File

@@ -0,0 +1,77 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestBase_BasicWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
baseName := "lark-cli-e2e-base-basic-" + clie2e.GenerateSuffix()
baseToken := createBaseWithRetry(t, ctx, baseName)
t.Run("get base", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+base-get", "--base-token", baseToken},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
returnedBaseToken := gjson.Get(result.Stdout, "data.base.app_token").String()
if returnedBaseToken == "" {
returnedBaseToken = gjson.Get(result.Stdout, "data.base.base_token").String()
}
assert.Equal(t, baseToken, returnedBaseToken, "stdout:\n%s", result.Stdout)
assert.NotEmpty(t, gjson.Get(result.Stdout, "data.base.name").String(), "stdout:\n%s", result.Stdout)
})
tableName := "lark-cli-e2e-table-basic-" + clie2e.GenerateSuffix()
tableID, primaryFieldID, primaryViewID := createTableWithRetry(
t,
parentT,
ctx,
baseToken,
tableName,
`[{"name":"Name","type":"text"}]`,
`{"name":"Main","type":"grid"}`,
)
t.Run("get table", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+table-get", "--base-token", baseToken, "--table-id", tableID},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, tableID, gjson.Get(result.Stdout, "data.table.id").String())
assert.Equal(t, tableName, gjson.Get(result.Stdout, "data.table.name").String())
})
t.Run("list tables and find created table", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+table-list", "--base-token", baseToken},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.True(t, gjson.Get(result.Stdout, `data.tables.#(id=="`+tableID+`")`).Exists(), "stdout:\n%s", result.Stdout)
})
require.NotEmpty(t, primaryFieldID)
require.NotEmpty(t, primaryViewID)
}

View File

@@ -0,0 +1,127 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestBase_RoleWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
baseToken := createBaseWithRetry(t, ctx, "lark-cli-e2e-base-role-"+clie2e.GenerateSuffix())
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+advperm-enable", "--base-token", baseToken},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
roleName := "Reviewer-" + clie2e.GenerateSuffix()
createRole(t, ctx, baseToken, `{"role_name":"`+roleName+`","role_type":"custom_role"}`)
roleID := ""
parentT.Cleanup(func() {
if roleID == "" {
return
}
cleanupCtx, cancel := cleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"base", "+role-delete", "--base-token", baseToken, "--role-id", roleID, "--yes"},
DefaultAs: "bot",
})
if deleteErr != nil || deleteResult.ExitCode != 0 {
reportCleanupFailure(parentT, "delete role "+roleID, deleteResult, deleteErr)
}
})
t.Run("list", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+role-list", "--base-token", baseToken},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
roleListPayload := gjson.Get(result.Stdout, "data.data").String()
require.NotEmpty(t, roleListPayload, "stdout:\n%s", result.Stdout)
assert.True(t, gjson.Valid(roleListPayload), "role list payload should be valid JSON: %s", roleListPayload)
roleItems := gjson.Get(roleListPayload, "base_roles").Array()
assert.NotEmpty(t, roleItems, "role list should contain at least one role: %s", roleListPayload)
found := false
for _, item := range roleItems {
rolePayload := item.String()
if !gjson.Valid(rolePayload) {
continue
}
if gjson.Get(rolePayload, "role_name").String() == roleName {
roleID = gjson.Get(rolePayload, "role_id").String()
found = true
break
}
}
require.True(t, found, "stdout:\n%s", result.Stdout)
require.NotEmpty(t, roleID, "stdout:\n%s", result.Stdout)
})
t.Run("get", func(t *testing.T) {
require.NotEmpty(t, roleID, "role ID should be resolved before get")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+role-get", "--base-token", baseToken, "--role-id", roleID},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
rolePayload := gjson.Get(result.Stdout, "data.data").String()
require.NotEmpty(t, rolePayload, "stdout:\n%s", result.Stdout)
require.True(t, gjson.Valid(rolePayload), "stdout:\n%s", result.Stdout)
assert.Equal(t, roleID, gjson.Get(rolePayload, "role_id").String())
})
t.Run("update", func(t *testing.T) {
require.NotEmpty(t, roleID, "role ID should be resolved before update")
updatedRoleName := roleName + " Updated"
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+role-update", "--base-token", baseToken, "--role-id", roleID, "--json", `{"role_name":"` + updatedRoleName + `","role_type":"custom_role"}`, "--yes"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
getResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+role-get", "--base-token", baseToken, "--role-id", roleID},
DefaultAs: "bot",
})
require.NoError(t, err)
getResult.AssertExitCode(t, 0)
getResult.AssertStdoutStatus(t, true)
rolePayload := gjson.Get(getResult.Stdout, "data.data").String()
require.NotEmpty(t, rolePayload, "stdout:\n%s", getResult.Stdout)
require.True(t, gjson.Valid(rolePayload), "stdout:\n%s", getResult.Stdout)
assert.Equal(t, updatedRoleName, gjson.Get(rolePayload, "role_name").String())
})
}

View File

@@ -0,0 +1,164 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
const cleanupTimeout = 30 * time.Second
func reportCleanupFailure(parentT *testing.T, prefix string, result *clie2e.Result, err error) {
parentT.Helper()
if err != nil {
parentT.Errorf("%s: %v", prefix, err)
return
}
if result == nil {
parentT.Errorf("%s: nil result", prefix)
return
}
if isCleanupSuppressedResult(result) {
return
}
parentT.Errorf("%s failed: exit=%d stdout=%s stderr=%s", prefix, result.ExitCode, result.Stdout, result.Stderr)
}
func cleanupContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), cleanupTimeout)
}
func isCleanupSuppressedResult(result *clie2e.Result) bool {
if result == nil {
return false
}
raw := strings.TrimSpace(result.Stdout)
if raw == "" {
raw = strings.TrimSpace(result.Stderr)
}
if raw == "" {
return false
}
start := strings.LastIndex(raw, "\n{")
if start >= 0 {
start++
} else {
start = strings.Index(raw, "{")
}
if start < 0 {
return false
}
payload := raw[start:]
if !gjson.Valid(payload) {
return false
}
if gjson.Get(payload, "error.type").String() != "api_error" {
return false
}
if gjson.Get(payload, "error.detail.type").String() == "not_found" ||
strings.Contains(strings.ToLower(gjson.Get(payload, "error.message").String()), "not found") {
return true
}
return gjson.Get(payload, "error.code").Int() == 800004135 ||
strings.Contains(strings.ToLower(gjson.Get(payload, "error.message").String()), " limited")
}
func createBaseWithRetry(t *testing.T, ctx context.Context, name string) string {
t.Helper()
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"base", "+base-create", "--name", name, "--time-zone", "Asia/Shanghai"},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
baseToken := gjson.Get(result.Stdout, "data.base.app_token").String()
if baseToken == "" {
baseToken = gjson.Get(result.Stdout, "data.base.base_token").String()
}
require.NotEmpty(t, baseToken, "stdout:\n%s", result.Stdout)
return baseToken
}
func createTableWithRetry(t *testing.T, parentT *testing.T, ctx context.Context, baseToken string, name string, fieldsJSON string, viewJSON string) (tableID string, primaryFieldID string, primaryViewID string) {
t.Helper()
args := []string{"base", "+table-create", "--base-token", baseToken, "--name", name}
if fieldsJSON != "" {
args = append(args, "--fields", fieldsJSON)
}
if viewJSON != "" {
args = append(args, "--view", viewJSON)
}
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: args,
DefaultAs: "bot",
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
tableID = gjson.Get(result.Stdout, "data.table.id").String()
if tableID == "" {
tableID = gjson.Get(result.Stdout, "data.table.table_id").String()
}
require.NotEmpty(t, tableID, "stdout:\n%s", result.Stdout)
primaryFieldID = gjson.Get(result.Stdout, "data.fields.0.id").String()
if primaryFieldID == "" {
primaryFieldID = gjson.Get(result.Stdout, "data.fields.0.field_id").String()
}
primaryViewID = gjson.Get(result.Stdout, "data.views.0.id").String()
if primaryViewID == "" {
primaryViewID = gjson.Get(result.Stdout, "data.views.0.view_id").String()
}
parentT.Cleanup(func() {
cleanupCtx, cancel := cleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"base", "+table-delete", "--base-token", baseToken, "--table-id", tableID, "--yes"},
DefaultAs: "bot",
})
if deleteErr != nil || deleteResult.ExitCode != 0 {
reportCleanupFailure(parentT, "delete table "+tableID, deleteResult, deleteErr)
}
})
return tableID, primaryFieldID, primaryViewID
}
func createRole(t *testing.T, ctx context.Context, baseToken string, body string) string {
t.Helper()
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"base", "+role-create", "--base-token", baseToken, "--json", body},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
return gjson.Get(result.Stdout, "data.role_id").String()
}

View File

@@ -0,0 +1,86 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestCalendar_CreateEvent tests the workflow of creating a calendar event.
func TestCalendar_CreateEvent(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
eventSummary := "lark-cli-e2e-event-" + suffix
eventDescription := "test event description"
startAt := time.Now().UTC().Add(1 * time.Hour).Truncate(time.Minute)
endAt := startAt.Add(1 * time.Hour)
startTime := startAt.Format(time.RFC3339)
endTime := endAt.Format(time.RFC3339)
var eventID string
calendarID := getPrimaryCalendarID(t, ctx)
t.Run("create event with shortcut", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "+create",
"--summary", eventSummary,
"--start", startTime,
"--end", endTime,
"--calendar-id", calendarID,
"--description", eventDescription,
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
eventID = gjson.Get(result.Stdout, "data.event_id").String()
require.NotEmpty(t, eventID)
})
t.Run("verify event created", func(t *testing.T) {
require.NotEmpty(t, eventID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "events", "get"},
DefaultAs: "bot",
Params: map[string]any{
"calendar_id": calendarID,
"event_id": eventID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, eventSummary, gjson.Get(result.Stdout, "data.event.summary").String())
assert.Equal(t, eventDescription, gjson.Get(result.Stdout, "data.event.description").String())
assert.Equal(t, unixSecondsRFC3339(startAt), gjson.Get(result.Stdout, "data.event.start_time.timestamp").String())
assert.Equal(t, unixSecondsRFC3339(endAt), gjson.Get(result.Stdout, "data.event.end_time.timestamp").String())
})
t.Run("delete event", func(t *testing.T) {
require.NotEmpty(t, eventID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "events", "delete"},
DefaultAs: "bot",
Params: map[string]any{
"calendar_id": calendarID,
"event_id": eventID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
})
}

View File

@@ -0,0 +1,136 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestCalendar_ManageCalendar tests the workflow of managing calendars.
func TestCalendar_ManageCalendar(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
calendarSummary := "lark-cli-e2e-cal-" + suffix
updatedCalendarSummary := calendarSummary + "-updated"
calendarDescription := "test calendar created by e2e"
var createdCalendarID string
t.Run("list calendars", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "list"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
require.NotEmpty(t, gjson.Get(result.Stdout, "data.calendar_list").Array(), "stdout:\n%s", result.Stdout)
})
t.Run("get primary calendar", func(t *testing.T) {
primaryCalendarID := getPrimaryCalendarID(t, ctx)
require.NotEmpty(t, primaryCalendarID)
})
t.Run("create calendar", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "create"},
DefaultAs: "bot",
Data: map[string]any{
"summary": calendarSummary,
"description": calendarDescription,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
createdCalendarID = gjson.Get(result.Stdout, "data.calendar.calendar_id").String()
require.NotEmpty(t, createdCalendarID)
})
t.Run("get created calendar", func(t *testing.T) {
require.NotEmpty(t, createdCalendarID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "get"},
DefaultAs: "bot",
Params: map[string]any{
"calendar_id": createdCalendarID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, createdCalendarID, gjson.Get(result.Stdout, "data.calendar_id").String())
assert.Equal(t, calendarSummary, gjson.Get(result.Stdout, "data.summary").String())
assert.Equal(t, calendarDescription, gjson.Get(result.Stdout, "data.description").String())
})
t.Run("find created calendar in list", func(t *testing.T) {
require.NotEmpty(t, createdCalendarID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "list"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
require.True(t, gjson.Get(result.Stdout, `data.calendar_list.#(calendar_id=="`+createdCalendarID+`")`).Exists(), "stdout:\n%s", result.Stdout)
})
t.Run("update calendar", func(t *testing.T) {
require.NotEmpty(t, createdCalendarID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "patch"},
DefaultAs: "bot",
Params: map[string]any{
"calendar_id": createdCalendarID,
},
Data: map[string]any{
"summary": updatedCalendarSummary,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
})
t.Run("verify updated calendar", func(t *testing.T) {
require.NotEmpty(t, createdCalendarID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "get"},
DefaultAs: "bot",
Params: map[string]any{
"calendar_id": createdCalendarID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, updatedCalendarSummary, gjson.Get(result.Stdout, "data.summary").String())
})
t.Run("delete calendar", func(t *testing.T) {
require.NotEmpty(t, createdCalendarID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "delete"},
DefaultAs: "bot",
Params: map[string]any{
"calendar_id": createdCalendarID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
})
}

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"context"
"strconv"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func getPrimaryCalendarID(t *testing.T, ctx context.Context) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "primary"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
calendarID := gjson.Get(result.Stdout, "data.calendars.0.calendar.calendar_id").String()
require.NotEmpty(t, calendarID, "stdout:\n%s", result.Stdout)
return calendarID
}
func unixSecondsRFC3339(t time.Time) string {
return strconv.FormatInt(t.Unix(), 10)
}

View File

@@ -1,6 +1,6 @@
---
name: cli-e2e-testcase-writer
description: Write scenario-based end-to-end Go testcases for the compiled `lark-cli` binary under `tests/cli_e2e`. Use when adding or updating a CLI testcase that should autonomously explore help and schema output, build a self-contained lifecycle with `clie2e.RunCmd`, organize steps with `t.Run`, clean up with `t.Cleanup`, and assert JSON output with `testify/assert` and `gjson`.
description: Use when adding or updating Go CLI E2E coverage for one `tests/cli_e2e/{domain}` domain of the compiled `lark-cli`, especially when the work requires live `--help` or `schema` exploration, scenario-based `clie2e.RunCmd` workflows, and per-domain `coverage.md` maintenance.
metadata:
requires:
bins: ["lark-cli"]
@@ -8,211 +8,115 @@ metadata:
# CLI E2E Testcase Writer
Write testcase code, not framework code. `tests/cli_e2e/core.go` already provides the harness, and `tests/cli_e2e/demo/task_lifecycle_test.go` is the reference example only. Unless the user explicitly asks for framework work, add or update testcase files only.
Work on one domain per run. Produce exactly two artifacts for that domain:
- workflow testcase files under `tests/cli_e2e/{domain}/`
- `tests/cli_e2e/{domain}/coverage.md`
## What a good testcase looks like
Focus on domain testcase files. Do not change shared E2E support code such as `tests/cli_e2e/core.go` unless the user explicitly asks. Treat `tests/cli_e2e/demo/` as reference only.
A good cli e2e testcase here is:
- scenario-based, not a loose smoke test
- self-contained and data-consistent
create the resource you later read, update, search, or delete
- broad enough to prove the workflow
usually create plus one or more follow-up reads or mutations plus teardown
- scoped to one feature or one workflow
do not turn one testcase into the entire domain
- written with normal Go testing primitives
## Core standard
This is different from traditional API test suites where usage docs live elsewhere. Here, the command contract is discoverable from `lark-cli --help`, domain help, subcommand help, and schema output, and the agent is expected to explore and verify it autonomously.
- Make the testcase scenario-based and self-contained.
- Prove one workflow end to end: create plus follow-up read, or mutate plus teardown.
- Prefer one file per workflow or one closely related feature.
- For mutable flows, prove persisted state with read-after-write assertions, not just exit code.
- Leave prerequisite-heavy paths uncovered when they cannot be proven, and explain why in `coverage.md`.
## File organization
## Workflow
Put real domain testcases under:
```text
tests/cli_e2e/{domain}/
```
Examples:
- `tests/cli_e2e/task/task_status_workflow_test.go`
- `tests/cli_e2e/task/task_comment_workflow_test.go`
Treat `tests/cli_e2e/demo/` as reference material, not as the place to accumulate real coverage.
## How to split cases
Split by feature or workflow, not by API surface inventory.
Good splits:
- one file for task status flow: `create -> complete -> get -> reopen -> get`
- one file for task comment flow
- one file for task reminder flow
- one file for tasklist association flow
Bad split:
- one giant `task_test.go` that creates a task, updates it, comments it, reminds it, assigns it, adds followers, attaches tasklists, and queries everything in one lifecycle
Prefer:
- one top-level test per workflow
- one file per workflow or per closely related feature
- small shared helpers in the same domain test package when setup/cleanup logic truly repeats
## Explore before writing
Do not guess command names, flags, or payload fields from memory. Discover them:
### 1. Explore the live CLI before writing code
```bash
lark-cli --help
lark-cli <domain> --help
lark-cli <domain> +<shortcut> -h
lark-cli <domain> <resource> <method> -h
lark-cli schema <domain>.<resource>.<method>
lark-cli <domain> <group> --help
lark-cli <domain> <group> <method> -h
lark-cli schema <domain>.<group>.<method>
```
Use this exploration loop repeatedly while writing the testcase:
1. find the right domain and command path
2. decide whether the scenario should use a shortcut or a resource method
3. inspect the exact `--params` and `--data` shape
4. run the draft testcase
5. inspect failures, then go back to help or schema and refine
### 2. Count leaf commands for the denominator
Also inspect environmental constraints before finalizing coverage:
- whether the current test environment supports `bot`, `user`, or both
- whether the scenario needs external identities, preexisting groups, documents, chats, or other remote fixtures
- whether the command path is actually executable in CI-like conditions
- A leaf command is one that executes an action — it has no further subcommands.
- If `lark-cli <domain> <group> --help` lists no subcommands, `<group>` itself is the leaf.
- Count `task +create` as one leaf and `task tasks get` as one leaf.
- Do not count parameter combinations.
- Reuse coverage already present under `tests/cli_e2e/{domain}/`. Do not count `tests/cli_e2e/demo/`.
## Use the harness directly
### 3. Choose the proof surface before editing
Call `clie2e.RunCmd` with `clie2e.Request`.
Identify the provable risks for the touched workflow: invalid input, missing prerequisite, identity or permission, state transition, output shape, cleanup safety. If only the happy path is testable, document the blocked risk areas in `coverage.md`.
```go
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "tasks", "get"},
Params: map[string]any{
"task_guid": taskGUID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
### 4. Add or update the workflow testcase
- Use `clie2e.RunCmd(ctx, clie2e.Request{...})`.
- Put command path and plain flags in `Args`; put JSON in `Params` (URL/path parameters) and `Data` (request body).
- Prefer one top-level test per workflow with `t.Run` substeps.
- Register teardown on `parentT.Cleanup` so it survives subtest failures.
- When touching an existing command, verify the JSON response shape is stable: assert status type, field paths, and identifiers consumed by later steps before changing assertions.
### 5. Run and iterate
Run `go test ./tests/cli_e2e/{domain} -count=1` while iterating and before finishing. If command shape or behavior is unclear, re-check help or schema (step 1) before changing assertions.
### 6. Refresh the domain outputs
- Update the workflow testcase files.
- Update `coverage.md`: recompute the denominator from live help output, mark each command as `shortcut` or `api`, and keep one command table for the whole domain.
## Testcase rules
- Override `BinaryPath`, `DefaultAs`, or `Format` on `clie2e.Request` only when the testcase truly needs it.
- Use `require.NoError`, `result.AssertExitCode`, `result.AssertStdoutStatus`, `assert`, and `gjson`.
- Shortcut responses (`{ok: bool}`) assert `true`; API responses (`{code: int}`) assert `0`.
- Use `t.Helper()` only for setup or assertion helpers that are called from multiple tests.
- Use table-driven tests only when the scenario shape repeats across inputs.
- For expected failures, assert stderr content and exit code when the environment makes them deterministic.
- If identity or external fixtures cannot be proven, leave the command uncovered and document the prerequisite rather than faking confidence.
## coverage.md
Keep `coverage.md` brief and mechanical. Include:
- a domain-specific H1 title
- a metrics section with denominator, covered count, and coverage rate
- a summary section restating each `Test...` workflow, key `t.Run(...)` proof points, and main blockers
- one command table for all commands
Recommended structure:
```markdown
# <Domain> CLI E2E Coverage
## Metrics
- Denominator: N leaf commands
- Covered: N
- Coverage: N%
## Summary
- TestXxx: ... key `t.Run(...)` proof points ...
- Blocked area: ...
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | task +create | shortcut | task_status_workflow_test.go::TestTask_StatusWorkflow | basic create; create with due | |
| ✕ | task +assign | shortcut | | none | requires real user open_id |
```
Use `Request` like this:
- `Args`: command path and plain flags
- `Params`: JSON for `--params`
- `Data`: JSON for `--data`
- `BinaryPath`, `DefaultAs`, `Format`: only when the testcase must override defaults
- Mark each command `shortcut` or `api`.
- Write testcase entries in `go test -run` friendly form.
- Commands only exercised in `parentT.Cleanup` teardown are not counted as covered.
- Do not split covered and uncovered commands into separate sections.
## Default testcase shape
## Guardrails
Use one top-level test per workflow. Break the workflow into substeps with `t.Run`.
```go
func TestDomain_Scenario(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := time.Now().UTC().Format("20060102-150405")
var resourceID string
t.Run("create", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{...})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
resourceID = gjson.Get(result.Stdout, "data.guid").String()
require.NotEmpty(t, resourceID)
parentT.Cleanup(func() {
// best-effort delete
})
})
t.Run("get", func(t *testing.T) {
require.NotEmpty(t, resourceID)
})
}
```
Use this shape because:
- `t.Run` makes reports readable
- `parentT.Cleanup` keeps created resources alive for later substeps
- one testcase owns one full resource lifecycle
## Data self-consistency
Prefer workflows whose data can be created and cleaned up entirely within the testcase.
Good:
- create a task, then get/update/comment/delete that same task
- create a tasklist, then add a task created by the testcase
Be explicit when the data is not self-consistent:
- if a testcase needs a real user open_id, preexisting chat, existing document, or tenant-specific fixture, do not invent one
- call out the missing prerequisite to the user
- if you still want to leave a reference testcase in code, write it with `t.Skip()` and a short reason
Example:
```go
func TestTask_AssignWorkflow_UserOnly(t *testing.T) {
t.Skip("requires a real user open_id and user-capable test environment")
}
```
Do not silently hardcode made-up IDs, fake URLs, or guessed remote resources just to make the testcase look complete.
## Environment constraints
Assume the current local/CI-like environment may support only `bot` identity by default.
Implications:
- do not assume `--as user` works
- commands or workflows that require user identity may be unsupported in the current environment
- confirm this by checking help, running the command, or using known repo guidance before writing the final testcase set
When `--as user` is unavailable:
- still implement bot-compatible workflows normally
- for user-only workflows, either stop and tell the user what prerequisite is missing, or leave a skipped testcase with `t.Skip()`
Typical risky areas:
- `+get-my-tasks`
- commands that require current-user profile or self identity lookup
- workflows that need a real user open_id for assign/follower/member mutations
## Go testing rules
- Use `t.Run` for lifecycle steps such as `create`, `update`, `get`, `list`, `delete`.
- Use `t.Cleanup` for teardown and shared cleanup.
- Use `t.Helper()` in local helpers when the same setup or assertion logic really repeats.
- Use table-driven tests only when the same scenario shape repeats across multiple inputs. Do not force table-driven style onto a single live workflow.
- Use `require.NoError` for command execution and prerequisites.
- Use `assert` for returned field values after the command has succeeded.
- Use `gjson.Get(result.Stdout, "...")` for JSON field extraction.
## Output conventions
- shortcut-style commands often return `{"ok": true, ...}` and should use `result.AssertStdoutStatus(t, true)`
- service-style commands often return `{"code": 0, "data": ...}` and should use `result.AssertStdoutStatus(t, 0)`
Then assert the business fields with `gjson`.
## Common mistakes
- Do not modify `tests/cli_e2e/core.go` just because one testcase wants a convenience wrapper.
- Do not write a testcase that depends on preexisting remote data.
- Do not put agent, model, or vendor brand names into task summaries, comments, tasklist names, fixture IDs, or other visible remote test data; use neutral prefixes such as `lark-cli-e2e-` or `<domain>-e2e-`.
- Do not attach cleanup to the create subtest if later subtests still need the resource.
- Run as bot identity only; do not assume `--as user` works.
- Do not place new real coverage under `tests/cli_e2e/demo/`.
- Do not dump all domain behaviors into one file or one testcase.
- Do not hardcode obvious defaults unless the command really needs explicit flags.
- Do not guess `Params` or `Data` fields when schema output can tell you the exact shape.
- Do not fabricate prerequisite data when the scenario needs real external fixtures.
- Do not force a user-only workflow to run in a bot-only environment; use `t.Skip()` with a concrete reason.
- Do not stop after the first draft. Run, inspect, explore again, and improve the testcase.
## Validation
- Run `go test ./tests/cli_e2e/... -count=1`.
- Rerun the touched package directly when the testcase is live and slow.
- If behavior is unclear, go back to help and schema before changing the testcase.
- Do not depend on preexisting remote data.
- Do not fabricate open_ids, chats, docs, or other remote fixtures.
- Prefer deterministic negative cases over tenant-dependent assertions.
- Do not guess `Params` or `Data` fields when help or schema can tell you the exact shape.
- Do not hardcode obvious defaults unless the command truly requires explicit flags.
- Do not put agent, model, or vendor brand names in visible remote test data; use neutral prefixes such as `lark-cli-e2e-` or `<domain>-e2e-`.
- A command is covered only when the testcase asserts returned fields or persisted state, not just exit code.
- Cleanup-only execution is not primary coverage, except `delete` in the same workflow that created the resource.

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
)
func TestContact_GetUser_BotWorkflow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
var targetOpenID string
t.Run("discover-user-via-api", func(t *testing.T) {
// Bot identity cannot use +search-user or +get-user (self).
// However, it CAN call the raw API to list users if it has contact permissions.
// We use this to discover a real open_id for the next step.
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"api", "get", "/open-apis/contact/v3/users"},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
targetOpenID = gjson.Get(result.Stdout, "data.items.0.open_id").String()
require.NotEmpty(t, targetOpenID, "expected to find at least one user via raw API")
})
t.Run("get-user-by-id-as-bot", func(t *testing.T) {
require.NotEmpty(t, targetOpenID, "targetOpenID should be populated")
// DefaultAs is automatically "bot" in the clie2e framework
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"contact", "+get-user", "--user-id", targetOpenID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
returnedID := gjson.Get(result.Stdout, "data.user.open_id").String()
require.Equal(t, targetOpenID, returnedID)
})
}

View File

@@ -16,6 +16,7 @@ import (
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
@@ -57,6 +58,15 @@ type Result struct {
RunErr error
}
// RetryOptions configures retry behavior for flaky external API calls.
type RetryOptions struct {
Attempts int
InitialDelay time.Duration
MaxDelay time.Duration
BackoffMultiple int
ShouldRetry func(*Result) bool
}
// RunCmd executes lark-cli and captures stdout/stderr/exit code.
func RunCmd(ctx context.Context, req Request) (*Result, error) {
binaryPath, err := ResolveBinaryPath(req)
@@ -99,6 +109,63 @@ func RunCmd(ctx context.Context, req Request) (*Result, error) {
return result, nil
}
// RunCmdWithRetry reruns a command when the result matches the configured retry condition.
func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Result, error) {
if opts.Attempts <= 0 {
opts.Attempts = 4
}
if opts.InitialDelay <= 0 {
opts.InitialDelay = 1 * time.Second
}
if opts.MaxDelay <= 0 {
opts.MaxDelay = 6 * time.Second
}
if opts.BackoffMultiple <= 1 {
opts.BackoffMultiple = 2
}
if opts.ShouldRetry == nil {
opts.ShouldRetry = func(result *Result) bool {
return result != nil && result.ExitCode != 0
}
}
delay := opts.InitialDelay
var lastResult *Result
for attempt := 1; attempt <= opts.Attempts; attempt++ {
result, err := RunCmd(ctx, req)
if err != nil {
return nil, err
}
lastResult = result
if attempt == opts.Attempts || !opts.ShouldRetry(result) {
return result, nil
}
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return lastResult, nil
case <-timer.C:
}
nextDelay := delay * time.Duration(opts.BackoffMultiple)
if nextDelay > opts.MaxDelay {
delay = opts.MaxDelay
} else {
delay = nextDelay
}
}
return lastResult, nil
}
// GenerateSuffix returns a high-entropy UTC timestamp suffix suitable for remote test resource names.
func GenerateSuffix() string {
now := time.Now().UTC()
return fmt.Sprintf("%s-%09d", now.Format("20060102-150405"), now.Nanosecond())
}
// ResolveBinaryPath finds the CLI binary path using request, env, then PATH.
func ResolveBinaryPath(req Request) (string, error) {
if req.BinaryPath != "" {

View File

@@ -0,0 +1,42 @@
# Demo Coverage Template
> This file is a demo template only.
> It shows the expected `coverage.md` shape for real domains under `tests/cli_e2e/{domain}`.
> The numbers, command list, and coverage status below are illustrative, not authoritative.
> `tests/cli_e2e/demo/` is reference material and is not part of formal CLI E2E coverage accounting.
> `lark-cli demo --help` does not exist, so this file cannot be recomputed from live domain help output.
## Metrics
- Denominator: 8 leaf commands
- Covered: 3
- Coverage: 37.5%
## Summary
- Purpose: show humans and AI agents how to maintain a per-domain coverage file even when the directory is documentation-only and not backed by a real `lark-cli demo` command tree.
- TestDemo_TaskLifecycle: demonstrates one minimal task lifecycle workflow for documentation purposes.
- TestDemo_TaskLifecycle/create: runs `task +create` with `summary` and `description`, captures the returned `taskGUID`, and registers parent cleanup for later teardown.
- TestDemo_TaskLifecycle/update: runs `task +update --task-id <guid>` and mutates both `summary` and `description` on the created task.
- TestDemo_TaskLifecycle/get: runs `task tasks get` for the same task and asserts the persisted `guid`, updated `summary`, and updated `description`.
- Cleanup note: `task tasks delete` is executed in `parentT.Cleanup`, but this template intentionally keeps cleanup-only execution marked uncovered so workflow assertions remain distinct from teardown mechanics.
- Demo-only gap note: `task +complete`, `task +reopen`, `task +assign`, and `task +get-my-tasks` are intentionally left as uncovered examples for a minimal template.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | task +create | shortcut | task_lifecycle_test.go::TestDemo_TaskLifecycle/create | basic create; summary; description | demo example |
| ✓ | task +update | shortcut | task_lifecycle_test.go::TestDemo_TaskLifecycle/update | --task-id; update summary; update description | demo example |
| ✓ | task tasks get | api | task_lifecycle_test.go::TestDemo_TaskLifecycle/get | task_guid in --params | demo example |
| ✕ | task tasks delete | api | | none | cleanup exists in parentT.Cleanup, but demo coverage intentionally treats cleanup-only execution as uncovered |
| ✕ | task +complete | shortcut | | none | not shown in this minimal lifecycle example |
| ✕ | task +reopen | shortcut | | none | not shown in this minimal lifecycle example |
| ✕ | task +assign | shortcut | | none | example of a user-identity-sensitive command; requires real user fixtures |
| ✕ | task +get-my-tasks | shortcut | | none | example of a current-user-dependent command; often unavailable in bot-only environments |
## Notes
- In a real domain, recompute the denominator from live `lark-cli --help` exploration instead of copying this file.
- Replace demo rows with real command inventory for that domain.
- Keep skipped commands unchecked; reuse the `t.Skip(...)` reason as the uncovered reason.

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package docs
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDocs_CreateAndFetchWorkflow tests the create and fetch lifecycle.
func TestDocs_CreateAndFetchWorkflow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderName := "lark-cli-e2e-docs-folder-" + suffix
docTitle := "lark-cli-e2e-docs-" + suffix
docContent := "# Test Document\n\nThis document was created by lark-cli e2e test."
folderToken := createDocsFolderWithRetry(t, ctx, folderName)
var docToken string
t.Run("create", func(t *testing.T) {
docToken = createDocWithRetry(t, ctx, folderToken, docTitle, docContent)
})
t.Run("fetch", func(t *testing.T) {
require.NotEmpty(t, docToken, "document token should be created before fetch")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+fetch",
"--doc", docToken,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, docTitle, gjson.Get(result.Stdout, "data.title").String())
})
}

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package docs
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDocs_UpdateWorkflow tests the create, update, and verify lifecycle.
func TestDocs_UpdateWorkflow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderName := "lark-cli-e2e-update-folder-" + suffix
originalTitle := "lark-cli-e2e-update-" + suffix
updatedTitle := "lark-cli-e2e-update-updated-" + suffix
originalContent := "# Original\n\nThis is the original content."
updatedContent := "# Updated\n\nThis is the updated content."
folderToken := createDocsFolderWithRetry(t, ctx, folderName)
var docToken string
t.Run("create", func(t *testing.T) {
docToken = createDocWithRetry(t, ctx, folderToken, originalTitle, originalContent)
})
t.Run("update-title-and-content", func(t *testing.T) {
require.NotEmpty(t, docToken, "document token should be created before update")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+update",
"--doc", docToken,
"--mode", "overwrite",
"--markdown", updatedContent,
"--new-title", updatedTitle,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("verify", func(t *testing.T) {
require.NotEmpty(t, docToken, "document token should be created before verify")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+fetch",
"--doc", docToken,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, updatedTitle, gjson.Get(result.Stdout, "data.title").String())
})
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package docs
import (
"context"
"testing"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func createDocsFolderWithRetry(t *testing.T, ctx context.Context, name string) string {
t.Helper()
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"drive", "files", "create_folder"},
Data: map[string]any{
"name": name,
"folder_token": "",
},
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
folderToken := gjson.Get(result.Stdout, "data.token").String()
require.NotEmpty(t, folderToken, "stdout:\n%s", result.Stdout)
return folderToken
}
func createDocWithRetry(t *testing.T, ctx context.Context, folderToken string, title string, markdown string) string {
t.Helper()
require.NotEmpty(t, folderToken, "folder token is required")
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"docs", "+create",
"--folder-token", folderToken,
"--title", title,
"--markdown", markdown,
},
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
docToken := gjson.Get(result.Stdout, "data.doc_id").String()
require.NotEmpty(t, docToken, "stdout:\n%s", result.Stdout)
return docToken
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
)
// TestDrive_FilesCreateFolderWorkflow tests the files create_folder resource command.
func TestDrive_FilesCreateFolderWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderName := "lark-cli-e2e-drive-folder-" + suffix
t.Run("create_folder", func(t *testing.T) {
folderToken := createDriveFolder(t, parentT, ctx, folderName)
if folderToken == "" {
t.Fatalf("folder token should be available")
}
})
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"testing"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// createDriveFolder creates a private folder for the current workflow and
// deletes it during cleanup.
func createDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, name string) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"drive", "files", "create_folder"},
DefaultAs: "bot",
Data: map[string]any{
"name": name,
"folder_token": "",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
folderToken := gjson.Get(result.Stdout, "data.token").String()
require.NotEmpty(t, folderToken, "stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
clie2e.RunCmd(context.Background(), clie2e.Request{
Args: []string{"drive", "files", "delete"},
DefaultAs: "bot",
Params: map[string]any{"file_token": folderToken, "type": "folder"},
})
})
return folderToken
}

View File

@@ -0,0 +1,124 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestIM_ChatUpdateWorkflow tests the +chat-update shortcut.
func TestIM_ChatUpdateWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
originalName := "lark-cli-e2e-im-update-" + suffix
updatedName := originalName + "-updated"
updatedDescription := "Updated description for e2e test"
chatID := createChat(t, parentT, ctx, originalName)
t.Run("update chat name", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-update",
"--chat-id", chatID,
"--name", updatedName,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("update chat description", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-update",
"--chat-id", chatID,
"--description", updatedDescription,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("get updated chat", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "chats", "get"},
Params: map[string]any{"chat_id": chatID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, updatedName, gjson.Get(result.Stdout, "data.name").String())
assert.Equal(t, updatedDescription, gjson.Get(result.Stdout, "data.description").String())
})
}
// TestIM_ChatsGetWorkflow tests the im chats get command.
func TestIM_ChatsGetWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
chatName := "lark-cli-e2e-chats-get-" + suffix
chatID := createChat(t, parentT, ctx, chatName)
t.Run("get chat info", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "chats", "get"},
Params: map[string]any{"chat_id": chatID},
})
require.NoError(t, err)
t.Logf("chats get result: %s", result.Stdout)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
dataExists := gjson.Get(result.Stdout, "data").Exists()
require.True(t, dataExists, "data object should exist")
chatNameGot := gjson.Get(result.Stdout, "data.name").String()
require.Equal(t, chatName, chatNameGot)
})
}
// TestIM_ChatsLinkWorkflow tests the im chats link command.
func TestIM_ChatsLinkWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
chatName := "lark-cli-e2e-chats-link-" + suffix
chatID := createChat(t, parentT, ctx, chatName)
t.Run("get chat share link", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "chats", "link"},
Params: map[string]any{"chat_id": chatID},
Data: map[string]any{
"validity_period": "week",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
shareLink := gjson.Get(result.Stdout, "data.share_link").String()
require.NotEmpty(t, shareLink, "share_link should not be empty")
t.Logf("Generated share link: %s", shareLink)
})
}

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"testing"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// createChat creates a private chat with the given name and returns the chatID.
// The chat will be automatically cleaned up via parentT.Cleanup().
// Note: Chat deletion is not available via lark-cli im command.
func createChat(t *testing.T, parentT *testing.T, ctx context.Context, name string) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-create",
"--name", name,
"--type", "private",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
chatID := gjson.Get(result.Stdout, "data.chat_id").String()
require.NotEmpty(t, chatID, "chat_id should not be empty")
parentT.Cleanup(func() {
// No IM chat delete command is currently available in lark-cli,
// so created chats are intentionally left in the test account.
})
return chatID
}
// sendMessage sends a text message to the specified chat and returns the messageID.
func sendMessage(t *testing.T, parentT *testing.T, ctx context.Context, chatID string, text string) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-send",
"--chat-id", chatID,
"--text", text,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
messageID := gjson.Get(result.Stdout, "data.message_id").String()
require.NotEmpty(t, messageID, "message_id should not be empty")
return messageID
}

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
// TestIM_MessagesReplyWorkflow tests the +messages-reply shortcut.
func TestIM_MessagesReplyWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
chatName := "lark-cli-e2e-im-reply-" + suffix
originalMessage := "Original message for reply test"
replyText := "This is a reply"
chatID := createChat(t, parentT, ctx, chatName)
messageID := sendMessage(t, parentT, ctx, chatID, originalMessage)
t.Run("reply to message with text", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-reply",
"--message-id", messageID,
"--text", replyText,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("reply to message with markdown", func(t *testing.T) {
markdownReply := "**Bold** reply"
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-reply",
"--message-id", messageID,
"--markdown", markdownReply,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
}

View File

@@ -0,0 +1,239 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"fmt"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestSheets_CRUDE2EWorkflow tests the full lifecycle of spreadsheet operations
// using all shortcut methods: +create, +read, +write, +append, +find, +info, +export
func TestSheets_CRUDE2EWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
spreadsheetToken := ""
sheetID := ""
t.Run("create spreadsheet with +create", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"sheets", "+create", "--title", "lark-cli-e2e-sheets-" + suffix},
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
spreadsheetToken = gjson.Get(result.Stdout, "data.spreadsheet_token").String()
require.NotEmpty(t, spreadsheetToken, "spreadsheet token should not be empty, stdout: %s", result.Stdout)
parentT.Cleanup(func() {
// Best-effort cleanup - spreadsheets don't have a direct delete shortcut
// The spreadsheet will be cleaned up by the test environment if needed
})
})
t.Run("get spreadsheet info with +info", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"sheets", "+info", "--spreadsheet-token", spreadsheetToken},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, spreadsheetToken, gjson.Get(result.Stdout, "data.spreadsheet.spreadsheet.token").String())
sheetID = gjson.Get(result.Stdout, "data.sheets.sheets.0.sheet_id").String()
require.NotEmpty(t, sheetID, "sheet_id should not be empty, stdout: %s", result.Stdout)
})
t.Run("write data with +write", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
require.NotEmpty(t, sheetID, "sheet_id is required")
values := [][]any{
{"Name", "Age", "City"},
{"Alice", 25, "Beijing"},
{"Bob", 30, "Shanghai"},
}
valuesJSON, _ := json.Marshal(values)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"sheets", "+write",
"--spreadsheet-token", spreadsheetToken,
"--sheet-id", sheetID,
"--range", "A1:C3",
"--values", string(valuesJSON),
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("read data with +read", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
require.NotEmpty(t, sheetID, "sheet_id is required")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"sheets", "+read",
"--spreadsheet-token", spreadsheetToken,
"--sheet-id", sheetID,
"--range", "A1:C3",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
// Verify the data was written correctly
values := gjson.Get(result.Stdout, "data.valueRange.values")
require.True(t, values.IsArray(), "values should be an array, stdout: %s", result.Stdout)
assert.Equal(t, "Name", values.Array()[0].Array()[0].String())
assert.Equal(t, "Alice", values.Array()[1].Array()[0].String())
})
t.Run("append rows with +append", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
require.NotEmpty(t, sheetID, "sheet_id is required")
values := [][]any{{"Charlie", 28, "Guangzhou"}}
valuesJSON, _ := json.Marshal(values)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"sheets", "+append",
"--spreadsheet-token", spreadsheetToken,
"--sheet-id", sheetID,
"--range", "A4:C4",
"--values", string(valuesJSON),
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("find cells with +find", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
require.NotEmpty(t, sheetID, "sheet_id is required")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"sheets", "+find",
"--spreadsheet-token", spreadsheetToken,
"--sheet-id", sheetID,
"--find", "Alice",
"--range", fmt.Sprintf("%s!A1:C10", sheetID),
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, true, gjson.Get(result.Stdout, "ok").Bool(), "stdout:\n%s", result.Stdout)
matchedCells := gjson.Get(result.Stdout, "data.find_result.matched_cells")
require.True(t, matchedCells.IsArray(), "matched_cells should be an array, stdout: %s", result.Stdout)
assert.True(t, len(matchedCells.Array()) > 0, "should find at least one cell containing 'Alice'")
})
t.Run("export spreadsheet with +export", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
// Export is an async operation; verify it initiates correctly
// The command may have filesystem race issues but the API call succeeds
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"sheets", "+export",
"--spreadsheet-token", spreadsheetToken,
"--file-extension", "xlsx",
},
})
require.NoError(t, err)
// Export initiates successfully and returns file_token even if there's a temp file race
assert.NotEmpty(t, gjson.Get(result.Stdout, "data.file_token").String(),
"export should return file_token, stdout: %s", result.Stdout)
})
}
// TestSheets_SpreadsheetsResource tests the spreadsheets resource methods
func TestSheets_SpreadsheetsResource(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
spreadsheetToken := ""
t.Run("create spreadsheet with spreadsheets create", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"sheets", "spreadsheets", "create"},
Data: map[string]any{
"title": "lark-cli-e2e-sheets-resource-" + suffix,
},
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
spreadsheetToken = gjson.Get(result.Stdout, "data.spreadsheet.spreadsheet_token").String()
require.NotEmpty(t, spreadsheetToken, "spreadsheet token should not be empty, stdout: %s", result.Stdout)
parentT.Cleanup(func() {
// Best-effort cleanup
})
})
t.Run("get spreadsheet with spreadsheets get", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"sheets", "spreadsheets", "get"},
Params: map[string]any{"spreadsheet_token": spreadsheetToken},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, spreadsheetToken, gjson.Get(result.Stdout, "data.spreadsheet.token").String())
assert.NotEmpty(t, gjson.Get(result.Stdout, "data.spreadsheet.url").String())
})
t.Run("patch spreadsheet with spreadsheets patch", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
updatedTitle := "lark-cli-e2e-sheets-patched-" + suffix
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"sheets", "spreadsheets", "patch"},
Params: map[string]any{"spreadsheet_token": spreadsheetToken},
Data: map[string]any{"title": updatedTitle},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
// Verify the title was updated by fetching the spreadsheet
getResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"sheets", "spreadsheets", "get"},
Params: map[string]any{"spreadsheet_token": spreadsheetToken},
})
require.NoError(t, err)
getResult.AssertExitCode(t, 0)
getResult.AssertStdoutStatus(t, 0)
// Verify the title was actually updated
assert.Equal(t, updatedTitle, gjson.Get(getResult.Stdout, "data.spreadsheet.title").String())
})
}

View File

@@ -0,0 +1,174 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"fmt"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestSheets_FilterWorkflow tests the spreadsheet sheet filter operations
func TestSheets_FilterWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
spreadsheetToken := ""
sheetID := ""
t.Run("create spreadsheet with initial data", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"sheets", "+create", "--title", "lark-cli-e2e-sheets-filter-" + suffix},
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
spreadsheetToken = gjson.Get(result.Stdout, "data.spreadsheet_token").String()
require.NotEmpty(t, spreadsheetToken, "spreadsheet token should not be empty, stdout: %s", result.Stdout)
parentT.Cleanup(func() {
// No sheets delete command is currently available in lark-cli,
// so created spreadsheets are intentionally left in the test account.
})
})
t.Run("get sheet info", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"sheets", "+info", "--spreadsheet-token", spreadsheetToken},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
sheetID = gjson.Get(result.Stdout, "data.sheets.sheets.0.sheet_id").String()
require.NotEmpty(t, sheetID, "sheet_id should not be empty, stdout: %s", result.Stdout)
})
t.Run("write test data for filtering", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
require.NotEmpty(t, sheetID, "sheet_id is required")
values := [][]any{
{"Name", "Score", "Grade"},
{"Alice", 85, "B"},
{"Bob", 92, "A"},
{"Charlie", 78, "C"},
{"Diana", 95, "A"},
}
valuesJSON, _ := json.Marshal(values)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"sheets", "+write",
"--spreadsheet-token", spreadsheetToken,
"--sheet-id", sheetID,
"--range", "A1:C5",
"--values", string(valuesJSON),
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("create filter with spreadsheet.sheet.filters create", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
require.NotEmpty(t, sheetID, "sheet_id is required")
filterData := map[string]any{
"range": fmt.Sprintf("%s!A1:D5", sheetID),
"col": "C",
"filter_type": "multiValue",
"condition": map[string]any{
"filter_type": "multiValue",
"expected": []any{"A", "B"},
},
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"sheets", "spreadsheet.sheet.filters", "create"},
Params: map[string]any{
"spreadsheet_token": spreadsheetToken,
"sheet_id": sheetID,
},
Data: filterData,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
})
t.Run("get filter with spreadsheet.sheet.filters get", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
require.NotEmpty(t, sheetID, "sheet_id is required")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"sheets", "spreadsheet.sheet.filters", "get"},
Params: map[string]any{
"spreadsheet_token": spreadsheetToken,
"sheet_id": sheetID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
filterInfo := gjson.Get(result.Stdout, "data.sheet_filter_info")
require.True(t, filterInfo.Exists(), "filter info should exist, stdout: %s", result.Stdout)
})
t.Run("update filter with spreadsheet.sheet.filters update", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
require.NotEmpty(t, sheetID, "sheet_id is required")
filterData := map[string]any{
"col": "B",
"filter_type": "number",
"condition": map[string]any{
"filter_type": "number",
"compare_type": "greater",
"expected": []any{80},
},
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"sheets", "spreadsheet.sheet.filters", "update"},
Params: map[string]any{
"spreadsheet_token": spreadsheetToken,
"sheet_id": sheetID,
},
Data: filterData,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
})
t.Run("delete filter with spreadsheet.sheet.filters delete", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
require.NotEmpty(t, sheetID, "sheet_id is required")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"sheets", "spreadsheet.sheet.filters", "delete"},
Params: map[string]any{
"spreadsheet_token": spreadsheetToken,
"sheet_id": sheetID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
})
}

View File

@@ -0,0 +1,50 @@
# Task CLI E2E Coverage
## Metrics
- Denominator: 29 leaf commands
- Covered: 10
- Coverage: 34.5%
## Summary
- TestTask_StatusWorkflow: creates a task via `task +create`, then proves `task +complete`, `task tasks get`, and `task +reopen` through `complete`, `get completed task`, `reopen`, and `get reopened task`; asserts `status` flips between `done` and `todo` and `completed_at` is set then cleared.
- TestTask_ReminderWorkflow: creates a task with a due time via `task +create`, then proves `task +reminder` and `task tasks get` through `set reminder`, `get task with reminder`, `remove reminder`, and `get task without reminder`; asserts `relative_fire_minute=30`, reminder id presence, and reminder removal.
- TestTask_CommentWorkflow: creates a task via `task +create`, runs `comment`, and asserts the returned comment id is non-empty; this is the direct proof for `task +comment`.
- TestTask_TasklistWorkflow: runs `create tasklist with task`, then `get tasklist`, `list tasklist tasks`, and `get task`; proves `task +tasklist-create`, `task tasklists get`, `task tasklists tasks`, and `task tasks get` with seeded task payload and task-to-tasklist linkage.
- TestTask_TasklistAddTaskWorkflow: creates a standalone tasklist and task, runs `add task to tasklist`, then `list tasklist tasks` and `get task with tasklist link`; proves `task +tasklist-task-add`, `task tasklists tasks`, and `task tasks get`, including no failed tasks in the add response.
- Cleanup path note: workflow-created tasks and tasklists are deleted through direct `task tasks delete` / `task tasklists delete` cleanup paths in `helpers_test.go::createTask`, `helpers_test.go::createTasklist`, and `tasklist_workflow_test.go::TestTask_TasklistWorkflow`, but those cleanup-only executions are not counted as command coverage because no testcase asserts delete behavior as the primary proof surface.
- Blocked area: assignee, follower, and tasklist member mutations still require stable real-user `open_id` fixtures; the current suite is bot-safe only.
- Blocked area: `task +get-my-tasks` still depends on `--as user` identity plus deterministic user-scoped data.
- Gap pattern: direct `tasks create/delete/list/patch`, `tasklists create/delete/list/patch`, `members *`, and `subtasks *` APIs still lack deterministic direct-call workflows, so shortcut coverage does not count for those leaf commands.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✕ | task +assign | shortcut | | none | requires real assignee open_id fixtures; shortcut defaults to `--as user` |
| ✓ | task +comment | shortcut | task_comment_workflow_test.go::TestTask_CommentWorkflow/comment | `--task-id`; `--content` | |
| ✓ | task +complete | shortcut | task_status_workflow_test.go::TestTask_StatusWorkflow/complete | `--task-id` | |
| ✓ | task +create | shortcut | task_status_workflow_test.go::TestTask_StatusWorkflow; task_comment_workflow_test.go::TestTask_CommentWorkflow; task_reminder_workflow_test.go::TestTask_ReminderWorkflow; tasklist_add_task_workflow_test.go::TestTask_TasklistAddTaskWorkflow | `summary` + `description`; `due.timestamp` + `due.is_all_day` | |
| ✕ | task +followers | shortcut | | none | requires real follower open_id fixtures; shortcut defaults to `--as user` |
| ✕ | task +get-my-tasks | shortcut | | none | depends on `--as user` identity and deterministic user-scoped task data |
| ✓ | task +reminder | shortcut | task_reminder_workflow_test.go::TestTask_ReminderWorkflow/set reminder; task_reminder_workflow_test.go::TestTask_ReminderWorkflow/remove reminder | `--task-id --set 30m`; `--task-id --remove` | |
| ✓ | task +reopen | shortcut | task_status_workflow_test.go::TestTask_StatusWorkflow/reopen | `--task-id` | |
| ✓ | task +tasklist-create | shortcut | tasklist_workflow_test.go::TestTask_TasklistWorkflow/create tasklist with task; tasklist_add_task_workflow_test.go::TestTask_TasklistAddTaskWorkflow | `--name` only; `--name` plus task array in `--data` | |
| ✕ | task +tasklist-members | shortcut | | none | requires real member open_id fixtures to add, remove, or set tasklist members |
| ✓ | task +tasklist-task-add | shortcut | tasklist_add_task_workflow_test.go::TestTask_TasklistAddTaskWorkflow/add task to tasklist | `--tasklist-id`; `--task-id` | |
| ✕ | task +update | shortcut | | none | no dedicated workflow yet for summary, description, or due-field mutation assertions |
| ✕ | task members add | api | | none | requires stable member fixtures and explicit direct API-body assertions |
| ✕ | task members remove | api | | none | requires stable member fixtures and explicit direct API-body assertions |
| ✕ | task subtasks create | api | | none | needs a parent-task workflow plus direct subtask payload assertions |
| ✕ | task subtasks list | api | | none | needs deterministic subtask fixtures created in the same workflow |
| ✕ | task tasklists add_members | api | | none | requires real member open_id fixtures and direct API coverage |
| ✕ | task tasklists create | api | | none | only covered indirectly through `task +tasklist-create`; no direct API invocation yet |
| ✕ | task tasklists delete | api | | none | only exercised in parent cleanup; no testcase asserts delete behavior or post-delete state as the primary proof |
| ✓ | task tasklists get | api | tasklist_workflow_test.go::TestTask_TasklistWorkflow/get tasklist | `tasklist_guid` in `--params` | |
| ✕ | task tasklists list | api | | none | needs isolated list or filter assertions against ambient tasklist data |
| ✕ | task tasklists patch | api | | none | no dedicated direct tasklist-update workflow yet |
| ✕ | task tasklists remove_members | api | | none | requires real member open_id fixtures and direct API coverage |
| ✓ | task tasklists tasks | api | tasklist_workflow_test.go::TestTask_TasklistWorkflow/list tasklist tasks; tasklist_add_task_workflow_test.go::TestTask_TasklistAddTaskWorkflow/list tasklist tasks | `tasklist_guid`; `page_size` | |
| ✕ | task tasks create | api | | none | only covered indirectly through `task +create`; no direct API invocation yet |
| ✕ | task tasks delete | api | | none | only exercised in parent cleanup; no testcase asserts delete behavior or post-delete state as the primary proof |
| ✓ | task tasks get | api | task_status_workflow_test.go::TestTask_StatusWorkflow/get completed task; task_status_workflow_test.go::TestTask_StatusWorkflow/get reopened task; task_reminder_workflow_test.go::TestTask_ReminderWorkflow/get task with reminder; task_reminder_workflow_test.go::TestTask_ReminderWorkflow/get task without reminder; tasklist_workflow_test.go::TestTask_TasklistWorkflow/get task; tasklist_add_task_workflow_test.go::TestTask_TasklistAddTaskWorkflow/get task with tasklist link | `task_guid` in `--params`; assert status, reminders, summary, description, and tasklist link | |
| ✕ | task tasks list | api | | none | needs isolated list or filter assertions against ambient task data |
| ✕ | task tasks patch | api | | none | no dedicated direct task-update workflow yet |

View File

@@ -19,7 +19,7 @@ func TestTask_CommentWorkflow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := time.Now().UTC().Format("20060102-150405")
suffix := clie2e.GenerateSuffix()
commentContent := "lark-cli-e2e-comment-" + suffix
taskGUID := createTask(t, parentT, ctx, clie2e.Request{
Args: []string{"task", "+create"},

View File

@@ -19,7 +19,7 @@ func TestTask_ReminderWorkflow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := time.Now().UTC().Format("20060102-150405")
suffix := clie2e.GenerateSuffix()
taskGUID := createTask(t, parentT, ctx, clie2e.Request{
Args: []string{"task", "+create"},
Data: map[string]any{
@@ -57,9 +57,9 @@ func TestTask_ReminderWorkflow(t *testing.T) {
})
t.Run("remove reminder", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"task", "+reminder", "--task-id", taskGUID, "--remove"},
})
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)

View File

@@ -19,7 +19,7 @@ func TestTask_StatusWorkflow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := time.Now().UTC().Format("20060102-150405")
suffix := clie2e.GenerateSuffix()
taskGUID := createTask(t, parentT, ctx, clie2e.Request{
Args: []string{"task", "+create"},
Data: map[string]any{

View File

@@ -19,7 +19,7 @@ func TestTask_TasklistAddTaskWorkflow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := time.Now().UTC().Format("20060102-150405")
suffix := clie2e.GenerateSuffix()
tasklistName := "lark-cli-e2e-tasklist-add-" + suffix
taskSummary := "lark-cli-e2e-tasklist-add-task-" + suffix

View File

@@ -19,7 +19,7 @@ func TestTask_TasklistWorkflow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := time.Now().UTC().Format("20060102-150405")
suffix := clie2e.GenerateSuffix()
tasklistName := "lark-cli-e2e-tasklist-" + suffix
taskSummary := "lark-cli-e2e-task-in-tasklist-" + suffix
taskDescription := "created by tests/cli_e2e/task"

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"testing"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func createWikiNode(t *testing.T, ctx context.Context, req clie2e.Request) gjson.Result {
t.Helper()
result, err := clie2e.RunCmdWithRetry(ctx, req, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
node := gjson.Get(result.Stdout, "data.node")
require.True(t, node.Exists(), "stdout:\n%s", result.Stdout)
return node
}
func findWikiNodeByToken(t *testing.T, ctx context.Context, spaceID string, nodeToken string) gjson.Result {
t.Helper()
require.NotEmpty(t, spaceID, "space ID is required")
require.NotEmpty(t, nodeToken, "node token is required")
pageToken := ""
seenPageTokens := map[string]struct{}{}
for {
params := map[string]any{
"space_id": spaceID,
"page_size": 50,
}
if pageToken != "" {
if _, seen := seenPageTokens[pageToken]; seen {
t.Fatalf("wiki node list pagination loop detected for space %q, repeated page_token %q", spaceID, pageToken)
}
seenPageTokens[pageToken] = struct{}{}
params["page_token"] = pageToken
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"wiki", "nodes", "list"},
DefaultAs: "bot",
Params: params,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
node := gjson.Get(result.Stdout, `data.items.#(node_token=="`+nodeToken+`")`)
if node.Exists() {
return node
}
hasMore := gjson.Get(result.Stdout, "data.has_more").Bool()
pageToken = gjson.Get(result.Stdout, "data.page_token").String()
if !hasMore || pageToken == "" {
t.Fatalf("wiki node %q not found in listed pages, last stdout:\n%s", nodeToken, result.Stdout)
}
}
}

View File

@@ -0,0 +1,150 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestWiki_NodeWorkflow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
createdTitle := "lark-cli-e2e-wiki-create-" + suffix
copiedTitle := "lark-cli-e2e-wiki-copy-" + suffix
var spaceID string
var createdNodeToken string
var createdObjToken string
var copiedNodeToken string
t.Run("create node", func(t *testing.T) {
node := createWikiNode(t, ctx, clie2e.Request{
Args: []string{"wiki", "nodes", "create"},
DefaultAs: "bot",
Params: map[string]any{
"space_id": "my_library",
},
Data: map[string]any{
"node_type": "origin",
"obj_type": "docx",
"title": createdTitle,
},
})
spaceID = node.Get("space_id").String()
createdNodeToken = node.Get("node_token").String()
createdObjToken = node.Get("obj_token").String()
require.NotEmpty(t, spaceID)
require.NotEmpty(t, createdNodeToken)
require.NotEmpty(t, createdObjToken)
assert.Equal(t, createdTitle, node.Get("title").String())
assert.Equal(t, "origin", node.Get("node_type").String())
assert.Equal(t, "docx", node.Get("obj_type").String())
})
t.Run("get created node", func(t *testing.T) {
require.NotEmpty(t, createdNodeToken, "node token should be created before get_node")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"wiki", "spaces", "get_node"},
DefaultAs: "bot",
Params: map[string]any{
"token": createdNodeToken,
"obj_type": "wiki",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, createdNodeToken, gjson.Get(result.Stdout, "data.node.node_token").String())
assert.Equal(t, createdObjToken, gjson.Get(result.Stdout, "data.node.obj_token").String())
assert.Equal(t, createdTitle, gjson.Get(result.Stdout, "data.node.title").String())
assert.Equal(t, spaceID, gjson.Get(result.Stdout, "data.node.space_id").String())
})
t.Run("get space", func(t *testing.T) {
require.NotEmpty(t, spaceID, "space ID should be available before get")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"wiki", "spaces", "get"},
DefaultAs: "bot",
Params: map[string]any{
"space_id": spaceID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, spaceID, gjson.Get(result.Stdout, "data.space.space_id").String())
assert.NotEmpty(t, gjson.Get(result.Stdout, "data.space.name").String(), "stdout:\n%s", result.Stdout)
})
t.Run("list spaces", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"wiki", "spaces", "list"},
DefaultAs: "bot",
Params: map[string]any{
"page_size": 1,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.True(t, gjson.Get(result.Stdout, "data.page_token").Exists(), "stdout:\n%s", result.Stdout)
assert.True(t, gjson.Get(result.Stdout, "data.items").Exists(), "stdout:\n%s", result.Stdout)
})
t.Run("list nodes and find created node", func(t *testing.T) {
require.NotEmpty(t, spaceID, "space ID should be available before list")
require.NotEmpty(t, createdNodeToken, "node token should be available before list")
nodeItem := findWikiNodeByToken(t, ctx, spaceID, createdNodeToken)
assert.Equal(t, createdTitle, nodeItem.Get("title").String())
assert.Equal(t, createdObjToken, nodeItem.Get("obj_token").String())
})
t.Run("copy node", func(t *testing.T) {
require.NotEmpty(t, spaceID, "space ID should be available before copy")
require.NotEmpty(t, createdNodeToken, "node token should be available before copy")
copiedNode := createWikiNode(t, ctx, clie2e.Request{
Args: []string{"wiki", "nodes", "copy"},
DefaultAs: "bot",
Params: map[string]any{
"space_id": spaceID,
"node_token": createdNodeToken,
},
Data: map[string]any{
"target_space_id": spaceID,
"title": copiedTitle,
},
})
copiedNodeToken = copiedNode.Get("node_token").String()
require.NotEmpty(t, copiedNodeToken)
assert.Equal(t, copiedTitle, copiedNode.Get("title").String())
assert.Equal(t, spaceID, copiedNode.Get("space_id").String())
assert.NotEqual(t, createdNodeToken, copiedNodeToken)
})
t.Run("list nodes and find copied node", func(t *testing.T) {
require.NotEmpty(t, spaceID, "space ID should be available before second list")
require.NotEmpty(t, copiedNodeToken, "copied node token should be available before second list")
nodeItem := findWikiNodeByToken(t, ctx, spaceID, copiedNodeToken)
assert.Equal(t, copiedTitle, nodeItem.Get("title").String())
})
}