diff --git a/shortcuts/drive/drive_task_result.go b/shortcuts/drive/drive_task_result.go index 6b90db9a3..f2b257d44 100644 --- a/shortcuts/drive/drive_task_result.go +++ b/shortcuts/drive/drive_task_result.go @@ -5,27 +5,31 @@ package drive import ( "context" + "errors" "fmt" "strings" + "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) // DriveTaskResult exposes a unified read path for the async task types produced -// by Drive import, export, and folder move flows. +// by Drive import, export, folder move/delete, and wiki move flows. var DriveTaskResult = common.Shortcut{ Service: "drive", Command: "+task_result", - Description: "Poll async task result for import, export, move, or delete operations", + Description: "Poll async task result for import, export, drive move/delete, or wiki move operations", Risk: "read", - Scopes: []string{"drive:drive.metadata:readonly"}, - AuthTypes: []string{"user", "bot"}, + // This shortcut multiplexes multiple backend APIs with different scope + // requirements, so scenario-specific prechecks are handled in Validate. + Scopes: []string{}, + 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 move/delete folder tasks)", Required: false}, - {Name: "scenario", Desc: "task scenario: import, export, or task_check", Required: true}, + {Name: "task-id", Desc: "async task ID (for drive task_check or wiki_move tasks)", Required: false}, + {Name: "scenario", Desc: "task scenario: import, export, task_check, or wiki_move", 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 { @@ -34,9 +38,10 @@ var DriveTaskResult = common.Shortcut{ "import": true, "export": true, "task_check": true, + "wiki_move": true, } if !validScenarios[scenario] { - return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check", scenario) + return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move", scenario) } // Validate required params based on scenario @@ -48,9 +53,9 @@ var DriveTaskResult = common.Shortcut{ if err := validate.ResourceName(runtime.Str("ticket"), "--ticket"); err != nil { return output.ErrValidation("%s", err) } - case "task_check": + case "task_check", "wiki_move": if runtime.Str("task-id") == "" { - return output.ErrValidation("--task-id is required for task_check scenario") + return output.ErrValidation("--task-id is required for %s scenario", scenario) } if err := validate.ResourceName(runtime.Str("task-id"), "--task-id"); err != nil { return output.ErrValidation("%s", err) @@ -67,7 +72,7 @@ var DriveTaskResult = common.Shortcut{ } } - return nil + return validateDriveTaskResultScopes(ctx, runtime, scenario) }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { scenario := strings.ToLower(runtime.Str("scenario")) @@ -92,6 +97,11 @@ var DriveTaskResult = common.Shortcut{ dry.GET("/open-apis/drive/v1/files/task_check"). Desc("[1] Query move/delete folder task status"). Params(driveTaskCheckParams(taskID)) + case "wiki_move": + dry.GET("/open-apis/wiki/v2/tasks/:task_id"). + Desc("[1] Query wiki move task result"). + Set("task_id", taskID). + Params(map[string]interface{}{"task_type": "move"}) } return dry @@ -116,6 +126,8 @@ var DriveTaskResult = common.Shortcut{ result, err = queryExportTask(runtime, ticket, fileToken) case "task_check": result, err = queryTaskCheck(runtime, taskID) + case "wiki_move": + result, err = queryWikiMoveTask(runtime, taskID) } if err != nil { @@ -196,3 +208,263 @@ func queryTaskCheck(runtime *common.RuntimeContext, taskID string) (map[string]i "failed": status.Failed(), }, nil } + +func validateDriveTaskResultScopes(ctx context.Context, runtime *common.RuntimeContext, scenario string) error { + result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID)) + if err != nil { + // Propagate cancellation/timeout so callers stop instead of falling through + // to the API call. Other token errors are non-fatal here: the API call will + // surface a clearer permission error. + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err + } + return nil + } + if result == nil || result.Scopes == "" { + return nil + } + + var required []string + switch scenario { + case "import", "export", "task_check": + required = []string{"drive:drive.metadata:readonly"} + case "wiki_move": + required = []string{"wiki:space:read"} + } + + return requireDriveScopes(result.Scopes, required) +} + +func requireDriveScopes(storedScopes string, required []string) error { + if len(required) == 0 { + return nil + } + + missing := missingDriveScopes(storedScopes, required) + if len(missing) == 0 { + return nil + } + + return output.ErrWithHint(output.ExitAuth, "missing_scope", + fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")), + fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " "))) +} + +func missingDriveScopes(storedScopes string, required []string) []string { + granted := make(map[string]bool) + for _, scope := range strings.Fields(storedScopes) { + granted[scope] = true + } + + missing := make([]string, 0, len(required)) + for _, scope := range required { + if !granted[scope] { + missing = append(missing, scope) + } + } + return missing +} + +type wikiMoveTaskResultStatus struct { + Node map[string]interface{} + Status int + StatusMsg string +} + +type wikiMoveTaskQueryStatus struct { + TaskID string + MoveResults []wikiMoveTaskResultStatus +} + +func (s wikiMoveTaskQueryStatus) Ready() bool { + if len(s.MoveResults) == 0 { + return false + } + for _, result := range s.MoveResults { + if result.Status != 0 { + return false + } + } + return true +} + +func (s wikiMoveTaskQueryStatus) Failed() bool { + for _, result := range s.MoveResults { + if result.Status < 0 { + return true + } + } + return false +} + +func (s wikiMoveTaskQueryStatus) FirstResult() *wikiMoveTaskResultStatus { + if len(s.MoveResults) == 0 { + return nil + } + return &s.MoveResults[0] +} + +// primaryResult picks the most informative move_result for top-level status +// surfacing: prefer a failing entry so multi-doc tasks don't mask failures +// behind an earlier success, then a still-processing entry, and finally fall +// back to the first entry. +func (s wikiMoveTaskQueryStatus) primaryResult() *wikiMoveTaskResultStatus { + for i := range s.MoveResults { + if s.MoveResults[i].Status < 0 { + return &s.MoveResults[i] + } + } + for i := range s.MoveResults { + if s.MoveResults[i].Status > 0 { + return &s.MoveResults[i] + } + } + return s.FirstResult() +} + +func (s wikiMoveTaskQueryStatus) PrimaryStatusCode() int { + if r := s.primaryResult(); r != nil { + return r.Status + } + return 1 +} + +func (s wikiMoveTaskQueryStatus) PrimaryStatusLabel() string { + if r := s.primaryResult(); r != nil { + if msg := strings.TrimSpace(r.StatusMsg); msg != "" { + return msg + } + } + switch { + case s.Ready(): + return "success" + case s.Failed(): + return "failure" + default: + return "processing" + } +} + +func queryWikiMoveTask(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) { + status, err := getWikiMoveTaskStatus(runtime, taskID) + if err != nil { + return nil, err + } + + out := map[string]interface{}{ + "scenario": "wiki_move", + "task_id": status.TaskID, + "ready": status.Ready(), + "failed": status.Failed(), + "status": status.PrimaryStatusCode(), + "status_msg": status.PrimaryStatusLabel(), + } + + moveResults := make([]map[string]interface{}, 0, len(status.MoveResults)) + for _, result := range status.MoveResults { + item := map[string]interface{}{ + "status": result.Status, + "status_msg": result.StatusMsg, + } + if result.Node != nil { + item["node"] = result.Node + } + moveResults = append(moveResults, item) + } + if len(moveResults) > 0 { + out["move_results"] = moveResults + } + + if first := status.FirstResult(); first != nil { + // Mirror the first moved node at the top level so follow-up commands can + // reuse a stable field set without digging into move_results[0].node. + if first.Node != nil { + out["node"] = first.Node + appendWikiMoveNodeFields(out, first.Node) + if token := common.GetString(first.Node, "node_token"); token != "" { + out["wiki_token"] = token + } + } + } + + return out, nil +} + +func getWikiMoveTaskStatus(runtime *common.RuntimeContext, taskID string) (wikiMoveTaskQueryStatus, error) { + if err := validate.ResourceName(taskID, "--task-id"); err != nil { + return wikiMoveTaskQueryStatus{}, 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": "move"}, + nil, + ) + if err != nil { + return wikiMoveTaskQueryStatus{}, err + } + + return parseWikiMoveTaskQueryStatus(taskID, common.GetMap(data, "task")) +} + +func parseWikiMoveTaskQueryStatus(taskID string, task map[string]interface{}) (wikiMoveTaskQueryStatus, error) { + if task == nil { + return wikiMoveTaskQueryStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task") + } + + status := wikiMoveTaskQueryStatus{ + TaskID: common.GetString(task, "task_id"), + } + if status.TaskID == "" { + status.TaskID = taskID + } + + for _, item := range common.GetSlice(task, "move_result") { + resultMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + + status.MoveResults = append(status.MoveResults, wikiMoveTaskResultStatus{ + Node: parseWikiMoveTaskNode(common.GetMap(resultMap, "node")), + Status: int(common.GetFloat(resultMap, "status")), + StatusMsg: common.GetString(resultMap, "status_msg"), + }) + } + + return status, nil +} + +func parseWikiMoveTaskNode(node map[string]interface{}) map[string]interface{} { + if node == nil { + return nil + } + + return map[string]interface{}{ + "space_id": common.GetString(node, "space_id"), + "node_token": common.GetString(node, "node_token"), + "obj_token": common.GetString(node, "obj_token"), + "obj_type": common.GetString(node, "obj_type"), + "parent_node_token": common.GetString(node, "parent_node_token"), + "node_type": common.GetString(node, "node_type"), + "origin_node_token": common.GetString(node, "origin_node_token"), + "title": common.GetString(node, "title"), + "has_child": common.GetBool(node, "has_child"), + } +} + +func appendWikiMoveNodeFields(out, node map[string]interface{}) { + if out == nil || node == nil { + return + } + out["space_id"] = common.GetString(node, "space_id") + out["node_token"] = common.GetString(node, "node_token") + out["obj_token"] = common.GetString(node, "obj_token") + out["obj_type"] = common.GetString(node, "obj_type") + out["parent_node_token"] = common.GetString(node, "parent_node_token") + out["node_type"] = common.GetString(node, "node_type") + out["origin_node_token"] = common.GetString(node, "origin_node_token") + out["title"] = common.GetString(node, "title") + out["has_child"] = common.GetBool(node, "has_child") +} diff --git a/shortcuts/drive/drive_task_result_test.go b/shortcuts/drive/drive_task_result_test.go index 6a98d0a8f..9bbc97aee 100644 --- a/shortcuts/drive/drive_task_result_test.go +++ b/shortcuts/drive/drive_task_result_test.go @@ -7,12 +7,15 @@ import ( "bytes" "context" "encoding/json" + "errors" "strings" "testing" "github.com/spf13/cobra" "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/shortcuts/common" ) @@ -54,6 +57,13 @@ func TestDriveTaskResultValidateErrorsByScenario(t *testing.T) { }, wantErr: "--task-id is required", }, + { + name: "wiki move missing task id", + flags: map[string]string{ + "scenario": "wiki_move", + }, + wantErr: "--task-id is required", + }, } for _, tt := range tests { @@ -277,3 +287,259 @@ func TestDriveTaskResultTaskCheckTreatsFailAsFailed(t *testing.T) { t.Fatalf("stdout missing ready=false: %s", stdout.String()) } } + +type mockDriveTaskResultTokenResolver struct { + token string + scopes string + err error +} + +func (m *mockDriveTaskResultTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) { + if m.err != nil { + return nil, m.err + } + token := m.token + if token == "" { + token = "test-token" + } + return &credential.TokenResult{Token: token, Scopes: m.scopes}, nil +} + +func newDriveTaskResultRuntimeWithScopes(t *testing.T, as core.Identity, scopes string) *common.RuntimeContext { + t.Helper() + + cfg := driveTestConfig() + factory, _, _, _ := cmdutil.TestFactory(t, cfg) + factory.Credential = credential.NewCredentialProvider(nil, nil, &mockDriveTaskResultTokenResolver{scopes: scopes}, nil) + + runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "drive +task_result"}, cfg, as) + runtime.Factory = factory + return runtime +} + +func TestDriveTaskResultDryRunWikiMoveIncludesTaskTypeParam(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_move"); err != nil { + t.Fatalf("set --scenario: %v", err) + } + if err := cmd.Flags().Set("task-id", "task_123"); 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"] != "move" { + t.Fatalf("wiki move params = %#v, want task_type=move", got.API[0].Params) + } +} + +func TestDriveTaskResultWikiMoveIncludesFlattenedNodeFields(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/tasks/task_123", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "task": map[string]interface{}{ + "task_id": "task_123", + "move_result": []interface{}{ + map[string]interface{}{ + "status": 0, + "status_msg": "success", + "node": map[string]interface{}{ + "space_id": "space_dst", + "node_token": "wik_done", + "obj_token": "sheet_token", + "obj_type": "sheet", + "node_type": "origin", + "title": "Roadmap", + }, + }, + }, + }, + }, + }, + }) + + err := mountAndRunDrive(t, DriveTaskResult, []string{ + "+task_result", + "--scenario", "wiki_move", + "--task-id", "task_123", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeDriveEnvelope(t, stdout) + if data["scenario"] != "wiki_move" || data["task_id"] != "task_123" { + t.Fatalf("unexpected wiki_move envelope: %#v", data) + } + if data["ready"] != true || data["failed"] != false || data["wiki_token"] != "wik_done" { + t.Fatalf("unexpected readiness fields: %#v", data) + } + if data["title"] != "Roadmap" || data["obj_type"] != "sheet" || data["space_id"] != "space_dst" { + t.Fatalf("flattened node fields missing: %#v", data) + } + moveResults, ok := data["move_results"].([]interface{}) + if !ok || len(moveResults) != 1 { + t.Fatalf("move_results = %#v, want one result", data["move_results"]) + } +} + +func TestValidateDriveTaskResultScopesWikiMoveRequiresWikiScope(t *testing.T) { + t.Parallel() + + runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "drive:drive.metadata:readonly") + err := validateDriveTaskResultScopes(context.Background(), runtime, "wiki_move") + if err == nil || !strings.Contains(err.Error(), "missing required scope(s): wiki:space:read") { + t.Fatalf("expected missing wiki scope error, got %v", err) + } +} + +func TestValidateDriveTaskResultScopesWikiMoveAcceptsWikiScope(t *testing.T) { + t.Parallel() + + runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "wiki:space:read") + err := validateDriveTaskResultScopes(context.Background(), runtime, "wiki_move") + if err != nil { + t.Fatalf("validateDriveTaskResultScopes() error = %v", err) + } +} + +func TestValidateDriveTaskResultScopesDriveScenariosRequireDriveScope(t *testing.T) { + t.Parallel() + + runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "wiki:space:read") + err := validateDriveTaskResultScopes(context.Background(), runtime, "import") + if err == nil || !strings.Contains(err.Error(), "missing required scope(s): drive:drive.metadata:readonly") { + t.Fatalf("expected missing drive scope error, got %v", err) + } +} + +func TestParseWikiMoveTaskQueryStatusFallbackTaskIDAndNode(t *testing.T) { + t.Parallel() + + status, err := parseWikiMoveTaskQueryStatus("task_fallback", map[string]interface{}{ + "move_result": []interface{}{ + map[string]interface{}{ + "status": 0, + "status_msg": "success", + "node": map[string]interface{}{ + "space_id": "space_dst", + "node_token": "wik_done", + "obj_token": "sheet_token", + "obj_type": "sheet", + "title": "Roadmap", + }, + }, + }, + }) + if err != nil { + t.Fatalf("parseWikiMoveTaskQueryStatus() error = %v", err) + } + if status.TaskID != "task_fallback" || !status.Ready() || status.PrimaryStatusLabel() != "success" { + t.Fatalf("unexpected parsed status: %+v", status) + } + if first := status.FirstResult(); first == nil || first.Node == nil || first.Node["node_token"] != "wik_done" { + t.Fatalf("parsed node = %+v", first) + } +} + +func TestParseWikiMoveTaskQueryStatusRejectsMissingTask(t *testing.T) { + t.Parallel() + + _, err := parseWikiMoveTaskQueryStatus("task_123", nil) + if err == nil || !strings.Contains(err.Error(), "missing task") { + t.Fatalf("expected missing task error, got %v", err) + } +} + +func TestWikiMoveTaskQueryStatusPrimarySurfacesFailureOverEarlierSuccess(t *testing.T) { + t.Parallel() + + status := wikiMoveTaskQueryStatus{ + MoveResults: []wikiMoveTaskResultStatus{ + {Status: 0, StatusMsg: "success"}, + {Status: -3, StatusMsg: "permission denied"}, + {Status: 1, StatusMsg: "processing"}, + }, + } + if got := status.PrimaryStatusCode(); got != -3 { + t.Fatalf("PrimaryStatusCode = %d, want -3", got) + } + if got := status.PrimaryStatusLabel(); got != "permission denied" { + t.Fatalf("PrimaryStatusLabel = %q, want permission denied", got) + } + // FirstResult must keep its literal "first entry" semantics for callers + // that flatten node fields from the first move_result. + if first := status.FirstResult(); first == nil || first.StatusMsg != "success" { + t.Fatalf("FirstResult = %+v, want first success entry", first) + } +} + +func TestWikiMoveTaskQueryStatusPrimaryPrefersProcessingOverFirstSuccess(t *testing.T) { + t.Parallel() + + status := wikiMoveTaskQueryStatus{ + MoveResults: []wikiMoveTaskResultStatus{ + {Status: 0, StatusMsg: "success"}, + {Status: 1, StatusMsg: "processing"}, + }, + } + if got := status.PrimaryStatusCode(); got != 1 { + t.Fatalf("PrimaryStatusCode = %d, want 1", got) + } + if got := status.PrimaryStatusLabel(); got != "processing" { + t.Fatalf("PrimaryStatusLabel = %q, want processing", got) + } +} + +type cancelingTokenResolver struct{} + +func (cancelingTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) { + return nil, context.Canceled +} + +func TestValidateDriveTaskResultScopesPropagatesContextCancellation(t *testing.T) { + t.Parallel() + + cfg := driveTestConfig() + factory, _, _, _ := cmdutil.TestFactory(t, cfg) + factory.Credential = credential.NewCredentialProvider(nil, nil, cancelingTokenResolver{}, nil) + + runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "drive +task_result"}, cfg, core.AsUser) + runtime.Factory = factory + + err := validateDriveTaskResultScopes(context.Background(), runtime, "wiki_move") + if err == nil || !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } +} diff --git a/shortcuts/wiki/shortcuts.go b/shortcuts/wiki/shortcuts.go index 2d4a5015b..d670827e9 100644 --- a/shortcuts/wiki/shortcuts.go +++ b/shortcuts/wiki/shortcuts.go @@ -8,6 +8,7 @@ import "github.com/larksuite/cli/shortcuts/common" // Shortcuts returns all wiki shortcuts. func Shortcuts() []common.Shortcut { return []common.Shortcut{ + WikiMove, WikiNodeCreate, } } diff --git a/shortcuts/wiki/wiki_move.go b/shortcuts/wiki/wiki_move.go new file mode 100644 index 000000000..7931c2549 --- /dev/null +++ b/shortcuts/wiki/wiki_move.go @@ -0,0 +1,671 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package wiki + +import ( + "context" + "errors" + "fmt" + "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" +) + +var ( + wikiMovePollAttempts = 30 + wikiMovePollInterval = 2 * time.Second +) + +const ( + wikiMoveModeNode = "node" + wikiMoveModeDocsToWiki = "docs_to_wiki" +) + +var wikiMoveObjectTypes = []string{ + "doc", + "sheet", + "bitable", + "mindnote", + "docx", + "file", + "slides", +} + +// WikiMove moves an existing wiki node inside Wiki or migrates a Drive +// document into Wiki with bounded polling for async task completion. +var WikiMove = common.Shortcut{ + Service: "wiki", + Command: "+move", + Description: "Move a wiki node, or move a Drive document into Wiki", + Risk: "write", + Scopes: []string{"wiki:node:move", "wiki:node:read", "wiki:space:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "node-token", Desc: "wiki node token to move inside Wiki"}, + {Name: "source-space-id", Desc: "source wiki space ID for --node-token; if omitted, it is resolved from the node token"}, + {Name: "target-space-id", Desc: "target wiki space ID; required for docs-to-wiki, optional for node move when --target-parent-token is set"}, + {Name: "target-parent-token", Desc: "target parent wiki node token; if omitted for docs-to-wiki, the document is moved to the target space root"}, + {Name: "obj-type", Desc: "Drive document type for docs-to-wiki mode", Enum: wikiMoveObjectTypes}, + {Name: "obj-token", Desc: "Drive document token for docs-to-wiki mode"}, + {Name: "apply", Type: "bool", Desc: "submit a move request when the caller lacks permission to move the document immediately"}, + }, + Tips: []string{ + "Use --node-token to move an existing wiki node inside or across wiki spaces.", + "Use --obj-type and --obj-token to move a Drive document into Wiki.", + "If docs-to-wiki returns a long-running task, 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 { + spec := readWikiMoveSpec(runtime) + // `my_library` is a per-user personal-library alias; it has no meaning + // for a tenant_access_token (--as bot), so reject early with a clear + // hint instead of letting the API return a confusing error. + if runtime.As().IsBot() && spec.TargetSpaceID == wikiMyLibrarySpaceID { + return output.ErrValidation("--target-space-id my_library is a per-user personal library alias and cannot be used with --as bot; resolve it to a real space_id first via `lark-cli wiki spaces get --params '{\"space_id\":\"my_library\"}' --as user`") + } + return validateWikiMoveSpec(spec) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return buildWikiMoveDryRun(readWikiMoveSpec(runtime)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spec := readWikiMoveSpec(runtime) + fmt.Fprintf(runtime.IO().ErrOut, "Running wiki move (%s)...\n", spec.Mode()) + + out, err := runWikiMove(ctx, wikiMoveAPI{runtime: runtime}, runtime, spec) + if err != nil { + return err + } + + runtime.Out(out, nil) + return nil + }, +} + +type wikiMoveSpec struct { + NodeToken string + SourceSpaceID string + TargetSpaceID string + TargetParentToken string + ObjType string + ObjToken string + Apply bool +} + +func (spec wikiMoveSpec) Mode() string { + if spec.NodeToken != "" { + return wikiMoveModeNode + } + return wikiMoveModeDocsToWiki +} + +func (spec wikiMoveSpec) NodeMoveBody() map[string]interface{} { + body := map[string]interface{}{} + if spec.TargetParentToken != "" { + body["target_parent_token"] = spec.TargetParentToken + } + if spec.TargetSpaceID != "" { + body["target_space_id"] = spec.TargetSpaceID + } + return body +} + +func (spec wikiMoveSpec) DocsToWikiBody() map[string]interface{} { + body := map[string]interface{}{ + "obj_type": spec.ObjType, + "obj_token": spec.ObjToken, + } + if spec.TargetParentToken != "" { + body["parent_wiki_token"] = spec.TargetParentToken + } + if spec.Apply { + body["apply"] = true + } + return body +} + +type wikiMoveTaskResult struct { + Node *wikiNodeRecord + Status int + StatusMsg string +} + +type wikiMoveTaskStatus struct { + TaskID string + MoveResults []wikiMoveTaskResult +} + +func (s wikiMoveTaskStatus) Ready() bool { + if len(s.MoveResults) == 0 { + return false + } + for _, result := range s.MoveResults { + if result.Status != 0 { + return false + } + } + return true +} + +func (s wikiMoveTaskStatus) Failed() bool { + for _, result := range s.MoveResults { + if result.Status < 0 { + return true + } + } + return false +} + +func (s wikiMoveTaskStatus) Pending() bool { + return !s.Ready() && !s.Failed() +} + +func (s wikiMoveTaskStatus) FirstResult() *wikiMoveTaskResult { + if len(s.MoveResults) == 0 { + return nil + } + return &s.MoveResults[0] +} + +// primaryResult picks the most informative move_result for top-level status +// surfacing: prefer a failing entry so multi-doc tasks don't mask failures +// behind an earlier success, then a still-processing entry, and finally fall +// back to the first entry. +func (s wikiMoveTaskStatus) primaryResult() *wikiMoveTaskResult { + for i := range s.MoveResults { + if s.MoveResults[i].Status < 0 { + return &s.MoveResults[i] + } + } + for i := range s.MoveResults { + if s.MoveResults[i].Status > 0 { + return &s.MoveResults[i] + } + } + return s.FirstResult() +} + +func (s wikiMoveTaskStatus) PrimaryStatusCode() int { + if r := s.primaryResult(); r != nil { + return r.Status + } + return 1 +} + +func (s wikiMoveTaskStatus) PrimaryStatusLabel() string { + if r := s.primaryResult(); r != nil { + if msg := strings.TrimSpace(r.StatusMsg); msg != "" { + return msg + } + } + switch { + case s.Ready(): + return "success" + case s.Failed(): + return "failure" + default: + return "processing" + } +} + +type wikiMoveDocsResponse struct { + WikiToken string + TaskID string + Applied bool +} + +type wikiMoveClient interface { + GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) + MoveNode(ctx context.Context, sourceSpaceID string, spec wikiMoveSpec) (*wikiNodeRecord, error) + MoveDocsToWiki(ctx context.Context, targetSpaceID string, spec wikiMoveSpec) (*wikiMoveDocsResponse, error) + GetMoveTask(ctx context.Context, taskID string) (wikiMoveTaskStatus, error) +} + +type wikiMoveAPI struct { + runtime *common.RuntimeContext +} + +func (api wikiMoveAPI) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) { + data, err := api.runtime.CallAPI( + "GET", + "/open-apis/wiki/v2/spaces/get_node", + map[string]interface{}{"token": token}, + nil, + ) + if err != nil { + return nil, err + } + return parseWikiNodeRecord(common.GetMap(data, "node")) +} + +func (api wikiMoveAPI) MoveNode(ctx context.Context, sourceSpaceID string, spec wikiMoveSpec) (*wikiNodeRecord, error) { + data, err := api.runtime.CallAPI( + "POST", + fmt.Sprintf( + "/open-apis/wiki/v2/spaces/%s/nodes/%s/move", + validate.EncodePathSegment(sourceSpaceID), + validate.EncodePathSegment(spec.NodeToken), + ), + nil, + spec.NodeMoveBody(), + ) + if err != nil { + return nil, err + } + return parseWikiNodeRecord(common.GetMap(data, "node")) +} + +func (api wikiMoveAPI) MoveDocsToWiki(ctx context.Context, targetSpaceID string, spec wikiMoveSpec) (*wikiMoveDocsResponse, error) { + data, err := api.runtime.CallAPI( + "POST", + fmt.Sprintf( + "/open-apis/wiki/v2/spaces/%s/nodes/move_docs_to_wiki", + validate.EncodePathSegment(targetSpaceID), + ), + nil, + spec.DocsToWikiBody(), + ) + if err != nil { + return nil, err + } + + return &wikiMoveDocsResponse{ + WikiToken: common.GetString(data, "wiki_token"), + TaskID: common.GetString(data, "task_id"), + Applied: common.GetBool(data, "applied"), + }, nil +} + +func (api wikiMoveAPI) GetMoveTask(ctx context.Context, taskID string) (wikiMoveTaskStatus, error) { + data, err := api.runtime.CallAPI( + "GET", + fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)), + map[string]interface{}{"task_type": "move"}, + nil, + ) + if err != nil { + return wikiMoveTaskStatus{}, err + } + return parseWikiMoveTaskStatus(taskID, common.GetMap(data, "task")) +} + +func readWikiMoveSpec(runtime *common.RuntimeContext) wikiMoveSpec { + return wikiMoveSpec{ + NodeToken: strings.TrimSpace(runtime.Str("node-token")), + SourceSpaceID: strings.TrimSpace(runtime.Str("source-space-id")), + TargetSpaceID: strings.TrimSpace(runtime.Str("target-space-id")), + TargetParentToken: strings.TrimSpace(runtime.Str("target-parent-token")), + ObjType: strings.ToLower(strings.TrimSpace(runtime.Str("obj-type"))), + ObjToken: strings.TrimSpace(runtime.Str("obj-token")), + Apply: runtime.Bool("apply"), + } +} + +func validateWikiMoveSpec(spec wikiMoveSpec) error { + if err := validateOptionalResourceName(spec.NodeToken, "--node-token"); err != nil { + return err + } + if err := validateOptionalResourceName(spec.SourceSpaceID, "--source-space-id"); err != nil { + return err + } + if err := validateOptionalResourceName(spec.TargetSpaceID, "--target-space-id"); err != nil { + return err + } + if err := validateOptionalResourceName(spec.TargetParentToken, "--target-parent-token"); err != nil { + return err + } + if err := validateOptionalResourceName(spec.ObjToken, "--obj-token"); err != nil { + return err + } + + if spec.NodeToken != "" { + if spec.ObjType != "" || spec.ObjToken != "" || spec.Apply { + return output.ErrValidation("--node-token cannot be combined with --obj-type, --obj-token, or --apply") + } + if spec.TargetParentToken == "" && spec.TargetSpaceID == "" { + return output.ErrValidation("--target-parent-token and --target-space-id cannot both be empty for wiki node move") + } + return nil + } + + if spec.SourceSpaceID != "" { + return output.ErrValidation("--source-space-id can only be used with --node-token") + } + if spec.ObjType == "" && spec.ObjToken == "" && !spec.Apply { + return output.ErrValidation("provide --node-token for wiki node move, or provide --obj-type and --obj-token for docs-to-wiki move") + } + if spec.ObjType == "" { + return output.ErrValidation("--obj-type is required for docs-to-wiki move") + } + if spec.ObjToken == "" { + return output.ErrValidation("--obj-token is required for docs-to-wiki move") + } + if spec.TargetSpaceID == "" { + return output.ErrValidation("--target-space-id is required for docs-to-wiki move") + } + + return nil +} + +func buildWikiMoveDryRun(spec wikiMoveSpec) *common.DryRunAPI { + dry := common.NewDryRunAPI() + switch spec.Mode() { + case wikiMoveModeNode: + step := 1 + switch { + case spec.SourceSpaceID == "" && spec.TargetParentToken != "": + dry.Desc("3-step orchestration: resolve source node -> resolve target parent -> move wiki node") + case spec.SourceSpaceID == "": + dry.Desc("2-step orchestration: resolve source node -> move wiki node") + case spec.TargetParentToken != "": + dry.Desc("2-step orchestration: resolve target parent -> move wiki node") + default: + dry.Desc("1-step request: move wiki node") + } + + if spec.SourceSpaceID == "" { + dry.GET("/open-apis/wiki/v2/spaces/get_node"). + Desc(fmt.Sprintf("[%d] Resolve source space from node token", step)). + Params(map[string]interface{}{"token": spec.NodeToken}) + step++ + } + if spec.TargetParentToken != "" { + dry.GET("/open-apis/wiki/v2/spaces/get_node"). + Desc(fmt.Sprintf("[%d] Resolve target parent node", step)). + Params(map[string]interface{}{"token": spec.TargetParentToken}) + step++ + } + + dry.POST(fmt.Sprintf( + "/open-apis/wiki/v2/spaces/%s/nodes/%s/move", + dryRunWikiMoveSourceSpaceID(spec), + validate.EncodePathSegment(spec.NodeToken), + )). + Desc(fmt.Sprintf("[%d] Move wiki node", step)). + Body(spec.NodeMoveBody()) + case wikiMoveModeDocsToWiki: + dry.Desc("2-step orchestration: move Drive document into Wiki -> poll wiki task result when task_id is returned") + dry.POST(fmt.Sprintf( + "/open-apis/wiki/v2/spaces/%s/nodes/move_docs_to_wiki", + dryRunWikiMoveTargetSpaceID(spec), + )). + Desc("[1] Move Drive document into Wiki"). + Body(spec.DocsToWikiBody()) + dry.GET("/open-apis/wiki/v2/tasks/:task_id"). + Desc("[2] Poll wiki move task result when async"). + Set("task_id", ""). + Params(map[string]interface{}{"task_type": "move"}) + default: + dry.Set("error", "unknown wiki move mode") + } + return dry +} + +func dryRunWikiMoveSourceSpaceID(spec wikiMoveSpec) string { + if spec.SourceSpaceID != "" { + return validate.EncodePathSegment(spec.SourceSpaceID) + } + return "" +} + +func dryRunWikiMoveTargetSpaceID(spec wikiMoveSpec) string { + if spec.TargetSpaceID != "" { + return validate.EncodePathSegment(spec.TargetSpaceID) + } + return "" +} + +func runWikiMove(ctx context.Context, client wikiMoveClient, runtime *common.RuntimeContext, spec wikiMoveSpec) (map[string]interface{}, error) { + switch spec.Mode() { + case wikiMoveModeNode: + return runWikiNodeMove(ctx, client, spec) + case wikiMoveModeDocsToWiki: + return runWikiDocsToWikiMove(ctx, client, runtime, spec) + default: + return nil, output.ErrValidation("unknown wiki move mode") + } +} + +func runWikiNodeMove(ctx context.Context, client wikiMoveClient, spec wikiMoveSpec) (map[string]interface{}, error) { + sourceSpaceID, targetSpaceID, err := resolveWikiNodeMoveSpaces(ctx, client, spec) + if err != nil { + return nil, err + } + + node, err := client.MoveNode(ctx, sourceSpaceID, spec) + if err != nil { + return nil, err + } + + out := map[string]interface{}{ + "mode": wikiMoveModeNode, + "source_space_id": sourceSpaceID, + "target_space_id": targetSpaceID, + } + appendWikiNodeOutput(out, node) + return out, nil +} + +func resolveWikiNodeMoveSpaces(ctx context.Context, client wikiMoveClient, spec wikiMoveSpec) (string, string, error) { + // Node move requests may start from just a node token and/or a target parent. + // Resolve both ends up front so we can fail on space mismatches before sending + // the mutation request. + sourceSpaceID := spec.SourceSpaceID + if sourceSpaceID == "" { + sourceNode, err := client.GetNode(ctx, spec.NodeToken) + if err != nil { + return "", "", err + } + sourceSpaceID, err = requireWikiNodeSpaceID(sourceNode) + if err != nil { + return "", "", err + } + } + + targetSpaceID := spec.TargetSpaceID + if spec.TargetParentToken != "" { + targetParent, err := client.GetNode(ctx, spec.TargetParentToken) + if err != nil { + return "", "", err + } + parentSpaceID, err := requireWikiNodeSpaceID(targetParent) + if err != nil { + return "", "", err + } + if targetSpaceID == "" { + targetSpaceID = parentSpaceID + } else if targetSpaceID != parentSpaceID { + return "", "", output.ErrValidation( + "--target-space-id %q does not match target parent node space %q", + spec.TargetSpaceID, + parentSpaceID, + ) + } + } + + if targetSpaceID == "" { + targetSpaceID = sourceSpaceID + } + + return sourceSpaceID, targetSpaceID, nil +} + +func runWikiDocsToWikiMove(ctx context.Context, client wikiMoveClient, runtime *common.RuntimeContext, spec wikiMoveSpec) (map[string]interface{}, error) { + response, err := client.MoveDocsToWiki(ctx, spec.TargetSpaceID, spec) + if err != nil { + return nil, err + } + + out := map[string]interface{}{ + "mode": wikiMoveModeDocsToWiki, + "obj_type": spec.ObjType, + "obj_token": spec.ObjToken, + "target_space_id": spec.TargetSpaceID, + "target_parent_token": spec.TargetParentToken, + } + + // move_docs_to_wiki has three success-shaped responses: immediate completion, + // approval-request submission, or an async task that must be polled. + switch { + case response.WikiToken != "": + out["ready"] = true + out["failed"] = false + out["wiki_token"] = response.WikiToken + out["node_token"] = response.WikiToken + return out, nil + case response.Applied: + out["ready"] = false + out["failed"] = false + out["applied"] = true + out["status_msg"] = "move request submitted for approval" + return out, nil + case response.TaskID != "": + fmt.Fprintf(runtime.IO().ErrOut, "Docs-to-wiki move is async, polling task %s...\n", response.TaskID) + status, ready, err := pollWikiMoveTask(ctx, client, runtime, response.TaskID) + if err != nil { + return nil, err + } + + out["task_id"] = response.TaskID + out["ready"] = ready + out["failed"] = status.Failed() + out["status"] = status.PrimaryStatusCode() + out["status_msg"] = status.PrimaryStatusLabel() + if first := status.FirstResult(); first != nil { + appendWikiNodeOutput(out, first.Node) + if first.Node != nil && first.Node.NodeToken != "" { + out["wiki_token"] = first.Node.NodeToken + } + } + if !ready { + nextCommand := wikiMoveTaskResultCommand(response.TaskID, runtime.As()) + fmt.Fprintf(runtime.IO().ErrOut, "Wiki move task is still in progress. Continue with: %s\n", nextCommand) + out["timed_out"] = true + out["next_command"] = nextCommand + } + return out, nil + default: + return nil, output.Errorf(output.ExitAPI, "api_error", "move_docs_to_wiki returned neither wiki_token, task_id, nor applied result") + } +} + +func wikiMoveTaskResultCommand(taskID string, identity core.Identity) string { + asFlag := string(identity) + if asFlag == "" { + asFlag = "user" + } + return fmt.Sprintf("lark-cli drive +task_result --scenario wiki_move --task-id %s --as %s", taskID, asFlag) +} + +func pollWikiMoveTask(ctx context.Context, client wikiMoveClient, runtime *common.RuntimeContext, taskID string) (wikiMoveTaskStatus, bool, error) { + lastStatus := wikiMoveTaskStatus{TaskID: taskID} + var lastErr error + hadSuccessfulPoll := false + + // The move request itself 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 <= wikiMovePollAttempts; attempt++ { + if attempt > 1 { + select { + case <-ctx.Done(): + return lastStatus, false, ctx.Err() + case <-time.After(wikiMovePollInterval): + } + } + + status, err := client.GetMoveTask(ctx, taskID) + if err != nil { + lastErr = err + fmt.Fprintf(runtime.IO().ErrOut, "Wiki move status attempt %d/%d failed: %v\n", attempt, wikiMovePollAttempts, err) + continue + } + lastStatus = status + hadSuccessfulPoll = true + + if status.Ready() { + fmt.Fprintf(runtime.IO().ErrOut, "Wiki move task completed successfully.\n") + return status, true, nil + } + if status.Failed() { + return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki move task failed: %s", status.PrimaryStatusLabel()) + } + + fmt.Fprintf(runtime.IO().ErrOut, "Wiki move status %d/%d: %s\n", attempt, wikiMovePollAttempts, status.PrimaryStatusLabel()) + } + + if !hadSuccessfulPoll && lastErr != nil { + nextCommand := wikiMoveTaskResultCommand(taskID, runtime.As()) + hint := fmt.Sprintf( + "the wiki move 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 +} + +func parseWikiMoveTaskStatus(taskID string, task map[string]interface{}) (wikiMoveTaskStatus, error) { + if task == nil { + return wikiMoveTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task") + } + + status := wikiMoveTaskStatus{ + TaskID: common.GetString(task, "task_id"), + } + if status.TaskID == "" { + status.TaskID = taskID + } + + for _, item := range common.GetSlice(task, "move_result") { + resultMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + + var node *wikiNodeRecord + if nodeMap := common.GetMap(resultMap, "node"); nodeMap != nil { + parsedNode, err := parseWikiNodeRecord(nodeMap) + if err != nil { + return wikiMoveTaskStatus{}, err + } + node = parsedNode + } + + status.MoveResults = append(status.MoveResults, wikiMoveTaskResult{ + Node: node, + Status: int(common.GetFloat(resultMap, "status")), + StatusMsg: common.GetString(resultMap, "status_msg"), + }) + } + + return status, nil +} + +func appendWikiNodeOutput(out map[string]interface{}, node *wikiNodeRecord) { + if out == nil || node == nil { + return + } + out["space_id"] = node.SpaceID + out["node_token"] = node.NodeToken + out["obj_token"] = node.ObjToken + out["obj_type"] = node.ObjType + out["parent_node_token"] = node.ParentNodeToken + out["node_type"] = node.NodeType + out["origin_node_token"] = node.OriginNodeToken + out["title"] = node.Title + out["has_child"] = node.HasChild +} diff --git a/shortcuts/wiki/wiki_move_test.go b/shortcuts/wiki/wiki_move_test.go new file mode 100644 index 000000000..bfae5b212 --- /dev/null +++ b/shortcuts/wiki/wiki_move_test.go @@ -0,0 +1,905 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package wiki + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "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/credential" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +type fakeWikiMoveNodeCall struct { + SourceSpaceID string + Spec wikiMoveSpec +} + +type fakeWikiDocsToWikiMoveCall struct { + TargetSpaceID string + Spec wikiMoveSpec +} + +type fakeWikiMoveClient struct { + nodes map[string]*wikiNodeRecord + + getNodeErr error + moveNode *wikiNodeRecord + moveNodeErr error + docsResp *wikiMoveDocsResponse + docsErr error + + taskStatuses []wikiMoveTaskStatus + taskErrs []error + + getNodeCalls []string + moveNodeCalls []fakeWikiMoveNodeCall + docsToWikiCalls []fakeWikiDocsToWikiMoveCall + moveTaskCallArgs []string +} + +func (fake *fakeWikiMoveClient) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) { + fake.getNodeCalls = append(fake.getNodeCalls, token) + if fake.getNodeErr != nil { + return nil, fake.getNodeErr + } + if node, ok := fake.nodes[token]; ok { + return node, nil + } + return &wikiNodeRecord{}, nil +} + +func (fake *fakeWikiMoveClient) MoveNode(ctx context.Context, sourceSpaceID string, spec wikiMoveSpec) (*wikiNodeRecord, error) { + fake.moveNodeCalls = append(fake.moveNodeCalls, fakeWikiMoveNodeCall{SourceSpaceID: sourceSpaceID, Spec: spec}) + if fake.moveNodeErr != nil { + return nil, fake.moveNodeErr + } + if fake.moveNode != nil { + return fake.moveNode, nil + } + return &wikiNodeRecord{SpaceID: sourceSpaceID, NodeToken: spec.NodeToken}, nil +} + +func (fake *fakeWikiMoveClient) MoveDocsToWiki(ctx context.Context, targetSpaceID string, spec wikiMoveSpec) (*wikiMoveDocsResponse, error) { + fake.docsToWikiCalls = append(fake.docsToWikiCalls, fakeWikiDocsToWikiMoveCall{TargetSpaceID: targetSpaceID, Spec: spec}) + if fake.docsErr != nil { + return nil, fake.docsErr + } + if fake.docsResp != nil { + return fake.docsResp, nil + } + return &wikiMoveDocsResponse{}, nil +} + +func (fake *fakeWikiMoveClient) GetMoveTask(ctx context.Context, taskID string) (wikiMoveTaskStatus, error) { + idx := len(fake.moveTaskCallArgs) + fake.moveTaskCallArgs = append(fake.moveTaskCallArgs, taskID) + if idx < len(fake.taskErrs) && fake.taskErrs[idx] != nil { + return wikiMoveTaskStatus{TaskID: taskID}, fake.taskErrs[idx] + } + if idx < len(fake.taskStatuses) { + status := fake.taskStatuses[idx] + if status.TaskID == "" { + status.TaskID = taskID + } + return status, nil + } + return wikiMoveTaskStatus{TaskID: taskID}, nil +} + +type mockWikiMoveTokenResolver struct { + token string + scopes string + err error +} + +type wikiMoveAccountResolver struct { + cfg *core.CliConfig +} + +func (r *wikiMoveAccountResolver) ResolveAccount(ctx context.Context) (*credential.Account, error) { + return credential.AccountFromCliConfig(r.cfg), nil +} + +func (m *mockWikiMoveTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) { + if m.err != nil { + return nil, m.err + } + token := m.token + if token == "" { + token = "test-token" + } + return &credential.TokenResult{Token: token, Scopes: m.scopes}, nil +} + +var wikiMovePollMu sync.Mutex + +func withSingleWikiMovePoll(t *testing.T) { + t.Helper() + wikiMovePollMu.Lock() + + prevAttempts, prevInterval := wikiMovePollAttempts, wikiMovePollInterval + wikiMovePollAttempts, wikiMovePollInterval = 1, 0 + t.Cleanup(func() { + wikiMovePollAttempts, wikiMovePollInterval = prevAttempts, prevInterval + wikiMovePollMu.Unlock() + }) +} + +func newWikiMoveRuntimeWithScopes(t *testing.T, as core.Identity, scopes string) (*common.RuntimeContext, *bytes.Buffer) { + t.Helper() + + cfg := wikiTestConfig() + factory, _, stderr, _ := cmdutil.TestFactory(t, cfg) + factory.Credential = credential.NewCredentialProvider(nil, nil, &mockWikiMoveTokenResolver{scopes: scopes}, nil) + + runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "wiki +move"}, cfg, as) + runtime.Factory = factory + return runtime, stderr +} + +func decodeWikiEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} { + t.Helper() + + var env struct { + OK bool `json:"ok"` + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("unmarshal wiki envelope: %v\nstdout=%s", err, stdout.String()) + } + if !env.OK { + t.Fatalf("expected ok=true envelope, got stdout=%s", stdout.String()) + } + return env.Data +} + +func decodeWikiCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interface{} { + t.Helper() + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("unmarshal captured body: %v\nraw=%s", err, string(stub.CapturedBody)) + } + return body +} + +func TestValidateWikiMoveSpecRejectsInvalidCombinations(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spec wikiMoveSpec + wantErr string + }{ + { + name: "node move rejects docs flags", + spec: wikiMoveSpec{NodeToken: "wik_node", ObjType: "sheet", TargetSpaceID: "space_dst"}, + wantErr: "cannot be combined", + }, + { + name: "node move requires target", + spec: wikiMoveSpec{NodeToken: "wik_node"}, + wantErr: "cannot both be empty", + }, + { + name: "source space requires node token", + spec: wikiMoveSpec{SourceSpaceID: "space_src", ObjType: "sheet", ObjToken: "sheet_token", TargetSpaceID: "space_dst"}, + wantErr: "can only be used with --node-token", + }, + { + name: "docs to wiki requires obj type", + spec: wikiMoveSpec{ObjToken: "sheet_token", TargetSpaceID: "space_dst"}, + wantErr: "--obj-type is required", + }, + { + name: "docs to wiki requires obj token", + spec: wikiMoveSpec{ObjType: "sheet", TargetSpaceID: "space_dst"}, + wantErr: "--obj-token is required", + }, + { + name: "docs to wiki requires target space", + spec: wikiMoveSpec{ObjType: "sheet", ObjToken: "sheet_token"}, + wantErr: "--target-space-id is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateWikiMoveSpec(tt.spec) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + }) + } +} + +func TestValidateWikiMoveSpecAcceptsValidModes(t *testing.T) { + t.Parallel() + + for _, spec := range []wikiMoveSpec{ + {NodeToken: "wik_node", TargetSpaceID: "space_dst"}, + {ObjType: "sheet", ObjToken: "sheet_token", TargetSpaceID: "space_dst", TargetParentToken: "wik_parent", Apply: true}, + } { + if err := validateWikiMoveSpec(spec); err != nil { + t.Fatalf("validateWikiMoveSpec(%+v) error = %v", spec, err) + } + } +} + +func TestWikiMoveDeclaredScopes(t *testing.T) { + t.Parallel() + + want := []string{"wiki:node:move", "wiki:node:read", "wiki:space:read"} + if !reflect.DeepEqual(WikiMove.Scopes, want) { + t.Fatalf("WikiMove.Scopes = %v, want %v", WikiMove.Scopes, want) + } +} + +func TestWikiMoveShortcutMissingDeclaredScope(t *testing.T) { + cfg := wikiTestConfig() + factory, stdout, _, _ := cmdutil.TestFactory(t, cfg) + factory.Credential = credential.NewCredentialProvider(nil, &wikiMoveAccountResolver{cfg: cfg}, &mockWikiMoveTokenResolver{scopes: "wiki:node:read"}, nil) + + err := mountAndRunWiki(t, WikiMove, []string{ + "+move", + "--node-token", "wik_node", + "--target-space-id", "space_dst", + "--as", "user", + }, factory, stdout) + if err == nil { + t.Fatal("expected missing scope error, got nil") + } + if !strings.Contains(err.Error(), "missing required scope(s): wiki:node:move") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestWikiMoveTaskStatusPendingAndFallbackLabels(t *testing.T) { + t.Parallel() + + pending := wikiMoveTaskStatus{} + if !pending.Pending() || pending.PrimaryStatusLabel() != "processing" { + t.Fatalf("pending status = %+v", pending) + } + + ready := wikiMoveTaskStatus{MoveResults: []wikiMoveTaskResult{{Status: 0}}} + if !ready.Ready() || ready.PrimaryStatusLabel() != "success" { + t.Fatalf("ready status = %+v", ready) + } + + failed := wikiMoveTaskStatus{MoveResults: []wikiMoveTaskResult{{Status: -1}}} + if !failed.Failed() || failed.PrimaryStatusLabel() != "failure" { + t.Fatalf("failed status = %+v", failed) + } +} + +func TestWikiMoveTaskStatusPrimarySurfacesFailureOverEarlierSuccess(t *testing.T) { + t.Parallel() + + status := wikiMoveTaskStatus{ + MoveResults: []wikiMoveTaskResult{ + {Status: 0, StatusMsg: "success"}, + {Status: -3, StatusMsg: "permission denied"}, + {Status: 1, StatusMsg: "processing"}, + }, + } + if got := status.PrimaryStatusCode(); got != -3 { + t.Fatalf("PrimaryStatusCode = %d, want -3", got) + } + if got := status.PrimaryStatusLabel(); got != "permission denied" { + t.Fatalf("PrimaryStatusLabel = %q, want permission denied", got) + } + // FirstResult must keep its literal "first entry" semantics for callers + // that flatten node fields from the first move_result. + if first := status.FirstResult(); first == nil || first.StatusMsg != "success" { + t.Fatalf("FirstResult = %+v, want first success entry", first) + } +} + +func TestWikiMoveTaskStatusPrimaryPrefersProcessingOverFirstSuccess(t *testing.T) { + t.Parallel() + + status := wikiMoveTaskStatus{ + MoveResults: []wikiMoveTaskResult{ + {Status: 0, StatusMsg: "success"}, + {Status: 1, StatusMsg: "processing"}, + }, + } + if got := status.PrimaryStatusCode(); got != 1 { + t.Fatalf("PrimaryStatusCode = %d, want 1", got) + } + if got := status.PrimaryStatusLabel(); got != "processing" { + t.Fatalf("PrimaryStatusLabel = %q, want processing", got) + } +} + +func TestWikiMoveValidateRejectsBotMyLibrary(t *testing.T) { + cfg := wikiTestConfig() + factory, stdout, _, _ := cmdutil.TestFactory(t, cfg) + + err := mountAndRunWiki(t, WikiMove, []string{ + "+move", + "--obj-type", "docx", + "--obj-token", "doccnXXX", + "--target-space-id", "my_library", + "--as", "bot", + }, factory, stdout) + if err == nil { + t.Fatal("expected validation error for bot + my_library, got nil") + } + if !strings.Contains(err.Error(), "my_library") || !strings.Contains(err.Error(), "--as bot") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestWikiMoveValidateAllowsUserMyLibrary(t *testing.T) { + t.Parallel() + + // Bot guard must not affect user identity. We only assert the my_library + // validation path doesn't trip; an empty obj-token still fails downstream + // for unrelated reasons, so we check the error does not mention my_library. + if err := validateWikiMoveSpec(wikiMoveSpec{ + ObjType: "docx", + ObjToken: "doccnXXX", + TargetSpaceID: "my_library", + }); err != nil { + t.Fatalf("validateWikiMoveSpec(user my_library) = %v, want nil", err) + } +} + +func TestWikiMoveDryRunNodeMoveIncludesResolutionSteps(t *testing.T) { + t.Parallel() + + cmd := &cobra.Command{Use: "wiki +move"} + cmd.Flags().String("node-token", "", "") + cmd.Flags().String("source-space-id", "", "") + cmd.Flags().String("target-space-id", "", "") + cmd.Flags().String("target-parent-token", "", "") + cmd.Flags().String("obj-type", "", "") + cmd.Flags().String("obj-token", "", "") + cmd.Flags().Bool("apply", false, "") + if err := cmd.Flags().Set("node-token", "wik_node"); err != nil { + t.Fatalf("set --node-token: %v", err) + } + if err := cmd.Flags().Set("target-parent-token", "wik_parent"); err != nil { + t.Fatalf("set --target-parent-token: %v", err) + } + + runtime := common.TestNewRuntimeContext(cmd, nil) + dry := WikiMove.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) + } + if !bytes.Contains(data, []byte(`"description":"3-step orchestration:`)) { + t.Fatalf("dry run missing 3-step description: %s", string(data)) + } + if !bytes.Contains(data, []byte(`"target_parent_token":"wik_parent"`)) { + t.Fatalf("dry run missing target_parent_token body: %s", string(data)) + } + if !bytes.Contains(data, []byte(`/open-apis/wiki/v2/spaces/\u003cresolved_source_space_id\u003e/nodes/wik_node/move`)) { + t.Fatalf("dry run missing resolved source placeholder: %s", string(data)) + } +} + +func TestWikiMoveDryRunDocsToWikiIncludesTaskPoll(t *testing.T) { + t.Parallel() + + cmd := &cobra.Command{Use: "wiki +move"} + cmd.Flags().String("node-token", "", "") + cmd.Flags().String("source-space-id", "", "") + cmd.Flags().String("target-space-id", "", "") + cmd.Flags().String("target-parent-token", "", "") + cmd.Flags().String("obj-type", "", "") + cmd.Flags().String("obj-token", "", "") + cmd.Flags().Bool("apply", false, "") + if err := cmd.Flags().Set("obj-type", "sheet"); err != nil { + t.Fatalf("set --obj-type: %v", err) + } + if err := cmd.Flags().Set("obj-token", "sheet_token"); err != nil { + t.Fatalf("set --obj-token: %v", err) + } + if err := cmd.Flags().Set("target-space-id", "space_dst"); err != nil { + t.Fatalf("set --target-space-id: %v", err) + } + if err := cmd.Flags().Set("apply", "true"); err != nil { + t.Fatalf("set --apply: %v", err) + } + + runtime := common.TestNewRuntimeContext(cmd, nil) + dry := WikiMove.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) + } + if !bytes.Contains(data, []byte(`"obj_type":"sheet"`)) || !bytes.Contains(data, []byte(`"apply":true`)) { + t.Fatalf("dry run missing docs-to-wiki body: %s", string(data)) + } + if !bytes.Contains(data, []byte(`"task_type":"move"`)) { + t.Fatalf("dry run missing task polling params: %s", string(data)) + } +} + +func TestResolveWikiNodeMoveSpacesUsesSourceAndTargetLookups(t *testing.T) { + t.Parallel() + + client := &fakeWikiMoveClient{ + nodes: map[string]*wikiNodeRecord{ + "wik_node": {SpaceID: "space_src"}, + "wik_parent": {SpaceID: "space_dst"}, + }, + } + + sourceSpaceID, targetSpaceID, err := resolveWikiNodeMoveSpaces(context.Background(), client, wikiMoveSpec{ + NodeToken: "wik_node", + TargetParentToken: "wik_parent", + }) + if err != nil { + t.Fatalf("resolveWikiNodeMoveSpaces() error = %v", err) + } + if sourceSpaceID != "space_src" || targetSpaceID != "space_dst" { + t.Fatalf("resolved spaces = (%q, %q), want (%q, %q)", sourceSpaceID, targetSpaceID, "space_src", "space_dst") + } + if strings.Join(client.getNodeCalls, ",") != "wik_node,wik_parent" { + t.Fatalf("getNodeCalls = %v, want source and target-parent lookups", client.getNodeCalls) + } +} + +func TestResolveWikiNodeMoveSpacesRejectsTargetSpaceMismatch(t *testing.T) { + t.Parallel() + + client := &fakeWikiMoveClient{ + nodes: map[string]*wikiNodeRecord{ + "wik_parent": {SpaceID: "space_parent"}, + }, + } + + _, _, err := resolveWikiNodeMoveSpaces(context.Background(), client, wikiMoveSpec{ + NodeToken: "wik_node", + SourceSpaceID: "space_src", + TargetSpaceID: "space_other", + TargetParentToken: "wik_parent", + }) + if err == nil || !strings.Contains(err.Error(), "does not match") { + t.Fatalf("expected mismatch error, got %v", err) + } +} + +func TestRunWikiNodeMoveReturnsResolvedMetadata(t *testing.T) { + t.Parallel() + + client := &fakeWikiMoveClient{ + nodes: map[string]*wikiNodeRecord{ + "wik_node": {SpaceID: "space_src"}, + "wik_parent": {SpaceID: "space_dst"}, + }, + moveNode: &wikiNodeRecord{ + SpaceID: "space_dst", + NodeToken: "wik_moved", + ObjToken: "sheet_token", + ObjType: "sheet", + ParentNodeToken: "wik_parent", + NodeType: wikiNodeTypeOrigin, + Title: "Roadmap", + }, + } + + out, err := runWikiNodeMove(context.Background(), client, wikiMoveSpec{ + NodeToken: "wik_node", + TargetParentToken: "wik_parent", + }) + if err != nil { + t.Fatalf("runWikiNodeMove() error = %v", err) + } + if len(client.moveNodeCalls) != 1 { + t.Fatalf("MoveNode called %d times, want 1", len(client.moveNodeCalls)) + } + if client.moveNodeCalls[0].SourceSpaceID != "space_src" { + t.Fatalf("source space = %q, want %q", client.moveNodeCalls[0].SourceSpaceID, "space_src") + } + if out["mode"] != wikiMoveModeNode || out["source_space_id"] != "space_src" || out["target_space_id"] != "space_dst" { + t.Fatalf("unexpected node move output: %#v", out) + } + if out["node_token"] != "wik_moved" || out["title"] != "Roadmap" { + t.Fatalf("node fields not propagated: %#v", out) + } +} + +func TestRunWikiMoveDispatchesByMode(t *testing.T) { + t.Parallel() + + runtime, _ := newWikiMoveRuntimeWithScopes(t, core.AsUser, "") + client := &fakeWikiMoveClient{ + docsResp: &wikiMoveDocsResponse{WikiToken: "wik_ready"}, + moveNode: &wikiNodeRecord{SpaceID: "space_dst", NodeToken: "wik_node"}, + } + + nodeOut, err := runWikiMove(context.Background(), client, runtime, wikiMoveSpec{ + NodeToken: "wik_node", + SourceSpaceID: "space_src", + TargetSpaceID: "space_dst", + }) + if err != nil { + t.Fatalf("runWikiMove(node) error = %v", err) + } + if nodeOut["mode"] != wikiMoveModeNode { + t.Fatalf("node mode output = %#v", nodeOut) + } + + docsOut, err := runWikiMove(context.Background(), client, runtime, wikiMoveSpec{ + ObjType: "sheet", + ObjToken: "sheet_token", + TargetSpaceID: "space_dst", + }) + if err != nil { + t.Fatalf("runWikiMove(docs_to_wiki) error = %v", err) + } + if docsOut["mode"] != wikiMoveModeDocsToWiki { + t.Fatalf("docs-to-wiki output = %#v", docsOut) + } +} + +func TestRunWikiDocsToWikiMoveSyncReady(t *testing.T) { + t.Parallel() + + runtime, _ := newWikiMoveRuntimeWithScopes(t, core.AsUser, "") + client := &fakeWikiMoveClient{ + docsResp: &wikiMoveDocsResponse{WikiToken: "wik_ready"}, + } + + out, err := runWikiDocsToWikiMove(context.Background(), client, runtime, wikiMoveSpec{ + ObjType: "sheet", + ObjToken: "sheet_token", + TargetSpaceID: "space_dst", + }) + if err != nil { + t.Fatalf("runWikiDocsToWikiMove() error = %v", err) + } + if out["ready"] != true || out["failed"] != false { + t.Fatalf("expected ready sync result, got %#v", out) + } + if out["wiki_token"] != "wik_ready" || out["node_token"] != "wik_ready" { + t.Fatalf("wiki token fields = %#v", out) + } + if len(client.docsToWikiCalls) != 1 || client.docsToWikiCalls[0].TargetSpaceID != "space_dst" { + t.Fatalf("unexpected docs-to-wiki calls: %#v", client.docsToWikiCalls) + } +} + +func TestRunWikiDocsToWikiMoveApplied(t *testing.T) { + t.Parallel() + + runtime, _ := newWikiMoveRuntimeWithScopes(t, core.AsUser, "") + client := &fakeWikiMoveClient{ + docsResp: &wikiMoveDocsResponse{Applied: true}, + } + + out, err := runWikiDocsToWikiMove(context.Background(), client, runtime, wikiMoveSpec{ + ObjType: "sheet", + ObjToken: "sheet_token", + TargetSpaceID: "space_dst", + }) + if err != nil { + t.Fatalf("runWikiDocsToWikiMove() error = %v", err) + } + if out["applied"] != true || out["ready"] != false || out["failed"] != false { + t.Fatalf("expected applied response, got %#v", out) + } + if out["status_msg"] != "move request submitted for approval" { + t.Fatalf("status_msg = %#v", out["status_msg"]) + } +} + +func TestRunWikiDocsToWikiMoveAsyncReady(t *testing.T) { + withSingleWikiMovePoll(t) + + runtime, stderr := newWikiMoveRuntimeWithScopes(t, core.AsUser, "") + client := &fakeWikiMoveClient{ + docsResp: &wikiMoveDocsResponse{TaskID: "task_123"}, + taskStatuses: []wikiMoveTaskStatus{{ + MoveResults: []wikiMoveTaskResult{{ + Status: 0, + StatusMsg: "success", + Node: &wikiNodeRecord{ + SpaceID: "space_dst", + NodeToken: "wik_done", + ObjToken: "sheet_token", + ObjType: "sheet", + NodeType: wikiNodeTypeOrigin, + Title: "Roadmap", + }, + }}, + }}, + } + + out, err := runWikiDocsToWikiMove(context.Background(), client, runtime, wikiMoveSpec{ + ObjType: "sheet", + ObjToken: "sheet_token", + TargetSpaceID: "space_dst", + }) + if err != nil { + t.Fatalf("runWikiDocsToWikiMove() error = %v", err) + } + if out["task_id"] != "task_123" || out["ready"] != true || out["failed"] != false { + t.Fatalf("unexpected async-ready output: %#v", out) + } + if out["wiki_token"] != "wik_done" || out["title"] != "Roadmap" || out["status_msg"] != "success" { + t.Fatalf("async-ready output missing flattened fields: %#v", out) + } + if !strings.Contains(stderr.String(), "Docs-to-wiki move is async") || !strings.Contains(stderr.String(), "completed successfully") { + t.Fatalf("stderr = %q, want async progress logs", stderr.String()) + } +} + +func TestRunWikiDocsToWikiMoveAsyncTimeoutReturnsNextCommand(t *testing.T) { + withSingleWikiMovePoll(t) + + runtime, stderr := newWikiMoveRuntimeWithScopes(t, core.AsUser, "") + client := &fakeWikiMoveClient{ + docsResp: &wikiMoveDocsResponse{TaskID: "task_123"}, + taskStatuses: []wikiMoveTaskStatus{{ + MoveResults: []wikiMoveTaskResult{{Status: 1, StatusMsg: "processing"}}, + }}, + } + + out, err := runWikiDocsToWikiMove(context.Background(), client, runtime, wikiMoveSpec{ + ObjType: "sheet", + ObjToken: "sheet_token", + TargetSpaceID: "space_dst", + }) + if err != nil { + t.Fatalf("runWikiDocsToWikiMove() error = %v", err) + } + if out["ready"] != false || out["timed_out"] != true || out["next_command"] != wikiMoveTaskResultCommand("task_123", core.AsUser) { + t.Fatalf("expected timeout response, got %#v", out) + } + if out["status_msg"] != "processing" { + t.Fatalf("status_msg = %#v, want processing", out["status_msg"]) + } + if !strings.Contains(stderr.String(), "Continue with") { + t.Fatalf("stderr = %q, want continuation hint", stderr.String()) + } +} + +func TestRunWikiDocsToWikiMoveAsyncFailureReturnsStructuredError(t *testing.T) { + withSingleWikiMovePoll(t) + + runtime, _ := newWikiMoveRuntimeWithScopes(t, core.AsUser, "") + client := &fakeWikiMoveClient{ + docsResp: &wikiMoveDocsResponse{TaskID: "task_123"}, + taskStatuses: []wikiMoveTaskStatus{{ + MoveResults: []wikiMoveTaskResult{{Status: -1, StatusMsg: "approval rejected"}}, + }}, + } + + _, err := runWikiDocsToWikiMove(context.Background(), client, runtime, wikiMoveSpec{ + ObjType: "sheet", + ObjToken: "sheet_token", + TargetSpaceID: "space_dst", + }) + if err == nil || !strings.Contains(err.Error(), "wiki move task failed: approval rejected") { + t.Fatalf("expected async failure error, got %v", err) + } +} + +func TestWikiMoveExecuteNodeShortcut(t *testing.T) { + 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_src"}, + }, + }, + }) + 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_dst"}, + }, + }, + }) + moveStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/wiki/v2/spaces/space_src/nodes/wik_node/move", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "space_id": "space_dst", + "node_token": "wik_moved", + "obj_token": "sheet_token", + "obj_type": "sheet", + "parent_node_token": "wik_parent", + "node_type": "origin", + "title": "Roadmap", + }, + }, + }, + } + reg.Register(moveStub) + + err := mountAndRunWiki(t, WikiMove, []string{ + "+move", + "--node-token", "wik_node", + "--target-parent-token", "wik_parent", + "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + data := decodeWikiEnvelope(t, stdout) + if data["mode"] != wikiMoveModeNode || data["source_space_id"] != "space_src" || data["target_space_id"] != "space_dst" { + t.Fatalf("unexpected node shortcut output: %#v", data) + } + body := decodeWikiCapturedJSONBody(t, moveStub) + if body["target_parent_token"] != "wik_parent" { + t.Fatalf("move body = %#v, want target_parent_token", body) + } +} + +func TestWikiMoveExecuteDocsToWikiShortcutAsyncSuccess(t *testing.T) { + withSingleWikiMovePoll(t) + + factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig()) + docsStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/wiki/v2/spaces/space_dst/nodes/move_docs_to_wiki", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "task_id": "task_123", + }, + }, + } + reg.Register(docsStub) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/tasks/task_123", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "task": map[string]interface{}{ + "task_id": "task_123", + "move_result": []interface{}{ + map[string]interface{}{ + "status": 0, + "status_msg": "success", + "node": map[string]interface{}{ + "space_id": "space_dst", + "node_token": "wik_done", + "obj_token": "sheet_token", + "obj_type": "sheet", + "node_type": "origin", + "title": "Roadmap", + }, + }, + }, + }, + }, + }, + }) + + err := mountAndRunWiki(t, WikiMove, []string{ + "+move", + "--obj-type", "sheet", + "--obj-token", "sheet_token", + "--target-space-id", "space_dst", + "--apply", + "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + data := decodeWikiEnvelope(t, stdout) + if data["mode"] != wikiMoveModeDocsToWiki || data["ready"] != true || data["wiki_token"] != "wik_done" { + t.Fatalf("unexpected docs-to-wiki shortcut output: %#v", data) + } + body := decodeWikiCapturedJSONBody(t, docsStub) + if body["obj_type"] != "sheet" || body["obj_token"] != "sheet_token" || body["apply"] != true { + t.Fatalf("docs-to-wiki body = %#v", body) + } +} + +func TestPollWikiMoveTaskWrapsRepeatedPollFailuresWithHint(t *testing.T) { + withSingleWikiMovePoll(t) + + runtime, stderr := newWikiMoveRuntimeWithScopes(t, core.AsUser, "") + client := &fakeWikiMoveClient{ + taskErrs: []error{output.ErrWithHint(output.ExitAPI, "api_error", "poll failed", "retry original")}, + } + + status, ready, err := pollWikiMoveTask(context.Background(), client, runtime, "task_123") + if err == nil { + t.Fatal("expected pollWikiMoveTask() error, got nil") + } + if ready { + t.Fatal("expected ready=false when every poll fails") + } + if status.TaskID != "task_123" { + t.Fatalf("status.TaskID = %q, want %q", status.TaskID, "task_123") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("expected structured exit error, got %T %v", err, err) + } + if !strings.Contains(exitErr.Detail.Hint, "retry original") || !strings.Contains(exitErr.Detail.Hint, wikiMoveTaskResultCommand("task_123", core.AsUser)) { + t.Fatalf("hint = %q, want original hint and resume command", exitErr.Detail.Hint) + } + if !strings.Contains(stderr.String(), "Wiki move status attempt 1/1 failed") { + t.Fatalf("stderr = %q, want poll failure log", stderr.String()) + } +} + +func TestParseWikiMoveTaskStatusFallbackTaskIDAndNode(t *testing.T) { + t.Parallel() + + status, err := parseWikiMoveTaskStatus("task_fallback", map[string]interface{}{ + "move_result": []interface{}{ + map[string]interface{}{ + "status": 0, + "status_msg": "success", + "node": map[string]interface{}{ + "space_id": "space_dst", + "node_token": "wik_done", + "obj_token": "sheet_token", + "obj_type": "sheet", + "node_type": wikiNodeTypeOrigin, + "title": "Roadmap", + }, + }, + }, + }) + if err != nil { + t.Fatalf("parseWikiMoveTaskStatus() error = %v", err) + } + if status.TaskID != "task_fallback" { + t.Fatalf("TaskID = %q, want %q", status.TaskID, "task_fallback") + } + if !status.Ready() || status.PrimaryStatusLabel() != "success" { + t.Fatalf("unexpected parsed status: %+v", status) + } + if first := status.FirstResult(); first == nil || first.Node == nil || first.Node.NodeToken != "wik_done" { + t.Fatalf("parsed node = %+v", first) + } +} + +func TestParseWikiMoveTaskStatusRejectsMissingTask(t *testing.T) { + t.Parallel() + + _, err := parseWikiMoveTaskStatus("task_123", nil) + if err == nil || !strings.Contains(err.Error(), "missing task") { + t.Fatalf("expected missing task error, got %v", err) + } +} diff --git a/shortcuts/wiki/wiki_node_create_test.go b/shortcuts/wiki/wiki_node_create_test.go index c86b17b56..7fd184d6f 100644 --- a/shortcuts/wiki/wiki_node_create_test.go +++ b/shortcuts/wiki/wiki_node_create_test.go @@ -94,15 +94,18 @@ func mountAndRunWiki(t *testing.T, shortcut common.Shortcut, args []string, fact return parent.Execute() } -func TestWikiShortcutsIncludesNodeCreate(t *testing.T) { +func TestWikiShortcutsIncludeMoveAndNodeCreate(t *testing.T) { t.Parallel() shortcuts := Shortcuts() - if len(shortcuts) != 1 { - t.Fatalf("len(Shortcuts()) = %d, want 1", len(shortcuts)) + if len(shortcuts) != 2 { + t.Fatalf("len(Shortcuts()) = %d, want 2", len(shortcuts)) } - if shortcuts[0].Command != "+node-create" { - t.Fatalf("shortcut command = %q, want %q", shortcuts[0].Command, "+node-create") + 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") } } diff --git a/skills/lark-drive/references/lark-drive-move.md b/skills/lark-drive/references/lark-drive-move.md index 57d931326..1a11bc799 100644 --- a/skills/lark-drive/references/lark-drive-move.md +++ b/skills/lark-drive/references/lark-drive-move.md @@ -5,6 +5,33 @@ 将文件或文件夹移动到用户云空间的其他位置。 +## 与 `wiki +move` 的区别 + +- `drive +move` 只处理 **Drive 文件夹树内部** 的位置调整,目标位置用 `--folder-token` 表示 +- `wiki +move` 处理的是 **Wiki 知识空间 / 页面层级**:要么移动已有 Wiki 节点,要么把 Drive 文档迁入 Wiki +- 如果用户说“移动到某个文件夹”“移动到我的空间根目录”,应使用 `drive +move` +- 如果用户说“移动到某个知识库 / 页面下”“迁入 Wiki / 知识空间”,应使用 `wiki +move` +- 如果用户说“移动到我的文档库 / 我的知识库 / 个人知识库 / my_library”,不要使用 `drive +move`;先按 Wiki 目标处理 +- `我的文档库` 不是 Drive root folder,也不是 `--folder-token` 省略后的默认目的地 +- `drive +move` 不支持 wiki 文档;如果目标是 Wiki,不要尝试用 `drive +move` 代替 + +## 不要误用到 `我的文档库` + +下面几种说法都**不应该**触发 `drive +move`: + +- `移动到我的文档库` +- `放到我的知识库` +- `迁入个人知识库` +- `move to My Document Library` + +这些目标都应该先走 Wiki 解析流程: + +```bash +lark-cli wiki spaces get --params '{"space_id":"my_library"}' +``` + +拿到真实 `space_id` 后,再改用 `wiki +move`。不要因为 `drive +move` 可以省略 `--folder-token` 就把它当作“我的文档库”的近似目标。 + ## 命令 ```bash @@ -60,6 +87,7 @@ lark-cli drive +move \ - **轮询超时不是失败**:文件夹移动内置最多轮询 30 次、每次间隔 2 秒;如果轮询结束任务仍未完成,会返回 `task_id`、`status`、`ready=false`、`timed_out=true` 和 `next_command` - **继续查询**:当看到 `next_command` 时,改用 `lark-cli drive +task_result --scenario task_check --task-id ` 继续查询 - **目标文件夹**:如果不指定 `--folder-token`,文件将被移动到用户的根文件夹("我的空间") +- **不要混淆产品概念**:这里的“根文件夹 / 我的空间”仅属于 Drive 文件夹树,不等于 Wiki 的“我的文档库” - **权限要求**:需要被移动文件的可管理权限、被移动文件所在位置的编辑权限、目标位置的编辑权限 ## 推荐续跑方式 diff --git a/skills/lark-drive/references/lark-drive-task-result.md b/skills/lark-drive/references/lark-drive-task-result.md index fa5556209..954eb38eb 100644 --- a/skills/lark-drive/references/lark-drive-task-result.md +++ b/skills/lark-drive/references/lark-drive-task-result.md @@ -3,7 +3,7 @@ > **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 -查询异步任务结果。该 shortcut 聚合了导入、导出、移动/删除文件夹等多种异步任务的结果查询,统一接口方便调用。 +查询异步任务结果。该 shortcut 聚合了导入、导出、移动/删除文件夹、Wiki 节点 / 文档迁入 Wiki 等多种异步任务的结果查询,统一接口方便调用。 > [!IMPORTANT] > 对于 `import` 场景,如果使用 `--as bot` 且这次查询**已经拿到最终在线文档目标**(`ready=true` 且返回了最终 `token` / `url`),CLI 会**再次尝试为当前 CLI 用户自动授予该资源的 `full_access`(可管理权限)**。 @@ -35,15 +35,20 @@ lark-cli drive +task_result \ lark-cli drive +task_result \ --scenario task_check \ --task-id + +# 查询 Wiki 移动任务结果(wiki +move 异步超时后的续跑) +lark-cli drive +task_result \ + --scenario wiki_move \ + --task-id ``` ## 参数 | 参数 | 必填 | 说明 | |------|------|------| -| `--scenario` | 是 | 任务场景,可选值:`import` (导入任务)、`export` (导出任务)、`task_check` (移动/删除文件夹任务) | +| `--scenario` | 是 | 任务场景,可选值:`import` (导入任务)、`export` (导出任务)、`task_check` (移动/删除文件夹任务)、`wiki_move` (Wiki 移动任务) | | `--ticket` | 条件必填 | 异步任务 ticket,**import/export 场景必填** | -| `--task-id` | 条件必填 | 异步任务 ID,**task_check 场景必填** | +| `--task-id` | 条件必填 | 异步任务 ID,**task_check / wiki_move 场景必填** | | `--file-token` | 条件必填 | 导出任务对应的源文档 token,**export 场景必填** | ## 场景说明 @@ -53,6 +58,7 @@ lark-cli drive +task_result \ | `import` | 文档导入任务(如将本地文件导入为云文档) | `--ticket` | | `export` | 文档导出任务(如云文档导出为 PDF/Word) | `--ticket`、`--file-token` | | `task_check` | 文件夹移动/删除任务 | `--task-id` | +| `wiki_move` | Wiki 移动任务(`wiki +move` 的 docs-to-wiki 异步流程,超时后续跑用) | `--task-id` | ## 返回结果 @@ -135,6 +141,55 @@ lark-cli drive +task_result \ - `ready`: 是否已经完成 - `failed`: 是否已经失败 +### Wiki_move 场景返回 + +```json +{ + "scenario": "wiki_move", + "task_id": "", + "ready": true, + "failed": false, + "status": 0, + "status_msg": "success", + "wiki_token": "wikcnXXX", + "node_token": "wikcnXXX", + "space_id": "", + "obj_token": "", + "obj_type": "docx", + "parent_node_token": "", + "node_type": "origin", + "origin_node_token": "", + "title": "项目计划", + "has_child": false, + "node": { + "space_id": "", + "node_token": "wikcnXXX", + "obj_token": "", + "obj_type": "docx", + "parent_node_token": "", + "node_type": "origin", + "origin_node_token": "", + "title": "项目计划", + "has_child": false + }, + "move_results": [ + { + "status": 0, + "status_msg": "success", + "node": { "...": "同上" } + } + ] +} +``` + +**字段说明:** +- `ready`: 所有 `move_results[].status` 都为 `0` 时为 `true` +- `failed`: 任一 `move_results[].status` 小于 `0` 时为 `true` +- `status` / `status_msg`: 第一个 move_result 的状态码 / 标签(无结果时回退为 `1` / `processing`) +- `wiki_token` / `node_token`: 移入 Wiki 后的目标节点 token(首个结果有 `node.node_token` 时镜像到顶层,便于下游脚本使用) +- `space_id`、`obj_token`、`obj_type`、`title` 等:从首个 `move_results[0].node` 平铺到顶层,方便直接引用 +- `move_results`: 保留完整列表(适用于一次任务移动多个文档的场景) + ## 使用场景 ### 配合 +import 使用 @@ -162,6 +217,20 @@ lark-cli drive +move --file-token --type folder --folder-token ``` +### 配合 wiki +move 使用 + +```bash +# 1. 把 Drive 文档迁入 Wiki(异步任务可能返回 task_id) +lark-cli wiki +move --obj-type docx --obj-token --target-space-id +# 若内置轮询窗口内完成:直接返回 ready=true 和 wiki_token +# 若轮询窗口结束仍未完成:返回 ready=false、task_id、timed_out=true 和 next_command + +# 2. 续跑查询 Wiki 移动结果(next_command 即下面这条) +lark-cli drive +task_result --scenario wiki_move --task-id --as user +``` + +> **身份保持一致**:续跑命令的 `--as` 必须与原 `wiki +move` 调用一致;`wiki +move` 的 `next_command` 已自动带上正确的 `--as`。 + ### 配合 +export 使用 ```bash @@ -184,6 +253,7 @@ lark-cli drive +export-download --file-token | import | `drive:drive.metadata:readonly` | | export | `drive:drive.metadata:readonly` | | task_check | `drive:drive.metadata:readonly` | +| wiki_move | `wiki:space:read` | > [!NOTE] > `import` 场景在 `--as bot` 且任务最终就绪时,还可能额外尝试一次协作者授权;如果 `permission_grant.status = failed`,请根据失败信息检查应用是否具备相应的文档协作者授权能力。 diff --git a/skills/lark-wiki/SKILL.md b/skills/lark-wiki/SKILL.md index 5cb071e7b..0322e803e 100644 --- a/skills/lark-wiki/SKILL.md +++ b/skills/lark-wiki/SKILL.md @@ -40,8 +40,16 @@ Shortcut 是对常用操作的高级封装(`lark-cli wiki + [flags]`) | Shortcut | 说明 | |----------|------| +| [`+move`](references/lark-wiki-move.md) | Move a wiki node, or move a Drive document into Wiki | | [`+node-create`](references/lark-wiki-node-create.md) | Create a wiki node with automatic space resolution | +## 目标语义约束 + +- `我的文档库` / `My Document Library` / `我的知识库` / `个人知识库` / `my_library` 都应视为 **Wiki personal library**,不是 Drive 根目录 +- 处理这类目标时,先解析 `my_library` 对应的真实 `space_id`,再执行 `wiki +move`、`wiki +node-create` 或其他 Wiki 写操作 +- 不要因为缺少显式 `space_id` 就退化成 `drive +move` +- 如果用户明确说的是 Drive 文件夹、云空间根目录、`我的空间`,才进入 Drive 域处理 + ## API Resources ```bash diff --git a/skills/lark-wiki/references/lark-wiki-move.md b/skills/lark-wiki/references/lark-wiki-move.md new file mode 100644 index 000000000..11031ba4f --- /dev/null +++ b/skills/lark-wiki/references/lark-wiki-move.md @@ -0,0 +1,183 @@ +# wiki +move + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +在飞书知识库中移动已有 Wiki 节点,或将 Drive 文档迁入 Wiki。这个 shortcut 统一封装了两类流程: + +- `node` 模式:移动已有 Wiki 节点,可同空间移动,也可跨空间移动 +- `docs_to_wiki` 模式:把 Drive 文档迁入目标知识空间;必要时可提交移动申请,并在异步任务场景下自动有限轮询 + +当 `docs_to_wiki` 返回 `task_id` 时,shortcut 会先轮询一小段时间;如果轮询窗口内仍未完成,会返回 `next_command`,让调用方继续执行 `lark-cli drive +task_result --scenario wiki_move --task-id `。 + +## 与 `drive +move` 的区别 + +- `wiki +move` 的目标是 **知识空间或 Wiki 父节点**,使用 `--target-space-id` / `--target-parent-token` +- `drive +move` 的目标是 **Drive 文件夹**,使用 `--folder-token` +- 如果源对象已经是 Wiki 节点,必须使用 `wiki +move`,而不是 `drive +move` +- 如果源对象还是 Drive 文档,但用户要“迁入知识库”“挂到某个 Wiki 页面下”,也应使用 `wiki +move` +- 如果用户只是想整理云空间文件夹,把文件/文件夹挪到另一个 Drive 文件夹,应使用 `drive +move` + +## 口语目标识别 + +- 当用户说“移动到某个知识库”“挂到某个页面下”“迁入 Wiki”时,按 **Wiki 目标** 处理,优先使用 `wiki +move` +- 当用户说“移动到某个文件夹”“移动到云空间根目录”时,按 **Drive 文件夹目标** 处理,优先使用 `drive +move` +- 当用户说“移动到我的文档库”“移动到我的知识库”“放到个人知识库”时,应先按 **Wiki 个人知识库目标** 理解,而不是直接退化成 `drive +move` +- 遇到“我的文档库”这类表述时,可以把它理解成:先用 `my_library` 去查询用户个人知识库,再拿到真实 `space_id` +- 推荐做法是先执行 `lark-cli wiki spaces get --params '{"space_id":"my_library"}'`,取回真实知识库 `space_id`,再把这个 `space_id` 用到 `wiki +move` +- 当前 `wiki +move` 文档的主示例仍以显式 `--target-space-id` / `--target-parent-token` 为主;如果调用方只有自然语言目标,不要因为目标暂时不明确就改走 `drive +move` + +## 命令 + +```bash +# 将已有 wiki 节点移动到另一个父节点下 +lark-cli wiki +move \ + --node-token \ + --target-parent-token + +# 将已有 wiki 节点移动到另一个知识空间根目录 +lark-cli wiki +move \ + --node-token \ + --target-space-id + +# 将 Drive 文档迁入某个知识空间根目录 +lark-cli wiki +move \ + --obj-type docx \ + --obj-token \ + --target-space-id + +# 将 Drive 文档迁入某个父节点下;如果当前没有直接移动权限,则提交申请 +lark-cli wiki +move \ + --obj-type sheet \ + --obj-token \ + --target-space-id \ + --target-parent-token \ + --apply + +# 预览底层调用链 +lark-cli wiki +move \ + --obj-type docx \ + --obj-token \ + --target-space-id \ + --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--node-token` | 条件必填 | 要移动的 Wiki 节点 token。传入后命令进入 `node` 模式 | +| `--source-space-id` | 否 | 源知识空间 ID,仅 `node` 模式可用;不传时会根据 `--node-token` 自动解析 | +| `--target-space-id` | 条件必填 | 目标知识空间 ID。`docs_to_wiki` 模式必填;`node` 模式下如果不传,则必须传 `--target-parent-token` | +| `--target-parent-token` | 否 | 目标父节点 token。`docs_to_wiki` 不传时表示迁入目标知识空间根目录 | +| `--obj-type` | 条件必填 | Drive 文档类型,仅 `docs_to_wiki` 模式可用。可选值:`doc`、`sheet`、`bitable`、`mindnote`、`docx`、`file`、`slides` | +| `--obj-token` | 条件必填 | Drive 文档 token,仅 `docs_to_wiki` 模式可用 | +| `--apply` | 否 | 仅 `docs_to_wiki` 模式可用;当当前调用方不能直接移动文档时,提交一个 move request | + +## 模式选择与校验规则 + +- **`node` 模式**:只要传了 `--node-token`,就会按“移动已有 Wiki 节点”执行 +- **`docs_to_wiki` 模式**:未传 `--node-token` 时,按“把 Drive 文档迁入 Wiki”执行 +- `node` 模式下,`--node-token` 不能与 `--obj-type`、`--obj-token`、`--apply` 同时使用 +- `node` 模式下,`--target-parent-token` 和 `--target-space-id` 不能同时为空 +- `docs_to_wiki` 模式下,必须同时提供 `--obj-type`、`--obj-token`、`--target-space-id` +- `docs_to_wiki` 模式下,`--source-space-id` 非法,只能用于 `node` 模式 + +## 空间解析与一致性校验 + +### `node` 模式 + +- **源空间解析**:如果未传 `--source-space-id`,shortcut 会先调用 `GET /open-apis/wiki/v2/spaces/get_node` 查询 `--node-token`,再读取其 `space_id` +- **目标父节点解析**:如果传了 `--target-parent-token`,shortcut 会先解析该父节点所属的 `space_id` +- **一致性校验**:如果同时传了 `--target-space-id` 和 `--target-parent-token`,shortcut 会校验两者是否属于同一个知识空间;不一致时直接返回验证错误 +- **移动到空间根目录**:如果只传 `--target-space-id`,则表示移动到该知识空间根目录 + +### `docs_to_wiki` 模式 + +- `--target-space-id` 始终必填 +- `--target-parent-token` 可选;不传时表示移动到目标知识空间根目录 +- 请求体会自动映射成 `obj_type`、`obj_token`、`parent_wiki_token`、`apply` + +## 行为说明 + +- **`node` 模式是同步操作**:请求成功后直接返回移动后的节点信息 +- **`docs_to_wiki` 可能是同步,也可能是异步**: + - 如果接口直接返回 `wiki_token`,shortcut 会立刻返回 `ready=true` + - 如果接口返回 `applied=true`,shortcut 会返回 `ready=false`、`failed=false`、`applied=true` 和 `status_msg="move request submitted for approval"` + - 如果接口返回 `task_id`,shortcut 会先进入有限轮询 +- **有限轮询窗口**:固定最多轮询 `30` 次,每次间隔 `2` 秒 +- **轮询超时不是失败**:如果轮询窗口结束任务仍在处理中,会返回 `task_id`、`status`、`status_msg`、`ready=false`、`timed_out=true` 和 `next_command` +- **继续查询**:看到 `next_command` 后,改用 `lark-cli drive +task_result --scenario wiki_move --task-id ` 继续查 +- **任务失败直接报错**:如果轮询期间任务进入失败态,shortcut 会直接返回错误,不会再输出 `ready=false` 结果 +- **轮询请求全部失败时也直接报错**:如果任务已创建,但后续每一次状态查询都失败,shortcut 会返回带 hint 的错误,并给出继续查询命令 + +## 返回结果 + +### `node` 模式典型返回 + +```json +{ + "mode": "node", + "source_space_id": "space_src", + "target_space_id": "space_dst", + "space_id": "space_dst", + "node_token": "wikcnode_xxx", + "obj_token": "doccn_xxx", + "obj_type": "docx", + "parent_node_token": "wikcparent_xxx", + "node_type": "origin", + "origin_node_token": "", + "title": "项目计划", + "has_child": false +} +``` + +### `docs_to_wiki` 异步超时返回 + +```json +{ + "mode": "docs_to_wiki", + "obj_type": "docx", + "obj_token": "doccn_xxx", + "target_space_id": "space_xxx", + "target_parent_token": "wikcparent_xxx", + "task_id": "7500000000000000001", + "ready": false, + "failed": false, + "status": 1, + "status_msg": "processing", + "timed_out": true, + "next_command": "lark-cli drive +task_result --scenario wiki_move --task-id 7500000000000000001" +} +``` + +**输出字段说明:** + +- `mode`:当前执行模式,值为 `node` 或 `docs_to_wiki` +- `ready`:任务是否已经完成并可直接继续使用结果 +- `failed`:任务是否已失败 +- `task_id`:异步任务 ID,仅异步场景返回 +- `status` / `status_msg`:异步任务的主状态码和可读状态 +- `wiki_token`:docs-to-wiki 成功后返回的 Wiki 节点 token;同时也会镜像到 `node_token` +- `space_id`、`node_token`、`obj_token`、`obj_type`、`parent_node_token`、`title` 等:成功拿到节点信息时返回,方便下游继续调用 + +## dry-run 编排 + +- `node` 模式下,dry-run 会根据是否需要解析源节点 / 目标父节点,展示 1 到 3 步的调用链 +- `docs_to_wiki` 模式下,dry-run 会展示两步: + 1. `POST /open-apis/wiki/v2/spaces/{target_space_id}/nodes/move_docs_to_wiki` + 2. `GET /open-apis/wiki/v2/tasks/{task_id}?task_type=move` + +## 权限说明 + +CLI 会在执行前做本地 scope 预检查;当前 shortcut 声明的权限为 `wiki:node:move`、`wiki:node:read`、`wiki:space:read`(分别覆盖 move 写操作、节点解析读操作、以及异步任务轮询读操作)。如果本地 token 已记录 scopes 且缺失任一权限,命令会直接提示重新执行 `lark-cli auth login --scope ...`。 + +当异步任务超时后,后续 `lark-cli drive +task_result --scenario wiki_move --task-id ` 只需要 `wiki:space:read` 权限。 + +> [!CAUTION] +> `wiki +move` 是**写入操作**。执行前必须确认用户意图,以及目标节点 / 目标知识空间是否明确。 + +## 参考 + +- [lark-wiki](../SKILL.md) -- 知识库全部命令 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 +- [drive +task_result](../../lark-drive/references/lark-drive-task-result.md) -- docs-to-wiki 异步任务的续跑查询命令