Files
larksuite-cli/shortcuts/wiki/wiki_delete.go
liujinkun2025 c4fb7006d2 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
2026-05-19 11:21:54 +08:00

218 lines
7.4 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"fmt"
"strings"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var (
wikiDeleteSpacePollAttempts = 30
wikiDeleteSpacePollInterval = 2 * time.Second
)
// Back-compat aliases — the shared async-task helper now owns the strings,
// but tests still reference these names.
const (
wikiDeleteSpaceStatusSuccess = wikiAsyncStatusSuccess
wikiDeleteSpaceStatusFailure = wikiAsyncStatusFailure
wikiDeleteSpaceStatusProcessing = wikiAsyncStatusProcessing
)
// WikiDeleteSpace deletes a wiki space. The DELETE endpoint may complete
// synchronously (empty task_id) or return a task_id that must be polled
// against /open-apis/wiki/v2/tasks/:task_id with task_type=delete_space.
var WikiDeleteSpace = common.Shortcut{
Service: "wiki",
Command: "+delete-space",
Description: "Delete a wiki space, polling the async delete task when needed",
Risk: "high-risk-write",
Scopes: []string{"wiki:space:write_only", "wiki:space:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "space-id", Desc: "wiki space ID to delete", Required: true},
},
Tips: []string{
"Deletion is irreversible; double-check --space-id before running.",
"This is a high-risk-write command; pass --yes to confirm the deletion.",
"If the API returns a long-running task, this command polls for a bounded window and then prints a follow-up drive +task_result command.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateWikiDeleteSpaceSpec(readWikiDeleteSpaceSpec(runtime))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return buildWikiDeleteSpaceDryRun(readWikiDeleteSpaceSpec(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := readWikiDeleteSpaceSpec(runtime)
fmt.Fprintf(runtime.IO().ErrOut, "Deleting wiki space %s...\n", spec.SpaceID)
out, err := runWikiDeleteSpace(ctx, wikiDeleteSpaceAPI{runtime: runtime}, runtime, spec)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
type wikiDeleteSpaceSpec struct {
SpaceID string
}
type wikiDeleteSpaceResponse struct {
TaskID string
}
// 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)
GetDeleteSpaceTask(ctx context.Context, taskID string) (wikiDeleteSpaceTaskStatus, error)
}
type wikiDeleteSpaceAPI struct {
runtime *common.RuntimeContext
}
func (api wikiDeleteSpaceAPI) DeleteSpace(ctx context.Context, spaceID string) (*wikiDeleteSpaceResponse, error) {
data, err := api.runtime.CallAPI(
"DELETE",
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(spaceID)),
nil,
nil,
)
if err != nil {
return nil, err
}
return &wikiDeleteSpaceResponse{
TaskID: common.GetString(data, "task_id"),
}, nil
}
func (api wikiDeleteSpaceAPI) GetDeleteSpaceTask(ctx context.Context, taskID string) (wikiDeleteSpaceTaskStatus, error) {
data, err := api.runtime.CallAPI(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
map[string]interface{}{"task_type": "delete_space"},
nil,
)
if err != nil {
return wikiDeleteSpaceTaskStatus{}, err
}
return parseWikiAsyncTaskStatus(taskID, common.GetMap(data, "task"), wikiAsyncResultDeleteSpace)
}
func readWikiDeleteSpaceSpec(runtime *common.RuntimeContext) wikiDeleteSpaceSpec {
return wikiDeleteSpaceSpec{
SpaceID: strings.TrimSpace(runtime.Str("space-id")),
}
}
func validateWikiDeleteSpaceSpec(spec wikiDeleteSpaceSpec) error {
if spec.SpaceID == "" {
return output.ErrValidation("--space-id is required")
}
return validateOptionalResourceName(spec.SpaceID, "--space-id")
}
func buildWikiDeleteSpaceDryRun(spec wikiDeleteSpaceSpec) *common.DryRunAPI {
dry := common.NewDryRunAPI()
dry.Desc("2-step orchestration: delete wiki space -> poll wiki delete task when task_id is returned")
dry.DELETE(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", dryRunWikiDeleteSpaceID(spec)))
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
Desc("[2] Poll wiki delete-space task result when async").
Set("task_id", "<task_id>").
Params(map[string]interface{}{"task_type": "delete_space"})
return dry
}
func dryRunWikiDeleteSpaceID(spec wikiDeleteSpaceSpec) string {
if spec.SpaceID != "" {
return validate.EncodePathSegment(spec.SpaceID)
}
return "<space_id>"
}
func runWikiDeleteSpace(ctx context.Context, client wikiDeleteSpaceClient, runtime *common.RuntimeContext, spec wikiDeleteSpaceSpec) (map[string]interface{}, error) {
response, err := client.DeleteSpace(ctx, spec.SpaceID)
if err != nil {
return nil, err
}
out := map[string]interface{}{
"space_id": spec.SpaceID,
}
// Empty task_id means the delete completed synchronously. A non-empty
// task_id means the backend queued an async deletion; poll until it
// resolves or the bounded window elapses.
if response.TaskID == "" {
// Sync and async success envelopes keep the same shape so downstream
// scripts can read `status` uniformly regardless of which branch fired.
out["ready"] = true
out["failed"] = false
out["status"] = wikiDeleteSpaceStatusSuccess
out["status_msg"] = wikiDeleteSpaceStatusSuccess
return out, nil
}
fmt.Fprintf(runtime.IO().ErrOut, "Wiki space delete is async, polling task %s...\n", response.TaskID)
status, ready, err := pollWikiDeleteSpaceTask(ctx, client, runtime, response.TaskID)
if err != nil {
return nil, err
}
out["task_id"] = response.TaskID
out["ready"] = ready
out["failed"] = status.Failed()
out["status"] = status.StatusCode()
out["status_msg"] = status.StatusLabel()
if !ready {
nextCommand := wikiDeleteSpaceTaskResultCommand(response.TaskID, runtime.As())
fmt.Fprintf(runtime.IO().ErrOut, "Wiki delete-space task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
return out, nil
}
func wikiDeleteSpaceTaskResultCommand(taskID string, identity core.Identity) string {
asFlag := string(identity)
if asFlag == "" {
asFlag = "user"
}
return fmt.Sprintf("lark-cli drive +task_result --scenario wiki_delete_space --task-id %s --as %s", taskID, asFlag)
}
func pollWikiDeleteSpaceTask(ctx context.Context, client wikiDeleteSpaceClient, runtime *common.RuntimeContext, taskID string) (wikiDeleteSpaceTaskStatus, bool, error) {
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) {
return parseWikiAsyncTaskStatus(taskID, task, wikiAsyncResultDeleteSpace)
}