mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
feat(wiki): add +node-get / +node-delete / +space-create shortcuts (#904)
- +node-get: wrap wiki.spaces.get_node; accepts node_token, obj_token, or a Lark URL (URL path auto-infers obj_type); formatted output with creator / updated_at. No synthesized url — get_node returns none and a BuildResourceURL fallback is a non-canonical link that misleads in a read/confirm command (sibling read shortcuts omit it too) - +node-delete: wrap space.node delete; high-risk-write (--yes gated), async delete-node task polling, auto-resolves space_id via get_node when --space-id omitted, actionable hints for codes 131011 / 131003. The delete-node task result lives under the gateway's generic `simple_task_result` key (NOT `delete_node_result`) - +space-create: wrap spaces.create; user-only identity, --name required (no empty-name spaces), flattened space output, no url - factor the shared wiki async-task poll loop into wiki_async_task.go; preserve upstream Lark Detail.Code on poll exhaustion (no longer rebuilt via lossy ErrWithHint) - drive +task_result: add wiki_delete_node scenario so +node-delete's async-timeout next_command actually resolves - skill docs: reference pages for the 3 new shortcuts + SKILL.md shortcuts table (no raw nodes.delete API exists — it's shortcut-only, so it is intentionally absent from API Resources / permission table); drop the circular TestWikiShortcutsIncludeAllCommands change-detector Change-Id: I316f78290cec5bc50f80d629173e3bf2a35dd005
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -12,7 +12,10 @@ func Shortcuts() []common.Shortcut {
|
||||
WikiNodeCreate,
|
||||
WikiDeleteSpace,
|
||||
WikiSpaceList,
|
||||
WikiSpaceCreate,
|
||||
WikiNodeList,
|
||||
WikiNodeCopy,
|
||||
WikiNodeGet,
|
||||
WikiNodeDelete,
|
||||
}
|
||||
}
|
||||
|
||||
207
shortcuts/wiki/wiki_async_task.go
Normal file
207
shortcuts/wiki/wiki_async_task.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// Shared async-task polling for wiki delete operations. The wiki delete
|
||||
// endpoints (DELETE /spaces/{id}, DELETE /spaces/{id}/nodes/{token}) may
|
||||
// return either an empty task_id (sync completion) or a task_id that must
|
||||
// be polled against /wiki/v2/tasks/{task_id}?task_type=<...>.
|
||||
//
|
||||
// For historical reasons /wiki/v2/tasks/{task_id} stashes the status under a
|
||||
// different key per task type: delete-space uses `delete_space_result`, while
|
||||
// delete-node uses the generic `simple_task_result` (the gateway's reusable
|
||||
// "future async tasks share this" field). move tasks use `move_result` and are
|
||||
// handled separately in wiki_move.go. Every key still exposes a `status`, so
|
||||
// the poll loop / classification is factored out here and the caller passes
|
||||
// the right result key.
|
||||
//
|
||||
// Note: `simple_task_result` only carries `status` (no `status_msg`), so for
|
||||
// delete-node StatusLabel() falls back to the status code — which is fine.
|
||||
|
||||
const (
|
||||
wikiAsyncStatusSuccess = "success"
|
||||
wikiAsyncStatusFailure = "failure"
|
||||
wikiAsyncStatusProcessing = "processing"
|
||||
|
||||
wikiAsyncTaskTypeDeleteSpace = "delete_space"
|
||||
wikiAsyncTaskTypeDeleteNode = "delete_node"
|
||||
|
||||
wikiAsyncResultDeleteSpace = "delete_space_result"
|
||||
// wikiAsyncResultSimpleTask is the generic result key the gateway uses for
|
||||
// delete-node (and intends to reuse for future async task types). It is
|
||||
// NOT `delete_node_result` — that key does not exist in the response.
|
||||
wikiAsyncResultSimpleTask = "simple_task_result"
|
||||
)
|
||||
|
||||
// wikiAsyncTaskStatus is the unified poll-response shape used by every wiki
|
||||
// delete task. The taskID is captured so error/resume hints can name it.
|
||||
type wikiAsyncTaskStatus struct {
|
||||
TaskID string
|
||||
Status string
|
||||
StatusMsg string
|
||||
}
|
||||
|
||||
// normalizedStatus collapses whitespace and case so " SUCCESS " classifies
|
||||
// the same as "success". Ready()/Failed() (control flow) derive from this;
|
||||
// StatusCode()/StatusLabel() (display) deliberately surface the raw backend
|
||||
// value instead. For the real status enums (delete-node: processing/success/
|
||||
// failed; delete-space's documented set) the two agree. They only diverge for
|
||||
// an undocumented status string, which is intentional — an unrecognized status
|
||||
// is shown verbatim rather than masked as a hard failure.
|
||||
func (s wikiAsyncTaskStatus) normalizedStatus() string {
|
||||
return strings.ToLower(strings.TrimSpace(s.Status))
|
||||
}
|
||||
|
||||
func (s wikiAsyncTaskStatus) Ready() bool {
|
||||
return s.normalizedStatus() == wikiAsyncStatusSuccess
|
||||
}
|
||||
|
||||
func (s wikiAsyncTaskStatus) Failed() bool {
|
||||
// The sample protocol only documents "success" as a terminal OK. Treat any
|
||||
// explicit "failure"/"failed" signal as terminal, and unknown non-success
|
||||
// values as still-processing so we don't misreport a novel status as a hard
|
||||
// failure.
|
||||
lowered := s.normalizedStatus()
|
||||
return lowered == wikiAsyncStatusFailure || lowered == "failed"
|
||||
}
|
||||
|
||||
// StatusCode returns a never-empty status value for the output envelope. If
|
||||
// the backend response omits delete_*_result.status (or sends whitespace),
|
||||
// fall back to "processing" so the documented timeout-shape stays accurate.
|
||||
func (s wikiAsyncTaskStatus) StatusCode() string {
|
||||
if status := strings.TrimSpace(s.Status); status != "" {
|
||||
return status
|
||||
}
|
||||
return wikiAsyncStatusProcessing
|
||||
}
|
||||
|
||||
func (s wikiAsyncTaskStatus) StatusLabel() string {
|
||||
if msg := strings.TrimSpace(s.StatusMsg); msg != "" {
|
||||
return msg
|
||||
}
|
||||
return s.StatusCode()
|
||||
}
|
||||
|
||||
// wikiAsyncTaskFetcher returns the latest status for taskID. Implementations
|
||||
// translate from runtime.CallAPI responses or test fakes.
|
||||
type wikiAsyncTaskFetcher func(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error)
|
||||
|
||||
// parseWikiAsyncTaskStatus normalizes an /wiki/v2/tasks/{task_id} payload.
|
||||
// resultKey selects the right shape ("delete_space_result" for delete-space,
|
||||
// "simple_task_result" for delete-node).
|
||||
func parseWikiAsyncTaskStatus(taskID string, task map[string]interface{}, resultKey string) (wikiAsyncTaskStatus, error) {
|
||||
if task == nil {
|
||||
return wikiAsyncTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
|
||||
}
|
||||
|
||||
result := common.GetMap(task, resultKey)
|
||||
status := wikiAsyncTaskStatus{
|
||||
TaskID: common.GetString(task, "task_id"),
|
||||
}
|
||||
if status.TaskID == "" {
|
||||
status.TaskID = taskID
|
||||
}
|
||||
if result != nil {
|
||||
status.Status = common.GetString(result, "status")
|
||||
status.StatusMsg = common.GetString(result, "status_msg")
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// pollWikiAsyncTask runs the bounded polling loop shared by every wiki delete
|
||||
// shortcut. label is the human-readable operation name surfaced in stderr
|
||||
// progress lines ("delete-space" / "delete-node"). nextCommand is the resume
|
||||
// hint embedded into the wrapped error when every poll fails.
|
||||
//
|
||||
// attempts/interval are taken as parameters (instead of consts) so callers
|
||||
// can keep their per-operation tunable constants for back-compat with the
|
||||
// existing test hooks.
|
||||
func pollWikiAsyncTask(
|
||||
ctx context.Context,
|
||||
runtime *common.RuntimeContext,
|
||||
taskID, label string,
|
||||
attempts int,
|
||||
interval time.Duration,
|
||||
fetcher wikiAsyncTaskFetcher,
|
||||
nextCommand string,
|
||||
) (wikiAsyncTaskStatus, bool, error) {
|
||||
lastStatus := wikiAsyncTaskStatus{TaskID: taskID}
|
||||
var lastErr error
|
||||
hadSuccessfulPoll := false
|
||||
|
||||
// The delete request already succeeded. Treat poll failures as transient
|
||||
// until every attempt fails, then return a resume hint instead of
|
||||
// discarding the task identifier.
|
||||
for attempt := 1; attempt <= attempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return lastStatus, false, ctx.Err()
|
||||
case <-time.After(interval):
|
||||
}
|
||||
}
|
||||
|
||||
status, err := fetcher(ctx, taskID)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki %s status attempt %d/%d failed: %v\n", label, attempt, attempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hadSuccessfulPoll = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki %s task completed successfully.\n", label)
|
||||
return status, true, nil
|
||||
}
|
||||
if status.Failed() {
|
||||
return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki %s task %s failed: %s", label, taskID, status.StatusLabel())
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki %s status %d/%d: %s\n", label, attempt, attempts, status.StatusLabel())
|
||||
}
|
||||
|
||||
if !hadSuccessfulPoll && lastErr != nil {
|
||||
hint := fmt.Sprintf(
|
||||
"the wiki %s task was created but every status poll failed (task_id=%s)\nretry status lookup with: %s",
|
||||
label, taskID, nextCommand,
|
||||
)
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(lastErr, &exitErr) && exitErr.Detail != nil {
|
||||
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
|
||||
hint = exitErr.Detail.Hint + "\n" + hint
|
||||
}
|
||||
// ErrWithHint rebuilds the error and drops the upstream Lark
|
||||
// Detail.Code / ConsoleURL / Risk / nested Detail. Build the
|
||||
// ExitError by hand so the original API code survives a fully
|
||||
// failed poll, matching wrapWikiNodeDeleteAPIError.
|
||||
return lastStatus, false, &output.ExitError{
|
||||
Code: exitErr.Code,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: exitErr.Detail.Type,
|
||||
Code: exitErr.Detail.Code,
|
||||
Message: exitErr.Detail.Message,
|
||||
Hint: hint,
|
||||
ConsoleURL: exitErr.Detail.ConsoleURL,
|
||||
Risk: exitErr.Detail.Risk,
|
||||
Detail: exitErr.Detail.Detail,
|
||||
},
|
||||
}
|
||||
}
|
||||
return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint)
|
||||
}
|
||||
|
||||
return lastStatus, false, nil
|
||||
}
|
||||
181
shortcuts/wiki/wiki_async_task_test.go
Normal file
181
shortcuts/wiki/wiki_async_task_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// pollWikiAsyncTask is shared infrastructure for every wiki delete shortcut,
|
||||
// so it gets a dedicated test surface here rather than relying only on the
|
||||
// transitive coverage from the delete-space / delete-node paths.
|
||||
|
||||
func TestPollWikiAsyncTaskSuccessFirstPoll(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, stderr := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
status, ready, err := pollWikiAsyncTask(
|
||||
context.Background(), runtime, "task_ok", "delete-node", 3, 0,
|
||||
func(context.Context, string) (wikiAsyncTaskStatus, error) {
|
||||
return wikiAsyncTaskStatus{Status: "success"}, nil
|
||||
},
|
||||
"resume-cmd",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("pollWikiAsyncTask() error = %v", err)
|
||||
}
|
||||
if !ready || !status.Ready() {
|
||||
t.Fatalf("ready = %v, status = %+v, want ready", ready, status)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "delete-node task completed successfully") {
|
||||
t.Fatalf("stderr = %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollWikiAsyncTaskFailureIsTerminal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
_, ready, err := pollWikiAsyncTask(
|
||||
context.Background(), runtime, "task_x", "delete-node", 3, 0,
|
||||
func(context.Context, string) (wikiAsyncTaskStatus, error) {
|
||||
return wikiAsyncTaskStatus{Status: "failure", StatusMsg: "denied"}, nil
|
||||
},
|
||||
"resume-cmd",
|
||||
)
|
||||
if ready {
|
||||
t.Fatalf("ready = true, want false on failure")
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "delete-node task task_x failed: denied") {
|
||||
t.Fatalf("err = %v, want terminal failure with reason", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollWikiAsyncTaskTimeoutWhenAlwaysProcessing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
status, ready, err := pollWikiAsyncTask(
|
||||
context.Background(), runtime, "task_slow", "delete-space", 2, 0,
|
||||
func(context.Context, string) (wikiAsyncTaskStatus, error) {
|
||||
return wikiAsyncTaskStatus{Status: "processing"}, nil
|
||||
},
|
||||
"resume-cmd",
|
||||
)
|
||||
// A still-processing task after the bounded window is a soft timeout:
|
||||
// no error, ready=false, status preserved so the caller can print the
|
||||
// follow-up command.
|
||||
if err != nil {
|
||||
t.Fatalf("pollWikiAsyncTask() error = %v, want nil on timeout", err)
|
||||
}
|
||||
if ready {
|
||||
t.Fatalf("ready = true, want false on timeout")
|
||||
}
|
||||
if status.StatusCode() != "processing" {
|
||||
t.Fatalf("status = %+v, want processing preserved", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollWikiAsyncTaskAllPollsFailWrapsWithResumeHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, stderr := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
_, ready, err := pollWikiAsyncTask(
|
||||
context.Background(), runtime, "task_lost", "delete-node", 2, 0,
|
||||
func(context.Context, string) (wikiAsyncTaskStatus, error) {
|
||||
return wikiAsyncTaskStatus{}, errors.New("transport boom")
|
||||
},
|
||||
"lark-cli drive +task_result --task-id task_lost",
|
||||
)
|
||||
if ready {
|
||||
t.Fatalf("ready = true, want false when every poll failed")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("err = %T %v, want *output.ExitError with detail", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("exit code = %d, want ExitAPI", exitErr.Code)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "every status poll failed (task_id=task_lost)") ||
|
||||
!strings.Contains(exitErr.Detail.Hint, "lark-cli drive +task_result --task-id task_lost") {
|
||||
t.Fatalf("hint = %q, want resume guidance naming the task", exitErr.Detail.Hint)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "attempt 2/2 failed") {
|
||||
t.Fatalf("stderr = %q, want per-attempt progress", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollWikiAsyncTaskPrependsUpstreamExitHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
upstream := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991663,
|
||||
Message: "permission denied",
|
||||
Hint: "grant the wiki:node:retrieve scope",
|
||||
},
|
||||
}
|
||||
_, _, err := pollWikiAsyncTask(
|
||||
context.Background(), runtime, "task_perm", "delete-node", 1, 0,
|
||||
func(context.Context, string) (wikiAsyncTaskStatus, error) {
|
||||
return wikiAsyncTaskStatus{}, upstream
|
||||
},
|
||||
"resume-cmd",
|
||||
)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("err = %T %v, want *output.ExitError", err, err)
|
||||
}
|
||||
// The upstream hint must lead so the actionable cause is read first, with
|
||||
// the resume guidance appended. Type and exit code propagate from upstream.
|
||||
if !strings.HasPrefix(exitErr.Detail.Hint, "grant the wiki:node:retrieve scope\n") {
|
||||
t.Fatalf("hint = %q, want upstream hint prepended", exitErr.Detail.Hint)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "resume-cmd") {
|
||||
t.Fatalf("hint = %q, want resume command appended", exitErr.Detail.Hint)
|
||||
}
|
||||
if exitErr.Detail.Type != "permission" || exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("exitErr = %+v, want permission/ExitAPI propagated", exitErr)
|
||||
}
|
||||
if exitErr.Detail.Message != "permission denied" {
|
||||
t.Fatalf("message = %q, want upstream message preserved", exitErr.Detail.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollWikiAsyncTaskHonoursContextCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
calls := 0
|
||||
_, ready, err := pollWikiAsyncTask(
|
||||
ctx, runtime, "task_cancel", "delete-node", 5, time.Hour,
|
||||
func(context.Context, string) (wikiAsyncTaskStatus, error) {
|
||||
calls++
|
||||
cancel() // cancel before the next attempt's inter-poll wait
|
||||
return wikiAsyncTaskStatus{Status: "processing"}, nil
|
||||
},
|
||||
"resume-cmd",
|
||||
)
|
||||
if ready {
|
||||
t.Fatalf("ready = true, want false on cancellation")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err = %v, want context.Canceled", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("fetcher calls = %d, want 1 (cancelled before second poll)", calls)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
440
shortcuts/wiki/wiki_node_delete.go
Normal file
440
shortcuts/wiki/wiki_node_delete.go
Normal file
@@ -0,0 +1,440 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// wikiNodeDeleteObjTypes is the set of obj_type values the delete-node API
|
||||
// accepts. Unlike wikiNodeGetObjTypeEnum this includes "wiki" — for
|
||||
// delete-node, obj_type="wiki" means the token is a wiki node_token, whereas
|
||||
// the get_node API omits obj_type for node_tokens.
|
||||
var wikiNodeDeleteObjTypes = []string{
|
||||
"wiki", "doc", "docx", "sheet", "bitable", "mindnote", "slides", "file",
|
||||
}
|
||||
|
||||
var (
|
||||
wikiDeleteNodePollAttempts = 30
|
||||
wikiDeleteNodePollInterval = 2 * time.Second
|
||||
)
|
||||
|
||||
// Lark wiki API error codes the delete-node API surfaces with actionable
|
||||
// CLI workarounds. The full list is in the OpenAPI spec; we only special-case
|
||||
// the codes whose remediation is non-obvious (UI approval, subtree size).
|
||||
const (
|
||||
wikiDeleteNodeErrCodeApprovalRequired = 131011
|
||||
wikiDeleteNodeErrCodeSubtreeTooLarge = 131003
|
||||
)
|
||||
|
||||
// WikiNodeDelete deletes a wiki node (or pulls a cloud doc out of Wiki). The
|
||||
// API mirrors +delete-space — synchronous on small deletes, async with a
|
||||
// task_id for cascade deletes — so this shortcut shares the async-polling
|
||||
// helper. Space ID is optional: when omitted, +node-delete first looks up the
|
||||
// node via get_node to resolve the space ID so callers do not have to chain
|
||||
// commands.
|
||||
var WikiNodeDelete = common.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+node-delete",
|
||||
Description: "Delete a wiki node, polling the async delete task when needed",
|
||||
Risk: "high-risk-write",
|
||||
// API spec lists wiki:node:create as the only declared scope for the
|
||||
// delete endpoint. Naming is unfortunate, but the scope-preflight needs
|
||||
// the literal string.
|
||||
Scopes: []string{"wiki:node:create"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "node-token", Desc: "wiki node_token, cloud-doc obj_token, or a Lark URL embedding one of them", Required: true},
|
||||
// Not Required at the cobra level: URL inputs auto-infer obj_type
|
||||
// from the path, and the parser enforces explicit obj_type for raw
|
||||
// tokens. Forcing Cobra Required here breaks the URL ergonomic.
|
||||
{Name: "obj-type", Desc: "token kind; no default — pass explicitly when --node-token is a raw token (URL inputs auto-infer)", Enum: wikiNodeDeleteObjTypes},
|
||||
{Name: "space-id", Desc: "wiki space ID; auto-resolved via get_node when omitted"},
|
||||
{Name: "include-children", Type: "bool", Default: "true", Desc: "cascade delete the subtree (default); pass --include-children=false to lift direct children up to the parent"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Deletion is irreversible; double-check --node-token and --obj-type before running.",
|
||||
"This is a high-risk-write command; pass --yes to confirm the deletion.",
|
||||
"--node-token accepts a raw token (wikcnXXX, docxXXX, ...) or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>; URL paths also imply --obj-type.",
|
||||
"Run +node-get first to confirm space_id / obj_type when in doubt.",
|
||||
"Auto-resolving space_id (when --space-id is omitted) also calls get_node, which needs the wiki:node:retrieve scope; pass --space-id to skip that lookup if your token only carries wiki:node:create.",
|
||||
"Async deletes return a task_id; this command polls for a bounded window and then prints a follow-up drive +task_result command.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readWikiNodeDeleteSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readWikiNodeDeleteSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildWikiNodeDeleteDryRun(spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readWikiNodeDeleteSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := runWikiNodeDelete(ctx, wikiNodeDeleteAPI{runtime: runtime}, runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// wikiNodeDeleteSpec is the normalized input for the shortcut. Token / ObjType
|
||||
// reconcile URL inputs with the explicit flags; SourceKind is purely for the
|
||||
// dry-run description string.
|
||||
type wikiNodeDeleteSpec struct {
|
||||
NodeToken string
|
||||
ObjType string
|
||||
SpaceID string
|
||||
IncludeChildren bool
|
||||
SourceKind string // "raw" | "url"
|
||||
}
|
||||
|
||||
// RequestBody builds the JSON body for DELETE /spaces/{id}/nodes/{token}.
|
||||
func (spec wikiNodeDeleteSpec) RequestBody() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"obj_type": spec.ObjType,
|
||||
"include_children": spec.IncludeChildren,
|
||||
}
|
||||
}
|
||||
|
||||
// wikiNodeDeleteClient isolates the network operations so business logic can
|
||||
// be unit-tested without real HTTP calls. Mirrors wikiDeleteSpaceClient.
|
||||
type wikiNodeDeleteClient interface {
|
||||
ResolveNode(ctx context.Context, token, objType string) (*wikiNodeRecord, error)
|
||||
DeleteNode(ctx context.Context, spaceID string, spec wikiNodeDeleteSpec) (string, error)
|
||||
GetDeleteNodeTask(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error)
|
||||
}
|
||||
|
||||
type wikiNodeDeleteAPI struct {
|
||||
runtime *common.RuntimeContext
|
||||
}
|
||||
|
||||
func (api wikiNodeDeleteAPI) ResolveNode(ctx context.Context, token, objType string) (*wikiNodeRecord, error) {
|
||||
params := map[string]interface{}{"token": token}
|
||||
// get_node takes obj_type only when the token is an obj_token. For
|
||||
// wiki node_tokens the API rejects an obj_type kwarg, so omit it.
|
||||
if objType != "" && objType != "wiki" {
|
||||
params["obj_type"] = objType
|
||||
}
|
||||
data, err := api.runtime.CallAPI("GET", "/open-apis/wiki/v2/spaces/get_node", params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseWikiNodeRecord(common.GetMap(data, "node"))
|
||||
}
|
||||
|
||||
func (api wikiNodeDeleteAPI) DeleteNode(ctx context.Context, spaceID string, spec wikiNodeDeleteSpec) (string, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
"DELETE",
|
||||
fmt.Sprintf(
|
||||
"/open-apis/wiki/v2/spaces/%s/nodes/%s",
|
||||
validate.EncodePathSegment(spaceID),
|
||||
validate.EncodePathSegment(spec.NodeToken),
|
||||
),
|
||||
nil,
|
||||
spec.RequestBody(),
|
||||
)
|
||||
if err != nil {
|
||||
return "", wrapWikiNodeDeleteAPIError(err)
|
||||
}
|
||||
return common.GetString(data, "task_id"), nil
|
||||
}
|
||||
|
||||
func (api wikiNodeDeleteAPI) GetDeleteNodeTask(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
|
||||
map[string]interface{}{"task_type": wikiAsyncTaskTypeDeleteNode},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return wikiAsyncTaskStatus{}, err
|
||||
}
|
||||
return parseWikiAsyncTaskStatus(taskID, common.GetMap(data, "task"), wikiAsyncResultSimpleTask)
|
||||
}
|
||||
|
||||
func readWikiNodeDeleteSpec(runtime *common.RuntimeContext) (wikiNodeDeleteSpec, error) {
|
||||
return parseWikiNodeDeleteSpec(
|
||||
runtime.Str("node-token"),
|
||||
runtime.Str("obj-type"),
|
||||
runtime.Str("space-id"),
|
||||
runtime.Bool("include-children"),
|
||||
)
|
||||
}
|
||||
|
||||
// parseWikiNodeDeleteSpec normalizes the raw flag values: extracts a token
|
||||
// from a URL when provided, reconciles URL-implied obj_type against the
|
||||
// explicit flag, and validates that the resulting obj_type is one the delete
|
||||
// API accepts.
|
||||
func parseWikiNodeDeleteSpec(rawToken, rawObjType, rawSpaceID string, includeChildren bool) (wikiNodeDeleteSpec, error) {
|
||||
tokenInput := strings.TrimSpace(rawToken)
|
||||
if tokenInput == "" {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation("--node-token is required")
|
||||
}
|
||||
|
||||
spec := wikiNodeDeleteSpec{
|
||||
ObjType: strings.ToLower(strings.TrimSpace(rawObjType)),
|
||||
SpaceID: strings.TrimSpace(rawSpaceID),
|
||||
IncludeChildren: includeChildren,
|
||||
}
|
||||
|
||||
if strings.Contains(tokenInput, "://") {
|
||||
u, err := url.Parse(tokenInput)
|
||||
if err != nil || u.Path == "" {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation("--node-token URL is malformed: %q", tokenInput)
|
||||
}
|
||||
token, urlObjType, ok := tokenAndObjTypeFromWikiURL(u.Path)
|
||||
if !ok {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation(
|
||||
"unsupported --node-token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token",
|
||||
u.Path,
|
||||
)
|
||||
}
|
||||
spec.NodeToken = token
|
||||
spec.SourceKind = "url"
|
||||
|
||||
// /wiki/<token> implies node_token → obj_type=wiki for the delete API.
|
||||
// Cloud doc paths (/docx/, /sheets/, ...) already give us a concrete type.
|
||||
inferred := urlObjType
|
||||
if inferred == "" {
|
||||
inferred = "wiki"
|
||||
}
|
||||
switch {
|
||||
case spec.ObjType == "":
|
||||
spec.ObjType = inferred
|
||||
case spec.ObjType != inferred:
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation(
|
||||
"--obj-type %q does not match the obj_type %q implied by the URL path; pass only one",
|
||||
spec.ObjType, inferred,
|
||||
)
|
||||
}
|
||||
} else if strings.ContainsAny(tokenInput, "/?#") {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation(
|
||||
"--node-token must be a raw token or a full URL; partial paths are not accepted: %q",
|
||||
tokenInput,
|
||||
)
|
||||
} else {
|
||||
spec.NodeToken = tokenInput
|
||||
spec.SourceKind = "raw"
|
||||
}
|
||||
|
||||
if spec.ObjType == "" {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation(
|
||||
"--obj-type is required (one of: %s)",
|
||||
strings.Join(wikiNodeDeleteObjTypes, ", "),
|
||||
)
|
||||
}
|
||||
if !isValidWikiDeleteObjType(spec.ObjType) {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation(
|
||||
"--obj-type %q is not valid; pick one of: %s",
|
||||
spec.ObjType, strings.Join(wikiNodeDeleteObjTypes, ", "),
|
||||
)
|
||||
}
|
||||
if err := validateOptionalResourceName(spec.NodeToken, "--node-token"); err != nil {
|
||||
return wikiNodeDeleteSpec{}, err
|
||||
}
|
||||
if err := validateOptionalResourceName(spec.SpaceID, "--space-id"); err != nil {
|
||||
return wikiNodeDeleteSpec{}, err
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func isValidWikiDeleteObjType(v string) bool {
|
||||
for _, t := range wikiNodeDeleteObjTypes {
|
||||
if v == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildWikiNodeDeleteDryRun(spec wikiNodeDeleteSpec) *common.DryRunAPI {
|
||||
dry := common.NewDryRunAPI().Desc(
|
||||
"async-aware: delete wiki node -> poll wiki delete-node task when task_id is returned (auto-resolves space_id via get_node when --space-id is omitted)",
|
||||
)
|
||||
|
||||
if spec.SpaceID == "" {
|
||||
params := map[string]interface{}{"token": spec.NodeToken}
|
||||
if spec.ObjType != "" && spec.ObjType != "wiki" {
|
||||
params["obj_type"] = spec.ObjType
|
||||
}
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve space_id via get_node").
|
||||
Params(params)
|
||||
dry.DELETE(fmt.Sprintf(
|
||||
"/open-apis/wiki/v2/spaces/%s/nodes/%s",
|
||||
"<resolved_space_id>",
|
||||
validate.EncodePathSegment(spec.NodeToken),
|
||||
)).
|
||||
Desc("[2] Delete wiki node").
|
||||
Body(spec.RequestBody())
|
||||
} else {
|
||||
dry.DELETE(fmt.Sprintf(
|
||||
"/open-apis/wiki/v2/spaces/%s/nodes/%s",
|
||||
validate.EncodePathSegment(spec.SpaceID),
|
||||
validate.EncodePathSegment(spec.NodeToken),
|
||||
)).
|
||||
Desc("[1] Delete wiki node").
|
||||
Body(spec.RequestBody())
|
||||
}
|
||||
|
||||
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
|
||||
Desc("[N] Poll wiki delete-node task result when async").
|
||||
Set("task_id", "<task_id>").
|
||||
Params(map[string]interface{}{"task_type": wikiAsyncTaskTypeDeleteNode})
|
||||
|
||||
return dry
|
||||
}
|
||||
|
||||
func runWikiNodeDelete(ctx context.Context, client wikiNodeDeleteClient, runtime *common.RuntimeContext, spec wikiNodeDeleteSpec) (map[string]interface{}, error) {
|
||||
spaceID, err := resolveWikiNodeDeleteSpaceID(ctx, client, runtime, spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Deleting wiki node %s in space %s (obj_type=%s, include_children=%t)...\n",
|
||||
common.MaskToken(spec.NodeToken), common.MaskToken(spaceID), spec.ObjType, spec.IncludeChildren)
|
||||
|
||||
taskID, err := client.DeleteNode(ctx, spaceID, spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"space_id": spaceID,
|
||||
"node_token": spec.NodeToken,
|
||||
"obj_type": spec.ObjType,
|
||||
"include_children": spec.IncludeChildren,
|
||||
}
|
||||
|
||||
// Empty task_id means the delete completed synchronously. Match the
|
||||
// shape used by +delete-space so downstream scripts can read `status`
|
||||
// uniformly regardless of which branch fired.
|
||||
if taskID == "" {
|
||||
out["ready"] = true
|
||||
out["failed"] = false
|
||||
out["status"] = wikiAsyncStatusSuccess
|
||||
out["status_msg"] = wikiAsyncStatusSuccess
|
||||
return out, nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki node delete is async, polling task %s...\n", taskID)
|
||||
nextCommand := wikiDeleteNodeTaskResultCommand(taskID, runtime.As())
|
||||
status, ready, err := pollWikiAsyncTask(
|
||||
ctx, runtime, taskID, "delete-node",
|
||||
wikiDeleteNodePollAttempts, wikiDeleteNodePollInterval,
|
||||
func(ctx context.Context, id string) (wikiAsyncTaskStatus, error) {
|
||||
return client.GetDeleteNodeTask(ctx, id)
|
||||
},
|
||||
nextCommand,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out["task_id"] = taskID
|
||||
out["ready"] = ready
|
||||
out["failed"] = status.Failed()
|
||||
out["status"] = status.StatusCode()
|
||||
out["status_msg"] = status.StatusLabel()
|
||||
|
||||
if !ready {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki delete-node task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveWikiNodeDeleteSpaceID returns the explicit space_id when the caller
|
||||
// supplied one, otherwise resolves it via get_node. The latter saves callers
|
||||
// from running +node-get first when they only have a node_token.
|
||||
func resolveWikiNodeDeleteSpaceID(ctx context.Context, client wikiNodeDeleteClient, runtime *common.RuntimeContext, spec wikiNodeDeleteSpec) (string, error) {
|
||||
if spec.SpaceID != "" {
|
||||
return spec.SpaceID, nil
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolving space_id via get_node for token %s...\n", common.MaskToken(spec.NodeToken))
|
||||
node, err := client.ResolveNode(ctx, spec.NodeToken, spec.ObjType)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
spaceID, err := requireWikiNodeSpaceID(node)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved to space %s\n", common.MaskToken(spaceID))
|
||||
return spaceID, nil
|
||||
}
|
||||
|
||||
func wikiDeleteNodeTaskResultCommand(taskID string, identity core.Identity) string {
|
||||
asFlag := string(identity)
|
||||
if asFlag == "" {
|
||||
asFlag = "user"
|
||||
}
|
||||
return fmt.Sprintf("lark-cli drive +task_result --scenario wiki_delete_node --task-id %s --as %s", taskID, asFlag)
|
||||
}
|
||||
|
||||
// wrapWikiNodeDeleteAPIError attaches actionable hints to the two Lark error
|
||||
// codes whose remediation lives outside the CLI:
|
||||
// - 131011: approval required (deletion gated by Wiki UI approval flow)
|
||||
// - 131003: subtree too large to cascade-delete (must split or use
|
||||
// include_children=false)
|
||||
//
|
||||
// Other codes pass through untouched so the generic error envelope still
|
||||
// surfaces the original code+message.
|
||||
func wrapWikiNodeDeleteAPIError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
return err
|
||||
}
|
||||
var hint string
|
||||
switch exitErr.Detail.Code {
|
||||
case wikiDeleteNodeErrCodeApprovalRequired:
|
||||
hint = "this wiki node has delete-approval enabled; ask the user to apply via the Wiki UI (CLI cannot bypass approval)"
|
||||
case wikiDeleteNodeErrCodeSubtreeTooLarge:
|
||||
hint = "the subtree is too large to cascade-delete in one call; pass --include-children=false to keep the children (they will be moved up to the parent), or delete sub-trees first"
|
||||
}
|
||||
if hint == "" {
|
||||
return err
|
||||
}
|
||||
if existing := strings.TrimSpace(exitErr.Detail.Hint); existing != "" {
|
||||
hint = existing + "\n" + hint
|
||||
}
|
||||
// ErrWithHint drops the upstream Detail.Code / Detail / Risk fields; build
|
||||
// the ExitError by hand so the Lark error code stays available to logs and
|
||||
// downstream pivots.
|
||||
return &output.ExitError{
|
||||
Code: exitErr.Code,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: exitErr.Detail.Type,
|
||||
Code: exitErr.Detail.Code,
|
||||
Message: exitErr.Detail.Message,
|
||||
Hint: hint,
|
||||
ConsoleURL: exitErr.Detail.ConsoleURL,
|
||||
Risk: exitErr.Detail.Risk,
|
||||
Detail: exitErr.Detail.Detail,
|
||||
},
|
||||
}
|
||||
}
|
||||
611
shortcuts/wiki/wiki_node_delete_test.go
Normal file
611
shortcuts/wiki/wiki_node_delete_test.go
Normal file
@@ -0,0 +1,611 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ── parseWikiNodeDeleteSpec ─────────────────────────────────────────────────
|
||||
|
||||
func TestParseWikiNodeDeleteSpecAcceptsRawWikiToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeDeleteSpec("wikcnABC", "wiki", "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
|
||||
}
|
||||
if spec.NodeToken != "wikcnABC" || spec.ObjType != "wiki" || spec.SourceKind != "raw" || !spec.IncludeChildren {
|
||||
t.Fatalf("spec = %+v", spec)
|
||||
}
|
||||
body := spec.RequestBody()
|
||||
if body["obj_type"] != "wiki" || body["include_children"] != true {
|
||||
t.Fatalf("RequestBody = %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeDeleteSpecRejectsMissingObjType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeDeleteSpec("wikcnABC", "", "", true)
|
||||
if err == nil || !strings.Contains(err.Error(), "--obj-type is required") {
|
||||
t.Fatalf("expected obj-type required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeDeleteSpecRejectsInvalidObjType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeDeleteSpec("wikcnABC", "comment", "", true)
|
||||
if err == nil || !strings.Contains(err.Error(), "is not valid") {
|
||||
t.Fatalf("expected invalid obj-type error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeDeleteSpecRejectsEmptyToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeDeleteSpec(" ", "wiki", "", true)
|
||||
if err == nil || !strings.Contains(err.Error(), "--node-token is required") {
|
||||
t.Fatalf("expected token required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeDeleteSpecExtractsTokenFromWikiURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeDeleteSpec("https://feishu.cn/wiki/wikcnABC", "", "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
|
||||
}
|
||||
if spec.NodeToken != "wikcnABC" || spec.ObjType != "wiki" || spec.SourceKind != "url" {
|
||||
t.Fatalf("spec = %+v, want url-extracted node_token + obj_type=wiki", spec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeDeleteSpecInfersObjTypeFromDocxURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeDeleteSpec("https://feishu.cn/docx/docxXYZ", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
|
||||
}
|
||||
if spec.NodeToken != "docxXYZ" || spec.ObjType != "docx" || spec.IncludeChildren {
|
||||
t.Fatalf("spec = %+v, want docxXYZ obj_type=docx include_children=false", spec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeDeleteSpecRejectsURLObjTypeMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeDeleteSpec("https://feishu.cn/docx/docxXYZ", "wiki", "", true)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match the obj_type") {
|
||||
t.Fatalf("expected obj-type mismatch error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeDeleteSpecRejectsPartialPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeDeleteSpec("/wiki/wikcnABC", "wiki", "", true)
|
||||
if err == nil || !strings.Contains(err.Error(), "partial paths are not accepted") {
|
||||
t.Fatalf("expected partial-path rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DryRun ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBuildWikiNodeDeleteDryRunWithoutSpaceIDShowsResolve(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeDeleteSpec("https://feishu.cn/docx/docxXYZ", "", "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
|
||||
}
|
||||
|
||||
dry := buildWikiNodeDeleteDryRun(spec)
|
||||
got := decodeDryRunAPIs(t, dry)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len(dry.api) = %d, want 3 (get_node, delete, task poll)", len(got))
|
||||
}
|
||||
if got[0].URL != "/open-apis/wiki/v2/spaces/get_node" {
|
||||
t.Fatalf("step[0].URL = %q, want get_node", got[0].URL)
|
||||
}
|
||||
if got[0].Params["obj_type"] != "docx" || got[0].Params["token"] != "docxXYZ" {
|
||||
t.Fatalf("step[0].params = %#v", got[0].Params)
|
||||
}
|
||||
if got[1].URL != "/open-apis/wiki/v2/spaces/<resolved_space_id>/nodes/docxXYZ" {
|
||||
t.Fatalf("step[1].URL = %q, want delete with placeholder", got[1].URL)
|
||||
}
|
||||
if got[1].Body["obj_type"] != "docx" || got[1].Body["include_children"] != true {
|
||||
t.Fatalf("step[1].body = %#v", got[1].Body)
|
||||
}
|
||||
if got[2].Params["task_type"] != "delete_node" {
|
||||
t.Fatalf("step[2].params task_type = %#v, want delete_node", got[2].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWikiNodeDeleteDryRunWithSpaceIDOmitsResolve(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeDeleteSpec("wikcnABC", "wiki", "7629741305993170448", false)
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
|
||||
}
|
||||
|
||||
dry := buildWikiNodeDeleteDryRun(spec)
|
||||
got := decodeDryRunAPIs(t, dry)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(dry.api) = %d, want 2 (delete + task poll) when --space-id supplied", len(got))
|
||||
}
|
||||
if got[0].Method != "DELETE" || got[0].URL != "/open-apis/wiki/v2/spaces/7629741305993170448/nodes/wikcnABC" {
|
||||
t.Fatalf("step[0] = %+v", got[0])
|
||||
}
|
||||
if got[0].Body["include_children"] != false {
|
||||
t.Fatalf("body include_children = %#v", got[0].Body["include_children"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── runWikiNodeDelete unit ──────────────────────────────────────────────────
|
||||
|
||||
type fakeWikiNodeDeleteClient struct {
|
||||
resolveErr error
|
||||
resolveNode *wikiNodeRecord
|
||||
resolveCalls []string
|
||||
|
||||
deleteErr error
|
||||
deleteTaskID string
|
||||
deleteCalls []struct {
|
||||
SpaceID string
|
||||
Spec wikiNodeDeleteSpec
|
||||
}
|
||||
|
||||
taskStatuses []wikiAsyncTaskStatus
|
||||
taskErrs []error
|
||||
taskCallArgs []string
|
||||
}
|
||||
|
||||
func (fake *fakeWikiNodeDeleteClient) ResolveNode(ctx context.Context, token, objType string) (*wikiNodeRecord, error) {
|
||||
fake.resolveCalls = append(fake.resolveCalls, token)
|
||||
if fake.resolveErr != nil {
|
||||
return nil, fake.resolveErr
|
||||
}
|
||||
return fake.resolveNode, nil
|
||||
}
|
||||
|
||||
func (fake *fakeWikiNodeDeleteClient) DeleteNode(ctx context.Context, spaceID string, spec wikiNodeDeleteSpec) (string, error) {
|
||||
fake.deleteCalls = append(fake.deleteCalls, struct {
|
||||
SpaceID string
|
||||
Spec wikiNodeDeleteSpec
|
||||
}{SpaceID: spaceID, Spec: spec})
|
||||
if fake.deleteErr != nil {
|
||||
return "", fake.deleteErr
|
||||
}
|
||||
return fake.deleteTaskID, nil
|
||||
}
|
||||
|
||||
func (fake *fakeWikiNodeDeleteClient) GetDeleteNodeTask(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error) {
|
||||
idx := len(fake.taskCallArgs)
|
||||
fake.taskCallArgs = append(fake.taskCallArgs, taskID)
|
||||
if idx < len(fake.taskErrs) && fake.taskErrs[idx] != nil {
|
||||
return wikiAsyncTaskStatus{TaskID: taskID}, fake.taskErrs[idx]
|
||||
}
|
||||
if idx < len(fake.taskStatuses) {
|
||||
status := fake.taskStatuses[idx]
|
||||
if status.TaskID == "" {
|
||||
status.TaskID = taskID
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
return wikiAsyncTaskStatus{TaskID: taskID}, nil
|
||||
}
|
||||
|
||||
var wikiDeleteNodePollMu sync.Mutex
|
||||
|
||||
func withSingleWikiDeleteNodePoll(t *testing.T) {
|
||||
t.Helper()
|
||||
wikiDeleteNodePollMu.Lock()
|
||||
|
||||
prevAttempts, prevInterval := wikiDeleteNodePollAttempts, wikiDeleteNodePollInterval
|
||||
wikiDeleteNodePollAttempts, wikiDeleteNodePollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
wikiDeleteNodePollAttempts, wikiDeleteNodePollInterval = prevAttempts, prevInterval
|
||||
wikiDeleteNodePollMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func newWikiNodeDeleteRuntime(t *testing.T, as core.Identity) (*common.RuntimeContext, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
|
||||
cfg := wikiTestConfig()
|
||||
factory, _, stderr, _ := cmdutil.TestFactory(t, cfg)
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "wiki +node-delete"}, cfg, as)
|
||||
runtime.Factory = factory
|
||||
return runtime, stderr
|
||||
}
|
||||
|
||||
func TestRunWikiNodeDeleteResolvesSpaceWhenMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
client := &fakeWikiNodeDeleteClient{
|
||||
resolveNode: &wikiNodeRecord{SpaceID: "space_resolved"},
|
||||
}
|
||||
|
||||
out, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
|
||||
NodeToken: "wikcnABC",
|
||||
ObjType: "wiki",
|
||||
IncludeChildren: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiNodeDelete() error = %v", err)
|
||||
}
|
||||
if len(client.resolveCalls) != 1 || client.resolveCalls[0] != "wikcnABC" {
|
||||
t.Fatalf("resolve calls = %v", client.resolveCalls)
|
||||
}
|
||||
if len(client.deleteCalls) != 1 || client.deleteCalls[0].SpaceID != "space_resolved" {
|
||||
t.Fatalf("delete calls = %+v", client.deleteCalls)
|
||||
}
|
||||
if out["space_id"] != "space_resolved" || out["ready"] != true || out["status"] != "success" {
|
||||
t.Fatalf("sync output = %#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiNodeDeleteSkipsResolveWhenSpaceProvided(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
client := &fakeWikiNodeDeleteClient{}
|
||||
|
||||
_, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
|
||||
NodeToken: "wikcnABC", ObjType: "wiki", SpaceID: "space_explicit",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiNodeDelete() error = %v", err)
|
||||
}
|
||||
if len(client.resolveCalls) != 0 {
|
||||
t.Fatalf("resolveCalls should be empty when --space-id supplied, got %v", client.resolveCalls)
|
||||
}
|
||||
if client.deleteCalls[0].SpaceID != "space_explicit" {
|
||||
t.Fatalf("delete used wrong space: %+v", client.deleteCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiNodeDeleteAsyncReadyShape(t *testing.T) {
|
||||
withSingleWikiDeleteNodePoll(t)
|
||||
|
||||
runtime, stderr := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
client := &fakeWikiNodeDeleteClient{
|
||||
deleteTaskID: "task_async_node",
|
||||
taskStatuses: []wikiAsyncTaskStatus{{Status: "success"}},
|
||||
}
|
||||
|
||||
out, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
|
||||
NodeToken: "wikcnABC", ObjType: "wiki", SpaceID: "space_123", IncludeChildren: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiNodeDelete() error = %v", err)
|
||||
}
|
||||
if out["task_id"] != "task_async_node" || out["ready"] != true || out["failed"] != false {
|
||||
t.Fatalf("async-ready output = %#v", out)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "async, polling task") || !strings.Contains(stderr.String(), "delete-node task completed successfully") {
|
||||
t.Fatalf("stderr = %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiNodeDeleteAsyncTimeoutReturnsNextCommand(t *testing.T) {
|
||||
withSingleWikiDeleteNodePoll(t)
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
client := &fakeWikiNodeDeleteClient{
|
||||
deleteTaskID: "task_async_node",
|
||||
taskStatuses: []wikiAsyncTaskStatus{{Status: "processing"}},
|
||||
}
|
||||
|
||||
out, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
|
||||
NodeToken: "wikcnABC", ObjType: "wiki", SpaceID: "space_123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runWikiNodeDelete() error = %v", err)
|
||||
}
|
||||
wantNext := wikiDeleteNodeTaskResultCommand("task_async_node", core.AsUser)
|
||||
if out["ready"] != false || out["timed_out"] != true || out["next_command"] != wantNext {
|
||||
t.Fatalf("timeout output = %#v", out)
|
||||
}
|
||||
if !strings.Contains(wantNext, "wiki_delete_node") {
|
||||
t.Fatalf("next command should scope wiki_delete_node, got %q", wantNext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiNodeDeleteAsyncFailureSurfacesReason(t *testing.T) {
|
||||
withSingleWikiDeleteNodePoll(t)
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
client := &fakeWikiNodeDeleteClient{
|
||||
deleteTaskID: "task_async_node",
|
||||
taskStatuses: []wikiAsyncTaskStatus{{Status: "failure", StatusMsg: "permission denied"}},
|
||||
}
|
||||
|
||||
_, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
|
||||
NodeToken: "wikcnABC", ObjType: "wiki", SpaceID: "space_123",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "delete-node task task_async_node failed: permission denied") {
|
||||
t.Fatalf("expected async failure error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── error code hint mapping ─────────────────────────────────────────────────
|
||||
|
||||
func TestWrapWikiNodeDeleteAPIErrorAddsApprovalHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: wikiDeleteNodeErrCodeApprovalRequired,
|
||||
Message: "node requires delete approval",
|
||||
},
|
||||
}
|
||||
got := wrapWikiNodeDeleteAPIError(in)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError, got %T %v", got, got)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "delete-approval enabled") || !strings.Contains(exitErr.Detail.Hint, "Wiki UI") {
|
||||
t.Fatalf("hint = %q, want approval guidance", exitErr.Detail.Hint)
|
||||
}
|
||||
// Original code/message must be preserved so logs and dashboards still
|
||||
// pivot on the upstream error code.
|
||||
if exitErr.Detail.Code != wikiDeleteNodeErrCodeApprovalRequired {
|
||||
t.Fatalf("hint wrapper lost the original code: %d", exitErr.Detail.Code)
|
||||
}
|
||||
if exitErr.Detail.Message != "node requires delete approval" {
|
||||
t.Fatalf("message changed unexpectedly: %q", exitErr.Detail.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapWikiNodeDeleteAPIErrorAddsSubtreeHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: wikiDeleteNodeErrCodeSubtreeTooLarge,
|
||||
Message: "subtree too large",
|
||||
},
|
||||
}
|
||||
got := wrapWikiNodeDeleteAPIError(in)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T %v", got, got)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--include-children=false") {
|
||||
t.Fatalf("hint = %q, want subtree-too-large guidance", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapWikiNodeDeleteAPIErrorPassesThroughUnknownCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{Type: "api_error", Code: 131005, Message: "node not found"},
|
||||
}
|
||||
got := wrapWikiNodeDeleteAPIError(in)
|
||||
if !reflect.DeepEqual(got, in) {
|
||||
t.Fatalf("unknown code should pass through; got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapWikiNodeDeleteAPIErrorIgnoresNonExit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := errors.New("transport boom")
|
||||
if got := wrapWikiNodeDeleteAPIError(in); got != in {
|
||||
t.Fatalf("non-ExitError should pass through, got %T %v", got, got)
|
||||
}
|
||||
if got := wrapWikiNodeDeleteAPIError(nil); got != nil {
|
||||
t.Fatalf("nil should pass through, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mounted execute (httpmock) ──────────────────────────────────────────────
|
||||
|
||||
func TestWikiNodeDeleteExecuteRequiresYesConfirmation(t *testing.T) {
|
||||
factory, stdout, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeDelete, []string{
|
||||
"+node-delete",
|
||||
"--node-token", "wikcnABC",
|
||||
"--obj-type", "wiki",
|
||||
"--space-id", "space_123",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
|
||||
t.Fatalf("expected high-risk confirmation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeDeleteExecuteSync(t *testing.T) {
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
|
||||
deleteStub := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes/wikcnABC",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
"msg": "success",
|
||||
},
|
||||
}
|
||||
reg.Register(deleteStub)
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeDelete, []string{
|
||||
"+node-delete",
|
||||
"--node-token", "wikcnABC",
|
||||
"--obj-type", "wiki",
|
||||
"--space-id", "space_123",
|
||||
"--yes",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
if data["ready"] != true || data["failed"] != false || data["space_id"] != "space_123" {
|
||||
t.Fatalf("sync output = %#v", data)
|
||||
}
|
||||
if data["obj_type"] != "wiki" || data["include_children"] != true {
|
||||
t.Fatalf("obj_type/include_children = %#v / %#v", data["obj_type"], data["include_children"])
|
||||
}
|
||||
|
||||
var captured map[string]interface{}
|
||||
if err := json.Unmarshal(deleteStub.CapturedBody, &captured); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
if captured["obj_type"] != "wiki" || captured["include_children"] != true {
|
||||
t.Fatalf("captured DELETE body = %#v", captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeDeleteExecuteResolvesSpaceIDFromURL(t *testing.T) {
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
|
||||
resolveStub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_resolved",
|
||||
"node_token": "wikcnABC",
|
||||
"obj_token": "docxXYZ",
|
||||
"obj_type": "docx",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
var resolveQuery string
|
||||
resolveStub.OnMatch = func(req *http.Request) { resolveQuery = req.URL.RawQuery }
|
||||
reg.Register(resolveStub)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_resolved/nodes/docxXYZ",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeDelete, []string{
|
||||
"+node-delete",
|
||||
"--node-token", "https://feishu.cn/docx/docxXYZ",
|
||||
"--yes",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(resolveQuery, "token=docxXYZ") || !strings.Contains(resolveQuery, "obj_type=docx") {
|
||||
t.Fatalf("resolve query = %q, want token+obj_type", resolveQuery)
|
||||
}
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
if data["space_id"] != "space_resolved" || data["obj_type"] != "docx" {
|
||||
t.Fatalf("output = %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeDeleteExecuteAsyncSuccess(t *testing.T) {
|
||||
withSingleWikiDeleteNodePoll(t)
|
||||
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes/wikcnABC",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"task_id": "task_async_node"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/tasks/task_async_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
// Gateway returns delete-node status under the generic
|
||||
// simple_task_result key (NOT delete_node_result).
|
||||
"simple_task_result": map[string]interface{}{
|
||||
"status": "success",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeDelete, []string{
|
||||
"+node-delete",
|
||||
"--node-token", "wikcnABC",
|
||||
"--obj-type", "wiki",
|
||||
"--space-id", "space_123",
|
||||
"--yes",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
if data["task_id"] != "task_async_node" || data["ready"] != true || data["failed"] != false {
|
||||
t.Fatalf("async-success output = %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type dryRunStep struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
}
|
||||
|
||||
func decodeDryRunAPIs(t *testing.T, dry *common.DryRunAPI) []dryRunStep {
|
||||
t.Helper()
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
API []dryRunStep `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run: %v", err)
|
||||
}
|
||||
return got.API
|
||||
}
|
||||
370
shortcuts/wiki/wiki_node_get.go
Normal file
370
shortcuts/wiki/wiki_node_get.go
Normal file
@@ -0,0 +1,370 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// wikiNodeGetURLObjTypes maps a Lark URL path prefix (slash-bounded) to the
|
||||
// obj_type the wiki get_node API expects when the token is an obj_token.
|
||||
// /wiki/ is handled separately because node_tokens take no obj_type.
|
||||
//
|
||||
// INVARIANT: the prefixes must be mutually exclusive (no prefix may be a
|
||||
// prefix of another). tokenAndObjTypeFromWikiURL ranges this map, and Go map
|
||||
// iteration order is randomized — overlapping prefixes would make the match
|
||||
// non-deterministic. The trailing slash keeps them disjoint today (e.g.
|
||||
// "/docx/" does not start with "/doc/"); preserve that when adding entries.
|
||||
var wikiNodeGetURLObjTypes = map[string]string{
|
||||
"/docx/": "docx",
|
||||
"/doc/": "doc",
|
||||
"/sheets/": "sheet",
|
||||
"/base/": "bitable",
|
||||
"/mindnote/": "mindnote",
|
||||
"/slides/": "slides",
|
||||
"/file/": "file",
|
||||
}
|
||||
|
||||
// wikiNodeGetObjTypeEnum is the union of obj_types accepted by the upstream
|
||||
// API. It is a superset of the create / move enums so this shortcut can look
|
||||
// up legacy `doc` nodes too.
|
||||
var wikiNodeGetObjTypeEnum = []string{
|
||||
"doc", "docx", "sheet", "bitable", "mindnote", "slides", "file",
|
||||
}
|
||||
|
||||
// WikiNodeGet wraps wiki.spaces.get_node so callers can resolve a node by
|
||||
// node_token, obj_token, or a Lark URL without hand-rolling a
|
||||
// `wiki spaces get_node --params ...` invocation. The shortcut prints a
|
||||
// formatted view of the node (title / obj_type / obj_token / parent /
|
||||
// creator / updated_at) and is intended as the "what am I about to
|
||||
// touch?" step before +move / +node-copy / +delete-space.
|
||||
var WikiNodeGet = common.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+node-get",
|
||||
Description: "Get wiki node details by node_token, obj_token, or Lark URL",
|
||||
Risk: "read",
|
||||
Scopes: []string{"wiki:node:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "wiki node_token, obj_token, or a Lark URL embedding one of them", Required: true},
|
||||
{Name: "obj-type", Desc: "obj_type when --token is an obj_token; auto-inferred from URL path when omitted", Enum: wikiNodeGetObjTypeEnum},
|
||||
{Name: "space-id", Desc: "optional: assert the resolved node lives in this space"},
|
||||
},
|
||||
Tips: []string{
|
||||
"--token accepts a raw token (wikcnXXX, docxXXX, ...) or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>.",
|
||||
"For raw obj_tokens (not starting with wik), pass --obj-type so the API knows how to resolve them; URL inputs infer it from the path.",
|
||||
"Pair with +move / +node-copy / +delete-space to confirm space_id, obj_type, and parent before mutating.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readWikiNodeGetSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readWikiNodeGetSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildWikiNodeGetDryRun(spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readWikiNodeGetSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Fetching wiki node %s...\n", common.MaskToken(spec.Token))
|
||||
|
||||
data, err := runtime.CallAPI("GET", "/open-apis/wiki/v2/spaces/get_node", spec.RequestParams(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw := common.GetMap(data, "node")
|
||||
node, err := parseWikiNodeRecord(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if spec.SpaceID != "" && node.SpaceID != "" && spec.SpaceID != node.SpaceID {
|
||||
return output.ErrValidation(
|
||||
"--space-id %q does not match the resolved node space %q (node_token=%s)",
|
||||
spec.SpaceID, node.SpaceID, node.NodeToken,
|
||||
)
|
||||
}
|
||||
if spec.SpaceID != "" && node.SpaceID == "" {
|
||||
// The cross-check was requested but get_node returned no space_id,
|
||||
// so it silently passed. Surface that the assertion was a no-op
|
||||
// rather than letting the caller assume it was verified.
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"Warning: --space-id %q could not be verified; the resolved node carries no space_id.\n",
|
||||
spec.SpaceID)
|
||||
}
|
||||
|
||||
out := wikiNodeGetOutput(node, raw)
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderWikiNodeGetPretty(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// wikiNodeGetSpec is the normalized input for the shortcut.
|
||||
type wikiNodeGetSpec struct {
|
||||
// Token is the resolved token (after URL extraction) to send to the API.
|
||||
Token string
|
||||
// ObjType is the resolved obj_type. Empty for node_tokens (the API does
|
||||
// not need obj_type for `wik`-prefixed tokens).
|
||||
ObjType string
|
||||
// SpaceID is an optional cross-check; when set, the response space_id must match.
|
||||
SpaceID string
|
||||
// SourceKind records how Token was derived for the dry-run description:
|
||||
// "url-wiki", "url-obj", "raw-node", "raw-obj".
|
||||
SourceKind string
|
||||
}
|
||||
|
||||
// RequestParams returns the query params for GET /wiki/v2/spaces/get_node.
|
||||
func (spec wikiNodeGetSpec) RequestParams() map[string]interface{} {
|
||||
params := map[string]interface{}{"token": spec.Token}
|
||||
if spec.ObjType != "" {
|
||||
params["obj_type"] = spec.ObjType
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func readWikiNodeGetSpec(runtime *common.RuntimeContext) (wikiNodeGetSpec, error) {
|
||||
return parseWikiNodeGetSpec(
|
||||
runtime.Str("token"),
|
||||
runtime.Str("obj-type"),
|
||||
runtime.Str("space-id"),
|
||||
)
|
||||
}
|
||||
|
||||
// parseWikiNodeGetSpec normalizes the raw flag values: extracts a token from a
|
||||
// URL when needed, picks the obj_type (URL path > explicit flag > none for
|
||||
// node_tokens), and validates the token shape.
|
||||
func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetSpec, error) {
|
||||
tokenInput := strings.TrimSpace(rawToken)
|
||||
if tokenInput == "" {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation("--token is required")
|
||||
}
|
||||
|
||||
spec := wikiNodeGetSpec{
|
||||
ObjType: strings.ToLower(strings.TrimSpace(rawObjType)),
|
||||
SpaceID: strings.TrimSpace(rawSpaceID),
|
||||
}
|
||||
|
||||
if strings.Contains(tokenInput, "://") {
|
||||
u, err := url.Parse(tokenInput)
|
||||
if err != nil || u.Path == "" {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation("--token URL is malformed: %q", tokenInput)
|
||||
}
|
||||
token, urlObjType, ok := tokenAndObjTypeFromWikiURL(u.Path)
|
||||
if !ok {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation(
|
||||
"unsupported --token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token",
|
||||
u.Path,
|
||||
)
|
||||
}
|
||||
spec.Token = token
|
||||
if urlObjType == "" {
|
||||
spec.SourceKind = "url-wiki"
|
||||
} else {
|
||||
spec.SourceKind = "url-obj"
|
||||
}
|
||||
switch {
|
||||
case spec.ObjType == "" && urlObjType != "":
|
||||
spec.ObjType = urlObjType
|
||||
case spec.ObjType != "" && urlObjType != "" && spec.ObjType != urlObjType:
|
||||
return wikiNodeGetSpec{}, output.ErrValidation(
|
||||
"--obj-type %q does not match the obj_type %q implied by the URL path; pass only one",
|
||||
spec.ObjType, urlObjType,
|
||||
)
|
||||
}
|
||||
} else if strings.ContainsAny(tokenInput, "/?#") {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation(
|
||||
"--token must be a raw token or a full URL; partial paths are not accepted: %q",
|
||||
tokenInput,
|
||||
)
|
||||
} else {
|
||||
spec.Token = tokenInput
|
||||
if looksLikeWikiNodeToken(spec.Token) {
|
||||
spec.SourceKind = "raw-node"
|
||||
// node_tokens take no obj_type; reject a conflicting flag rather
|
||||
// than silently passing it (the API would just ignore it, but the
|
||||
// mismatch signals caller confusion).
|
||||
if spec.ObjType != "" {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation(
|
||||
"--obj-type is only valid for obj_tokens; %q looks like a node_token",
|
||||
spec.Token,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
spec.SourceKind = "raw-obj"
|
||||
// A raw obj_token needs an explicit obj_type: get_node would
|
||||
// otherwise default to "doc" and fail confusingly for docx /
|
||||
// sheet / bitable / ... Fail fast with the same upfront contract
|
||||
// as +node-delete instead of deferring to an opaque API error.
|
||||
if spec.ObjType == "" {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation(
|
||||
"--obj-type is required for a raw obj_token %q (one of: %s); or pass a typed Lark URL (e.g. /docx/<token>) so it can be inferred",
|
||||
spec.Token, strings.Join(wikiNodeGetObjTypeEnum, ", "),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateOptionalResourceName(spec.Token, "--token"); err != nil {
|
||||
return wikiNodeGetSpec{}, err
|
||||
}
|
||||
if err := validateOptionalResourceName(spec.SpaceID, "--space-id"); err != nil {
|
||||
return wikiNodeGetSpec{}, err
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// looksLikeWikiNodeToken returns true when the token has the `wik` prefix used
|
||||
// for node_tokens. Lark wiki tokens are case-insensitive in practice; callers
|
||||
// pass `wikcn`/`wikus`/`Wik...` interchangeably, so normalize for the check.
|
||||
//
|
||||
// This is a heuristic based on the current Lark token-naming convention, not a
|
||||
// guaranteed invariant: if Lark ever introduces a non-node token type that
|
||||
// also starts with `wik`, it would be misclassified. Worst case is a
|
||||
// confusing API error (no data risk); revisit if the token scheme changes.
|
||||
func looksLikeWikiNodeToken(token string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(token), "wik")
|
||||
}
|
||||
|
||||
// tokenAndObjTypeFromWikiURL extracts the token and inferred obj_type from a
|
||||
// Lark URL path. The wiki path returns an empty obj_type because node_tokens
|
||||
// don't need one.
|
||||
func tokenAndObjTypeFromWikiURL(path string) (token, objType string, ok bool) {
|
||||
if t, found := wikiPathSegmentAfter(path, "/wiki/"); found {
|
||||
return t, "", true
|
||||
}
|
||||
for prefix, ot := range wikiNodeGetURLObjTypes {
|
||||
if t, found := wikiPathSegmentAfter(path, prefix); found {
|
||||
return t, ot, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// wikiPathSegmentAfter returns the first path segment after prefix, or ("",
|
||||
// false) when path doesn't start with prefix or the segment is empty.
|
||||
func wikiPathSegmentAfter(path, prefix string) (string, bool) {
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return "", false
|
||||
}
|
||||
rest := path[len(prefix):]
|
||||
if i := strings.IndexByte(rest, '/'); i >= 0 {
|
||||
rest = rest[:i]
|
||||
}
|
||||
rest = strings.TrimSpace(rest)
|
||||
if rest == "" {
|
||||
return "", false
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
func buildWikiNodeGetDryRun(spec wikiNodeGetSpec) *common.DryRunAPI {
|
||||
dry := common.NewDryRunAPI()
|
||||
switch spec.SourceKind {
|
||||
case "url-wiki":
|
||||
dry.Desc("Resolve wiki node from /wiki/ URL")
|
||||
case "url-obj":
|
||||
dry.Desc("Resolve wiki node from Lark document URL (obj_type inferred from path)")
|
||||
case "raw-node":
|
||||
dry.Desc("Look up wiki node by node_token")
|
||||
case "raw-obj":
|
||||
dry.Desc("Look up wiki node by obj_token")
|
||||
}
|
||||
return dry.GET("/open-apis/wiki/v2/spaces/get_node").Params(spec.RequestParams())
|
||||
}
|
||||
|
||||
// wikiNodeGetOutput shapes the structured output. It carries the formatted
|
||||
// values (title/obj_type/obj_token/parent_node_token/creator/updated_at)
|
||||
// the user asked for, plus enough raw fields (node_type, has_child, owner,
|
||||
// timestamps) that callers can pipe into +move / +node-copy without rerunning
|
||||
// get_node.
|
||||
//
|
||||
// No synthesized `url` is emitted: get_node returns none, and a
|
||||
// BuildResourceURL fallback (www.feishu.cn/wiki/<node_token>) is a
|
||||
// non-canonical link that misleads in a read/confirm command. Sibling read
|
||||
// shortcuts (+node-list, +node-copy) likewise omit it; node_token/obj_token
|
||||
// are the precise identifiers.
|
||||
func wikiNodeGetOutput(node *wikiNodeRecord, raw map[string]interface{}) map[string]interface{} {
|
||||
out := map[string]interface{}{
|
||||
"space_id": node.SpaceID,
|
||||
"node_token": node.NodeToken,
|
||||
"obj_token": node.ObjToken,
|
||||
"obj_type": node.ObjType,
|
||||
"node_type": node.NodeType,
|
||||
"parent_node_token": node.ParentNodeToken,
|
||||
"origin_node_token": node.OriginNodeToken,
|
||||
"title": node.Title,
|
||||
"has_child": node.HasChild,
|
||||
}
|
||||
|
||||
creator := strings.TrimSpace(common.GetString(raw, "node_creator"))
|
||||
if creator == "" {
|
||||
creator = strings.TrimSpace(common.GetString(raw, "creator"))
|
||||
}
|
||||
out["creator"] = creator
|
||||
out["owner"] = common.GetString(raw, "owner")
|
||||
|
||||
objEditRaw := common.GetString(raw, "obj_edit_time")
|
||||
out["obj_edit_time"] = objEditRaw
|
||||
out["obj_create_time"] = common.GetString(raw, "obj_create_time")
|
||||
out["node_create_time"] = common.GetString(raw, "node_create_time")
|
||||
out["updated_at"] = formatWikiTimestamp(objEditRaw)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// formatWikiTimestamp turns a Lark unix-seconds string (the format used by
|
||||
// wiki.spaces.get_node) into a UTC RFC3339 string. UTC (not the host's local
|
||||
// zone) keeps the output stable regardless of where the CLI runs. Returns ""
|
||||
// when the input is empty or not numeric so the pretty renderer falls back
|
||||
// to "-".
|
||||
func formatWikiTimestamp(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
secs, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return time.Unix(secs, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func renderWikiNodeGetPretty(w io.Writer, out map[string]interface{}) {
|
||||
fmt.Fprintln(w, "Wiki node:")
|
||||
fmt.Fprintf(w, " title: %s\n", valueOrDash(out["title"]))
|
||||
fmt.Fprintf(w, " obj_type: %s\n", valueOrDash(out["obj_type"]))
|
||||
fmt.Fprintf(w, " obj_token: %s\n", valueOrDash(out["obj_token"]))
|
||||
fmt.Fprintf(w, " node_token: %s\n", valueOrDash(out["node_token"]))
|
||||
fmt.Fprintf(w, " space_id: %s\n", valueOrDash(out["space_id"]))
|
||||
fmt.Fprintf(w, " parent_node_token: %s\n", valueOrDash(out["parent_node_token"]))
|
||||
fmt.Fprintf(w, " node_type: %s\n", valueOrDash(out["node_type"]))
|
||||
if origin, _ := out["origin_node_token"].(string); origin != "" {
|
||||
fmt.Fprintf(w, " origin_node_token: %s\n", origin)
|
||||
}
|
||||
hasChild, _ := out["has_child"].(bool)
|
||||
fmt.Fprintf(w, " has_child: %t\n", hasChild)
|
||||
fmt.Fprintf(w, " creator: %s\n", valueOrDash(out["creator"]))
|
||||
if owner, _ := out["owner"].(string); owner != "" {
|
||||
fmt.Fprintf(w, " owner: %s\n", owner)
|
||||
}
|
||||
fmt.Fprintf(w, " updated_at: %s\n", valueOrDash(out["updated_at"]))
|
||||
}
|
||||
321
shortcuts/wiki/wiki_node_get_test.go
Normal file
321
shortcuts/wiki/wiki_node_get_test.go
Normal file
@@ -0,0 +1,321 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestParseWikiNodeGetSpecRawNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeGetSpec("wikcnABC", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||
}
|
||||
if spec.Token != "wikcnABC" || spec.ObjType != "" || spec.SourceKind != "raw-node" {
|
||||
t.Fatalf("spec = %+v, want raw-node wikcnABC with no obj_type", spec)
|
||||
}
|
||||
if got := spec.RequestParams(); !reflect.DeepEqual(got, map[string]interface{}{"token": "wikcnABC"}) {
|
||||
t.Fatalf("RequestParams() = %v, want {token: wikcnABC}", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRawObjTokenWithExplicitObjType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeGetSpec("docxXYZ", "docx", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||
}
|
||||
if spec.Token != "docxXYZ" || spec.ObjType != "docx" || spec.SourceKind != "raw-obj" {
|
||||
t.Fatalf("spec = %+v, want raw-obj docxXYZ obj_type=docx", spec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRejectsRawObjTokenWithoutObjType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Mirrors +node-delete: a raw obj_token with no --obj-type must fail
|
||||
// upfront instead of defaulting to "doc" and hitting an opaque API error.
|
||||
_, err := parseWikiNodeGetSpec("bascnXYZ", "", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "--obj-type is required for a raw obj_token") {
|
||||
t.Fatalf("expected raw obj_token obj-type-required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRejectsObjTypeOnNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeGetSpec("wikcnABC", "docx", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "only valid for obj_tokens") {
|
||||
t.Fatalf("expected node_token + obj_type rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecExtractsTokenFromWikiURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeGetSpec("https://feishu.cn/wiki/wikcnABC?foo=bar", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||
}
|
||||
if spec.Token != "wikcnABC" || spec.ObjType != "" || spec.SourceKind != "url-wiki" {
|
||||
t.Fatalf("spec = %+v, want url-wiki wikcnABC", spec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecExtractsTokenAndObjTypeFromDocxURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeGetSpec("https://feishu.cn/docx/docxXYZ", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||
}
|
||||
if spec.Token != "docxXYZ" || spec.ObjType != "docx" || spec.SourceKind != "url-obj" {
|
||||
t.Fatalf("spec = %+v, want url-obj docxXYZ", spec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRejectsURLObjTypeMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeGetSpec("https://feishu.cn/sheets/shtXYZ", "docx", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match the obj_type") {
|
||||
t.Fatalf("expected URL/obj-type mismatch error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRejectsUnsupportedURLPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeGetSpec("https://feishu.cn/im/chat/oc_123", "", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported --token URL path") {
|
||||
t.Fatalf("expected unsupported URL path error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRejectsPartialPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeGetSpec("/wiki/wikcnABC", "", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "partial paths are not accepted") {
|
||||
t.Fatalf("expected partial-path rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRejectsEmptyToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if _, err := parseWikiNodeGetSpec(" ", "", ""); err == nil || !strings.Contains(err.Error(), "--token is required") {
|
||||
t.Fatalf("expected required-token error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWikiNodeGetDryRunSendsObjType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec, err := parseWikiNodeGetSpec("https://feishu.cn/docx/docxXYZ", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||
}
|
||||
|
||||
dry := buildWikiNodeGetDryRun(spec)
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 || got.API[0].URL != "/open-apis/wiki/v2/spaces/get_node" {
|
||||
t.Fatalf("dry-run api = %#v, want single get_node call", got.API)
|
||||
}
|
||||
if got.API[0].Params["token"] != "docxXYZ" || got.API[0].Params["obj_type"] != "docx" {
|
||||
t.Fatalf("dry-run params = %#v", got.API[0].Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatWikiTimestamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := formatWikiTimestamp(""); got != "" {
|
||||
t.Fatalf("formatWikiTimestamp(empty) = %q, want empty", got)
|
||||
}
|
||||
if got := formatWikiTimestamp("not-a-number"); got != "" {
|
||||
t.Fatalf("formatWikiTimestamp(non-numeric) = %q, want empty", got)
|
||||
}
|
||||
// Output is UTC, so it is deterministic regardless of host timezone.
|
||||
if got := formatWikiTimestamp("1700000000"); got != "2023-11-14T22:13:20Z" {
|
||||
t.Fatalf("formatWikiTimestamp(1700000000) = %q, want 2023-11-14T22:13:20Z (UTC)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeGetMountedExecuteParsesURLAndFormatsOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, stderr, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_123",
|
||||
"node_token": "wikcnABC",
|
||||
"obj_token": "docxXYZ",
|
||||
"obj_type": "docx",
|
||||
"parent_node_token": "wikcnPARENT",
|
||||
"node_type": "origin",
|
||||
"title": "Design Spec",
|
||||
"has_child": true,
|
||||
"node_creator": "ou_creator",
|
||||
"owner": "ou_owner",
|
||||
"obj_edit_time": "1700000000",
|
||||
"obj_create_time": "1690000000",
|
||||
"node_create_time": "1690000001",
|
||||
},
|
||||
},
|
||||
"msg": "success",
|
||||
},
|
||||
}
|
||||
var capturedQuery string
|
||||
stub.OnMatch = func(req *http.Request) {
|
||||
capturedQuery = req.URL.RawQuery
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeGet, []string{
|
||||
"+node-get",
|
||||
"--token", "https://feishu.cn/docx/docxXYZ",
|
||||
"--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(capturedQuery, "token=docxXYZ") || !strings.Contains(capturedQuery, "obj_type=docx") {
|
||||
t.Fatalf("captured query = %q, want token=docxXYZ and obj_type=docx", capturedQuery)
|
||||
}
|
||||
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
if data["title"] != "Design Spec" {
|
||||
t.Fatalf("title = %#v, want Design Spec", data["title"])
|
||||
}
|
||||
if data["obj_type"] != "docx" || data["obj_token"] != "docxXYZ" {
|
||||
t.Fatalf("obj_type/obj_token = %#v / %#v", data["obj_type"], data["obj_token"])
|
||||
}
|
||||
if data["parent_node_token"] != "wikcnPARENT" {
|
||||
t.Fatalf("parent_node_token = %#v", data["parent_node_token"])
|
||||
}
|
||||
if data["creator"] != "ou_creator" {
|
||||
t.Fatalf("creator = %#v, want ou_creator", data["creator"])
|
||||
}
|
||||
if data["owner"] != "ou_owner" {
|
||||
t.Fatalf("owner = %#v, want ou_owner", data["owner"])
|
||||
}
|
||||
if got, _ := data["updated_at"].(string); got != "2023-11-14T22:13:20Z" {
|
||||
t.Fatalf("updated_at = %#v, want 2023-11-14T22:13:20Z (UTC)", data["updated_at"])
|
||||
}
|
||||
// +node-get deliberately does not synthesize a url (get_node returns none;
|
||||
// a BuildResourceURL fallback would be a non-canonical, misleading link in
|
||||
// a read/confirm command).
|
||||
if _, ok := data["url"]; ok {
|
||||
t.Fatalf("did not expect a url field in +node-get output, got %#v", data["url"])
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "Fetching wiki node") {
|
||||
t.Fatalf("stderr = %q, want fetching message", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeGetFallsBackToCreatorWhenNodeCreatorMissing(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_123",
|
||||
"node_token": "wikcnABC",
|
||||
"obj_token": "docxXYZ",
|
||||
"obj_type": "docx",
|
||||
"node_type": "origin",
|
||||
"title": "Fallback Creator",
|
||||
"creator": "ou_legacy_creator",
|
||||
},
|
||||
},
|
||||
"msg": "success",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeGet, []string{
|
||||
"+node-get",
|
||||
"--token", "wikcnABC",
|
||||
"--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
if data["creator"] != "ou_legacy_creator" {
|
||||
t.Fatalf("creator = %#v, want fallback to creator field", data["creator"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeGetRejectsSpaceIDMismatch(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"space_id": "space_actual",
|
||||
"node_token": "wikcnABC",
|
||||
"obj_token": "docxXYZ",
|
||||
"obj_type": "docx",
|
||||
"node_type": "origin",
|
||||
"title": "Mismatch",
|
||||
},
|
||||
},
|
||||
"msg": "success",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeGet, []string{
|
||||
"+node-get",
|
||||
"--token", "wikcnABC",
|
||||
"--space-id", "space_expected",
|
||||
"--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match the resolved node space") {
|
||||
t.Fatalf("expected space mismatch error, got %v", err)
|
||||
}
|
||||
}
|
||||
120
shortcuts/wiki/wiki_space_create.go
Normal file
120
shortcuts/wiki/wiki_space_create.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// WikiSpaceCreate wraps wiki.spaces.create. The raw API only takes two
|
||||
// optional string fields, so the shortcut's value is flag ergonomics
|
||||
// (no hand-written --params JSON), output flattening (data.space.* lifted
|
||||
// to the top level), and a dry-run preview.
|
||||
//
|
||||
// The API only accepts a user access token (no tenant/bot), so AuthTypes is
|
||||
// user-only — the framework's CheckIdentity rejects --as bot for us.
|
||||
var WikiSpaceCreate = common.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+space-create",
|
||||
Description: "Create a wiki space",
|
||||
Risk: "write",
|
||||
// The API accepts wiki:wiki or wiki:space:write_only. The framework's
|
||||
// scope preflight does exact-string matching (see +space-list), so
|
||||
// declare the narrowest form the API takes to avoid false-rejecting
|
||||
// tokens that only carry wiki:space:write_only.
|
||||
Scopes: []string{"wiki:space:write_only"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "wiki space name", Required: true},
|
||||
{Name: "description", Desc: "wiki space description"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Only --as user is supported; the create API does not accept a tenant/bot token.",
|
||||
"The underlying spaces.create API is flagged danger in the schema browser; a space is recoverable via `wiki +delete-space` if created by mistake.",
|
||||
"--name is required: an unnamed space is almost always an accident and is hard to find later.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readWikiSpaceCreateSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readWikiSpaceCreateSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST(wikiSpacesAPIPath).
|
||||
Body(spec.RequestBody())
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readWikiSpaceCreateSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating wiki space %q...\n", spec.Name)
|
||||
|
||||
data, err := runtime.CallAPI("POST", wikiSpacesAPIPath, nil, spec.RequestBody())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
raw := common.GetMap(data, "space")
|
||||
if raw == nil {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "wiki space create returned no space")
|
||||
}
|
||||
|
||||
out := wikiSpaceCreateOutput(raw)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created wiki space %s\n", common.MaskToken(common.GetString(out, "space_id")))
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// wikiSpaceCreateSpec is the normalized CLI input.
|
||||
type wikiSpaceCreateSpec struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
// RequestBody converts the normalized input into the OpenAPI payload. Both
|
||||
// fields are optional per the API, but Validate enforces a non-empty name,
|
||||
// so name is always present here.
|
||||
func (spec wikiSpaceCreateSpec) RequestBody() map[string]interface{} {
|
||||
body := map[string]interface{}{"name": spec.Name}
|
||||
if spec.Description != "" {
|
||||
body["description"] = spec.Description
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func readWikiSpaceCreateSpec(runtime *common.RuntimeContext) (wikiSpaceCreateSpec, error) {
|
||||
spec := wikiSpaceCreateSpec{
|
||||
Name: strings.TrimSpace(runtime.Str("name")),
|
||||
Description: strings.TrimSpace(runtime.Str("description")),
|
||||
}
|
||||
if spec.Name == "" {
|
||||
return wikiSpaceCreateSpec{}, output.ErrValidation("--name is required and cannot be blank")
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// wikiSpaceCreateOutput flattens data.space into the top-level envelope. It
|
||||
// reads the raw map (rather than parseWikiSpaceRecord) so the description
|
||||
// the caller just set round-trips back in the output.
|
||||
func wikiSpaceCreateOutput(raw map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"space_id": common.GetString(raw, "space_id"),
|
||||
"name": common.GetString(raw, "name"),
|
||||
"description": common.GetString(raw, "description"),
|
||||
"space_type": common.GetString(raw, "space_type"),
|
||||
"visibility": common.GetString(raw, "visibility"),
|
||||
"open_sharing": common.GetString(raw, "open_sharing"),
|
||||
}
|
||||
}
|
||||
207
shortcuts/wiki/wiki_space_create_test.go
Normal file
207
shortcuts/wiki/wiki_space_create_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestWikiSpaceCreateDeclaredContract(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if WikiSpaceCreate.Command != "+space-create" {
|
||||
t.Fatalf("Command = %q, want +space-create", WikiSpaceCreate.Command)
|
||||
}
|
||||
if WikiSpaceCreate.Risk != "write" {
|
||||
t.Fatalf("Risk = %q, want write", WikiSpaceCreate.Risk)
|
||||
}
|
||||
if !reflect.DeepEqual(WikiSpaceCreate.AuthTypes, []string{"user"}) {
|
||||
t.Fatalf("AuthTypes = %v, want [user]", WikiSpaceCreate.AuthTypes)
|
||||
}
|
||||
if !reflect.DeepEqual(WikiSpaceCreate.Scopes, []string{"wiki:space:write_only"}) {
|
||||
t.Fatalf("Scopes = %v, want [wiki:space:write_only]", WikiSpaceCreate.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadWikiSpaceCreateSpecRejectsBlankName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "wiki +space-create"}
|
||||
cmd.Flags().String("name", " ", "")
|
||||
cmd.Flags().String("description", "", "")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
if _, err := readWikiSpaceCreateSpec(runtime); err == nil || !strings.Contains(err.Error(), "--name is required") {
|
||||
t.Fatalf("expected blank-name rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiSpaceCreateRequestBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
nameOnly := wikiSpaceCreateSpec{Name: "Eng Wiki"}.RequestBody()
|
||||
if !reflect.DeepEqual(nameOnly, map[string]interface{}{"name": "Eng Wiki"}) {
|
||||
t.Fatalf("name-only body = %#v", nameOnly)
|
||||
}
|
||||
|
||||
full := wikiSpaceCreateSpec{Name: "Eng Wiki", Description: "team docs"}.RequestBody()
|
||||
if full["name"] != "Eng Wiki" || full["description"] != "team docs" {
|
||||
t.Fatalf("full body = %#v", full)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiSpaceCreateDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "wiki +space-create"}
|
||||
cmd.Flags().String("name", "Eng Wiki", "")
|
||||
cmd.Flags().String("description", "team docs", "")
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
|
||||
dry := WikiSpaceCreate.DryRun(nil, runtime)
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 || got.API[0].Method != "POST" || got.API[0].URL != "/open-apis/wiki/v2/spaces" {
|
||||
t.Fatalf("dry-run api = %#v", got.API)
|
||||
}
|
||||
if got.API[0].Body["name"] != "Eng Wiki" || got.API[0].Body["description"] != "team docs" {
|
||||
t.Fatalf("dry-run body = %#v", got.API[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiSpaceCreateDryRunBlankNameSurfacesError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "wiki +space-create"}
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("description", "", "")
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
|
||||
dry := WikiSpaceCreate.DryRun(nil, runtime)
|
||||
data, _ := json.Marshal(dry)
|
||||
if !strings.Contains(string(data), "--name is required") {
|
||||
t.Fatalf("dry-run should surface validation error, got %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiSpaceCreateMountedExecuteFlattensSpace(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, stderr, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/wiki/v2/spaces",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"space": map[string]interface{}{
|
||||
"space_id": "7160145948494381236",
|
||||
"name": "Eng Wiki",
|
||||
"description": "team docs",
|
||||
"space_type": "team",
|
||||
"visibility": "private",
|
||||
"open_sharing": "closed",
|
||||
},
|
||||
},
|
||||
"msg": "success",
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
|
||||
err := mountAndRunWiki(t, WikiSpaceCreate, []string{
|
||||
"+space-create",
|
||||
"--name", "Eng Wiki",
|
||||
"--description", "team docs",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeWikiEnvelope(t, stdout)
|
||||
if data["space_id"] != "7160145948494381236" {
|
||||
t.Fatalf("space_id = %#v", data["space_id"])
|
||||
}
|
||||
if data["name"] != "Eng Wiki" || data["description"] != "team docs" {
|
||||
t.Fatalf("name/description = %#v / %#v", data["name"], data["description"])
|
||||
}
|
||||
if data["space_type"] != "team" || data["visibility"] != "private" || data["open_sharing"] != "closed" {
|
||||
t.Fatalf("space_type/visibility/open_sharing = %#v", data)
|
||||
}
|
||||
if _, ok := data["url"]; ok {
|
||||
t.Fatalf("output must not include a url field, got %#v", data["url"])
|
||||
}
|
||||
|
||||
var captured map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &captured); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
if captured["name"] != "Eng Wiki" || captured["description"] != "team docs" {
|
||||
t.Fatalf("captured request body = %#v", captured)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Created wiki space") {
|
||||
t.Fatalf("stderr = %q, want creation log", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiSpaceCreateRejectsBotIdentity(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
|
||||
err := mountAndRunWiki(t, WikiSpaceCreate, []string{
|
||||
"+space-create",
|
||||
"--name", "Eng Wiki",
|
||||
"--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "only supports: user") {
|
||||
t.Fatalf("expected bot identity rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiSpaceCreateErrorsWhenNoSpaceReturned(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/wiki/v2/spaces",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
"msg": "success",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiSpaceCreate, []string{
|
||||
"+space-create",
|
||||
"--name", "Eng Wiki",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "returned no space") {
|
||||
t.Fatalf("expected missing-space error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -62,8 +62,11 @@ Shortcut 是对常用操作的高级封装(`lark-cli wiki +<verb> [flags]`)
|
||||
| [`+node-create`](references/lark-wiki-node-create.md) | Create a wiki node with automatic space resolution |
|
||||
| [`+delete-space`](references/lark-wiki-delete-space.md) | Delete a wiki space, polling the async delete task when needed |
|
||||
| [`+space-list`](references/lark-wiki-space-list.md) | List all wiki spaces accessible to the caller |
|
||||
| [`+space-create`](references/lark-wiki-space-create.md) | Create a wiki space (user identity only) |
|
||||
| [`+node-list`](references/lark-wiki-node-list.md) | List wiki nodes in a space or under a parent node (supports pagination) |
|
||||
| [`+node-copy`](references/lark-wiki-node-copy.md) | Copy a wiki node to a target space or parent node |
|
||||
| [`+node-get`](references/lark-wiki-node-get.md) | Get a wiki node's details by node_token / obj_token / Lark URL |
|
||||
| [`+node-delete`](references/lark-wiki-node-delete.md) | Delete a wiki node, polling the async delete task when needed |
|
||||
|
||||
## API Resources
|
||||
|
||||
|
||||
62
skills/lark-wiki/references/lark-wiki-node-delete.md
Normal file
62
skills/lark-wiki/references/lark-wiki-node-delete.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# lark-wiki +node-delete
|
||||
|
||||
Delete a wiki node (or pull a cloud doc out of Wiki). OpenAPI: `DELETE /open-apis/wiki/v2/spaces/:space_id/nodes/:node_token`.
|
||||
|
||||
> ⚠️ **High-risk write & irreversible** — deletes the node and (by default) its whole subtree. Requires explicit `--yes`; without it the CLI returns a `confirmation_required` error and nothing is deleted.
|
||||
|
||||
- **Sync / async**: an empty `task_id` means the delete completed synchronously (`ready=true`). A non-empty `task_id` triggers bounded polling; if the window elapses the output carries `timed_out=true` and a `next_command`:
|
||||
`lark-cli drive +task_result --scenario wiki_delete_node --task-id <TASK_ID> --as <user|bot>`
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
lark-cli wiki +node-delete \
|
||||
--node-token <node_token | obj_token | Lark URL> \
|
||||
[--obj-type <wiki|doc|docx|sheet|bitable|mindnote|slides|file>] \
|
||||
[--space-id <space_id>] \
|
||||
[--include-children=true|false] \
|
||||
--yes \
|
||||
[--as user|bot]
|
||||
|
||||
# Preview the call chain without deleting
|
||||
lark-cli wiki +node-delete --node-token <token> --obj-type wiki --dry-run
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Type | Required | Default | Description |
|
||||
|------|------|----------|---------|-------------|
|
||||
| `--node-token` | string | **Yes** | — | `node_token`, cloud-doc `obj_token`, or a Lark URL embedding one; URL paths also imply `--obj-type` |
|
||||
| `--obj-type` | enum | Conditional | — | Required for a raw token (URL inputs auto-infer). `wiki` = the token is a `node_token`; otherwise the cloud-doc type |
|
||||
| `--space-id` | string | No | — | Auto-resolved via `get_node` when omitted (extra lookup; pass it to skip) |
|
||||
| `--include-children` | bool | No | `true` | Cascade-delete the subtree (default). `--include-children=false` lifts direct children up to the parent |
|
||||
| `--yes` | bool | Yes (real delete) | — | Confirm the high-risk operation. Without it the CLI returns `confirmation_required` |
|
||||
| `--as` | enum | No | `auto` | Identity `user`/`bot`; wiki is user-centric → pass `--as user` |
|
||||
|
||||
## Output
|
||||
|
||||
```json
|
||||
{
|
||||
"space_id": "7160145948494381236",
|
||||
"node_token": "wikcnEXAMPLE",
|
||||
"obj_type": "wiki",
|
||||
"include_children": true,
|
||||
"ready": true,
|
||||
"failed": false,
|
||||
"status": "success",
|
||||
"status_msg": "success"
|
||||
}
|
||||
```
|
||||
|
||||
Async/timeout adds `task_id`, `timed_out`, and `next_command`.
|
||||
|
||||
## Behavior
|
||||
|
||||
- **Task poll**: `GET /open-apis/wiki/v2/tasks/{task_id}?task_type=delete_node`. The status lives under `data.task.simple_task_result.status` (the gateway's generic key — **not** `delete_node_result`); that object has no `status_msg`, so the label falls back to the status code.
|
||||
- **Error hints**:
|
||||
- `131011` → the node has delete-approval enabled; apply via the Wiki UI (CLI cannot bypass approval).
|
||||
- `131003` → subtree too large to cascade-delete; use `--include-children=false` or delete sub-trees first.
|
||||
|
||||
## Required Scope
|
||||
|
||||
`wiki:node:create` (the delete endpoint declares this scope). Auto-resolving `space_id` additionally needs `wiki:node:retrieve`; pass `--space-id` to avoid that lookup.
|
||||
56
skills/lark-wiki/references/lark-wiki-node-get.md
Normal file
56
skills/lark-wiki/references/lark-wiki-node-get.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# lark-wiki +node-get
|
||||
|
||||
Get a wiki node's details by `node_token`, `obj_token`, or a Lark URL. Use this as the "what am I about to touch?" step before `+move` / `+node-copy` / `+node-delete`.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
lark-cli wiki +node-get \
|
||||
--token <node_token | obj_token | Lark URL> \
|
||||
[--obj-type <doc|docx|sheet|bitable|mindnote|slides|file>] \
|
||||
[--space-id <space_id>] \
|
||||
[--format json|pretty|table|csv|ndjson] \
|
||||
[--as user|bot]
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Type | Required | Default | Description |
|
||||
|------|------|----------|---------|-------------|
|
||||
| `--token` | string | **Yes** | — | `node_token`, cloud-doc `obj_token`, or a Lark URL embedding one (e.g. `https://feishu.cn/wiki/<token>` or `https://feishu.cn/docx/<token>`) |
|
||||
| `--obj-type` | enum | No | — | Needed when `--token` is a raw `obj_token`; auto-inferred from the URL path. Not allowed when the token looks like a `node_token` (`wik...`) |
|
||||
| `--space-id` | string | No | — | Optional cross-check: fail if the resolved node does not live in this space |
|
||||
| `--format` | enum | No | `json` | `json` / `pretty` / `table` / `csv` / `ndjson` |
|
||||
| `--as` | enum | No | `auto` | Identity `user`/`bot`; wiki is user-centric → pass `--as user` |
|
||||
|
||||
## Output
|
||||
|
||||
```json
|
||||
{
|
||||
"space_id": "7160145948494381236",
|
||||
"node_token": "wikcnEXAMPLE",
|
||||
"obj_token": "docxEXAMPLE",
|
||||
"obj_type": "docx",
|
||||
"node_type": "origin",
|
||||
"parent_node_token": "wikcnPARENT",
|
||||
"origin_node_token": "",
|
||||
"title": "Design Spec",
|
||||
"has_child": true,
|
||||
"creator": "ou_xxx",
|
||||
"owner": "ou_yyy",
|
||||
"obj_edit_time": "1700000000",
|
||||
"obj_create_time": "1690000000",
|
||||
"node_create_time": "1690000001",
|
||||
"updated_at": "2023-11-14T22:13:20Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The underlying API is `GET /open-apis/wiki/v2/spaces/get_node`. For a `node_token` no `obj_type` is sent; for an `obj_token` the `obj_type` (explicit or URL-inferred) is required.
|
||||
- `creator` falls back to `creator` when `node_creator` is absent. `updated_at` is `obj_edit_time` formatted as RFC3339.
|
||||
- No `url` is returned: `get_node` does not provide one and a synthesized `www.feishu.cn/wiki/<node_token>` link is non-canonical/misleading for a read command. Use `node_token` / `obj_token` as the identifiers.
|
||||
|
||||
## Required Scope
|
||||
|
||||
`wiki:node:retrieve`
|
||||
46
skills/lark-wiki/references/lark-wiki-space-create.md
Normal file
46
skills/lark-wiki/references/lark-wiki-space-create.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# lark-wiki +space-create
|
||||
|
||||
Create a wiki space. OpenAPI: `POST /open-apis/wiki/v2/spaces`. This is the project-initialization entry point — the alternative is hand-writing `wiki spaces create --params '{...}'`.
|
||||
|
||||
> The underlying `spaces.create` API is flagged `danger: true` in the schema browser, but it is **not** confirmation-gated (no `--yes`). A space created by mistake is recoverable via `wiki +delete-space`.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
lark-cli wiki +space-create \
|
||||
--name <space_name> \
|
||||
[--description <text>] \
|
||||
[--as user]
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Type | Required | Default | Description |
|
||||
|------|------|----------|---------|-------------|
|
||||
| `--name` | string | **Yes** | — | Wiki space name. Blank/whitespace is rejected (an unnamed space is almost always an accident) |
|
||||
| `--description` | string | No | — | Wiki space description |
|
||||
| `--as` | enum | No | `user` | **User identity only** — the create API does not accept a tenant/bot token; `--as bot` is rejected upfront |
|
||||
|
||||
## Output
|
||||
|
||||
```json
|
||||
{
|
||||
"space_id": "7160145948494381236",
|
||||
"name": "Engineering Wiki",
|
||||
"description": "team docs",
|
||||
"space_type": "team",
|
||||
"visibility": "private",
|
||||
"open_sharing": "closed"
|
||||
}
|
||||
```
|
||||
|
||||
There is no `url` field — the create API does not return one.
|
||||
|
||||
## Notes
|
||||
|
||||
- Only `--as user` is supported; this command declares `AuthTypes: ["user"]` and the framework rejects `--as bot` with a clear message.
|
||||
- `--dry-run` previews the `POST /open-apis/wiki/v2/spaces` request (and surfaces the blank-name validation error early).
|
||||
|
||||
## Required Scope
|
||||
|
||||
`wiki:space:write_only`
|
||||
Reference in New Issue
Block a user