feat(wiki): add +node-get / +node-delete / +space-create shortcuts (#904)

- +node-get: wrap wiki.spaces.get_node; accepts node_token, obj_token,
  or a Lark URL (URL path auto-infers obj_type); formatted output with
  creator / updated_at. No synthesized url — get_node returns none and a
  BuildResourceURL fallback is a non-canonical link that misleads in a
  read/confirm command (sibling read shortcuts omit it too)
- +node-delete: wrap space.node delete; high-risk-write (--yes gated),
  async delete-node task polling, auto-resolves space_id via get_node
  when --space-id omitted, actionable hints for codes 131011 / 131003.
  The delete-node task result lives under the gateway's generic
  `simple_task_result` key (NOT `delete_node_result`)
- +space-create: wrap spaces.create; user-only identity, --name
  required (no empty-name spaces), flattened space output, no url
- factor the shared wiki async-task poll loop into wiki_async_task.go;
  preserve upstream Lark Detail.Code on poll exhaustion (no longer
  rebuilt via lossy ErrWithHint)
- drive +task_result: add wiki_delete_node scenario so +node-delete's
  async-timeout next_command actually resolves
- skill docs: reference pages for the 3 new shortcuts + SKILL.md
  shortcuts table (no raw nodes.delete API exists — it's shortcut-only,
  so it is intentionally absent from API Resources / permission table);
  drop the circular TestWikiShortcutsIncludeAllCommands change-detector

Change-Id: I316f78290cec5bc50f80d629173e3bf2a35dd005
This commit is contained in:
liujinkun2025
2026-05-19 11:21:54 +08:00
committed by GitHub
parent 583349e572
commit c4fb7006d2
19 changed files with 2844 additions and 149 deletions

View File

@@ -20,7 +20,7 @@ import (
var DriveTaskResult = common.Shortcut{
Service: "drive",
Command: "+task_result",
Description: "Poll async task result for import, export, drive move/delete, wiki move, or wiki delete-space operations",
Description: "Poll async task result for import, export, drive move/delete, wiki move, wiki delete-space, or wiki delete-node operations",
Risk: "read",
// This shortcut multiplexes multiple backend APIs with different scope
// requirements, so scenario-specific prechecks are handled in Validate.
@@ -28,8 +28,8 @@ var DriveTaskResult = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "ticket", Desc: "async task ticket (for import/export tasks)", Required: false},
{Name: "task-id", Desc: "async task ID (for drive task_check, wiki_move, or wiki_delete_space tasks)", Required: false},
{Name: "scenario", Desc: "task scenario: import, export, task_check, wiki_move, or wiki_delete_space", Required: true},
{Name: "task-id", Desc: "async task ID (for drive task_check, wiki_move, wiki_delete_space, or wiki_delete_node tasks)", Required: false},
{Name: "scenario", Desc: "task scenario: import, export, task_check, wiki_move, wiki_delete_space, or wiki_delete_node", Required: true},
{Name: "file-token", Desc: "source document token used for export task status lookup", Required: false},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -40,9 +40,10 @@ var DriveTaskResult = common.Shortcut{
"task_check": true,
"wiki_move": true,
"wiki_delete_space": true,
"wiki_delete_node": true,
}
if !validScenarios[scenario] {
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space", scenario)
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space, wiki_delete_node", scenario)
}
// Validate required params based on scenario
@@ -54,7 +55,7 @@ var DriveTaskResult = common.Shortcut{
if err := validate.ResourceName(runtime.Str("ticket"), "--ticket"); err != nil {
return output.ErrValidation("%s", err)
}
case "task_check", "wiki_move", "wiki_delete_space":
case "task_check", "wiki_move", "wiki_delete_space", "wiki_delete_node":
if runtime.Str("task-id") == "" {
return output.ErrValidation("--task-id is required for %s scenario", scenario)
}
@@ -108,6 +109,11 @@ var DriveTaskResult = common.Shortcut{
Desc("[1] Query wiki delete-space task result").
Set("task_id", taskID).
Params(map[string]interface{}{"task_type": "delete_space"})
case "wiki_delete_node":
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
Desc("[1] Query wiki delete-node task result").
Set("task_id", taskID).
Params(map[string]interface{}{"task_type": "delete_node"})
}
return dry
@@ -136,6 +142,8 @@ var DriveTaskResult = common.Shortcut{
result, err = queryWikiMoveTask(runtime, taskID)
case "wiki_delete_space":
result, err = queryWikiDeleteSpaceTask(runtime, taskID)
case "wiki_delete_node":
result, err = queryWikiDeleteNodeTask(runtime, taskID)
}
if err != nil {
@@ -236,7 +244,7 @@ func validateDriveTaskResultScopes(ctx context.Context, runtime *common.RuntimeC
switch scenario {
case "import", "export", "task_check":
required = []string{"drive:drive.metadata:readonly"}
case "wiki_move", "wiki_delete_space":
case "wiki_move", "wiki_delete_space", "wiki_delete_node":
required = []string{"wiki:space:read"}
}
@@ -540,3 +548,64 @@ func queryWikiDeleteSpaceTask(runtime *common.RuntimeContext, taskID string) (ma
"status_msg": label,
}, nil
}
// queryWikiDeleteNodeTask returns the normalized status of an async wiki
// delete-node task. For historical reasons the gateway stashes delete-node
// status under the generic `simple_task_result` key (NOT `delete_node_result`),
// and that object only carries `status` — there is no `status_msg`, so the
// label falls back to the status code. Mirrors queryWikiDeleteSpaceTask;
// intentionally duplicated here (rather than importing the wiki package) to
// keep drive from depending on shortcuts/wiki.
func queryWikiDeleteNodeTask(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
return nil, output.ErrValidation("%s", err)
}
data, err := runtime.CallAPI(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
map[string]interface{}{"task_type": "delete_node"},
nil,
)
if err != nil {
return nil, err
}
task := common.GetMap(data, "task")
if task == nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
}
resolvedTaskID := common.GetString(task, "task_id")
if resolvedTaskID == "" {
resolvedTaskID = taskID
}
result := common.GetMap(task, "simple_task_result")
var status string
if result != nil {
status = common.GetString(result, "status")
}
// Keep in sync with wiki.parseWikiAsyncTaskStatus / wikiAsyncTaskStatus
// classification (intentionally duplicated to avoid a drive→wiki import —
// see the doc comment above). If the success/failed/processing rules change
// there, mirror the change here.
lowered := strings.ToLower(strings.TrimSpace(status))
ready := lowered == "success"
failed := lowered == "failure" || lowered == "failed"
resolvedStatus := strings.TrimSpace(status)
if resolvedStatus == "" {
resolvedStatus = "processing"
}
return map[string]interface{}{
"scenario": "wiki_delete_node",
"task_id": resolvedTaskID,
"ready": ready,
"failed": failed,
"status": resolvedStatus,
"status_msg": resolvedStatus,
}, nil
}

View File

@@ -417,10 +417,10 @@ func TestDriveTaskResultWikiMoveIncludesFlattenedNodeFields(t *testing.T) {
func TestValidateDriveTaskResultScopesWikiScenariosRequireWikiScope(t *testing.T) {
t.Parallel()
// wiki_move and wiki_delete_space both read wiki task status, so both must
// require wiki:space:read. A single table keeps this invariant explicit
// without duplicating near-identical test functions per scenario.
for _, scenario := range []string{"wiki_move", "wiki_delete_space"} {
// wiki_move, wiki_delete_space and wiki_delete_node all read wiki task
// status, so all must require wiki:space:read. A single table keeps this
// invariant explicit without duplicating near-identical test functions.
for _, scenario := range []string{"wiki_move", "wiki_delete_space", "wiki_delete_node"} {
t.Run(scenario+"/rejects missing scope", func(t *testing.T) {
t.Parallel()
runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "drive:drive.metadata:readonly")
@@ -518,6 +518,105 @@ func TestDriveTaskResultWikiDeleteSpaceSuccess(t *testing.T) {
}
}
func TestDriveTaskResultDryRunWikiDeleteNodeIncludesTaskTypeParam(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +task_result"}
cmd.Flags().String("scenario", "", "")
cmd.Flags().String("ticket", "", "")
cmd.Flags().String("task-id", "", "")
cmd.Flags().String("file-token", "", "")
if err := cmd.Flags().Set("scenario", "wiki_delete_node"); err != nil {
t.Fatalf("set --scenario: %v", err)
}
if err := cmd.Flags().Set("task-id", "task_del_node_1"); err != nil {
t.Fatalf("set --task-id: %v", err)
}
runtime := common.TestNewRuntimeContext(cmd, nil)
dry := DriveTaskResult.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 1 {
t.Fatalf("expected 1 API call, got %d", len(got.API))
}
if got.API[0].Params["task_type"] != "delete_node" {
t.Fatalf("wiki delete-node params = %#v, want task_type=delete_node", got.API[0].Params)
}
}
func TestDriveTaskResultWikiDeleteNodeSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/tasks/task_del_node_1",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"task": map[string]interface{}{
// Gateway returns delete-node status under the generic
// simple_task_result key (NOT delete_node_result), and it
// carries only `status` (no status_msg).
"simple_task_result": map[string]interface{}{
"status": "success",
},
},
},
},
})
err := mountAndRunDrive(t, DriveTaskResult, []string{
"+task_result",
"--scenario", "wiki_delete_node",
"--task-id", "task_del_node_1",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if data["scenario"] != "wiki_delete_node" || data["task_id"] != "task_del_node_1" {
t.Fatalf("unexpected wiki_delete_node envelope: %#v", data)
}
if data["ready"] != true || data["failed"] != false || data["status"] != "success" {
t.Fatalf("unexpected readiness fields: %#v", data)
}
// simple_task_result has no status_msg; label must fall back to status.
if data["status_msg"] != "success" {
t.Fatalf("status_msg = %#v, want fallback to status", data["status_msg"])
}
}
func TestDriveTaskResultRejectsUnknownScenarioListsWikiDeleteNode(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveTaskResult, []string{
"+task_result",
"--scenario", "bogus",
"--task-id", "task_x",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "wiki_delete_node") {
t.Fatalf("expected unsupported-scenario error listing wiki_delete_node, got %v", err)
}
}
func TestValidateDriveTaskResultScopesDriveScenariosRequireDriveScope(t *testing.T) {
t.Parallel()

View File

@@ -12,7 +12,10 @@ func Shortcuts() []common.Shortcut {
WikiNodeCreate,
WikiDeleteSpace,
WikiSpaceList,
WikiSpaceCreate,
WikiNodeList,
WikiNodeCopy,
WikiNodeGet,
WikiNodeDelete,
}
}

View File

@@ -0,0 +1,207 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// Shared async-task polling for wiki delete operations. The wiki delete
// endpoints (DELETE /spaces/{id}, DELETE /spaces/{id}/nodes/{token}) may
// return either an empty task_id (sync completion) or a task_id that must
// be polled against /wiki/v2/tasks/{task_id}?task_type=<...>.
//
// For historical reasons /wiki/v2/tasks/{task_id} stashes the status under a
// different key per task type: delete-space uses `delete_space_result`, while
// delete-node uses the generic `simple_task_result` (the gateway's reusable
// "future async tasks share this" field). move tasks use `move_result` and are
// handled separately in wiki_move.go. Every key still exposes a `status`, so
// the poll loop / classification is factored out here and the caller passes
// the right result key.
//
// Note: `simple_task_result` only carries `status` (no `status_msg`), so for
// delete-node StatusLabel() falls back to the status code — which is fine.
const (
wikiAsyncStatusSuccess = "success"
wikiAsyncStatusFailure = "failure"
wikiAsyncStatusProcessing = "processing"
wikiAsyncTaskTypeDeleteSpace = "delete_space"
wikiAsyncTaskTypeDeleteNode = "delete_node"
wikiAsyncResultDeleteSpace = "delete_space_result"
// wikiAsyncResultSimpleTask is the generic result key the gateway uses for
// delete-node (and intends to reuse for future async task types). It is
// NOT `delete_node_result` — that key does not exist in the response.
wikiAsyncResultSimpleTask = "simple_task_result"
)
// wikiAsyncTaskStatus is the unified poll-response shape used by every wiki
// delete task. The taskID is captured so error/resume hints can name it.
type wikiAsyncTaskStatus struct {
TaskID string
Status string
StatusMsg string
}
// normalizedStatus collapses whitespace and case so " SUCCESS " classifies
// the same as "success". Ready()/Failed() (control flow) derive from this;
// StatusCode()/StatusLabel() (display) deliberately surface the raw backend
// value instead. For the real status enums (delete-node: processing/success/
// failed; delete-space's documented set) the two agree. They only diverge for
// an undocumented status string, which is intentional — an unrecognized status
// is shown verbatim rather than masked as a hard failure.
func (s wikiAsyncTaskStatus) normalizedStatus() string {
return strings.ToLower(strings.TrimSpace(s.Status))
}
func (s wikiAsyncTaskStatus) Ready() bool {
return s.normalizedStatus() == wikiAsyncStatusSuccess
}
func (s wikiAsyncTaskStatus) Failed() bool {
// The sample protocol only documents "success" as a terminal OK. Treat any
// explicit "failure"/"failed" signal as terminal, and unknown non-success
// values as still-processing so we don't misreport a novel status as a hard
// failure.
lowered := s.normalizedStatus()
return lowered == wikiAsyncStatusFailure || lowered == "failed"
}
// StatusCode returns a never-empty status value for the output envelope. If
// the backend response omits delete_*_result.status (or sends whitespace),
// fall back to "processing" so the documented timeout-shape stays accurate.
func (s wikiAsyncTaskStatus) StatusCode() string {
if status := strings.TrimSpace(s.Status); status != "" {
return status
}
return wikiAsyncStatusProcessing
}
func (s wikiAsyncTaskStatus) StatusLabel() string {
if msg := strings.TrimSpace(s.StatusMsg); msg != "" {
return msg
}
return s.StatusCode()
}
// wikiAsyncTaskFetcher returns the latest status for taskID. Implementations
// translate from runtime.CallAPI responses or test fakes.
type wikiAsyncTaskFetcher func(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error)
// parseWikiAsyncTaskStatus normalizes an /wiki/v2/tasks/{task_id} payload.
// resultKey selects the right shape ("delete_space_result" for delete-space,
// "simple_task_result" for delete-node).
func parseWikiAsyncTaskStatus(taskID string, task map[string]interface{}, resultKey string) (wikiAsyncTaskStatus, error) {
if task == nil {
return wikiAsyncTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
}
result := common.GetMap(task, resultKey)
status := wikiAsyncTaskStatus{
TaskID: common.GetString(task, "task_id"),
}
if status.TaskID == "" {
status.TaskID = taskID
}
if result != nil {
status.Status = common.GetString(result, "status")
status.StatusMsg = common.GetString(result, "status_msg")
}
return status, nil
}
// pollWikiAsyncTask runs the bounded polling loop shared by every wiki delete
// shortcut. label is the human-readable operation name surfaced in stderr
// progress lines ("delete-space" / "delete-node"). nextCommand is the resume
// hint embedded into the wrapped error when every poll fails.
//
// attempts/interval are taken as parameters (instead of consts) so callers
// can keep their per-operation tunable constants for back-compat with the
// existing test hooks.
func pollWikiAsyncTask(
ctx context.Context,
runtime *common.RuntimeContext,
taskID, label string,
attempts int,
interval time.Duration,
fetcher wikiAsyncTaskFetcher,
nextCommand string,
) (wikiAsyncTaskStatus, bool, error) {
lastStatus := wikiAsyncTaskStatus{TaskID: taskID}
var lastErr error
hadSuccessfulPoll := false
// The delete request already succeeded. Treat poll failures as transient
// until every attempt fails, then return a resume hint instead of
// discarding the task identifier.
for attempt := 1; attempt <= attempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return lastStatus, false, ctx.Err()
case <-time.After(interval):
}
}
status, err := fetcher(ctx, taskID)
if err != nil {
lastErr = err
fmt.Fprintf(runtime.IO().ErrOut, "Wiki %s status attempt %d/%d failed: %v\n", label, attempt, attempts, err)
continue
}
lastStatus = status
hadSuccessfulPoll = true
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Wiki %s task completed successfully.\n", label)
return status, true, nil
}
if status.Failed() {
return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki %s task %s failed: %s", label, taskID, status.StatusLabel())
}
fmt.Fprintf(runtime.IO().ErrOut, "Wiki %s status %d/%d: %s\n", label, attempt, attempts, status.StatusLabel())
}
if !hadSuccessfulPoll && lastErr != nil {
hint := fmt.Sprintf(
"the wiki %s task was created but every status poll failed (task_id=%s)\nretry status lookup with: %s",
label, taskID, nextCommand,
)
var exitErr *output.ExitError
if errors.As(lastErr, &exitErr) && exitErr.Detail != nil {
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
hint = exitErr.Detail.Hint + "\n" + hint
}
// ErrWithHint rebuilds the error and drops the upstream Lark
// Detail.Code / ConsoleURL / Risk / nested Detail. Build the
// ExitError by hand so the original API code survives a fully
// failed poll, matching wrapWikiNodeDeleteAPIError.
return lastStatus, false, &output.ExitError{
Code: exitErr.Code,
Detail: &output.ErrDetail{
Type: exitErr.Detail.Type,
Code: exitErr.Detail.Code,
Message: exitErr.Detail.Message,
Hint: hint,
ConsoleURL: exitErr.Detail.ConsoleURL,
Risk: exitErr.Detail.Risk,
Detail: exitErr.Detail.Detail,
},
}
}
return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint)
}
return lastStatus, false, nil
}

