diff --git a/shortcuts/drive/drive_task_result.go b/shortcuts/drive/drive_task_result.go index 5fc971404..d506e1b17 100644 --- a/shortcuts/drive/drive_task_result.go +++ b/shortcuts/drive/drive_task_result.go @@ -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 +} diff --git a/shortcuts/drive/drive_task_result_test.go b/shortcuts/drive/drive_task_result_test.go index 69bb91cf5..79e43d76d 100644 --- a/shortcuts/drive/drive_task_result_test.go +++ b/shortcuts/drive/drive_task_result_test.go @@ -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() diff --git a/shortcuts/wiki/shortcuts.go b/shortcuts/wiki/shortcuts.go index da5b388d0..d07cff09e 100644 --- a/shortcuts/wiki/shortcuts.go +++ b/shortcuts/wiki/shortcuts.go @@ -12,7 +12,10 @@ func Shortcuts() []common.Shortcut { WikiNodeCreate, WikiDeleteSpace, WikiSpaceList, + WikiSpaceCreate, WikiNodeList, WikiNodeCopy, + WikiNodeGet, + WikiNodeDelete, } } diff --git a/shortcuts/wiki/wiki_async_task.go b/shortcuts/wiki/wiki_async_task.go new file mode 100644 index 000000000..4404f6c1d --- /dev/null +++ b/shortcuts/wiki/wiki_async_task.go @@ -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 +} diff --git a/shortcuts/wiki/wiki_async_task_test.go b/shortcuts/wiki/wiki_async_task_test.go new file mode 100644 index 000000000..5b8fd5afe --- /dev/null +++ b/shortcuts/wiki/wiki_async_task_test.go @@ -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) + } +} diff --git a/shortcuts/wiki/wiki_delete.go b/shortcuts/wiki/wiki_delete.go index fea7237f6..484d7d468 100644 --- a/shortcuts/wiki/wiki_delete.go +++ b/shortcuts/wiki/wiki_delete.go @@ -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) } diff --git a/shortcuts/wiki/wiki_delete_test.go b/shortcuts/wiki/wiki_delete_test.go index e39df0814..e2abe608d 100644 --- a/shortcuts/wiki/wiki_delete_test.go +++ b/shortcuts/wiki/wiki_delete_test.go @@ -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()) } diff --git a/shortcuts/wiki/wiki_node_create_test.go b/shortcuts/wiki/wiki_node_create_test.go index df22f9008..3cdab33c0 100644 --- a/shortcuts/wiki/wiki_node_create_test.go +++ b/shortcuts/wiki/wiki_node_create_test.go @@ -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() diff --git a/shortcuts/wiki/wiki_node_delete.go b/shortcuts/wiki/wiki_node_delete.go new file mode 100644 index 000000000..800581f0c --- /dev/null +++ b/shortcuts/wiki/wiki_node_delete.go @@ -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/ or https://feishu.cn/docx/; 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/ 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", + "", + 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", ""). + 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, + }, + } +} diff --git a/shortcuts/wiki/wiki_node_delete_test.go b/shortcuts/wiki/wiki_node_delete_test.go new file mode 100644 index 000000000..a580e2f9a --- /dev/null +++ b/shortcuts/wiki/wiki_node_delete_test.go @@ -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//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 +} diff --git a/shortcuts/wiki/wiki_node_get.go b/shortcuts/wiki/wiki_node_get.go new file mode 100644 index 000000000..d89d3b86f --- /dev/null +++ b/shortcuts/wiki/wiki_node_get.go @@ -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/ or https://feishu.cn/docx/.", + "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/) 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/) 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"])) +} diff --git a/shortcuts/wiki/wiki_node_get_test.go b/shortcuts/wiki/wiki_node_get_test.go new file mode 100644 index 000000000..ea1a2fd4e --- /dev/null +++ b/shortcuts/wiki/wiki_node_get_test.go @@ -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) + } +} diff --git a/shortcuts/wiki/wiki_space_create.go b/shortcuts/wiki/wiki_space_create.go new file mode 100644 index 000000000..0b963b02c --- /dev/null +++ b/shortcuts/wiki/wiki_space_create.go @@ -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"), + } +} diff --git a/shortcuts/wiki/wiki_space_create_test.go b/shortcuts/wiki/wiki_space_create_test.go new file mode 100644 index 000000000..7e3d31f58 --- /dev/null +++ b/shortcuts/wiki/wiki_space_create_test.go @@ -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) + } +} diff --git a/shortcuts/wiki/wiki_space_list.go b/shortcuts/wiki/wiki_space_list.go index 7816467a6..77b724599 100644 --- a/shortcuts/wiki/wiki_space_list.go +++ b/shortcuts/wiki/wiki_space_list.go @@ -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 } diff --git a/skills/lark-wiki/SKILL.md b/skills/lark-wiki/SKILL.md index dbe4cf0a9..dac4de1f5 100644 --- a/skills/lark-wiki/SKILL.md +++ b/skills/lark-wiki/SKILL.md @@ -62,8 +62,11 @@ Shortcut 是对常用操作的高级封装(`lark-cli wiki + [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 diff --git a/skills/lark-wiki/references/lark-wiki-node-delete.md b/skills/lark-wiki/references/lark-wiki-node-delete.md new file mode 100644 index 000000000..e28fecb64 --- /dev/null +++ b/skills/lark-wiki/references/lark-wiki-node-delete.md @@ -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 --as ` + +## Usage + +```bash +lark-cli wiki +node-delete \ + --node-token \ + [--obj-type ] \ + [--space-id ] \ + [--include-children=true|false] \ + --yes \ + [--as user|bot] + +# Preview the call chain without deleting +lark-cli wiki +node-delete --node-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. diff --git a/skills/lark-wiki/references/lark-wiki-node-get.md b/skills/lark-wiki/references/lark-wiki-node-get.md new file mode 100644 index 000000000..dd8b88b11 --- /dev/null +++ b/skills/lark-wiki/references/lark-wiki-node-get.md @@ -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 \ + [--obj-type ] \ + [--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/` or `https://feishu.cn/docx/`) | +| `--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/` link is non-canonical/misleading for a read command. Use `node_token` / `obj_token` as the identifiers. + +## Required Scope + +`wiki:node:retrieve` diff --git a/skills/lark-wiki/references/lark-wiki-space-create.md b/skills/lark-wiki/references/lark-wiki-space-create.md new file mode 100644 index 000000000..f560b5efa --- /dev/null +++ b/skills/lark-wiki/references/lark-wiki-space-create.md @@ -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 \ + [--description ] \ + [--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`