From 154ecdb90f9a5a19152b36e7874adba016395cd7 Mon Sep 17 00:00:00 2001 From: evandance <120630830+evandance@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:02:29 +0800 Subject: [PATCH] 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. --- .golangci.yml | 6 +- .../rule_no_legacy_common_helper_call.go | 1 + .../rule_no_legacy_envelope_literal.go | 1 + lint/errscontract/rules_test.go | 18 ++++ shortcuts/wiki/wiki_async_task.go | 40 +++----- shortcuts/wiki/wiki_async_task_test.go | 71 ++++++++------ shortcuts/wiki/wiki_delete.go | 8 +- shortcuts/wiki/wiki_delete_test.go | 39 ++++---- shortcuts/wiki/wiki_list_copy_test.go | 2 + shortcuts/wiki/wiki_member_add.go | 10 +- shortcuts/wiki/wiki_member_helpers.go | 6 +- shortcuts/wiki/wiki_member_list.go | 2 +- shortcuts/wiki/wiki_member_remove.go | 6 +- shortcuts/wiki/wiki_move.go | 69 ++++++++----- shortcuts/wiki/wiki_move_test.go | 73 ++++++++------ shortcuts/wiki/wiki_node_copy.go | 16 ++- shortcuts/wiki/wiki_node_create.go | 76 +++++++------- shortcuts/wiki/wiki_node_create_test.go | 98 ++++++++++++++++--- shortcuts/wiki/wiki_node_delete.go | 60 +++++------- shortcuts/wiki/wiki_node_delete_test.go | 68 ++++++------- shortcuts/wiki/wiki_node_get.go | 36 +++---- shortcuts/wiki/wiki_node_list.go | 5 +- shortcuts/wiki/wiki_space_create.go | 8 +- shortcuts/wiki/wiki_space_list.go | 7 +- 24 files changed, 416 insertions(+), 310 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 7caac0c6..b198d6ab 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -73,20 +73,20 @@ linters: - forbidigo # errs-typed-only enforced on paths already migrated to errs.NewXxxError. # Add a path when its migration is complete. - - path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|internal/event/consume/|cmd/event/|events/|shortcuts/event/) + - path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/) text: errs-typed-only linters: - forbidigo # errs-no-bare-wrap enforced on paths fully migrated to typed final # errors. Scoped separately from errs-typed-only because cmd/auth/, # cmd/config/ still have residual fmt.Errorf and must not be caught. - - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/) + - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/) text: errs-no-bare-wrap linters: - forbidigo # errs-no-legacy-helper enforced on domains whose shared validation/save # helpers have migrated to typed final errors. - - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|cmd/event/|events/|shortcuts/event/) + - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/) text: errs-no-legacy-helper linters: - forbidigo diff --git a/lint/errscontract/rule_no_legacy_common_helper_call.go b/lint/errscontract/rule_no_legacy_common_helper_call.go index 1d5336f5..723fc657 100644 --- a/lint/errscontract/rule_no_legacy_common_helper_call.go +++ b/lint/errscontract/rule_no_legacy_common_helper_call.go @@ -33,6 +33,7 @@ var migratedCommonHelperPaths = []string{ "shortcuts/task/", "shortcuts/vc/", "shortcuts/whiteboard/", + "shortcuts/wiki/", } const commonImportPath = "github.com/larksuite/cli/shortcuts/common" diff --git a/lint/errscontract/rule_no_legacy_envelope_literal.go b/lint/errscontract/rule_no_legacy_envelope_literal.go index 03be7c3a..61787452 100644 --- a/lint/errscontract/rule_no_legacy_envelope_literal.go +++ b/lint/errscontract/rule_no_legacy_envelope_literal.go @@ -34,6 +34,7 @@ var migratedEnvelopePaths = []string{ "shortcuts/task/", "shortcuts/vc/", "shortcuts/whiteboard/", + "shortcuts/wiki/", "shortcuts/im/", } diff --git a/lint/errscontract/rules_test.go b/lint/errscontract/rules_test.go index d736a6a3..67ef6408 100644 --- a/lint/errscontract/rules_test.go +++ b/lint/errscontract/rules_test.go @@ -960,6 +960,7 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes "shortcuts/slides/slides_create.go", "shortcuts/task/task_update.go", "shortcuts/whiteboard/whiteboard_query.go", + "shortcuts/wiki/wiki_node_get.go", } for _, path := range paths { for _, helper := range helpers { @@ -1076,6 +1077,23 @@ func boom() { } } +func TestCheckNoLegacyCommonHelperCall_CoversWikiPathWithAliasAndFunctionValue(t *testing.T) { + src := `package migrated + +import c "github.com/larksuite/cli/shortcuts/common" + +func boom() { + f := c.FlagErrorf + _ = f + c.WrapInputStatError(nil) +} +` + v := CheckNoLegacyCommonHelperCall("shortcuts/wiki/wiki_node_get.go", src) + if len(v) != 2 { + t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on wiki path, got %d: %+v", len(v), v) + } +} + func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) { src := `package contact diff --git a/shortcuts/wiki/wiki_async_task.go b/shortcuts/wiki/wiki_async_task.go index 4404f6c1..e54b615b 100644 --- a/shortcuts/wiki/wiki_async_task.go +++ b/shortcuts/wiki/wiki_async_task.go @@ -5,12 +5,11 @@ package wiki import ( "context" - "errors" "fmt" "strings" "time" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -95,7 +94,7 @@ func (s wikiAsyncTaskStatus) StatusLabel() string { } // wikiAsyncTaskFetcher returns the latest status for taskID. Implementations -// translate from runtime.CallAPI responses or test fakes. +// translate from runtime.CallAPITyped responses or test fakes. type wikiAsyncTaskFetcher func(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error) // parseWikiAsyncTaskStatus normalizes an /wiki/v2/tasks/{task_id} payload. @@ -103,7 +102,7 @@ type wikiAsyncTaskFetcher func(ctx context.Context, taskID string) (wikiAsyncTas // "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") + return wikiAsyncTaskStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task") } result := common.GetMap(task, resultKey) @@ -167,7 +166,7 @@ func pollWikiAsyncTask( 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()) + return status, false, errs.NewAPIError(errs.SubtypeServerError, "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()) @@ -178,29 +177,18 @@ func pollWikiAsyncTask( "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, - }, + // The poll error comes from a typed CallAPITyped path; append the resume + // hint in place so the original category / subtype / code / log_id + // survives a fully failed poll (per ERROR_CONTRACT.md "propagate typed + // errors unchanged"), matching wrapWikiNodeDeleteAPIError. + if p, ok := errs.ProblemOf(lastErr); ok { + if strings.TrimSpace(p.Hint) != "" { + hint = p.Hint + "\n" + hint } + p.Hint = hint + return lastStatus, false, lastErr } - return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint) + return lastStatus, false, errs.NewInternalError(errs.SubtypeUnknown, "%s", lastErr.Error()).WithHint("%s", hint).WithCause(lastErr) } return lastStatus, false, nil diff --git a/shortcuts/wiki/wiki_async_task_test.go b/shortcuts/wiki/wiki_async_task_test.go index 5b8fd5af..57a56203 100644 --- a/shortcuts/wiki/wiki_async_task_test.go +++ b/shortcuts/wiki/wiki_async_task_test.go @@ -10,8 +10,8 @@ import ( "testing" "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/output" ) // pollWikiAsyncTask is shared infrastructure for every wiki delete shortcut, @@ -88,45 +88,54 @@ func TestPollWikiAsyncTaskAllPollsFailWrapsWithResumeHint(t *testing.T) { t.Parallel() runtime, stderr := newWikiNodeDeleteRuntime(t, core.AsUser) + transportErr := errors.New("transport boom") _, ready, err := pollWikiAsyncTask( context.Background(), runtime, "task_lost", "delete-node", 2, 0, func(context.Context, string) (wikiAsyncTaskStatus, error) { - return wikiAsyncTaskStatus{}, errors.New("transport boom") + return wikiAsyncTaskStatus{}, transportErr }, "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) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("err = %T %v, want a typed errs.* error", err, err) } - if exitErr.Code != output.ExitAPI { - t.Fatalf("exit code = %d, want ExitAPI", exitErr.Code) + if p.Subtype != errs.SubtypeUnknown { + t.Fatalf("subtype = %q, want unknown for an untyped poll failure", p.Subtype) } - 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 !errors.Is(err, transportErr) { + t.Fatalf("err does not preserve the transport cause: %v", err) + } + if !strings.Contains(p.Hint, "every status poll failed (task_id=task_lost)") || + !strings.Contains(p.Hint, "lark-cli drive +task_result --task-id task_lost") { + t.Fatalf("hint = %q, want resume guidance naming the task", p.Hint) } if !strings.Contains(stderr.String(), "attempt 2/2 failed") { t.Fatalf("stderr = %q, want per-attempt progress", stderr.String()) } } +func TestParseWikiAsyncTaskStatusRejectsNilTask(t *testing.T) { + t.Parallel() + + _, err := parseWikiAsyncTaskStatus("task_x", nil, "delete_space_result") + p, ok := errs.ProblemOf(err) + if !ok || p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("expected internal/invalid_response, got %v", err) + } +} + 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", - }, - } + // The upstream poll error is a typed error carrying its own hint, mirroring + // what runtime.CallAPITyped produces for a permission failure. + upstream := errs.NewPermissionError(errs.SubtypePermissionDenied, "permission denied"). + WithHint("grant the wiki:node:retrieve scope") _, _, err := pollWikiAsyncTask( context.Background(), runtime, "task_perm", "delete-node", 1, 0, func(context.Context, string) (wikiAsyncTaskStatus, error) { @@ -134,23 +143,23 @@ func TestPollWikiAsyncTaskPrependsUpstreamExitHint(t *testing.T) { }, "resume-cmd", ) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("err = %T %v, want *output.ExitError", err, err) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("err = %T %v, want a typed errs.* error", 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) + // the resume guidance appended. The original typed error propagates in place. + if !strings.HasPrefix(p.Hint, "grant the wiki:node:retrieve scope\n") { + t.Fatalf("hint = %q, want upstream hint prepended", p.Hint) } - if !strings.Contains(exitErr.Detail.Hint, "resume-cmd") { - t.Fatalf("hint = %q, want resume command appended", exitErr.Detail.Hint) + if !strings.Contains(p.Hint, "resume-cmd") { + t.Fatalf("hint = %q, want resume command appended", p.Hint) } - if exitErr.Detail.Type != "permission" || exitErr.Code != output.ExitAPI { - t.Fatalf("exitErr = %+v, want permission/ExitAPI propagated", exitErr) + if p.Subtype != errs.SubtypePermissionDenied { + t.Fatalf("subtype = %q, want permission_denied propagated", p.Subtype) } - if exitErr.Detail.Message != "permission denied" { - t.Fatalf("message = %q, want upstream message preserved", exitErr.Detail.Message) + if p.Message != "permission denied" { + t.Fatalf("message = %q, want upstream message preserved", p.Message) } } diff --git a/shortcuts/wiki/wiki_delete.go b/shortcuts/wiki/wiki_delete.go index 484d7d46..3c3eb835 100644 --- a/shortcuts/wiki/wiki_delete.go +++ b/shortcuts/wiki/wiki_delete.go @@ -9,8 +9,8 @@ import ( "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" ) @@ -89,7 +89,7 @@ type wikiDeleteSpaceAPI struct { } func (api wikiDeleteSpaceAPI) DeleteSpace(ctx context.Context, spaceID string) (*wikiDeleteSpaceResponse, error) { - data, err := api.runtime.CallAPI( + data, err := api.runtime.CallAPITyped( "DELETE", fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(spaceID)), nil, @@ -104,7 +104,7 @@ func (api wikiDeleteSpaceAPI) DeleteSpace(ctx context.Context, spaceID string) ( } func (api wikiDeleteSpaceAPI) GetDeleteSpaceTask(ctx context.Context, taskID string) (wikiDeleteSpaceTaskStatus, error) { - data, err := api.runtime.CallAPI( + data, err := api.runtime.CallAPITyped( "GET", fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)), map[string]interface{}{"task_type": "delete_space"}, @@ -124,7 +124,7 @@ func readWikiDeleteSpaceSpec(runtime *common.RuntimeContext) wikiDeleteSpaceSpec func validateWikiDeleteSpaceSpec(spec wikiDeleteSpaceSpec) error { if spec.SpaceID == "" { - return output.ErrValidation("--space-id is required") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--space-id is required").WithParam("--space-id") } return validateOptionalResourceName(spec.SpaceID, "--space-id") } diff --git a/shortcuts/wiki/wiki_delete_test.go b/shortcuts/wiki/wiki_delete_test.go index e2abe608..b2772884 100644 --- a/shortcuts/wiki/wiki_delete_test.go +++ b/shortcuts/wiki/wiki_delete_test.go @@ -6,7 +6,6 @@ package wiki import ( "bytes" "context" - "errors" "reflect" "strings" "sync" @@ -14,11 +13,12 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/httpmock" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -266,19 +266,18 @@ 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 + // Seed a typed error that carries an upstream Lark code and hint 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. + // hint): the poll-exhaustion path must propagate the typed error in place. + seeded := errclass.BuildAPIError( + map[string]any{"code": float64(131006), "msg": "poll failed"}, + errclass.ClassifyContext{}, + ) + if p, ok := errs.ProblemOf(seeded); ok { + p.Hint = "retry original" + } client := &fakeWikiDeleteSpaceClient{ - taskErrs: []error{&output.ExitError{ - Code: output.ExitAPI, - Detail: &output.ErrDetail{ - Type: "api_error", - Code: 131006, - Message: "poll failed", - Hint: "retry original", - }, - }}, + taskErrs: []error{seeded}, } status, ready, err := pollWikiDeleteSpaceTask(context.Background(), client, runtime, "task_123") @@ -291,15 +290,15 @@ func TestPollWikiDeleteSpaceTaskWrapsPollFailuresWithHint(t *testing.T) { if status.TaskID != "task_123" { t.Fatalf("status.TaskID = %q, want %q", status.TaskID, "task_123") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured exit error, got %T %v", err, err) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected a typed errs.* error, got %T %v", err, err) } - 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 !strings.Contains(p.Hint, "retry original") || !strings.Contains(p.Hint, wikiDeleteSpaceTaskResultCommand("task_123", core.AsUser)) { + t.Fatalf("hint = %q, want original hint and resume command", p.Hint) } - if exitErr.Detail.Code != 131006 { - t.Fatalf("Detail.Code = %d, want 131006 preserved through poll exhaustion", exitErr.Detail.Code) + if p.Code != 131006 { + t.Fatalf("Code = %d, want 131006 preserved through poll exhaustion", p.Code) } if !strings.Contains(stderr.String(), "Wiki delete-space status attempt 1/1 failed") { t.Fatalf("stderr = %q, want poll failure log", stderr.String()) diff --git a/shortcuts/wiki/wiki_list_copy_test.go b/shortcuts/wiki/wiki_list_copy_test.go index e5bbf31f..924a9129 100644 --- a/shortcuts/wiki/wiki_list_copy_test.go +++ b/shortcuts/wiki/wiki_list_copy_test.go @@ -351,6 +351,7 @@ func TestWikiNodeCopyRequiresTargetSpaceOrParent(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "--target-space-id or --target-parent-node-token") { t.Fatalf("expected target validation error, got %v", err) } + requireWikiValidationParams(t, err, "--target-space-id", "--target-parent-node-token") } func TestWikiNodeCopyRejectsBothTargetFlags(t *testing.T) { @@ -365,6 +366,7 @@ func TestWikiNodeCopyRejectsBothTargetFlags(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { t.Fatalf("expected mutually exclusive error, got %v", err) } + requireWikiValidationParams(t, err, "--target-space-id", "--target-parent-node-token") } // TestWikiNodeCopyDeclaredHighRiskWrite pins down the high-risk-write diff --git a/shortcuts/wiki/wiki_member_add.go b/shortcuts/wiki/wiki_member_add.go index 5337a32a..a7fc8639 100644 --- a/shortcuts/wiki/wiki_member_add.go +++ b/shortcuts/wiki/wiki_member_add.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -65,7 +65,7 @@ var WikiMemberAdd = common.Shortcut{ common.MaskToken(spec.MemberID), spec.MemberType, spec.MemberRole, common.MaskToken(spaceID)) path := fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/members", validate.EncodePathSegment(spaceID)) - data, err := runtime.CallAPI("POST", path, spec.QueryParams(), spec.RequestBody()) + data, err := runtime.CallAPITyped("POST", path, spec.QueryParams(), spec.RequestBody()) if err != nil { return err } @@ -131,16 +131,16 @@ func readWikiMemberAddSpec(runtime *common.RuntimeContext) (wikiMemberAddSpec, e return wikiMemberAddSpec{}, err } if spec.MemberID == "" { - return wikiMemberAddSpec{}, output.ErrValidation("--member-id is required and cannot be blank") + return wikiMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and cannot be blank").WithParam("--member-id") } // The space-member API rejects opendepartmentid grants under a // tenant_access_token; surface that as a CLI validation error so callers do // not waste a network round-trip on a server-side 403. The escape hatch is // --as user, which is the only identity the API accepts for departments. if runtime.As().IsBot() && spec.MemberType == "opendepartmentid" { - return wikiMemberAddSpec{}, output.ErrValidation( + return wikiMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--as bot does not support --member-type opendepartmentid; rerun with --as user", - ) + ).WithParam("--member-type") } // --member-type / --member-role enum membership is enforced by the // framework's validateEnumFlags (runner.go) before Validate runs, so no diff --git a/shortcuts/wiki/wiki_member_helpers.go b/shortcuts/wiki/wiki_member_helpers.go index 8eac0da2..8fd1e412 100644 --- a/shortcuts/wiki/wiki_member_helpers.go +++ b/shortcuts/wiki/wiki_member_helpers.go @@ -6,7 +6,7 @@ package wiki import ( "fmt" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -27,10 +27,10 @@ var wikiMemberRoles = []string{"admin", "member"} // tenant_access_token; same contract as +node-list / +node-create) func validateWikiMemberSpaceID(runtime *common.RuntimeContext, spaceID string) error { if spaceID == "" { - return output.ErrValidation("--space-id is required and cannot be blank") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--space-id is required and cannot be blank").WithParam("--space-id") } if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID { - return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit --space-id").WithParam("--space-id") } return validateOptionalResourceName(spaceID, "--space-id") } diff --git a/shortcuts/wiki/wiki_member_list.go b/shortcuts/wiki/wiki_member_list.go index 0aead4cd..8dbe84db 100644 --- a/shortcuts/wiki/wiki_member_list.go +++ b/shortcuts/wiki/wiki_member_list.go @@ -122,7 +122,7 @@ func fetchWikiMembers(runtime *common.RuntimeContext, spaceID string) ([]map[str if pageToken != "" { params["page_token"] = pageToken } - data, err := runtime.CallAPI("GET", apiPath, params, nil) + data, err := runtime.CallAPITyped("GET", apiPath, params, nil) if err != nil { return nil, false, "", err } diff --git a/shortcuts/wiki/wiki_member_remove.go b/shortcuts/wiki/wiki_member_remove.go index 6a8bab86..1a0a3644 100644 --- a/shortcuts/wiki/wiki_member_remove.go +++ b/shortcuts/wiki/wiki_member_remove.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -68,7 +68,7 @@ var WikiMemberRemove = common.Shortcut{ validate.EncodePathSegment(spaceID), validate.EncodePathSegment(spec.MemberID), ) - data, err := runtime.CallAPI("DELETE", path, nil, spec.RequestBody()) + data, err := runtime.CallAPITyped("DELETE", path, nil, spec.RequestBody()) if err != nil { return err } @@ -122,7 +122,7 @@ func readWikiMemberRemoveSpec(runtime *common.RuntimeContext) (wikiMemberRemoveS return wikiMemberRemoveSpec{}, err } if spec.MemberID == "" { - return wikiMemberRemoveSpec{}, output.ErrValidation("--member-id is required and cannot be blank") + return wikiMemberRemoveSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and cannot be blank").WithParam("--member-id") } // Enum membership for --member-type / --member-role is enforced by the // framework's validateEnumFlags (runner.go) before Validate runs. diff --git a/shortcuts/wiki/wiki_move.go b/shortcuts/wiki/wiki_move.go index 7931c254..8ee798bc 100644 --- a/shortcuts/wiki/wiki_move.go +++ b/shortcuts/wiki/wiki_move.go @@ -5,13 +5,12 @@ package wiki import ( "context" - "errors" "fmt" "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" ) @@ -65,7 +64,7 @@ var WikiMove = common.Shortcut{ // for a tenant_access_token (--as bot), so reject early with a clear // hint instead of letting the API return a confusing error. if runtime.As().IsBot() && spec.TargetSpaceID == wikiMyLibrarySpaceID { - return output.ErrValidation("--target-space-id my_library is a per-user personal library alias and cannot be used with --as bot; resolve it to a real space_id first via `lark-cli wiki spaces get --params '{\"space_id\":\"my_library\"}' --as user`") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-space-id my_library is a per-user personal library alias and cannot be used with --as bot; resolve it to a real space_id first via `lark-cli wiki spaces get --params '{\"space_id\":\"my_library\"}' --as user`").WithParam("--target-space-id") } return validateWikiMoveSpec(spec) }, @@ -230,7 +229,7 @@ type wikiMoveAPI struct { } func (api wikiMoveAPI) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) { - data, err := api.runtime.CallAPI( + data, err := api.runtime.CallAPITyped( "GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": token}, @@ -243,7 +242,7 @@ func (api wikiMoveAPI) GetNode(ctx context.Context, token string) (*wikiNodeReco } func (api wikiMoveAPI) MoveNode(ctx context.Context, sourceSpaceID string, spec wikiMoveSpec) (*wikiNodeRecord, error) { - data, err := api.runtime.CallAPI( + data, err := api.runtime.CallAPITyped( "POST", fmt.Sprintf( "/open-apis/wiki/v2/spaces/%s/nodes/%s/move", @@ -260,7 +259,7 @@ func (api wikiMoveAPI) MoveNode(ctx context.Context, sourceSpaceID string, spec } func (api wikiMoveAPI) MoveDocsToWiki(ctx context.Context, targetSpaceID string, spec wikiMoveSpec) (*wikiMoveDocsResponse, error) { - data, err := api.runtime.CallAPI( + data, err := api.runtime.CallAPITyped( "POST", fmt.Sprintf( "/open-apis/wiki/v2/spaces/%s/nodes/move_docs_to_wiki", @@ -281,7 +280,7 @@ func (api wikiMoveAPI) MoveDocsToWiki(ctx context.Context, targetSpaceID string, } func (api wikiMoveAPI) GetMoveTask(ctx context.Context, taskID string) (wikiMoveTaskStatus, error) { - data, err := api.runtime.CallAPI( + data, err := api.runtime.CallAPITyped( "GET", fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)), map[string]interface{}{"task_type": "move"}, @@ -324,28 +323,42 @@ func validateWikiMoveSpec(spec wikiMoveSpec) error { if spec.NodeToken != "" { if spec.ObjType != "" || spec.ObjToken != "" || spec.Apply { - return output.ErrValidation("--node-token cannot be combined with --obj-type, --obj-token, or --apply") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token cannot be combined with --obj-type, --obj-token, or --apply"). + WithParams( + errs.InvalidParam{Name: "--obj-type", Reason: "cannot be combined with --node-token"}, + errs.InvalidParam{Name: "--obj-token", Reason: "cannot be combined with --node-token"}, + errs.InvalidParam{Name: "--apply", Reason: "cannot be combined with --node-token"}, + ) } if spec.TargetParentToken == "" && spec.TargetSpaceID == "" { - return output.ErrValidation("--target-parent-token and --target-space-id cannot both be empty for wiki node move") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-parent-token and --target-space-id cannot both be empty for wiki node move"). + WithParams( + errs.InvalidParam{Name: "--target-parent-token", Reason: "provide --target-parent-token or --target-space-id"}, + errs.InvalidParam{Name: "--target-space-id", Reason: "provide --target-parent-token or --target-space-id"}, + ) } return nil } if spec.SourceSpaceID != "" { - return output.ErrValidation("--source-space-id can only be used with --node-token") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--source-space-id can only be used with --node-token").WithParam("--source-space-id") } if spec.ObjType == "" && spec.ObjToken == "" && !spec.Apply { - return output.ErrValidation("provide --node-token for wiki node move, or provide --obj-type and --obj-token for docs-to-wiki move") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "provide --node-token for wiki node move, or provide --obj-type and --obj-token for docs-to-wiki move"). + WithParams( + errs.InvalidParam{Name: "--node-token", Reason: "provide --node-token, or --obj-type and --obj-token"}, + errs.InvalidParam{Name: "--obj-type", Reason: "provide --node-token, or --obj-type and --obj-token"}, + errs.InvalidParam{Name: "--obj-token", Reason: "provide --node-token, or --obj-type and --obj-token"}, + ) } if spec.ObjType == "" { - return output.ErrValidation("--obj-type is required for docs-to-wiki move") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--obj-type is required for docs-to-wiki move").WithParam("--obj-type") } if spec.ObjToken == "" { - return output.ErrValidation("--obj-token is required for docs-to-wiki move") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--obj-token is required for docs-to-wiki move").WithParam("--obj-token") } if spec.TargetSpaceID == "" { - return output.ErrValidation("--target-space-id is required for docs-to-wiki move") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-space-id is required for docs-to-wiki move").WithParam("--target-space-id") } return nil @@ -426,7 +439,7 @@ func runWikiMove(ctx context.Context, client wikiMoveClient, runtime *common.Run case wikiMoveModeDocsToWiki: return runWikiDocsToWikiMove(ctx, client, runtime, spec) default: - return nil, output.ErrValidation("unknown wiki move mode") + return nil, errs.NewInternalError(errs.SubtypeUnknown, "unknown wiki move mode") } } @@ -479,11 +492,11 @@ func resolveWikiNodeMoveSpaces(ctx context.Context, client wikiMoveClient, spec if targetSpaceID == "" { targetSpaceID = parentSpaceID } else if targetSpaceID != parentSpaceID { - return "", "", output.ErrValidation( + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-space-id %q does not match target parent node space %q", spec.TargetSpaceID, parentSpaceID, - ) + ).WithParam("--target-space-id") } } @@ -549,7 +562,7 @@ func runWikiDocsToWikiMove(ctx context.Context, client wikiMoveClient, runtime * } return out, nil default: - return nil, output.Errorf(output.ExitAPI, "api_error", "move_docs_to_wiki returned neither wiki_token, task_id, nor applied result") + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "move_docs_to_wiki returned neither wiki_token, task_id, nor applied result") } } @@ -592,7 +605,7 @@ func pollWikiMoveTask(ctx context.Context, client wikiMoveClient, runtime *commo return status, true, nil } if status.Failed() { - return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki move task failed: %s", status.PrimaryStatusLabel()) + return status, false, errs.NewAPIError(errs.SubtypeServerError, "wiki move task failed: %s", status.PrimaryStatusLabel()) } fmt.Fprintf(runtime.IO().ErrOut, "Wiki move status %d/%d: %s\n", attempt, wikiMovePollAttempts, status.PrimaryStatusLabel()) @@ -605,14 +618,18 @@ func pollWikiMoveTask(ctx context.Context, client wikiMoveClient, runtime *commo 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 + // The poll error comes from a typed CallAPITyped path; append the resume + // hint in place so the original category / subtype / code / log_id + // survives a fully failed poll (per ERROR_CONTRACT.md "propagate typed + // errors unchanged"). + if p, ok := errs.ProblemOf(lastErr); ok { + if strings.TrimSpace(p.Hint) != "" { + hint = p.Hint + "\n" + hint } - return lastStatus, false, output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint) + p.Hint = hint + return lastStatus, false, lastErr } - return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint) + return lastStatus, false, errs.NewInternalError(errs.SubtypeSDKError, "%s", lastErr.Error()).WithHint("%s", hint).WithCause(lastErr) } return lastStatus, false, nil @@ -620,7 +637,7 @@ func pollWikiMoveTask(ctx context.Context, client wikiMoveClient, runtime *commo func parseWikiMoveTaskStatus(taskID string, task map[string]interface{}) (wikiMoveTaskStatus, error) { if task == nil { - return wikiMoveTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task") + return wikiMoveTaskStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task") } status := wikiMoveTaskStatus{ diff --git a/shortcuts/wiki/wiki_move_test.go b/shortcuts/wiki/wiki_move_test.go index bfae5b21..6d9a7e64 100644 --- a/shortcuts/wiki/wiki_move_test.go +++ b/shortcuts/wiki/wiki_move_test.go @@ -7,7 +7,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "reflect" "strings" "sync" @@ -15,11 +14,11 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/httpmock" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -181,39 +180,52 @@ func TestValidateWikiMoveSpecRejectsInvalidCombinations(t *testing.T) { t.Parallel() tests := []struct { - name string - spec wikiMoveSpec - wantErr string + name string + spec wikiMoveSpec + wantErr string + wantParams []string }{ { - name: "node move rejects docs flags", - spec: wikiMoveSpec{NodeToken: "wik_node", ObjType: "sheet", TargetSpaceID: "space_dst"}, - wantErr: "cannot be combined", + name: "node move rejects docs flags", + spec: wikiMoveSpec{NodeToken: "wik_node", ObjType: "sheet", TargetSpaceID: "space_dst"}, + wantErr: "cannot be combined", + wantParams: []string{"--obj-type", "--obj-token", "--apply"}, }, { - name: "node move requires target", - spec: wikiMoveSpec{NodeToken: "wik_node"}, - wantErr: "cannot both be empty", + name: "node move requires target", + spec: wikiMoveSpec{NodeToken: "wik_node"}, + wantErr: "cannot both be empty", + wantParams: []string{"--target-parent-token", "--target-space-id"}, }, { - name: "source space requires node token", - spec: wikiMoveSpec{SourceSpaceID: "space_src", ObjType: "sheet", ObjToken: "sheet_token", TargetSpaceID: "space_dst"}, - wantErr: "can only be used with --node-token", + name: "requires a move mode", + spec: wikiMoveSpec{TargetSpaceID: "space_dst"}, + wantErr: "provide --node-token for wiki node move", + wantParams: []string{"--node-token", "--obj-type", "--obj-token"}, }, { - name: "docs to wiki requires obj type", - spec: wikiMoveSpec{ObjToken: "sheet_token", TargetSpaceID: "space_dst"}, - wantErr: "--obj-type is required", + name: "source space requires node token", + spec: wikiMoveSpec{SourceSpaceID: "space_src", ObjType: "sheet", ObjToken: "sheet_token", TargetSpaceID: "space_dst"}, + wantErr: "can only be used with --node-token", + wantParams: []string{"--source-space-id"}, }, { - name: "docs to wiki requires obj token", - spec: wikiMoveSpec{ObjType: "sheet", TargetSpaceID: "space_dst"}, - wantErr: "--obj-token is required", + name: "docs to wiki requires obj type", + spec: wikiMoveSpec{ObjToken: "sheet_token", TargetSpaceID: "space_dst"}, + wantErr: "--obj-type is required", + wantParams: []string{"--obj-type"}, }, { - name: "docs to wiki requires target space", - spec: wikiMoveSpec{ObjType: "sheet", ObjToken: "sheet_token"}, - wantErr: "--target-space-id is required", + name: "docs to wiki requires obj token", + spec: wikiMoveSpec{ObjType: "sheet", TargetSpaceID: "space_dst"}, + wantErr: "--obj-token is required", + wantParams: []string{"--obj-token"}, + }, + { + name: "docs to wiki requires target space", + spec: wikiMoveSpec{ObjType: "sheet", ObjToken: "sheet_token"}, + wantErr: "--target-space-id is required", + wantParams: []string{"--target-space-id"}, }, } @@ -225,6 +237,9 @@ func TestValidateWikiMoveSpecRejectsInvalidCombinations(t *testing.T) { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) } + if len(tt.wantParams) > 0 { + requireWikiValidationParams(t, err, tt.wantParams...) + } }) } } @@ -837,7 +852,7 @@ func TestPollWikiMoveTaskWrapsRepeatedPollFailuresWithHint(t *testing.T) { runtime, stderr := newWikiMoveRuntimeWithScopes(t, core.AsUser, "") client := &fakeWikiMoveClient{ - taskErrs: []error{output.ErrWithHint(output.ExitAPI, "api_error", "poll failed", "retry original")}, + taskErrs: []error{errs.NewAPIError(errs.SubtypeServerError, "poll failed").WithHint("retry original")}, } status, ready, err := pollWikiMoveTask(context.Background(), client, runtime, "task_123") @@ -850,12 +865,12 @@ func TestPollWikiMoveTaskWrapsRepeatedPollFailuresWithHint(t *testing.T) { if status.TaskID != "task_123" { t.Fatalf("status.TaskID = %q, want %q", status.TaskID, "task_123") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured exit error, got %T %v", err, err) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected a typed errs.* error, got %T %v", err, err) } - if !strings.Contains(exitErr.Detail.Hint, "retry original") || !strings.Contains(exitErr.Detail.Hint, wikiMoveTaskResultCommand("task_123", core.AsUser)) { - t.Fatalf("hint = %q, want original hint and resume command", exitErr.Detail.Hint) + if !strings.Contains(p.Hint, "retry original") || !strings.Contains(p.Hint, wikiMoveTaskResultCommand("task_123", core.AsUser)) { + t.Fatalf("hint = %q, want original hint and resume command", p.Hint) } if !strings.Contains(stderr.String(), "Wiki move status attempt 1/1 failed") { t.Fatalf("stderr = %q, want poll failure log", stderr.String()) diff --git a/shortcuts/wiki/wiki_node_copy.go b/shortcuts/wiki/wiki_node_copy.go index c1bea07c..67a5d271 100644 --- a/shortcuts/wiki/wiki_node_copy.go +++ b/shortcuts/wiki/wiki_node_copy.go @@ -9,7 +9,7 @@ import ( "io" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -44,10 +44,18 @@ var WikiNodeCopy = common.Shortcut{ targetSpaceID := strings.TrimSpace(runtime.Str("target-space-id")) targetParent := strings.TrimSpace(runtime.Str("target-parent-node-token")) if targetSpaceID == "" && targetParent == "" { - return output.ErrValidation("at least one of --target-space-id or --target-parent-node-token is required") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "at least one of --target-space-id or --target-parent-node-token is required"). + WithParams( + errs.InvalidParam{Name: "--target-space-id", Reason: "provide --target-space-id or --target-parent-node-token"}, + errs.InvalidParam{Name: "--target-parent-node-token", Reason: "provide --target-space-id or --target-parent-node-token"}, + ) } if targetSpaceID != "" && targetParent != "" { - return output.ErrValidation("--target-space-id and --target-parent-node-token are mutually exclusive; provide only one") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-space-id and --target-parent-node-token are mutually exclusive; provide only one"). + WithParams( + errs.InvalidParam{Name: "--target-space-id", Reason: "mutually exclusive with --target-parent-node-token"}, + errs.InvalidParam{Name: "--target-parent-node-token", Reason: "mutually exclusive with --target-space-id"}, + ) } if err := validateOptionalResourceName(targetSpaceID, "--target-space-id"); err != nil { return err @@ -72,7 +80,7 @@ var WikiNodeCopy = common.Shortcut{ fmt.Fprintf(runtime.IO().ErrOut, "Copying wiki node %s from space %s\n", common.MaskToken(nodeToken), common.MaskToken(spaceID)) - data, err := runtime.CallAPI("POST", + data, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes/%s/copy", validate.EncodePathSegment(spaceID), validate.EncodePathSegment(nodeToken)), diff --git a/shortcuts/wiki/wiki_node_create.go b/shortcuts/wiki/wiki_node_create.go index d9e6a868..6356ede8 100644 --- a/shortcuts/wiki/wiki_node_create.go +++ b/shortcuts/wiki/wiki_node_create.go @@ -5,12 +5,12 @@ package wiki import ( "context" - "errors" "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" @@ -170,7 +170,7 @@ type wikiNodeCreateAPI struct { } func (api wikiNodeCreateAPI) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) { - data, err := api.runtime.CallAPI( + data, err := api.runtime.CallAPITyped( "GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": token}, @@ -183,7 +183,7 @@ func (api wikiNodeCreateAPI) GetNode(ctx context.Context, token string) (*wikiNo } func (api wikiNodeCreateAPI) GetSpace(ctx context.Context, spaceID string) (*wikiSpaceRecord, error) { - data, err := api.runtime.CallAPI( + data, err := api.runtime.CallAPITyped( "GET", fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(spaceID)), nil, @@ -196,7 +196,7 @@ func (api wikiNodeCreateAPI) GetSpace(ctx context.Context, spaceID string) (*wik } func (api wikiNodeCreateAPI) CreateNode(ctx context.Context, spaceID string, spec wikiNodeCreateSpec) (*wikiNodeRecord, error) { - data, err := api.runtime.CallAPI( + data, err := api.runtime.CallAPITyped( "POST", fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID)), nil, @@ -231,22 +231,26 @@ func validateWikiNodeCreateSpec(spec wikiNodeCreateSpec, identity core.Identity) } if spec.NodeType == wikiNodeTypeShortcut && spec.OriginNodeToken == "" { - return output.ErrValidation("--origin-node-token is required when --node-type=shortcut") + 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 output.ErrValidation("--origin-node-token can only be used when --node-type=shortcut") + 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 output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id or --parent-node-token") + 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 output.ErrValidation("bot identity requires --space-id or --parent-node-token") + 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 @@ -334,7 +338,7 @@ func runWikiNodeCreate(ctx context.Context, client wikiNodeCreateClient, identit return nil, wrapWikiNodeCreateRetryError(lastErr) } if node == nil { - return nil, output.Errorf(output.ExitAPI, "api_error", "wiki node create returned no node") + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node create returned no node") } return &wikiNodeCreateExecution{ @@ -346,45 +350,32 @@ func runWikiNodeCreate(ctx context.Context, client wikiNodeCreateClient, identit // 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 { + p, ok := errs.ProblemOf(err) + if !ok { return false } - return exitErr.Detail.Code == output.LarkErrWikiLockContention + return p.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. +// API error in place, preserving its typed category / subtype / code / log_id. func wrapWikiNodeCreateRetryError(err error) error { if err == nil { return nil } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == 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(exitErr.Detail.Hint); existing != "" { + if existing := strings.TrimSpace(p.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, - } + p.Hint = hint + return err } // resolveWikiNodeCreateSpace applies the shortcut's precedence rules: @@ -397,7 +388,11 @@ func resolveWikiNodeCreateSpace(ctx context.Context, client wikiNodeCreateClient return resolveWikiNodeCreateSpaceFromParentNode(ctx, client, spec.ParentNodeToken) } if identity.IsBot() { - return wikiResolvedSpace{}, output.ErrValidation("bot identity requires --space-id or --parent-node-token") + 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) } @@ -434,12 +429,12 @@ func resolveWikiNodeCreateSpaceFromExplicitSpace(ctx context.Context, client wik return wikiResolvedSpace{}, err } if parentSpaceID != resolved.SpaceID { - return wikiResolvedSpace{}, output.ErrValidation( + 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 @@ -483,21 +478,22 @@ 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") + 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 "", output.ErrValidation("personal document library was not found, please specify --space-id") + 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.CallAPI( + data, err := runtime.CallAPITyped( "GET", fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(wikiMyLibrarySpaceID)), nil, nil, @@ -517,14 +513,14 @@ func validateOptionalResourceName(value, flagName string) error { return nil } if err := validate.ResourceName(value, flagName); err != nil { - return output.ErrValidation("%s", err) + 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, output.Errorf(output.ExitAPI, "api_error", "wiki node response missing node") + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node response missing node") } return &wikiNodeRecord{ SpaceID: common.GetString(node, "space_id"), @@ -542,7 +538,7 @@ func parseWikiNodeRecord(node map[string]interface{}) (*wikiNodeRecord, error) { 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 nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki space response missing space") } return &wikiSpaceRecord{ SpaceID: common.GetString(space, "space_id"), diff --git a/shortcuts/wiki/wiki_node_create_test.go b/shortcuts/wiki/wiki_node_create_test.go index 88964871..8d183bcd 100644 --- a/shortcuts/wiki/wiki_node_create_test.go +++ b/shortcuts/wiki/wiki_node_create_test.go @@ -16,13 +16,26 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) +// wikiTestLockContentionErr builds the typed API error that runtime.CallAPITyped +// produces for a wiki write-lock-contention response (code 131009), so the +// retry path's errs.ProblemOf(...).Code check sees the same shape in tests as +// in production. +func wikiTestLockContentionErr() error { + return errclass.BuildAPIError( + map[string]any{"code": float64(output.LarkErrWikiLockContention), "msg": "lock contention"}, + errclass.ClassifyContext{}, + ) +} + type fakeWikiNodeCreateCall struct { SpaceID string Spec wikiNodeCreateSpec @@ -116,6 +129,44 @@ func mountAndRunWiki(t *testing.T, shortcut common.Shortcut, args []string, fact return parent.Execute() } +// requireWikiValidationParams asserts err carries a *errs.ValidationError of +// category validation / subtype invalid_argument and that its named flags +// (single Param or structured Params) cover every wanted flag, locking the +// typed contract and input-recovery fields so callers and agents can re-fill +// the right flags without parsing the prose message. +func requireWikiValidationParams(t *testing.T, err error, wantNames ...string) { + t.Helper() + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err) + } + if ve.Category != errs.CategoryValidation || ve.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected validation/invalid_argument, got %s/%s", ve.Category, ve.Subtype) + } + have := make(map[string]bool, len(ve.Params)+1) + if ve.Param != "" { + have[ve.Param] = true + } + for _, p := range ve.Params { + have[p.Name] = true + } + for _, name := range wantNames { + if !have[name] { + t.Fatalf("flag %q not found; Param=%q Params=%+v", name, ve.Param, ve.Params) + } + } +} + +func TestRequireWikiSpaceIDTreatsEmptyAsInvalidResponse(t *testing.T) { + t.Parallel() + + _, err := requireWikiSpaceID(&wikiSpaceRecord{}) + p, ok := errs.ProblemOf(err) + if !ok || p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("expected internal/invalid_response, got %v", err) + } +} + func TestValidateWikiNodeCreateSpecRejectsShortcutWithoutOriginNodeToken(t *testing.T) { t.Parallel() @@ -151,6 +202,7 @@ func TestValidateWikiNodeCreateSpecRejectsBotWithoutLocation(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "bot identity requires --space-id or --parent-node-token") { t.Fatalf("expected bot location validation error, got %v", err) } + requireWikiValidationParams(t, err, "--space-id", "--parent-node-token") } func TestValidateWikiNodeCreateSpecRejectsBotMyLibrarySpaceID(t *testing.T) { @@ -236,6 +288,19 @@ func TestResolveWikiNodeCreateSpaceUsesMyLibraryFallback(t *testing.T) { } } +func TestResolveWikiNodeCreateSpaceRejectsBotWithoutLocation(t *testing.T) { + t.Parallel() + + _, err := resolveWikiNodeCreateSpace(context.Background(), &fakeWikiNodeCreateClient{}, core.AsBot, wikiNodeCreateSpec{ + NodeType: wikiNodeTypeOrigin, + ObjType: "docx", + }) + if err == nil || !strings.Contains(err.Error(), "bot identity requires --space-id or --parent-node-token") { + t.Fatalf("expected bot location validation error, got %v", err) + } + requireWikiValidationParams(t, err, "--space-id", "--parent-node-token") +} + func TestRunWikiNodeCreateCreatesNodeInResolvedSpace(t *testing.T) { t.Parallel() @@ -785,7 +850,7 @@ func TestWikiNodeURL(t *testing.T) { func TestRunWikiNodeCreateRetriesOnLockContention(t *testing.T) { t.Parallel() - lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil) + lockErr := wikiTestLockContentionErr() client := &fakeWikiNodeCreateClient{ spaces: map[string]*wikiSpaceRecord{ @@ -831,7 +896,7 @@ func TestRunWikiNodeCreateRetriesOnLockContention(t *testing.T) { func TestRunWikiNodeCreateRetriesExhausted(t *testing.T) { t.Parallel() - lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil) + lockErr := wikiTestLockContentionErr() client := &fakeWikiNodeCreateClient{ spaces: map[string]*wikiSpaceRecord{ @@ -853,25 +918,30 @@ func TestRunWikiNodeCreateRetriesExhausted(t *testing.T) { if len(client.createInvoked) != 3 { t.Fatalf("create invoked %d times, want 3", len(client.createInvoked)) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected ExitError, got %T: %v", err, err) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected a typed errs.* error, got %T: %v", err, err) } - if exitErr.Detail.Code != output.LarkErrWikiLockContention { - t.Fatalf("error code = %d, want %d", exitErr.Detail.Code, output.LarkErrWikiLockContention) + if p.Code != output.LarkErrWikiLockContention { + t.Fatalf("error code = %d, want %d", p.Code, output.LarkErrWikiLockContention) } - if !strings.Contains(exitErr.Detail.Hint, "failed after 2 retries") { - t.Fatalf("hint = %q, want retry exhaustion message", exitErr.Detail.Hint) + if !strings.Contains(p.Hint, "failed after 2 retries") { + t.Fatalf("hint = %q, want retry exhaustion message", p.Hint) } - if !strings.Contains(exitErr.Detail.Hint, "lock contention") { - t.Fatalf("hint = %q, want original classification hint preserved", exitErr.Detail.Hint) + if !strings.Contains(p.Hint, "lock contention") { + t.Fatalf("hint = %q, want original classification hint preserved", p.Hint) } } func TestRunWikiNodeCreateNoRetryOnNonContentionError(t *testing.T) { t.Parallel() - otherErr := output.ErrAPI(output.LarkErrRateLimit, "rate limit", nil) // rate limit, not lock contention + // A typed API error for a different code (rate limit, not lock contention), + // mirroring what runtime.CallAPITyped produces. + otherErr := errclass.BuildAPIError( + map[string]any{"code": float64(output.LarkErrRateLimit), "msg": "rate limit"}, + errclass.ClassifyContext{}, + ) client := &fakeWikiNodeCreateClient{ spaces: map[string]*wikiSpaceRecord{ @@ -901,7 +971,7 @@ func TestRunWikiNodeCreateNoRetryOnNonContentionError(t *testing.T) { func TestRunWikiNodeCreateRetriesOnFirstLockThenSucceeds(t *testing.T) { t.Parallel() - lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil) + lockErr := wikiTestLockContentionErr() client := &fakeWikiNodeCreateClient{ spaces: map[string]*wikiSpaceRecord{ @@ -944,7 +1014,7 @@ func TestRunWikiNodeCreateRetriesOnFirstLockThenSucceeds(t *testing.T) { func TestRunWikiNodeCreateRetryContextCancelled(t *testing.T) { t.Parallel() - lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil) + lockErr := wikiTestLockContentionErr() client := &fakeWikiNodeCreateClient{ spaces: map[string]*wikiSpaceRecord{ diff --git a/shortcuts/wiki/wiki_node_delete.go b/shortcuts/wiki/wiki_node_delete.go index 800581f0..fc33c507 100644 --- a/shortcuts/wiki/wiki_node_delete.go +++ b/shortcuts/wiki/wiki_node_delete.go @@ -5,14 +5,13 @@ package wiki import ( "context" - "errors" "fmt" "net/url" "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" ) @@ -135,7 +134,7 @@ func (api wikiNodeDeleteAPI) ResolveNode(ctx context.Context, token, objType str if objType != "" && objType != "wiki" { params["obj_type"] = objType } - data, err := api.runtime.CallAPI("GET", "/open-apis/wiki/v2/spaces/get_node", params, nil) + data, err := api.runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", params, nil) if err != nil { return nil, err } @@ -143,7 +142,7 @@ func (api wikiNodeDeleteAPI) ResolveNode(ctx context.Context, token, objType str } func (api wikiNodeDeleteAPI) DeleteNode(ctx context.Context, spaceID string, spec wikiNodeDeleteSpec) (string, error) { - data, err := api.runtime.CallAPI( + data, err := api.runtime.CallAPITyped( "DELETE", fmt.Sprintf( "/open-apis/wiki/v2/spaces/%s/nodes/%s", @@ -160,7 +159,7 @@ func (api wikiNodeDeleteAPI) DeleteNode(ctx context.Context, spaceID string, spe } func (api wikiNodeDeleteAPI) GetDeleteNodeTask(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error) { - data, err := api.runtime.CallAPI( + data, err := api.runtime.CallAPITyped( "GET", fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)), map[string]interface{}{"task_type": wikiAsyncTaskTypeDeleteNode}, @@ -188,7 +187,7 @@ func readWikiNodeDeleteSpec(runtime *common.RuntimeContext) (wikiNodeDeleteSpec, func parseWikiNodeDeleteSpec(rawToken, rawObjType, rawSpaceID string, includeChildren bool) (wikiNodeDeleteSpec, error) { tokenInput := strings.TrimSpace(rawToken) if tokenInput == "" { - return wikiNodeDeleteSpec{}, output.ErrValidation("--node-token is required") + return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token is required").WithParam("--node-token") } spec := wikiNodeDeleteSpec{ @@ -200,14 +199,14 @@ func parseWikiNodeDeleteSpec(rawToken, rawObjType, rawSpaceID string, includeChi 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) + return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token URL is malformed: %q", tokenInput).WithParam("--node-token") } token, urlObjType, ok := tokenAndObjTypeFromWikiURL(u.Path) if !ok { - return wikiNodeDeleteSpec{}, output.ErrValidation( + return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --node-token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token", u.Path, - ) + ).WithParam("--node-token") } spec.NodeToken = token spec.SourceKind = "url" @@ -222,32 +221,32 @@ func parseWikiNodeDeleteSpec(rawToken, rawObjType, rawSpaceID string, includeChi case spec.ObjType == "": spec.ObjType = inferred case spec.ObjType != inferred: - return wikiNodeDeleteSpec{}, output.ErrValidation( + return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--obj-type %q does not match the obj_type %q implied by the URL path; pass only one", spec.ObjType, inferred, - ) + ).WithParam("--obj-type") } } else if strings.ContainsAny(tokenInput, "/?#") { - return wikiNodeDeleteSpec{}, output.ErrValidation( + return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token must be a raw token or a full URL; partial paths are not accepted: %q", tokenInput, - ) + ).WithParam("--node-token") } else { spec.NodeToken = tokenInput spec.SourceKind = "raw" } if spec.ObjType == "" { - return wikiNodeDeleteSpec{}, output.ErrValidation( + return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--obj-type is required (one of: %s)", strings.Join(wikiNodeDeleteObjTypes, ", "), - ) + ).WithParam("--obj-type") } if !isValidWikiDeleteObjType(spec.ObjType) { - return wikiNodeDeleteSpec{}, output.ErrValidation( + return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--obj-type %q is not valid; pick one of: %s", spec.ObjType, strings.Join(wikiNodeDeleteObjTypes, ", "), - ) + ).WithParam("--obj-type") } if err := validateOptionalResourceName(spec.NodeToken, "--node-token"); err != nil { return wikiNodeDeleteSpec{}, err @@ -405,12 +404,12 @@ func wrapWikiNodeDeleteAPIError(err error) error { if err == nil { return nil } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { + p, ok := errs.ProblemOf(err) + if !ok { return err } var hint string - switch exitErr.Detail.Code { + switch p.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: @@ -419,22 +418,11 @@ func wrapWikiNodeDeleteAPIError(err error) error { if hint == "" { return err } - if existing := strings.TrimSpace(exitErr.Detail.Hint); existing != "" { + // Append the hint in place so the typed error keeps its category / subtype / + // code / log_id (per ERROR_CONTRACT.md "propagate typed errors unchanged"). + if existing := strings.TrimSpace(p.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, - }, - } + p.Hint = hint + return err } diff --git a/shortcuts/wiki/wiki_node_delete_test.go b/shortcuts/wiki/wiki_node_delete_test.go index a580e2f9..5e4190dd 100644 --- a/shortcuts/wiki/wiki_node_delete_test.go +++ b/shortcuts/wiki/wiki_node_delete_test.go @@ -9,17 +9,17 @@ import ( "encoding/json" "errors" "net/http" - "reflect" "strings" "sync" "testing" "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/httpmock" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -357,63 +357,55 @@ func TestRunWikiNodeDeleteAsyncFailureSurfacesReason(t *testing.T) { 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", - }, - } + in := errclass.BuildAPIError( + map[string]any{"code": float64(wikiDeleteNodeErrCodeApprovalRequired), "msg": "node requires delete approval"}, + errclass.ClassifyContext{}, + ) got := wrapWikiNodeDeleteAPIError(in) - var exitErr *output.ExitError - if !errors.As(got, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected ExitError, got %T %v", got, got) + p, ok := errs.ProblemOf(got) + if !ok { + t.Fatalf("expected a typed errs.* error, 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) + if !strings.Contains(p.Hint, "delete-approval enabled") || !strings.Contains(p.Hint, "Wiki UI") { + t.Fatalf("hint = %q, want approval guidance", p.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 p.Code != wikiDeleteNodeErrCodeApprovalRequired { + t.Fatalf("hint wrapper lost the original code: %d", p.Code) } - if exitErr.Detail.Message != "node requires delete approval" { - t.Fatalf("message changed unexpectedly: %q", exitErr.Detail.Message) + if p.Message != "node requires delete approval" { + t.Fatalf("message changed unexpectedly: %q", p.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", - }, - } + in := errclass.BuildAPIError( + map[string]any{"code": float64(wikiDeleteNodeErrCodeSubtreeTooLarge), "msg": "subtree too large"}, + errclass.ClassifyContext{}, + ) got := wrapWikiNodeDeleteAPIError(in) - var exitErr *output.ExitError - if !errors.As(got, &exitErr) { - t.Fatalf("expected ExitError, got %T %v", got, got) + p, ok := errs.ProblemOf(got) + if !ok { + t.Fatalf("expected a typed errs.* error, 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) + if !strings.Contains(p.Hint, "--include-children=false") { + t.Fatalf("hint = %q, want subtree-too-large guidance", p.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"}, - } + in := errclass.BuildAPIError( + map[string]any{"code": float64(131005), "msg": "node not found"}, + errclass.ClassifyContext{}, + ) got := wrapWikiNodeDeleteAPIError(in) - if !reflect.DeepEqual(got, in) { - t.Fatalf("unknown code should pass through; got %#v", got) + if got != in { + t.Fatalf("unknown code should pass through unchanged; got %#v", got) } } diff --git a/shortcuts/wiki/wiki_node_get.go b/shortcuts/wiki/wiki_node_get.go index b7c2d6b9..8597fb3f 100644 --- a/shortcuts/wiki/wiki_node_get.go +++ b/shortcuts/wiki/wiki_node_get.go @@ -12,7 +12,7 @@ import ( "strings" "time" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) @@ -98,7 +98,7 @@ var WikiNodeGet = common.Shortcut{ 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) + data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", spec.RequestParams(), nil) if err != nil { return err } @@ -109,10 +109,10 @@ var WikiNodeGet = common.Shortcut{ } if spec.SpaceID != "" && node.SpaceID != "" && spec.SpaceID != node.SpaceID { - return output.ErrValidation( + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--space-id %q does not match the resolved node space %q (node_token=%s)", spec.SpaceID, node.SpaceID, node.NodeToken, - ) + ).WithParam("--space-id") } if spec.SpaceID != "" && node.SpaceID == "" { // The cross-check was requested but get_node returned no space_id, @@ -178,8 +178,8 @@ func resolveWikiNodeGetRawToken(nodeToken, legacyToken string) (string, error) { legacy := strings.TrimSpace(legacyToken) switch { case canonical != "" && legacy != "" && canonical != legacy: - return "", output.ErrValidation( - "--node-token and --token are both set with different values; pass --node-token only (--token is deprecated)") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, + "--node-token and --token are both set with different values; pass --node-token only (--token is deprecated)").WithParam("--token") case canonical != "": return nodeToken, nil default: @@ -193,7 +193,7 @@ func resolveWikiNodeGetRawToken(nodeToken, legacyToken string) (string, error) { func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetSpec, error) { tokenInput := strings.TrimSpace(rawToken) if tokenInput == "" { - return wikiNodeGetSpec{}, output.ErrValidation("--node-token is required") + return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token is required").WithParam("--node-token") } spec := wikiNodeGetSpec{ @@ -204,14 +204,14 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS if strings.Contains(tokenInput, "://") { u, err := url.Parse(tokenInput) if err != nil || u.Path == "" { - return wikiNodeGetSpec{}, output.ErrValidation("--node-token URL is malformed: %q", tokenInput) + return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token URL is malformed: %q", tokenInput).WithParam("--node-token") } token, urlObjType, ok := tokenAndObjTypeFromWikiURL(u.Path) if !ok { - return wikiNodeGetSpec{}, output.ErrValidation( + return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --node-token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token", u.Path, - ) + ).WithParam("--node-token") } spec.Token = token if urlObjType == "" { @@ -223,16 +223,16 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS case spec.ObjType == "" && urlObjType != "": spec.ObjType = urlObjType case spec.ObjType != "" && urlObjType != "" && spec.ObjType != urlObjType: - return wikiNodeGetSpec{}, output.ErrValidation( + return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--obj-type %q does not match the obj_type %q implied by the URL path; pass only one", spec.ObjType, urlObjType, - ) + ).WithParam("--obj-type") } } else if strings.ContainsAny(tokenInput, "/?#") { - return wikiNodeGetSpec{}, output.ErrValidation( + return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token must be a raw token or a full URL; partial paths are not accepted: %q", tokenInput, - ) + ).WithParam("--node-token") } else { spec.Token = tokenInput if looksLikeWikiNodeToken(spec.Token) { @@ -241,10 +241,10 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS // than silently passing it (the API would just ignore it, but the // mismatch signals caller confusion). if spec.ObjType != "" { - return wikiNodeGetSpec{}, output.ErrValidation( + return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--obj-type is only valid for obj_tokens; %q looks like a node_token", spec.Token, - ) + ).WithParam("--obj-type") } } else { spec.SourceKind = "raw-obj" @@ -253,10 +253,10 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS // 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( + return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--obj-type is required for a raw obj_token %q (one of: %s); or pass a typed Lark URL (e.g. /docx/) so it can be inferred", spec.Token, strings.Join(wikiNodeGetObjTypeEnum, ", "), - ) + ).WithParam("--obj-type") } } } diff --git a/shortcuts/wiki/wiki_node_list.go b/shortcuts/wiki/wiki_node_list.go index c743f232..35032df5 100644 --- a/shortcuts/wiki/wiki_node_list.go +++ b/shortcuts/wiki/wiki_node_list.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" @@ -53,7 +54,7 @@ var WikiNodeList = common.Shortcut{ // hint instead of deferring to API-time errors. Matches the contract // used by +node-create and +move. if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID { - return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit --space-id").WithParam("--space-id") } if err := validateOptionalResourceName(spaceID, "--space-id"); err != nil { return err @@ -150,7 +151,7 @@ func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[strin if pageToken != "" { params["page_token"] = pageToken } - data, err := runtime.CallAPI("GET", apiPath, params, nil) + data, err := runtime.CallAPITyped("GET", apiPath, params, nil) if err != nil { return nil, false, "", err } diff --git a/shortcuts/wiki/wiki_space_create.go b/shortcuts/wiki/wiki_space_create.go index 0b963b02..30e62ab7 100644 --- a/shortcuts/wiki/wiki_space_create.go +++ b/shortcuts/wiki/wiki_space_create.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -60,14 +60,14 @@ var WikiSpaceCreate = common.Shortcut{ fmt.Fprintf(runtime.IO().ErrOut, "Creating wiki space %q...\n", spec.Name) - data, err := runtime.CallAPI("POST", wikiSpacesAPIPath, nil, spec.RequestBody()) + data, err := runtime.CallAPITyped("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") + return errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki space create returned no space") } out := wikiSpaceCreateOutput(raw) @@ -100,7 +100,7 @@ func readWikiSpaceCreateSpec(runtime *common.RuntimeContext) (wikiSpaceCreateSpe Description: strings.TrimSpace(runtime.Str("description")), } if spec.Name == "" { - return wikiSpaceCreateSpec{}, output.ErrValidation("--name is required and cannot be blank") + return wikiSpaceCreateSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--name is required and cannot be blank").WithParam("--name") } return spec, nil } diff --git a/shortcuts/wiki/wiki_space_list.go b/shortcuts/wiki/wiki_space_list.go index 77b72459..df752d25 100644 --- a/shortcuts/wiki/wiki_space_list.go +++ b/shortcuts/wiki/wiki_space_list.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -103,7 +104,7 @@ func fetchWikiSpaces(runtime *common.RuntimeContext) ([]map[string]interface{}, if pageToken != "" { params["page_token"] = pageToken } - data, err := runtime.CallAPI("GET", wikiSpacesAPIPath, params, nil) + data, err := runtime.CallAPITyped("GET", wikiSpacesAPIPath, params, nil) if err != nil { return nil, false, "", err } @@ -181,10 +182,10 @@ func valueOrDash(v interface{}) string { // +space-list and +node-list. func validateWikiListPagination(runtime *common.RuntimeContext, maxPageSize int) error { if n := runtime.Int("page-size"); n < 1 || n > maxPageSize { - return common.FlagErrorf("--page-size must be between 1 and %d", maxPageSize) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be between 1 and %d", maxPageSize).WithParam("--page-size") } if n := runtime.Int("page-limit"); n < 0 { - return common.FlagErrorf("--page-limit must be a non-negative integer") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be a non-negative integer").WithParam("--page-limit") } return nil }