View File

@@ -0,0 +1,181 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// pollWikiAsyncTask is shared infrastructure for every wiki delete shortcut,
// so it gets a dedicated test surface here rather than relying only on the
// transitive coverage from the delete-space / delete-node paths.
func TestPollWikiAsyncTaskSuccessFirstPoll(t *testing.T) {
t.Parallel()
runtime, stderr := newWikiNodeDeleteRuntime(t, core.AsUser)
status, ready, err := pollWikiAsyncTask(
context.Background(), runtime, "task_ok", "delete-node", 3, 0,
func(context.Context, string) (wikiAsyncTaskStatus, error) {
return wikiAsyncTaskStatus{Status: "success"}, nil
},
"resume-cmd",
)
if err != nil {
t.Fatalf("pollWikiAsyncTask() error = %v", err)
}
if !ready || !status.Ready() {
t.Fatalf("ready = %v, status = %+v, want ready", ready, status)
}
if !strings.Contains(stderr.String(), "delete-node task completed successfully") {
t.Fatalf("stderr = %q", stderr.String())
}
}
func TestPollWikiAsyncTaskFailureIsTerminal(t *testing.T) {
t.Parallel()
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
_, ready, err := pollWikiAsyncTask(
context.Background(), runtime, "task_x", "delete-node", 3, 0,
func(context.Context, string) (wikiAsyncTaskStatus, error) {
return wikiAsyncTaskStatus{Status: "failure", StatusMsg: "denied"}, nil
},
"resume-cmd",
)
if ready {
t.Fatalf("ready = true, want false on failure")
}
if err == nil || !strings.Contains(err.Error(), "delete-node task task_x failed: denied") {
t.Fatalf("err = %v, want terminal failure with reason", err)
}
}
func TestPollWikiAsyncTaskTimeoutWhenAlwaysProcessing(t *testing.T) {
t.Parallel()
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
status, ready, err := pollWikiAsyncTask(
context.Background(), runtime, "task_slow", "delete-space", 2, 0,
func(context.Context, string) (wikiAsyncTaskStatus, error) {
return wikiAsyncTaskStatus{Status: "processing"}, nil
},
"resume-cmd",
)
// A still-processing task after the bounded window is a soft timeout:
// no error, ready=false, status preserved so the caller can print the
// follow-up command.
if err != nil {
t.Fatalf("pollWikiAsyncTask() error = %v, want nil on timeout", err)
}
if ready {
t.Fatalf("ready = true, want false on timeout")
}
if status.StatusCode() != "processing" {
t.Fatalf("status = %+v, want processing preserved", status)
}
}
func TestPollWikiAsyncTaskAllPollsFailWrapsWithResumeHint(t *testing.T) {
t.Parallel()
runtime, stderr := newWikiNodeDeleteRuntime(t, core.AsUser)
_, ready, err := pollWikiAsyncTask(
context.Background(), runtime, "task_lost", "delete-node", 2, 0,
func(context.Context, string) (wikiAsyncTaskStatus, error) {
return wikiAsyncTaskStatus{}, errors.New("transport boom")
},
"lark-cli drive +task_result --task-id task_lost",
)
if ready {
t.Fatalf("ready = true, want false when every poll failed")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("err = %T %v, want *output.ExitError with detail", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("exit code = %d, want ExitAPI", exitErr.Code)
}
if !strings.Contains(exitErr.Detail.Hint, "every status poll failed (task_id=task_lost)") ||
!strings.Contains(exitErr.Detail.Hint, "lark-cli drive +task_result --task-id task_lost") {
t.Fatalf("hint = %q, want resume guidance naming the task", exitErr.Detail.Hint)
}
if !strings.Contains(stderr.String(), "attempt 2/2 failed") {
t.Fatalf("stderr = %q, want per-attempt progress", stderr.String())
}
}
func TestPollWikiAsyncTaskPrependsUpstreamExitHint(t *testing.T) {
t.Parallel()
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
upstream := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "permission",
Code: 99991663,
Message: "permission denied",
Hint: "grant the wiki:node:retrieve scope",
},
}
_, _, err := pollWikiAsyncTask(
context.Background(), runtime, "task_perm", "delete-node", 1, 0,
func(context.Context, string) (wikiAsyncTaskStatus, error) {
return wikiAsyncTaskStatus{}, upstream
},
"resume-cmd",
)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("err = %T %v, want *output.ExitError", err, err)
}
// The upstream hint must lead so the actionable cause is read first, with
// the resume guidance appended. Type and exit code propagate from upstream.
if !strings.HasPrefix(exitErr.Detail.Hint, "grant the wiki:node:retrieve scope\n") {
t.Fatalf("hint = %q, want upstream hint prepended", exitErr.Detail.Hint)
}
if !strings.Contains(exitErr.Detail.Hint, "resume-cmd") {
t.Fatalf("hint = %q, want resume command appended", exitErr.Detail.Hint)
}
if exitErr.Detail.Type != "permission" || exitErr.Code != output.ExitAPI {
t.Fatalf("exitErr = %+v, want permission/ExitAPI propagated", exitErr)
}
if exitErr.Detail.Message != "permission denied" {
t.Fatalf("message = %q, want upstream message preserved", exitErr.Detail.Message)
}
}
func TestPollWikiAsyncTaskHonoursContextCancellation(t *testing.T) {
t.Parallel()
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
ctx, cancel := context.WithCancel(context.Background())
calls := 0
_, ready, err := pollWikiAsyncTask(
ctx, runtime, "task_cancel", "delete-node", 5, time.Hour,
func(context.Context, string) (wikiAsyncTaskStatus, error) {
calls++
cancel() // cancel before the next attempt's inter-poll wait
return wikiAsyncTaskStatus{Status: "processing"}, nil
},
"resume-cmd",
)
if ready {
t.Fatalf("ready = true, want false on cancellation")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("err = %v, want context.Canceled", err)
}
if calls != 1 {
t.Fatalf("fetcher calls = %d, want 1 (cancelled before second poll)", calls)
}
}

View File

