diff --git a/.gitignore b/.gitignore index 90313e48..dc576a2c 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ cmd/api/download.bin app.log /sidecar-server-demo /server-demo +.tmp/ diff --git a/.gitleaks.toml b/.gitleaks.toml index 597b3395..8dbe4165 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -14,3 +14,4 @@ id = "lark-session-token" description = "Detect Lark session tokens" regex = '''\bXN0YXJ0-[A-Za-z0-9_-]+-WVuZA\b''' keywords = ["XN0YXJ0-", "-WVuZA"] + diff --git a/Makefile b/Makefile index 7d78c510..7733335b 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/scripts/check-doc-tokens.sh b/scripts/check-doc-tokens.sh new file mode 100755 index 00000000..a02c8f14 --- /dev/null +++ b/scripts/check-doc-tokens.sh @@ -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 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 +# 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 diff --git a/shortcuts/wiki/shortcuts.go b/shortcuts/wiki/shortcuts.go index f22be780..da5b388d 100644 --- a/shortcuts/wiki/shortcuts.go +++ b/shortcuts/wiki/shortcuts.go @@ -11,5 +11,8 @@ func Shortcuts() []common.Shortcut { WikiMove, WikiNodeCreate, WikiDeleteSpace, + WikiSpaceList, + WikiNodeList, + WikiNodeCopy, } } diff --git a/shortcuts/wiki/wiki_list_copy_test.go b/shortcuts/wiki/wiki_list_copy_test.go new file mode 100644 index 00000000..6c9cf07d --- /dev/null +++ b/shortcuts/wiki/wiki_list_copy_test.go @@ -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) + } + } +} diff --git a/shortcuts/wiki/wiki_node_copy.go b/shortcuts/wiki/wiki_node_copy.go new file mode 100644 index 00000000..5a4a97d0 --- /dev/null +++ b/shortcuts/wiki/wiki_node_copy.go @@ -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, + } +} diff --git a/shortcuts/wiki/wiki_node_create.go b/shortcuts/wiki/wiki_node_create.go index 639fdddf..d9fd7e5a 100644 --- a/shortcuts/wiki/wiki_node_create.go +++ b/shortcuts/wiki/wiki_node_create.go @@ -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 diff --git a/shortcuts/wiki/wiki_node_create_test.go b/shortcuts/wiki/wiki_node_create_test.go index a057c25d..df22f900 100644 --- a/shortcuts/wiki/wiki_node_create_test.go +++ b/shortcuts/wiki/wiki_node_create_test.go @@ -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") diff --git a/shortcuts/wiki/wiki_node_list.go b/shortcuts/wiki/wiki_node_list.go new file mode 100644 index 00000000..c743f232 --- /dev/null +++ b/shortcuts/wiki/wiki_node_list.go @@ -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", "")). + Desc("[2] List nodes"). + Params(params). + Set("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) + } +} diff --git a/shortcuts/wiki/wiki_space_list.go b/shortcuts/wiki/wiki_space_list.go new file mode 100644 index 00000000..7816467a --- /dev/null +++ b/shortcuts/wiki/wiki_space_list.go @@ -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)") + } +} diff --git a/skills/lark-doc/references/lark-doc-search.md b/skills/lark-doc/references/lark-doc-search.md index 6ca0df4d..3047a6c2 100644 --- a/skills/lark-doc/references/lark-doc-search.md +++ b/skills/lark-doc/references/lark-doc-search.md @@ -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 '' ### 常见 `--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} diff --git a/skills/lark-minutes/references/lark-minutes-search.md b/skills/lark-minutes/references/lark-minutes-search.md index 86b7e5da..cec6099e 100644 --- a/skills/lark-minutes/references/lark-minutes-search.md +++ b/skills/lark-minutes/references/lark-minutes-search.md @@ -174,7 +174,7 @@ lark-cli minutes +search --query "预算复盘" --page-size 20 --page-token ' [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 [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` | diff --git a/skills/lark-wiki/references/lark-wiki-node-copy.md b/skills/lark-wiki/references/lark-wiki-node-copy.md new file mode 100644 index 00000000..ebd3ab26 --- /dev/null +++ b/skills/lark-wiki/references/lark-wiki-node-copy.md @@ -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 \ + --node-token \ + (--target-space-id | --target-parent-node-token ) \ + [--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 \ + --node-token wikcn_EXAMPLE_TOKEN \ + --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` diff --git a/skills/lark-wiki/references/lark-wiki-node-list.md b/skills/lark-wiki/references/lark-wiki-node-list.md new file mode 100644 index 00000000..ebf12c5c --- /dev/null +++ b/skills/lark-wiki/references/lark-wiki-node-list.md @@ -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 + +# Drill into a sub-directory (still single page by default) +lark-cli wiki +node-list --space-id --parent-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 --page-all + +# Walk every page with a higher cap +lark-cli wiki +node-list --space-id --page-all --page-limit 30 + +# Resume from a cursor +lark-cli wiki +node-list --space-id --page-token + +# Pretty / table output +lark-cli wiki +node-list --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=` 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` diff --git a/skills/lark-wiki/references/lark-wiki-space-list.md b/skills/lark-wiki/references/lark-wiki-space-list.md new file mode 100644 index 00000000..0662cc3b --- /dev/null +++ b/skills/lark-wiki/references/lark-wiki-space-list.md @@ -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 + +# 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=` 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` diff --git a/tests/cli_e2e/wiki/coverage.md b/tests/cli_e2e/wiki/coverage.md index 6343cf91..be022097 100644 --- a/tests/cli_e2e/wiki/coverage.md +++ b/tests/cli_e2e/wiki/coverage.md @@ -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` | | diff --git a/tests/cli_e2e/wiki/wiki_shortcut_workflow_test.go b/tests/cli_e2e/wiki/wiki_shortcut_workflow_test.go new file mode 100644 index 00000000..863b5241 --- /dev/null +++ b/tests/cli_e2e/wiki/wiki_shortcut_workflow_test.go @@ -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=` 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) + }) +}