// Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT package wiki import ( "context" "errors" "fmt" "io" "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" ) const ( wikiNodeTypeOrigin = "origin" wikiNodeTypeShortcut = "shortcut" wikiMyLibrarySpaceID = "my_library" wikiResolvedByExplicitSpaceID = "explicit_space_id" wikiResolvedByParentNode = "parent_node_token" wikiResolvedByMyLibrary = "my_library" ) const ( // wikiNodeCreateMaxRetries is the maximum number of retry attempts after // the initial request when the API returns lock contention (code 131009). wikiNodeCreateMaxRetries = 2 // wikiNodeCreateRetryBaseDelay is the initial backoff delay for lock // contention retries. Subsequent retries double the delay (250ms, 500ms). wikiNodeCreateRetryBaseDelay = 250 * time.Millisecond ) var wikiObjectTypes = []string{ "sheet", "mindnote", "bitable", "docx", "slides", } // WikiNodeCreate wraps wiki node creation with shortcut-specific ergonomics: // it can infer the target space from the parent node or the caller's personal // document library instead of forcing users to pass a numeric space ID first. var WikiNodeCreate = common.Shortcut{ Service: "wiki", Command: "+node-create", Description: "Create a wiki node with automatic space resolution", Risk: "write", Scopes: []string{"wiki:node:create", "wiki:node:read", "wiki:space:read"}, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "space-id", Desc: "target wiki space ID; use my_library for the personal document library"}, {Name: "parent-node-token", Desc: "parent wiki node token; if set, the new node is created under that parent"}, {Name: "title", Desc: "node title"}, {Name: "node-type", Default: wikiNodeTypeOrigin, Desc: "node type", Enum: []string{wikiNodeTypeOrigin, wikiNodeTypeShortcut}}, {Name: "obj-type", Default: "docx", Desc: "target object type", Enum: wikiObjectTypes}, {Name: "origin-node-token", Desc: "source node token when --node-type=shortcut"}, }, Tips: []string{ "If --space-id and --parent-node-token are both omitted, user identity falls back to my_library.", "Use --node-type shortcut --origin-node-token to create a shortcut node.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateWikiNodeCreateSpec(readWikiNodeCreateSpec(runtime), runtime.As()) }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { dry := buildWikiNodeCreateDryRun(readWikiNodeCreateSpec(runtime)) if runtime.IsBot() { dry.Desc("After wiki node creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new wiki node.") } return dry }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { spec := readWikiNodeCreateSpec(runtime) fmt.Fprintf(runtime.IO().ErrOut, "Creating wiki node...\n") execution, err := runWikiNodeCreate(ctx, wikiNodeCreateAPI{runtime: runtime}, runtime.As(), spec, runtime.IO().ErrOut) if err != nil { return err } fmt.Fprintf(runtime.IO().ErrOut, "Created wiki node in space %s via %s.\n", execution.ResolvedSpace.SpaceID, execution.ResolvedSpace.ResolvedBy) runtime.Out(augmentWikiNodeCreateOutput(runtime, execution), nil) return nil }, } // wikiNodeCreateSpec is the normalized CLI input for the shortcut. type wikiNodeCreateSpec struct { SpaceID string ParentNodeToken string Title string NodeType string ObjType string OriginNodeToken string } // RequestBody converts the normalized shortcut input into the OpenAPI payload. func (spec wikiNodeCreateSpec) RequestBody() map[string]interface{} { body := map[string]interface{}{ "node_type": spec.NodeType, "obj_type": spec.ObjType, } if spec.Title != "" { body["title"] = spec.Title } if spec.ParentNodeToken != "" { body["parent_node_token"] = spec.ParentNodeToken } if spec.OriginNodeToken != "" { body["origin_node_token"] = spec.OriginNodeToken } return body } // wikiNodeRecord contains the response fields used by the shortcut. type wikiNodeRecord struct { SpaceID string NodeToken string ObjToken string ObjType string ParentNodeToken string NodeType string OriginNodeToken string Title string HasChild bool URL string } // wikiSpaceRecord contains the response fields used when resolving spaces. type wikiSpaceRecord struct { SpaceID string Name string SpaceType string Visibility string OpenSharing string } // wikiResolvedSpace captures both the final numeric space ID and how it was // derived. Keeping the provenance separate makes the command output easier to // understand and keeps the resolution logic testable. type wikiResolvedSpace struct { SpaceID string ResolvedBy string ParentNode *wikiNodeRecord } type wikiNodeCreateExecution struct { Node *wikiNodeRecord ResolvedSpace wikiResolvedSpace } // wikiNodeCreateClient isolates the network operations so the resolution logic // can be unit-tested without real HTTP calls. type wikiNodeCreateClient interface { GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) GetSpace(ctx context.Context, spaceID string) (*wikiSpaceRecord, error) CreateNode(ctx context.Context, spaceID string, spec wikiNodeCreateSpec) (*wikiNodeRecord, error) } type wikiNodeCreateAPI struct { runtime *common.RuntimeContext } func (api wikiNodeCreateAPI) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) { data, err := api.runtime.CallAPI( "GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": token}, nil, ) if err != nil { return nil, err } return parseWikiNodeRecord(common.GetMap(data, "node")) } func (api wikiNodeCreateAPI) GetSpace(ctx context.Context, spaceID string) (*wikiSpaceRecord, error) { data, err := api.runtime.CallAPI( "GET", fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(spaceID)), nil, nil, ) if err != nil { return nil, err } return parseWikiSpaceRecord(common.GetMap(data, "space")) } func (api wikiNodeCreateAPI) CreateNode(ctx context.Context, spaceID string, spec wikiNodeCreateSpec) (*wikiNodeRecord, error) { data, err := api.runtime.CallAPI( "POST", fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID)), nil, spec.RequestBody(), ) if err != nil { return nil, err } return parseWikiNodeRecord(common.GetMap(data, "node")) } func readWikiNodeCreateSpec(runtime *common.RuntimeContext) wikiNodeCreateSpec { return wikiNodeCreateSpec{ SpaceID: strings.TrimSpace(runtime.Str("space-id")), ParentNodeToken: strings.TrimSpace(runtime.Str("parent-node-token")), Title: strings.TrimSpace(runtime.Str("title")), NodeType: strings.ToLower(strings.TrimSpace(runtime.Str("node-type"))), ObjType: strings.ToLower(strings.TrimSpace(runtime.Str("obj-type"))), OriginNodeToken: strings.TrimSpace(runtime.Str("origin-node-token")), } } func validateWikiNodeCreateSpec(spec wikiNodeCreateSpec, identity core.Identity) error { if err := validateOptionalResourceName(spec.SpaceID, "--space-id"); err != nil { return err } if err := validateOptionalResourceName(spec.ParentNodeToken, "--parent-node-token"); err != nil { return err } if err := validateOptionalResourceName(spec.OriginNodeToken, "--origin-node-token"); err != nil { return err } if spec.NodeType == wikiNodeTypeShortcut && spec.OriginNodeToken == "" { return output.ErrValidation("--origin-node-token is required when --node-type=shortcut") } if spec.NodeType != wikiNodeTypeShortcut && spec.OriginNodeToken != "" { return output.ErrValidation("--origin-node-token can only be used when --node-type=shortcut") } // Bot identity has no meaningful "personal document library" target, so // my_library must be rejected explicitly instead of deferring to API-time // resolution errors. if identity.IsBot() && spec.SpaceID == wikiMyLibrarySpaceID { return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id or --parent-node-token") } // Bot identity also cannot fall back implicitly, so it requires an explicit // target or a parent it can resolve from. if identity.IsBot() && spec.SpaceID == "" && spec.ParentNodeToken == "" { return output.ErrValidation("bot identity requires --space-id or --parent-node-token") } return nil } func buildWikiNodeCreateDryRun(spec wikiNodeCreateSpec) *common.DryRunAPI { dry := common.NewDryRunAPI() step := 1 switch { case needsMyLibraryLookup(spec) && spec.ParentNodeToken != "": dry.Desc("3-step orchestration: resolve my_library -> resolve parent node -> create wiki node") case needsMyLibraryLookup(spec): dry.Desc("2-step orchestration: resolve my_library -> create wiki node") case spec.ParentNodeToken != "": dry.Desc("2-step orchestration: resolve parent node -> create wiki node") default: dry.Desc("1-step request: create wiki node") } if needsMyLibraryLookup(spec) { dry.GET("/open-apis/wiki/v2/spaces/my_library"). Desc(fmt.Sprintf("[%d] Resolve my_library space ID", step)) step++ } if spec.ParentNodeToken != "" { dry.GET("/open-apis/wiki/v2/spaces/get_node"). Desc(fmt.Sprintf("[%d] Resolve parent node space", step)). Params(map[string]interface{}{"token": spec.ParentNodeToken}) step++ } dry.POST(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", dryRunWikiNodeCreateSpaceID(spec))). Desc(fmt.Sprintf("[%d] Create wiki node", step)). Body(spec.RequestBody()) return dry } func dryRunWikiNodeCreateSpaceID(spec wikiNodeCreateSpec) string { if spec.SpaceID != "" && spec.SpaceID != wikiMyLibrarySpaceID { return spec.SpaceID } return "" } func needsMyLibraryLookup(spec wikiNodeCreateSpec) bool { if spec.ParentNodeToken != "" && spec.SpaceID == "" { return false } return spec.SpaceID == "" || spec.SpaceID == wikiMyLibrarySpaceID } func runWikiNodeCreate(ctx context.Context, client wikiNodeCreateClient, identity core.Identity, spec wikiNodeCreateSpec, errOut io.Writer) (*wikiNodeCreateExecution, error) { resolvedSpace, err := resolveWikiNodeCreateSpace(ctx, client, identity, spec) if err != nil { return nil, err } var ( node *wikiNodeRecord lastErr error ) for attempt := 0; attempt <= wikiNodeCreateMaxRetries; attempt++ { if attempt > 0 { delay := wikiNodeCreateRetryBaseDelay << uint(attempt-1) fmt.Fprintf(errOut, "Wiki node create encountered lock contention, retrying (attempt %d/%d) in %v...\n", attempt, wikiNodeCreateMaxRetries, delay) select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(delay): } } node, lastErr = client.CreateNode(ctx, resolvedSpace.SpaceID, spec) if lastErr == nil { break } if !isWikiNodeLockContention(lastErr) { return nil, lastErr } } if lastErr != nil { return nil, wrapWikiNodeCreateRetryError(lastErr) } if node == nil { return nil, output.Errorf(output.ExitAPI, "api_error", "wiki node create returned no node") } return &wikiNodeCreateExecution{ Node: node, ResolvedSpace: resolvedSpace, }, nil } // isWikiNodeLockContention returns true if the error is a Lark API error with // code 131009 (wiki node lock contention), which is retryable with backoff. func isWikiNodeLockContention(err error) bool { var exitErr *output.ExitError if !errors.As(err, &exitErr) || exitErr.Detail == nil { return false } return exitErr.Detail.Code == output.LarkErrWikiLockContention } // wrapWikiNodeCreateRetryError appends a retry-exhaustion hint to the original // API error. It builds the ExitError by hand (instead of using ErrWithHint) so // the original Lark error code survives in the envelope. func wrapWikiNodeCreateRetryError(err error) error { if err == nil { return nil } var exitErr *output.ExitError if !errors.As(err, &exitErr) || exitErr.Detail == nil { return err } hint := fmt.Sprintf( "wiki node create failed after %d retries due to lock contention; try again later or reduce concurrent node creations under the same parent", wikiNodeCreateMaxRetries, ) if existing := strings.TrimSpace(exitErr.Detail.Hint); existing != "" { hint = existing + "\n" + hint } 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, }, Err: exitErr.Err, Raw: exitErr.Raw, } } // resolveWikiNodeCreateSpace applies the shortcut's precedence rules: // explicit space ID wins, then parent-node inference, then my_library fallback. func resolveWikiNodeCreateSpace(ctx context.Context, client wikiNodeCreateClient, identity core.Identity, spec wikiNodeCreateSpec) (wikiResolvedSpace, error) { if spec.SpaceID != "" { return resolveWikiNodeCreateSpaceFromExplicitSpace(ctx, client, spec) } if spec.ParentNodeToken != "" { return resolveWikiNodeCreateSpaceFromParentNode(ctx, client, spec.ParentNodeToken) } if identity.IsBot() { return wikiResolvedSpace{}, output.ErrValidation("bot identity requires --space-id or --parent-node-token") } return resolveWikiNodeCreateSpaceFromMyLibrary(ctx, client) } func resolveWikiNodeCreateSpaceFromExplicitSpace(ctx context.Context, client wikiNodeCreateClient, spec wikiNodeCreateSpec) (wikiResolvedSpace, error) { resolved := wikiResolvedSpace{ SpaceID: spec.SpaceID, ResolvedBy: wikiResolvedByExplicitSpaceID, } if spec.SpaceID == wikiMyLibrarySpaceID { space, err := client.GetSpace(ctx, wikiMyLibrarySpaceID) if err != nil { return wikiResolvedSpace{}, err } spaceID, err := requireWikiSpaceID(space) if err != nil { return wikiResolvedSpace{}, err } resolved.SpaceID = spaceID resolved.ResolvedBy = wikiResolvedByMyLibrary } if spec.ParentNodeToken == "" { return resolved, nil } parent, err := client.GetNode(ctx, spec.ParentNodeToken) if err != nil { return wikiResolvedSpace{}, err } parentSpaceID, err := requireWikiNodeSpaceID(parent) if err != nil { return wikiResolvedSpace{}, err } if parentSpaceID != resolved.SpaceID { return wikiResolvedSpace{}, output.ErrValidation( "--space-id %q does not match parent node space %q (resolved space: %q)", spec.SpaceID, parentSpaceID, resolved.SpaceID, ) } resolved.ParentNode = parent return resolved, nil } func resolveWikiNodeCreateSpaceFromParentNode(ctx context.Context, client wikiNodeCreateClient, parentNodeToken string) (wikiResolvedSpace, error) { parent, err := client.GetNode(ctx, parentNodeToken) if err != nil { return wikiResolvedSpace{}, err } spaceID, err := requireWikiNodeSpaceID(parent) if err != nil { return wikiResolvedSpace{}, err } return wikiResolvedSpace{ SpaceID: spaceID, ResolvedBy: wikiResolvedByParentNode, ParentNode: parent, }, nil } func resolveWikiNodeCreateSpaceFromMyLibrary(ctx context.Context, client wikiNodeCreateClient) (wikiResolvedSpace, error) { space, err := client.GetSpace(ctx, wikiMyLibrarySpaceID) if err != nil { return wikiResolvedSpace{}, err } spaceID, err := requireWikiSpaceID(space) if err != nil { return wikiResolvedSpace{}, err } return wikiResolvedSpace{ SpaceID: spaceID, ResolvedBy: wikiResolvedByMyLibrary, }, nil } func requireWikiNodeSpaceID(node *wikiNodeRecord) (string, error) { if node != nil && node.SpaceID != "" { return node.SpaceID, nil } return "", output.Errorf(output.ExitAPI, "api_error", "wiki node lookup returned no space_id") } func requireWikiSpaceID(space *wikiSpaceRecord) (string, error) { if space != nil && space.SpaceID != "" { return space.SpaceID, nil } return "", output.ErrValidation("personal document library was not found, please specify --space-id") } // resolveMyLibrarySpaceID calls GET /wiki/v2/spaces/my_library and returns // the per-user real space_id. Shared by shortcuts that accept the my_library // alias (e.g. +node-create, +node-list) so the behavior stays consistent. func resolveMyLibrarySpaceID(runtime *common.RuntimeContext) (string, error) { data, err := runtime.CallAPI( "GET", fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(wikiMyLibrarySpaceID)), nil, nil, ) if err != nil { return "", err } space, err := parseWikiSpaceRecord(common.GetMap(data, "space")) if err != nil { return "", err } return requireWikiSpaceID(space) } func validateOptionalResourceName(value, flagName string) error { if value == "" { return nil } if err := validate.ResourceName(value, flagName); err != nil { return output.ErrValidation("%s", err) } return nil } func parseWikiNodeRecord(node map[string]interface{}) (*wikiNodeRecord, error) { if node == nil { return nil, output.Errorf(output.ExitAPI, "api_error", "wiki node response missing node") } return &wikiNodeRecord{ SpaceID: common.GetString(node, "space_id"), NodeToken: common.GetString(node, "node_token"), ObjToken: common.GetString(node, "obj_token"), ObjType: common.GetString(node, "obj_type"), ParentNodeToken: common.GetString(node, "parent_node_token"), NodeType: common.GetString(node, "node_type"), OriginNodeToken: common.GetString(node, "origin_node_token"), Title: common.GetString(node, "title"), HasChild: common.GetBool(node, "has_child"), URL: common.GetString(node, "url"), }, nil } func parseWikiSpaceRecord(space map[string]interface{}) (*wikiSpaceRecord, error) { if space == nil { return nil, output.Errorf(output.ExitAPI, "api_error", "wiki space response missing space") } return &wikiSpaceRecord{ SpaceID: common.GetString(space, "space_id"), Name: common.GetString(space, "name"), SpaceType: common.GetString(space, "space_type"), Visibility: common.GetString(space, "visibility"), OpenSharing: common.GetString(space, "open_sharing"), }, nil } func wikiNodeCreateOutput(execution *wikiNodeCreateExecution) map[string]interface{} { node := execution.Node return map[string]interface{}{ "resolved_space_id": execution.ResolvedSpace.SpaceID, "resolved_by": execution.ResolvedSpace.ResolvedBy, "space_id": node.SpaceID, "node_token": node.NodeToken, "obj_token": node.ObjToken, "obj_type": node.ObjType, "node_type": node.NodeType, "title": node.Title, "parent_node_token": node.ParentNodeToken, "origin_node_token": node.OriginNodeToken, "has_child": node.HasChild, } } func augmentWikiNodeCreateOutput(runtime *common.RuntimeContext, execution *wikiNodeCreateExecution) map[string]interface{} { if execution == nil || execution.Node == nil { return map[string]interface{}{} } out := wikiNodeCreateOutput(execution) if grant := common.AutoGrantCurrentUserDrivePermission(runtime, execution.Node.NodeToken, "wiki"); grant != nil { out["permission_grant"] = grant } if u := wikiNodeURL(runtime.Config.Brand, execution.Node); u != "" { out["url"] = u } return out }