Files
larksuite-cli/shortcuts/wiki/wiki_node_create.go
evandance 154ecdb90f feat(wiki): emit typed error envelopes across the wiki domain (#1350)
Emit structured validation, API, network, file, and internal error envelopes for Wiki shortcuts so users and agents can recover from failed wiki workflows using stable type, subtype, param, and code fields.

Add Wiki domain errscontract and golangci guards to prevent legacy envelope and common helper regressions.
2026-06-11 14:02:29 +08:00

583 lines
20 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"fmt"
"io"
"strings"
"time"
"github.com/larksuite/cli/errs"
"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 <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.CallAPITyped(
"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.CallAPITyped(
"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.CallAPITyped(
"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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--origin-node-token is required when --node-type=shortcut").WithParam("--origin-node-token")
}
if spec.NodeType != wikiNodeTypeShortcut && spec.OriginNodeToken != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--origin-node-token can only be used when --node-type=shortcut").WithParam("--origin-node-token")
}
// 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 errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit --space-id or --parent-node-token").WithParam("--space-id")
}
// 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 errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity requires --space-id or --parent-node-token").
WithParams(
errs.InvalidParam{Name: "--space-id", Reason: "provide --space-id or --parent-node-token for bot identity"},
errs.InvalidParam{Name: "--parent-node-token", Reason: "provide --space-id or --parent-node-token for bot identity"},
)
}
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 "<resolved_space_id>"
}
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, errs.NewInternalError(errs.SubtypeInvalidResponse, "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 {
p, ok := errs.ProblemOf(err)
if !ok {
return false
}
return p.Code == output.LarkErrWikiLockContention
}
// wrapWikiNodeCreateRetryError appends a retry-exhaustion hint to the original
// API error in place, preserving its typed category / subtype / code / log_id.
func wrapWikiNodeCreateRetryError(err error) error {
if err == nil {
return nil
}
p, ok := errs.ProblemOf(err)
if !ok {
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(p.Hint); existing != "" {
hint = existing + "\n" + hint
}
p.Hint = hint
return err
}
// 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{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity requires --space-id or --parent-node-token").
WithParams(
errs.InvalidParam{Name: "--space-id", Reason: "provide --space-id or --parent-node-token for bot identity"},
errs.InvalidParam{Name: "--parent-node-token", Reason: "provide --space-id or --parent-node-token for bot identity"},
)
}
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{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--space-id %q does not match parent node space %q (resolved space: %q)",
spec.SpaceID,
parentSpaceID,
resolved.SpaceID,
).WithParam("--space-id")
}
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 "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node lookup returned no space_id")
}
func requireWikiSpaceID(space *wikiSpaceRecord) (string, error) {
if space != nil && space.SpaceID != "" {
return space.SpaceID, nil
}
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "personal document library lookup returned no space_id").
WithHint("specify --space-id explicitly to target a space directly")
}
// 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.CallAPITyped(
"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 errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(flagName).WithCause(err)
}
return nil
}
func parseWikiNodeRecord(node map[string]interface{}) (*wikiNodeRecord, error) {
if node == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "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, errs.NewInternalError(errs.SubtypeInvalidResponse, "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
}