@@ -5,7 +5,6 @@ package wiki
import (
"context"
"errors"
"fmt"
"strings"
"time"
@@ -21,10 +20,12 @@ var (
wikiDeleteSpacePollInterval = 2 * time.Second
)
// Back-compat aliases — the shared async-task helper now owns the strings,
// but tests still reference these names.
const (
wikiDeleteSpaceStatusSuccess = "success"
wikiDeleteSpaceStatusFailure = "failure"
wikiDeleteSpaceStatusProcessing = "processing"
wikiDeleteSpaceStatusSuccess = wikiAsyncStatusSuccess
wikiDeleteSpaceStatusFailure = wikiAsyncStatusFailure
wikiDeleteSpaceStatusProcessing = wikiAsyncStatusProcessing
)
// WikiDeleteSpace deletes a wiki space. The DELETE endpoint may complete
@@ -73,48 +74,10 @@ type wikiDeleteSpaceResponse struct {
TaskID string
}
type wikiDeleteSpaceTaskStatus struct {
TaskID string
Status string
StatusMsg string
}
// normalizedStatus collapses whitespace and case so " SUCCESS " is
// classified the same as "success". Ready / Failed / StatusCode all derive
// from this so classification and the output `status` field can't disagree.
func (s wikiDeleteSpaceTaskStatus) normalizedStatus() string {
return strings.ToLower(strings.TrimSpace(s.Status))
}
func (s wikiDeleteSpaceTaskStatus) Ready() bool {
return s.normalizedStatus() == wikiDeleteSpaceStatusSuccess
}
func (s wikiDeleteSpaceTaskStatus) Failed() bool {
// The sample protocol only documents "success" as a terminal OK. Treat any
// explicit "failure"/"failed" signal as terminal, and unknown non-success
// values as still-processing so we don't misreport a novel status as a hard
// failure.
lowered := s.normalizedStatus()
return lowered == wikiDeleteSpaceStatusFailure || lowered == "failed"
}
// StatusCode returns a never-empty status value for the output envelope. If
// the backend response omits delete_space_result.status (or sends whitespace),
// fall back to "processing" so the documented timeout-shape stays accurate.
func (s wikiDeleteSpaceTaskStatus) StatusCode() string {
if status := strings.TrimSpace(s.Status); status != "" {
return status
}
return wikiDeleteSpaceStatusProcessing
}
func (s wikiDeleteSpaceTaskStatus) StatusLabel() string {
if msg := strings.TrimSpace(s.StatusMsg); msg != "" {
return msg
}
return s.StatusCode()
}
// wikiDeleteSpaceTaskStatus is an alias for the shared wiki async-task shape;
// kept as a named type for the existing test surface. delete-node uses the
// same type directly under its real name (wikiAsyncTaskStatus).
type wikiDeleteSpaceTaskStatus = wikiAsyncTaskStatus
type wikiDeleteSpaceClient interface {
DeleteSpace(ctx context.Context, spaceID string) (*wikiDeleteSpaceResponse, error)
@@ -150,7 +113,7 @@ func (api wikiDeleteSpaceAPI) GetDeleteSpaceTask(ctx context.Context, taskID str
if err != nil {
return wikiDeleteSpaceTaskStatus{}, err
}
return parseWikiDeleteSpaceTaskStatus(taskID, common.GetMap(data, "task"))
return parseWikiAsyncTaskStatus(taskID, common.GetMap(data, "task"), wikiAsyncResultDeleteSpace)
}
func readWikiDeleteSpaceSpec(runtime *common.RuntimeContext) wikiDeleteSpaceSpec {
@@ -237,77 +200,18 @@ func wikiDeleteSpaceTaskResultCommand(taskID string, identity core.Identity) str
}
func pollWikiDeleteSpaceTask(ctx context.Context, client wikiDeleteSpaceClient, runtime *common.RuntimeContext, taskID string) (wikiDeleteSpaceTaskStatus, bool, error) {
lastStatus := wikiDeleteSpaceTaskStatus{TaskID: taskID}
var lastErr error
hadSuccessfulPoll := false
// The delete request already succeeded. Treat poll failures as transient
// until every attempt fails, then return a resume hint instead of discarding
// the task identifier.
for attempt := 1; attempt <= wikiDeleteSpacePollAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return lastStatus, false, ctx.Err()
case <-time.After(wikiDeleteSpacePollInterval):
}
}
status, err := client.GetDeleteSpaceTask(ctx, taskID)
if err != nil {
lastErr = err
fmt.Fprintf(runtime.IO().ErrOut, "Wiki delete-space status attempt %d/%d failed: %v\n", attempt, wikiDeleteSpacePollAttempts, err)
continue
}
lastStatus = status
hadSuccessfulPoll = true
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Wiki delete-space task completed successfully.\n")
return status, true, nil
}
if status.Failed() {
return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki delete-space task %s failed: %s", taskID, status.StatusLabel())
}
fmt.Fprintf(runtime.IO().ErrOut, "Wiki delete-space status %d/%d: %s\n", attempt, wikiDeleteSpacePollAttempts, status.StatusLabel())
}
if !hadSuccessfulPoll && lastErr != nil {
nextCommand := wikiDeleteSpaceTaskResultCommand(taskID, runtime.As())
hint := fmt.Sprintf(
"the wiki delete-space task was created but every status poll failed (task_id=%s)\nretry status lookup with: %s",
taskID,
nextCommand,
)
var exitErr *output.ExitError
if errors.As(lastErr, &exitErr) && exitErr.Detail != nil {
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
hint = exitErr.Detail.Hint + "\n" + hint
}
return lastStatus, false, output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint)
}
return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint)
}
return lastStatus, false, nil
return pollWikiAsyncTask(
ctx, runtime, taskID, "delete-space",
wikiDeleteSpacePollAttempts, wikiDeleteSpacePollInterval,
func(ctx context.Context, id string) (wikiAsyncTaskStatus, error) {
return client.GetDeleteSpaceTask(ctx, id)
},
wikiDeleteSpaceTaskResultCommand(taskID, runtime.As()),
)
}
// parseWikiDeleteSpaceTaskStatus is kept as a thin wrapper for the existing
// test surface; new callers should use parseWikiAsyncTaskStatus directly.
func parseWikiDeleteSpaceTaskStatus(taskID string, task map[string]interface{}) (wikiDeleteSpaceTaskStatus, error) {
if task == nil {
return wikiDeleteSpaceTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
}
result := common.GetMap(task, "delete_space_result")
status := wikiDeleteSpaceTaskStatus{
TaskID: common.GetString(task, "task_id"),
}
if status.TaskID == "" {
status.TaskID = taskID
}
if result != nil {
status.Status = common.GetString(result, "status")
status.StatusMsg = common.GetString(result, "status_msg")
}
return status, nil
return parseWikiAsyncTaskStatus(taskID, task, wikiAsyncResultDeleteSpace)
}

View File

