mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
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>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -39,3 +39,4 @@ cmd/api/download.bin
|
||||
app.log
|
||||
/sidecar-server-demo
|
||||
/server-demo
|
||||
.tmp/
|
||||
|
||||
@@ -14,3 +14,4 @@ id = "lark-session-token"
|
||||
description = "Detect Lark session tokens"
|
||||
regex = '''\bXN0YXJ0-[A-Za-z0-9_-]+-WVuZA\b'''
|
||||
keywords = ["XN0YXJ0-", "-WVuZA"]
|
||||
|
||||
|
||||
14
Makefile
14
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
|
||||
|
||||
66
scripts/check-doc-tokens.sh
Executable file
66
scripts/check-doc-tokens.sh
Executable 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
|
||||
@@ -11,5 +11,8 @@ func Shortcuts() []common.Shortcut {
|
||||
WikiMove,
|
||||
WikiNodeCreate,
|
||||
WikiDeleteSpace,
|
||||
WikiSpaceList,
|
||||
WikiNodeList,
|
||||
WikiNodeCopy,
|
||||
}
|
||||
}
|
||||
|
||||
973
shortcuts/wiki/wiki_list_copy_test.go
Normal file
973
shortcuts/wiki/wiki_list_copy_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
140
shortcuts/wiki/wiki_node_copy.go
Normal file
140
shortcuts/wiki/wiki_node_copy.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
218
shortcuts/wiki/wiki_node_list.go
Normal file
218
shortcuts/wiki/wiki_node_list.go
Normal 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)
|
||||
}
|
||||
}
|
||||
211
shortcuts/wiki/wiki_space_list.go
Normal file
211
shortcuts/wiki/wiki_space_list.go
Normal 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)")
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
## 常见错误与排查
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
72
skills/lark-wiki/references/lark-wiki-node-copy.md
Normal file
72
skills/lark-wiki/references/lark-wiki-node-copy.md
Normal 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`
|
||||
88
skills/lark-wiki/references/lark-wiki-node-list.md
Normal file
88
skills/lark-wiki/references/lark-wiki-node-list.md
Normal 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`
|
||||
68
skills/lark-wiki/references/lark-wiki-space-list.md
Normal file
68
skills/lark-wiki/references/lark-wiki-space-list.md
Normal 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`
|
||||
@@ -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` | |
|
||||
|
||||
269
tests/cli_e2e/wiki/wiki_shortcut_workflow_test.go
Normal file
269
tests/cli_e2e/wiki/wiki_shortcut_workflow_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user