Compare commits

...

5 Commits

Author SHA1 Message Date
sunyihong.cpdsss
b709824aae fix: add expression to avoid misunderstanding
Change-Id: Ib3a6c8a327b95c3f837d4bb565365235d0f0dfb8
2026-05-15 16:40:41 +08:00
河伯
f03138b9f0 feat(wiki): add +space-list / +node-list / +node-copy shortcuts (#392)
Introduce three new wiki shortcuts that wrap the corresponding raw APIs
with structured flags, formatted output, my_library alias handling, and
unified envelope shape, replacing the bare `lark-cli wiki spaces list`
/ `wiki nodes list` / `wiki nodes copy` flows for the common cases.

Shortcuts
- wiki +space-list (read, scopes: wiki:space:retrieve):
  lists wiki spaces. Default fetches a single page; --page-all walks
  every page capped by --page-limit (default 10, 0 = unlimited).
  Supports --page-size / --page-token / --format json|pretty|table|csv|ndjson.
  Output: {spaces, has_more, page_token} + Meta.Count. Pretty mode
  distinguishes "no spaces" from "empty page with has_more" and hints
  the caller to resume.

- wiki +node-list (read, scopes: wiki:node:retrieve):
  lists nodes in a space or under a parent. Same pagination + format
  story as +space-list. Accepts the my_library alias for --space-id
  with --as user (resolved via a shared resolveMyLibrarySpaceID helper
  extracted from +node-create); rejects my_library upfront for --as bot.

- wiki +node-copy (high-risk-write, scopes: wiki:node:copy):
  copies a node into a target space or parent. --target-space-id and
  --target-parent-node-token are mutually exclusive. Risk is marked
  high-risk-write to match the upstream API's danger: true flag, so the
  framework requires --yes. Source is preserved; subtree is copied.

Both list shortcuts pick the narrowest scope the upstream API accepts.
The framework's preflight (internal/auth/scope.go MissingScopes) does
exact-string scope matching, so declaring the broader wiki:wiki:readonly
form would wrongly reject tokens that carry only the per-API scope —
which the API itself accepts — and emit a misleading missing-scope hint.

Shared changes
- shortcuts/wiki/wiki_node_create.go: factor out resolveMyLibrarySpaceID
  so +node-list and +node-create share one my_library resolution path.
- shortcuts/wiki/shortcuts.go: register the three new shortcuts.
- skills/lark-wiki/SKILL.md and references/lark-wiki-{space,node-list,
  node-copy}.md: documentation for the new shortcuts.

Tooling
- scripts/check-doc-tokens.sh + Makefile gitleaks target:
  pre-commit check that scans skill reference docs for realistic-looking
  Lark token values without the _EXAMPLE_TOKEN placeholder convention,
  preventing gitleaks false positives.
- .gitleaks.toml: allowlist tuning.
- .gitignore: ignore .tmp/.

Tests
- shortcuts/wiki/wiki_list_copy_test.go: unit tests covering registry
  membership, declared-narrow-scope pinning, flag validation (page-size
  range, page-limit >= 0, target flag exclusivity, my_library + bot
  rejection), auto-pagination merging, --page-limit truncation
  surfacing next cursor, --page-token single-page mode, empty-slice
  serialisation, has_more hint pretty rendering, my_library user-path
  resolution, +node-copy copy-to-space / copy-to-parent + body shape,
  pretty rendering, and the high-risk-write --yes gate.
- tests/cli_e2e/wiki/wiki_shortcut_workflow_test.go: live end-to-end
  workflow exercising the shortcut layer against a real tenant.
  Reuses an existing my_library node as a host so the test never adds
  to the top-layer quota; the copy is placed under the same host node.
- tests/cli_e2e/wiki/coverage.md: shortcut coverage entries added.

Minor cleanups
- skills/lark-doc/references/lark-doc-search.md and
  skills/lark-minutes/references/lark-minutes-search.md: replace
  realistic-looking example ou_ tokens with _EXAMPLE_ placeholders so
  scripts/check-doc-tokens.sh passes.

Change-Id: I9efb0557f477d369d7f26a09c1e154d4ab15b253

Co-authored-by: liujinkun <liujinkun@bytedance.com>
2026-05-15 14:38:18 +08:00
Cato
ed9eecf94f fix(selfupdate): use LookPath instead of Executable for binary verification (fixes #836) (#886)
* fix(selfupdate): use LookPath instead of Executable for binary verification (fixes #836)

VerifyBinary was using vfs.Executable() to find the binary to run --version against.
On Linux with global npm install, this returns the inode of the running binary (old version),
not the newly installed one that sits behind npm's bin symlink.

Switch to exec.LookPath("lark-cli") which resolves the PATH entry and follows npm's
bin symlink to the correct newly installed version, matching what the user actually runs.

* test(selfupdate): add LookPath-based tests for VerifyBinary

Add TestVerifyBinaryLookPath, TestVerifyBinaryLookPathNotFound, and
TestVerifyBinaryEmptyOutput. Expose execLookPath variable so tests can
inject a mock LookPath and cover the full VerifyBinary execution path
including version parsing and error branches.

* test(selfupdate): add os/exec import and isolate config dir in VerifyBinary tests

CodeRabbit feedback:
- Add missing os/exec import for execLookPath variable
- Add t.Setenv(LARKSUITE_CLI_CONFIG_DIR, ...) to each new test for config isolation

* test(selfupdate): extract execLookPath to separate lookpath.go

Move the execLookPath variable declaration to its own file so it is
accessible to updater.go without the test-only import cycle.

* fix(selfupdate): remove unused os/exec import from test file

* fix(selfupdate): gofmt + fold lookpath hook and restore version fences

- Move execLookPath into updater.go (drops redundant lookpath.go)
- Document package-level mock: no t.Parallel()
- Extend TestVerifyBinaryLookPath with exact-match regressions (0.0, 12.1.0 vs 2.1.0)

Co-authored-by: CatfishGG <catfishgg@users.noreply.github.com>
2026-05-14 23:30:30 +08:00
liangshuo-1
f49a2f7e14 fix(registry): wait for background meta refresh before test reset (#894)
* fix(registry): wait for background meta refresh before test reset

TestComputeMinimumScopeSet can start doBackgroundRefresh via Init() while
the next test's resetInit() mutates package-level globals the goroutine
still reads (e.g. remoteMetaURL / configuredBrand), causing data races under
-race in the coverage job.

Track the refresh goroutine with a WaitGroup and drain it at the start of
resetInit() in tests.
2026-05-14 22:33:21 +08:00
caojie0621
a93fb2d6b3 docs: add drive permission public patch error guidance (#863) 2026-05-14 21:57:55 +08:00
25 changed files with 2351 additions and 69 deletions

1
.gitignore vendored
View File

@@ -39,3 +39,4 @@ cmd/api/download.bin
app.log
/sidecar-server-demo
/server-demo
.tmp/

View File

@@ -14,3 +14,4 @@ id = "lark-session-token"
description = "Detect Lark session tokens"
regex = '''\bXN0YXJ0-[A-Za-z0-9_-]+-WVuZA\b'''
keywords = ["XN0YXJ0-", "-WVuZA"]

View File

@@ -8,7 +8,9 @@ DATE := $(shell date +%Y-%m-%d)
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
PREFIX ?= /usr/local
.PHONY: build vet test unit-test integration-test install uninstall clean fetch_meta
.PHONY: all build vet test unit-test integration-test install uninstall clean fetch_meta gitleaks
all: test
fetch_meta:
python3 scripts/fetch_meta.py
@@ -37,3 +39,13 @@ uninstall:
clean:
rm -f $(BINARY)
# Run secret-leak checks locally before pushing.
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.
# Step 2: gitleaks scans the full repo for real leaked secrets.
# Install gitleaks: https://github.com/gitleaks/gitleaks#installing
gitleaks:
@bash scripts/check-doc-tokens.sh
@command -v gitleaks >/dev/null 2>&1 || { echo "gitleaks not found. Install: brew install gitleaks"; exit 1; }
gitleaks detect --redact -v --exit-code=2

View File

@@ -255,11 +255,18 @@ func doSyncFetch() {
// --- background refresh ---
var refreshOnce sync.Once
var (
refreshOnce sync.Once
bgRefreshInFlight sync.WaitGroup // tracks doBackgroundRefresh goroutines for test teardown (resetInit)
)
func triggerBackgroundRefresh() {
refreshOnce.Do(func() {
go doBackgroundRefresh()
bgRefreshInFlight.Add(1)
go func() {
defer bgRefreshInFlight.Done()
doBackgroundRefresh()
}()
})
}

View File

@@ -17,8 +17,18 @@ import (
"github.com/larksuite/cli/internal/core"
)
// waitBackgroundRefresh blocks until any in-flight background refresh started by
// triggerBackgroundRefresh has finished. Lives in this _test file so production
// binaries cannot call it and accidentally block on test teardown state.
func waitBackgroundRefresh() {
bgRefreshInFlight.Wait()
}
// resetInit resets the package-level state so each test starts fresh.
func resetInit() {
// Must wait: a prior test's Init() may have started doBackgroundRefresh which
// reads globals this function mutates (see CI race: TestComputeMinimumScopeSet → Tenant).
waitBackgroundRefresh()
initOnce = sync.Once{}
mergedServices = make(map[string]map[string]interface{})
mergedProjectList = nil

View File

@@ -17,6 +17,13 @@ import (
"github.com/larksuite/cli/internal/vfs"
)
// execLookPath is the LookPath implementation used by VerifyBinary.
// It defaults to the standard library exec.LookPath but is swapped in tests
// via lookPathMock to provide controlled binary resolution.
//
// Tests that mutate execLookPath must not call t.Parallel().
var execLookPath = exec.LookPath
// InstallMethod describes how the CLI was installed.
type InstallMethod int
@@ -186,13 +193,13 @@ func (u *Updater) VerifyBinary(expectedVersion string) error {
if u.VerifyOverride != nil {
return u.VerifyOverride(expectedVersion)
}
// Prefer the current executable path (what the user actually launched).
// Use Executable() directly without EvalSymlinks — after npm install the
// symlink target may have changed, but the path itself is still valid for
// execution. Fall back to LookPath only if Executable() fails entirely.
exe, err := vfs.Executable()
// Prefer PATH resolution so npm global bin symlinks pick up the newly
// installed binary (#836). If `lark-cli` is not on PATH (e.g. the user
// invoked this process by absolute path), fall back to the running
// executable — same as the pre-#836 secondary resolution path.
exe, err := execLookPath("lark-cli")
if err != nil {
exe, err = exec.LookPath("lark-cli")
exe, err = vfs.Executable()
if err != nil {
return fmt.Errorf("cannot locate binary: %w", err)
}

View File

@@ -4,6 +4,7 @@
package selfupdate
import (
"fmt"
"os"
"path/filepath"
"runtime"
@@ -12,6 +13,7 @@ import (
"github.com/larksuite/cli/internal/vfs"
)
// executableTestFS mocks vfs for tests that still need vfs.Executable.
type executableTestFS struct {
vfs.OsFs
exe string
@@ -19,6 +21,28 @@ type executableTestFS struct {
func (f executableTestFS) Executable() (string, error) { return f.exe, nil }
// lookPathMock patches execLookPath within VerifyBinary for controlled testing.
// Do not use t.Parallel() in tests that install this mock — it mutates a package-level var.
type lookPathMock struct {
oldLookPath func(string) (string, error)
result string
resultErr error
}
func (m *lookPathMock) install(bin string) {
m.oldLookPath = execLookPath
execLookPath = func(name string) (string, error) {
if name == bin {
return m.result, m.resultErr
}
return m.oldLookPath(name)
}
}
func (m *lookPathMock) restore() {
execLookPath = m.oldLookPath
}
func TestResolveExe(t *testing.T) {
u := New()
p, err := u.resolveExe()
@@ -44,46 +68,101 @@ func TestCleanupStaleFiles_NoPanic(t *testing.T) {
u.CleanupStaleFiles()
}
func TestVerifyBinaryChecksVersion(t *testing.T) {
func TestVerifyBinaryLookPath(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell script")
}
dir := t.TempDir()
exe := filepath.Join(dir, "lark-cli")
// Script prints version string matching real CLI format when --version is passed.
script := "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo \"lark-cli version 2.0.0\"; exit 0; fi\nexit 12\n"
if err := os.WriteFile(exe, []byte(script), 0755); err != nil {
bin := filepath.Join(dir, "lark-cli")
script := "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo \"lark-cli version 2.1.0\"; exit 0; fi\nexit 12\n"
if err := os.WriteFile(bin, []byte(script), 0755); err != nil {
t.Fatalf("write test binary: %v", err)
}
// Mock vfs.Executable to return our test script, matching VerifyBinary's
// primary lookup path. Also prepend to PATH for the LookPath fallback.
origFS := vfs.DefaultFS
vfs.DefaultFS = executableTestFS{OsFs: vfs.OsFs{}, exe: exe}
t.Cleanup(func() { vfs.DefaultFS = origFS })
mock := &lookPathMock{result: bin}
mock.install("lark-cli")
t.Cleanup(mock.restore)
origPath := os.Getenv("PATH")
t.Setenv("PATH", dir+string(os.PathListSeparator)+origPath)
// Matching version → success.
if err := New().VerifyBinary("2.0.0"); err != nil {
t.Fatalf("VerifyBinary(matching) error = %v, want nil", err)
if err := New().VerifyBinary("2.1.0"); err != nil {
t.Fatalf("VerifyBinary(2.1.0) error = %v, want nil", err)
}
// Mismatched version → error.
if err := New().VerifyBinary("3.0.0"); err == nil {
t.Fatal("VerifyBinary(mismatched) expected error, got nil")
}
// Substring of actual version must not match (e.g. "0.0" is in "2.0.0").
// Regression: version must match exactly (not substring / prefix).
if err := New().VerifyBinary("0.0"); err == nil {
t.Fatal("VerifyBinary(substring) expected error, got nil")
t.Fatal("VerifyBinary(substring-style mismatch) expected error, got nil")
}
// Version that is a prefix of actual must not match (e.g. "2.0.0" in "12.0.0").
// Binary reports "2.0.0", asking for "12.0.0" must fail.
if err := New().VerifyBinary("12.0.0"); err == nil {
t.Fatal("VerifyBinary(prefix-mismatch) expected error, got nil")
if err := New().VerifyBinary("12.1.0"); err == nil {
t.Fatal("VerifyBinary(prefix-style mismatch) expected error, got nil")
}
}
func TestVerifyBinaryLookPathNotFound(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
mock := &lookPathMock{result: "", resultErr: fmt.Errorf("not found")}
mock.install("lark-cli")
t.Cleanup(mock.restore)
oldFS := vfs.DefaultFS
t.Cleanup(func() { vfs.DefaultFS = oldFS })
// Without this, VerifyBinary would fall back to the real test binary, which
// is not a lark-cli --version implementation.
vfs.DefaultFS = executableTestFS{exe: filepath.Join(t.TempDir(), "missing-lark-cli")}
if err := New().VerifyBinary("2.0.0"); err == nil {
t.Fatal("VerifyBinary(not-found) expected error, got nil")
}
}
func TestVerifyBinaryFallbackExecutableWhenNotOnPath(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell script")
}
dir := t.TempDir()
bin := filepath.Join(dir, "lark-cli-abs")
script := "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo \"lark-cli version 2.1.0\"; exit 0; fi\nexit 12\n"
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatalf("write test binary: %v", err)
}
mock := &lookPathMock{result: "", resultErr: fmt.Errorf("not on PATH")}
mock.install("lark-cli")
t.Cleanup(mock.restore)
oldFS := vfs.DefaultFS
t.Cleanup(func() { vfs.DefaultFS = oldFS })
vfs.DefaultFS = executableTestFS{exe: bin}
if err := New().VerifyBinary("2.1.0"); err != nil {
t.Fatalf("VerifyBinary(fallback executable) error = %v, want nil", err)
}
}
func TestVerifyBinaryEmptyOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell script")
}
dir := t.TempDir()
bin := filepath.Join(dir, "lark-cli")
script := "#!/bin/sh\necho\nexit 0\n"
if err := os.WriteFile(bin, []byte(script), 0755); err != nil {
t.Fatalf("write test binary: %v", err)
}
mock := &lookPathMock{result: bin}
mock.install("lark-cli")
t.Cleanup(mock.restore)
if err := New().VerifyBinary("2.0.0"); err == nil {
t.Fatal("VerifyBinary(empty output) expected error, got nil")
}
}

66
scripts/check-doc-tokens.sh Executable file
View File

@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
#
# check-doc-tokens.sh
#
# Scans skill reference docs for token-like values that look realistic but
# are not using the required placeholder format (*_EXAMPLE_TOKEN or similar).
#
# Real token patterns (Lark API) often look like:
# wikcnXXXXXXXXX doccnXXXXXXX shtcnXXX fldcnXXX ou_XXXX cli_XXXX
#
# Docs MUST use clearly fake placeholders, e.g.:
# wikcn_EXAMPLE_TOKEN doccn_EXAMPLE_TOKEN <space_id> your_token_here
#
# If this check fails, replace the realistic-looking value with a placeholder
# like `wikcn_EXAMPLE_TOKEN` so gitleaks CI won't flag it as a real secret.
set -euo pipefail
SKILLS_DIR="${1:-skills}"
ERRORS=0
# Patterns that indicate a realistic-looking Lark token value.
# Three forms are detected:
# 1. JSON-style quoted strings: "field": "token_value"
# 2. Markdown backtick spans: `token_value`
# 3. Bare tokens: --flag wikcnABC123 (e.g. inside fenced code blocks)
#
# Token prefixes used by Lark Open Platform:
# wikcn doccn docx shtcn bascn fldcn vewcn tbln ou_ cli_ obcn flec
#
# Excluded (clearly fake, matched by PLACEHOLDER_RE below):
# - Values containing EXAMPLE / _TOKEN / XXXX / your_ / _here
# - Angle-bracket placeholders <your_token>
# Require at least one digit in the suffix — real API tokens are always alphanumeric
# with digits. Pure-letter suffixes (e.g. ou_manager, ou_director) are clearly fake names.
PREFIXES='(wikcn|doccn|docx[a-z]|shtcn|bascn|fldcn|vewcn|tbln|obcn|flec|ou_|cli_)'
TOKEN_BODY="${PREFIXES}"'[A-Za-z0-9]*[0-9][A-Za-z0-9]{3,}'
REALISTIC_TOKEN_RE="\"${TOKEN_BODY}\"|\`${TOKEN_BODY}\`|\\b${TOKEN_BODY}\\b"
PLACEHOLDER_RE='(EXAMPLE|_TOKEN|XXXX|xxxx|<|>|your_|_here)'
while IFS= read -r -d '' file; do
# grep returns exit 1 when no match — use || true to avoid set -e killing us
# Then filter out values that are clearly placeholders (EXAMPLE, XXXX, etc.)
matches=$(grep -nEo "$REALISTIC_TOKEN_RE" "$file" 2>/dev/null | grep -vE "$PLACEHOLDER_RE" || true)
if [[ -n "$matches" ]]; then
echo ""
echo "$file"
echo " Contains realistic-looking token values that may trigger gitleaks:"
while IFS= read -r line; do
echo " $line"
done <<< "$matches"
echo " → Replace with a placeholder, e.g.: wikcn_EXAMPLE_TOKEN, doccn_EXAMPLE_TOKEN"
ERRORS=$((ERRORS + 1))
fi
done < <(find "$SKILLS_DIR" -path "*/references/*.md" -print0)
if [[ $ERRORS -gt 0 ]]; then
echo ""
echo "❌ check-doc-tokens: $ERRORS file(s) contain realistic token values in reference docs."
echo " Use _EXAMPLE_TOKEN placeholders to avoid false positives in gitleaks CI."
exit 1
else
echo "✅ check-doc-tokens: all reference docs use safe placeholder tokens."
fi

View File

@@ -11,5 +11,8 @@ func Shortcuts() []common.Shortcut {
WikiMove,
WikiNodeCreate,
WikiDeleteSpace,
WikiSpaceList,
WikiNodeList,
WikiNodeCopy,
}
}

View File

@@ -0,0 +1,973 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"encoding/json"
"net/http"
"net/url"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// ── +space-list ──────────────────────────────────────────────────────────────
func TestWikiShortcutsIncludesSpaceListNodeListNodeCopy(t *testing.T) {
t.Parallel()
commands := map[string]bool{}
for _, s := range Shortcuts() {
commands[s.Command] = true
}
for _, want := range []string{"+space-list", "+node-list", "+node-copy"} {
if !commands[want] {
t.Errorf("Shortcuts() missing %q", want)
}
}
}
// TestWikiListShortcutsDeclareNarrowScopes pins the per-endpoint scope
// choice. The framework's preflight does exact string matching, so a broad
// scope (e.g. wiki:wiki:readonly) would wrongly reject tokens carrying only
// the narrow per-API scope that the API actually accepts.
func TestWikiListShortcutsDeclareNarrowScopes(t *testing.T) {
t.Parallel()
cases := []struct {
name string
shortcut common.Shortcut
want []string
}{
{"+space-list", WikiSpaceList, []string{"wiki:space:retrieve"}},
{"+node-list", WikiNodeList, []string{"wiki:node:retrieve"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if !reflect.DeepEqual(tc.shortcut.Scopes, tc.want) {
t.Fatalf("%s scopes = %v, want %v", tc.name, tc.shortcut.Scopes, tc.want)
}
})
}
}
func TestWikiSpaceListReturnsPaginatedSpaces(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"space_id": "space_1",
"name": "Engineering Wiki",
"space_type": "team",
},
map[string]interface{}{
"space_id": "space_2",
"name": "Personal Library",
"space_type": "my_library",
},
},
},
"msg": "success",
},
})
err := mountAndRunWiki(t, WikiSpaceList, []string{"+space-list", "--as", "bot"}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
OK bool `json:"ok"`
Data struct {
Spaces []map[string]interface{} `json:"spaces"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
} `json:"data"`
Meta struct {
Count float64 `json:"count"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if !envelope.OK {
t.Fatalf("expected ok=true, got %s", stdout.String())
}
if envelope.Meta.Count != 2 {
t.Fatalf("meta.count = %v, want 2", envelope.Meta.Count)
}
if envelope.Data.HasMore {
t.Fatalf("has_more = true, want false on natural end")
}
if envelope.Data.Spaces[0]["name"] != "Engineering Wiki" {
t.Fatalf("spaces[0].name = %v, want %q", envelope.Data.Spaces[0]["name"], "Engineering Wiki")
}
}
// ── +node-list ───────────────────────────────────────────────────────────────
func TestWikiNodeListRequiresSpaceID(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiNodeList, []string{"+node-list", "--as", "user"}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "required") {
t.Fatalf("expected required flag error, got %v", err)
}
}
func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"space_id": "space_123",
"node_token": "wik_node_1",
"obj_token": "docx_1",
"obj_type": "docx",
"parent_node_token": "",
"node_type": "origin",
"title": "Getting Started",
"has_child": true,
},
map[string]interface{}{
"space_id": "space_123",
"node_token": "wik_node_2",
"obj_token": "docx_2",
"obj_type": "docx",
"parent_node_token": "",
"node_type": "origin",
"title": "Architecture",
"has_child": false,
},
},
},
"msg": "success",
},
})
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "space_123", "--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
OK bool `json:"ok"`
Data struct {
Nodes []map[string]interface{} `json:"nodes"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
} `json:"data"`
Meta struct {
Count float64 `json:"count"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if !envelope.OK {
t.Fatalf("expected ok=true, got %s", stdout.String())
}
if envelope.Meta.Count != 2 {
t.Fatalf("meta.count = %v, want 2", envelope.Meta.Count)
}
if envelope.Data.Nodes[0]["title"] != "Getting Started" {
t.Fatalf("nodes[0].title = %v, want %q", envelope.Data.Nodes[0]["title"], "Getting Started")
}
if envelope.Data.Nodes[0]["has_child"] != true {
t.Fatalf("nodes[0].has_child = %v, want true", envelope.Data.Nodes[0]["has_child"])
}
}
func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes?page_size=50&parent_node_token=wik_parent",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"space_id": "space_123",
"node_token": "wik_child",
"obj_token": "docx_child",
"obj_type": "docx",
"parent_node_token": "wik_parent",
"node_type": "origin",
"title": "Child Doc",
"has_child": false,
},
},
},
"msg": "success",
},
}
reg.Register(stub)
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "space_123", "--parent-node-token", "wik_parent", "--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
// Verify the correct node was returned (parent_node_token was passed correctly).
var envelope struct {
OK bool `json:"ok"`
Data struct {
Nodes []map[string]interface{} `json:"nodes"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if !envelope.OK {
t.Fatalf("expected ok=true, got %s", stdout.String())
}
if len(envelope.Data.Nodes) != 1 {
t.Fatalf("len(nodes) = %d, want 1", len(envelope.Data.Nodes))
}
if envelope.Data.Nodes[0]["parent_node_token"] != "wik_parent" {
t.Fatalf("nodes[0].parent_node_token = %v, want %q", envelope.Data.Nodes[0]["parent_node_token"], "wik_parent")
}
}
func TestWikiNodeListRejectsMyLibraryForBot(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "my_library", "--as", "bot",
}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "bot identity does not support --space-id my_library") {
t.Fatalf("expected my_library bot rejection, got %v", err)
}
}
func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
// Step 1: resolve my_library to the real space_id.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/my_library",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"space": map[string]interface{}{
"space_id": "space_personal_42",
"name": "My Library",
"space_type": "my_library",
},
},
},
})
// Step 2: list nodes in the resolved space.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_personal_42/nodes",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"space_id": "space_personal_42",
"node_token": "wik_personal_1",
"title": "Personal Note",
},
},
},
},
})
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "my_library", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
OK bool `json:"ok"`
Data struct {
Nodes []map[string]interface{} `json:"nodes"`
} `json:"data"`
Meta struct {
Count float64 `json:"count"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if envelope.Meta.Count != 1 {
t.Fatalf("meta.count = %v, want 1", envelope.Meta.Count)
}
if envelope.Data.Nodes[0]["space_id"] != "space_personal_42" {
t.Fatalf("nodes[0].space_id = %v, want space_personal_42", envelope.Data.Nodes[0]["space_id"])
}
}
// ── +node-copy ───────────────────────────────────────────────────────────────
func TestWikiNodeCopyRequiresTargetSpaceOrParent(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiNodeCopy, []string{
"+node-copy", "--space-id", "space_123", "--node-token", "wik_src", "--as", "bot",
}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "--target-space-id or --target-parent-node-token") {
t.Fatalf("expected target validation error, got %v", err)
}
}
func TestWikiNodeCopyRejectsBothTargetFlags(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiNodeCopy, []string{
"+node-copy", "--space-id", "space_123", "--node-token", "wik_src",
"--target-space-id", "space_dst", "--target-parent-node-token", "wik_parent",
"--as", "bot",
}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("expected mutually exclusive error, got %v", err)
}
}
// TestWikiNodeCopyDeclaredHighRiskWrite pins down the high-risk-write
// contract: invocation without --yes must fail with a confirmation_required
// error and must NOT issue the underlying API call. The aligned upstream
// schema flags this API as `danger: true`, and the shortcut now matches that
// risk classification.
func TestWikiNodeCopyDeclaredHighRiskWrite(t *testing.T) {
t.Parallel()
if WikiNodeCopy.Risk != "high-risk-write" {
t.Fatalf("WikiNodeCopy.Risk = %q, want %q", WikiNodeCopy.Risk, "high-risk-write")
}
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
// No HTTP stub registered — if the gate leaks, the request fires and
// httpmock errors with "no stub for POST ..." instead of the expected
// confirmation_required error, making the regression obvious.
err := mountAndRunWiki(t, WikiNodeCopy, []string{
"+node-copy",
"--space-id", "space_src",
"--node-token", "wik_src",
"--target-space-id", "space_dst",
"--as", "bot",
}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
t.Fatalf("expected confirmation_required error, got %v", err)
}
}
func TestWikiNodeCopyCopiesNodeToTargetSpace(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, stderr, reg := cmdutil.TestFactory(t, wikiTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces/space_src/nodes/wik_src/copy",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_dst",
"node_token": "wik_copied",
"obj_token": "docx_copied",
"obj_type": "docx",
"parent_node_token": "",
"node_type": "origin",
"title": "Architecture (Copy)",
"has_child": false,
},
},
"msg": "success",
},
}
reg.Register(stub)
err := mountAndRunWiki(t, WikiNodeCopy, []string{
"+node-copy",
"--space-id", "space_src",
"--node-token", "wik_src",
"--target-space-id", "space_dst",
"--title", "Architecture (Copy)",
"--yes",
"--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if !envelope.OK {
t.Fatalf("expected ok=true, got %s", stdout.String())
}
if envelope.Data["node_token"] != "wik_copied" {
t.Fatalf("node_token = %v, want %q", envelope.Data["node_token"], "wik_copied")
}
if envelope.Data["space_id"] != "space_dst" {
t.Fatalf("space_id = %v, want %q", envelope.Data["space_id"], "space_dst")
}
var captured map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if captured["target_space_id"] != "space_dst" {
t.Fatalf("captured target_space_id = %v, want %q", captured["target_space_id"], "space_dst")
}
if captured["title"] != "Architecture (Copy)" {
t.Fatalf("captured title = %v, want %q", captured["title"], "Architecture (Copy)")
}
if got := stderr.String(); !strings.Contains(got, "Copying wiki node") {
t.Fatalf("stderr = %q, want copy message", got)
}
}
func TestWikiNodeCopyCopiesNodeToTargetParent(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces/space_src/nodes/wik_src/copy",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_src",
"node_token": "wik_copied2",
"obj_token": "docx_copied2",
"obj_type": "docx",
"parent_node_token": "wik_parent_dst",
"node_type": "origin",
"title": "Architecture",
"has_child": false,
},
},
"msg": "success",
},
}
reg.Register(stub)
err := mountAndRunWiki(t, WikiNodeCopy, []string{
"+node-copy",
"--space-id", "space_src",
"--node-token", "wik_src",
"--target-parent-node-token", "wik_parent_dst",
"--yes",
"--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var captured map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if captured["target_parent_token"] != "wik_parent_dst" {
t.Fatalf("captured target_parent_token = %v, want %q", captured["target_parent_token"], "wik_parent_dst")
}
if _, hasTitle := captured["title"]; hasTitle {
t.Fatalf("title should not be in body when --title not provided, got %v", captured)
}
}
// ── +space-list / +node-list pagination & format ─────────────────────────────
func TestWikiSpaceListRejectsInvalidPageSize(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiSpaceList, []string{
"+space-list", "--page-size", "0", "--as", "bot",
}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "--page-size must be between 1 and 50") {
t.Fatalf("expected page-size validation error, got %v", err)
}
}
func TestWikiSpaceListRejectsNegativePageLimit(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiSpaceList, []string{
"+space-list", "--page-limit", "-1", "--as", "bot",
}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "--page-limit must be a non-negative integer") {
t.Fatalf("expected page-limit validation error, got %v", err)
}
}
func TestWikiSpaceListAutoPaginatesAcrossPages(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
// Page 1: has_more=true, page_token set. Loop must continue.
page1 := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "tok_page2",
"items": []interface{}{
map[string]interface{}{"space_id": "sp_1", "name": "First"},
},
},
},
}
// Page 2: must receive page_token=tok_page2 in query. Captured to verify.
var page2Query string
page2 := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces",
OnMatch: func(req *http.Request) { page2Query = req.URL.RawQuery },
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{
map[string]interface{}{"space_id": "sp_2", "name": "Second"},
},
},
},
}
reg.Register(page1)
reg.Register(page2)
err := mountAndRunWiki(t, WikiSpaceList, []string{"+space-list", "--page-all", "--as", "bot"}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
Data struct {
Spaces []map[string]interface{} `json:"spaces"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
} `json:"data"`
Meta struct {
Count float64 `json:"count"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if envelope.Meta.Count != 2 || len(envelope.Data.Spaces) != 2 {
t.Fatalf("merged spaces = %d / count=%v, want 2 / 2", len(envelope.Data.Spaces), envelope.Meta.Count)
}
if envelope.Data.HasMore || envelope.Data.PageToken != "" {
t.Fatalf("natural end should clear has_more/page_token, got has_more=%v page_token=%q", envelope.Data.HasMore, envelope.Data.PageToken)
}
q, _ := url.ParseQuery(page2Query)
if q.Get("page_token") != "tok_page2" {
t.Fatalf("page2 page_token = %q, want tok_page2", q.Get("page_token"))
}
}
func TestWikiSpaceListPageLimitTruncatesAndExposesNextCursor(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
// Only stub page 1; with --page-limit=1, the loop must stop BEFORE
// requesting page 2 — and surface has_more/page_token so the caller can resume.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "tok_next",
"items": []interface{}{
map[string]interface{}{"space_id": "sp_only", "name": "First"},
},
},
},
})
err := mountAndRunWiki(t, WikiSpaceList, []string{
"+space-list", "--page-all", "--page-limit", "1", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
Data struct {
Spaces []map[string]interface{} `json:"spaces"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if len(envelope.Data.Spaces) != 1 {
t.Fatalf("spaces = %d, want 1 (capped)", len(envelope.Data.Spaces))
}
if !envelope.Data.HasMore || envelope.Data.PageToken != "tok_next" {
t.Fatalf("truncated state = has_more=%v page_token=%q, want true / tok_next", envelope.Data.HasMore, envelope.Data.PageToken)
}
}
func TestWikiSpaceListExplicitPageTokenStopsAfterOnePage(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
// Stub a page where has_more=true; auto-pagination should NOT trigger
// because the caller supplied an explicit --page-token cursor.
var capturedQuery string
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces",
OnMatch: func(req *http.Request) { capturedQuery = req.URL.RawQuery },
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "tok_next",
"items": []interface{}{map[string]interface{}{"space_id": "sp_x"}},
},
},
})
err := mountAndRunWiki(t, WikiSpaceList, []string{
"+space-list", "--page-token", "tok_input", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
q, _ := url.ParseQuery(capturedQuery)
if q.Get("page_token") != "tok_input" {
t.Fatalf("captured page_token = %q, want tok_input", q.Get("page_token"))
}
}
func TestWikiSpaceListPrettyFormatRendersFields(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"space_id": "sp_1",
"name": "Engineering",
"description": "team docs",
"space_type": "team",
"visibility": "public",
"open_sharing": "open",
},
},
},
},
})
err := mountAndRunWiki(t, WikiSpaceList, []string{
"+space-list", "--format", "pretty", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
out := stdout.String()
for _, want := range []string{
"Engineering",
"space_id: sp_1",
"space_type: team",
"visibility: public",
"description: team docs",
} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q, got:\n%s", want, out)
}
}
}
func TestWikiNodeListDefaultIsSinglePage(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
// Only one stub registered; if the default tried to auto-paginate, the
// loop would attempt a 2nd request and httpmock would error. So this
// test pins down the "default = single page" contract.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "tok_next",
"items": []interface{}{
map[string]interface{}{"space_id": "space_123", "node_token": "wik_1", "title": "First"},
},
},
},
})
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "space_123", "--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
Data struct {
Nodes []map[string]interface{} `json:"nodes"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if len(envelope.Data.Nodes) != 1 {
t.Fatalf("nodes = %d, want 1 (single page default)", len(envelope.Data.Nodes))
}
if !envelope.Data.HasMore || envelope.Data.PageToken != "tok_next" {
t.Fatalf("single-page default should surface upstream cursor, got has_more=%v page_token=%q", envelope.Data.HasMore, envelope.Data.PageToken)
}
}
func TestWikiNodeListPrettyFormatRendersFields(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": false,
"items": []interface{}{
map[string]interface{}{
"space_id": "space_123",
"node_token": "wik_1",
"obj_type": "docx",
"obj_token": "docx_1",
"title": "Getting Started",
"has_child": true,
},
},
},
},
})
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "space_123", "--format", "pretty", "--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
out := stdout.String()
for _, want := range []string{
"Getting Started",
"node_token: wik_1",
"obj_type: docx",
"has_child: true",
} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q, got:\n%s", want, out)
}
}
}
// ── QA-driven fixes: empty slice + has_more hint + node-copy format ──
func TestWikiSpaceListEmptyResultReturnsEmptySliceNotNull(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{},
},
},
})
err := mountAndRunWiki(t, WikiSpaceList, []string{"+space-list", "--as", "bot"}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
// Substring assertion is the only reliable way to distinguish [] from null
// in serialised JSON — unmarshalling both back into a Go slice would
// collapse the distinction.
if !strings.Contains(stdout.String(), `"spaces": []`) {
t.Fatalf("expected spaces to be empty array [], got:\n%s", stdout.String())
}
if strings.Contains(stdout.String(), `"spaces": null`) {
t.Fatalf("spaces serialised as null — JSON consumers expect []:\n%s", stdout.String())
}
var envelope struct {
Meta struct {
Count float64 `json:"count"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if envelope.Meta.Count != 0 {
t.Fatalf("meta.count = %v, want 0", envelope.Meta.Count)
}
}
func TestWikiSpaceListPrettyHintsWhenEmptyButHasMore(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "tok_more",
"items": []interface{}{},
},
},
})
err := mountAndRunWiki(t, WikiSpaceList, []string{"+space-list", "--format", "pretty", "--as", "bot"}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
out := stdout.String()
// When the bot's first page is filtered out by upstream permissions, the
// blanket "No wiki spaces found." used to mislead users into thinking they
// had no access at all. Pretty mode must now distinguish that case.
if strings.Contains(out, "No wiki spaces found.") {
t.Fatalf("pretty output should not flatly claim 'No wiki spaces found.' when has_more=true; got:\n%s", out)
}
for _, want := range []string{
"Current page is empty but the server reports more pages.",
"tok_more",
} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q, got:\n%s", want, out)
}
}
}
func TestWikiNodeCopyHasFormatPrettyRendersNode(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces/space_src/nodes/wik_src/copy",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_dst",
"node_token": "wik_copied",
"obj_token": "docx_copied",
"obj_type": "docx",
"parent_node_token": "wik_parent",
"node_type": "origin",
"title": "Architecture (Copy)",
},
},
},
})
err := mountAndRunWiki(t, WikiNodeCopy, []string{
"+node-copy",
"--space-id", "space_src",
"--node-token", "wik_src",
"--target-space-id", "space_dst",
"--title", "Architecture (Copy)",
"--format", "pretty",
"--yes",
"--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
out := stdout.String()
for _, want := range []string{
"Copied node:",
"title: Architecture (Copy)",
"node_token: wik_copied",
"space_id: space_dst",
"parent_node_token: wik_parent",
} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q, got:\n%s", want, out)
}
}
}

View File

@@ -0,0 +1,140 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// WikiNodeCopy copies a wiki node into a target space or under a target parent node.
var WikiNodeCopy = common.Shortcut{
Service: "wiki",
Command: "+node-copy",
Description: "Copy a wiki node to a target space or parent node",
Risk: "high-risk-write",
Scopes: []string{"wiki:node:copy"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "space-id", Desc: "source wiki space ID", Required: true},
{Name: "node-token", Desc: "source node token to copy", Required: true},
{Name: "target-space-id", Desc: "target wiki space ID; required if --target-parent-node-token is not set"},
{Name: "target-parent-node-token", Desc: "target parent node token; required if --target-space-id is not set"},
{Name: "title", Desc: "new title for the copied node; leave empty to keep the original title"},
},
Tips: []string{
"At least one of --target-space-id or --target-parent-node-token must be provided.",
"Omit --title to keep the original node title in the copy.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateOptionalResourceName(strings.TrimSpace(runtime.Str("space-id")), "--space-id"); err != nil {
return err
}
if err := validateOptionalResourceName(strings.TrimSpace(runtime.Str("node-token")), "--node-token"); err != nil {
return err
}
targetSpaceID := strings.TrimSpace(runtime.Str("target-space-id"))
targetParent := strings.TrimSpace(runtime.Str("target-parent-node-token"))
if targetSpaceID == "" && targetParent == "" {
return output.ErrValidation("at least one of --target-space-id or --target-parent-node-token is required")
}
if targetSpaceID != "" && targetParent != "" {
return output.ErrValidation("--target-space-id and --target-parent-node-token are mutually exclusive; provide only one")
}
if err := validateOptionalResourceName(targetSpaceID, "--target-space-id"); err != nil {
return err
}
return validateOptionalResourceName(targetParent, "--target-parent-node-token")
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spaceID := strings.TrimSpace(runtime.Str("space-id"))
nodeToken := strings.TrimSpace(runtime.Str("node-token"))
return common.NewDryRunAPI().
POST(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes/%s/copy",
validate.EncodePathSegment(spaceID),
validate.EncodePathSegment(nodeToken))).
Body(buildNodeCopyBody(runtime)).
Set("space_id", spaceID).
Set("node_token", nodeToken)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spaceID := strings.TrimSpace(runtime.Str("space-id"))
nodeToken := strings.TrimSpace(runtime.Str("node-token"))
fmt.Fprintf(runtime.IO().ErrOut, "Copying wiki node %s from space %s\n",
common.MaskToken(nodeToken), common.MaskToken(spaceID))
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes/%s/copy",
validate.EncodePathSegment(spaceID),
validate.EncodePathSegment(nodeToken)),
nil, buildNodeCopyBody(runtime))
if err != nil {
return err
}
node, err := parseWikiNodeRecord(common.GetMap(data, "node"))
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Copied to node %s in space %s\n",
common.MaskToken(node.NodeToken), common.MaskToken(node.SpaceID))
out := wikiNodeCopyOutput(node)
runtime.OutFormat(out, nil, func(w io.Writer) {
renderWikiNodeCopyPretty(w, out)
})
return nil
},
}
func renderWikiNodeCopyPretty(w io.Writer, out map[string]interface{}) {
fmt.Fprintf(w, "Copied node:\n")
fmt.Fprintf(w, " title: %s\n", valueOrDash(out["title"]))
fmt.Fprintf(w, " node_token: %s\n", valueOrDash(out["node_token"]))
fmt.Fprintf(w, " space_id: %s\n", valueOrDash(out["space_id"]))
fmt.Fprintf(w, " obj_type: %s\n", valueOrDash(out["obj_type"]))
fmt.Fprintf(w, " obj_token: %s\n", valueOrDash(out["obj_token"]))
if parent, _ := out["parent_node_token"].(string); parent != "" {
fmt.Fprintf(w, " parent_node_token: %s\n", parent)
}
}
func buildNodeCopyBody(runtime *common.RuntimeContext) map[string]interface{} {
// Validate has already rejected the case where both --target-space-id and
// --target-parent-node-token are set (mutually exclusive). It is safe to
// inline both flags here; do not loosen that check without revisiting this
// body builder, or the upstream API will see an ambiguous request shape.
body := map[string]interface{}{}
if v := strings.TrimSpace(runtime.Str("target-space-id")); v != "" {
body["target_space_id"] = v
}
if v := strings.TrimSpace(runtime.Str("target-parent-node-token")); v != "" {
body["target_parent_token"] = v
}
if v := strings.TrimSpace(runtime.Str("title")); v != "" {
body["title"] = v
}
return body
}
func wikiNodeCopyOutput(node *wikiNodeRecord) map[string]interface{} {
return map[string]interface{}{
"space_id": node.SpaceID,
"node_token": node.NodeToken,
"obj_token": node.ObjToken,
"obj_type": node.ObjType,
"node_type": node.NodeType,
"title": node.Title,
"parent_node_token": node.ParentNodeToken,
"has_child": node.HasChild,
}
}

View File

@@ -413,6 +413,25 @@ func requireWikiSpaceID(space *wikiSpaceRecord) (string, error) {
return "", output.ErrValidation("personal document library was not found, please specify --space-id")
}
// resolveMyLibrarySpaceID calls GET /wiki/v2/spaces/my_library and returns
// the per-user real space_id. Shared by shortcuts that accept the my_library
// alias (e.g. +node-create, +node-list) so the behavior stays consistent.
func resolveMyLibrarySpaceID(runtime *common.RuntimeContext) (string, error) {
data, err := runtime.CallAPI(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(wikiMyLibrarySpaceID)),
nil, nil,
)
if err != nil {
return "", err
}
space, err := parseWikiSpaceRecord(common.GetMap(data, "space"))
if err != nil {
return "", err
}
return requireWikiSpaceID(space)
}
func validateOptionalResourceName(value, flagName string) error {
if value == "" {
return nil

View File

@@ -111,8 +111,8 @@ func TestWikiShortcutsIncludeAllCommands(t *testing.T) {
t.Parallel()
shortcuts := Shortcuts()
if len(shortcuts) != 3 {
t.Fatalf("len(Shortcuts()) = %d, want 3", len(shortcuts))
if len(shortcuts) != 6 {
t.Fatalf("len(Shortcuts()) = %d, want 6", len(shortcuts))
}
if shortcuts[0].Command != "+move" {
t.Fatalf("shortcuts[0].Command = %q, want %q", shortcuts[0].Command, "+move")

View File

@@ -0,0 +1,218 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"fmt"
"io"
"strconv"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
wikiNodeListDefaultPageSize = 50
wikiNodeListMaxPageSize = 50
)
// WikiNodeList lists child nodes in a wiki space or under a parent node.
var WikiNodeList = common.Shortcut{
Service: "wiki",
Command: "+node-list",
Description: "List wiki nodes in a space or under a parent node",
Risk: "read",
// Same exact-match-scope reasoning as +space-list: declare the
// narrowest scope the upstream API accepts so we don't false-reject
// tokens that only carry wiki:node:retrieve.
Scopes: []string{"wiki:node:retrieve"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "space-id", Desc: "wiki space ID; use my_library for the personal document library, or +space-list to discover other space IDs", Required: true},
{Name: "parent-node-token", Desc: "parent node token; if omitted, lists the root-level nodes of the space"},
{Name: "page-size", Type: "int", Default: strconv.Itoa(wikiNodeListDefaultPageSize), Desc: fmt.Sprintf("page size, 1-%d", wikiNodeListMaxPageSize)},
{Name: "page-token", Desc: "page token; implies single-page fetch (no auto-pagination)"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (capped by --page-limit)"},
{Name: "page-limit", Type: "int", Default: "10", Desc: "max pages to fetch with --page-all (default 10, 0 = unlimited)"},
},
Tips: []string{
"Default fetches a single page; pass --page-all to walk every page (large knowledge bases can be huge — keep an eye on --page-limit).",
"Use --parent-node-token to drill into a sub-directory.",
"Run +space-list first to discover your space IDs, including the personal document library.",
"--space-id my_library is a per-user alias and is only valid with --as user.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
spaceID := strings.TrimSpace(runtime.Str("space-id"))
// 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 deferring to API-time errors. Matches the contract
// used by +node-create and +move.
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id")
}
if err := validateOptionalResourceName(spaceID, "--space-id"); err != nil {
return err
}
if err := validateOptionalResourceName(strings.TrimSpace(runtime.Str("parent-node-token")), "--parent-node-token"); err != nil {
return err
}
return validateWikiListPagination(runtime, wikiNodeListMaxPageSize)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spaceID := strings.TrimSpace(runtime.Str("space-id"))
params := map[string]interface{}{"page_size": runtime.Int("page-size")}
if pt := strings.TrimSpace(runtime.Str("parent-node-token")); pt != "" {
params["parent_node_token"] = pt
}
if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" {
params["page_token"] = pt
}
d := common.NewDryRunAPI()
if wikiListShouldAutoPaginate(runtime) {
d.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
}
// When the caller passes my_library, +node-list must first resolve it
// to the real per-user space_id before listing nodes, mirroring the
// two-step orchestration used by +node-create.
if spaceID == wikiMyLibrarySpaceID {
return d.
Desc("2-step orchestration: resolve my_library -> list nodes").
GET("/open-apis/wiki/v2/spaces/my_library").
Desc("[1] Resolve my_library space ID").
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", "<resolved_space_id>")).
Desc("[2] List nodes").
Params(params).
Set("space_id", "<resolved_space_id>")
}
return d.
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID))).
Params(params).
Set("space_id", spaceID)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
warnIfConflictingPagingFlags(runtime)
spaceID := strings.TrimSpace(runtime.Str("space-id"))
// Resolve the my_library alias to the per-user real space_id before
// listing, so the subsequent request hits a concrete space endpoint.
if spaceID == wikiMyLibrarySpaceID {
resolved, err := resolveMyLibrarySpaceID(runtime)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved my_library to space %s\n", common.MaskToken(resolved))
spaceID = resolved
}
nodes, hasMore, nextToken, err := fetchWikiNodes(runtime, spaceID)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Found %d node(s)\n", len(nodes))
outData := map[string]interface{}{
"nodes": nodes,
"has_more": hasMore,
"page_token": nextToken,
}
runtime.OutFormat(outData, &output.Meta{Count: len(nodes)}, func(w io.Writer) {
renderWikiNodesPretty(w, nodes, hasMore, nextToken)
})
return nil
},
}
func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[string]interface{}, bool, string, error) {
pageSize := runtime.Int("page-size")
startToken := strings.TrimSpace(runtime.Str("page-token"))
parentNodeToken := strings.TrimSpace(runtime.Str("parent-node-token"))
auto := wikiListShouldAutoPaginate(runtime)
pageLimit := runtime.Int("page-limit")
apiPath := fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID))
// Non-nil empty slice keeps json output stable as `[]` instead of `null`.
var (
nodes = make([]map[string]interface{}, 0)
pageToken = startToken
lastHasMore bool
lastPageToken string
)
for page := 0; ; page++ {
params := map[string]interface{}{"page_size": pageSize}
if parentNodeToken != "" {
params["parent_node_token"] = parentNodeToken
}
if pageToken != "" {
params["page_token"] = pageToken
}
data, err := runtime.CallAPI("GET", apiPath, params, nil)
if err != nil {
return nil, false, "", err
}
items, _ := data["items"].([]interface{})
for _, item := range items {
if m, ok := item.(map[string]interface{}); ok {
nodes = append(nodes, wikiNodeListItem(m))
}
}
lastHasMore, _ = data["has_more"].(bool)
lastPageToken, _ = data["page_token"].(string)
if !auto {
break
}
if !lastHasMore || lastPageToken == "" {
break
}
if pageLimit > 0 && page+1 >= pageLimit {
break
}
pageToken = lastPageToken
}
return nodes, lastHasMore, lastPageToken, nil
}
func wikiNodeListItem(m map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"space_id": common.GetString(m, "space_id"),
"node_token": common.GetString(m, "node_token"),
"obj_token": common.GetString(m, "obj_token"),
"obj_type": common.GetString(m, "obj_type"),
"parent_node_token": common.GetString(m, "parent_node_token"),
"node_type": common.GetString(m, "node_type"),
"title": common.GetString(m, "title"),
"has_child": common.GetBool(m, "has_child"),
}
}
func renderWikiNodesPretty(w io.Writer, nodes []map[string]interface{}, hasMore bool, pageToken string) {
if len(nodes) == 0 {
if hasMore && pageToken != "" {
fmt.Fprintln(w, "Current page is empty but the server reports more pages.")
fmt.Fprintln(w, "Pass --page-all to walk every page, or --page-token to resume from the cursor below:")
fmt.Fprintf(w, " next page_token: %s\n", pageToken)
return
}
fmt.Fprintln(w, "No wiki nodes found.")
return
}
for i, n := range nodes {
fmt.Fprintf(w, "[%d] %s\n", i+1, valueOrDash(n["title"]))
fmt.Fprintf(w, " node_token: %s\n", valueOrDash(n["node_token"]))
fmt.Fprintf(w, " obj_type: %s\n", valueOrDash(n["obj_type"]))
fmt.Fprintf(w, " obj_token: %s\n", valueOrDash(n["obj_token"]))
hasChild, _ := n["has_child"].(bool)
fmt.Fprintf(w, " has_child: %t\n", hasChild)
if parent, _ := n["parent_node_token"].(string); parent != "" {
fmt.Fprintf(w, " parent: %s\n", parent)
}
fmt.Fprintln(w)
}
if hasMore && pageToken != "" {
fmt.Fprintf(w, "Next page token: %s\n", pageToken)
}
}

View File

@@ -0,0 +1,211 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"fmt"
"io"
"strconv"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const (
wikiSpaceListAPIPath = "/open-apis/wiki/v2/spaces"
wikiSpaceListDefaultPageSize = 50
wikiSpaceListMaxPageSize = 50
)
// WikiSpaceList lists all wiki spaces the caller has access to.
var WikiSpaceList = common.Shortcut{
Service: "wiki",
Command: "+space-list",
Description: "List wiki spaces accessible to the caller",
Risk: "read",
// Declare the narrowest valid scope: the upstream API accepts any of
// wiki:wiki / wiki:wiki:readonly / wiki:space:retrieve, but the
// framework's preflight does exact-string scope matching (see
// internal/auth/scope.go), so picking the broad readonly form would
// wrongly reject tokens that only carry the narrow retrieve scope and
// hand them a misleading missing-scope hint.
Scopes: []string{"wiki:space:retrieve"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "page-size", Type: "int", Default: strconv.Itoa(wikiSpaceListDefaultPageSize), Desc: fmt.Sprintf("page size, 1-%d", wikiSpaceListMaxPageSize)},
{Name: "page-token", Desc: "page token; implies single-page fetch (no auto-pagination)"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (capped by --page-limit)"},
{Name: "page-limit", Type: "int", Default: "10", Desc: "max pages to fetch with --page-all (default 10, 0 = unlimited)"},
},
Tips: []string{
"Default fetches a single page (matches other list shortcuts in this CLI); pass --page-all to pull every page.",
"The underlying API never returns the my_library personal library; resolve it via `wiki spaces get --params '{\"space_id\":\"my_library\"}'`.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateWikiListPagination(runtime, wikiSpaceListMaxPageSize)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params := map[string]interface{}{"page_size": runtime.Int("page-size")}
if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" {
params["page_token"] = pt
}
dry := common.NewDryRunAPI()
// Auto-pagination is the default — make it explicit in the dry-run so
// callers can see whether the loop will fire.
if wikiListShouldAutoPaginate(runtime) {
dry.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
}
return dry.GET(wikiSpaceListAPIPath).Params(params)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
warnIfConflictingPagingFlags(runtime)
spaces, hasMore, nextToken, err := fetchWikiSpaces(runtime)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Found %d wiki space(s)\n", len(spaces))
outData := map[string]interface{}{
"spaces": spaces,
"has_more": hasMore,
"page_token": nextToken,
}
runtime.OutFormat(outData, &output.Meta{Count: len(spaces)}, func(w io.Writer) {
renderWikiSpacesPretty(w, spaces, hasMore, nextToken)
})
return nil
},
}
// fetchWikiSpaces honours the four pagination flags:
// - default (no --page-all, no --page-token): fetch a single page from the start
// - --page-token X: fetch a single page starting at X (auto-pagination disabled)
// - --page-all: pull subsequent pages, capped by --page-limit (default 10; 0 = unlimited)
//
// The returned slice is always non-nil so json output stays as `[]` instead of `null`.
func fetchWikiSpaces(runtime *common.RuntimeContext) ([]map[string]interface{}, bool, string, error) {
pageSize := runtime.Int("page-size")
startToken := strings.TrimSpace(runtime.Str("page-token"))
auto := wikiListShouldAutoPaginate(runtime)
pageLimit := runtime.Int("page-limit")
var (
spaces = make([]map[string]interface{}, 0)
pageToken = startToken
lastHasMore bool
lastPageToken string
)
for page := 0; ; page++ {
params := map[string]interface{}{"page_size": pageSize}
if pageToken != "" {
params["page_token"] = pageToken
}
data, err := runtime.CallAPI("GET", wikiSpaceListAPIPath, params, nil)
if err != nil {
return nil, false, "", err
}
items, _ := data["items"].([]interface{})
for _, item := range items {
if m, ok := item.(map[string]interface{}); ok {
spaces = append(spaces, parseWikiSpaceItem(m))
}
}
lastHasMore, _ = data["has_more"].(bool)
lastPageToken, _ = data["page_token"].(string)
if !auto {
break
}
if !lastHasMore || lastPageToken == "" {
break
}
if pageLimit > 0 && page+1 >= pageLimit {
break
}
pageToken = lastPageToken
}
return spaces, lastHasMore, lastPageToken, nil
}
func parseWikiSpaceItem(m map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"space_id": common.GetString(m, "space_id"),
"name": common.GetString(m, "name"),
"description": common.GetString(m, "description"),
"space_type": common.GetString(m, "space_type"),
"visibility": common.GetString(m, "visibility"),
"open_sharing": common.GetString(m, "open_sharing"),
}
}
func renderWikiSpacesPretty(w io.Writer, spaces []map[string]interface{}, hasMore bool, pageToken string) {
if len(spaces) == 0 {
// Distinguish "nothing here" from "current page empty but server says
// more pages follow" — the latter is a hint to keep paginating instead
// of giving up.
if hasMore && pageToken != "" {
fmt.Fprintln(w, "Current page is empty but the server reports more pages.")
fmt.Fprintln(w, "Pass --page-all to walk every page, or --page-token to resume from the cursor below:")
fmt.Fprintf(w, " next page_token: %s\n", pageToken)
return
}
fmt.Fprintln(w, "No wiki spaces found.")
return
}
for i, s := range spaces {
fmt.Fprintf(w, "[%d] %s\n", i+1, valueOrDash(s["name"]))
fmt.Fprintf(w, " space_id: %s\n", valueOrDash(s["space_id"]))
fmt.Fprintf(w, " space_type: %s\n", valueOrDash(s["space_type"]))
fmt.Fprintf(w, " visibility: %s\n", valueOrDash(s["visibility"]))
fmt.Fprintf(w, " open_sharing: %s\n", valueOrDash(s["open_sharing"]))
if desc, _ := s["description"].(string); desc != "" {
fmt.Fprintf(w, " description: %s\n", desc)
}
fmt.Fprintln(w)
}
if hasMore && pageToken != "" {
fmt.Fprintf(w, "Next page token: %s\n", pageToken)
}
}
func valueOrDash(v interface{}) string {
if s, ok := v.(string); ok && s != "" {
return s
}
return "-"
}
// validateWikiListPagination performs flag-level validation shared by
// +space-list and +node-list.
func validateWikiListPagination(runtime *common.RuntimeContext, maxPageSize int) error {
if n := runtime.Int("page-size"); n < 1 || n > maxPageSize {
return common.FlagErrorf("--page-size must be between 1 and %d", maxPageSize)
}
if n := runtime.Int("page-limit"); n < 0 {
return common.FlagErrorf("--page-limit must be a non-negative integer")
}
return nil
}
// wikiListShouldAutoPaginate reports whether the fetch loop should keep
// requesting additional pages. An explicit --page-token disables auto loop
// because the caller has supplied a specific cursor.
func wikiListShouldAutoPaginate(runtime *common.RuntimeContext) bool {
if strings.TrimSpace(runtime.Str("page-token")) != "" {
return false
}
return runtime.Bool("page-all")
}
// warnIfConflictingPagingFlags logs a notice when --page-token and --page-all
// are both set. --page-token wins (single-page fetch from the supplied cursor)
// and --page-all is silently ignored, which would otherwise look like a bug to
// callers expecting subsequent pages to be drained.
func warnIfConflictingPagingFlags(runtime *common.RuntimeContext) {
if strings.TrimSpace(runtime.Str("page-token")) != "" && runtime.Bool("page-all") {
fmt.Fprintln(runtime.IO().ErrOut,
"warning: --page-token is set, so --page-all is ignored (single-page fetch from the supplied cursor)")
}
}

View File

@@ -57,7 +57,7 @@ lark-cli docs +search \
# 按文档所有者过滤creator_ids 传文档所有者 open_id不是邮箱 / user_id
lark-cli docs +search \
--query "季度总结" \
--filter '{"creator_ids":["ou_7890123456abcdef"]}'
--filter '{"creator_ids":["ou_EXAMPLE_USER_ID"]}'
# 只搜索指定类型
lark-cli docs +search \
@@ -87,7 +87,7 @@ lark-cli docs +search \
# 只搜索指定分享者分享过的文档sharer_ids 传分享者 open_id最多 20 个)
lark-cli docs +search \
--query "复盘" \
--filter '{"sharer_ids":["ou_7890123456abcdef"]}'
--filter '{"sharer_ids":["ou_EXAMPLE_USER_ID"]}'
# 按创建时间过滤并指定排序方式
lark-cli docs +search \
@@ -97,7 +97,7 @@ lark-cli docs +search \
# 组合多个筛选条件
lark-cli docs +search \
--query "项目复盘" \
--filter '{"creator_ids":["ou_7890123456abcdef"],"doc_types":["DOCX","SHEET"],"only_title":true,"sort_type":"OPEN_TIME","open_time":{"start":"2026-01-01T00:00:00+08:00"}}'
--filter '{"creator_ids":["ou_EXAMPLE_USER_ID"],"doc_types":["DOCX","SHEET"],"only_title":true,"sort_type":"OPEN_TIME","open_time":{"start":"2026-01-01T00:00:00+08:00"}}'
# 只在指定知识空间下搜 Wiki
lark-cli docs +search \
@@ -179,10 +179,10 @@ lark-cli docs +search --query "方案" --format json --page-token '<PAGE_TOKEN>'
### 常见 `--filter` JSON 片段
```json
{"creator_ids":["ou_7890123456abcdef"]}
{"creator_ids":["ou_EXAMPLE_USER_ID"]}
{"doc_types":["SHEET","DOCX"]}
{"chat_ids":["oc_1234567890abcdef"]}
{"sharer_ids":["ou_7890123456abcdef"]}
{"sharer_ids":["ou_EXAMPLE_USER_ID"]}
{"folder_tokens":["fld_123456"]}
{"only_title":true}
{"only_comment":true}

View File

@@ -200,6 +200,19 @@ lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_typedocx/doc/sheet/slides |
#### `permission.public.patch` 错误码引导
调用 `lark-cli drive permission.public patch` 更新文档公开权限失败时,如果返回以下错误码,按表格给用户明确下一步。不要把这些错误简单归类为缺少 scope它们通常表示租户、对外分享或文档密级策略拦截。
| 错误码 | 含义 | 给用户的引导 |
|--------|------------------------|--------------|
| `91009` | 对外分享被租户安全策略管控,当前用户无法开启 | 提示用户:对外分享能力被租户安全策略统一管控,无法通过 API 或当前用户直接开启;需要联系租户管理员调整组织级对外分享策略。 |
| `91010` | 文档对外分享未打开 | 提示用户:当前文档尚未打开对外分享,请先在文档权限设置中打开对外分享,再重试 `permission.public.patch`。 |
| `91011` | 对外分享被文档密级管控 | 提示用户:对外分享被密级策略拦截,需要打开目标文档,在文档内发起密级豁免或进行密级降级后再重试;回复中必须给出目标文档 URL。 |
| `91012` | 权限设置被文档密级管控 | 提示用户:该权限设置被密级策略拦截,需要打开目标文档,在文档内发起密级豁免或进行密级降级后再重试;回复中必须给出目标文档 URL。 |
当用户最初提供的是文档 URL遇到 `91011` 或 `91012` 时直接把该 URL 原样返回给用户作为操作入口;如果上下文只有 token需要先尽量通过已有上下文、搜索结果或元数据恢复目标文档 URL再给出可点击的文档 URL。
### 授权当前应用访问文档
当需要将文档权限授予**当前应用bot自身**时,先通过 bot info 接口获取应用的 open_id再调用权限接口授权
@@ -302,27 +315,29 @@ lark-cli drive <resource> <method> [flags] # 调用 API
## 权限表
| 方法 | 所需 scope |
|------|-----------|
| `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` |
| `file.comments.patch` | `docs:document.comment:update` |
| `file.comment.replys.create` | `docs:document.comment:create` |
| `file.comment.replys.delete` | `docs:document.comment:delete` |
| `file.comment.replys.list` | `docs:document.comment:read` |
| `file.comment.replys.update` | `docs:document.comment:update` |
| `permission.members.auth` | `docs:permission.member:auth` |
| `permission.members.create` | `docs:permission.member:create` |
| `permission.members.transfer_owner` | `docs:permission.member:transfer` |
| `metas.batch_query` | `drive:drive.metadata:readonly` |
| `user.remove_subscription` | `docs:event:subscribe` |
| `user.subscription` | `docs:event:subscribe` |
| `user.subscription_status` | `docs:event:subscribe` |
| `file.statistics.get` | `drive:drive.metadata:readonly` |
| `file.view_records.list` | `drive:file:view_record:readonly` |
| `file.comment.reply.reactions.update_reaction` | `docs:document.comment:create` |
| 方法 | 所需 scope |
|------------------------------------------------|-----------------------------------|
| `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` |
| `file.comments.patch` | `docs:document.comment:update` |
| `file.comment.replys.create` | `docs:document.comment:create` |
| `file.comment.replys.delete` | `docs:document.comment:delete` |
| `file.comment.replys.list` | `docs:document.comment:read` |
| `file.comment.replys.update` | `docs:document.comment:update` |
| `permission.members.auth` | `docs:permission.member:auth` |
| `permission.members.create` | `docs:permission.member:create` |
| `permission.members.transfer_owner` | `docs:permission.member:transfer` |
| `permission.public.get` | `docs:permission.setting:read` |
| `permission.public.patch` | `docs:permission.setting:write_only` |
| `metas.batch_query` | `drive:drive.metadata:readonly` |
| `user.remove_subscription` | `docs:event:subscribe` |
| `user.subscription` | `docs:event:subscribe` |
| `user.subscription_status` | `docs:event:subscribe` |
| `file.statistics.get` | `drive:drive.metadata:readonly` |
| `file.view_records.list` | `drive:file:view_record:readonly` |
| `file.comment.reply.reactions.update_reaction` | `docs:document.comment:create` |

View File

@@ -174,7 +174,7 @@ lark-cli minutes +search --query "预算复盘" --page-size 20 --page-token '<PA
lark-cli minutes minutes get --params '{"minute_token": "obcn***************"}'
# 查妙记关联的纪要产物:逐字稿、总结、待办、章节等 → 用 lark-cli vc +notes
lark-cli vc +notes --minute-tokens obcnhijv43vq6bcsl5xasfb2
lark-cli vc +notes --minute-tokens obcn_EXAMPLE_TOKEN
```
## 常见错误与排查

View File

@@ -17,6 +17,22 @@ Alignment (对齐关系): Objective ↔ Objective
Category (分类): Objective 的分组标签
```
## 常用用户表述
以下是部分用户可能会使用的指令表述,以及它们对应的实体与字段。
- "进度 / 完成度 / 进展值",当用户提到量化的进度或进展这样的概念时
- 对应实体: Indicator, 通常主要关注进度的当前值(Indicator.current_value)
- "进展 / 更新 / Check-in",当用户提到泛化的进度更新,尤其是有明确的文本化内容时
- 对应实体: Progress
- "Objective 或 KeyResult 的状态"
- 对应字段: Indicator.indicator_status
- 注意,虽然 Objective/KeyResult 下的 Progress 中也有 ProgressRate 字段,但这一字段仅代表这条 Progress 的进度状态(创建 Progress 时的状态),而非 Objective/KeyResult 的当前进度
- "打分 / 评分 / 分数"
- 对应字段: Objective.score, KeyResult.score, Cycle.score
- "对齐 / 挂靠"
- 对应实体: Alignment
---
## Owner (所有者)

View File

@@ -57,6 +57,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli wiki +<verb> [flags]`
| [`+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 |
| [`+delete-space`](references/lark-wiki-delete-space.md) | Delete a wiki space, polling the async delete task when needed |
| [`+space-list`](references/lark-wiki-space-list.md) | List all wiki spaces accessible to the caller |
| [`+node-list`](references/lark-wiki-node-list.md) | List wiki nodes in a space or under a parent node (supports pagination) |
| [`+node-copy`](references/lark-wiki-node-copy.md) | Copy a wiki node to a target space or parent node |
## API Resources
@@ -98,6 +101,7 @@ lark-cli wiki <resource> <method> [flags] # 调用 API
| `members.delete` | `wiki:member:update` |
| `members.list` | `wiki:member:retrieve` |
| `nodes.copy` | `wiki:node:copy` |
| `nodes.move` | `wiki:node:move` |
| `nodes.create` | `wiki:node:create` |
| `nodes.list` | `wiki:node:retrieve` |

View File

@@ -0,0 +1,72 @@
# lark-wiki +node-copy
Copy a wiki node (including its content) to a target space or under a target parent node. Used for cross-space migration.
> ⚠️ **High-risk write** — the upstream API is flagged `danger: true`, so this shortcut requires explicit `--yes` confirmation before issuing the request. Forgetting `--yes` returns a `confirmation_required` error and the copy is **not** performed.
## Usage
```bash
lark-cli wiki +node-copy \
--space-id <source_space_id> \
--node-token <source_node_token> \
(--target-space-id <target_space_id> | --target-parent-node-token <token>) \
[--title <new_title>] \
--yes \
[--as user|bot]
```
## Flags
| Flag | Required | Description |
|------|----------|-------------|
| `--space-id` | **Yes** | Source wiki space ID |
| `--node-token` | **Yes** | Source node token to copy |
| `--target-space-id` | Conditional | Target space ID. Required if `--target-parent-node-token` is not set |
| `--target-parent-node-token` | Conditional | Target parent node token. Required if `--target-space-id` is not set |
| `--title` | No | New title for the copied node. Omit to keep the original title |
| `--yes` | **Yes** | Confirm the high-risk operation. Without this flag the shortcut refuses to send the API request |
| `--format` | No | Output format: `json` (default) / `pretty` / `table` / `csv` / `ndjson` |
| `--as` | No | Identity: `user` or `bot` (default: `user`) |
> At least one of `--target-space-id` or `--target-parent-node-token` must be provided.
## Output
```json
{
"space_id": "target_space_id",
"node_token": "wikcn_EXAMPLE_TOKEN",
"obj_token": "doccn_EXAMPLE_TOKEN",
"obj_type": "docx",
"node_type": "origin",
"title": "Getting Started (Copy)",
"parent_node_token": "",
"has_child": false
}
```
## Migration workflow
To migrate a subtree from one space to another:
```bash
# 1. List nodes in the source space
lark-cli wiki +node-list --space-id source_space_id
# 2. Copy each node to the target space
lark-cli wiki +node-copy \
--space-id <source_space_id> \
--node-token wikcn_EXAMPLE_TOKEN \
--target-space-id <target_space_id> \
--yes
```
## Notes
- Copying is recursive — the subtree under the node is also copied.
- There is no native move API; migration = copy to target + (manually delete source if needed).
## Required Scope
`wiki:node:copy`

View File

@@ -0,0 +1,88 @@
# lark-wiki +node-list
List wiki nodes in a space or under a specific parent node. **Default fetches a single page** (large knowledge bases can have thousands of nodes — opt into `--page-all` explicitly with an eye on `--page-limit`).
## Usage
```bash
# Default: single page of root nodes
lark-cli wiki +node-list --space-id <SPACE_ID>
# Drill into a sub-directory (still single page by default)
lark-cli wiki +node-list --space-id <SPACE_ID> --parent-node-token <NODE_TOKEN>
# Personal document library (user identity only)
lark-cli wiki +node-list --space-id my_library --as user
# Walk every page (capped by --page-limit, default 10)
lark-cli wiki +node-list --space-id <SPACE_ID> --page-all
# Walk every page with a higher cap
lark-cli wiki +node-list --space-id <SPACE_ID> --page-all --page-limit 30
# Resume from a cursor
lark-cli wiki +node-list --space-id <SPACE_ID> --page-token <TOKEN>
# Pretty / table output
lark-cli wiki +node-list --space-id <SPACE_ID> --format pretty
```
## Flags
| Flag | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `--space-id` | string | **Yes** | — | Wiki space ID. Use `my_library` for personal document library (user only) |
| `--parent-node-token` | string | No | — | Parent node token; omit to list the space root |
| `--page-size` | int | No | 50 | Page size, 1-50 |
| `--page-token` | string | No | — | Page cursor; implies single-page fetch (no auto-pagination) |
| `--page-all` | bool | No | `false` | Automatically paginate through all pages (capped by `--page-limit`) |
| `--page-limit` | int | No | 10 | Max pages with `--page-all` (0 = unlimited) |
| `--format` | enum | No | `json` | `json` / `pretty` / `table` / `csv` / `ndjson` |
| `--as` | enum | No | `user` | Identity: `user` or `bot` |
## Output
```json
{
"ok": true,
"data": {
"nodes": [
{
"space_id": "6946843325487912356",
"node_token": "wikcn_EXAMPLE_TOKEN",
"obj_token": "doccn_EXAMPLE_TOKEN",
"obj_type": "docx",
"parent_node_token": "",
"node_type": "origin",
"title": "Getting Started",
"has_child": true
}
],
"has_more": false,
"page_token": ""
},
"meta": { "count": 1 }
}
```
When the default single-page fetch (or `--page-all` capped by `--page-limit`) does not exhaust the upstream cursor, `has_more=true` and `page_token=<cursor>` so the caller can resume via `--page-token` or by increasing `--page-limit`.
## Traverse the wiki tree
To list all content recursively, call `+node-list` again with each node's `node_token` as `--parent-node-token` when `has_child` is `true`.
```bash
# Step 1: list root nodes
lark-cli wiki +node-list --space-id 6946843325487912356
# Step 2: drill into a node that has children
lark-cli wiki +node-list --space-id 6946843325487912356 --parent-node-token wikcn_EXAMPLE_TOKEN
```
## Notes
- `--space-id my_library` is a per-user alias and only valid with `--as user`. The shortcut will refuse `--as bot` with `my_library` upfront.
## Required Scope
`wiki:node:retrieve`

View File

@@ -0,0 +1,68 @@
# lark-wiki +space-list
List wiki spaces accessible to the caller. **Default fetches a single page** (matches the rest of the CLI's list shortcuts); pass `--page-all` to walk every page.
## Usage
```bash
# Default: single page (first up to --page-size items)
lark-cli wiki +space-list
# Walk every page (capped by --page-limit, default 10)
lark-cli wiki +space-list --page-all
# Walk every page, no cap (use with care if you have many spaces)
lark-cli wiki +space-list --page-all --page-limit 0
# Resume from a specific cursor (single-page fetch regardless of --page-all)
lark-cli wiki +space-list --page-token <TOKEN>
# Pretty / table / csv / ndjson output
lark-cli wiki +space-list --format pretty
lark-cli wiki +space-list --format table
```
## Flags
| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--page-size` | int | 50 | Page size, 1-50 |
| `--page-token` | string | — | Page cursor; implies single-page fetch (no auto-pagination) |
| `--page-all` | bool | `false` | Automatically paginate through all pages (capped by `--page-limit`) |
| `--page-limit` | int | 10 | Max pages with `--page-all` (0 = unlimited) |
| `--format` | enum | `json` | `json` / `pretty` / `table` / `csv` / `ndjson` |
| `--as` | enum | `user` | Identity: `user` or `bot` |
## Output
```json
{
"ok": true,
"data": {
"spaces": [
{
"space_id": "6946843325487912356",
"name": "Engineering Wiki",
"description": "...",
"space_type": "team",
"visibility": "private",
"open_sharing": "closed"
}
],
"has_more": false,
"page_token": ""
},
"meta": { "count": 1 }
}
```
When the default single-page fetch (or `--page-all` capped by `--page-limit`) does not exhaust the upstream cursor, `has_more=true` and `page_token=<cursor>` so the caller can resume via `--page-token` or by increasing `--page-limit`.
## Notes
- **The underlying API never returns the my_library personal library**; resolve it via `lark-cli wiki spaces get --params '{"space_id":"my_library"}'`.
- Use `space_id` from the output as `--space-id` for `+node-list` or `+node-copy`.
## Required Scope
`wiki:space:retrieve`

View File

@@ -1,13 +1,13 @@
# Wiki CLI E2E Coverage
## Metrics
- Denominator: 6 leaf commands
- Covered: 6
- Denominator: 6 leaf commands + 3 shortcut commands
- Covered: 9
- Coverage: 100.0%
## Summary
- TestWiki_NodeWorkflow: proves the full currently-tested wiki domain surface; key `t.Run(...)` proof points are `create node as bot`, `get created node as bot`, `get space as bot`, `list spaces as bot`, `list nodes and find created node as bot`, `copy node as bot`, and `list nodes and find copied node as bot`.
- The workflow covers both node creation/copy/listing and space lookup/listing with persisted token assertions.
- TestWiki_NodeWorkflow: proves the full currently-tested bare-API surface; key `t.Run(...)` proof points are `create node as bot`, `get created node as bot`, `get space as bot`, `list spaces as bot`, `list nodes and find created node as bot`, `copy node as bot`, and `list nodes and find copied node as bot`.
- TestWiki_ShortcutWorkflow: covers the shortcut layer for `wiki +space-list`, `wiki +node-list`, and `wiki +node-copy` — flag→body mapping, envelope shape (`{spaces|nodes, has_more, page_token}` + `meta.count`), `--page-all` / `--page-limit` truncation, my_library alias resolution (user positive + bot validation rejection), and copy-source-survival.
## Command Table
@@ -19,3 +19,6 @@
| ✓ | wiki spaces get | api | wiki_workflow_test.go::TestWiki_NodeWorkflow/get space as bot | `space_id` in `--params` | |
| ✓ | wiki spaces get_node | api | wiki_workflow_test.go::TestWiki_NodeWorkflow/get created node as bot | `token`; `obj_type` in `--params` | |
| ✓ | wiki spaces list | api | wiki_workflow_test.go::TestWiki_NodeWorkflow/list spaces as bot | `page_size` in `--params` | |
| ✓ | wiki +space-list | shortcut | wiki_shortcut_workflow_test.go::TestWiki_ShortcutWorkflow/+space-list: stable envelope shape | `--page-size`; `--format json`; bot identity | |
| ✓ | wiki +node-list | shortcut | wiki_shortcut_workflow_test.go::TestWiki_ShortcutWorkflow/+node-list: finds child under parent; +node-list: --page-limit caps the loop and exposes cursor; +node-list --space-id my_library --as bot: validation rejection; +node-list --space-id my_library --as user: resolves and lists | `--space-id`; `--parent-node-token`; `--page-all`; `--page-size`; `--page-limit`; my_library alias | |
| ✓ | wiki +node-copy | shortcut | wiki_shortcut_workflow_test.go::TestWiki_ShortcutWorkflow/+node-copy: copies child + verifies source survives + cleanup | `--space-id`; `--node-token`; `--target-space-id`; `--title` | |

View File

@@ -0,0 +1,269 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestWiki_ShortcutWorkflow exercises the shortcut layer (wiki +space-list,
// +node-list, +node-copy) end-to-end against a real Lark tenant. The existing
// TestWiki_NodeWorkflow only hits the bare `api` command, so it does not
// protect against regressions in shortcut-specific behavior — flag → body
// mapping, envelope shape ({spaces|nodes, has_more, page_token} + meta.count),
// auto-pagination, my_library alias resolution, or required-flag validation.
func TestWiki_ShortcutWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
parentTitle := "lark-cli-e2e-wiki-sc-parent-" + suffix
childTitle := "lark-cli-e2e-wiki-sc-child-" + suffix
copyTitle := "lark-cli-e2e-wiki-sc-copy-" + suffix
var spaceID, parentNodeToken, childNodeToken, childObjType string
// Setup: reuse an existing first-layer node in my_library as the host so
// we never bump the top-layer node count (the bot's my_library top layer
// has hit the API's "single-layer nodes ... upper limit" — code 131003 —
// in earlier CI runs because of leftover nodes). Then create a FRESH
// intermediate parent under that host, and put the test child under the
// fresh parent. We can't put the child directly under the host because
// leftover nodes from prior runs accumulate as the host's children, so
// `+node-list --parent-node-token=<host>` returns hundreds of unrelated
// nodes and the just-created child gets paged out (regardless of
// --page-limit) before the test can find it. An isolated intermediate
// parent always has exactly the children this test creates, so the
// pagination scan never has to dig through historical cruft.
t.Run("setup: locate my_library host node + create isolated parent + create test child", func(t *testing.T) {
listResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"api", "get", "/open-apis/wiki/v2/spaces/my_library/nodes"},
DefaultAs: "bot",
Params: map[string]any{"page_size": 50},
})
require.NoError(t, err)
listResult.AssertExitCode(t, 0)
listResult.AssertStdoutStatus(t, 0)
items := gjson.Get(listResult.Stdout, "data.items").Array()
if len(items) == 0 {
t.Skip("skipped: my_library has no existing top-level nodes to host the test structure")
}
host := items[0]
spaceID = host.Get("space_id").String()
hostNodeToken := host.Get("node_token").String()
require.NotEmpty(t, spaceID, "host space_id must be present in listing")
require.NotEmpty(t, hostNodeToken, "host node_token must be present in listing")
// Create a fresh intermediate parent under the host. The helper
// auto-registers a t.Cleanup callback that deletes this parent
// (and, by API cascade, anything still under it) after the test.
isolatedParent := createWikiNode(t, parentT, ctx, spaceID, map[string]any{
"node_type": "origin",
"obj_type": "docx",
"title": parentTitle,
"parent_node_token": hostNodeToken,
})
parentNodeToken = isolatedParent.Get("node_token").String()
require.NotEmpty(t, parentNodeToken, "isolated parent node_token must be present after create")
// Create the test child UNDER the freshly-isolated parent.
child := createWikiNode(t, parentT, ctx, spaceID, map[string]any{
"node_type": "origin",
"obj_type": "docx",
"title": childTitle,
"parent_node_token": parentNodeToken,
})
childNodeToken = child.Get("node_token").String()
childObjType = child.Get("obj_type").String()
require.NotEmpty(t, childNodeToken)
})
// QA-P1: +space-list envelope shape is stable for JSON consumers.
// `spaces` must always be an array (never null), and pagination metadata
// fields must always exist so downstream agents can introspect.
t.Run("+space-list: stable envelope shape", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"wiki", "+space-list", "--page-size", "1"},
DefaultAs: "bot",
Format: "json",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := gjson.Parse(result.Stdout)
require.True(t, out.Get("ok").Bool(), "stdout:\n%s", result.Stdout)
assert.True(t, out.Get("data.spaces").Exists(), "data.spaces must exist")
assert.True(t, out.Get("data.spaces").IsArray(), "data.spaces must be an array, even when empty")
assert.True(t, out.Get("data.has_more").Exists(), "data.has_more must always be present")
assert.True(t, out.Get("data.page_token").Exists(), "data.page_token must always be present")
// meta.count uses `json:",omitempty"` in the envelope framework, so the
// field is dropped when the count is zero. Comparing values (gjson
// returns 0 for missing keys) keeps the assertion correct in both the
// "no spaces visible" and "some spaces" cases without requiring a
// framework-level change.
spacesLen := len(out.Get("data.spaces").Array())
assert.Equal(t, float64(spacesLen), out.Get("meta.count").Float(),
"meta.count must equal len(data.spaces) (or be omitted when zero); stdout:\n%s", result.Stdout)
})
// QA-P1: +node-list correctly maps flags onto the underlying request body
// and surfaces the child we just created under the parent.
t.Run("+node-list: finds child under parent", func(t *testing.T) {
require.NotEmpty(t, spaceID)
require.NotEmpty(t, parentNodeToken)
require.NotEmpty(t, childNodeToken)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+node-list",
"--space-id", spaceID,
"--parent-node-token", parentNodeToken,
"--page-all",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := gjson.Parse(result.Stdout)
require.True(t, out.Get("ok").Bool(), "stdout:\n%s", result.Stdout)
match := out.Get(`data.nodes.#(node_token=="` + childNodeToken + `")`)
require.True(t, match.Exists(), "+node-list did not return the child we created:\n%s", result.Stdout)
assert.Equal(t, childTitle, match.Get("title").String())
assert.Equal(t, parentNodeToken, match.Get("parent_node_token").String())
})
// QA-P2: --page-size 1 --page-all --page-limit 1 must aggregate exactly
// one page and surface the next cursor when has_more=true. This catches
// regressions where the pagination loop overruns the cap or fails to
// surface has_more / page_token.
t.Run("+node-list: --page-limit caps the loop and exposes cursor", func(t *testing.T) {
require.NotEmpty(t, spaceID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+node-list",
"--space-id", spaceID,
"--page-size", "1",
"--page-all",
"--page-limit", "1",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := gjson.Parse(result.Stdout)
require.True(t, out.Get("ok").Bool(), "stdout:\n%s", result.Stdout)
nodes := out.Get("data.nodes").Array()
assert.LessOrEqual(t, len(nodes), 1, "--page-limit=1 + --page-size=1 should yield ≤1 node, got %d", len(nodes))
// has_more / page_token must still exist — never elided — so
// callers can resume regardless of whether the cap actually fired.
assert.True(t, out.Get("data.has_more").Exists())
assert.True(t, out.Get("data.page_token").Exists())
})
// QA-P1: +node-copy creates a copy under the same space and the source
// stays put (copy ≠ move). Cleanup deletes the copy. The copy is placed
// under the same host parent we use for the test child, so it doesn't
// add another top-layer node and trip the per-space limit.
t.Run("+node-copy: copies child + verifies source survives + cleanup", func(t *testing.T) {
require.NotEmpty(t, spaceID)
require.NotEmpty(t, parentNodeToken)
require.NotEmpty(t, childNodeToken)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+node-copy",
"--space-id", spaceID,
"--node-token", childNodeToken,
"--target-parent-node-token", parentNodeToken,
"--title", copyTitle,
},
// +node-copy is now declared high-risk-write to align with the
// upstream API's `danger: true` flag, so the framework requires
// explicit confirmation before issuing the request.
Yes: true,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := gjson.Parse(result.Stdout)
require.True(t, out.Get("ok").Bool(), "stdout:\n%s", result.Stdout)
copiedNodeToken := out.Get("data.node_token").String()
copiedSpaceID := out.Get("data.space_id").String()
copiedObjType := out.Get("data.obj_type").String()
require.NotEmpty(t, copiedNodeToken, "stdout:\n%s", result.Stdout)
require.NotEmpty(t, copiedSpaceID)
assert.Equal(t, copyTitle, out.Get("data.title").String())
parentT.Cleanup(func() {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := deleteWikiNode(cleanupCtx, copiedSpaceID, copiedNodeToken, copiedObjType)
clie2e.ReportCleanupFailure(parentT, "delete copied wiki node "+copiedNodeToken, deleteResult, deleteErr)
})
// Copy must be retrievable; source must still exist (copy ≠ move).
copied := getWikiNode(t, ctx, copiedNodeToken)
assert.Equal(t, copyTitle, copied.Get("title").String())
original := getWikiNode(t, ctx, childNodeToken)
assert.Equal(t, childTitle, original.Get("title").String(),
"source node must remain after +node-copy (copy is non-destructive)")
_ = childObjType // reserved for future +node-list filter checks
})
// QA-P2: bot identity must be rejected upfront when --space-id=my_library
// because the personal-library alias is per-user and meaningless for a
// tenant_access_token. The shortcut layer should fail before sending any
// HTTP request, with a validation error mentioning my_library.
t.Run("+node-list --space-id my_library --as bot: validation rejection", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"wiki", "+node-list", "--space-id", "my_library"},
DefaultAs: "bot",
})
require.NoError(t, err)
assert.NotEqual(t, 0, result.ExitCode, "bot + my_library must fail")
combined := strings.ToLower(result.Stdout + "\n" + result.Stderr)
assert.Contains(t, combined, "my_library",
"error must mention my_library to disambiguate from generic auth failures; got stdout=%s stderr=%s",
result.Stdout, result.Stderr)
})
// QA-P2: user identity must positively resolve --space-id=my_library to a
// real per-user space_id and proceed to list nodes. Skipped when no user
// token is available (matches the rest of the suite's user-flow gating).
t.Run("+node-list --space-id my_library --as user: resolves and lists", func(t *testing.T) {
clie2e.SkipWithoutUserToken(t)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"wiki", "+node-list", "--space-id", "my_library", "--page-size", "1"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := gjson.Parse(result.Stdout)
require.True(t, out.Get("ok").Bool(), "stdout:\n%s", result.Stdout)
assert.True(t, out.Get("data.nodes").Exists(), "data.nodes must exist after my_library resolution")
assert.True(t, out.Get("data.nodes").IsArray(), "data.nodes must be an array")
// stderr must record the my_library resolution so users/agents can
// see what space_id the alias mapped to.
assert.Contains(t, result.Stderr, "Resolved my_library",
"expected my_library resolution log on stderr; got: %s", result.Stderr)
})
}