@@ -266,8 +266,19 @@ func TestPollWikiDeleteSpaceTaskWrapsPollFailuresWithHint(t *testing.T) {
withSingleWikiDeleteSpacePoll(t)
runtime, stderr := newWikiDeleteSpaceRuntimeWithScopes(t, core.AsUser, "")
// Seed an error that carries an upstream Lark Detail.Code so the test
// pins that structured fields survive a fully failed poll (not just the
// hint). ErrWithHint drops Detail.Code, which is exactly what we fixed.
client := &fakeWikiDeleteSpaceClient{
taskErrs: []error{output.ErrWithHint(output.ExitAPI, "api_error", "poll failed", "retry original")},
taskErrs: []error{&output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "api_error",
Code: 131006,
Message: "poll failed",
Hint: "retry original",
},
}},
}
status, ready, err := pollWikiDeleteSpaceTask(context.Background(), client, runtime, "task_123")
@@ -287,6 +298,9 @@ func TestPollWikiDeleteSpaceTaskWrapsPollFailuresWithHint(t *testing.T) {
if !strings.Contains(exitErr.Detail.Hint, "retry original") || !strings.Contains(exitErr.Detail.Hint, wikiDeleteSpaceTaskResultCommand("task_123", core.AsUser)) {
t.Fatalf("hint = %q, want original hint and resume command", exitErr.Detail.Hint)
}
if exitErr.Detail.Code != 131006 {
t.Fatalf("Detail.Code = %d, want 131006 preserved through poll exhaustion", exitErr.Detail.Code)
}
if !strings.Contains(stderr.String(), "Wiki delete-space status attempt 1/1 failed") {
t.Fatalf("stderr = %q, want poll failure log", stderr.String())
}

View File

@@ -107,24 +107,6 @@ func mountAndRunWiki(t *testing.T, shortcut common.Shortcut, args []string, fact
return parent.Execute()
}
func TestWikiShortcutsIncludeAllCommands(t *testing.T) {
t.Parallel()
shortcuts := 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")
}
if shortcuts[1].Command != "+node-create" {
t.Fatalf("shortcuts[1].Command = %q, want %q", shortcuts[1].Command, "+node-create")
}
if shortcuts[2].Command != "+delete-space" {
t.Fatalf("shortcuts[2].Command = %q, want %q", shortcuts[2].Command, "+delete-space")
}
}
func TestValidateWikiNodeCreateSpecRejectsShortcutWithoutOriginNodeToken(t *testing.T) {
t.Parallel()

View File

@@ -0,0 +1,440 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// wikiNodeDeleteObjTypes is the set of obj_type values the delete-node API
// accepts. Unlike wikiNodeGetObjTypeEnum this includes "wiki" — for
// delete-node, obj_type="wiki" means the token is a wiki node_token, whereas
// the get_node API omits obj_type for node_tokens.
var wikiNodeDeleteObjTypes = []string{
"wiki", "doc", "docx", "sheet", "bitable", "mindnote", "slides", "file",
}
var (
wikiDeleteNodePollAttempts = 30
wikiDeleteNodePollInterval = 2 * time.Second
)
// Lark wiki API error codes the delete-node API surfaces with actionable
// CLI workarounds. The full list is in the OpenAPI spec; we only special-case
// the codes whose remediation is non-obvious (UI approval, subtree size).
const (
wikiDeleteNodeErrCodeApprovalRequired = 131011
wikiDeleteNodeErrCodeSubtreeTooLarge = 131003
)
// WikiNodeDelete deletes a wiki node (or pulls a cloud doc out of Wiki). The
// API mirrors +delete-space — synchronous on small deletes, async with a
// task_id for cascade deletes — so this shortcut shares the async-polling
// helper. Space ID is optional: when omitted, +node-delete first looks up the
// node via get_node to resolve the space ID so callers do not have to chain
// commands.
var WikiNodeDelete = common.Shortcut{
Service: "wiki",
Command: "+node-delete",
Description: "Delete a wiki node, polling the async delete task when needed",
Risk: "high-risk-write",
// API spec lists wiki:node:create as the only declared scope for the
// delete endpoint. Naming is unfortunate, but the scope-preflight needs
// the literal string.
Scopes: []string{"wiki:node:create"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "node-token", Desc: "wiki node_token, cloud-doc obj_token, or a Lark URL embedding one of them", Required: true},
// Not Required at the cobra level: URL inputs auto-infer obj_type
// from the path, and the parser enforces explicit obj_type for raw
// tokens. Forcing Cobra Required here breaks the URL ergonomic.
{Name: "obj-type", Desc: "token kind; no default — pass explicitly when --node-token is a raw token (URL inputs auto-infer)", Enum: wikiNodeDeleteObjTypes},
{Name: "space-id", Desc: "wiki space ID; auto-resolved via get_node when omitted"},
{Name: "include-children", Type: "bool", Default: "true", Desc: "cascade delete the subtree (default); pass --include-children=false to lift direct children up to the parent"},
},
Tips: []string{
"Deletion is irreversible; double-check --node-token and --obj-type before running.",
"This is a high-risk-write command; pass --yes to confirm the deletion.",
"--node-token accepts a raw token (wikcnXXX, docxXXX, ...) or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>; URL paths also imply --obj-type.",
"Run +node-get first to confirm space_id / obj_type when in doubt.",
"Auto-resolving space_id (when --space-id is omitted) also calls get_node, which needs the wiki:node:retrieve scope; pass --space-id to skip that lookup if your token only carries wiki:node:create.",
"Async deletes return a task_id; this command polls for a bounded window and then prints a follow-up drive +task_result command.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readWikiNodeDeleteSpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readWikiNodeDeleteSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildWikiNodeDeleteDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readWikiNodeDeleteSpec(runtime)
if err != nil {
return err
}
out, err := runWikiNodeDelete(ctx, wikiNodeDeleteAPI{runtime: runtime}, runtime, spec)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
// wikiNodeDeleteSpec is the normalized input for the shortcut. Token / ObjType
// reconcile URL inputs with the explicit flags; SourceKind is purely for the
// dry-run description string.
type wikiNodeDeleteSpec struct {
NodeToken string
ObjType string
SpaceID string
IncludeChildren bool
SourceKind string // "raw" | "url"
}
// RequestBody builds the JSON body for DELETE /spaces/{id}/nodes/{token}.
func (spec wikiNodeDeleteSpec) RequestBody() map[string]interface{} {
return map[string]interface{}{
"obj_type": spec.ObjType,
"include_children": spec.IncludeChildren,
}
}
// wikiNodeDeleteClient isolates the network operations so business logic can
// be unit-tested without real HTTP calls. Mirrors wikiDeleteSpaceClient.
type wikiNodeDeleteClient interface {
ResolveNode(ctx context.Context, token, objType string) (*wikiNodeRecord, error)
DeleteNode(ctx context.Context, spaceID string, spec wikiNodeDeleteSpec) (string, error)
GetDeleteNodeTask(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error)
}
type wikiNodeDeleteAPI struct {
runtime *common.RuntimeContext
}
func (api wikiNodeDeleteAPI) ResolveNode(ctx context.Context, token, objType string) (*wikiNodeRecord, error) {
params := map[string]interface{}{"token": token}
// get_node takes obj_type only when the token is an obj_token. For
// wiki node_tokens the API rejects an obj_type kwarg, so omit it.
if objType != "" && objType != "wiki" {
params["obj_type"] = objType
}
data, err := api.runtime.CallAPI("GET", "/open-apis/wiki/v2/spaces/get_node", params, nil)
if err != nil {
return nil, err
}
return parseWikiNodeRecord(common.GetMap(data, "node"))
}
func (api wikiNodeDeleteAPI) DeleteNode(ctx context.Context, spaceID string, spec wikiNodeDeleteSpec) (string, error) {
data, err := api.runtime.CallAPI(
"DELETE",
fmt.Sprintf(
"/open-apis/wiki/v2/spaces/%s/nodes/%s",
validate.EncodePathSegment(spaceID),
validate.EncodePathSegment(spec.NodeToken),
),
nil,
spec.RequestBody(),
)
if err != nil {
return "", wrapWikiNodeDeleteAPIError(err)
}
return common.GetString(data, "task_id"), nil
}
func (api wikiNodeDeleteAPI) GetDeleteNodeTask(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error) {
data, err := api.runtime.CallAPI(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
map[string]interface{}{"task_type": wikiAsyncTaskTypeDeleteNode},
nil,
)
if err != nil {
return wikiAsyncTaskStatus{}, err
}
return parseWikiAsyncTaskStatus(taskID, common.GetMap(data, "task"), wikiAsyncResultSimpleTask)
}
func readWikiNodeDeleteSpec(runtime *common.RuntimeContext) (wikiNodeDeleteSpec, error) {
return parseWikiNodeDeleteSpec(
runtime.Str("node-token"),
runtime.Str("obj-type"),
runtime.Str("space-id"),
runtime.Bool("include-children"),
)
}
// parseWikiNodeDeleteSpec normalizes the raw flag values: extracts a token
// from a URL when provided, reconciles URL-implied obj_type against the
// explicit flag, and validates that the resulting obj_type is one the delete
// API accepts.
func parseWikiNodeDeleteSpec(rawToken, rawObjType, rawSpaceID string, includeChildren bool) (wikiNodeDeleteSpec, error) {
tokenInput := strings.TrimSpace(rawToken)
if tokenInput == "" {
return wikiNodeDeleteSpec{}, output.ErrValidation("--node-token is required")
}
spec := wikiNodeDeleteSpec{
ObjType: strings.ToLower(strings.TrimSpace(rawObjType)),
SpaceID: strings.TrimSpace(rawSpaceID),
IncludeChildren: includeChildren,
}
if strings.Contains(tokenInput, "://") {
u, err := url.Parse(tokenInput)
if err != nil || u.Path == "" {
return wikiNodeDeleteSpec{}, output.ErrValidation("--node-token URL is malformed: %q", tokenInput)
}
token, urlObjType, ok := tokenAndObjTypeFromWikiURL(u.Path)
if !ok {
return wikiNodeDeleteSpec{}, output.ErrValidation(
"unsupported --node-token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token",
u.Path,
)
}
spec.NodeToken = token
spec.SourceKind = "url"
// /wiki/<token> implies node_token → obj_type=wiki for the delete API.
// Cloud doc paths (/docx/, /sheets/, ...) already give us a concrete type.
inferred := urlObjType
if inferred == "" {
inferred = "wiki"
}
switch {
case spec.ObjType == "":
spec.ObjType = inferred
case spec.ObjType != inferred:
return wikiNodeDeleteSpec{}, output.ErrValidation(
"--obj-type %q does not match the obj_type %q implied by the URL path; pass only one",
spec.ObjType, inferred,
)
}
} else if strings.ContainsAny(tokenInput, "/?#") {
return wikiNodeDeleteSpec{}, output.ErrValidation(
"--node-token must be a raw token or a full URL; partial paths are not accepted: %q",
tokenInput,
)
} else {
spec.NodeToken = tokenInput
spec.SourceKind = "raw"
}
if spec.ObjType == "" {
return wikiNodeDeleteSpec{}, output.ErrValidation(
"--obj-type is required (one of: %s)",
strings.Join(wikiNodeDeleteObjTypes, ", "),
)
}
if !isValidWikiDeleteObjType(spec.ObjType) {
return wikiNodeDeleteSpec{}, output.ErrValidation(
"--obj-type %q is not valid; pick one of: %s",
spec.ObjType, strings.Join(wikiNodeDeleteObjTypes, ", "),
)
}
if err := validateOptionalResourceName(spec.NodeToken, "--node-token"); err != nil {
return wikiNodeDeleteSpec{}, err
}
if err := validateOptionalResourceName(spec.SpaceID, "--space-id"); err != nil {
return wikiNodeDeleteSpec{}, err
}
return spec, nil
}
func isValidWikiDeleteObjType(v string) bool {
for _, t := range wikiNodeDeleteObjTypes {
if v == t {
return true
}
}
return false
}
func buildWikiNodeDeleteDryRun(spec wikiNodeDeleteSpec) *common.DryRunAPI {
dry := common.NewDryRunAPI().Desc(
"async-aware: delete wiki node -> poll wiki delete-node task when task_id is returned (auto-resolves space_id via get_node when --space-id is omitted)",
)
if spec.SpaceID == "" {
params := map[string]interface{}{"token": spec.NodeToken}
if spec.ObjType != "" && spec.ObjType != "wiki" {
params["obj_type"] = spec.ObjType
}
dry.GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve space_id via get_node").
Params(params)
dry.DELETE(fmt.Sprintf(
"/open-apis/wiki/v2/spaces/%s/nodes/%s",
"<resolved_space_id>",
validate.EncodePathSegment(spec.NodeToken),
)).
Desc("[2] Delete wiki node").
Body(spec.RequestBody())
} else {
dry.DELETE(fmt.Sprintf(
"/open-apis/wiki/v2/spaces/%s/nodes/%s",
validate.EncodePathSegment(spec.SpaceID),
validate.EncodePathSegment(spec.NodeToken),
)).
Desc("[1] Delete wiki node").
Body(spec.RequestBody())
}
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
Desc("[N] Poll wiki delete-node task result when async").
Set("task_id", "<task_id>").
Params(map[string]interface{}{"task_type": wikiAsyncTaskTypeDeleteNode})
return dry
}
func runWikiNodeDelete(ctx context.Context, client wikiNodeDeleteClient, runtime *common.RuntimeContext, spec wikiNodeDeleteSpec) (map[string]interface{}, error) {
spaceID, err := resolveWikiNodeDeleteSpaceID(ctx, client, runtime, spec)
if err != nil {
return nil, err
}
fmt.Fprintf(runtime.IO().ErrOut, "Deleting wiki node %s in space %s (obj_type=%s, include_children=%t)...\n",
common.MaskToken(spec.NodeToken), common.MaskToken(spaceID), spec.ObjType, spec.IncludeChildren)
taskID, err := client.DeleteNode(ctx, spaceID, spec)
if err != nil {
return nil, err
}
out := map[string]interface{}{
"space_id": spaceID,
"node_token": spec.NodeToken,
"obj_type": spec.ObjType,
"include_children": spec.IncludeChildren,
}
// Empty task_id means the delete completed synchronously. Match the
// shape used by +delete-space so downstream scripts can read `status`
// uniformly regardless of which branch fired.
if taskID == "" {
out["ready"] = true
out["failed"] = false
out["status"] = wikiAsyncStatusSuccess
out["status_msg"] = wikiAsyncStatusSuccess
return out, nil
}
fmt.Fprintf(runtime.IO().ErrOut, "Wiki node delete is async, polling task %s...\n", taskID)
nextCommand := wikiDeleteNodeTaskResultCommand(taskID, runtime.As())
status, ready, err := pollWikiAsyncTask(
ctx, runtime, taskID, "delete-node",
wikiDeleteNodePollAttempts, wikiDeleteNodePollInterval,
func(ctx context.Context, id string) (wikiAsyncTaskStatus, error) {
return client.GetDeleteNodeTask(ctx, id)
},
nextCommand,
)
if err != nil {
return nil, err
}
out["task_id"] = taskID
out["ready"] = ready
out["failed"] = status.Failed()
out["status"] = status.StatusCode()
out["status_msg"] = status.StatusLabel()
if !ready {
fmt.Fprintf(runtime.IO().ErrOut, "Wiki delete-node task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
return out, nil
}
// resolveWikiNodeDeleteSpaceID returns the explicit space_id when the caller
// supplied one, otherwise resolves it via get_node. The latter saves callers
// from running +node-get first when they only have a node_token.
func resolveWikiNodeDeleteSpaceID(ctx context.Context, client wikiNodeDeleteClient, runtime *common.RuntimeContext, spec wikiNodeDeleteSpec) (string, error) {
if spec.SpaceID != "" {
return spec.SpaceID, nil
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolving space_id via get_node for token %s...\n", common.MaskToken(spec.NodeToken))
node, err := client.ResolveNode(ctx, spec.NodeToken, spec.ObjType)
if err != nil {
return "", err
}
spaceID, err := requireWikiNodeSpaceID(node)
if err != nil {
return "", err
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved to space %s\n", common.MaskToken(spaceID))
return spaceID, nil
}
func wikiDeleteNodeTaskResultCommand(taskID string, identity core.Identity) string {
asFlag := string(identity)
if asFlag == "" {
asFlag = "user"
}
return fmt.Sprintf("lark-cli drive +task_result --scenario wiki_delete_node --task-id %s --as %s", taskID, asFlag)
}
// wrapWikiNodeDeleteAPIError attaches actionable hints to the two Lark error
// codes whose remediation lives outside the CLI:
// - 131011: approval required (deletion gated by Wiki UI approval flow)
// - 131003: subtree too large to cascade-delete (must split or use
// include_children=false)
//
// Other codes pass through untouched so the generic error envelope still
// surfaces the original code+message.
func wrapWikiNodeDeleteAPIError(err error) error {
if err == nil {
return nil
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return err
}
var hint string
switch exitErr.Detail.Code {
case wikiDeleteNodeErrCodeApprovalRequired:
hint = "this wiki node has delete-approval enabled; ask the user to apply via the Wiki UI (CLI cannot bypass approval)"
case wikiDeleteNodeErrCodeSubtreeTooLarge:
hint = "the subtree is too large to cascade-delete in one call; pass --include-children=false to keep the children (they will be moved up to the parent), or delete sub-trees first"
}
if hint == "" {
return err
}
if existing := strings.TrimSpace(exitErr.Detail.Hint); existing != "" {
hint = existing + "\n" + hint
}
// ErrWithHint drops the upstream Detail.Code / Detail / Risk fields; build
// the ExitError by hand so the Lark error code stays available to logs and
// downstream pivots.
return &output.ExitError{
Code: exitErr.Code,
Detail: &output.ErrDetail{
Type: exitErr.Detail.Type,
Code: exitErr.Detail.Code,
Message: exitErr.Detail.Message,
Hint: hint,
ConsoleURL: exitErr.Detail.ConsoleURL,
Risk: exitErr.Detail.Risk,
Detail: exitErr.Detail.Detail,
},
}
}

View File

@@ -0,0 +1,611 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"reflect"
"strings"
"sync"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// ── parseWikiNodeDeleteSpec ─────────────────────────────────────────────────
func TestParseWikiNodeDeleteSpecAcceptsRawWikiToken(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeDeleteSpec("wikcnABC", "wiki", "", true)
if err != nil {
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
}
if spec.NodeToken != "wikcnABC" || spec.ObjType != "wiki" || spec.SourceKind != "raw" || !spec.IncludeChildren {
t.Fatalf("spec = %+v", spec)
}
body := spec.RequestBody()
if body["obj_type"] != "wiki" || body["include_children"] != true {
t.Fatalf("RequestBody = %#v", body)
}
}
func TestParseWikiNodeDeleteSpecRejectsMissingObjType(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeDeleteSpec("wikcnABC", "", "", true)
if err == nil || !strings.Contains(err.Error(), "--obj-type is required") {
t.Fatalf("expected obj-type required error, got %v", err)
}
}
func TestParseWikiNodeDeleteSpecRejectsInvalidObjType(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeDeleteSpec("wikcnABC", "comment", "", true)
if err == nil || !strings.Contains(err.Error(), "is not valid") {
t.Fatalf("expected invalid obj-type error, got %v", err)
}
}
func TestParseWikiNodeDeleteSpecRejectsEmptyToken(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeDeleteSpec(" ", "wiki", "", true)
if err == nil || !strings.Contains(err.Error(), "--node-token is required") {
t.Fatalf("expected token required error, got %v", err)
}
}
func TestParseWikiNodeDeleteSpecExtractsTokenFromWikiURL(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeDeleteSpec("https://feishu.cn/wiki/wikcnABC", "", "", true)
if err != nil {
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
}
if spec.NodeToken != "wikcnABC" || spec.ObjType != "wiki" || spec.SourceKind != "url" {
t.Fatalf("spec = %+v, want url-extracted node_token + obj_type=wiki", spec)
}
}
func TestParseWikiNodeDeleteSpecInfersObjTypeFromDocxURL(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeDeleteSpec("https://feishu.cn/docx/docxXYZ", "", "", false)
if err != nil {
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
}
if spec.NodeToken != "docxXYZ" || spec.ObjType != "docx" || spec.IncludeChildren {
t.Fatalf("spec = %+v, want docxXYZ obj_type=docx include_children=false", spec)
}
}
func TestParseWikiNodeDeleteSpecRejectsURLObjTypeMismatch(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeDeleteSpec("https://feishu.cn/docx/docxXYZ", "wiki", "", true)
if err == nil || !strings.Contains(err.Error(), "does not match the obj_type") {
t.Fatalf("expected obj-type mismatch error, got %v", err)
}
}
func TestParseWikiNodeDeleteSpecRejectsPartialPath(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeDeleteSpec("/wiki/wikcnABC", "wiki", "", true)
if err == nil || !strings.Contains(err.Error(), "partial paths are not accepted") {
t.Fatalf("expected partial-path rejection, got %v", err)
}
}
// ── DryRun ──────────────────────────────────────────────────────────────────
func TestBuildWikiNodeDeleteDryRunWithoutSpaceIDShowsResolve(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeDeleteSpec("https://feishu.cn/docx/docxXYZ", "", "", true)
if err != nil {
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
}
dry := buildWikiNodeDeleteDryRun(spec)
got := decodeDryRunAPIs(t, dry)
if len(got) != 3 {
t.Fatalf("len(dry.api) = %d, want 3 (get_node, delete, task poll)", len(got))
}
if got[0].URL != "/open-apis/wiki/v2/spaces/get_node" {
t.Fatalf("step[0].URL = %q, want get_node", got[0].URL)
}
if got[0].Params["obj_type"] != "docx" || got[0].Params["token"] != "docxXYZ" {
t.Fatalf("step[0].params = %#v", got[0].Params)
}
if got[1].URL != "/open-apis/wiki/v2/spaces/<resolved_space_id>/nodes/docxXYZ" {
t.Fatalf("step[1].URL = %q, want delete with placeholder", got[1].URL)
}
if got[1].Body["obj_type"] != "docx" || got[1].Body["include_children"] != true {
t.Fatalf("step[1].body = %#v", got[1].Body)
}
if got[2].Params["task_type"] != "delete_node" {
t.Fatalf("step[2].params task_type = %#v, want delete_node", got[2].Params)
}
}
func TestBuildWikiNodeDeleteDryRunWithSpaceIDOmitsResolve(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeDeleteSpec("wikcnABC", "wiki", "7629741305993170448", false)
if err != nil {
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
}
dry := buildWikiNodeDeleteDryRun(spec)
got := decodeDryRunAPIs(t, dry)
if len(got) != 2 {
t.Fatalf("len(dry.api) = %d, want 2 (delete + task poll) when --space-id supplied", len(got))
}
if got[0].Method != "DELETE" || got[0].URL != "/open-apis/wiki/v2/spaces/7629741305993170448/nodes/wikcnABC" {
t.Fatalf("step[0] = %+v", got[0])
}
if got[0].Body["include_children"] != false {
t.Fatalf("body include_children = %#v", got[0].Body["include_children"])
}
}
// ── runWikiNodeDelete unit ──────────────────────────────────────────────────
type fakeWikiNodeDeleteClient struct {
resolveErr error
resolveNode *wikiNodeRecord
resolveCalls []string
deleteErr error
deleteTaskID string
deleteCalls []struct {
SpaceID string
Spec wikiNodeDeleteSpec
}
taskStatuses []wikiAsyncTaskStatus
taskErrs []error
taskCallArgs []string
}
func (fake *fakeWikiNodeDeleteClient) ResolveNode(ctx context.Context, token, objType string) (*wikiNodeRecord, error) {
fake.resolveCalls = append(fake.resolveCalls, token)
if fake.resolveErr != nil {
return nil, fake.resolveErr
}
return fake.resolveNode, nil
}
func (fake *fakeWikiNodeDeleteClient) DeleteNode(ctx context.Context, spaceID string, spec wikiNodeDeleteSpec) (string, error) {
fake.deleteCalls = append(fake.deleteCalls, struct {
SpaceID string
Spec wikiNodeDeleteSpec
}{SpaceID: spaceID, Spec: spec})
if fake.deleteErr != nil {
return "", fake.deleteErr
}
return fake.deleteTaskID, nil
}
func (fake *fakeWikiNodeDeleteClient) GetDeleteNodeTask(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error) {
idx := len(fake.taskCallArgs)
fake.taskCallArgs = append(fake.taskCallArgs, taskID)
if idx < len(fake.taskErrs) && fake.taskErrs[idx] != nil {
return wikiAsyncTaskStatus{TaskID: taskID}, fake.taskErrs[idx]
}
if idx < len(fake.taskStatuses) {
status := fake.taskStatuses[idx]
if status.TaskID == "" {
status.TaskID = taskID
}
return status, nil
}
return wikiAsyncTaskStatus{TaskID: taskID}, nil
}
var wikiDeleteNodePollMu sync.Mutex
func withSingleWikiDeleteNodePoll(t *testing.T) {
t.Helper()
wikiDeleteNodePollMu.Lock()
prevAttempts, prevInterval := wikiDeleteNodePollAttempts, wikiDeleteNodePollInterval
wikiDeleteNodePollAttempts, wikiDeleteNodePollInterval = 1, 0
t.Cleanup(func() {
wikiDeleteNodePollAttempts, wikiDeleteNodePollInterval = prevAttempts, prevInterval
wikiDeleteNodePollMu.Unlock()
})
}
func newWikiNodeDeleteRuntime(t *testing.T, as core.Identity) (*common.RuntimeContext, *bytes.Buffer) {
t.Helper()
cfg := wikiTestConfig()
factory, _, stderr, _ := cmdutil.TestFactory(t, cfg)
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "wiki +node-delete"}, cfg, as)
runtime.Factory = factory
return runtime, stderr
}
func TestRunWikiNodeDeleteResolvesSpaceWhenMissing(t *testing.T) {
t.Parallel()
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
client := &fakeWikiNodeDeleteClient{
resolveNode: &wikiNodeRecord{SpaceID: "space_resolved"},
}
out, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
NodeToken: "wikcnABC",
ObjType: "wiki",
IncludeChildren: true,
})
if err != nil {
t.Fatalf("runWikiNodeDelete() error = %v", err)
}
if len(client.resolveCalls) != 1 || client.resolveCalls[0] != "wikcnABC" {
t.Fatalf("resolve calls = %v", client.resolveCalls)
}
if len(client.deleteCalls) != 1 || client.deleteCalls[0].SpaceID != "space_resolved" {
t.Fatalf("delete calls = %+v", client.deleteCalls)
}
if out["space_id"] != "space_resolved" || out["ready"] != true || out["status"] != "success" {
t.Fatalf("sync output = %#v", out)
}
}
func TestRunWikiNodeDeleteSkipsResolveWhenSpaceProvided(t *testing.T) {
t.Parallel()
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
client := &fakeWikiNodeDeleteClient{}
_, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
NodeToken: "wikcnABC", ObjType: "wiki", SpaceID: "space_explicit",
})
if err != nil {
t.Fatalf("runWikiNodeDelete() error = %v", err)
}
if len(client.resolveCalls) != 0 {
t.Fatalf("resolveCalls should be empty when --space-id supplied, got %v", client.resolveCalls)
}
if client.deleteCalls[0].SpaceID != "space_explicit" {
t.Fatalf("delete used wrong space: %+v", client.deleteCalls)
}
}
func TestRunWikiNodeDeleteAsyncReadyShape(t *testing.T) {
withSingleWikiDeleteNodePoll(t)
runtime, stderr := newWikiNodeDeleteRuntime(t, core.AsUser)
client := &fakeWikiNodeDeleteClient{
deleteTaskID: "task_async_node",
taskStatuses: []wikiAsyncTaskStatus{{Status: "success"}},
}
out, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
NodeToken: "wikcnABC", ObjType: "wiki", SpaceID: "space_123", IncludeChildren: true,
})
if err != nil {
t.Fatalf("runWikiNodeDelete() error = %v", err)
}
if out["task_id"] != "task_async_node" || out["ready"] != true || out["failed"] != false {
t.Fatalf("async-ready output = %#v", out)
}
if !strings.Contains(stderr.String(), "async, polling task") || !strings.Contains(stderr.String(), "delete-node task completed successfully") {
t.Fatalf("stderr = %q", stderr.String())
}
}
func TestRunWikiNodeDeleteAsyncTimeoutReturnsNextCommand(t *testing.T) {
withSingleWikiDeleteNodePoll(t)
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
client := &fakeWikiNodeDeleteClient{
deleteTaskID: "task_async_node",
taskStatuses: []wikiAsyncTaskStatus{{Status: "processing"}},
}
out, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
NodeToken: "wikcnABC", ObjType: "wiki", SpaceID: "space_123",
})
if err != nil {
t.Fatalf("runWikiNodeDelete() error = %v", err)
}
wantNext := wikiDeleteNodeTaskResultCommand("task_async_node", core.AsUser)
if out["ready"] != false || out["timed_out"] != true || out["next_command"] != wantNext {
t.Fatalf("timeout output = %#v", out)
}
if !strings.Contains(wantNext, "wiki_delete_node") {
t.Fatalf("next command should scope wiki_delete_node, got %q", wantNext)
}
}
func TestRunWikiNodeDeleteAsyncFailureSurfacesReason(t *testing.T) {
withSingleWikiDeleteNodePoll(t)
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
client := &fakeWikiNodeDeleteClient{
deleteTaskID: "task_async_node",
taskStatuses: []wikiAsyncTaskStatus{{Status: "failure", StatusMsg: "permission denied"}},
}
_, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
NodeToken: "wikcnABC", ObjType: "wiki", SpaceID: "space_123",
})
if err == nil || !strings.Contains(err.Error(), "delete-node task task_async_node failed: permission denied") {
t.Fatalf("expected async failure error, got %v", err)
}
}
// ── error code hint mapping ─────────────────────────────────────────────────
func TestWrapWikiNodeDeleteAPIErrorAddsApprovalHint(t *testing.T) {
t.Parallel()
in := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "api_error",
Code: wikiDeleteNodeErrCodeApprovalRequired,
Message: "node requires delete approval",
},
}
got := wrapWikiNodeDeleteAPIError(in)
var exitErr *output.ExitError
if !errors.As(got, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError, got %T %v", got, got)
}
if !strings.Contains(exitErr.Detail.Hint, "delete-approval enabled") || !strings.Contains(exitErr.Detail.Hint, "Wiki UI") {
t.Fatalf("hint = %q, want approval guidance", exitErr.Detail.Hint)
}
// Original code/message must be preserved so logs and dashboards still
// pivot on the upstream error code.
if exitErr.Detail.Code != wikiDeleteNodeErrCodeApprovalRequired {
t.Fatalf("hint wrapper lost the original code: %d", exitErr.Detail.Code)
}
if exitErr.Detail.Message != "node requires delete approval" {
t.Fatalf("message changed unexpectedly: %q", exitErr.Detail.Message)
}
}
func TestWrapWikiNodeDeleteAPIErrorAddsSubtreeHint(t *testing.T) {
t.Parallel()
in := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "api_error",
Code: wikiDeleteNodeErrCodeSubtreeTooLarge,
Message: "subtree too large",
},
}
got := wrapWikiNodeDeleteAPIError(in)
var exitErr *output.ExitError
if !errors.As(got, &exitErr) {
t.Fatalf("expected ExitError, got %T %v", got, got)
}
if !strings.Contains(exitErr.Detail.Hint, "--include-children=false") {
t.Fatalf("hint = %q, want subtree-too-large guidance", exitErr.Detail.Hint)
}
}
func TestWrapWikiNodeDeleteAPIErrorPassesThroughUnknownCodes(t *testing.T) {
t.Parallel()
in := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "api_error", Code: 131005, Message: "node not found"},
}
got := wrapWikiNodeDeleteAPIError(in)
if !reflect.DeepEqual(got, in) {
t.Fatalf("unknown code should pass through; got %#v", got)
}
}
func TestWrapWikiNodeDeleteAPIErrorIgnoresNonExit(t *testing.T) {
t.Parallel()
in := errors.New("transport boom")
if got := wrapWikiNodeDeleteAPIError(in); got != in {
t.Fatalf("non-ExitError should pass through, got %T %v", got, got)
}
if got := wrapWikiNodeDeleteAPIError(nil); got != nil {
t.Fatalf("nil should pass through, got %v", got)
}
}
// ── Mounted execute (httpmock) ──────────────────────────────────────────────
func TestWikiNodeDeleteExecuteRequiresYesConfirmation(t *testing.T) {
factory, stdout, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiNodeDelete, []string{
"+node-delete",
"--node-token", "wikcnABC",
"--obj-type", "wiki",
"--space-id", "space_123",
"--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
t.Fatalf("expected high-risk confirmation error, got %v", err)
}
}
func TestWikiNodeDeleteExecuteSync(t *testing.T) {
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
deleteStub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes/wikcnABC",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
"msg": "success",
},
}
reg.Register(deleteStub)
err := mountAndRunWiki(t, WikiNodeDelete, []string{
"+node-delete",
"--node-token", "wikcnABC",
"--obj-type", "wiki",
"--space-id", "space_123",
"--yes",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
if data["ready"] != true || data["failed"] != false || data["space_id"] != "space_123" {
t.Fatalf("sync output = %#v", data)
}
if data["obj_type"] != "wiki" || data["include_children"] != true {
t.Fatalf("obj_type/include_children = %#v / %#v", data["obj_type"], data["include_children"])
}
var captured map[string]interface{}
if err := json.Unmarshal(deleteStub.CapturedBody, &captured); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if captured["obj_type"] != "wiki" || captured["include_children"] != true {
t.Fatalf("captured DELETE body = %#v", captured)
}
}
func TestWikiNodeDeleteExecuteResolvesSpaceIDFromURL(t *testing.T) {
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
resolveStub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_resolved",
"node_token": "wikcnABC",
"obj_token": "docxXYZ",
"obj_type": "docx",
},
},
},
}
var resolveQuery string
resolveStub.OnMatch = func(req *http.Request) { resolveQuery = req.URL.RawQuery }
reg.Register(resolveStub)
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/wiki/v2/spaces/space_resolved/nodes/docxXYZ",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
},
})
err := mountAndRunWiki(t, WikiNodeDelete, []string{
"+node-delete",
"--node-token", "https://feishu.cn/docx/docxXYZ",
"--yes",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
if !strings.Contains(resolveQuery, "token=docxXYZ") || !strings.Contains(resolveQuery, "obj_type=docx") {
t.Fatalf("resolve query = %q, want token+obj_type", resolveQuery)
}
data := decodeWikiEnvelope(t, stdout)
if data["space_id"] != "space_resolved" || data["obj_type"] != "docx" {
t.Fatalf("output = %#v", data)
}
}
func TestWikiNodeDeleteExecuteAsyncSuccess(t *testing.T) {
withSingleWikiDeleteNodePoll(t)
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes/wikcnABC",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task_id": "task_async_node"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/tasks/task_async_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"task": map[string]interface{}{
// Gateway returns delete-node status under the generic
// simple_task_result key (NOT delete_node_result).
"simple_task_result": map[string]interface{}{
"status": "success",
},
},
},
},
})
err := mountAndRunWiki(t, WikiNodeDelete, []string{
"+node-delete",
"--node-token", "wikcnABC",
"--obj-type", "wiki",
"--space-id", "space_123",
"--yes",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
if data["task_id"] != "task_async_node" || data["ready"] != true || data["failed"] != false {
t.Fatalf("async-success output = %#v", data)
}
}
// ── helpers ─────────────────────────────────────────────────────────────────
type dryRunStep struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
Params map[string]interface{} `json:"params"`
}
func decodeDryRunAPIs(t *testing.T, dry *common.DryRunAPI) []dryRunStep {
t.Helper()
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []dryRunStep `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run: %v", err)
}
return got.API
}

View File

@@ -0,0 +1,370 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"fmt"
"io"
"net/url"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// wikiNodeGetURLObjTypes maps a Lark URL path prefix (slash-bounded) to the
// obj_type the wiki get_node API expects when the token is an obj_token.
// /wiki/ is handled separately because node_tokens take no obj_type.
//
// INVARIANT: the prefixes must be mutually exclusive (no prefix may be a
// prefix of another). tokenAndObjTypeFromWikiURL ranges this map, and Go map
// iteration order is randomized — overlapping prefixes would make the match
// non-deterministic. The trailing slash keeps them disjoint today (e.g.
// "/docx/" does not start with "/doc/"); preserve that when adding entries.
var wikiNodeGetURLObjTypes = map[string]string{
"/docx/": "docx",
"/doc/": "doc",
"/sheets/": "sheet",
"/base/": "bitable",
"/mindnote/": "mindnote",
"/slides/": "slides",
"/file/": "file",
}
// wikiNodeGetObjTypeEnum is the union of obj_types accepted by the upstream
// API. It is a superset of the create / move enums so this shortcut can look
// up legacy `doc` nodes too.
var wikiNodeGetObjTypeEnum = []string{
"doc", "docx", "sheet", "bitable", "mindnote", "slides", "file",
}
// WikiNodeGet wraps wiki.spaces.get_node so callers can resolve a node by
// node_token, obj_token, or a Lark URL without hand-rolling a
// `wiki spaces get_node --params ...` invocation. The shortcut prints a
// formatted view of the node (title / obj_type / obj_token / parent /
// creator / updated_at) and is intended as the "what am I about to
// touch?" step before +move / +node-copy / +delete-space.
var WikiNodeGet = common.Shortcut{
Service: "wiki",
Command: "+node-get",
Description: "Get wiki node details by node_token, obj_token, or Lark URL",
Risk: "read",
Scopes: []string{"wiki:node:retrieve"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "token", Desc: "wiki node_token, obj_token, or a Lark URL embedding one of them", Required: true},
{Name: "obj-type", Desc: "obj_type when --token is an obj_token; auto-inferred from URL path when omitted", Enum: wikiNodeGetObjTypeEnum},
{Name: "space-id", Desc: "optional: assert the resolved node lives in this space"},
},
Tips: []string{
"--token accepts a raw token (wikcnXXX, docxXXX, ...) or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>.",
"For raw obj_tokens (not starting with wik), pass --obj-type so the API knows how to resolve them; URL inputs infer it from the path.",
"Pair with +move / +node-copy / +delete-space to confirm space_id, obj_type, and parent before mutating.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readWikiNodeGetSpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readWikiNodeGetSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildWikiNodeGetDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readWikiNodeGetSpec(runtime)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Fetching wiki node %s...\n", common.MaskToken(spec.Token))
data, err := runtime.CallAPI("GET", "/open-apis/wiki/v2/spaces/get_node", spec.RequestParams(), nil)
if err != nil {
return err
}
raw := common.GetMap(data, "node")
node, err := parseWikiNodeRecord(raw)
if err != nil {
return err
}
if spec.SpaceID != "" && node.SpaceID != "" && spec.SpaceID != node.SpaceID {
return output.ErrValidation(
"--space-id %q does not match the resolved node space %q (node_token=%s)",
spec.SpaceID, node.SpaceID, node.NodeToken,
)
}
if spec.SpaceID != "" && node.SpaceID == "" {
// The cross-check was requested but get_node returned no space_id,
// so it silently passed. Surface that the assertion was a no-op
// rather than letting the caller assume it was verified.
fmt.Fprintf(runtime.IO().ErrOut,
"Warning: --space-id %q could not be verified; the resolved node carries no space_id.\n",
spec.SpaceID)
}
out := wikiNodeGetOutput(node, raw)
runtime.OutFormat(out, nil, func(w io.Writer) {
renderWikiNodeGetPretty(w, out)
})
return nil
},
}
// wikiNodeGetSpec is the normalized input for the shortcut.
type wikiNodeGetSpec struct {
// Token is the resolved token (after URL extraction) to send to the API.
Token string
// ObjType is the resolved obj_type. Empty for node_tokens (the API does
// not need obj_type for `wik`-prefixed tokens).
ObjType string
// SpaceID is an optional cross-check; when set, the response space_id must match.
SpaceID string
// SourceKind records how Token was derived for the dry-run description:
// "url-wiki", "url-obj", "raw-node", "raw-obj".
SourceKind string
}
// RequestParams returns the query params for GET /wiki/v2/spaces/get_node.
func (spec wikiNodeGetSpec) RequestParams() map[string]interface{} {
params := map[string]interface{}{"token": spec.Token}
if spec.ObjType != "" {
params["obj_type"] = spec.ObjType
}
return params
}
func readWikiNodeGetSpec(runtime *common.RuntimeContext) (wikiNodeGetSpec, error) {
return parseWikiNodeGetSpec(
runtime.Str("token"),
runtime.Str("obj-type"),
runtime.Str("space-id"),
)
}
// parseWikiNodeGetSpec normalizes the raw flag values: extracts a token from a
// URL when needed, picks the obj_type (URL path > explicit flag > none for
// node_tokens), and validates the token shape.
func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetSpec, error) {
tokenInput := strings.TrimSpace(rawToken)
if tokenInput == "" {
return wikiNodeGetSpec{}, output.ErrValidation("--token is required")
}
spec := wikiNodeGetSpec{
ObjType: strings.ToLower(strings.TrimSpace(rawObjType)),
SpaceID: strings.TrimSpace(rawSpaceID),
}
if strings.Contains(tokenInput, "://") {
u, err := url.Parse(tokenInput)
if err != nil || u.Path == "" {
return wikiNodeGetSpec{}, output.ErrValidation("--token URL is malformed: %q", tokenInput)
}
token, urlObjType, ok := tokenAndObjTypeFromWikiURL(u.Path)
if !ok {
return wikiNodeGetSpec{}, output.ErrValidation(
"unsupported --token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token",
u.Path,
)
}
spec.Token = token
if urlObjType == "" {
spec.SourceKind = "url-wiki"
} else {
spec.SourceKind = "url-obj"
}
switch {
case spec.ObjType == "" && urlObjType != "":
spec.ObjType = urlObjType
case spec.ObjType != "" && urlObjType != "" && spec.ObjType != urlObjType:
return wikiNodeGetSpec{}, output.ErrValidation(
"--obj-type %q does not match the obj_type %q implied by the URL path; pass only one",
spec.ObjType, urlObjType,
)
}
} else if strings.ContainsAny(tokenInput, "/?#") {
return wikiNodeGetSpec{}, output.ErrValidation(
"--token must be a raw token or a full URL; partial paths are not accepted: %q",
tokenInput,
)
} else {
spec.Token = tokenInput
if looksLikeWikiNodeToken(spec.Token) {
spec.SourceKind = "raw-node"
// node_tokens take no obj_type; reject a conflicting flag rather
// than silently passing it (the API would just ignore it, but the
// mismatch signals caller confusion).
if spec.ObjType != "" {
return wikiNodeGetSpec{}, output.ErrValidation(
"--obj-type is only valid for obj_tokens; %q looks like a node_token",
spec.Token,
)
}
} else {
spec.SourceKind = "raw-obj"
// A raw obj_token needs an explicit obj_type: get_node would
// otherwise default to "doc" and fail confusingly for docx /
// sheet / bitable / ... Fail fast with the same upfront contract
// as +node-delete instead of deferring to an opaque API error.
if spec.ObjType == "" {
return wikiNodeGetSpec{}, output.ErrValidation(
"--obj-type is required for a raw obj_token %q (one of: %s); or pass a typed Lark URL (e.g. /docx/<token>) so it can be inferred",
spec.Token, strings.Join(wikiNodeGetObjTypeEnum, ", "),
)
}
}
}
if err := validateOptionalResourceName(spec.Token, "--token"); err != nil {
return wikiNodeGetSpec{}, err
}
if err := validateOptionalResourceName(spec.SpaceID, "--space-id"); err != nil {
return wikiNodeGetSpec{}, err
}
return spec, nil
}
// looksLikeWikiNodeToken returns true when the token has the `wik` prefix used
// for node_tokens. Lark wiki tokens are case-insensitive in practice; callers
// pass `wikcn`/`wikus`/`Wik...` interchangeably, so normalize for the check.
//
// This is a heuristic based on the current Lark token-naming convention, not a
// guaranteed invariant: if Lark ever introduces a non-node token type that
// also starts with `wik`, it would be misclassified. Worst case is a
// confusing API error (no data risk); revisit if the token scheme changes.
func looksLikeWikiNodeToken(token string) bool {
return strings.HasPrefix(strings.ToLower(token), "wik")
}
// tokenAndObjTypeFromWikiURL extracts the token and inferred obj_type from a
// Lark URL path. The wiki path returns an empty obj_type because node_tokens
// don't need one.
func tokenAndObjTypeFromWikiURL(path string) (token, objType string, ok bool) {
if t, found := wikiPathSegmentAfter(path, "/wiki/"); found {
return t, "", true
}
for prefix, ot := range wikiNodeGetURLObjTypes {
if t, found := wikiPathSegmentAfter(path, prefix); found {
return t, ot, true
}
}
return "", "", false
}
// wikiPathSegmentAfter returns the first path segment after prefix, or ("",
// false) when path doesn't start with prefix or the segment is empty.
func wikiPathSegmentAfter(path, prefix string) (string, bool) {
if !strings.HasPrefix(path, prefix) {
return "", false
}
rest := path[len(prefix):]
if i := strings.IndexByte(rest, '/'); i >= 0 {
rest = rest[:i]
}
rest = strings.TrimSpace(rest)
if rest == "" {
return "", false
}
return rest, true
}
func buildWikiNodeGetDryRun(spec wikiNodeGetSpec) *common.DryRunAPI {
dry := common.NewDryRunAPI()
switch spec.SourceKind {
case "url-wiki":
dry.Desc("Resolve wiki node from /wiki/ URL")
case "url-obj":
dry.Desc("Resolve wiki node from Lark document URL (obj_type inferred from path)")
case "raw-node":
dry.Desc("Look up wiki node by node_token")
case "raw-obj":
dry.Desc("Look up wiki node by obj_token")
}
return dry.GET("/open-apis/wiki/v2/spaces/get_node").Params(spec.RequestParams())
}
// wikiNodeGetOutput shapes the structured output. It carries the formatted
// values (title/obj_type/obj_token/parent_node_token/creator/updated_at)
// the user asked for, plus enough raw fields (node_type, has_child, owner,
// timestamps) that callers can pipe into +move / +node-copy without rerunning
// get_node.
//
// No synthesized `url` is emitted: get_node returns none, and a
// BuildResourceURL fallback (www.feishu.cn/wiki/<node_token>) is a
// non-canonical link that misleads in a read/confirm command. Sibling read
// shortcuts (+node-list, +node-copy) likewise omit it; node_token/obj_token
// are the precise identifiers.
func wikiNodeGetOutput(node *wikiNodeRecord, raw map[string]interface{}) map[string]interface{} {
out := map[string]interface{}{
"space_id": node.SpaceID,
"node_token": node.NodeToken,
"obj_token": node.ObjToken,
"obj_type": node.ObjType,
"node_type": node.NodeType,
"parent_node_token": node.ParentNodeToken,
"origin_node_token": node.OriginNodeToken,
"title": node.Title,
"has_child": node.HasChild,
}
creator := strings.TrimSpace(common.GetString(raw, "node_creator"))
if creator == "" {
creator = strings.TrimSpace(common.GetString(raw, "creator"))
}
out["creator"] = creator
out["owner"] = common.GetString(raw, "owner")
objEditRaw := common.GetString(raw, "obj_edit_time")
out["obj_edit_time"] = objEditRaw
out["obj_create_time"] = common.GetString(raw, "obj_create_time")
out["node_create_time"] = common.GetString(raw, "node_create_time")
out["updated_at"] = formatWikiTimestamp(objEditRaw)
return out
}
// formatWikiTimestamp turns a Lark unix-seconds string (the format used by
// wiki.spaces.get_node) into a UTC RFC3339 string. UTC (not the host's local
// zone) keeps the output stable regardless of where the CLI runs. Returns ""
// when the input is empty or not numeric so the pretty renderer falls back
// to "-".
func formatWikiTimestamp(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
secs, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return ""
}
return time.Unix(secs, 0).UTC().Format(time.RFC3339)
}
func renderWikiNodeGetPretty(w io.Writer, out map[string]interface{}) {
fmt.Fprintln(w, "Wiki node:")
fmt.Fprintf(w, " title: %s\n", valueOrDash(out["title"]))
fmt.Fprintf(w, " obj_type: %s\n", valueOrDash(out["obj_type"]))
fmt.Fprintf(w, " obj_token: %s\n", valueOrDash(out["obj_token"]))
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, " parent_node_token: %s\n", valueOrDash(out["parent_node_token"]))
fmt.Fprintf(w, " node_type: %s\n", valueOrDash(out["node_type"]))
if origin, _ := out["origin_node_token"].(string); origin != "" {
fmt.Fprintf(w, " origin_node_token: %s\n", origin)
}
hasChild, _ := out["has_child"].(bool)
fmt.Fprintf(w, " has_child: %t\n", hasChild)
fmt.Fprintf(w, " creator: %s\n", valueOrDash(out["creator"]))
if owner, _ := out["owner"].(string); owner != "" {
fmt.Fprintf(w, " owner: %s\n", owner)
}
fmt.Fprintf(w, " updated_at: %s\n", valueOrDash(out["updated_at"]))
}

View File

@@ -0,0 +1,321 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"encoding/json"
"net/http"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestParseWikiNodeGetSpecRawNodeToken(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeGetSpec("wikcnABC", "", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
if spec.Token != "wikcnABC" || spec.ObjType != "" || spec.SourceKind != "raw-node" {
t.Fatalf("spec = %+v, want raw-node wikcnABC with no obj_type", spec)
}
if got := spec.RequestParams(); !reflect.DeepEqual(got, map[string]interface{}{"token": "wikcnABC"}) {
t.Fatalf("RequestParams() = %v, want {token: wikcnABC}", got)
}
}
func TestParseWikiNodeGetSpecRawObjTokenWithExplicitObjType(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeGetSpec("docxXYZ", "docx", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
if spec.Token != "docxXYZ" || spec.ObjType != "docx" || spec.SourceKind != "raw-obj" {
t.Fatalf("spec = %+v, want raw-obj docxXYZ obj_type=docx", spec)
}
}
func TestParseWikiNodeGetSpecRejectsRawObjTokenWithoutObjType(t *testing.T) {
t.Parallel()
// Mirrors +node-delete: a raw obj_token with no --obj-type must fail
// upfront instead of defaulting to "doc" and hitting an opaque API error.
_, err := parseWikiNodeGetSpec("bascnXYZ", "", "")
if err == nil || !strings.Contains(err.Error(), "--obj-type is required for a raw obj_token") {
t.Fatalf("expected raw obj_token obj-type-required error, got %v", err)
}
}
func TestParseWikiNodeGetSpecRejectsObjTypeOnNodeToken(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeGetSpec("wikcnABC", "docx", "")
if err == nil || !strings.Contains(err.Error(), "only valid for obj_tokens") {
t.Fatalf("expected node_token + obj_type rejection, got %v", err)
}
}
func TestParseWikiNodeGetSpecExtractsTokenFromWikiURL(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeGetSpec("https://feishu.cn/wiki/wikcnABC?foo=bar", "", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
if spec.Token != "wikcnABC" || spec.ObjType != "" || spec.SourceKind != "url-wiki" {
t.Fatalf("spec = %+v, want url-wiki wikcnABC", spec)
}
}
func TestParseWikiNodeGetSpecExtractsTokenAndObjTypeFromDocxURL(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeGetSpec("https://feishu.cn/docx/docxXYZ", "", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
if spec.Token != "docxXYZ" || spec.ObjType != "docx" || spec.SourceKind != "url-obj" {
t.Fatalf("spec = %+v, want url-obj docxXYZ", spec)
}
}
func TestParseWikiNodeGetSpecRejectsURLObjTypeMismatch(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeGetSpec("https://feishu.cn/sheets/shtXYZ", "docx", "")
if err == nil || !strings.Contains(err.Error(), "does not match the obj_type") {
t.Fatalf("expected URL/obj-type mismatch error, got %v", err)
}
}
func TestParseWikiNodeGetSpecRejectsUnsupportedURLPath(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeGetSpec("https://feishu.cn/im/chat/oc_123", "", "")
if err == nil || !strings.Contains(err.Error(), "unsupported --token URL path") {
t.Fatalf("expected unsupported URL path error, got %v", err)
}
}
func TestParseWikiNodeGetSpecRejectsPartialPath(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeGetSpec("/wiki/wikcnABC", "", "")
if err == nil || !strings.Contains(err.Error(), "partial paths are not accepted") {
t.Fatalf("expected partial-path rejection, got %v", err)
}
}
func TestParseWikiNodeGetSpecRejectsEmptyToken(t *testing.T) {
t.Parallel()
if _, err := parseWikiNodeGetSpec(" ", "", ""); err == nil || !strings.Contains(err.Error(), "--token is required") {
t.Fatalf("expected required-token error, got %v", err)
}
}
func TestBuildWikiNodeGetDryRunSendsObjType(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeGetSpec("https://feishu.cn/docx/docxXYZ", "", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
dry := buildWikiNodeGetDryRun(spec)
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run: %v", err)
}
if len(got.API) != 1 || got.API[0].URL != "/open-apis/wiki/v2/spaces/get_node" {
t.Fatalf("dry-run api = %#v, want single get_node call", got.API)
}
if got.API[0].Params["token"] != "docxXYZ" || got.API[0].Params["obj_type"] != "docx" {
t.Fatalf("dry-run params = %#v", got.API[0].Params)
}
}
func TestFormatWikiTimestamp(t *testing.T) {
t.Parallel()
if got := formatWikiTimestamp(""); got != "" {
t.Fatalf("formatWikiTimestamp(empty) = %q, want empty", got)
}
if got := formatWikiTimestamp("not-a-number"); got != "" {
t.Fatalf("formatWikiTimestamp(non-numeric) = %q, want empty", got)
}
// Output is UTC, so it is deterministic regardless of host timezone.
if got := formatWikiTimestamp("1700000000"); got != "2023-11-14T22:13:20Z" {
t.Fatalf("formatWikiTimestamp(1700000000) = %q, want 2023-11-14T22:13:20Z (UTC)", got)
}
}
func TestWikiNodeGetMountedExecuteParsesURLAndFormatsOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, stderr, reg := cmdutil.TestFactory(t, wikiTestConfig())
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_123",
"node_token": "wikcnABC",
"obj_token": "docxXYZ",
"obj_type": "docx",
"parent_node_token": "wikcnPARENT",
"node_type": "origin",
"title": "Design Spec",
"has_child": true,
"node_creator": "ou_creator",
"owner": "ou_owner",
"obj_edit_time": "1700000000",
"obj_create_time": "1690000000",
"node_create_time": "1690000001",
},
},
"msg": "success",
},
}
var capturedQuery string
stub.OnMatch = func(req *http.Request) {
capturedQuery = req.URL.RawQuery
}
reg.Register(stub)
err := mountAndRunWiki(t, WikiNodeGet, []string{
"+node-get",
"--token", "https://feishu.cn/docx/docxXYZ",
"--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
if !strings.Contains(capturedQuery, "token=docxXYZ") || !strings.Contains(capturedQuery, "obj_type=docx") {
t.Fatalf("captured query = %q, want token=docxXYZ and obj_type=docx", capturedQuery)
}
data := decodeWikiEnvelope(t, stdout)
if data["title"] != "Design Spec" {
t.Fatalf("title = %#v, want Design Spec", data["title"])
}
if data["obj_type"] != "docx" || data["obj_token"] != "docxXYZ" {
t.Fatalf("obj_type/obj_token = %#v / %#v", data["obj_type"], data["obj_token"])
}
if data["parent_node_token"] != "wikcnPARENT" {
t.Fatalf("parent_node_token = %#v", data["parent_node_token"])
}
if data["creator"] != "ou_creator" {
t.Fatalf("creator = %#v, want ou_creator", data["creator"])
}
if data["owner"] != "ou_owner" {
t.Fatalf("owner = %#v, want ou_owner", data["owner"])
}
if got, _ := data["updated_at"].(string); got != "2023-11-14T22:13:20Z" {
t.Fatalf("updated_at = %#v, want 2023-11-14T22:13:20Z (UTC)", data["updated_at"])
}
// +node-get deliberately does not synthesize a url (get_node returns none;
// a BuildResourceURL fallback would be a non-canonical, misleading link in
// a read/confirm command).
if _, ok := data["url"]; ok {
t.Fatalf("did not expect a url field in +node-get output, got %#v", data["url"])
}
if got := stderr.String(); !strings.Contains(got, "Fetching wiki node") {
t.Fatalf("stderr = %q, want fetching message", got)
}
}
func TestWikiNodeGetFallsBackToCreatorWhenNodeCreatorMissing(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/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_123",
"node_token": "wikcnABC",
"obj_token": "docxXYZ",
"obj_type": "docx",
"node_type": "origin",
"title": "Fallback Creator",
"creator": "ou_legacy_creator",
},
},
"msg": "success",
},
})
err := mountAndRunWiki(t, WikiNodeGet, []string{
"+node-get",
"--token", "wikcnABC",
"--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
if data["creator"] != "ou_legacy_creator" {
t.Fatalf("creator = %#v, want fallback to creator field", data["creator"])
}
}
func TestWikiNodeGetRejectsSpaceIDMismatch(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/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_actual",
"node_token": "wikcnABC",
"obj_token": "docxXYZ",
"obj_type": "docx",
"node_type": "origin",
"title": "Mismatch",
},
},
"msg": "success",
},
})
err := mountAndRunWiki(t, WikiNodeGet, []string{
"+node-get",
"--token", "wikcnABC",
"--space-id", "space_expected",
"--as", "bot",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "does not match the resolved node space") {
t.Fatalf("expected space mismatch error, got %v", err)
}
}

View File

@@ -0,0 +1,120 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// WikiSpaceCreate wraps wiki.spaces.create. The raw API only takes two
// optional string fields, so the shortcut's value is flag ergonomics
// (no hand-written --params JSON), output flattening (data.space.* lifted
// to the top level), and a dry-run preview.
//
// The API only accepts a user access token (no tenant/bot), so AuthTypes is
// user-only — the framework's CheckIdentity rejects --as bot for us.
var WikiSpaceCreate = common.Shortcut{
Service: "wiki",
Command: "+space-create",
Description: "Create a wiki space",
Risk: "write",
// The API accepts wiki:wiki or wiki:space:write_only. The framework's
// scope preflight does exact-string matching (see +space-list), so
// declare the narrowest form the API takes to avoid false-rejecting
// tokens that only carry wiki:space:write_only.
Scopes: []string{"wiki:space:write_only"},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "name", Desc: "wiki space name", Required: true},
{Name: "description", Desc: "wiki space description"},
},
Tips: []string{
"Only --as user is supported; the create API does not accept a tenant/bot token.",
"The underlying spaces.create API is flagged danger in the schema browser; a space is recoverable via `wiki +delete-space` if created by mistake.",
"--name is required: an unnamed space is almost always an accident and is hard to find later.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readWikiSpaceCreateSpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readWikiSpaceCreateSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST(wikiSpacesAPIPath).
Body(spec.RequestBody())
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readWikiSpaceCreateSpec(runtime)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Creating wiki space %q...\n", spec.Name)
data, err := runtime.CallAPI("POST", wikiSpacesAPIPath, nil, spec.RequestBody())
if err != nil {
return err
}
raw := common.GetMap(data, "space")
if raw == nil {
return output.Errorf(output.ExitAPI, "api_error", "wiki space create returned no space")
}
out := wikiSpaceCreateOutput(raw)
fmt.Fprintf(runtime.IO().ErrOut, "Created wiki space %s\n", common.MaskToken(common.GetString(out, "space_id")))
runtime.Out(out, nil)
return nil
},
}
// wikiSpaceCreateSpec is the normalized CLI input.
type wikiSpaceCreateSpec struct {
Name string
Description string
}
// RequestBody converts the normalized input into the OpenAPI payload. Both
// fields are optional per the API, but Validate enforces a non-empty name,
// so name is always present here.
func (spec wikiSpaceCreateSpec) RequestBody() map[string]interface{} {
body := map[string]interface{}{"name": spec.Name}
if spec.Description != "" {
body["description"] = spec.Description
}
return body
}
func readWikiSpaceCreateSpec(runtime *common.RuntimeContext) (wikiSpaceCreateSpec, error) {
spec := wikiSpaceCreateSpec{
Name: strings.TrimSpace(runtime.Str("name")),
Description: strings.TrimSpace(runtime.Str("description")),
}
if spec.Name == "" {
return wikiSpaceCreateSpec{}, output.ErrValidation("--name is required and cannot be blank")
}
return spec, nil
}
// wikiSpaceCreateOutput flattens data.space into the top-level envelope. It
// reads the raw map (rather than parseWikiSpaceRecord) so the description
// the caller just set round-trips back in the output.
func wikiSpaceCreateOutput(raw map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"space_id": common.GetString(raw, "space_id"),
"name": common.GetString(raw, "name"),
"description": common.GetString(raw, "description"),
"space_type": common.GetString(raw, "space_type"),
"visibility": common.GetString(raw, "visibility"),
"open_sharing": common.GetString(raw, "open_sharing"),
}
}

View File

@@ -0,0 +1,207 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestWikiSpaceCreateDeclaredContract(t *testing.T) {
t.Parallel()
if WikiSpaceCreate.Command != "+space-create" {
t.Fatalf("Command = %q, want +space-create", WikiSpaceCreate.Command)
}
if WikiSpaceCreate.Risk != "write" {
t.Fatalf("Risk = %q, want write", WikiSpaceCreate.Risk)
}
if !reflect.DeepEqual(WikiSpaceCreate.AuthTypes, []string{"user"}) {
t.Fatalf("AuthTypes = %v, want [user]", WikiSpaceCreate.AuthTypes)
}
if !reflect.DeepEqual(WikiSpaceCreate.Scopes, []string{"wiki:space:write_only"}) {
t.Fatalf("Scopes = %v, want [wiki:space:write_only]", WikiSpaceCreate.Scopes)
}
}
func TestReadWikiSpaceCreateSpecRejectsBlankName(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "wiki +space-create"}
cmd.Flags().String("name", " ", "")
cmd.Flags().String("description", "", "")
runtime := common.TestNewRuntimeContext(cmd, nil)
if _, err := readWikiSpaceCreateSpec(runtime); err == nil || !strings.Contains(err.Error(), "--name is required") {
t.Fatalf("expected blank-name rejection, got %v", err)
}
}
func TestWikiSpaceCreateRequestBody(t *testing.T) {
t.Parallel()
nameOnly := wikiSpaceCreateSpec{Name: "Eng Wiki"}.RequestBody()
if !reflect.DeepEqual(nameOnly, map[string]interface{}{"name": "Eng Wiki"}) {
t.Fatalf("name-only body = %#v", nameOnly)
}
full := wikiSpaceCreateSpec{Name: "Eng Wiki", Description: "team docs"}.RequestBody()
if full["name"] != "Eng Wiki" || full["description"] != "team docs" {
t.Fatalf("full body = %#v", full)
}
}
func TestWikiSpaceCreateDryRun(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "wiki +space-create"}
cmd.Flags().String("name", "Eng Wiki", "")
cmd.Flags().String("description", "team docs", "")
runtime := common.TestNewRuntimeContext(cmd, nil)
dry := WikiSpaceCreate.DryRun(nil, runtime)
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run: %v", err)
}
if len(got.API) != 1 || got.API[0].Method != "POST" || got.API[0].URL != "/open-apis/wiki/v2/spaces" {
t.Fatalf("dry-run api = %#v", got.API)
}
if got.API[0].Body["name"] != "Eng Wiki" || got.API[0].Body["description"] != "team docs" {
t.Fatalf("dry-run body = %#v", got.API[0].Body)
}
}
func TestWikiSpaceCreateDryRunBlankNameSurfacesError(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "wiki +space-create"}
cmd.Flags().String("name", "", "")
cmd.Flags().String("description", "", "")
runtime := common.TestNewRuntimeContext(cmd, nil)
dry := WikiSpaceCreate.DryRun(nil, runtime)
data, _ := json.Marshal(dry)
if !strings.Contains(string(data), "--name is required") {
t.Fatalf("dry-run should surface validation error, got %s", data)
}
}
func TestWikiSpaceCreateMountedExecuteFlattensSpace(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, stderr, reg := cmdutil.TestFactory(t, wikiTestConfig())
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"space": map[string]interface{}{
"space_id": "7160145948494381236",
"name": "Eng Wiki",
"description": "team docs",
"space_type": "team",
"visibility": "private",
"open_sharing": "closed",
},
},
"msg": "success",
},
}
reg.Register(createStub)
err := mountAndRunWiki(t, WikiSpaceCreate, []string{
"+space-create",
"--name", "Eng Wiki",
"--description", "team docs",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
if data["space_id"] != "7160145948494381236" {
t.Fatalf("space_id = %#v", data["space_id"])
}
if data["name"] != "Eng Wiki" || data["description"] != "team docs" {
t.Fatalf("name/description = %#v / %#v", data["name"], data["description"])
}
if data["space_type"] != "team" || data["visibility"] != "private" || data["open_sharing"] != "closed" {
t.Fatalf("space_type/visibility/open_sharing = %#v", data)
}
if _, ok := data["url"]; ok {
t.Fatalf("output must not include a url field, got %#v", data["url"])
}
var captured map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &captured); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if captured["name"] != "Eng Wiki" || captured["description"] != "team docs" {
t.Fatalf("captured request body = %#v", captured)
}
if !strings.Contains(stderr.String(), "Created wiki space") {
t.Fatalf("stderr = %q, want creation log", stderr.String())
}
}
func TestWikiSpaceCreateRejectsBotIdentity(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiSpaceCreate, []string{
"+space-create",
"--name", "Eng Wiki",
"--as", "bot",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "only supports: user") {
t.Fatalf("expected bot identity rejection, got %v", err)
}
}
func TestWikiSpaceCreateErrorsWhenNoSpaceReturned(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",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
"msg": "success",
},
})
err := mountAndRunWiki(t, WikiSpaceCreate, []string{
"+space-create",
"--name", "Eng Wiki",
"--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "returned no space") {
t.Fatalf("expected missing-space error, got %v", err)
}
}

View File

@@ -15,7 +15,7 @@ import (
)
const (
wikiSpaceListAPIPath = "/open-apis/wiki/v2/spaces"
wikiSpacesAPIPath = "/open-apis/wiki/v2/spaces"
wikiSpaceListDefaultPageSize = 50
wikiSpaceListMaxPageSize = 50
)
@@ -59,7 +59,7 @@ var WikiSpaceList = common.Shortcut{
if wikiListShouldAutoPaginate(runtime) {
dry.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
}
return dry.GET(wikiSpaceListAPIPath).Params(params)
return dry.GET(wikiSpacesAPIPath).Params(params)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
warnIfConflictingPagingFlags(runtime)
@@ -103,7 +103,7 @@ func fetchWikiSpaces(runtime *common.RuntimeContext) ([]map[string]interface{},
if pageToken != "" {
params["page_token"] = pageToken
}
data, err := runtime.CallAPI("GET", wikiSpaceListAPIPath, params, nil)
data, err := runtime.CallAPI("GET", wikiSpacesAPIPath, params, nil)
if err != nil {
return nil, false, "", err
}

View File

@@ -62,8 +62,11 @@ Shortcut 是对常用操作的高级封装(`lark-cli wiki +<verb> [flags]`
| [`+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 |
| [`+space-create`](references/lark-wiki-space-create.md) | Create a wiki space (user identity only) |
| [`+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 |
| [`+node-get`](references/lark-wiki-node-get.md) | Get a wiki node's details by node_token / obj_token / Lark URL |
| [`+node-delete`](references/lark-wiki-node-delete.md) | Delete a wiki node, polling the async delete task when needed |
## API Resources

View File

@@ -0,0 +1,62 @@
# lark-wiki +node-delete
Delete a wiki node (or pull a cloud doc out of Wiki). OpenAPI: `DELETE /open-apis/wiki/v2/spaces/:space_id/nodes/:node_token`.
> ⚠️ **High-risk write & irreversible** — deletes the node and (by default) its whole subtree. Requires explicit `--yes`; without it the CLI returns a `confirmation_required` error and nothing is deleted.
- **Sync / async**: an empty `task_id` means the delete completed synchronously (`ready=true`). A non-empty `task_id` triggers bounded polling; if the window elapses the output carries `timed_out=true` and a `next_command`:
`lark-cli drive +task_result --scenario wiki_delete_node --task-id <TASK_ID> --as <user|bot>`
## Usage
```bash
lark-cli wiki +node-delete \
--node-token <node_token | obj_token | Lark URL> \
[--obj-type <wiki|doc|docx|sheet|bitable|mindnote|slides|file>] \
[--space-id <space_id>] \
[--include-children=true|false] \
--yes \
[--as user|bot]
# Preview the call chain without deleting
lark-cli wiki +node-delete --node-token <token> --obj-type wiki --dry-run
```
## Flags
| Flag | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `--node-token` | string | **Yes** | — | `node_token`, cloud-doc `obj_token`, or a Lark URL embedding one; URL paths also imply `--obj-type` |
| `--obj-type` | enum | Conditional | — | Required for a raw token (URL inputs auto-infer). `wiki` = the token is a `node_token`; otherwise the cloud-doc type |
| `--space-id` | string | No | — | Auto-resolved via `get_node` when omitted (extra lookup; pass it to skip) |
| `--include-children` | bool | No | `true` | Cascade-delete the subtree (default). `--include-children=false` lifts direct children up to the parent |
| `--yes` | bool | Yes (real delete) | — | Confirm the high-risk operation. Without it the CLI returns `confirmation_required` |
| `--as` | enum | No | `auto` | Identity `user`/`bot`; wiki is user-centric → pass `--as user` |
## Output
```json
{
"space_id": "7160145948494381236",
"node_token": "wikcnEXAMPLE",
"obj_type": "wiki",
"include_children": true,
"ready": true,
"failed": false,
"status": "success",
"status_msg": "success"
}
```
Async/timeout adds `task_id`, `timed_out`, and `next_command`.
## Behavior
- **Task poll**: `GET /open-apis/wiki/v2/tasks/{task_id}?task_type=delete_node`. The status lives under `data.task.simple_task_result.status` (the gateway's generic key — **not** `delete_node_result`); that object has no `status_msg`, so the label falls back to the status code.
- **Error hints**:
- `131011` → the node has delete-approval enabled; apply via the Wiki UI (CLI cannot bypass approval).
- `131003` → subtree too large to cascade-delete; use `--include-children=false` or delete sub-trees first.
## Required Scope
`wiki:node:create` (the delete endpoint declares this scope). Auto-resolving `space_id` additionally needs `wiki:node:retrieve`; pass `--space-id` to avoid that lookup.

View File

@@ -0,0 +1,56 @@
# lark-wiki +node-get
Get a wiki node's details by `node_token`, `obj_token`, or a Lark URL. Use this as the "what am I about to touch?" step before `+move` / `+node-copy` / `+node-delete`.
## Usage
```bash
lark-cli wiki +node-get \
--token <node_token | obj_token | Lark URL> \
[--obj-type <doc|docx|sheet|bitable|mindnote|slides|file>] \
[--space-id <space_id>] \
[--format json|pretty|table|csv|ndjson] \
[--as user|bot]
```
## Flags
| Flag | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `--token` | string | **Yes** | — | `node_token`, cloud-doc `obj_token`, or a Lark URL embedding one (e.g. `https://feishu.cn/wiki/<token>` or `https://feishu.cn/docx/<token>`) |
| `--obj-type` | enum | No | — | Needed when `--token` is a raw `obj_token`; auto-inferred from the URL path. Not allowed when the token looks like a `node_token` (`wik...`) |
| `--space-id` | string | No | — | Optional cross-check: fail if the resolved node does not live in this space |
| `--format` | enum | No | `json` | `json` / `pretty` / `table` / `csv` / `ndjson` |
| `--as` | enum | No | `auto` | Identity `user`/`bot`; wiki is user-centric → pass `--as user` |
## Output
```json
{
"space_id": "7160145948494381236",
"node_token": "wikcnEXAMPLE",
"obj_token": "docxEXAMPLE",
"obj_type": "docx",
"node_type": "origin",
"parent_node_token": "wikcnPARENT",
"origin_node_token": "",
"title": "Design Spec",
"has_child": true,
"creator": "ou_xxx",
"owner": "ou_yyy",
"obj_edit_time": "1700000000",
"obj_create_time": "1690000000",
"node_create_time": "1690000001",
"updated_at": "2023-11-14T22:13:20Z"
}
```
## Notes
- The underlying API is `GET /open-apis/wiki/v2/spaces/get_node`. For a `node_token` no `obj_type` is sent; for an `obj_token` the `obj_type` (explicit or URL-inferred) is required.
- `creator` falls back to `creator` when `node_creator` is absent. `updated_at` is `obj_edit_time` formatted as RFC3339.
- No `url` is returned: `get_node` does not provide one and a synthesized `www.feishu.cn/wiki/<node_token>` link is non-canonical/misleading for a read command. Use `node_token` / `obj_token` as the identifiers.
## Required Scope
`wiki:node:retrieve`

View File

@@ -0,0 +1,46 @@
# lark-wiki +space-create
Create a wiki space. OpenAPI: `POST /open-apis/wiki/v2/spaces`. This is the project-initialization entry point — the alternative is hand-writing `wiki spaces create --params '{...}'`.
> The underlying `spaces.create` API is flagged `danger: true` in the schema browser, but it is **not** confirmation-gated (no `--yes`). A space created by mistake is recoverable via `wiki +delete-space`.
## Usage
```bash
lark-cli wiki +space-create \
--name <space_name> \
[--description <text>] \
[--as user]
```
## Flags
| Flag | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `--name` | string | **Yes** | — | Wiki space name. Blank/whitespace is rejected (an unnamed space is almost always an accident) |
| `--description` | string | No | — | Wiki space description |
| `--as` | enum | No | `user` | **User identity only** — the create API does not accept a tenant/bot token; `--as bot` is rejected upfront |
## Output
```json
{
"space_id": "7160145948494381236",
"name": "Engineering Wiki",
"description": "team docs",
"space_type": "team",
"visibility": "private",
"open_sharing": "closed"
}
```
There is no `url` field — the create API does not return one.
## Notes
- Only `--as user` is supported; this command declares `AuthTypes: ["user"]` and the framework rejects `--as bot` with a clear message.
- `--dry-run` previews the `POST /open-apis/wiki/v2/spaces` request (and surfaces the blank-name validation error early).
## Required Scope
`wiki:space:write_only`