mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14a3213038 | ||
|
|
caff780c17 | ||
|
|
5778adfefa | ||
|
|
7400226e34 | ||
|
|
4a45e00139 | ||
|
|
f03138b9f0 | ||
|
|
ed9eecf94f | ||
|
|
f49a2f7e14 | ||
|
|
a93fb2d6b3 |
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"]
|
||||
|
||||
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -2,6 +2,24 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.32] - 2026-05-15
|
||||
|
||||
### Features
|
||||
|
||||
- **doc**: Add `--width`/`--height` flags to `docs +media-insert` (#832)
|
||||
- **wiki**: Add `+space-list` / `+node-list` / `+node-copy` shortcuts (#392)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Preserve parent token on nested overwrite (#908)
|
||||
- **selfupdate**: Use `LookPath` instead of `Executable` for binary verification (#886)
|
||||
- **registry**: Wait for background meta refresh before test reset (#894)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Add SVG whiteboard support to `lark-doc` v2 skill (#901)
|
||||
- **drive**: Add permission public patch error guidance (#863)
|
||||
|
||||
## [v1.0.31] - 2026-05-14
|
||||
|
||||
### Features
|
||||
@@ -703,6 +721,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.32]: https://github.com/larksuite/cli/releases/tag/v1.0.32
|
||||
[v1.0.31]: https://github.com/larksuite/cli/releases/tag/v1.0.31
|
||||
[v1.0.30]: https://github.com/larksuite/cli/releases/tag/v1.0.30
|
||||
[v1.0.29]: https://github.com/larksuite/cli/releases/tag/v1.0.29
|
||||
|
||||
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
|
||||
|
||||
@@ -408,6 +408,26 @@ func TestConfigBindRun_LarkChannel_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Env template form: secret = "${VAR}" should resolve via the SecretInput
|
||||
// pipeline (same path openclaw uses), so the keychain receives the env value
|
||||
// not the literal template string.
|
||||
func TestConfigBindRun_LarkChannel_EnvTemplate(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
clearAgentEnv(t)
|
||||
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
t.Setenv("LARK_APP_SECRET", "resolved_via_env")
|
||||
writeLarkChannelFixture(t, fakeHome,
|
||||
`{"accounts":{"app":{"id":"cli_lc_env","secret":"${LARK_APP_SECRET}","tenant":"feishu"}}}`)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}); err != nil {
|
||||
t.Fatalf("expected success, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// tenant: "lark" should land as Brand("lark"), not normalized to "feishu".
|
||||
func TestConfigBindRun_LarkChannel_LarkTenant(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
|
||||
@@ -312,13 +312,22 @@ func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
return nil, output.Errorf(output.ExitInternal, "lark-channel",
|
||||
"internal: appID %q does not match config", appID)
|
||||
}
|
||||
if b.cfg.Accounts.App.Secret == "" {
|
||||
if b.cfg.Accounts.App.Secret.IsZero() {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
||||
fmt.Sprintf("accounts.app.secret is empty in %s", b.path),
|
||||
"run lark-channel-bridge's setup to populate the app credential")
|
||||
}
|
||||
|
||||
stored, err := core.ForStorage(appID, core.PlainSecret(b.cfg.Accounts.App.Secret), b.opts.Factory.Keychain)
|
||||
// Resolve through the same SecretInput pipeline openclaw uses, so
|
||||
// bridge configs can use ${VAR} / env / file / exec just like openclaw.
|
||||
secret, err := binding.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv)
|
||||
if err != nil {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
||||
fmt.Sprintf("failed to resolve appSecret for %s: %v", appID, err),
|
||||
fmt.Sprintf("check appSecret configuration in %s", b.path))
|
||||
}
|
||||
|
||||
stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "lark-channel",
|
||||
"keychain unavailable: %v", err)
|
||||
|
||||
@@ -15,6 +15,11 @@ import (
|
||||
// Unknown fields are ignored — forward-compatible with future bridge versions.
|
||||
type LarkChannelRoot struct {
|
||||
Accounts LarkChannelAccounts `json:"accounts"`
|
||||
// Secrets is an optional registry of secret providers — same shape as
|
||||
// openclaw's `secrets` block. Lets bridge declare `exec` provider scripts
|
||||
// (for AES-encrypted secret backends), `env` allowlists, or `file`
|
||||
// indirection rules. Resolved by binding.ResolveSecretInput.
|
||||
Secrets *SecretsConfig `json:"secrets,omitempty"`
|
||||
}
|
||||
|
||||
// LarkChannelAccounts is the namespace for credential entries.
|
||||
@@ -26,13 +31,17 @@ type LarkChannelAccounts struct {
|
||||
}
|
||||
|
||||
// LarkChannelApp is the bot app credential entry.
|
||||
// Bridge stores the secret as plain text — secret-resolve indirection
|
||||
// (${VAR} / file: / exec:) is intentionally not supported here, matching
|
||||
// the bridge's on-disk format.
|
||||
//
|
||||
// `Secret` accepts the full SecretInput protocol (string / "${VAR}" template /
|
||||
// SecretRef object with source env|file|exec) so users can keep secrets out
|
||||
// of config.json — either by referencing an env var the bridge inherits, a
|
||||
// chmod-0400 file outside the bridge dir, or an exec script that decrypts a
|
||||
// local AES-encrypted secret store. Aligns lark-channel with the same secret
|
||||
// protocol openclaw already uses.
|
||||
type LarkChannelApp struct {
|
||||
ID string `json:"id"`
|
||||
Secret string `json:"secret"`
|
||||
Tenant string `json:"tenant"` // "feishu" | "lark"
|
||||
ID string `json:"id"`
|
||||
Secret SecretInput `json:"secret"`
|
||||
Tenant string `json:"tenant"` // "feishu" | "lark"
|
||||
}
|
||||
|
||||
// ReadLarkChannelConfig reads and parses ~/.lark-channel/config.json.
|
||||
|
||||
@@ -24,8 +24,11 @@ func TestReadLarkChannelConfig_Valid(t *testing.T) {
|
||||
if got := root.Accounts.App.ID; got != "cli_abc123" {
|
||||
t.Errorf("ID = %q, want %q", got, "cli_abc123")
|
||||
}
|
||||
if got := root.Accounts.App.Secret; got != "plain_secret" {
|
||||
t.Errorf("Secret = %q, want %q", got, "plain_secret")
|
||||
if got := root.Accounts.App.Secret.Plain; got != "plain_secret" {
|
||||
t.Errorf("Secret.Plain = %q, want %q", got, "plain_secret")
|
||||
}
|
||||
if root.Accounts.App.Secret.Ref != nil {
|
||||
t.Errorf("expected Plain form, got SecretRef = %+v", root.Accounts.App.Secret.Ref)
|
||||
}
|
||||
if got := root.Accounts.App.Tenant; got != "feishu" {
|
||||
t.Errorf("Tenant = %q, want %q", got, "feishu")
|
||||
@@ -92,8 +95,74 @@ func TestReadLarkChannelConfig_PartialFields(t *testing.T) {
|
||||
if root.Accounts.App.ID != "" {
|
||||
t.Errorf("expected empty ID, got %q", root.Accounts.App.ID)
|
||||
}
|
||||
if root.Accounts.App.Secret != "" {
|
||||
t.Errorf("expected empty Secret, got %q", root.Accounts.App.Secret)
|
||||
if !root.Accounts.App.Secret.IsZero() {
|
||||
t.Errorf("expected zero Secret, got %+v", root.Accounts.App.Secret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLarkChannelConfig_SecretEnvTemplate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "config.json")
|
||||
data := `{"accounts":{"app":{"id":"cli_a","secret":"${LARK_APP_SECRET}","tenant":"feishu"}}}`
|
||||
if err := os.WriteFile(p, []byte(data), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
root, err := ReadLarkChannelConfig(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := root.Accounts.App.Secret.Plain; got != "${LARK_APP_SECRET}" {
|
||||
t.Errorf("Secret.Plain = %q, want template string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLarkChannelConfig_SecretRefExec(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "config.json")
|
||||
data := `{
|
||||
"accounts": {
|
||||
"app": {
|
||||
"id": "cli_a",
|
||||
"secret": {"source": "exec", "provider": "decrypt", "id": "app-cli_a"},
|
||||
"tenant": "feishu"
|
||||
}
|
||||
},
|
||||
"secrets": {
|
||||
"providers": {
|
||||
"decrypt": {"source": "exec", "command": "/usr/local/bin/lark-channel-bridge", "args": ["secrets", "get"]}
|
||||
}
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(p, []byte(data), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
root, err := ReadLarkChannelConfig(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if root.Accounts.App.Secret.Ref == nil {
|
||||
t.Fatal("expected SecretRef, got Plain")
|
||||
}
|
||||
if got := root.Accounts.App.Secret.Ref.Source; got != "exec" {
|
||||
t.Errorf("Secret.Ref.Source = %q, want %q", got, "exec")
|
||||
}
|
||||
if got := root.Accounts.App.Secret.Ref.ID; got != "app-cli_a" {
|
||||
t.Errorf("Secret.Ref.ID = %q, want %q", got, "app-cli_a")
|
||||
}
|
||||
if root.Secrets == nil || root.Secrets.Providers["decrypt"] == nil {
|
||||
t.Errorf("expected secrets.providers[decrypt] to be parsed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLarkChannelConfig_SecretRefInvalidSource(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "config.json")
|
||||
data := `{"accounts":{"app":{"id":"cli_a","secret":{"source":"bogus","id":"x"},"tenant":"feishu"}}}`
|
||||
if err := os.WriteFile(p, []byte(data), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
if _, err := ReadLarkChannelConfig(p); err == nil {
|
||||
t.Fatal("expected error for invalid secret source, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -255,11 +255,18 @@ func doSyncFetch() {
|
||||
|
||||
// --- background refresh ---
|
||||
|
||||
var refreshOnce sync.Once
|
||||
var (
|
||||
refreshOnce sync.Once
|
||||
bgRefreshInFlight sync.WaitGroup // tracks doBackgroundRefresh goroutines for test teardown (resetInit)
|
||||
)
|
||||
|
||||
func triggerBackgroundRefresh() {
|
||||
refreshOnce.Do(func() {
|
||||
go doBackgroundRefresh()
|
||||
bgRefreshInFlight.Add(1)
|
||||
go func() {
|
||||
defer bgRefreshInFlight.Done()
|
||||
doBackgroundRefresh()
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,18 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// waitBackgroundRefresh blocks until any in-flight background refresh started by
|
||||
// triggerBackgroundRefresh has finished. Lives in this _test file so production
|
||||
// binaries cannot call it and accidentally block on test teardown state.
|
||||
func waitBackgroundRefresh() {
|
||||
bgRefreshInFlight.Wait()
|
||||
}
|
||||
|
||||
// resetInit resets the package-level state so each test starts fresh.
|
||||
func resetInit() {
|
||||
// Must wait: a prior test's Init() may have started doBackgroundRefresh which
|
||||
// reads globals this function mutates (see CI race: TestComputeMinimumScopeSet → Tenant).
|
||||
waitBackgroundRefresh()
|
||||
initOnce = sync.Once{}
|
||||
mergedServices = make(map[string]map[string]interface{})
|
||||
mergedProjectList = nil
|
||||
|
||||
@@ -17,6 +17,13 @@ import (
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// execLookPath is the LookPath implementation used by VerifyBinary.
|
||||
// It defaults to the standard library exec.LookPath but is swapped in tests
|
||||
// via lookPathMock to provide controlled binary resolution.
|
||||
//
|
||||
// Tests that mutate execLookPath must not call t.Parallel().
|
||||
var execLookPath = exec.LookPath
|
||||
|
||||
// InstallMethod describes how the CLI was installed.
|
||||
type InstallMethod int
|
||||
|
||||
@@ -186,13 +193,13 @@ func (u *Updater) VerifyBinary(expectedVersion string) error {
|
||||
if u.VerifyOverride != nil {
|
||||
return u.VerifyOverride(expectedVersion)
|
||||
}
|
||||
// Prefer the current executable path (what the user actually launched).
|
||||
// Use Executable() directly without EvalSymlinks — after npm install the
|
||||
// symlink target may have changed, but the path itself is still valid for
|
||||
// execution. Fall back to LookPath only if Executable() fails entirely.
|
||||
exe, err := vfs.Executable()
|
||||
// Prefer PATH resolution so npm global bin symlinks pick up the newly
|
||||
// installed binary (#836). If `lark-cli` is not on PATH (e.g. the user
|
||||
// invoked this process by absolute path), fall back to the running
|
||||
// executable — same as the pre-#836 secondary resolution path.
|
||||
exe, err := execLookPath("lark-cli")
|
||||
if err != nil {
|
||||
exe, err = exec.LookPath("lark-cli")
|
||||
exe, err = vfs.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot locate binary: %w", err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// executableTestFS mocks vfs for tests that still need vfs.Executable.
|
||||
type executableTestFS struct {
|
||||
vfs.OsFs
|
||||
exe string
|
||||
@@ -19,6 +21,28 @@ type executableTestFS struct {
|
||||
|
||||
func (f executableTestFS) Executable() (string, error) { return f.exe, nil }
|
||||
|
||||
// lookPathMock patches execLookPath within VerifyBinary for controlled testing.
|
||||
// Do not use t.Parallel() in tests that install this mock — it mutates a package-level var.
|
||||
type lookPathMock struct {
|
||||
oldLookPath func(string) (string, error)
|
||||
result string
|
||||
resultErr error
|
||||
}
|
||||
|
||||
func (m *lookPathMock) install(bin string) {
|
||||
m.oldLookPath = execLookPath
|
||||
execLookPath = func(name string) (string, error) {
|
||||
if name == bin {
|
||||
return m.result, m.resultErr
|
||||
}
|
||||
return m.oldLookPath(name)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *lookPathMock) restore() {
|
||||
execLookPath = m.oldLookPath
|
||||
}
|
||||
|
||||
func TestResolveExe(t *testing.T) {
|
||||
u := New()
|
||||
p, err := u.resolveExe()
|
||||
@@ -44,46 +68,101 @@ func TestCleanupStaleFiles_NoPanic(t *testing.T) {
|
||||
u.CleanupStaleFiles()
|
||||
}
|
||||
|
||||
func TestVerifyBinaryChecksVersion(t *testing.T) {
|
||||
func TestVerifyBinaryLookPath(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell script")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
exe := filepath.Join(dir, "lark-cli")
|
||||
// Script prints version string matching real CLI format when --version is passed.
|
||||
script := "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo \"lark-cli version 2.0.0\"; exit 0; fi\nexit 12\n"
|
||||
if err := os.WriteFile(exe, []byte(script), 0755); err != nil {
|
||||
bin := filepath.Join(dir, "lark-cli")
|
||||
script := "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo \"lark-cli version 2.1.0\"; exit 0; fi\nexit 12\n"
|
||||
if err := os.WriteFile(bin, []byte(script), 0755); err != nil {
|
||||
t.Fatalf("write test binary: %v", err)
|
||||
}
|
||||
|
||||
// Mock vfs.Executable to return our test script, matching VerifyBinary's
|
||||
// primary lookup path. Also prepend to PATH for the LookPath fallback.
|
||||
origFS := vfs.DefaultFS
|
||||
vfs.DefaultFS = executableTestFS{OsFs: vfs.OsFs{}, exe: exe}
|
||||
t.Cleanup(func() { vfs.DefaultFS = origFS })
|
||||
mock := &lookPathMock{result: bin}
|
||||
mock.install("lark-cli")
|
||||
t.Cleanup(mock.restore)
|
||||
|
||||
origPath := os.Getenv("PATH")
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+origPath)
|
||||
|
||||
// Matching version → success.
|
||||
if err := New().VerifyBinary("2.0.0"); err != nil {
|
||||
t.Fatalf("VerifyBinary(matching) error = %v, want nil", err)
|
||||
if err := New().VerifyBinary("2.1.0"); err != nil {
|
||||
t.Fatalf("VerifyBinary(2.1.0) error = %v, want nil", err)
|
||||
}
|
||||
|
||||
// Mismatched version → error.
|
||||
if err := New().VerifyBinary("3.0.0"); err == nil {
|
||||
t.Fatal("VerifyBinary(mismatched) expected error, got nil")
|
||||
}
|
||||
|
||||
// Substring of actual version must not match (e.g. "0.0" is in "2.0.0").
|
||||
// Regression: version must match exactly (not substring / prefix).
|
||||
if err := New().VerifyBinary("0.0"); err == nil {
|
||||
t.Fatal("VerifyBinary(substring) expected error, got nil")
|
||||
t.Fatal("VerifyBinary(substring-style mismatch) expected error, got nil")
|
||||
}
|
||||
|
||||
// Version that is a prefix of actual must not match (e.g. "2.0.0" in "12.0.0").
|
||||
// Binary reports "2.0.0", asking for "12.0.0" must fail.
|
||||
if err := New().VerifyBinary("12.0.0"); err == nil {
|
||||
t.Fatal("VerifyBinary(prefix-mismatch) expected error, got nil")
|
||||
if err := New().VerifyBinary("12.1.0"); err == nil {
|
||||
t.Fatal("VerifyBinary(prefix-style mismatch) expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBinaryLookPathNotFound(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
mock := &lookPathMock{result: "", resultErr: fmt.Errorf("not found")}
|
||||
mock.install("lark-cli")
|
||||
t.Cleanup(mock.restore)
|
||||
|
||||
oldFS := vfs.DefaultFS
|
||||
t.Cleanup(func() { vfs.DefaultFS = oldFS })
|
||||
// Without this, VerifyBinary would fall back to the real test binary, which
|
||||
// is not a lark-cli --version implementation.
|
||||
vfs.DefaultFS = executableTestFS{exe: filepath.Join(t.TempDir(), "missing-lark-cli")}
|
||||
|
||||
if err := New().VerifyBinary("2.0.0"); err == nil {
|
||||
t.Fatal("VerifyBinary(not-found) expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBinaryFallbackExecutableWhenNotOnPath(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell script")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "lark-cli-abs")
|
||||
script := "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo \"lark-cli version 2.1.0\"; exit 0; fi\nexit 12\n"
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write test binary: %v", err)
|
||||
}
|
||||
|
||||
mock := &lookPathMock{result: "", resultErr: fmt.Errorf("not on PATH")}
|
||||
mock.install("lark-cli")
|
||||
t.Cleanup(mock.restore)
|
||||
|
||||
oldFS := vfs.DefaultFS
|
||||
t.Cleanup(func() { vfs.DefaultFS = oldFS })
|
||||
vfs.DefaultFS = executableTestFS{exe: bin}
|
||||
|
||||
if err := New().VerifyBinary("2.1.0"); err != nil {
|
||||
t.Fatalf("VerifyBinary(fallback executable) error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBinaryEmptyOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell script")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "lark-cli")
|
||||
script := "#!/bin/sh\necho\nexit 0\n"
|
||||
if err := os.WriteFile(bin, []byte(script), 0755); err != nil {
|
||||
t.Fatalf("write test binary: %v", err)
|
||||
}
|
||||
|
||||
mock := &lookPathMock{result: bin}
|
||||
mock.install("lark-cli")
|
||||
t.Cleanup(mock.restore)
|
||||
|
||||
if err := New().VerifyBinary("2.0.0"); err == nil {
|
||||
t.Fatal("VerifyBinary(empty output) expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.31",
|
||||
"version": "1.0.32",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
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
|
||||
@@ -7,6 +7,11 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -55,6 +60,8 @@ var DocMediaInsert = common.Shortcut{
|
||||
{Name: "selection-with-ellipsis", Desc: "plain text (or 'start...end' to disambiguate) matching the target block's content. Media is inserted at the top-level ancestor of the matched block — i.e., when the selection is inside a callout, table cell, or nested list, media lands outside that container, not inside it. Pass 'start...end' (a unique prefix and suffix separated by '...') when the plain text appears in more than one block"},
|
||||
{Name: "before", Type: "bool", Desc: "insert before the matched block instead of after (requires --selection-with-ellipsis)"},
|
||||
{Name: "file-view", Desc: "file block rendering: card (default) | preview | inline; only applies when --type=file. preview renders audio/video as an inline player"},
|
||||
{Name: "width", Type: "int", Desc: "image display width in pixels (only for --type=image); if --height is omitted it is auto-computed from the source image aspect ratio"},
|
||||
{Name: "height", Type: "int", Desc: "image display height in pixels (only for --type=image); if --width is omitted it is auto-computed from the source image aspect ratio"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
filePath := runtime.Str("file")
|
||||
@@ -93,6 +100,24 @@ var DocMediaInsert = common.Shortcut{
|
||||
return output.ErrValidation("--file-view only applies when --type=file")
|
||||
}
|
||||
}
|
||||
widthChanged := runtime.Changed("width")
|
||||
heightChanged := runtime.Changed("height")
|
||||
if (widthChanged || heightChanged) && runtime.Str("type") != "image" {
|
||||
return output.ErrValidation("--width/--height only apply when --type=image")
|
||||
}
|
||||
if widthChanged && runtime.Int("width") <= 0 {
|
||||
return output.ErrValidation("--width must be a positive integer")
|
||||
}
|
||||
if heightChanged && runtime.Int("height") <= 0 {
|
||||
return output.ErrValidation("--height must be a positive integer")
|
||||
}
|
||||
const maxDimension = 10000
|
||||
if widthChanged && runtime.Int("width") > maxDimension {
|
||||
return output.ErrValidation("--width must not exceed %d pixels", maxDimension)
|
||||
}
|
||||
if heightChanged && runtime.Int("height") > maxDimension {
|
||||
return output.ErrValidation("--height must not exceed %d pixels", maxDimension)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -120,7 +145,25 @@ var DocMediaInsert = common.Shortcut{
|
||||
} else {
|
||||
createBlockData["index"] = "<children_len>"
|
||||
}
|
||||
batchUpdateData := buildBatchUpdateData("<new_block_id>", mediaType, "<file_token>", runtime.Str("align"), caption)
|
||||
// Best-effort dimension computation for dry-run.
|
||||
dryWidth := runtime.Int("width")
|
||||
dryHeight := runtime.Int("height")
|
||||
widthChanged := runtime.Changed("width")
|
||||
heightChanged := runtime.Changed("height")
|
||||
|
||||
if (widthChanged || heightChanged) && !(widthChanged && heightChanged) {
|
||||
if filePath == "<clipboard image>" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Note: cannot detect clipboard image dimensions in dry-run; provide both --width and --height for accurate preview\n")
|
||||
} else if nativeW, nativeH, err := detectImageDimensionsFromPath(runtime.FileIO(), filePath); err == nil {
|
||||
dims := computeMissingDimension(dryWidth, dryHeight, nativeW, nativeH)
|
||||
dryWidth = dims.width
|
||||
dryHeight = dims.height
|
||||
} else {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Note: unable to detect image dimensions from %s; provide both --width and --height to avoid failure at execution time\n", filePath)
|
||||
}
|
||||
}
|
||||
|
||||
batchUpdateData := buildBatchUpdateData("<new_block_id>", mediaType, "<file_token>", runtime.Str("align"), caption, dryWidth, dryHeight)
|
||||
|
||||
d := common.NewDryRunAPI()
|
||||
totalSteps := 4
|
||||
@@ -188,6 +231,9 @@ var DocMediaInsert = common.Shortcut{
|
||||
if runtime.Bool("from-clipboard") {
|
||||
d.Set("upload_size_note", "clipboard size unknown; single-part vs multipart decision deferred to runtime")
|
||||
}
|
||||
if runtime.Bool("from-clipboard") && (widthChanged || heightChanged) && !(widthChanged && heightChanged) {
|
||||
d.Set("dimension_note", "clipboard dimensions unknown; aspect-ratio calculation deferred to runtime")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -314,6 +360,42 @@ var DocMediaInsert = common.Shortcut{
|
||||
// interface stays a true nil for the --file path. Passing a typed-nil
|
||||
// *bytes.Reader here would make the downstream `if cfg.Content != nil`
|
||||
// check incorrectly take the clipboard branch and crash on Read.
|
||||
// Resolve display dimensions before upload to fail fast on unreadable images.
|
||||
var finalWidth, finalHeight int
|
||||
if mediaType == "image" {
|
||||
userWidth := runtime.Int("width")
|
||||
userHeight := runtime.Int("height")
|
||||
widthChanged := runtime.Changed("width")
|
||||
heightChanged := runtime.Changed("height")
|
||||
|
||||
if widthChanged && heightChanged {
|
||||
finalWidth = userWidth
|
||||
finalHeight = userHeight
|
||||
} else if widthChanged || heightChanged {
|
||||
var nativeW, nativeH int
|
||||
var dimErr error
|
||||
if clipboardContent != nil {
|
||||
nativeW, nativeH, dimErr = detectImageDimensions(bytes.NewReader(clipboardContent))
|
||||
} else {
|
||||
f, openErr := runtime.FileIO().Open(filePath)
|
||||
if openErr != nil {
|
||||
return withRollbackWarning(output.ErrValidation(
|
||||
"unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName))
|
||||
}
|
||||
nativeW, nativeH, dimErr = detectImageDimensions(f)
|
||||
f.Close()
|
||||
}
|
||||
if dimErr != nil {
|
||||
return withRollbackWarning(output.ErrValidation(
|
||||
"unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName))
|
||||
}
|
||||
dims := computeMissingDimension(userWidth, userHeight, nativeW, nativeH)
|
||||
finalWidth = dims.width
|
||||
finalHeight = dims.height
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Image dimensions: %dx%d (native: %dx%d)\n", finalWidth, finalHeight, nativeW, nativeH)
|
||||
}
|
||||
}
|
||||
|
||||
uploadCfg := UploadDocMediaFileConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
@@ -337,16 +419,23 @@ var DocMediaInsert = common.Shortcut{
|
||||
|
||||
if _, err := runtime.CallAPI("PATCH",
|
||||
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/batch_update", validate.EncodePathSegment(documentID)),
|
||||
nil, buildBatchUpdateData(replaceBlockID, mediaType, fileToken, alignStr, caption)); err != nil {
|
||||
nil, buildBatchUpdateData(replaceBlockID, mediaType, fileToken, alignStr, caption, finalWidth, finalHeight)); err != nil {
|
||||
return withRollbackWarning(err)
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
outData := map[string]interface{}{
|
||||
"document_id": documentID,
|
||||
"block_id": blockId,
|
||||
"file_token": fileToken,
|
||||
"type": mediaType,
|
||||
}, nil)
|
||||
}
|
||||
if finalWidth > 0 {
|
||||
outData["width"] = finalWidth
|
||||
}
|
||||
if finalHeight > 0 {
|
||||
outData["height"] = finalHeight
|
||||
}
|
||||
runtime.Out(outData, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -453,7 +542,51 @@ func resolveDocxDocumentID(runtime *common.RuntimeContext, input string) (string
|
||||
}
|
||||
}
|
||||
|
||||
func buildBatchUpdateData(blockID, mediaType, fileToken, alignStr, caption string) map[string]interface{} {
|
||||
type imageDimensions struct {
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func computeMissingDimension(userWidth, userHeight, nativeWidth, nativeHeight int) imageDimensions {
|
||||
if nativeWidth <= 0 || nativeHeight <= 0 {
|
||||
return imageDimensions{width: userWidth, height: userHeight}
|
||||
}
|
||||
if userWidth > 0 && userHeight == 0 {
|
||||
return imageDimensions{
|
||||
width: userWidth,
|
||||
height: (userWidth*nativeHeight + nativeWidth/2) / nativeWidth,
|
||||
}
|
||||
}
|
||||
if userHeight > 0 && userWidth == 0 {
|
||||
return imageDimensions{
|
||||
width: (userHeight*nativeWidth + nativeHeight/2) / nativeHeight,
|
||||
height: userHeight,
|
||||
}
|
||||
}
|
||||
return imageDimensions{width: userWidth, height: userHeight}
|
||||
}
|
||||
|
||||
func detectImageDimensions(r io.Reader) (width, height int, err error) {
|
||||
cfg, _, err := image.DecodeConfig(r)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return cfg.Width, cfg.Height, nil
|
||||
}
|
||||
|
||||
func detectImageDimensionsFromPath(fio fileio.FileIO, filePath string) (int, int, error) {
|
||||
if _, err := validate.SafeInputPath(filePath); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
f, err := fio.Open(filePath)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
return detectImageDimensions(f)
|
||||
}
|
||||
|
||||
func buildBatchUpdateData(blockID, mediaType, fileToken, alignStr, caption string, width, height int) map[string]interface{} {
|
||||
request := map[string]interface{}{
|
||||
"block_id": blockID,
|
||||
}
|
||||
@@ -465,6 +598,12 @@ func buildBatchUpdateData(blockID, mediaType, fileToken, alignStr, caption strin
|
||||
replaceImage := map[string]interface{}{
|
||||
"token": fileToken,
|
||||
}
|
||||
if width > 0 {
|
||||
replaceImage["width"] = width
|
||||
}
|
||||
if height > 0 {
|
||||
replaceImage["height"] = height
|
||||
}
|
||||
if alignVal, ok := alignMap[alignStr]; ok {
|
||||
replaceImage["align"] = alignVal
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package doc
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -176,7 +177,7 @@ func TestBuildDeleteBlockDataUsesHalfOpenInterval(t *testing.T) {
|
||||
func TestBuildBatchUpdateDataForImage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildBatchUpdateData("blk_1", "image", "file_tok", "center", "caption text")
|
||||
got := buildBatchUpdateData("blk_1", "image", "file_tok", "center", "caption text", 0, 0)
|
||||
want := map[string]interface{}{
|
||||
"requests": []interface{}{
|
||||
map[string]interface{}{
|
||||
@@ -199,7 +200,7 @@ func TestBuildBatchUpdateDataForImage(t *testing.T) {
|
||||
func TestBuildBatchUpdateDataForFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildBatchUpdateData("blk_2", "file", "file_tok", "", "")
|
||||
got := buildBatchUpdateData("blk_2", "file", "file_tok", "", "", 0, 0)
|
||||
want := map[string]interface{}{
|
||||
"requests": []interface{}{
|
||||
map[string]interface{}{
|
||||
@@ -215,6 +216,48 @@ func TestBuildBatchUpdateDataForFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBatchUpdateDataForImageWithWidthHeight(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildBatchUpdateData("blk_1", "image", "file_tok", "center", "caption text", 800, 447)
|
||||
want := map[string]interface{}{
|
||||
"requests": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_id": "blk_1",
|
||||
"replace_image": map[string]interface{}{
|
||||
"token": "file_tok",
|
||||
"width": 800,
|
||||
"height": 447,
|
||||
"align": 2,
|
||||
"caption": map[string]interface{}{"content": "caption text"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("buildBatchUpdateData(image, 800, 447) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBatchUpdateDataForFileIgnoresWidthHeight(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildBatchUpdateData("blk_2", "file", "file_tok", "", "", 800, 600)
|
||||
want := map[string]interface{}{
|
||||
"requests": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_id": "blk_2",
|
||||
"replace_file": map[string]interface{}{
|
||||
"token": "file_tok",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("buildBatchUpdateData(file, 800, 600) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAppendTargetUsesRootChildrenCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -669,10 +712,202 @@ func newMediaInsertValidateRuntime(t *testing.T, doc, mediaType, fileView string
|
||||
return common.TestNewRuntimeContext(cmd, nil)
|
||||
}
|
||||
|
||||
// Validate is the real user-facing contract for --file-view: unknown
|
||||
// values must be rejected, and passing the flag alongside --type!=file
|
||||
// must also be rejected. buildCreateBlockData tests alone cannot catch
|
||||
// regressions here, so lock the guard logic down explicitly.
|
||||
func newMediaInsertValidateRuntimeWithSize(t *testing.T, doc, mediaType string, width, height int, setWidth, setHeight bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "docs +media-insert"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().Bool("from-clipboard", false, "")
|
||||
cmd.Flags().String("doc", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("file-view", "", "")
|
||||
cmd.Flags().Int("width", 0, "")
|
||||
cmd.Flags().Int("height", 0, "")
|
||||
cmd.Flags().String("selection-with-ellipsis", "", "")
|
||||
cmd.Flags().Bool("before", false, "")
|
||||
if err := cmd.Flags().Set("file", "dummy.bin"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("doc", doc); err != nil {
|
||||
t.Fatalf("set --doc: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", mediaType); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
if setWidth {
|
||||
if err := cmd.Flags().Set("width", fmt.Sprintf("%d", width)); err != nil {
|
||||
t.Fatalf("set --width: %v", err)
|
||||
}
|
||||
}
|
||||
if setHeight {
|
||||
if err := cmd.Flags().Set("height", fmt.Sprintf("%d", height)); err != nil {
|
||||
t.Fatalf("set --height: %v", err)
|
||||
}
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, nil)
|
||||
}
|
||||
|
||||
func TestDocMediaInsertValidateWidthHeightOnlyForImage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mediaType string
|
||||
width int
|
||||
height int
|
||||
setWidth bool
|
||||
setHeight bool
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "width with file type is rejected",
|
||||
mediaType: "file",
|
||||
width: 800,
|
||||
setWidth: true,
|
||||
wantErr: "--width/--height only apply when --type=image",
|
||||
},
|
||||
{
|
||||
name: "height with file type is rejected",
|
||||
mediaType: "file",
|
||||
height: 600,
|
||||
setHeight: true,
|
||||
wantErr: "--width/--height only apply when --type=image",
|
||||
},
|
||||
{
|
||||
name: "explicit zero width is rejected",
|
||||
mediaType: "image",
|
||||
width: 0,
|
||||
setWidth: true,
|
||||
wantErr: "--width must be a positive integer",
|
||||
},
|
||||
{
|
||||
name: "negative width is rejected",
|
||||
mediaType: "image",
|
||||
width: -1,
|
||||
setWidth: true,
|
||||
wantErr: "--width must be a positive integer",
|
||||
},
|
||||
{
|
||||
name: "negative height is rejected",
|
||||
mediaType: "image",
|
||||
height: -5,
|
||||
setHeight: true,
|
||||
wantErr: "--height must be a positive integer",
|
||||
},
|
||||
{
|
||||
name: "valid width with image type is accepted",
|
||||
mediaType: "image",
|
||||
width: 800,
|
||||
setWidth: true,
|
||||
},
|
||||
{
|
||||
name: "valid width and height with image type is accepted",
|
||||
mediaType: "image",
|
||||
width: 800,
|
||||
height: 600,
|
||||
setWidth: true,
|
||||
setHeight: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, ttTemp := range tests {
|
||||
tt := ttTemp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newMediaInsertValidateRuntimeWithSize(t, "doxcnValidateSize", tt.mediaType, tt.width, tt.height, tt.setWidth, tt.setHeight)
|
||||
err := DocMediaInsert.Validate(context.Background(), rt)
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("Validate() error = nil, want error containing %q", tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("Validate() error = %q, want substring %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocMediaInsertValidateNoWidthHeightIsValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newMediaInsertValidateRuntimeWithSize(t, "doxcnNoSize", "image", 0, 0, false, false)
|
||||
err := DocMediaInsert.Validate(context.Background(), rt)
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() unexpected error when neither --width nor --height passed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoAspectRatioFromWidth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Native image: 1200x800 (3:2 ratio)
|
||||
// User provides width=600 → expected height = 600 * 800 / 1200 = 400
|
||||
got := computeMissingDimension(600, 0, 1200, 800)
|
||||
wantWidth, wantHeight := 600, 400
|
||||
if got.width != wantWidth || got.height != wantHeight {
|
||||
t.Fatalf("computeMissingDimension(600, 0, 1200, 800) = (%d, %d), want (%d, %d)", got.width, got.height, wantWidth, wantHeight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoAspectRatioFromHeight(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Native image: 1200x800 (3:2 ratio)
|
||||
// User provides height=400 → expected width = 400 * 1200 / 800 = 600
|
||||
got := computeMissingDimension(0, 400, 1200, 800)
|
||||
wantWidth, wantHeight := 600, 400
|
||||
if got.width != wantWidth || got.height != wantHeight {
|
||||
t.Fatalf("computeMissingDimension(0, 400, 1200, 800) = (%d, %d), want (%d, %d)", got.width, got.height, wantWidth, wantHeight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeMissingDimensionBothProvided(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := computeMissingDimension(800, 600, 1200, 900)
|
||||
if got.width != 800 || got.height != 600 {
|
||||
t.Fatalf("computeMissingDimension(800, 600, 1200, 900) = (%d, %d), want (800, 600)", got.width, got.height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeMissingDimensionNeitherProvided(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := computeMissingDimension(0, 0, 1200, 900)
|
||||
if got.width != 0 || got.height != 0 {
|
||||
t.Fatalf("computeMissingDimension(0, 0, 1200, 900) = (%d, %d), want (0, 0)", got.width, got.height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeMissingDimensionZeroNativeWidth(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := computeMissingDimension(600, 0, 0, 800)
|
||||
if got.width != 600 || got.height != 0 {
|
||||
t.Fatalf("computeMissingDimension(600, 0, 0, 800) = (%d, %d), want (600, 0)", got.width, got.height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeMissingDimensionZeroNativeHeight(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := computeMissingDimension(0, 400, 1200, 0)
|
||||
if got.width != 0 || got.height != 400 {
|
||||
t.Fatalf("computeMissingDimension(0, 400, 1200, 0) = (%d, %d), want (0, 400)", got.width, got.height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeMissingDimensionRounding(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := computeMissingDimension(999, 0, 1000, 333)
|
||||
want := (999*333 + 500) / 1000
|
||||
if got.height != want {
|
||||
t.Fatalf("computeMissingDimension(999, 0, 1000, 333).height = %d, want %d (rounded)", got.height, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocMediaInsertValidateFileView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -275,7 +275,14 @@ var DrivePush = common.Shortcut{
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
token, version, upErr := drivePushUploadFile(ctx, runtime, localFile, entry.FileToken, folderToken)
|
||||
parentToken, parentErr := drivePushEnsureParentToken(ctx, runtime, folderToken, rel, folderCache)
|
||||
if parentErr != nil {
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "failed", SizeBytes: localFile.Size, Error: parentErr.Error()})
|
||||
failed++
|
||||
uploadFailed = true
|
||||
continue
|
||||
}
|
||||
token, version, upErr := drivePushUploadFile(ctx, runtime, localFile, entry.FileToken, parentToken)
|
||||
if upErr != nil {
|
||||
// Token contract on overwrite failure: an in-place
|
||||
// overwrite preserves the file's token, so the
|
||||
@@ -580,6 +587,10 @@ func drivePushEnsureFolder(ctx context.Context, runtime *common.RuntimeContext,
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func drivePushEnsureParentToken(ctx context.Context, runtime *common.RuntimeContext, rootFolderToken, relPath string, folderCache map[string]string) (string, error) {
|
||||
return drivePushEnsureFolder(ctx, runtime, rootFolderToken, drivePushParentRel(relPath), folderCache)
|
||||
}
|
||||
|
||||
// drivePushUploadFile uploads (or overwrites) a single local file. When
|
||||
// existingToken is non-empty, the request adds the file_token form field to
|
||||
// trigger overwrite-with-version semantics on the backend; the response is
|
||||
|
||||
@@ -1296,6 +1296,130 @@ func TestDrivePushReusesExistingRemoteFolder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePushOverwriteNestedFileUsesParentFolderToken verifies that
|
||||
// overwriting an existing nested remote file keeps parent_node aligned with
|
||||
// the file's actual parent folder instead of the root folder token.
|
||||
func TestDrivePushOverwriteNestedFileUsesParentFolderToken(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "sub", "keep.txt"), []byte("local"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "fld_existing_sub", "name": "sub", "type": "folder"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=fld_existing_sub",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_keep_nested", "name": "keep.txt", "type": "file"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "tok_keep_nested",
|
||||
"version": "v2",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "overwrite",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
body := decodeDriveMultipartBody(t, uploadStub)
|
||||
if got := body.Fields["file_token"]; got != "tok_keep_nested" {
|
||||
t.Fatalf("upload_all file_token = %q, want tok_keep_nested", got)
|
||||
}
|
||||
if got := body.Fields["parent_node"]; got != "fld_existing_sub" {
|
||||
t.Fatalf("upload_all parent_node = %q, want fld_existing_sub", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushOverwriteNestedFileReportsParentEnsureFailure(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "sub", "keep.txt"), []byte("local"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_keep_nested", "name": "sub/keep.txt", "type": "file"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/create_folder",
|
||||
Body: map[string]interface{}{
|
||||
"code": 9999,
|
||||
"msg": "create parent failed",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "overwrite",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected parent ensure failure\nstdout: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"action": "failed"`) || !strings.Contains(stdout.String(), "create parent failed") {
|
||||
t.Fatalf("expected failed item with create_folder error, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePushMirrorsEmptyDirectories confirms the gap codex review
|
||||
// flagged: a local directory with no files inside must still surface on
|
||||
// Drive as a created sub-folder, not be silently dropped because the
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
name: lark-doc
|
||||
version: 2.0.0
|
||||
description: "飞书云文档 / Docx / 知识库 Wiki 文档(v2):创建、打开、读取、获取、查看、总结、整理、改写、翻译、审阅和编辑飞书文档内容。当用户给出飞书文档 URL/token,或说查看/读取/打开某个文档、提取文档内容、总结文档、生成/创建文档、追加/替换/删除/移动内容、调整排版、插入或下载文档图片/附件/素材/画板缩略图时使用。文档内容中出现嵌入电子表格、多维表格、画板、引用或同步块时,也先用本 skill 读取和提取 token,再切到对应 skill 下钻。使用本 skill 时,docs +create、docs +fetch、docs +update 必须携带 --api-version v2;默认使用 DocxXML,也支持 Markdown。"
|
||||
description: "飞书云文档 / Docx / 知识库 Wiki 文档(v2):创建、打开、读取、获取、查看、总结、整理、改写、翻译、审阅和编辑飞书文档内容。当用户给出飞书文档 URL/token,或说查看/读取/打开某个文档、提取文档内容、总结文档、生成/创建文档、追加/替换/删除/移动内容、调整排版、插入或下载文档图片/附件/素材/画板缩略图时使用。文档内容中出现嵌入电子表格、多维表格、需要将重要信息可视化为画板(含 SVG 画板)、引用或同步块时,也先用本 skill 读取和提取 token,再切到对应 skill 下钻。使用本 skill 时,docs +create、docs +fetch、docs +update 必须携带 --api-version v2;默认使用 DocxXML,也支持 Markdown。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -34,6 +33,8 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
|
||||
|
||||
## 快速决策
|
||||
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
|
||||
- 写文档时,重要信息(核心流程、架构、对比、风险、路线图、关键指标、因果关系)优先规划为画板,不要只用文字或表格承载
|
||||
- 新增画板必须隔离到 SubAgent:简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读 `lark-whiteboard`;复杂图才由主 Agent 先建 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 读取 `lark-whiteboard` 写入
|
||||
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
|
||||
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
|
||||
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`)
|
||||
|
||||
@@ -67,6 +67,12 @@ lark-cli docs +media-insert --doc doxcnXXX --file ./spec.pdf --type file
|
||||
|
||||
# 图片对齐与描述(caption)
|
||||
lark-cli docs +media-insert --doc doxcnXXX --from-clipboard --align center --caption "架构图"
|
||||
|
||||
# Insert image with explicit display width (height auto-computed from aspect ratio)
|
||||
lark-cli docs +media-insert --doc doxcnXXX --file ./banner.png --width 800 --align center
|
||||
|
||||
# Insert image with explicit width and height
|
||||
lark-cli docs +media-insert --doc doxcnXXX --from-clipboard --width 800 --height 447 --caption "architecture diagram"
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -79,6 +85,8 @@ lark-cli docs +media-insert --doc doxcnXXX --from-clipboard --align center --cap
|
||||
| `--type <type>` | 否 | `image`(默认)或 `file`。`--from-clipboard` 目前只产出 image。 |
|
||||
| `--align <align>` | 否 | 仅图片:`left` / `center`(默认)/ `right` |
|
||||
| `--caption <text>` | 否 | 仅图片:图片描述 |
|
||||
| `--width <px>` | 否 | Image display width in pixels (only for `--type=image`). If `--height` is omitted, it is auto-computed from the source image aspect ratio. Supported auto-detection formats: PNG, JPEG, GIF; other formats (WebP, BMP, etc.) require both `--width` and `--height`. |
|
||||
| `--height <px>` | 否 | Image display height in pixels (only for `--type=image`). If `--width` is omitted, it is auto-computed from the source image aspect ratio. Supported auto-detection formats: PNG, JPEG, GIF; other formats (WebP, BMP, etc.) require both `--width` and `--height`. |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 如果上一步是 [`lark-doc-create`](lark-doc-create.md),并且它在知识库/知识空间场景下返回的是 `/wiki/...` 形式的 `doc_url`,后续调用 `docs +media-insert` 时应优先传 `doc_id`,不要直接传这个 `doc_url`。
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -221,7 +221,7 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
|
||||
|
||||
## 画板处理
|
||||
|
||||
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch --api-version v2` 取到 `<whiteboard token="...">`,再切到 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 用 `whiteboard +update` 写入。
|
||||
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch --api-version v2` 取到 `<whiteboard token="...">`,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 读取 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 并写入。
|
||||
|
||||
画板的语法选型与插入示例见 [`lark-doc-style.md`](style/lark-doc-style.md) 的「画板语法与插入」章节。
|
||||
|
||||
|
||||
@@ -6,46 +6,79 @@
|
||||
|
||||
| Skill | 核心职责 | 约束 |
|
||||
|------|------|------|
|
||||
| `lark-doc` | 文档内容读取/更新、插入空白画板占位、获取 board_token | 不能直接编辑画板内容;`docs +update` 的画板能力仅限插入空白占位 |
|
||||
| `lark-whiteboard` | 查询/导出画板(+query);图表内容生成(Mermaid/DSL/SVG 路由、场景选型、渲染验证);写入画板(+update) | 图表内容生成由此 skill 完整执行,不依赖外部调度 |
|
||||
| `lark-doc` | 识别画板机会、判断简单/复杂、调度 SubAgent、插入简单 SVG 画板或复杂空白画板 | 主 Agent 不直接创作画板内容;简单图不需要读取 `lark-whiteboard` |
|
||||
| `lark-whiteboard` | 查询/导出已有画板;复杂图表生成(Mermaid/DSL/SVG 路由、场景选型、渲染验证);写入已有/空白画板 | 仅复杂图或已有画板更新时由独立 SubAgent 读取 |
|
||||
|
||||
## 画板优先规则
|
||||
|
||||
写文档时,重要信息优先画板化。遇到核心流程、系统架构、方案对比、风险链路、里程碑、指标趋势、因果归因、组织关系、能力分层等内容,不要只用段落或表格承载;除非内容只是一次性补充说明,否则应规划为画板。
|
||||
|
||||
同一篇文档可以有多个画板。优先多个聚焦画板,而不是把所有信息塞进一张大图。
|
||||
|
||||
## 文档与画板协同流程
|
||||
|
||||
### 步骤 1:判断场景
|
||||
### 步骤 1:识别画板机会
|
||||
|
||||
| 场景 | 入口 |
|
||||
|------|------|
|
||||
| 文档中需要插入新画板 | 继续步骤 2 |
|
||||
| 已有画板需要更新内容 | 先 `docs +fetch --api-version v2` 获取 `board_token`,跳至步骤 3 |
|
||||
| 文档中需要插入简单新画板 | 走步骤 2A |
|
||||
| 文档中需要插入复杂新画板 | 走步骤 2B |
|
||||
| 已有画板需要更新内容 | 先 `docs +fetch --api-version v2` 获取 `board_token`,跳至步骤 3B |
|
||||
| 只查看 / 下载已有画板 | 切换至 `lark-whiteboard`,不走本流程 |
|
||||
|
||||
### 步骤 2:在文档中创建空白画板
|
||||
简单图判定:节点少、静态、布局可控、适合一个完整自包含 SVG 表达,例如小型流程、2-3 方对比、小型状态机、简单时间线或小型示意图。
|
||||
|
||||
- 创建场景:`docs +create`;编辑场景:`docs +update`
|
||||
- markdown 中使用 `<whiteboard type="blank"></whiteboard>`(不要转义)
|
||||
- 多个画板时,在相应的地方插入各自的 whiteboard 标签
|
||||
- 从响应的 `data.board_tokens` 中读取 token 列表
|
||||
复杂图判定:节点多、跨泳道/跨系统、需要自动布局或精细排版、包含数据图表、组织架构、复杂架构、复杂依赖、已有画板更新,或需要 `lark-whiteboard` 的渲染验证。
|
||||
|
||||
### 步骤 3:生成并写入画板内容
|
||||
### 步骤 2A:简单图 — SubAgent 直接插入 SVG 画板
|
||||
|
||||
读取 [`../../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md),跳至"渲染 & 写入画板"章节,按其完整流程为每个 board_token 生成并写入图表内容。
|
||||
主 Agent 启动 SubAgent,让它用 `docs +create --api-version v2` / `docs +update --api-version v2` 插入:
|
||||
|
||||
多个画板时依次处理,每个画板完成后再处理下一个。
|
||||
```xml
|
||||
<whiteboard type="svg"><svg ...>...</svg></whiteboard>
|
||||
```
|
||||
|
||||
简单图 SubAgent 的最小上下文:
|
||||
- doc token、插入位置(标题 / block_id / command)
|
||||
- 图表目标、受众、源段落或数据
|
||||
- 要求读取 `lark-doc-xml.md`;不需要读取 `lark-whiteboard`
|
||||
- SVG 必须完整自包含:包含 `<svg>` 根节点和 `viewBox`,不引用外部图片、脚本、远程资源
|
||||
|
||||
### 步骤 2B:复杂图 — 先创建空白画板
|
||||
|
||||
- 主 Agent 使用 `docs +create --api-version v2` / `docs +update --api-version v2` 插入 `<whiteboard type="blank"></whiteboard>`。
|
||||
- 从 v2 响应的 `data.document.new_blocks[]` 中读取 `block_type == "whiteboard"` 的 `block_token` 作为 board_token。
|
||||
|
||||
### 步骤 3B:复杂图或已有画板 — 启动 lark-whiteboard SubAgent
|
||||
|
||||
复杂图和已有画板更新必须启动 SubAgent。主 Agent 只传最小上下文,不直接执行 `lark-whiteboard` 的渲染和写入流程。
|
||||
|
||||
复杂图 SubAgent 的最小上下文:
|
||||
- board_token
|
||||
- 图表目标、推荐画板类型、受众
|
||||
- 与图表直接相关的源段落或数据
|
||||
- 要求读取 [`../../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md),按其完整流程写入该 board_token
|
||||
|
||||
多个画板互不依赖时,可并行启动多个 SubAgent;每个 SubAgent 只负责一个画板或一个 SVG 插入点,不要互相复用上下文。
|
||||
|
||||
### 步骤 4:完成校验
|
||||
|
||||
- 确认每个 token 对应的画板都已填充真实内容
|
||||
- 不保留空白占位画板;只有空白画板而无内容视为任务未完成
|
||||
- 简单 SVG:确认插入的是 `<whiteboard type="svg">`,且内容是完整 `<svg ...>...</svg>`
|
||||
- 复杂画板:确认每个 token 对应的画板都已填充真实内容
|
||||
- 不保留空白占位画板;复杂路径只有空白画板而无内容视为任务未完成
|
||||
|
||||
---
|
||||
|
||||
## 语义与画板类型映射
|
||||
|
||||
下表用于帮助主 Agent 判断简单/复杂路径,并给 SubAgent 指定推荐画板类型。
|
||||
|
||||
| 语义 | 画板类型 |
|
||||
|------|------|
|
||||
| 架构/分层/技术方案/模块依赖/调用关系 | 架构图 |
|
||||
| 流程/审批/部署/业务流转/状态机 | 流程图 |
|
||||
| 跨角色流程/跨系统交互/端到端链路 | 泳道图 |
|
||||
| 小型流程/状态机/简单时间线/小型对比/小型示意图 | SVG 画板(简单路径) |
|
||||
| 架构/分层/技术方案/模块依赖/调用关系 | 架构图(复杂路径) |
|
||||
| 流程/审批/部署/业务流转/状态机 | 流程图(按复杂度分流) |
|
||||
| 跨角色流程/跨系统交互/端到端链路 | 泳道图(复杂路径) |
|
||||
| 组织/层级/汇报关系 | 组织架构图 |
|
||||
| 时间线/里程碑/版本规划 | 里程碑图 |
|
||||
| 因果/复盘/根因分析 | 鱼骨图 |
|
||||
@@ -56,6 +89,7 @@
|
||||
| 转化漏斗/销售漏斗 | 漏斗图 |
|
||||
| 分类梳理/知识体系/思维导图/时序图/类图 | Mermaid |
|
||||
| 数据分布/占比/饼图 | Mermaid |
|
||||
| 简单自定义图形/小型 SVG 示意图 | SVG 画板(简单路径) |
|
||||
| 柱状图/条形图/数据对比 | 柱状图 |
|
||||
| 折线图/趋势图/时序数据 | 折线图 |
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
|-|-|-|
|
||||
| `<callout>` | 高亮框,子块仅支持文本、标题、列表、待办、引用 | `emoji`(默认 bulb), `background-color`, `border-color`, `text-color` |
|
||||
| `<grid>` + `<column>` | 分栏布局,各列 width-ratio 之和为 1 | `width-ratio` |
|
||||
| `<whiteboard>` | 嵌入画板 | `type`: `mermaid` \| `plantuml` \| `blank` |
|
||||
| `<whiteboard>` | 嵌入画板 | `type`: `blank` \| `mermaid` \| `plantuml` \| `svg` |
|
||||
| `<pre>` | (代码块,内含 `code`)| `lang`, `caption` |
|
||||
| `<figure>` | 视图容器 | `view-type` |
|
||||
| `<bookmark>` | 书签链接 | `<bookmark name="标题" href="https://..."></bookmark>`,必传 name 和 href |
|
||||
@@ -41,7 +41,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
文档中可嵌入外部资源块(属于容器标签的特殊形式),需要额外语法创建:
|
||||
|
||||
- `<img>` — `<img href="https://..."/>` 上传网络图片
|
||||
- `<whiteboard>` — `<whiteboard type="blank"></whiteboard>` 空白;`<whiteboard type="mermaid|plantuml">内容</whiteboard>` 带内容;
|
||||
- `<whiteboard>` — 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整自包含 SVG</whiteboard>`;复杂图使用 `<whiteboard type="blank"></whiteboard>` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
|
||||
- `<sheet>` — `<sheet type="blank"></sheet>` 空白;`<sheet sheet-id="SID" token="TOKEN"></sheet>` 复制已有
|
||||
- `<task>` — `<task task-id="GUID"></task>`,必传 task-id(任务 guid)
|
||||
- `<chat_card>` — `<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id
|
||||
@@ -166,4 +166,4 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
|
||||
<task task-id="TASK_GUID"></task>
|
||||
<chat_card chat-id="CHAT_ID"></chat_card>
|
||||
```
|
||||
```
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
### 第一波 — 规划与骨架(串行)
|
||||
|
||||
1. 分析用户需求:受众、目的、范围
|
||||
2. 设计大纲——每个 h1/h2 章节至少规划 1 个非文本 block
|
||||
2. 设计大纲——每个 h1/h2 章节至少规划 1 个非文本 block;承载重要信息的章节优先规划画板
|
||||
3. `docs +create --api-version v2` **只建骨架**:标题 + 开头 `<callout>` + 各级标题 + 每节一句占位摘要
|
||||
- ⚠️ **不要**一次性把完整章节内容塞进 `--content`。超长 `--content` 容易触发字符/参数限制。
|
||||
- 完整内容留到第二波,由各 Agent 用 `docs +update --command append` 或 `block_insert_after` 分段写入。
|
||||
@@ -35,11 +35,13 @@
|
||||
|
||||
5. `docs +fetch --api-version v2 --detail with-ids` 获取文档,审查整体效果
|
||||
6. 评估样式达标(富 block 密度、元素多样性、连续 `<p>` 数量)
|
||||
7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。记录需要插图的章节及推荐的画板类型
|
||||
7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。重要信息优先画板化,记录需要插图的章节、推荐画板类型、简单/复杂路径和用于画图的源内容
|
||||
|
||||
### 第四波 — 润色与图表(并行 Agent)
|
||||
8. Spawn Agent 定向改进:(结合 `lark-doc-style.md` 润色)
|
||||
- **优先处理第三波识别出的画板需求**:简单图直接 `<whiteboard type="mermaid|plantuml">`,复杂图 spawn Agent 使用 **lark-whiteboard** skill
|
||||
### 第四波 — 画板与润色(并行 Agent)
|
||||
8. **优先处理第三波识别出的画板需求**:
|
||||
- 简单图:启动 SVG SubAgent,直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`;不读取 **lark-whiteboard**
|
||||
- 复杂图:主 Agent 先插入 `<whiteboard type="blank"></whiteboard>` 并提取 `block_token`,再为每个 `block_token` 启动 SubAgent 使用 **lark-whiteboard** skill 写入画板
|
||||
9. Spawn 内容改写 Agent 定向润色:
|
||||
- 文字密集章节转为 `<table>`/`<grid>`/`<callout>`
|
||||
- 主要章节间补充 `<hr/>`
|
||||
- 本地图片使用 `docs +media-insert` 插入
|
||||
@@ -47,4 +49,8 @@
|
||||
|
||||
## Agent 子任务要求
|
||||
|
||||
Spawn Agent 时必须提供:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`。
|
||||
内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`。
|
||||
|
||||
SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`。
|
||||
|
||||
复杂画板 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
|
||||
|
||||
@@ -8,26 +8,29 @@
|
||||
2. **Front-load 结论**:文档以 `<callout>` 开头概括核心结论;每章节首段点明要旨
|
||||
3. **视觉节奏**:连续纯文本不超过 3 段;不同主题章节间用 `<hr/>` 分隔
|
||||
4. **最少惊讶**:同类信息使用同类元素,全篇风格统一
|
||||
5. **重要信息画板化**:核心流程、架构、对比、风险、路线图、指标趋势等重要信息优先使用画板表达
|
||||
|
||||
## 二、元素选择指南
|
||||
|
||||
涉及图表需求时,简单图用 `<whiteboard type="mermaid/plantuml">` 内嵌,复杂图使用 **lark-whiteboard** skill。
|
||||
涉及图表需求时,先判定简单/复杂:简单图启动 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读取 **lark-whiteboard**;复杂图才使用空白画板 + **lark-whiteboard** SubAgent。
|
||||
|
||||
| 场景 | 推荐方案 |
|
||||
|-|-|
|
||||
| 核心结论 / 摘要 / 注意事项 | `<callout>` + emoji + 背景色 |
|
||||
| 方案对比 / 优劣势 / Before vs After | `<grid>` 2 列分栏 |
|
||||
| 3+ 属性的结构化数据 / 指标表 | `<table>` + 表头背景色 |
|
||||
| 任务清单 / 检查项 | `<checkbox>` |
|
||||
| 代码片段 | `<pre lang="x" caption="说明">` |
|
||||
| 引用 / 公式 | `<blockquote>` / `<latex>` |
|
||||
| 操作入口 / 跳转链接 | `<button>` / `<a type="url-preview">` |
|
||||
| 简单流程图 / 时序图 / 状态机 / 甘特图 | `<whiteboard type="mermaid/plantuml">` |
|
||||
| 复杂架构图 / 数据图 / 思维导图 / 组织架构 | **lark-whiteboard** skill |
|
||||
| 场景 | 推荐方案 |
|
||||
|-|---------------------------------------------------------------|
|
||||
| 核心结论 / 摘要 / 注意事项 | `<callout>` + emoji + 背景色 |
|
||||
| 重要方案对比 / 优劣势 / Before vs After | `<grid>` 2 列分栏;简单 SVG SubAgent;复杂矩阵用 lark-whiteboard SubAgent |
|
||||
| 简短低风险对比 | `<grid>` 2 列分栏 |
|
||||
| 3+ 属性的结构化数据 / 指标表 | `<table>` + 表头背景色 |
|
||||
| 任务清单 / 检查项 | `<checkbox>` |
|
||||
| 代码片段 | `<pre lang="x" caption="说明">` |
|
||||
| 引用 / 公式 | `<blockquote>` / `<latex>` |
|
||||
| 操作入口 / 跳转链接 | `<button>` / `<a type="url-preview">` |
|
||||
| 简单流程图 / 小型状态机 / 小型时间线 | 简单 SVG SubAgent |
|
||||
| 简单自定义图形 / 小型 SVG 示意图 | 简单 SVG SubAgent |
|
||||
| 复杂架构图 / 数据图 / 思维导图 / 组织架构 | 空白画板 + lark-whiteboard SubAgent |
|
||||
|
||||
### 画板意图识别
|
||||
|
||||
撰写或审查每个段落/章节时,**必须判断该内容是否适合用图表达**。满足以下任一特征时,应使用画板而非纯文本:
|
||||
撰写或审查每个段落/章节时,**必须判断该内容是否适合用图表达**。满足以下任一特征时,应使用画板而非纯文本;如果该内容承载章节核心结论、关键决策或主要论据,即使结构较简单也优先画板化:
|
||||
|
||||
| 内容特征 | 信号词 / 模式 | 推荐画板类型 |
|
||||
|-|-|-|
|
||||
@@ -45,21 +48,27 @@
|
||||
| 占比分布 | "占比"、"份额"、"分布"、百分比加总 ≈100% | 饼图 / 树状图 |
|
||||
|
||||
**判断规则:**
|
||||
- 简单图(节点 ≤ 10、无需精细排版)→ `<whiteboard type="mermaid/plantuml">` 内嵌
|
||||
- 复杂图(节点 > 10、需自定义布局/样式、数据图表)→ spawn Agent 使用 **lark-whiteboard** skill
|
||||
- 重要信息能图示就图示;不要为了省步骤把关键流程、架构、对比、风险链路写成纯文本
|
||||
- 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读取 **lark-whiteboard**
|
||||
- 复杂图或已有画板更新才先插入 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 使用 **lark-whiteboard** skill 写入内容
|
||||
- 低重要度、局部辅助信息才用 `<table>` / `<grid>` / `<callout>` 承载
|
||||
|
||||
### 画板语法与插入
|
||||
|
||||
> **提醒:** `docs +update` 不能编辑已有画板内容;下面的语法都是**新增**画板块。修改已有画板需切到 [`lark-whiteboard`](../../../lark-whiteboard/SKILL.md)。
|
||||
> **提醒:** `docs +update` 不能编辑已有画板内容;下面的语法都是**新增**画板块。修改已有画板需启动 SubAgent 读取 [`lark-whiteboard`](../../../lark-whiteboard/SKILL.md)。
|
||||
|
||||
#### 内嵌 Mermaid / PlantUML(首选)
|
||||
简单图直接用 `<whiteboard type="mermaid|plantuml">语法</whiteboard>`,作为 block 嵌入文档。
|
||||
#### 简单 SVG 画板(SubAgent 插入)
|
||||
|
||||
#### DSL 画板(Mermaid / PlantUML 不够用时)
|
||||
需要架构图、对比图、组织架构等复杂结构时:
|
||||
1. 用 `<whiteboard type="blank"></whiteboard>` 通过 `docs +create` / `docs +update` 插入空白画板
|
||||
2. 从响应 `data.document.new_blocks` 中提取画板 `block_token`
|
||||
3. 切到 [`lark-whiteboard`](../../../lark-whiteboard/SKILL.md) skill 设计并上传 DSL
|
||||
1. 主 Agent 启动 SubAgent,传入 doc token、插入位置、图表目标和源内容
|
||||
2. SubAgent 使用 `<whiteboard type="svg">完整自包含 SVG</whiteboard>` 通过 `docs +create --api-version v2` / `docs +update --api-version v2` 插入
|
||||
3. SVG 必须包含 `<svg>` 根节点和 `viewBox`,不要引用外部图片、脚本或远程资源
|
||||
|
||||
#### 复杂画板(空白画板 + lark-whiteboard SubAgent)
|
||||
|
||||
1. 用 `<whiteboard type="blank"></whiteboard>` 通过 `docs +create --api-version v2` / `docs +update --api-version v2` 插入空白画板
|
||||
2. 从 v2 响应 `data.document.new_blocks` 中提取画板 `block_token`
|
||||
3. 必须启动 SubAgent,把 `block_token`、图表目标、推荐画板类型和源内容交给它
|
||||
4. SubAgent 读取 [`lark-whiteboard`](../../../lark-whiteboard/SKILL.md) skill 并写入该画板;主 Agent 不直接调用画板渲染流程
|
||||
|
||||
更完整的协同流程见 [`lark-doc-whiteboard.md`](../lark-doc-whiteboard.md)。
|
||||
|
||||
|
||||
@@ -25,24 +25,30 @@
|
||||
- 用户明确要改整篇 → `docs +fetch --api-version v2 --detail with-ids`
|
||||
- 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) "意图引导:选择正确的 --scope"
|
||||
2. 系统性评估:结构清晰度、富 block 密度(≥40%)、元素多样性(≥3种)、连续 `<p>` 是否超过 3 段、是否有开头 callout 和章节 `<hr/>`
|
||||
3. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。记录需要插图的章节(block ID)及推荐的画板类型
|
||||
3. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。重要信息优先画板化,记录需要插图的章节(block ID)、推荐画板类型、简单/复杂路径和源内容片段
|
||||
4. 向用户简要说明改进计划(包含识别出的画板机会)
|
||||
|
||||
### 第二波 — 定向改写(并行 Agent)
|
||||
|
||||
5. Spawn Agent 在不重叠的章节上并行改进,各 Agent 收到文档 token 和特定 block ID:(见 `lark-doc-style.md`)
|
||||
5. **优先处理第一波识别出的画板候选段落**:
|
||||
- 简单图:启动 SVG SubAgent,直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`;不读取 **lark-whiteboard**
|
||||
- 复杂图:主 Agent 先插入 `<whiteboard type="blank"></whiteboard>` 并提取 `block_token`,再为每个 `block_token` 启动 SubAgent 使用 **lark-whiteboard** skill 写入画板
|
||||
6. Spawn 内容改写 Agent 在不重叠的章节上并行改进,各 Agent 收到文档 token 和特定 block ID:(见 `lark-doc-style.md`)
|
||||
- 开头适当添加 `<callout>`、重组引言
|
||||
- 纯文本转为 `<grid>`/`<table>`/`<whiteboard>`
|
||||
- **对第一波识别出的画板候选段落**:简单图直接 `<whiteboard type="mermaid|plantuml">`,复杂图 spawn Agent 使用 **lark-whiteboard** skill
|
||||
- 添加流程图、对比分栏等富 block
|
||||
- 纯文本转为 `<grid>`/`<table>`/`<callout>`
|
||||
- 添加低重要度对比分栏、关键提示等富 block;画板类需求只走第 5 步
|
||||
|
||||
### 第三波 — 验证(串行)
|
||||
|
||||
5. 获取更新后文档局部内容,重新检查样式指标
|
||||
6. 未达标则定向修正,向用户呈现结果
|
||||
7. 获取更新后文档局部内容,重新检查样式指标
|
||||
8. 未达标则定向修正,向用户呈现结果
|
||||
|
||||
## Agent 子任务要求
|
||||
|
||||
Spawn Agent 时必须提供:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`。
|
||||
内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`。
|
||||
|
||||
SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`。
|
||||
|
||||
复杂画板 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
|
||||
|
||||
**上下文节省提示**:Agent 如需在自己负责的章节内重新读取内容,优先用 `docs +fetch --api-version v2 --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉自己的章节,不要重复拉全文。
|
||||
|
||||
@@ -200,6 +200,19 @@ lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "
|
||||
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
|
||||
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet/slides) |
|
||||
|
||||
#### `permission.public.patch` 错误码引导
|
||||
|
||||
调用 `lark-cli drive permission.public patch` 更新文档公开权限失败时,如果返回以下错误码,按表格给用户明确下一步。不要把这些错误简单归类为缺少 scope;它们通常表示租户、对外分享或文档密级策略拦截。
|
||||
|
||||
| 错误码 | 含义 | 给用户的引导 |
|
||||
|--------|------------------------|--------------|
|
||||
| `91009` | 对外分享被租户安全策略管控,当前用户无法开启 | 提示用户:对外分享能力被租户安全策略统一管控,无法通过 API 或当前用户直接开启;需要联系租户管理员调整组织级对外分享策略。 |
|
||||
| `91010` | 文档对外分享未打开 | 提示用户:当前文档尚未打开对外分享,请先在文档权限设置中打开对外分享,再重试 `permission.public.patch`。 |
|
||||
| `91011` | 对外分享被文档密级管控 | 提示用户:对外分享被密级策略拦截,需要打开目标文档,在文档内发起密级豁免或进行密级降级后再重试;回复中必须给出目标文档 URL。 |
|
||||
| `91012` | 权限设置被文档密级管控 | 提示用户:该权限设置被密级策略拦截,需要打开目标文档,在文档内发起密级豁免或进行密级降级后再重试;回复中必须给出目标文档 URL。 |
|
||||
|
||||
当用户最初提供的是文档 URL,遇到 `91011` 或 `91012` 时直接把该 URL 原样返回给用户作为操作入口;如果上下文只有 token,需要先尽量通过已有上下文、搜索结果或元数据恢复目标文档 URL,再给出可点击的文档 URL。
|
||||
|
||||
### 授权当前应用访问文档
|
||||
|
||||
当需要将文档权限授予**当前应用(bot)自身**时,先通过 bot info 接口获取应用的 open_id,再调用权限接口授权:
|
||||
@@ -302,27 +315,29 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `files.copy` | `docs:document:copy` |
|
||||
| `files.create_folder` | `space:folder:create` |
|
||||
| `files.list` | `space:document:retrieve` |
|
||||
| `files.patch` | `docx:document:write_only` |
|
||||
| `file.comments.batch_query` | `docs:document.comment:read` |
|
||||
| `file.comments.create_v2` | `docs:document.comment:create` |
|
||||
| `file.comments.list` | `docs:document.comment:read` |
|
||||
| `file.comments.patch` | `docs:document.comment:update` |
|
||||
| `file.comment.replys.create` | `docs:document.comment:create` |
|
||||
| `file.comment.replys.delete` | `docs:document.comment:delete` |
|
||||
| `file.comment.replys.list` | `docs:document.comment:read` |
|
||||
| `file.comment.replys.update` | `docs:document.comment:update` |
|
||||
| `permission.members.auth` | `docs:permission.member:auth` |
|
||||
| `permission.members.create` | `docs:permission.member:create` |
|
||||
| `permission.members.transfer_owner` | `docs:permission.member:transfer` |
|
||||
| `metas.batch_query` | `drive:drive.metadata:readonly` |
|
||||
| `user.remove_subscription` | `docs:event:subscribe` |
|
||||
| `user.subscription` | `docs:event:subscribe` |
|
||||
| `user.subscription_status` | `docs:event:subscribe` |
|
||||
| `file.statistics.get` | `drive:drive.metadata:readonly` |
|
||||
| `file.view_records.list` | `drive:file:view_record:readonly` |
|
||||
| `file.comment.reply.reactions.update_reaction` | `docs:document.comment:create` |
|
||||
| 方法 | 所需 scope |
|
||||
|------------------------------------------------|-----------------------------------|
|
||||
| `files.copy` | `docs:document:copy` |
|
||||
| `files.create_folder` | `space:folder:create` |
|
||||
| `files.list` | `space:document:retrieve` |
|
||||
| `files.patch` | `docx:document:write_only` |
|
||||
| `file.comments.batch_query` | `docs:document.comment:read` |
|
||||
| `file.comments.create_v2` | `docs:document.comment:create` |
|
||||
| `file.comments.list` | `docs:document.comment:read` |
|
||||
| `file.comments.patch` | `docs:document.comment:update` |
|
||||
| `file.comment.replys.create` | `docs:document.comment:create` |
|
||||
| `file.comment.replys.delete` | `docs:document.comment:delete` |
|
||||
| `file.comment.replys.list` | `docs:document.comment:read` |
|
||||
| `file.comment.replys.update` | `docs:document.comment:update` |
|
||||
| `permission.members.auth` | `docs:permission.member:auth` |
|
||||
| `permission.members.create` | `docs:permission.member:create` |
|
||||
| `permission.members.transfer_owner` | `docs:permission.member:transfer` |
|
||||
| `permission.public.get` | `docs:permission.setting:read` |
|
||||
| `permission.public.patch` | `docs:permission.setting:write_only` |
|
||||
| `metas.batch_query` | `drive:drive.metadata:readonly` |
|
||||
| `user.remove_subscription` | `docs:event:subscribe` |
|
||||
| `user.subscription` | `docs:event:subscribe` |
|
||||
| `user.subscription_status` | `docs:event:subscribe` |
|
||||
| `file.statistics.get` | `drive:drive.metadata:readonly` |
|
||||
| `file.view_records.list` | `drive:file:view_record:readonly` |
|
||||
| `file.comment.reply.reactions.update_reaction` | `docs:document.comment:create` |
|
||||
|
||||
@@ -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`
|
||||
@@ -207,4 +207,69 @@ func TestDrive_DuplicateRemoteWorkflow(t *testing.T) {
|
||||
t.Fatalf("+status should converge to a clean unchanged mirror\nstdout:\n%s", statusResult.Stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("push overwrites nested remote file under its real parent", func(t *testing.T) {
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-nested-push-"+suffix, "")
|
||||
subFolderToken := createDriveFolder(t, parentT, ctx, "sub", folderToken)
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local", "sub"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir local/sub: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(workDir, "local", "sub", "keep.txt"), []byte("local-nested-overwrite"), 0o644); err != nil {
|
||||
t.Fatalf("write local/sub/keep.txt: %v", err)
|
||||
}
|
||||
|
||||
existingToken := uploadNamedFile(t, workDir, subFolderToken, "_nested_keep.txt", "keep.txt", "remote-before")
|
||||
|
||||
pushResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
"--if-exists", "overwrite",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
pushResult.AssertExitCode(t, 0)
|
||||
pushResult.AssertStdoutStatus(t, true)
|
||||
|
||||
if got := gjson.Get(pushResult.Stdout, "data.summary.uploaded").Int(); got != 1 {
|
||||
t.Fatalf("nested +push uploaded=%d, want 1\nstdout:\n%s", got, pushResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(pushResult.Stdout, `data.items.#(rel_path="sub/keep.txt").action`).String(); got != "overwritten" {
|
||||
t.Fatalf("nested +push action=%q, want overwritten\nstdout:\n%s", got, pushResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(pushResult.Stdout, `data.items.#(rel_path="sub/keep.txt").file_token`).String(); got != existingToken {
|
||||
t.Fatalf("nested +push file_token=%q, want existing token %q\nstdout:\n%s", got, existingToken, pushResult.Stdout)
|
||||
}
|
||||
|
||||
statusResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
skipDriveStatusExactIfMissingDownloadScope(t, statusResult)
|
||||
statusResult.AssertExitCode(t, 0)
|
||||
statusResult.AssertStdoutStatus(t, true)
|
||||
if got := gjson.Get(statusResult.Stdout, "data.unchanged.#").Int(); got != 1 {
|
||||
t.Fatalf("nested +status unchanged count=%d, want 1\nstdout:\n%s", got, statusResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(statusResult.Stdout, "data.unchanged.0.rel_path").String(); got != "sub/keep.txt" {
|
||||
t.Fatalf("nested +status unchanged rel_path=%q, want sub/keep.txt\nstdout:\n%s", got, statusResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(statusResult.Stdout, "data.modified.#").Int(); got != 0 ||
|
||||
gjson.Get(statusResult.Stdout, "data.new_local.#").Int() != 0 ||
|
||||
gjson.Get(statusResult.Stdout, "data.new_remote.#").Int() != 0 {
|
||||
t.Fatalf("nested overwrite should converge to a clean unchanged mirror\nstdout:\n%s", statusResult.Stdout)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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