From 1943a83452a2fcf80c6f57037ac994879b04a7d0 Mon Sep 17 00:00:00 2001 From: evandance Date: Sat, 13 Jun 2026 16:36:29 +0800 Subject: [PATCH] refactor: retire legacy error envelopes and enforce typed contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate all command error reporting onto the typed errs.* contract, remove the legacy error surface that predated it, and tighten the lint guards so the contract holds across the whole repository going forward. Every failure now reaches stderr as one envelope shape: a category, an optional subtype, a human- and agent-readable message, and a recovery hint, with invalid parameters listed under `params`. The legacy ExitError envelope, its constructors, and the boundary bridge that promoted untyped config and authorization errors are deleted, leaving a single path from error to wire. Predicate commands keep their silent-exit behavior through a dedicated signal that carries only an exit code. Infrastructure paths that still emitted ad-hoc envelopes — flag parsing, unknown commands and subcommands, plugin and policy guards, confirmation prompts, and auth/config failures — now classify into the same taxonomy. Business, API, auth, and config exit codes are preserved; the one behavioral change is that Cobra usage failures (missing required flag, unknown command, bad arguments) now emit the typed validation envelope and exit 2, matching the explicit flag and subcommand guards, instead of Cobra's plain-text exit 1. Enforcement is repo-wide rather than per-path: - The errscontract guards run by default everywhere instead of through a migration allowlist, so legacy envelopes cannot be reintroduced anywhere. - errorlint runs across the whole repository: every error wrap must use %w and every comparison must use errors.Is/errors.As, so interior wraps stay legal but can no longer break the chain the typed boundary relies on. - The errs-no-bare-wrap guard is keyed by structural prefix instead of an explicit per-domain allowlist, so new shortcut domains are covered without editing a list. It runs where forbidigo is enabled (the shortcut domains and the auth/config/service command groups); repo-wide chain integrity for the remaining command paths is carried by errorlint above. --- .golangci.yml | 50 +-- cmd/api/api.go | 48 ++- cmd/api/api_test.go | 121 +++++++ cmd/auth/check_test.go | 7 +- cmd/auth/list.go | 3 +- cmd/auth/login_test.go | 17 +- cmd/completion/completion.go | 7 +- cmd/config/bind.go | 5 +- cmd/config/bind_test.go | 110 +++---- cmd/config/binder_test.go | 10 +- cmd/config/config_test.go | 67 +++- cmd/config/init.go | 50 +-- cmd/config/init_guard_test.go | 24 +- cmd/config/init_messages.go | 13 + cmd/config/init_test.go | 16 +- cmd/doctor/doctor.go | 5 +- cmd/error_auth_hint.go | 27 -- cmd/event/consume.go | 6 +- cmd/event/format_helpers_test.go | 6 +- cmd/event/status_fail_on_orphan_test.go | 10 +- cmd/flag_suggest_test.go | 56 +++- cmd/platform_bootstrap_test.go | 30 +- cmd/platform_guards.go | 132 ++------ cmd/platform_guards_test.go | 33 +- cmd/plugin_integration_test.go | 157 +++++---- cmd/profile/add.go | 34 +- cmd/profile/list.go | 3 +- cmd/profile/profile_test.go | 244 +++++++++++++- cmd/profile/remove.go | 8 +- cmd/profile/rename.go | 10 +- cmd/profile/use.go | 8 +- cmd/prune.go | 27 +- cmd/prune_test.go | 54 ++-- cmd/root.go | 299 ++++-------------- cmd/root_integration_test.go | 288 ++++++++++------- cmd/root_test.go | 296 ++++++++--------- cmd/schema/schema_test.go | 41 +++ cmd/unknown_subcommand_test.go | 157 +++------ cmd/update/update.go | 23 +- cmd/update/update_test.go | 96 +++++- errs/ERROR_CONTRACT.md | 275 +++++++--------- errs/raw.go | 29 ++ errs/raw_test.go | 96 ++++++ errs/types_test.go | 6 +- extension/platform/abort.go | 4 +- extension/platform/errors.go | 6 +- internal/auth/errors.go | 35 +- internal/auth/errors_test.go | 13 +- internal/auth/uat_client.go | 6 +- internal/client/api_errors_test.go | 18 +- internal/client/client.go | 49 ++- internal/client/client_test.go | 23 +- internal/client/response.go | 18 ++ internal/client/response_test.go | 26 ++ internal/cmdpolicy/aggregation_test.go | 68 ++-- internal/cmdpolicy/apply.go | 62 ++-- internal/cmdpolicy/engine.go | 6 +- internal/cmdpolicy/source_label_test.go | 52 ++- internal/cmdutil/confirm.go | 35 +- internal/cmdutil/confirm_test.go | 63 ++-- internal/cmdutil/factory_default_test.go | 5 +- internal/cmdutil/fileupload.go | 49 ++- internal/cmdutil/fileupload_test.go | 54 ++++ internal/cmdutil/json.go | 18 +- internal/cmdutil/json_test.go | 52 ++- internal/cmdutil/lang.go | 7 +- internal/core/config.go | 32 +- internal/core/config_test.go | 5 +- internal/core/errors.go | 22 -- internal/core/notconfigured.go | 64 ++-- internal/core/notconfigured_test.go | 64 ++-- internal/errcompat/promote.go | 48 --- internal/errcompat/promote_auth.go | 32 -- internal/errcompat/promote_auth_test.go | 79 ----- internal/errcompat/promote_test.go | 105 ------ internal/hook/install.go | 74 ++--- internal/hook/install_test.go | 53 ++-- internal/keychain/keychain.go | 11 +- internal/keychain/keychain_darwin_test.go | 14 +- .../keychain/keychain_typed_error_test.go | 58 ++++ internal/output/bare.go | 19 ++ internal/output/bare_test.go | 23 ++ internal/output/emit.go | 17 +- internal/output/emit_test.go | 14 +- internal/output/envelope.go | 52 --- internal/output/errors.go | 172 +--------- internal/output/errors_test.go | 179 +---------- internal/output/exitcode.go | 15 +- internal/output/jq.go | 20 +- internal/output/lark_errors.go | 155 +-------- internal/output/lark_errors_test.go | 105 ------ internal/output/ownership_recovery.go | 8 - internal/output/ownership_recovery_test.go | 71 ----- internal/output/print.go | 6 +- .../rule_no_legacy_common_helper_call.go | 86 ++--- .../rule_no_legacy_envelope_literal.go | 62 +--- .../rule_no_legacy_runtime_api_call.go | 34 +- lint/errscontract/rule_no_registrar.go | 4 +- .../rule_typed_error_completeness.go | 4 +- lint/errscontract/rules_test.go | 131 ++++++-- lint/errscontract/scan.go | 8 +- shortcuts/apps/apps_callapi_typed_test.go | 8 +- shortcuts/apps/apps_db_table_list_test.go | 9 +- shortcuts/base/record_upload_attachment.go | 4 +- shortcuts/calendar/calendar_test.go | 2 +- shortcuts/common/call_api_typed_test.go | 36 ++- shortcuts/common/common.go | 20 -- shortcuts/common/drive_media_upload.go | 264 ++-------------- shortcuts/common/drive_media_upload_test.go | 246 ++------------ .../common/drive_media_upload_typed_test.go | 14 +- shortcuts/common/permission_grant.go | 45 +-- shortcuts/common/permission_grant_test.go | 48 ++- shortcuts/common/runner.go | 140 ++++---- .../common/runner_partial_failure_test.go | 2 +- shortcuts/common/runner_scope_test.go | 152 +++------ shortcuts/common/validate.go | 63 +--- shortcuts/common/validate_test.go | 60 +--- shortcuts/doc/doc_media_test.go | 4 +- shortcuts/doc/doc_media_upload.go | 4 +- shortcuts/drive/drive_add_comment_test.go | 4 +- shortcuts/drive/drive_errors.go | 17 +- shortcuts/drive/drive_import_common.go | 4 +- shortcuts/drive/drive_push.go | 8 +- shortcuts/drive/drive_upload.go | 8 +- shortcuts/mail/flag_suggest.go | 2 +- shortcuts/minutes/minutes_download_test.go | 2 +- shortcuts/register.go | 4 +- shortcuts/register_brand_guard_test.go | 27 +- shortcuts/register_test.go | 8 +- .../backward/lark_sheets_float_images.go | 4 +- shortcuts/sheets/flag_schema_test.go | 17 +- shortcuts/sheets/lark_sheet_object_crud.go | 2 +- shortcuts/sheets/lark_sheet_write_cells.go | 2 +- shortcuts/slides/slides_media_upload.go | 2 +- shortcuts/task/task_shortcut_test.go | 2 +- shortcuts/task/tasklist_add_task_test.go | 3 +- shortcuts/vc/vc_notes_test.go | 2 +- .../apps/apps_access_scope_get_dryrun_test.go | 7 +- tests/cli_e2e/apps/apps_create_dryrun_test.go | 11 +- .../apps/apps_html_publish_dryrun_test.go | 8 +- tests/cli_e2e/apps/apps_update_dryrun_test.go | 4 +- tests/cli_e2e/config/bind_test.go | 24 +- tests/cli_e2e/drive/drive_push_dryrun_test.go | 14 +- 143 files changed, 2933 insertions(+), 3893 deletions(-) create mode 100644 errs/raw.go create mode 100644 errs/raw_test.go delete mode 100644 internal/core/errors.go delete mode 100644 internal/errcompat/promote.go delete mode 100644 internal/errcompat/promote_auth.go delete mode 100644 internal/errcompat/promote_auth_test.go delete mode 100644 internal/errcompat/promote_test.go create mode 100644 internal/keychain/keychain_typed_error_test.go create mode 100644 internal/output/bare.go create mode 100644 internal/output/bare_test.go delete mode 100644 internal/output/ownership_recovery.go delete mode 100644 internal/output/ownership_recovery_test.go diff --git a/.golangci.yml b/.golangci.yml index 260e4b065..8cb1557eb 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -29,11 +29,11 @@ linters: - unused # checks for unused constants, variables, functions and types - depguard # blocks forbidden package imports - forbidigo # forbids specific function calls + - errorlint # enforces error wrapping (%w) and errors.Is/As over == and type asserts # To enable later after fixing existing issues: # - errcheck # checks for unchecked errors # - errname # checks that error types are named XxxError - # - errorlint # checks error wrapping best practices # - gosec # security-oriented linter # - misspell # finds commonly misspelled English words # - staticcheck # comprehensive static analysis @@ -49,9 +49,16 @@ linters: - gocritic - depguard - forbidigo - # Paths that run forbidigo. Add an entry when a path joins one of - # the rules below. + - errorlint # tests legitimately do identity (==) and concrete type-assert checks + # forbidigo runs repo-wide (minus the boundaries below) so errs-no-bare-wrap + # has no gap. The framework bans (os/vfs, raw HTTP, fmt.Print, filepath, + # log) stay scoped to shortcuts/ + internal/ + config/auth/service via the + # next rule; elsewhere only errs-no-bare-wrap fires. + - path-except: (shortcuts/|internal/|cmd/|events/) + linters: + - forbidigo - path-except: (shortcuts/|internal/|cmd/auth/|cmd/config/|cmd/service/) + text: (vfs|IOStreams|ctx\.Out|shortcuts-no-raw-http|filepath functions|os\.Exit|structured error return) linters: - forbidigo - path: internal/vfs/ @@ -71,25 +78,14 @@ linters: text: shortcuts-no-raw-http 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/apps/|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/apps/|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/) + # errs-no-bare-wrap enforced across every command/wire boundary by + # structural prefix, so any future business domain or command is covered + # without editing an allowlist. Genuine intermediate wraps inside these + # paths use //nolint:forbidigo with a reason. + - path-except: (cmd/|shortcuts/|events/) 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/apps/|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 settings: depguard: @@ -108,22 +104,6 @@ linters: Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation. forbidigo: forbid: - # ── legacy output.Err* helpers banned on migrated paths ── - # output.ErrBare is intentionally not listed — it is the predicate- - # command silent-exit signal, outside the typed envelope contract. - - pattern: output\.(ErrValidation|ErrAuth|ErrNetwork|ErrAPI|ErrWithHint|Errorf)\b - msg: >- - [errs-typed-only] use errs.NewXxxError(...) builder - (see errs/types.go). - # ── legacy shared error helpers banned on migrated domains ── - # These helpers emit legacy output.Err* / bare error shapes or drop - # typed metadata such as Param/Cause. Migrated domains must use typed - # common replacements or local typed helpers instead. - - pattern: (common\.FlagErrorf|common\.RejectDangerousChars|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b - msg: >- - [errs-no-legacy-helper] these shared helpers emit legacy or - metadata-poor error shapes. Use typed common replacements, typed - errs.NewXxxError builders, or domain-local typed helpers. # ── bare error wraps banned on fully-typed paths ── - pattern: (fmt\.Errorf|errors\.New)\b msg: >- diff --git a/cmd/api/api.go b/cmd/api/api.go index 072117d75..fee0f7c74 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -10,6 +10,7 @@ import ( "regexp" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" @@ -123,7 +124,13 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa // stdin conflict: --params and --data cannot both read from stdin, regardless of --file. if opts.Params == "-" && opts.Data == "-" { - return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)") + return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--params and --data cannot both read from stdin (-)"). + WithHint("pass at most one flag as '-'; give the other inline JSON or @file"). + WithParams( + errs.InvalidParam{Name: "--params", Reason: "reads from stdin (-)"}, + errs.InvalidParam{Name: "--data", Reason: "reads from stdin (-)"}, + ) } params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO) @@ -153,7 +160,10 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa return client.RawApiRequest{}, nil, err } if _, ok := dataFields.(map[string]any); !ok { - return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file") + return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--data must be a JSON object when used with --file"). + WithHint(`with --file, --data carries multipart form fields, e.g. --data '{"image_type":"message"}'`). + WithParam("--data") } } @@ -196,7 +206,13 @@ func apiRun(opts *APIOptions) error { } if opts.PageAll && opts.Output != "" { - return output.ErrValidation("--output and --page-all are mutually exclusive") + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--output and --page-all are mutually exclusive"). + WithHint("drop --page-all to save a binary response, or drop --output to paginate JSON"). + WithParams( + errs.InvalidParam{Name: "--output", Reason: "conflicts with --page-all"}, + errs.InvalidParam{Name: "--page-all", Reason: "conflicts with --output"}, + ) } if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil { return err @@ -239,11 +255,11 @@ func apiRun(opts *APIOptions) error { resp, err := ac.DoAPI(opts.Ctx, request) if err != nil { - // MarkRaw tells the dispatcher to skip the legacy enrichPermissionError - // pass on *output.ExitError values. Typed *errs.* errors that flow - // through here keep their canonical message / hint from BuildAPIError; - // MarkRaw is a no-op on those (it only flips a flag on *ExitError). - return output.MarkRaw(err) + // MarkRaw tells the dispatcher to skip hint enrichment so the typed + // error keeps its canonical message / hint from BuildAPIError: the + // `api` command is a passthrough surface and the caller wants the + // original Lark response wording, not a locally enriched variant. + return errs.MarkRaw(err) } err = client.HandleResponse(resp, client.ResponseOptions{ OutputPath: opts.Output, @@ -260,10 +276,10 @@ func apiRun(opts *APIOptions) error { // the client populate identity-aware fields (ConsoleURL etc.). CheckError: ac.CheckResponse, }) - // MarkRaw: see comment above on the DoAPI path. Skips legacy - // *ExitError enrichment; typed errors flow through unchanged. + // MarkRaw: see comment above on the DoAPI path. Skips dispatcher hint + // enrichment; the typed error's own message / hint flow through unchanged. if err != nil { - return output.MarkRaw(err) + return errs.MarkRaw(err) } return nil } @@ -279,7 +295,7 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp // When jq is set, always aggregate all pages then filter. if jqExpr != "" { if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, ac.CheckResponse); err != nil { - return output.MarkRaw(err) + return errs.MarkRaw(err) } return nil } @@ -291,11 +307,11 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp pf.FormatPage(items) }, pagOpts) if err != nil { - return output.MarkRaw(err) + return errs.MarkRaw(err) } if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil { output.FormatValue(out, result, output.FormatJSON) - return output.MarkRaw(apiErr) + return errs.MarkRaw(apiErr) } if !hasItems { fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format) @@ -305,11 +321,11 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp default: result, err := ac.PaginateAll(ctx, request, pagOpts) if err != nil { - return output.MarkRaw(err) + return errs.MarkRaw(err) } if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil { output.FormatValue(out, result, output.FormatJSON) - return output.MarkRaw(apiErr) + return errs.MarkRaw(apiErr) } output.FormatValue(out, result, format) return nil diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index 393e2542c..7986338f2 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -13,6 +13,7 @@ import ( "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/spf13/cobra" ) @@ -250,6 +251,28 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) { if !strings.Contains(err.Error(), "cannot both read from stdin") { t.Errorf("expected stdin conflict error, got: %v", err) } + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } + if ve.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument) + } + requireInvalidParamNames(t, ve, "--params", "--data") +} + +// requireInvalidParamNames asserts that ve.Params carries exactly the given +// flag names (order-sensitive, mirroring declaration order at the call site). +func requireInvalidParamNames(t *testing.T, ve *errs.ValidationError, names ...string) { + t.Helper() + if len(ve.Params) != len(names) { + t.Fatalf("Params = %+v, want %d entries %v", ve.Params, len(names), names) + } + for i, name := range names { + if ve.Params[i].Name != name { + t.Errorf("Params[%d].Name = %q, want %q", i, ve.Params[i].Name, name) + } + } } func TestApiCmd_OutputAndPageAllConflict(t *testing.T) { @@ -270,6 +293,41 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) { if gotOpts != nil && !strings.Contains(err.Error(), "mutually exclusive") { t.Errorf("expected 'mutually exclusive' error, got: %v", err) } + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } + if ve.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument) + } + requireInvalidParamNames(t, ve, "--output", "--page-all") +} + +// TestApiCmd_FileDataNotObject_TypedValidation pins the typed envelope for +// the --file + non-object --data rejection: *errs.ValidationError with +// subtype invalid_argument and the offending flag on Param. +func TestApiCmd_FileDataNotObject_TypedValidation(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + cmd := NewCmdApi(f, func(opts *APIOptions) error { + return apiRun(opts) + }) + cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--data", `["not-an-object"]`}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for non-object --data with --file") + } + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } + if ve.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument) + } + if ve.Param != "--data" { + t.Errorf("Param = %q, want %q", ve.Param, "--data") + } } func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) { @@ -360,6 +418,11 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) { if !strings.Contains(stdout.String(), "no permission") { t.Errorf("expected error message in stdout, got: %s", stdout.String()) } + // --page-all errors are raw passthrough: the dispatcher must not rewrite + // the message / hint with local enrichment. + if !errs.IsRaw(err) { + t.Errorf("expected --page-all error to be marked raw, got %T: %v", err, err) + } } func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) { @@ -737,6 +800,64 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) { } } +// TestApiCmd_APIError_RawPassthrough pins the raw-passthrough contract of the +// `api` command: errors returned to the dispatcher are marked raw via +// errs.MarkRaw, so the dispatcher skips hint enrichment and the message / +// hint stay exactly what errclass.BuildAPIError derived from the Lark +// response at classification time — nothing in the api command path rewrites +// them afterwards. +func TestApiCmd_APIError_RawPassthrough(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "cli_test_raw", AppSecret: "secret", Brand: core.BrandFeishu, + }) + + respBody := map[string]interface{}{ + "code": 99991679, + "msg": "scope missing", + } + reg.Register(&httpmock.Stub{ + URL: "/open-apis/docx/v1/documents/raw", + Body: respBody, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/raw", "--as", "bot"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for non-zero code") + } + + if !errs.IsRaw(err) { + t.Fatalf("expected error to be marked raw (errs.IsRaw), got %T: %v", err, err) + } + // The raw marker must not hide the typed error from the envelope writer. + var pe *errs.PermissionError + if !errors.As(err, &pe) { + t.Fatalf("expected *errs.PermissionError through the raw marker, got %T: %v", err, err) + } + + // Canonical baseline: classify the same response body the same way + // CheckResponse does. Message and hint surfaced by the command must equal + // the classification-time values byte for byte. + canonical := errclass.BuildAPIError(respBody, errclass.ClassifyContext{ + Brand: "feishu", AppID: "cli_test_raw", Identity: "bot", + }) + var want *errs.PermissionError + if !errors.As(canonical, &want) { + t.Fatalf("expected canonical *errs.PermissionError, got %T: %v", canonical, canonical) + } + if pe.Message != want.Message { + t.Errorf("Message = %q, want canonical %q (raw mode must not rewrite it)", pe.Message, want.Message) + } + if pe.Hint != want.Hint { + t.Errorf("Hint = %q, want canonical %q (raw mode must not rewrite it)", pe.Hint, want.Hint) + } + // The dispatcher-side scope enrichment string must never appear in raw mode. + if strings.Contains(pe.Hint, "current command requires scope(s)") { + t.Errorf("hint was rewritten by enrichment in raw mode: %q", pe.Hint) + } +} + func TestApiCmd_JsonFlag_Accepted(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, diff --git a/cmd/auth/check_test.go b/cmd/auth/check_test.go index ea69174cb..708e2925a 100644 --- a/cmd/auth/check_test.go +++ b/cmd/auth/check_test.go @@ -33,12 +33,9 @@ func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) { if got := output.ExitCodeOf(err); got != 1 { t.Errorf("exit code = %d, want 1 (predicate 'missing' signal)", got) } - var bare *output.ExitError + var bare *output.BareError if !errors.As(err, &bare) { - t.Fatalf("expected *output.ExitError (ErrBare), got %T: %v", err, err) - } - if bare.Detail != nil { - t.Errorf("ErrBare must carry no Detail (no envelope), got %+v", bare.Detail) + t.Fatalf("expected *output.BareError (ErrBare), got %T: %v", err, err) } if stderr.Len() != 0 { diff --git a/cmd/auth/list.go b/cmd/auth/list.go index d92a028cb..f345200a4 100644 --- a/cmd/auth/list.go +++ b/cmd/auth/list.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" @@ -59,7 +60,7 @@ func authListRun(opts *ListOptions) error { // keep the same contract here. We still want the hint to be // workspace-aware, so we pull the message+hint out of // NotConfiguredError() instead of hard-coding it. - var cfgErr *core.ConfigError + var cfgErr *errs.ConfigError if errors.As(core.NotConfiguredError(), &cfgErr) { fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message) if cfgErr.Hint != "" { diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index d063c3350..f2aa3389a 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -878,7 +878,7 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) { // contract that when --json is set and pollDeviceToken returns OK=false, // stdout carries the structured authorization_failed event and stderr is // NOT polluted with a typed envelope. The returned error is a bare -// ExitError with ExitAuth so the dispatcher only propagates the exit code +// BareError with ExitAuth so the dispatcher only propagates the exit code // without emitting a second envelope on top of the JSON event. func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) { keyring.MockInit() @@ -945,16 +945,13 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) { t.Errorf("stderr should not contain JSON envelope fields, got: %s", stderrStr) } - // Returned error must be the bare *output.ExitError signal (no envelope). - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + // Returned error must be the bare *output.BareError signal (no envelope). + var bareErr *output.BareError + if !errors.As(err, &bareErr) { + t.Fatalf("expected *output.BareError, got %T: %v", err, err) } - if exitErr.Code != output.ExitAuth { - t.Fatalf("ExitError.Code = %d, want %d", exitErr.Code, output.ExitAuth) - } - if exitErr.Detail != nil { - t.Errorf("ExitError.Detail should be nil for bare signal, got: %+v", exitErr.Detail) + if bareErr.Code != output.ExitAuth { + t.Fatalf("BareError.Code = %d, want %d", bareErr.Code, output.ExitAuth) } } diff --git a/cmd/completion/completion.go b/cmd/completion/completion.go index a7187bb33..3b2a4b7bd 100644 --- a/cmd/completion/completion.go +++ b/cmd/completion/completion.go @@ -4,8 +4,7 @@ package completion import ( - "fmt" - + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/spf13/cobra" ) @@ -32,7 +31,9 @@ func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command { case "powershell": return root.GenPowerShellCompletionWithDesc(out) default: - return fmt.Errorf("unsupported shell: %s", args[0]) + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "unsupported shell: %s", args[0]). + WithHint("supported shells: bash, zsh, fish, powershell") } }, } diff --git a/cmd/config/bind.go b/cmd/config/bind.go index 2ec7843f2..67916b17e 100644 --- a/cmd/config/bind.go +++ b/cmd/config/bind.go @@ -212,10 +212,7 @@ func finalizeSource(opts *BindOptions) (string, error) { if opts.IsTUI && !opts.langExplicit { lang, err := promptLangSelection() if err != nil { - if err == huh.ErrUserAborted { - return "", output.ErrBare(1) - } - return "", output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err) + return "", langSelectionError(err) } opts.Lang = string(lang) opts.UILang = lang diff --git a/cmd/config/bind_test.go b/cmd/config/bind_test.go index 0711e1057..f154bfb3f 100644 --- a/cmd/config/bind_test.go +++ b/cmd/config/bind_test.go @@ -20,35 +20,29 @@ import ( "github.com/larksuite/cli/internal/output" ) -// assertExitError checks the full structured error in one assertion. It -// accepts both *output.ExitError (used by output.ErrWithHint) and the -// typed errors (ValidationError, ConfigError) — they normalize to the same -// wantDetail fields. The wantDetail.Type is matched against the typed error's -// Category string ("validation", "config", etc.). -func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) { +// wantErrDetail is the normalized comparison shape for a typed error's wire +// fields: Type is the error's Category string ("validation", "config", ...), +// alongside Message and Hint. +type wantErrDetail struct { + Type string + Message string + Hint string +} + +// assertExitError checks the full structured error in one assertion against a +// typed error (ValidationError or ConfigError), normalizing its Category / +// Message / Hint to wantDetail. +func assertExitError(t *testing.T, err error, wantCode int, wantDetail wantErrDetail) { t.Helper() if err == nil { t.Fatal("expected error, got nil") } - var exitErr *output.ExitError - if errors.As(err, &exitErr) { - if exitErr.Code != wantCode { - t.Errorf("exit code = %d, want %d", exitErr.Code, wantCode) - } - if exitErr.Detail == nil { - t.Fatal("expected non-nil error detail") - } - if !reflect.DeepEqual(*exitErr.Detail, wantDetail) { - t.Errorf("error detail mismatch:\n got: %+v\n want: %+v", *exitErr.Detail, wantDetail) - } - return - } var ve *errs.ValidationError if errors.As(err, &ve) { if got := output.ExitCodeOf(err); got != wantCode { t.Errorf("exit code = %d, want %d", got, wantCode) } - gotDetail := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint} + gotDetail := wantErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint} if !reflect.DeepEqual(gotDetail, wantDetail) { t.Errorf("validation error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail) } @@ -59,13 +53,13 @@ func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.Er if got := output.ExitCodeOf(err); got != wantCode { t.Errorf("exit code = %d, want %d", got, wantCode) } - gotDetail := output.ErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint} + gotDetail := wantErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint} if !reflect.DeepEqual(gotDetail, wantDetail) { t.Errorf("config error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail) } return } - t.Fatalf("error type = %T, want *output.ExitError or *errs.ValidationError / *errs.ConfigError; error = %v", err, err) + t.Fatalf("error type = %T, want *errs.ValidationError / *errs.ConfigError; error = %v", err, err) } // assertEnvelope decodes stdout and checks it matches want exactly — every key @@ -179,15 +173,21 @@ func TestConfigBindRun_InvalidLang(t *testing.T) { if err == nil { t.Fatalf("expected validation error for --lang %q, got nil", tc.lang) } - exitErr, ok := err.(*output.ExitError) - if !ok { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + var valErr *errs.ValidationError + if !errors.As(err, &valErr) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } + if valErr.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument) + } + if valErr.Param != "--lang" { + t.Errorf("param = %q, want %q", valErr.Param, "--lang") } - if exitErr.Code != output.ExitValidation { - t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation) + if got := output.ExitCodeOf(err); got != output.ExitValidation { + t.Errorf("exit code = %d, want %d (validation)", got, output.ExitValidation) } - if !strings.Contains(exitErr.Error(), "invalid --lang") { - t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error()) + if !strings.Contains(err.Error(), "invalid --lang") { + t.Errorf("error message %q does not contain 'invalid --lang'", err.Error()) } }) } @@ -365,7 +365,7 @@ func TestConfigBindRun_InvalidSource(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "invalid"}) - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ + assertExitError(t, err, output.ExitValidation, wantErrDetail{ Type: "validation", Message: `invalid --source "invalid"; valid values: openclaw, hermes, lark-channel`, }) @@ -382,7 +382,7 @@ func TestConfigBindRun_MissingSourceNonTTY(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) // TestFactory has IsTerminal=false by default err := configBindRun(&BindOptions{Factory: f, Source: ""}) - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ + assertExitError(t, err, output.ExitValidation, wantErrDetail{ Type: "validation", Message: "cannot determine Agent source: no --source flag and no Agent environment detected", Hint: "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context", @@ -421,7 +421,7 @@ func TestConfigBindRun_SourceEnvMismatch_OpenClawFlagInHermesEnv(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"}) - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ + assertExitError(t, err, output.ExitValidation, wantErrDetail{ Type: "validation", Message: `--source "openclaw" does not match detected Agent environment (hermes)`, Hint: "remove --source to auto-detect, or run this command in the correct Agent context", @@ -437,7 +437,7 @@ func TestConfigBindRun_SourceEnvMismatch_HermesFlagInOpenClawEnv(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "hermes"}) - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ + assertExitError(t, err, output.ExitValidation, wantErrDetail{ Type: "validation", Message: `--source "hermes" does not match detected Agent environment (openclaw)`, Hint: "remove --source to auto-detect, or run this command in the correct Agent context", @@ -566,7 +566,7 @@ func TestConfigBindRun_HermesMissingEnvFile(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "hermes"}) envPath := filepath.Join(hermesHome, ".env") - assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + assertExitError(t, err, output.ExitAuth, wantErrDetail{ Type: "config", Message: "failed to read Hermes config: open " + envPath + ": no such file or directory", Hint: "verify Hermes is installed and configured at " + envPath, @@ -584,7 +584,7 @@ func TestConfigBindRun_OpenClawMissingFile(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"}) configPath := filepath.Join(openclawHome, ".openclaw", "openclaw.json") - assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + assertExitError(t, err, output.ExitAuth, wantErrDetail{ Type: "config", Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory", Hint: "verify OpenClaw is installed and configured", @@ -731,7 +731,7 @@ func TestConfigBindRun_SourceEnvMismatch_LarkChannelFlagInOpenClawEnv(t *testing f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}) - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ + assertExitError(t, err, output.ExitValidation, wantErrDetail{ Type: "validation", Message: `--source "lark-channel" does not match detected Agent environment (openclaw)`, Hint: "remove --source to auto-detect, or run this command in the correct Agent context", @@ -750,7 +750,7 @@ func TestConfigBindRun_LarkChannelMissingFile(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}) configPath := filepath.Join(fakeHome, ".lark-channel", "config.json") - assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + assertExitError(t, err, output.ExitAuth, wantErrDetail{ Type: "config", Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory", Hint: "verify lark-channel-bridge is installed and configured", @@ -770,7 +770,7 @@ func TestConfigBindRun_LarkChannelEmptyAppID(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}) - assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + assertExitError(t, err, output.ExitAuth, wantErrDetail{ Type: "config", Message: "accounts.app.id missing in " + configPath, Hint: "run lark-channel-bridge's setup to populate the app credential", @@ -789,7 +789,7 @@ func TestConfigBindRun_LarkChannelEmptySecret(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}) - assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + assertExitError(t, err, output.ExitAuth, wantErrDetail{ Type: "config", Message: "accounts.app.secret is empty in " + configPath, Hint: "run lark-channel-bridge's setup to populate the app credential", @@ -835,17 +835,19 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) { t.Fatal("expected error for unbound workspace") } // Should be a structured ConfigError suggesting config bind, not config init. - var cfgErr *core.ConfigError + var cfgErr *errs.ConfigError if !errors.As(err, &cfgErr) { - t.Fatalf("error type = %T, want *core.ConfigError", err) + t.Fatalf("error type = %T, want *errs.ConfigError", err) } // Config errors share ExitAuth (3); the workspace is detected but no // binding exists yet, which is a config error. - if cfgErr.Code != output.ExitAuth { - t.Errorf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth) + if got := output.ExitCodeOf(err); got != output.ExitAuth { + t.Errorf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth) } - if cfgErr.Type != "openclaw" { - t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw") + // The workspace name stays out of the wire subtype; it only appears in + // the message. + if cfgErr.Subtype != errs.SubtypeNotConfigured { + t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype) } if !strings.Contains(cfgErr.Message, "openclaw context detected") { t.Errorf("message missing 'openclaw context detected': %q", cfgErr.Message) @@ -1187,7 +1189,7 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) { // iterates a map — ordering is non-deterministic. DeepEqual inline against // each accepted variant so every ErrDetail field (Type, Code, Message, // Hint, ConsoleURL, Detail, and any future addition) is still compared. - base := output.ErrDetail{ + base := wantErrDetail{ Type: "validation", Message: "multiple accounts in openclaw.json; pass --app-id ", } @@ -1203,7 +1205,7 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) { if !errors.As(err, &ve) { t.Fatalf("error type = %T, want *errs.ValidationError; err = %v", err, err) } - got := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint} + got := wantErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint} if !reflect.DeepEqual(got, wantWorkFirst) && !reflect.DeepEqual(got, wantPersonalFirst) { t.Errorf("error detail did not match any accepted variant:\n got: %+v\n want: %+v OR %+v", got, wantWorkFirst, wantPersonalFirst) @@ -1230,7 +1232,7 @@ func TestConfigBindRun_OpenClawMultiAccount_WrongAppID(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", AppID: "nonexistent"}) - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ + assertExitError(t, err, output.ExitValidation, wantErrDetail{ Type: "validation", Message: `--app-id "nonexistent" not found in openclaw.json`, Hint: "available app IDs:\n cli_only_one", @@ -1250,7 +1252,7 @@ func TestConfigBindRun_InvalidIdentity(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "hermes", Identity: "invalid"}) - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ + assertExitError(t, err, output.ExitValidation, wantErrDetail{ Type: "validation", Message: `invalid --identity "invalid"; valid values: bot-only, user-default`, }) @@ -1536,7 +1538,7 @@ func TestConfigBindRun_HermesMissingAppID(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "hermes"}) envPath := filepath.Join(hermesHome, ".env") - assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + assertExitError(t, err, output.ExitAuth, wantErrDetail{ Type: "config", Message: "FEISHU_APP_ID not found in " + envPath, Hint: "run 'hermes setup' to configure Feishu credentials", @@ -1556,7 +1558,7 @@ func TestConfigBindRun_HermesMissingAppSecret(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "hermes"}) envPath := filepath.Join(hermesHome, ".env") - assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + assertExitError(t, err, output.ExitAuth, wantErrDetail{ Type: "config", Message: "FEISHU_APP_SECRET not found in " + envPath, Hint: "run 'hermes setup' to configure Feishu credentials", @@ -1582,7 +1584,7 @@ func TestConfigBindRun_OpenClawMissingFeishu(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"}) - assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + assertExitError(t, err, output.ExitAuth, wantErrDetail{ Type: "config", Message: "openclaw.json missing channels.feishu section", Hint: "configure Feishu in OpenClaw first", @@ -1610,7 +1612,7 @@ func TestConfigBindRun_OpenClawEmptyAppSecret(t *testing.T) { openclawPath := filepath.Join(openclawDir, "openclaw.json") f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"}) - assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + assertExitError(t, err, output.ExitAuth, wantErrDetail{ Type: "config", Message: "appSecret is empty for app cli_no_secret in " + openclawPath, Hint: "configure channels.feishu.appSecret in openclaw.json", @@ -1672,7 +1674,7 @@ func TestConfigBindRun_OpenClawDisabledAccount(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"}) - assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + assertExitError(t, err, output.ExitAuth, wantErrDetail{ Type: "config", Message: "no Feishu app configured in openclaw.json", Hint: "configure channels.feishu.appId in openclaw.json", diff --git a/cmd/config/binder_test.go b/cmd/config/binder_test.go index 6f1df1313..ee7c7e6de 100644 --- a/cmd/config/binder_test.go +++ b/cmd/config/binder_test.go @@ -51,7 +51,7 @@ func assertCandidate(t *testing.T, got *Candidate, want Candidate) { func TestSelectCandidate_ZeroCandidates_OpenClaw(t *testing.T) { b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"} _, err := selectCandidate(b, nil, "", false, tuiUnreachable(t)) - assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + assertExitError(t, err, output.ExitAuth, wantErrDetail{ Type: "config", Message: "no Feishu app configured in openclaw.json", Hint: "configure channels.feishu.appId in openclaw.json", @@ -64,7 +64,7 @@ func TestSelectCandidate_ZeroCandidates_GenericSource(t *testing.T) { // even before it has a bespoke error message. b := &fakeBinder{name: "hermes", path: "/tmp/.env"} _, err := selectCandidate(b, nil, "", false, tuiUnreachable(t)) - assertExitError(t, err, output.ExitAuth, output.ErrDetail{ + assertExitError(t, err, output.ExitAuth, wantErrDetail{ Type: "config", Message: "hermes: no app configured", }) @@ -100,7 +100,7 @@ func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) { {AppID: "cli_home", Label: "home"}, } _, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t)) - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ + assertExitError(t, err, output.ExitValidation, wantErrDetail{ Type: "validation", Message: `--app-id "nonexistent" not found in openclaw.json`, Hint: "available app IDs:\n cli_work (work)\n cli_home (home)", @@ -117,7 +117,7 @@ func TestSelectCandidate_MultiCandidate_NoFlag_NonTUI(t *testing.T) { {AppID: "cli_home", Label: "home"}, } _, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t)) - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ + assertExitError(t, err, output.ExitValidation, wantErrDetail{ Type: "validation", Message: "multiple accounts in openclaw.json; pass --app-id ", Hint: "available app IDs:\n cli_work (work)\n cli_home (home)", @@ -152,7 +152,7 @@ func TestSelectCandidate_SingleCandidate_WrongFlag(t *testing.T) { b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"} candidates := []Candidate{{AppID: "cli_only"}} _, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t)) - assertExitError(t, err, output.ExitValidation, output.ErrDetail{ + assertExitError(t, err, output.ExitValidation, wantErrDetail{ Type: "validation", Message: `--app-id "nonexistent" not found in openclaw.json`, Hint: "available app IDs:\n cli_only", diff --git a/cmd/config/config_test.go b/cmd/config/config_test.go index 04645c4ea..9f0f3da62 100644 --- a/cmd/config/config_test.go +++ b/cmd/config/config_test.go @@ -12,6 +12,7 @@ import ( "strings" "testing" + "github.com/larksuite/cli/errs" extcred "github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" @@ -92,16 +93,16 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) { t.Fatal("expected error") } - var cfgErr *core.ConfigError + var cfgErr *errs.ConfigError if !errors.As(err, &cfgErr) { - t.Fatalf("error type = %T, want *core.ConfigError", err) + t.Fatalf("error type = %T, want *errs.ConfigError", err) } // Config errors share ExitAuth (3), not ExitValidation. - if cfgErr.Code != output.ExitAuth { - t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth) + if got := output.ExitCodeOf(err); got != output.ExitAuth { + t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth) } - if cfgErr.Type != "config" || cfgErr.Message != "not configured" { - t.Fatalf("detail = %+v, want config/not configured", cfgErr) + if cfgErr.Subtype != errs.SubtypeNotConfigured || cfgErr.Message != "not configured" { + t.Fatalf("detail = %+v, want not_configured/not configured", cfgErr) } } @@ -233,15 +234,21 @@ func TestConfigInitCmd_InvalidLang(t *testing.T) { if err == nil { t.Fatalf("expected validation error for --lang %q, got nil", tc.lang) } - exitErr, ok := err.(*output.ExitError) - if !ok { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + var valErr *errs.ValidationError + if !errors.As(err, &valErr) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) } - if exitErr.Code != output.ExitValidation { - t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation) + if valErr.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument) } - if !strings.Contains(exitErr.Error(), "invalid --lang") { - t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error()) + if valErr.Param != "--lang" { + t.Errorf("param = %q, want %q", valErr.Param, "--lang") + } + if got := output.ExitCodeOf(err); got != output.ExitValidation { + t.Errorf("exit code = %d, want %d (validation)", got, output.ExitValidation) + } + if !strings.Contains(err.Error(), "invalid --lang") { + t.Errorf("error message %q does not contain 'invalid --lang'", err.Error()) } }) } @@ -385,8 +392,38 @@ func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T if err == nil { t.Fatal("expected conflict error") } - if !strings.Contains(err.Error(), "conflicts with existing appId") { - t.Fatalf("error = %v, want conflict with existing appId", err) + // A name/appId conflict is user input — a typed validation error naming the + // offending flag, not a system storage failure. + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("error type = %T, want *errs.ValidationError; err=%v", err, err) + } + if verr.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype = %q, want invalid_argument", verr.Subtype) + } + if verr.Param != "--name" { + t.Errorf("param = %q, want --name", verr.Param) + } + if output.ExitCodeOf(err) != output.ExitValidation { + t.Errorf("exit code = %d, want %d (validation)", output.ExitCodeOf(err), output.ExitValidation) + } + if !strings.Contains(verr.Message, "conflicts with existing appId") { + t.Errorf("message = %q, want conflict description", verr.Message) + } +} + +// TestWrapSaveConfigError_PassesTypedValidationThrough pins that a user-input +// validation error (e.g. the --name conflict) is not reclassified as an +// internal storage failure on its way up through the save call sites. +func TestWrapSaveConfigError_PassesTypedValidationThrough(t *testing.T) { + conflict := errs.NewValidationError(errs.SubtypeInvalidArgument, "name conflict").WithParam("--name") + var verr *errs.ValidationError + if !errors.As(wrapSaveConfigError(conflict), &verr) { + t.Fatalf("typed validation must pass through unchanged, got %T", wrapSaveConfigError(conflict)) + } + var ierr *errs.InternalError + if !errors.As(wrapSaveConfigError(errors.New("disk full")), &ierr) || ierr.Subtype != errs.SubtypeStorage { + t.Fatalf("untyped failure must become internal/storage") } } diff --git a/cmd/config/init.go b/cmd/config/init.go index de8f7b355..544c4c60b 100644 --- a/cmd/config/init.go +++ b/cmd/config/init.go @@ -6,13 +6,11 @@ package config import ( "bufio" "context" - "errors" "fmt" "io" "os" "strings" - "github.com/charmbracelet/huh" "github.com/spf13/cobra" "github.com/larksuite/cli/errs" @@ -127,12 +125,9 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error { if ws.IsLocal() { return nil } - return &core.ConfigError{ - Code: 2, - Type: ws.Display(), - Message: fmt.Sprintf("config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()), - Hint: "see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.", - } + return errs.NewConfigError(errs.SubtypeNotConfigured, + "config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()). + WithHint("see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.") } // hasAnyNonInteractiveFlag returns true if any non-interactive flag is set. @@ -183,6 +178,20 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior))) } +// wrapSaveConfigError passes an already-typed error (e.g. the --name conflict +// validation error from saveAsProfile) through unchanged, and classifies any +// other failure as an internal storage error. Without the passthrough a user +// input error would surface to agents as a system storage failure. +func wrapSaveConfigError(err error) error { + if err == nil { + return nil + } + if _, ok := errs.ProblemOf(err); ok { + return err + } + return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) +} + // saveAsProfile appends or updates a named profile in the config. // If a profile with the same name exists, it updates it; otherwise appends. // When updating, cleans up old keychain secrets if AppId changed. @@ -207,7 +216,9 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang) } else { if findAppIndexByAppID(multi, profileName) >= 0 { - return fmt.Errorf("profile name %q conflicts with existing appId", profileName) + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "profile name %q conflicts with existing appId", profileName). + WithParam("--name") } // Append new profile multi.Apps = append(multi.Apps, core.AppConfig{ @@ -249,8 +260,8 @@ func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int { // wrapUpdateExistingProfileErr classifies the error returned by // updateExistingProfileWithoutSecret. Typed errors (e.g. *errs.ValidationError // for blank-input) pass through unchanged so their exit code semantics -// survive; legacy *output.ExitError also passes through; everything else -// (filesystem, keychain, etc.) is wrapped as InternalError. +// survive; everything else (filesystem, keychain, etc.) is wrapped as +// InternalError. func wrapUpdateExistingProfileErr(err error) error { if err == nil { return nil @@ -258,10 +269,6 @@ func wrapUpdateExistingProfileErr(err error) error { if errs.IsTyped(err) { return err } - var exitErr *output.ExitError - if errors.As(err, &exitErr) { - return err - } return errs.NewInternalError(errs.SubtypeSDKError, "failed to save config: %v", err).WithCause(err) } @@ -336,7 +343,7 @@ func configInitRun(opts *ConfigInitOptions) error { return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) } if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil { - return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) + return wrapSaveConfigError(err) } output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) printLangPreferenceConfirmation(opts) @@ -353,10 +360,7 @@ func configInitRun(opts *ConfigInitOptions) error { if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() { lang, err := promptLangSelection() if err != nil { - if err == huh.ErrUserAborted { - return output.ErrBare(1) - } - return output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err) + return langSelectionError(err) } opts.Lang = string(lang) opts.UILang = lang @@ -379,7 +383,7 @@ func configInitRun(opts *ConfigInitOptions) error { return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) } if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil { - return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) + return wrapSaveConfigError(err) } printLangPreferenceConfirmation(opts) output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand}) @@ -409,7 +413,7 @@ func configInitRun(opts *ConfigInitOptions) error { return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) } if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil { - return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) + return wrapSaveConfigError(err) } } else if result.Mode == "existing" && result.AppID != "" { // Existing app with unchanged secret — update app ID and brand only @@ -514,7 +518,7 @@ func configInitRun(opts *ConfigInitOptions) error { return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) } if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil { - return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) + return wrapSaveConfigError(err) } output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) printLangPreferenceConfirmation(opts) diff --git a/cmd/config/init_guard_test.go b/cmd/config/init_guard_test.go index ee82e3672..33ff69bcd 100644 --- a/cmd/config/init_guard_test.go +++ b/cmd/config/init_guard_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/errs" ) func TestGuardAgentWorkspace_LocalAllows(t *testing.T) { @@ -26,12 +26,15 @@ func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) { if err == nil { t.Fatal("expected refusal in OpenClaw context, got nil") } - var cfgErr *core.ConfigError + var cfgErr *errs.ConfigError if !errors.As(err, &cfgErr) { - t.Fatalf("error type = %T, want *core.ConfigError", err) + t.Fatalf("error type = %T, want *errs.ConfigError", err) } - if cfgErr.Type != "openclaw" { - t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw") + if cfgErr.Subtype != errs.SubtypeNotConfigured { + t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype) + } + if !strings.Contains(cfgErr.Message, "openclaw") { + t.Errorf("message must name the openclaw workspace; got %q", cfgErr.Message) } if !strings.Contains(cfgErr.Hint, "config bind --help") { t.Errorf("hint must point to config bind --help; got %q", cfgErr.Hint) @@ -48,12 +51,15 @@ func TestGuardAgentWorkspace_HermesRefuses(t *testing.T) { if err == nil { t.Fatal("expected refusal in Hermes context, got nil") } - var cfgErr *core.ConfigError + var cfgErr *errs.ConfigError if !errors.As(err, &cfgErr) { - t.Fatalf("error type = %T, want *core.ConfigError", err) + t.Fatalf("error type = %T, want *errs.ConfigError", err) + } + if cfgErr.Subtype != errs.SubtypeNotConfigured { + t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype) } - if cfgErr.Type != "hermes" { - t.Errorf("type = %q, want %q", cfgErr.Type, "hermes") + if !strings.Contains(cfgErr.Message, "hermes") { + t.Errorf("message must name the hermes workspace; got %q", cfgErr.Message) } } diff --git a/cmd/config/init_messages.go b/cmd/config/init_messages.go index 27dbde1f7..d8bb75df7 100644 --- a/cmd/config/init_messages.go +++ b/cmd/config/init_messages.go @@ -4,10 +4,14 @@ package config import ( + "errors" + "github.com/charmbracelet/huh" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/i18n" + "github.com/larksuite/cli/internal/output" ) type initMsg struct { @@ -97,3 +101,12 @@ func promptLangSelection() (i18n.Lang, error) { } return lang, nil } + +// langSelectionError maps a promptLangSelection failure to its exit surface: +// user abort exits bare with code 1; any other failure is internal. +func langSelectionError(err error) error { + if errors.Is(err, huh.ErrUserAborted) { + return output.ErrBare(1) + } + return errs.NewInternalError(errs.SubtypeUnknown, "language selection failed: %v", err).WithCause(err) +} diff --git a/cmd/config/init_test.go b/cmd/config/init_test.go index 7df9bbee7..7347b8b52 100644 --- a/cmd/config/init_test.go +++ b/cmd/config/init_test.go @@ -65,8 +65,8 @@ func TestUpdateExistingProfileWithoutSecret_AppIdMismatch_EmitsValidationError(t // wrapUpdateExistingProfileErr is the caller-side classifier for the error // returned by updateExistingProfileWithoutSecret. It must preserve typed-error -// exit semantics (regression: typed ValidationError was being downgraded to -// InternalError by the legacy *output.ExitError-only passthrough). +// exit semantics: a typed ValidationError must keep ExitValidation rather than +// being downgraded to InternalError. func TestWrapUpdateExistingProfileErr_NilPassesThrough(t *testing.T) { if got := wrapUpdateExistingProfileErr(nil); got != nil { @@ -90,18 +90,6 @@ func TestWrapUpdateExistingProfileErr_TypedValidationErrorPreserved(t *testing.T } } -func TestWrapUpdateExistingProfileErr_LegacyExitErrorPreserved(t *testing.T) { - in := &output.ExitError{Code: 7, Err: errors.New("legacy")} - got := wrapUpdateExistingProfileErr(in) - var exitErr *output.ExitError - if !errors.As(got, &exitErr) { - t.Fatalf("expected *output.ExitError to pass through, got %T: %v", got, got) - } - if exitErr.Code != 7 { - t.Errorf("Code = %d, want 7", exitErr.Code) - } -} - func TestWrapUpdateExistingProfileErr_UntypedErrorBecomesInternal(t *testing.T) { in := fmt.Errorf("disk full") got := wrapUpdateExistingProfileErr(in) diff --git a/cmd/doctor/doctor.go b/cmd/doctor/doctor.go index 2a5265b12..a7ba30a4e 100644 --- a/cmd/doctor/doctor.go +++ b/cmd/doctor/doctor.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" @@ -94,7 +95,7 @@ func doctorRun(opts *DoctorOptions) error { // underlying problem is still visible. msg, hint := err.Error(), "" if errors.Is(err, os.ErrNotExist) { - var cfgErr *core.ConfigError + var cfgErr *errs.ConfigError if errors.As(core.NotConfiguredError(), &cfgErr) { msg, hint = cfgErr.Message, cfgErr.Hint } @@ -108,7 +109,7 @@ func doctorRun(opts *DoctorOptions) error { cfg, err := f.Config() if err != nil { hint := "" - var cfgErr *core.ConfigError + var cfgErr *errs.ConfigError if errors.As(err, &cfgErr) { hint = cfgErr.Hint } diff --git a/cmd/error_auth_hint.go b/cmd/error_auth_hint.go index 1c3f37e68..b25dec07c 100644 --- a/cmd/error_auth_hint.go +++ b/cmd/error_auth_hint.go @@ -15,7 +15,6 @@ import ( internalauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/shortcuts" shortcutcommon "github.com/larksuite/cli/shortcuts/common" @@ -49,32 +48,6 @@ func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) { authErr.Hint += "\n" + scopeHint } -// enrichMissingScopeError appends a "current command requires scope(s): X" -// hint to a legacy *output.ExitError when the underlying error carries the -// need_user_authorization marker AND the current command declares scopes -// locally. -// -// Deprecated: enrichment for the legacy envelope; the typed path is -// applyNeedAuthorizationHint above. -func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) { - if exitErr == nil || exitErr.Detail == nil { - return - } - if !internalauth.IsNeedUserAuthorizationError(exitErr) { - return - } - scopes := resolveDeclaredScopesForCurrentCommand(f) - if len(scopes) == 0 { - return - } - scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", ")) - if exitErr.Detail.Hint == "" { - exitErr.Detail.Hint = scopeHint - return - } - exitErr.Detail.Hint += "\n" + scopeHint -} - // resolveDeclaredScopesForCurrentCommand returns the scopes declared by the // current command for the resolved identity, checking shortcuts first and then // service methods from local registry metadata. diff --git a/cmd/event/consume.go b/cmd/event/consume.go index 376384686..21ded3f9c 100644 --- a/cmd/event/consume.go +++ b/cmd/event/consume.go @@ -386,9 +386,9 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) ( // Sentinels for errors.Is checks; call sites wrap them as typed ValidationError causes. var ( - errInvalidParamFormat = errors.New("invalid --param format") - errOutputDirTilde = errors.New("--output-dir does not support ~ expansion") - errOutputDirUnsafe = errors.New("unsafe --output-dir") + errInvalidParamFormat = errors.New("invalid --param format") //nolint:forbidigo // sentinel, typed at call sites + errOutputDirTilde = errors.New("--output-dir does not support ~ expansion") //nolint:forbidigo // sentinel, typed at call sites + errOutputDirUnsafe = errors.New("unsafe --output-dir") //nolint:forbidigo // sentinel, typed at call sites ) func parseParams(raw []string) (map[string]string, error) { diff --git a/cmd/event/format_helpers_test.go b/cmd/event/format_helpers_test.go index a9aaf694e..5e4117b8a 100644 --- a/cmd/event/format_helpers_test.go +++ b/cmd/event/format_helpers_test.go @@ -270,15 +270,15 @@ func TestExitForOrphan(t *testing.T) { if err == nil { t.Fatal("flag on + orphan → expected error, got nil") } - var exit *output.ExitError + var exit *output.BareError if !errorAs(err, &exit) || exit.Code != output.ExitValidation { t.Errorf("exit code = %v, want ExitValidation", err) } } func errorAs(err error, target interface{}) bool { - if e, ok := err.(*output.ExitError); ok { - if t, ok := target.(**output.ExitError); ok { + if e, ok := err.(*output.BareError); ok { + if t, ok := target.(**output.BareError); ok { *t = e return true } diff --git a/cmd/event/status_fail_on_orphan_test.go b/cmd/event/status_fail_on_orphan_test.go index 13bf04a6e..f0e475910 100644 --- a/cmd/event/status_fail_on_orphan_test.go +++ b/cmd/event/status_fail_on_orphan_test.go @@ -19,12 +19,12 @@ func TestExitForOrphan_Orphan(t *testing.T) { if err == nil { t.Fatal("expected error when failOnOrphan=true and orphan present") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T", err) + var bareErr *output.BareError + if !errors.As(err, &bareErr) { + t.Fatalf("expected *output.BareError, got %T", err) } - if exitErr.Code != output.ExitValidation { - t.Errorf("Code = %d, want %d", exitErr.Code, output.ExitValidation) + if bareErr.Code != output.ExitValidation { + t.Errorf("Code = %d, want %d", bareErr.Code, output.ExitValidation) } } diff --git a/cmd/flag_suggest_test.go b/cmd/flag_suggest_test.go index 7adb35053..8608c39ba 100644 --- a/cmd/flag_suggest_test.go +++ b/cmd/flag_suggest_test.go @@ -5,10 +5,10 @@ package cmd import ( "errors" - "slices" "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" "github.com/spf13/cobra" ) @@ -40,31 +40,53 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) { c.Flags().Bool("dry-run", false, "") err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T", err) + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) } - if exitErr.Detail.Type != "unknown_flag" { - t.Errorf("type = %q, want unknown_flag", exitErr.Detail.Type) + if verr.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype = %q, want invalid_argument", verr.Subtype) } - if !strings.Contains(exitErr.Detail.Hint, "--range") { - t.Errorf("hint should suggest --range, got %q", exitErr.Detail.Hint) + if code := output.ExitCodeOf(err); code != output.ExitValidation { + t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) } - detail, _ := exitErr.Detail.Detail.(map[string]any) - valid, _ := detail["valid_flags"].([]string) - if !slices.Contains(valid, "find") || !slices.Contains(valid, "range") { - t.Errorf("valid_flags should list find & range, got %v", valid) + // The offending flag is carried structurally on Params (replaces the + // legacy detail map) and named in the message. + if len(verr.Params) != 1 || verr.Params[0].Name != "--rang" { + t.Errorf("Params = %v, want one entry named --rang", verr.Params) + } + if len(verr.Params) == 1 && verr.Params[0].Reason == "" { + t.Error("Params[0].Reason must explain the rejection") + } + if !strings.Contains(verr.Message, "--rang") { + t.Errorf("message should name the offending flag, got %q", verr.Message) + } + // The recoverable suggestion is folded into the hint text (replaces the + // legacy valid_flags detail list). + if !strings.Contains(verr.Hint, "--range") { + t.Errorf("hint should suggest --range, got %q", verr.Hint) } } func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) { c := &cobra.Command{Use: "demo"} err := flagDidYouMean(c, errors.New("flag needs an argument: --find")) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T", err) + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) + } + // Non-unknown-flag errors stay generic: invalid_argument subtype, no + // structured param, generic --help hint (no "did you mean" suggestion). + if verr.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype = %q, want invalid_argument (non-unknown-flag errors stay generic)", verr.Subtype) + } + if code := output.ExitCodeOf(err); code != output.ExitValidation { + t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) + } + if verr.Param != "" || len(verr.Params) != 0 { + t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params) } - if exitErr.Detail.Type != "flag_error" { - t.Errorf("type = %q, want flag_error (non-unknown-flag errors stay generic)", exitErr.Detail.Type) + if strings.Contains(verr.Hint, "did you mean") { + t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint) } } diff --git a/cmd/platform_bootstrap_test.go b/cmd/platform_bootstrap_test.go index 308eb1636..f033d3fb5 100644 --- a/cmd/platform_bootstrap_test.go +++ b/cmd/platform_bootstrap_test.go @@ -9,10 +9,12 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/platform" "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" @@ -102,7 +104,7 @@ func findLeaf(t *testing.T, parent *cobra.Command, names ...string) *cobra.Comma } // Happy path: a valid policy.yml denies one specific command. The denied -// command's RunE returns a typed ExitError envelope; allowed commands are +// command's RunE returns a typed error envelope; allowed commands are // untouched. func TestApplyUserPolicyPruning_appliesValidPolicy(t *testing.T) { cfgDir := tmpHome(t) @@ -127,13 +129,27 @@ max_risk: write if err == nil { t.Fatalf("+delete-doc RunE should return an error") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "command_denied" { - t.Fatalf("expected command_denied ExitError, got %T %+v", err, err) + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err) } - detail, ok := exitErr.Detail.Detail.(map[string]any) - if !ok || detail["reason_code"] != "command_denylisted" { - t.Errorf("reason_code = %v, want command_denylisted", detail["reason_code"]) + if verr.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want failed_precondition", verr.Subtype) + } + if code := output.ExitCodeOf(err); code != output.ExitValidation { + t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) + } + // The denial taxonomy (reason_code, layer, rule) is preserved on the + // wrapped *platform.CommandDeniedError cause and folded into the hint. + var cd *platform.CommandDeniedError + if !errors.As(err, &cd) { + t.Fatalf("error chain should expose *platform.CommandDeniedError") + } + if cd.ReasonCode != "command_denylisted" { + t.Errorf("CommandDeniedError.ReasonCode = %q, want command_denylisted", cd.ReasonCode) + } + if !strings.Contains(verr.Hint, "command_denylisted") { + t.Errorf("hint should surface reason_code command_denylisted, got %q", verr.Hint) } // im/+send must be denied (domain not in Allow). diff --git a/cmd/platform_guards.go b/cmd/platform_guards.go index 5b167cb3d..a7c99e60d 100644 --- a/cmd/platform_guards.go +++ b/cmd/platform_guards.go @@ -8,9 +8,9 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/hook" - "github.com/larksuite/cli/internal/output" internalplatform "github.com/larksuite/cli/internal/platform" ) @@ -34,16 +34,8 @@ import ( // lands directly on their RunE, which now carries the guard. // // makeErr is called for every guarded dispatch; it must return a fresh -// *output.ExitError each time (the envelope writer mutates a few fields -// as it serialises). -// Deprecated: installFatalGuard accepts a *output.ExitError-producing lambda, -// which is part of the legacy error surface that predates the typed error -// contract introduced by errs/. New code MUST NOT add new callers — the -// platform-extension fatal-guard plumbing will switch to typed errs.* errors -// when the platform-extension framework migrates. This wrapper is retained -// only for the existing in-tree call sites; it will be removed once they -// have moved to the typed surface. -func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError) { +// typed error each time. +func installFatalGuard(rootCmd *cobra.Command, makeErr func() error) { // Two cobra subcommands are injected lazily at Execute() time and // would otherwise slip past walkGuard. We pre-register both so // walkGuard catches them. @@ -80,120 +72,65 @@ func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError) } // installPluginInstallErrorGuard surfaces a FailClosed plugin install -// failure as a structured plugin_install envelope before any command -// runs. -// Deprecated: installPluginInstallErrorGuard produces a legacy -// *output.ExitError via its internal makeErr lambda. New code MUST NOT add -// such producers — plugin install failures should surface as a typed -// *errs.XxxError once the platform-extension framework migrates. This -// helper is retained only while existing call sites are migrated; it will -// be removed once they have moved to the typed surface. +// failure as a typed validation error (failed_precondition) before any +// command runs. func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) { - makeErr := func() *output.ExitError { + makeErr := func() error { var pi *internalplatform.PluginInstallError if errors.As(installErr, &pi) { - return &output.ExitError{ - Code: output.ExitValidation, - Detail: &output.ErrDetail{ - Type: "plugin_install", - Message: pi.Error(), - Detail: map[string]any{ - "plugin": pi.PluginName, - "reason_code": pi.ReasonCode, - "reason": pi.Reason, - }, - }, - Err: installErr, - } - } - return &output.ExitError{ - Code: output.ExitValidation, - Detail: &output.ErrDetail{ - Type: "plugin_install", - Message: installErr.Error(), - Detail: map[string]any{ - "reason_code": internalplatform.ReasonInstallFailed, - }, - }, - Err: installErr, + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", pi.Error()). + WithHint("plugin %q failed to install (reason_code %s); fix or remove the plugin before running commands", pi.PluginName, pi.ReasonCode). + WithCause(installErr) } + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", installErr.Error()). + WithHint("a plugin failed to install (reason_code %s); fix or remove the plugin before running commands", internalplatform.ReasonInstallFailed). + WithCause(installErr) } installFatalGuard(rootCmd, makeErr) } // installPluginConflictGuard surfaces a Plugin.Restrict() configuration // error (single plugin invalid Rule or multiple plugins each contributing -// Restrict). The design separates the envelope type: +// Restrict). The hint separates the two failure modes by reason code: // -// - "plugin_install" with reason_code "invalid_rule" - single bad rule -// - "plugin_conflict" with reason_code "multiple_restrict_plugins" - multi +// - "invalid_rule" - single bad rule +// - "multiple_restrict_plugins" - multiple Restrict plugins conflict // // Either way the CLI must NOT silently continue with a broken policy. -// Deprecated: installPluginConflictGuard produces a legacy *output.ExitError -// via its internal makeErr lambda. New code MUST NOT add such producers — -// plugin conflict failures should surface as a typed *errs.XxxError once the -// platform-extension framework migrates. This helper is retained only while -// existing call sites are migrated; it will be removed once they have moved -// to the typed surface. func installPluginConflictGuard(rootCmd *cobra.Command, err error) { - makeErr := func() *output.ExitError { - envelopeType := "plugin_install" + makeErr := func() error { reasonCode := internalplatform.ReasonInvalidRule if errors.Is(err, cmdpolicy.ErrMultipleRestricts) { - envelopeType = "plugin_conflict" reasonCode = internalplatform.ReasonMultipleRestricts } - return &output.ExitError{ - Code: output.ExitValidation, - Detail: &output.ErrDetail{ - Type: envelopeType, - Message: err.Error(), - Detail: map[string]any{ - "reason_code": reasonCode, - }, - }, - Err: err, - } + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()). + WithHint("plugin policy configuration is broken (reason_code %s); fix the plugin's Restrict rule or remove the conflicting plugin", reasonCode). + WithCause(err) } installFatalGuard(rootCmd, makeErr) } // installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler -// failure as a plugin_lifecycle envelope. The reason_code splits -// returned-error vs panic so consumers (audit / on-call) can tell the -// two failure modes apart. -// Deprecated: installPluginLifecycleErrorGuard produces a legacy -// *output.ExitError via its internal makeErr lambda. New code MUST NOT add -// such producers — plugin lifecycle failures should surface as a typed -// *errs.XxxError once the platform-extension framework migrates. This -// helper is retained only while existing call sites are migrated; it will -// be removed once they have moved to the typed surface. +// failure as a typed validation error (failed_precondition). The hint's +// reason code splits returned-error vs panic so consumers (audit / +// on-call) can tell the two failure modes apart. func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) { - makeErr := func() *output.ExitError { + makeErr := func() error { reasonCode := "lifecycle_failed" - detail := map[string]any{ - "reason_code": reasonCode, - } + hookName := "" var le *hook.LifecycleError if errors.As(err, &le) { if le.Panic { reasonCode = "lifecycle_panic" } - detail = map[string]any{ - "reason_code": reasonCode, - "hook_name": le.HookName, - "event": "startup", - } + hookName = le.HookName } - return &output.ExitError{ - Code: output.ExitValidation, - Detail: &output.ErrDetail{ - Type: "plugin_lifecycle", - Message: err.Error(), - Detail: detail, - }, - Err: err, + typed := errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()). + WithCause(err) + if hookName != "" { + return typed.WithHint("plugin startup hook %q failed (reason_code %s); fix or remove the plugin before running commands", hookName, reasonCode) } + return typed.WithHint("a plugin startup hook failed (reason_code %s); fix or remove the plugin before running commands", reasonCode) } installFatalGuard(rootCmd, makeErr) } @@ -219,14 +156,7 @@ func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) { // // This way the very first non-nil step in cobra's chain is always our // guard, regardless of which leaf the user invoked. -// Deprecated: walkGuard accepts a *output.ExitError-producing lambda, part -// of the legacy error surface that predates the typed error contract -// introduced by errs/. New code MUST NOT add new callers — the platform- -// extension guard plumbing will switch to typed errs.* errors when the -// platform-extension framework migrates. This wrapper is retained only for -// the existing in-tree call sites; it will be removed once they have moved -// to the typed surface. -func walkGuard(cmd *cobra.Command, makeErr func() *output.ExitError) { +func walkGuard(cmd *cobra.Command, makeErr func() error) { if cmd == nil { return } diff --git a/cmd/platform_guards_test.go b/cmd/platform_guards_test.go index bd23e8563..4bc635249 100644 --- a/cmd/platform_guards_test.go +++ b/cmd/platform_guards_test.go @@ -6,12 +6,14 @@ package cmd import ( "context" "errors" + "strings" "sync" "testing" "time" "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/platform" "github.com/larksuite/cli/internal/hook" "github.com/larksuite/cli/internal/output" @@ -32,7 +34,7 @@ func (failClosedAbortingPlugin) Install(platform.Registrar) error { } // When a FailClosed plugin fails to install, buildInternal must -// install a PersistentPreRunE that returns a structured *output.ExitError. +// install a PersistentPreRunE that returns a typed *errs.ValidationError. // The user must NEVER see a silent partial-install state. // // This pins the build.go fix for codex's NEW ISSUE about @@ -93,26 +95,31 @@ func TestBuildInternal_failClosedAbortsCLI(t *testing.T) { checkGuardError(t, leaf.RunE(leaf, nil)) } -// checkGuardError asserts that err is the structured plugin_install -// ExitError the guard produces. +// checkGuardError asserts that err is the typed validation error the +// install guard produces: a failed_precondition *errs.ValidationError +// (exit 2) whose message + hint preserve the plugin name and the +// install_failed reason code (the recovery info that lived in the legacy +// detail map). func checkGuardError(t *testing.T, err error) { t.Helper() if err == nil { t.Fatalf("PersistentPreRunE must surface the install error, got nil") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected *output.ExitError, got %T %+v", err, err) + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err) } - if exitErr.Detail.Type != "plugin_install" { - t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type) + if verr.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want failed_precondition", verr.Subtype) } - detail := exitErr.Detail.Detail.(map[string]any) - if detail["plugin"] != "policy" { - t.Errorf("detail.plugin = %v, want policy", detail["plugin"]) + if code := output.ExitCodeOf(err); code != output.ExitValidation { + t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) } - if detail["reason_code"] != internalplatform.ReasonInstallFailed { - t.Errorf("detail.reason_code = %v, want install_failed", detail["reason_code"]) + if !strings.Contains(verr.Hint, "policy") { + t.Errorf("hint should name the failing plugin %q, got %q", "policy", verr.Hint) + } + if !strings.Contains(verr.Hint, internalplatform.ReasonInstallFailed) { + t.Errorf("hint should surface reason_code %q, got %q", internalplatform.ReasonInstallFailed, verr.Hint) } } diff --git a/cmd/plugin_integration_test.go b/cmd/plugin_integration_test.go index e439adbfc..150cb821a 100644 --- a/cmd/plugin_integration_test.go +++ b/cmd/plugin_integration_test.go @@ -8,11 +8,13 @@ import ( "errors" "os" "path/filepath" + "strings" "sync/atomic" "testing" "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/platform" "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" @@ -156,19 +158,23 @@ func TestPluginPipeline_wrapAbortReachesEnvelope(t *testing.T) { } err = leaf.RunE(leaf, nil) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected *output.ExitError, got %T %+v", err, err) + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err) } - if exitErr.Detail.Type != "hook" { - t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type) + if verr.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want failed_precondition", verr.Subtype) } - detail := exitErr.Detail.Detail.(map[string]any) - if detail["reason_code"] != "aborted" { - t.Errorf("detail.reason_code = %v, want aborted", detail["reason_code"]) + if code := output.ExitCodeOf(err); code != output.ExitValidation { + t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) } - if detail["hook_name"] != "policy-plugin.policy" { - t.Errorf("detail.hook_name = %v, want policy-plugin.policy", detail["hook_name"]) + // The namespaced hook name and the abort semantics are preserved in the + // message so a caller can identify which plugin hook rejected the call. + if !strings.Contains(verr.Message, "policy-plugin.policy") { + t.Errorf("message should name the aborting hook policy-plugin.policy, got %q", verr.Message) + } + if !strings.Contains(verr.Message, "aborted") { + t.Errorf("message should describe the abort, got %q", verr.Message) } // errors.As must still reach the original AbortError so consumers @@ -409,15 +415,20 @@ func TestPluginConflictGuard_MultipleRestrictAbortsCLI(t *testing.T) { t.Fatalf("no runnable leaf in command tree") } err := leaf.RunE(leaf, nil) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected *output.ExitError, got %T %+v", err, err) + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err) + } + if verr.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want failed_precondition", verr.Subtype) } - if exitErr.Detail.Type != "plugin_conflict" { - t.Errorf("envelope type = %q, want plugin_conflict", exitErr.Detail.Type) + if code := output.ExitCodeOf(err); code != output.ExitValidation { + t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) } - if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "multiple_restrict_plugins" { - t.Errorf("reason_code = %v, want multiple_restrict_plugins", rc) + // reason_code multiple_restrict_plugins is folded into the hint so the + // operator can distinguish a multi-Restrict conflict from a bad rule. + if !strings.Contains(verr.Hint, "multiple_restrict_plugins") { + t.Errorf("hint should surface reason_code multiple_restrict_plugins, got %q", verr.Hint) } } @@ -447,15 +458,20 @@ func TestPluginConflictGuard_InvalidRuleAbortsCLI(t *testing.T) { t.Fatalf("no runnable leaf in command tree") } err := leaf.RunE(leaf, nil) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected *output.ExitError, got %T %+v", err, err) + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err) } - if exitErr.Detail.Type != "plugin_install" { - t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type) + if verr.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want failed_precondition", verr.Subtype) } - if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "invalid_rule" { - t.Errorf("reason_code = %v, want invalid_rule", rc) + if code := output.ExitCodeOf(err); code != output.ExitValidation { + t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) + } + // reason_code invalid_rule is folded into the hint, distinct from the + // multiple_restrict_plugins conflict path. + if !strings.Contains(verr.Hint, "invalid_rule") { + t.Errorf("hint should surface reason_code invalid_rule, got %q", verr.Hint) } } @@ -484,19 +500,24 @@ func TestPluginLifecycleGuard_StartupErrorAbortsCLI(t *testing.T) { leaf := findRunnableLeaf(root) err := leaf.RunE(leaf, nil) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected *output.ExitError, got %T %+v", err, err) + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err) + } + if verr.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want failed_precondition", verr.Subtype) } - if exitErr.Detail.Type != "plugin_lifecycle" { - t.Errorf("envelope type = %q, want plugin_lifecycle", exitErr.Detail.Type) + if code := output.ExitCodeOf(err); code != output.ExitValidation { + t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) } - d := exitErr.Detail.Detail.(map[string]any) - if d["reason_code"] != "lifecycle_failed" { - t.Errorf("reason_code = %v, want lifecycle_failed", d["reason_code"]) + // reason_code lifecycle_failed (vs lifecycle_panic) and the failing + // hook name are folded into the hint so audit / on-call can tell the + // failure mode and which hook failed. + if !strings.Contains(verr.Hint, "lifecycle_failed") { + t.Errorf("hint should surface reason_code lifecycle_failed, got %q", verr.Hint) } - if d["hook_name"] != "lc.start" { - t.Errorf("hook_name = %v, want lc.start", d["hook_name"]) + if !strings.Contains(verr.Hint, "lc.start") { + t.Errorf("hint should name the failing hook lc.start, got %q", verr.Hint) } } @@ -520,12 +541,20 @@ func TestPluginLifecycleGuard_StartupPanicAbortsCLI(t *testing.T) { } leaf := findRunnableLeaf(root) err := leaf.RunE(leaf, nil) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T", err) + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) } - if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "lifecycle_panic" { - t.Errorf("reason_code = %v, want lifecycle_panic", rc) + if verr.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want failed_precondition", verr.Subtype) + } + if code := output.ExitCodeOf(err); code != output.ExitValidation { + t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) + } + // A panicking startup hook is distinguished from a returned error by + // reason_code lifecycle_panic in the hint. + if !strings.Contains(verr.Hint, "lifecycle_panic") { + t.Errorf("hint should surface reason_code lifecycle_panic, got %q", verr.Hint) } } @@ -579,19 +608,24 @@ func TestWrapperPanic_BecomesHookPanicEnvelope(t *testing.T) { }() err = leaf.RunE(leaf, nil) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected *output.ExitError, got %T %+v", err, err) + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err) } - if exitErr.Detail.Type != "hook" { - t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type) + if verr.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want failed_precondition", verr.Subtype) } - d := exitErr.Detail.Detail.(map[string]any) - if d["reason_code"] != "panic" { - t.Errorf("reason_code = %v, want panic", d["reason_code"]) + if code := output.ExitCodeOf(err); code != output.ExitValidation { + t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) } - if d["hook_name"] != "p.boom" { - t.Errorf("hook_name = %v, want p.boom (namespaced)", d["hook_name"]) + // The recovered panic surfaces as a structured error naming the + // namespaced hook (p.boom) and describing the panic, so the process + // never crashes and the caller can attribute the failure. + if !strings.Contains(verr.Message, "p.boom") { + t.Errorf("message should name the namespaced hook p.boom, got %q", verr.Message) + } + if !strings.Contains(verr.Message, "panic") { + t.Errorf("message should describe the panic, got %q", verr.Message) } } @@ -653,19 +687,24 @@ func TestWrapperFactoryPanic_BecomesHookPanicEnvelope(t *testing.T) { }() err = leaf.RunE(leaf, nil) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected *output.ExitError, got %T %+v", err, err) + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err) + } + if verr.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want failed_precondition", verr.Subtype) } - if exitErr.Detail.Type != "hook" { - t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type) + if code := output.ExitCodeOf(err); code != output.ExitValidation { + t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) } - d := exitErr.Detail.Detail.(map[string]any) - if d["reason_code"] != "panic" { - t.Errorf("reason_code = %v, want panic", d["reason_code"]) + // A panic in the wrapper FACTORY (not just the inner handler) is + // recovered into the same structured panic error, naming the + // namespaced hook fac.bad-factory. + if !strings.Contains(verr.Message, "fac.bad-factory") { + t.Errorf("message should name the namespaced hook fac.bad-factory, got %q", verr.Message) } - if d["hook_name"] != "fac.bad-factory" { - t.Errorf("hook_name = %v, want fac.bad-factory (namespaced)", d["hook_name"]) + if !strings.Contains(verr.Message, "panic") { + t.Errorf("message should describe the panic, got %q", verr.Message) } } diff --git a/cmd/profile/add.go b/cmd/profile/add.go index e05946d62..d384c5ba1 100644 --- a/cmd/profile/add.go +++ b/cmd/profile/add.go @@ -12,6 +12,7 @@ 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/i18n" @@ -53,7 +54,9 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command { func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, brand, lang string, useAfter bool) error { if err := core.ValidateProfileName(name); err != nil { - return output.ErrValidation("%v", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err). + WithCause(err). + WithParam("--name") } langPref, err := cmdutil.ParseLangFlag(lang) @@ -64,46 +67,57 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, // Read secret from stdin if !appSecretStdin { - return output.ErrValidation("app secret must be provided via stdin: use --app-secret-stdin and pipe the secret") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret must be provided via stdin"). + WithHint("use --app-secret-stdin and pipe the secret"). + WithParam("--app-secret-stdin") } scanner := bufio.NewScanner(f.IOStreams.In) if !scanner.Scan() { if err := scanner.Err(); err != nil { - return output.ErrValidation("failed to read secret from stdin: %v", err) + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "failed to read secret from stdin: %v", err). + WithCause(err). + WithParam("--app-secret-stdin") } - return output.ErrValidation("stdin is empty, expected app secret") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "stdin is empty, expected app secret"). + WithHint("pipe the app secret to stdin"). + WithParam("--app-secret-stdin") } appSecret := strings.TrimSpace(scanner.Text()) if appSecret == "" { - return output.ErrValidation("app secret read from stdin is empty") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret read from stdin is empty"). + WithHint("pipe a non-empty app secret to stdin"). + WithParam("--app-secret-stdin") } // Load or create config multi, err := core.LoadMultiAppConfig() if err != nil { if !errors.Is(err, os.ErrNotExist) { - return output.Errorf(output.ExitInternal, "internal", "failed to load config: %v", err) + return errs.NewInternalError(errs.SubtypeFileIO, "failed to load config: %v", err).WithCause(err) } multi = &core.MultiAppConfig{} } // Check name uniqueness if multi.FindApp(name) != nil { - return output.ErrValidation("profile %q already exists", name) + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "profile %q already exists", name). + WithHint("choose a different name, or remove the existing profile first"). + WithParam("--name") } // Check app-id uniqueness — keychain stores secrets by appId, so // multiple profiles sharing the same appId would collide on credentials. for _, a := range multi.Apps { if a.AppId == appID { - return output.ErrValidation("app-id %q is already used by profile %q; each profile must have a unique app-id", appID, a.ProfileName()) + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "app-id %q is already used by profile %q; each profile must have a unique app-id", appID, a.ProfileName()). + WithParam("--app-id") } } // Store secret securely secret, err := core.ForStorage(appID, core.PlainSecret(appSecret), f.Keychain) if err != nil { - return output.Errorf(output.ExitInternal, "internal", "%v", err) + return errs.NewInternalError(errs.SubtypeStorage, "%v", err).WithCause(err) } parsedBrand := core.ParseBrand(brand) @@ -134,7 +148,7 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, } if err := core.SaveMultiAppConfig(multi); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile %q added (%s, %s)", name, appID, parsedBrand)) diff --git a/cmd/profile/list.go b/cmd/profile/list.go index fb4cc1ffe..5b0234c2c 100644 --- a/cmd/profile/list.go +++ b/cmd/profile/list.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" @@ -45,7 +46,7 @@ func profileListRun(f *cmdutil.Factory) error { output.PrintJson(f.IOStreams.Out, []profileListItem{}) return nil } - return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err) + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "failed to load config: %v", err).WithCause(err) } if multi == nil || len(multi.Apps) == 0 { output.PrintJson(f.IOStreams.Out, []profileListItem{}) diff --git a/cmd/profile/profile_test.go b/cmd/profile/profile_test.go index 3cd724720..472a803bf 100644 --- a/cmd/profile/profile_test.go +++ b/cmd/profile/profile_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/i18n" @@ -50,6 +51,16 @@ func TestProfileAddRun_InvalidExistingConfigReturnsError(t *testing.T) { if !strings.Contains(err.Error(), "failed to load config") { t.Fatalf("error = %v, want failed to load config", err) } + var internalErr *errs.InternalError + if !errors.As(err, &internalErr) { + t.Fatalf("error type = %T, want *errs.InternalError; err=%v", err, err) + } + if internalErr.Subtype != errs.SubtypeFileIO { + t.Fatalf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeFileIO) + } + if code := output.ExitCodeOf(err); code != output.ExitInternal { + t.Fatalf("exit code = %d, want %d (ExitInternal)", code, output.ExitInternal) + } } // TestProfileAddRun_Lang covers the unified --lang contract on profile add: @@ -95,9 +106,9 @@ func TestProfileAddRun_Lang(t *testing.T) { if err == nil { t.Fatal("expected validation error for --lang ZH, got nil") } - exitErr, ok := err.(*output.ExitError) - if !ok || exitErr.Code != output.ExitValidation { - t.Fatalf("expected ExitValidation, got %T: %v", err, err) + var valErr *errs.ValidationError + if !errors.As(err, &valErr) || output.ExitCodeOf(err) != output.ExitValidation { + t.Fatalf("expected typed validation error with ExitValidation, got %T: %v", err, err) } }) } @@ -406,17 +417,226 @@ func TestProfileUseRun_SaveFailureReturnsStructuredError(t *testing.T) { func assertInternalExitError(t *testing.T, err error, wantMsg string) { t.Helper() - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("error type = %T, want *output.ExitError; err=%v", err, err) + var internalErr *errs.InternalError + if !errors.As(err, &internalErr) { + t.Fatalf("error type = %T, want *errs.InternalError; err=%v", err, err) + } + if internalErr.Subtype != errs.SubtypeStorage { + t.Fatalf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeStorage) + } + if internalErr.Cause == nil { + t.Fatalf("cause = nil, want wrapped underlying error") + } + if !strings.Contains(internalErr.Message, wantMsg) { + t.Fatalf("message = %q, want contains %q", internalErr.Message, wantMsg) + } + if code := output.ExitCodeOf(err); code != output.ExitInternal { + t.Fatalf("exit code = %d, want %d (ExitInternal)", code, output.ExitInternal) + } +} + +// assertValidationError asserts err is a typed *errs.ValidationError with the +// given subtype, message fragment, and exit code 2. +func assertValidationError(t *testing.T, err error, wantSubtype errs.Subtype, wantMsg string) *errs.ValidationError { + t.Helper() + + if err == nil { + t.Fatal("expected error, got nil") + } + var valErr *errs.ValidationError + if !errors.As(err, &valErr) { + t.Fatalf("error type = %T, want *errs.ValidationError; err=%v", err, err) + } + if valErr.Subtype != wantSubtype { + t.Fatalf("subtype = %q, want %q", valErr.Subtype, wantSubtype) + } + if !strings.Contains(valErr.Message, wantMsg) { + t.Fatalf("message = %q, want contains %q", valErr.Message, wantMsg) + } + if code := output.ExitCodeOf(err); code != output.ExitValidation { + t.Fatalf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) + } + return valErr +} + +func saveTwoProfiles(t *testing.T) { + t.Helper() + multi := &core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{ + {Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu}, + {Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark}, + }, } - if exitErr.Code != output.ExitInternal { - t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitInternal) + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig() error = %v", err) } - if exitErr.Detail == nil || exitErr.Detail.Type != "internal" { - t.Fatalf("detail = %#v, want internal detail", exitErr.Detail) +} + +func TestProfileAddRun_ValidationErrors(t *testing.T) { + t.Run("invalid profile name", func(t *testing.T) { + setupProfileConfigDir(t) + f, _, _, _ := cmdutil.TestFactory(t, nil) + f.IOStreams.In = strings.NewReader("secret\n") + err := profileAddRun(f, "bad name!", "app-x", true, "feishu", "", false) + valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "") + if valErr.Param != "--name" { + t.Fatalf("param = %q, want %q", valErr.Param, "--name") + } + if valErr.Cause == nil { + t.Fatal("cause = nil, want wrapped validation error") + } + }) + + t.Run("missing app-secret-stdin flag", func(t *testing.T) { + setupProfileConfigDir(t) + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := profileAddRun(f, "p", "app-x", false, "feishu", "", false) + valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "app secret must be provided via stdin") + if valErr.Param != "--app-secret-stdin" { + t.Fatalf("param = %q, want %q", valErr.Param, "--app-secret-stdin") + } + if valErr.Hint == "" { + t.Fatal("hint is empty, want actionable hint") + } + }) + + t.Run("empty stdin", func(t *testing.T) { + setupProfileConfigDir(t) + f, _, _, _ := cmdutil.TestFactory(t, nil) + f.IOStreams.In = strings.NewReader("") + err := profileAddRun(f, "p", "app-x", true, "feishu", "", false) + valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "stdin is empty") + if valErr.Param != "--app-secret-stdin" { + t.Fatalf("param = %q, want %q", valErr.Param, "--app-secret-stdin") + } + }) + + t.Run("blank secret on stdin", func(t *testing.T) { + setupProfileConfigDir(t) + f, _, _, _ := cmdutil.TestFactory(t, nil) + f.IOStreams.In = strings.NewReader(" \n") + err := profileAddRun(f, "p", "app-x", true, "feishu", "", false) + assertValidationError(t, err, errs.SubtypeInvalidArgument, "app secret read from stdin is empty") + }) + + t.Run("duplicate profile name", func(t *testing.T) { + setupProfileConfigDir(t) + saveTwoProfiles(t) + f, _, _, _ := cmdutil.TestFactory(t, nil) + f.IOStreams.In = strings.NewReader("secret\n") + err := profileAddRun(f, "default", "app-new", true, "feishu", "", false) + valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, `profile "default" already exists`) + if valErr.Param != "--name" { + t.Fatalf("param = %q, want %q", valErr.Param, "--name") + } + }) + + t.Run("duplicate app-id", func(t *testing.T) { + setupProfileConfigDir(t) + saveTwoProfiles(t) + f, _, _, _ := cmdutil.TestFactory(t, nil) + f.IOStreams.In = strings.NewReader("secret\n") + err := profileAddRun(f, "fresh", "app-default", true, "feishu", "", false) + valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "already used by profile") + if valErr.Param != "--app-id" { + t.Fatalf("param = %q, want %q", valErr.Param, "--app-id") + } + }) +} + +func TestProfileUseRun_ValidationErrors(t *testing.T) { + t.Run("no previous profile for toggle", func(t *testing.T) { + setupProfileConfigDir(t) + saveTwoProfiles(t) + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := profileUseRun(f, "-") + valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "no previous profile to switch back to") + if valErr.Hint == "" { + t.Fatal("hint is empty, want actionable hint") + } + }) + + t.Run("profile not found", func(t *testing.T) { + setupProfileConfigDir(t) + saveTwoProfiles(t) + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := profileUseRun(f, "ghost") + assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`) + }) +} + +func TestProfileRenameRun_ValidationErrors(t *testing.T) { + t.Run("invalid new name", func(t *testing.T) { + setupProfileConfigDir(t) + saveTwoProfiles(t) + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := profileRenameRun(f, "default", "bad name!") + valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "") + if valErr.Cause == nil { + t.Fatal("cause = nil, want wrapped validation error") + } + }) + + t.Run("old profile not found", func(t *testing.T) { + setupProfileConfigDir(t) + saveTwoProfiles(t) + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := profileRenameRun(f, "ghost", "fresh") + assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`) + }) + + t.Run("new name already exists", func(t *testing.T) { + setupProfileConfigDir(t) + saveTwoProfiles(t) + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := profileRenameRun(f, "default", "target") + valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, `profile "target" already exists`) + if valErr.Hint == "" { + t.Fatal("hint is empty, want actionable hint") + } + }) +} + +func TestProfileRemoveRun_ValidationErrors(t *testing.T) { + t.Run("profile not found", func(t *testing.T) { + setupProfileConfigDir(t) + saveTwoProfiles(t) + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := profileRemoveRun(f, "ghost") + assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`) + }) + + t.Run("cannot remove the only profile", func(t *testing.T) { + setupProfileConfigDir(t) + multi := &core.MultiAppConfig{ + CurrentApp: "solo", + Apps: []core.AppConfig{ + {Name: "solo", AppId: "app-solo", AppSecret: core.PlainSecret("secret-solo"), Brand: core.BrandFeishu}, + }, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig() error = %v", err) + } + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := profileRemoveRun(f, "solo") + valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "cannot remove the only profile") + if valErr.Hint == "" { + t.Fatal("hint is empty, want actionable hint") + } + }) +} + +func TestProfileListRun_InvalidConfigReturnsValidationError(t *testing.T) { + dir := setupProfileConfigDir(t) + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid json"), 0600); err != nil { + t.Fatalf("WriteFile() error = %v", err) } - if !strings.Contains(exitErr.Detail.Message, wantMsg) { - t.Fatalf("message = %q, want contains %q", exitErr.Detail.Message, wantMsg) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := profileListRun(f) + valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "failed to load config") + if valErr.Cause == nil { + t.Fatal("cause = nil, want wrapped load error") } } diff --git a/cmd/profile/remove.go b/cmd/profile/remove.go index 08c19234e..ff249c3d5 100644 --- a/cmd/profile/remove.go +++ b/cmd/profile/remove.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" @@ -40,11 +41,12 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error { idx := multi.FindAppIndex(name) if idx < 0 { - return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", ")) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", ")) } if len(multi.Apps) == 1 { - return output.ErrValidation("cannot remove the only profile") + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "cannot remove the only profile"). + WithHint("add another profile first: lark-cli profile add") } app := &multi.Apps[idx] @@ -65,7 +67,7 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error { } if err := core.SaveMultiAppConfig(multi); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } // Best-effort credential cleanup after config commit diff --git a/cmd/profile/rename.go b/cmd/profile/rename.go index 2a8f6a2e5..9506870f3 100644 --- a/cmd/profile/rename.go +++ b/cmd/profile/rename.go @@ -9,6 +9,7 @@ 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/output" @@ -30,7 +31,7 @@ func NewCmdProfileRename(f *cmdutil.Factory) *cobra.Command { func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error { if err := core.ValidateProfileName(newName); err != nil { - return output.ErrValidation("%v", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err) } multi, err := core.LoadOrNotConfigured() @@ -40,7 +41,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error { idx := multi.FindAppIndex(oldName) if idx < 0 { - return output.ErrValidation("profile %q not found, available profiles: %s", oldName, strings.Join(multi.ProfileNames(), ", ")) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", oldName, strings.Join(multi.ProfileNames(), ", ")) } // Check new name uniqueness across other profiles, allowing renames to this @@ -50,7 +51,8 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error { continue } if multi.Apps[i].Name == newName || multi.Apps[i].AppId == newName { - return output.ErrValidation("profile %q already exists", newName) + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "profile %q already exists", newName). + WithHint("choose a different name") } } @@ -66,7 +68,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error { } if err := core.SaveMultiAppConfig(multi); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile renamed: %q -> %q", oldProfileName, newName)) diff --git a/cmd/profile/use.go b/cmd/profile/use.go index 013ade47e..7080d5277 100644 --- a/cmd/profile/use.go +++ b/cmd/profile/use.go @@ -9,6 +9,7 @@ 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/output" @@ -40,14 +41,15 @@ func profileUseRun(f *cmdutil.Factory, name string) error { // Handle "-" for toggle-back if name == "-" { if multi.PreviousApp == "" { - return output.ErrValidation("no previous profile to switch back to") + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "no previous profile to switch back to"). + WithHint("switch to a profile by name first: lark-cli profile use ") } name = multi.PreviousApp } app := multi.FindApp(name) if app == nil { - return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", ")) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", ")) } targetName := app.ProfileName() @@ -66,7 +68,7 @@ func profileUseRun(f *cmdutil.Factory, name string) error { multi.CurrentApp = targetName if err := core.SaveMultiAppConfig(multi); err != nil { - return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Switched to profile %q (%s, %s)", targetName, app.AppId, app.Brand)) diff --git a/cmd/prune.go b/cmd/prune.go index 2ec66fdaf..49979423f 100644 --- a/cmd/prune.go +++ b/cmd/prune.go @@ -9,10 +9,10 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/output" ) // pruneForStrictMode removes commands incompatible with the active strict mode. @@ -65,10 +65,10 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma // pick auth's instead of our denial. A leaf-level no-op makes // cobra stop here and proceed to the wrapped RunE. // - // strict-mode keeps its short Message + independent Hint and - // composes the shared detail.* / wrapped-CommandDeniedError shape - // by hand; BuildDenialError would override Message with the - // CommandDeniedError.Error() long form. + // strict-mode keeps its short Message + independent Hint and wraps + // the CommandDeniedError as the Cause by hand; BuildDenialError + // would override Message with the CommandDeniedError.Error() long + // form. stubMessage := fmt.Sprintf( "strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()) @@ -105,20 +105,9 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma }, RunE: func(c *cobra.Command, _ []string) error { cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial) - // Legacy *output.ExitError producer: this literal predates the - // typed error contract introduced by errs/. New denial sites MUST - // NOT construct *output.ExitError directly — they should return a - // typed *errs.XxxError once the cmdpolicy framework migrates. - return &output.ExitError{ - Code: output.ExitValidation, - Detail: &output.ErrDetail{ - Type: "command_denied", - Message: stubMessage, - Hint: stubHint, - Detail: cmdpolicy.DenialDetailMap(cd), - }, - Err: cd, - } + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", stubMessage). + WithHint("denied by %s policy (reason_code %s); %s", cd.Layer, cd.ReasonCode, stubHint). + WithCause(cd) }, } } diff --git a/cmd/prune_test.go b/cmd/prune_test.go index d9a949c36..aee11177b 100644 --- a/cmd/prune_test.go +++ b/cmd/prune_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/platform" "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" @@ -247,9 +248,12 @@ func TestStrictModeStub_BypassesArgsValidator(t *testing.T) { } } -// Pins the strict-mode envelope shape: structured detail.* / wrapped -// CommandDeniedError for external agents, AND the historical short -// Message + independent Hint for existing consumers. +// Pins the strict-mode typed envelope: a failed_precondition +// *errs.ValidationError (exit 2) carrying the short historical Message, +// a Hint that still surfaces the policy layer + reason code (the +// safety-critical recovery info that lived in the legacy detail map), +// and the wrapped *platform.CommandDeniedError so external agents can +// still inspect the structured denial taxonomy via errors.As. func TestStrictModeStub_StructuredEnvelope(t *testing.T) { root := newTestTree() pruneForStrictMode(root, core.StrictModeBot) @@ -262,30 +266,33 @@ func TestStrictModeStub_StructuredEnvelope(t *testing.T) { t.Fatalf("strict-mode stub RunE should return error") } - var ee *output.ExitError - if !errors.As(err, &ee) { - t.Fatalf("err is not *output.ExitError: %T", err) + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("err is not *errs.ValidationError: %T", err) } - if ee.Detail == nil { - t.Fatalf("ExitError.Detail is nil; envelope writer cannot emit JSON") + if verr.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want failed_precondition", verr.Subtype) } - if ee.Detail.Type != "command_denied" { - t.Errorf("Detail.Type = %q, want command_denied", ee.Detail.Type) + if code := output.ExitCodeOf(err); code != output.ExitValidation { + t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) } - dm, ok := ee.Detail.Detail.(map[string]any) - if !ok { - t.Fatalf("Detail.Detail = %T, want map[string]any", ee.Detail.Detail) + // Short historical Message is preserved verbatim. + if verr.Message != `strict mode is "bot", only bot-identity commands are available` { + t.Errorf("Message = %q, want short historical form", verr.Message) } - if got, _ := dm["layer"].(string); got != cmdpolicy.LayerStrictMode { - t.Errorf("Detail.Detail[layer] = %q, want %q", got, cmdpolicy.LayerStrictMode) + // The denial layer + reason code remain user-readable in the hint, and + // the historical switch-policy guidance is still appended. + if !strings.Contains(verr.Hint, cmdpolicy.LayerStrictMode) { + t.Errorf("Hint = %q, want substring %q (policy layer)", verr.Hint, cmdpolicy.LayerStrictMode) } - if got, _ := dm["reason_code"].(string); got != "identity_not_supported" { - t.Errorf("Detail.Detail[reason_code] = %q, want identity_not_supported", got) + if !strings.Contains(verr.Hint, "identity_not_supported") { + t.Errorf("Hint = %q, want substring identity_not_supported (reason code)", verr.Hint) } - if got, _ := dm["policy_source"].(string); got != "strict-mode" { - t.Errorf("Detail.Detail[policy_source] = %q, want strict-mode", got) + if !strings.Contains(verr.Hint, "if the user explicitly wants to switch policy") { + t.Errorf("Hint = %q, want historical switch-policy guidance", verr.Hint) } + // The structured denial taxonomy survives on the wrapped cause. var cd *platform.CommandDeniedError if !errors.As(err, &cd) { t.Fatalf("err does not unwrap to *platform.CommandDeniedError") @@ -296,15 +303,12 @@ func TestStrictModeStub_StructuredEnvelope(t *testing.T) { if cd.ReasonCode != "identity_not_supported" { t.Errorf("CommandDeniedError.ReasonCode = %q, want identity_not_supported", cd.ReasonCode) } + if cd.PolicySource != "strict-mode" { + t.Errorf("CommandDeniedError.PolicySource = %q, want strict-mode", cd.PolicySource) + } if !strings.Contains(cd.Reason, `strict mode is "bot"`) { t.Errorf("CommandDeniedError.Reason = %q, want substring 'strict mode is \"bot\"'", cd.Reason) } - if ee.Detail.Message != `strict mode is "bot", only bot-identity commands are available` { - t.Errorf("Detail.Message = %q, want short historical form", ee.Detail.Message) - } - if !strings.HasPrefix(ee.Detail.Hint, "if the user explicitly wants to switch policy") { - t.Errorf("Detail.Hint = %q, want historical hint", ee.Detail.Hint) - } } // strictModeStubFrom must write the denial annotations so the hook diff --git a/cmd/root.go b/cmd/root.go index 1f5b2e491..d03ca3c68 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,17 +13,12 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/platform" - internalauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/deprecation" - "github.com/larksuite/cli/internal/errclass" - "github.com/larksuite/cli/internal/errcompat" "github.com/larksuite/cli/internal/hook" "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/internal/skillscheck" "github.com/larksuite/cli/internal/suggest" "github.com/larksuite/cli/internal/update" @@ -217,56 +212,37 @@ func configureFlagCompletions(args []string) { // and returns the process exit code. // // Dispatch order: -// 1. Legacy shapes (*core.ConfigError, *internalauth.NeedAuthorizationError) -// are promoted via errcompat to their typed errs/ counterparts, with the -// original preserved in the Cause chain. -// 2. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError, -// *errs.SecurityPolicyError, *errs.AuthenticationError): render via the -// typed envelope writer, which lifts extension fields (missing_scopes, -// console_url, challenge_url, ...) to the top level. Routed by -// errs.CategoryOf via ExitCodeOf. -// 3. Legacy *output.ExitError: asExitError adapts it to the legacy -// envelope, written via WriteErrorEnvelope. -// 4. Cobra errors (required flags, unknown commands, etc.): plain text. +// 1. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError, +// *errs.SecurityPolicyError, *errs.AuthenticationError, *errs.ConfigError): +// render via the typed envelope writer, which lifts extension fields +// (missing_scopes, console_url, challenge_url, ...) to the top level. +// Routed by errs.CategoryOf via ExitCodeOf. Auth and config errors are +// constructed typed at their origin (internal/auth, internal/core), so the +// dispatcher no longer promotes any legacy shape here. +// 2. PartialFailure / BareError signals: the result envelope is already on +// stdout; honor the exit code and write nothing to stderr. +// 3. Residual cobra usage errors (missing required flag, unknown command, +// argument validation): typed as an invalid_argument envelope (exit 2), +// matching the explicit flag/subcommand guards. Flag parse errors are +// already typed upstream by the root FlagErrorFunc. func handleRootError(f *cmdutil.Factory, err error) int { errOut := f.IOStreams.ErrOut - // Promote legacy error shapes into typed errs/ before envelope marshal. - // NeedAuthorizationError check is first because it is the more specific - // shape; *core.ConfigError check follows. errors.As preserves the original - // in the Cause chain, so external errors.As(&core.ConfigError{}) consumers - // (cmd/auth/list.go, cmd/doctor/doctor.go, ...) still match. - // - // Outer-typed short-circuit: if err is already a typed *errs.* error, - // skip PromoteXxxError so the producer's Subtype / Hint / extension - // fields are not overwritten by a coarser promoted shape derived from a - // legacy error buried in its Cause chain. Promotion is only for legacy - // untyped entry points. - if !isOuterTypedError(err) { - var needAuthErr *internalauth.NeedAuthorizationError - if errors.As(err, &needAuthErr) { - err = errcompat.PromoteAuthError(needAuthErr) - } else { - var cfgErr *core.ConfigError - if errors.As(err, &cfgErr) { - err = errcompat.PromoteConfigError(cfgErr) - } - } - } - // When the typed error is a need_user_authorization signal, fold in the // current command's declared scopes as a Hint so the user/AI sees the // concrete scope(s) to re-auth with. The hint is computed on the fly from // local shortcut/service metadata — it never depends on server state. - applyNeedAuthorizationHint(f, err) + if !errs.IsRaw(err) { + applyNeedAuthorizationHint(f, err) + } // Staged dispatch: capture the typed exit code BEFORE attempting the // envelope write. WriteTypedErrorEnvelope is best-effort on the wire // (partial-write still returns true) so the exit code we read here is // preserved even if stderr is torn — torn stderr must not downgrade - // typed exits 3/4/6/10 to the legacy "Error:" path with exit 1. + // typed exits 3/4/6/10 to the plain "Error:" path with exit 1. // WriteTypedErrorEnvelope still returns false when err carries no - // Problem; in that case we fall through to the legacy bridge below. + // Problem; in that case we fall through to the signal / plain-text paths. typedExit := output.ExitCodeOf(err) if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) { return typedExit @@ -279,58 +255,22 @@ func handleRootError(f *cmdutil.Factory, err error) int { return pfErr.Code } - if exitErr := asExitError(err); exitErr != nil { - if !exitErr.Raw { - // Raw errors (e.g. from `api` command via output.MarkRaw) - // preserve the original API error detail; skip enrichment - // which would clear it. - enrichMissingScopeError(f, exitErr) - enrichPermissionError(f, exitErr) - } - output.WriteErrorEnvelope(errOut, exitErr, string(f.ResolvedIdentity)) - return exitErr.Code + // Silent-exit signal (e.g. `auth check` predicate, or `update --json`): + // stdout already carries the result; honor the requested exit code and + // write nothing to stderr. + var bareErr *output.BareError + if errors.As(err, &bareErr) { + return bareErr.Code } - // A backward-compat alias records its deprecation notice in PreRunE, which - // runs before cobra's required-flag validation — but a missing required flag - // fails before RunE and lands here, where the bare "Error:" line would drop - // the notice. When a deprecation is pending, route through the structured - // envelope so the migration hint still reaches the caller; all other errors - // keep the existing plain output. - if deprecation.GetPending() != nil { - output.WriteErrorEnvelope(errOut, &output.ExitError{ - Code: 1, - Detail: &output.ErrDetail{Type: "validation", Message: err.Error()}, - }, string(f.ResolvedIdentity)) - return 1 - } - fmt.Fprintln(errOut, "Error:", err) - return 1 -} - -// isOuterTypedError returns true if err is a typed *errs.* error AT THE -// TOP OF THE CHAIN (not buried inside Unwrap). Used by handleRootError -// to gate PromoteXxxError so a producer's outer typed envelope is never -// overwritten by a coarser shape derived from its legacy Cause. -func isOuterTypedError(err error) bool { - _, ok := err.(errs.TypedError) - return ok -} - -// asExitError converts known structured error types to *output.ExitError. -// Returns nil for unrecognized errors (e.g. cobra flag errors). -// -// Deprecated: legacy *output.ExitError bridge. -func asExitError(err error) *output.ExitError { - var cfgErr *core.ConfigError - if errors.As(err, &cfgErr) { - return output.ErrWithHint(cfgErr.Code, cfgErr.Type, cfgErr.Message, cfgErr.Hint) - } - var exitErr *output.ExitError - if errors.As(err, &exitErr) { - return exitErr - } - return nil + // Anything reaching here is a cobra usage error (missing required flag, + // unknown command, bad args); RunE and flag-parse errors are already typed + // above. Classify as invalid_argument so it shares the typed envelope and + // exit 2 of the flag/subcommand guards instead of a plain "Error:" line, + // which also preserves any pending deprecation notice. + usageErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()) + output.WriteTypedErrorEnvelope(errOut, usageErr, string(f.ResolvedIdentity)) + return output.ExitCodeOf(usageErr) } // installUnknownSubcommandGuard replaces cobra's silent help fallback on @@ -361,13 +301,10 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) { } } -// Deprecated: unknownSubcommandRunE produces a legacy *output.ExitError that -// predates the typed error contract introduced by errs/. New code MUST NOT -// add producers of this shape — unknown-subcommand signals should move to -// a typed *errs.ValidationError (or a dedicated typed error) carrying the -// agent-protocol metadata as typed extension fields. This helper is retained -// only while existing dispatch sites are migrated; it will be removed once -// they have moved to the typed surface. +// unknownSubcommandRunE replaces cobra's silent help fallback on group commands +// with a typed *errs.ValidationError: a flag that belongs to a missing +// subcommand, a misplaced subcommand-only flag, or an unknown subcommand name +// each fail structured (exit 2) instead of degrading to help + exit 0. func unknownSubcommandRunE(cmd *cobra.Command, args []string) error { if len(args) == 0 { // A bare group (e.g. `sheets`), or one carrying only group-valid flags @@ -383,28 +320,13 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error { return cmd.Help() } if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 { - return &output.ExitError{ - Code: output.ExitValidation, - Detail: &output.ErrDetail{ - Type: "unknown_flag", - Message: fmt.Sprintf("unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()), - Hint: fmt.Sprintf("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath()), - Detail: map[string]any{ - // Keep the same detail keys as flagDidYouMean's unknown_flag - // so a consumer keyed on Type can read a stable shape. The - // subcommand isn't resolved here, so suggestions/valid_flags - // have no meaningful universe to draw from — emit empty - // rather than the group's own (misleading) flags. unknown is - // the back-compat singular field; unknown_flags carries the - // full list when more than one flag was supplied. - "unknown": strings.Join(unknown, ", "), - "unknown_flags": unknown, - "command_path": cmd.CommandPath(), - "suggestions": []string{}, - "valid_flags": []string{}, - }, - }, + verr := errs.NewValidationError(errs.SubtypeInvalidArgument, + "unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()). + WithHint("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath()) + for _, flag := range unknown { + verr.WithParams(errs.InvalidParam{Name: flag, Reason: "unknown flag before a subcommand"}) } + return verr } // The remaining flags are all defined somewhere in the tree. Those valid // on the group itself or inherited (e.g. the global --profile) do not @@ -416,19 +338,13 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error { if len(misplaced) == 0 { return cmd.Help() } - return &output.ExitError{ - Code: output.ExitValidation, - Detail: &output.ErrDetail{ - Type: "missing_subcommand", - Message: fmt.Sprintf("missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")), - Hint: fmt.Sprintf("run `%s --help` to list subcommands and their flags", cmd.CommandPath()), - Detail: map[string]any{ - "command_path": cmd.CommandPath(), - "flags": misplaced, - "suggestions": []string{}, - }, - }, + verr := errs.NewValidationError(errs.SubtypeInvalidArgument, + "missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")). + WithHint("run `%s --help` to list subcommands and their flags", cmd.CommandPath()) + for _, flag := range misplaced { + verr.WithParams(errs.InvalidParam{Name: flag, Reason: "flag belongs to a subcommand, not the group"}) } + return verr } unknown := args[0] available, deprecated := availableSubcommandNames(cmd) @@ -442,27 +358,10 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error { hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)", strings.Join(suggestions, ", "), cmd.CommandPath()) } - detail := map[string]any{ - "unknown": unknown, - "command_path": cmd.CommandPath(), - "suggestions": suggestions, - "available": available, - } - // Only services with backward-compat aliases (currently sheets) carry a - // deprecated bucket; omit the key elsewhere so every other service's - // envelope is unchanged. - if len(deprecated) > 0 { - detail["deprecated"] = deprecated - } - return &output.ExitError{ - Code: output.ExitValidation, - Detail: &output.ErrDetail{ - Type: "unknown_subcommand", - Message: msg, - Hint: hint, - Detail: detail, - }, - } + // The offending token is a positional subcommand name, not a flag, so it + // is not recorded as a param; the suggestions are already folded into hint. + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg). + WithHint("%s", hint) } // flagTokensInArgs returns the flag-like tokens (-x, --foo, --foo=bar) in @@ -588,22 +487,16 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin } // flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It -// converts cobra's flag-parse errors into the structured ErrorEnvelope: an -// unknown flag gets a focused "did you mean" hint plus the full valid-flag list -// in detail (so agents recover even when the typo is semantic, e.g. --query vs -// --find, where edit distance alone finds nothing). Other flag errors stay -// structured but generic. +// converts cobra's flag-parse errors into a typed validation envelope: an +// unknown flag gets a focused "did you mean" hint (so agents recover even when +// the typo is semantic, e.g. --query vs --find, where edit distance alone finds +// nothing) and the offending flag in `params`. Other flag errors stay typed +// but generic. func flagDidYouMean(c *cobra.Command, ferr error) error { name, isUnknown := unknownFlagName(ferr) if !isUnknown { - return &output.ExitError{ - Code: output.ExitValidation, - Detail: &output.ErrDetail{ - Type: "flag_error", - Message: ferr.Error(), - Hint: fmt.Sprintf("run `%s --help` for valid flags", c.CommandPath()), - }, - } + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()). + WithHint("run `%s --help` for valid flags", c.CommandPath()) } valid := visibleFlagNames(c) suggestions := suggest.Closest(name, valid, 3) @@ -615,20 +508,13 @@ func flagDidYouMean(c *cobra.Command, ferr error) error { hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)", strings.Join(suggestions, ", "), c.CommandPath()) } - return &output.ExitError{ - Code: output.ExitValidation, - Detail: &output.ErrDetail{ - Type: "unknown_flag", - Message: fmt.Sprintf("unknown flag %q for %q", "--"+name, c.CommandPath()), - Hint: hint, - Detail: map[string]any{ - "unknown": "--" + name, - "command_path": c.CommandPath(), - "suggestions": suggestions, - "valid_flags": valid, - }, - }, - } + // suggestions are folded into hint; valid_flags is recoverable via --help. + // Use Params (not the scalar Param) so this path matches the + // before-a-subcommand guard and carries a per-flag reason for agents. + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "unknown flag %q for %q", "--"+name, c.CommandPath()). + WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag"}). + WithHint("%s", hint) } // unknownFlagName extracts the offending long-flag name from cobra's flag-parse @@ -698,56 +584,3 @@ func installTipsHelpFunc(root *cobra.Command) { } }) } - -// enrichPermissionError rewrites the legacy *output.ExitError envelope so its -// Message + Hint match the per-subtype canonical text produced by the typed -// dispatcher path (errclass.CanonicalPermissionMessage / errclass.PermissionHint). -// This guarantees a caller observing the wire envelope cannot tell whether -// the error reached the dispatcher via the legacy *ExitError bridge or via -// the typed *errs.PermissionError fast path. -// -// Deprecated: legacy *output.ExitError enrichment; typed PermissionError -// values produced by errclass.BuildAPIError already carry MissingScopes + -// ConsoleURL directly. -func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) { - if exitErr.Detail == nil { - return - } - // Only the legacy permission-class envelope types route here. "app_status" - // covers 99991662 (app_disabled) / 99991673 (app_unavailable); "permission" - // covers the four scope-class codes (99991672 / 99991676 / 99991679 / 230027). - if exitErr.Detail.Type != "permission" && exitErr.Detail.Type != "app_status" { - return - } - - larkCode := exitErr.Detail.Code - meta, ok := errclass.LookupCodeMeta(larkCode) - if !ok || meta.Category != errs.CategoryAuthorization { - return - } - - // Extract required scopes from API error detail (shared helper). May be - // empty for app-status codes — canonical message + hint still apply. - missing := registry.ExtractRequiredScopes(exitErr.Detail.Detail) - - cfg, err := f.Config() - if err != nil { - return - } - - // Reuse the same console URL builder as the typed path so both wire - // envelopes carry identical console_url values for the same input. - consoleURL := errclass.ConsoleURL(string(cfg.Brand), cfg.AppID, missing) - - // Clear raw API detail — useful info is now in message/hint/console_url. - exitErr.Detail.Detail = nil - - identity := string(f.ResolvedIdentity) - if identity == "" { - identity = "user" - } - - exitErr.Detail.Message = errclass.CanonicalPermissionMessage(meta.Subtype, cfg.AppID, missing, exitErr.Detail.Message) - exitErr.Detail.Hint = errclass.PermissionHint(missing, identity, meta.Subtype, consoleURL) - exitErr.Detail.ConsoleURL = consoleURL -} diff --git a/cmd/root_integration_test.go b/cmd/root_integration_test.go index 2948cf028..d793db419 100644 --- a/cmd/root_integration_test.go +++ b/cmd/root_integration_test.go @@ -8,7 +8,6 @@ import ( "context" "encoding/json" "os" - "reflect" "strings" "testing" @@ -27,12 +26,12 @@ import ( "github.com/spf13/cobra" ) -// Canonical strict-mode envelope strings shared across fixtures -// (reflect.DeepEqual pins them; keep in sync with strictModeStubFrom). +// Canonical strict-mode envelope messages shared across fixtures. The +// switch-policy hint text is asserted by substring in +// assertStrictModeDenialEnvelope. const ( strictModeBotMessage = `strict mode is "bot", only bot-identity commands are available` strictModeUserMessage = `strict mode is "user", only user-identity commands are available` - strictModeHint = "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)" ) // buildIntegrationRootCmd creates a root command with api, service, and shortcut @@ -63,35 +62,44 @@ func executeRootIntegration(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Com return 0 } -// parseEnvelope parses stderr bytes into an ErrorEnvelope. -func parseEnvelope(t *testing.T, stderr *bytes.Buffer) output.ErrorEnvelope { +// typedErrorEnvelope mirrors the typed wire shape produced by +// WriteTypedErrorEnvelope: the inner error marshals an errs.Problem +// directly, so "type" is the category, "subtype" is top-level, and there +// is no nested "detail" object. Recovery info (policy source, reason +// code, suggestions) is folded into "hint". +type typedErrorEnvelope struct { + OK bool `json:"ok"` + Identity string `json:"identity,omitempty"` + Error struct { + Type string `json:"type"` + Subtype string `json:"subtype"` + Message string `json:"message"` + Hint string `json:"hint"` + Param string `json:"param,omitempty"` + } `json:"error"` +} + +// parseTypedEnvelope decodes stderr as the typed envelope and fails if the +// legacy nested "detail" object is present (the migration removed it). +func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope { t.Helper() if stderr.Len() == 0 { t.Fatal("expected non-empty stderr, got empty") } - var env output.ErrorEnvelope - if err := json.Unmarshal(stderr.Bytes(), &env); err != nil { - t.Fatalf("failed to parse stderr as ErrorEnvelope: %v\nstderr: %s", err, stderr.String()) + var raw map[string]any + if err := json.Unmarshal(stderr.Bytes(), &raw); err != nil { + t.Fatalf("failed to parse stderr as JSON: %v\nstderr: %s", err, stderr.String()) } - return env -} - -// assertEnvelope verifies exit code, stdout is empty, and stderr matches the -// expected ErrorEnvelope exactly via reflect.DeepEqual. -func assertEnvelope(t *testing.T, code int, wantCode int, stdout *bytes.Buffer, stderr *bytes.Buffer, want output.ErrorEnvelope) { - t.Helper() - if code != wantCode { - t.Errorf("exit code: got %d, want %d", code, wantCode) - } - if stdout.Len() != 0 { - t.Errorf("expected empty stdout, got:\n%s", stdout.String()) + if errObj, ok := raw["error"].(map[string]any); ok { + if _, hasDetail := errObj["detail"]; hasDetail { + t.Errorf("typed envelope must not carry a nested 'detail' object, got: %s", stderr.String()) + } } - got := parseEnvelope(t, stderr) - if !reflect.DeepEqual(got, want) { - gotJSON, _ := json.MarshalIndent(got, "", " ") - wantJSON, _ := json.MarshalIndent(want, "", " ") - t.Errorf("stderr envelope mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, wantJSON) + var env typedErrorEnvelope + if err := json.Unmarshal(stderr.Bytes(), &env); err != nil { + t.Fatalf("failed to parse stderr as typed envelope: %v\nstderr: %s", err, stderr.String()) } + return env } func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command { @@ -205,23 +213,71 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelop // auth login is user-only, so it gets pruned in strict-mode-bot and the // stub error fires (not login.go's inline check, which is shadowed by - // pruning). - assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{ - OK: false, - Error: &output.ErrDetail{ - Type: "command_denied", - Message: strictModeBotMessage, - Hint: strictModeHint, - Detail: map[string]any{ - "path": "auth/login", - "layer": "strict_mode", - "policy_source": "strict-mode", - "rule_name": "", - "reason_code": "identity_not_supported", - "reason": strictModeBotMessage, - }, - }, - }) + // pruning). The typed envelope is a failed_precondition validation + // error (exit 2); the strict-mode layer + reason code are folded into + // the hint. + if code != output.ExitValidation { + t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) + } + if stdout.Len() != 0 { + t.Errorf("expected empty stdout, got:\n%s", stdout.String()) + } + env := parseTypedEnvelope(t, stderr) + assertStrictModeDenialEnvelope(t, env, strictModeBotMessage) +} + +// assertStrictModeDenialEnvelope pins the shared strict-mode denial shape: +// a validation/failed_precondition envelope whose message is the short +// historical strict-mode line and whose hint still names the strict_mode +// layer + identity_not_supported reason code (the safety-critical recovery +// info), plus the historical switch-policy guidance. +func assertStrictModeDenialEnvelope(t *testing.T, env typedErrorEnvelope, wantMessage string) { + t.Helper() + if env.OK { + t.Errorf("envelope ok = true, want false") + } + if env.Error.Type != "validation" { + t.Errorf("error.type = %q, want validation", env.Error.Type) + } + if env.Error.Subtype != "failed_precondition" { + t.Errorf("error.subtype = %q, want failed_precondition", env.Error.Subtype) + } + if env.Error.Message != wantMessage { + t.Errorf("error.message = %q, want %q", env.Error.Message, wantMessage) + } + if !strings.Contains(env.Error.Hint, "strict_mode") { + t.Errorf("error.hint = %q, want substring strict_mode (policy layer)", env.Error.Hint) + } + if !strings.Contains(env.Error.Hint, "identity_not_supported") { + t.Errorf("error.hint = %q, want substring identity_not_supported (reason code)", env.Error.Hint) + } + if !strings.Contains(env.Error.Hint, "config strict-mode --help") { + t.Errorf("error.hint = %q, want historical switch-policy guidance", env.Error.Hint) + } +} + +// assertCheckStrictModeEnvelope pins the typed envelope produced by +// cmdutil.Factory.CheckStrictMode (the identity-guard path for explicit +// --as on shortcuts / service methods / api): a *errs.ValidationError with +// subtype invalid_argument, the canonical strict-mode message, and the +// switch-policy hint. +func assertCheckStrictModeEnvelope(t *testing.T, env typedErrorEnvelope, wantMessage string) { + t.Helper() + if env.OK { + t.Errorf("envelope ok = true, want false") + } + if env.Error.Type != "validation" { + t.Errorf("error.type = %q, want validation", env.Error.Type) + } + if env.Error.Subtype != "invalid_argument" { + t.Errorf("error.subtype = %q, want invalid_argument", env.Error.Subtype) + } + if env.Error.Message != wantMessage { + t.Errorf("error.message = %q, want %q", env.Error.Message, wantMessage) + } + if !strings.Contains(env.Error.Hint, "config strict-mode --help") { + t.Errorf("error.hint = %q, want switch-policy guidance", env.Error.Hint) + } } func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnvelope(t *testing.T) { @@ -232,22 +288,14 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnve "im", "+messages-search", "--chat-id", "oc_xxx", "--query", "hello", }) - assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{ - OK: false, - Error: &output.ErrDetail{ - Type: "command_denied", - Message: strictModeBotMessage, - Hint: strictModeHint, - Detail: map[string]any{ - "path": "im/+messages-search", - "layer": "strict_mode", - "policy_source": "strict-mode", - "rule_name": "", - "reason_code": "identity_not_supported", - "reason": strictModeBotMessage, - }, - }, - }) + if code != output.ExitValidation { + t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) + } + if stdout.Len() != 0 { + t.Errorf("expected empty stdout, got:\n%s", stdout.String()) + } + env := parseTypedEnvelope(t, stderr) + assertStrictModeDenialEnvelope(t, env, strictModeBotMessage) } func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t *testing.T) { @@ -277,15 +325,14 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn "im", "+chat-create", "--name", "probe", "--as", "bot", "--dry-run", }) - assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{ - OK: false, - Identity: "bot", - Error: &output.ErrDetail{ - Type: "validation", - Message: `strict mode is "user", only user-identity commands are available`, - Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)", - }, - }) + if code != output.ExitValidation { + t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) + } + if stdout.Len() != 0 { + t.Errorf("expected empty stdout, got:\n%s", stdout.String()) + } + env := parseTypedEnvelope(t, stderr) + assertCheckStrictModeEnvelope(t, env, strictModeUserMessage) } func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) { @@ -296,15 +343,14 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv "im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run", }) - assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{ - OK: false, - Identity: "user", - Error: &output.ErrDetail{ - Type: "validation", - Message: `strict mode is "bot", only bot-identity commands are available`, - Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)", - }, - }) + if code != output.ExitValidation { + t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) + } + if stdout.Len() != 0 { + t.Errorf("expected empty stdout, got:\n%s", stdout.String()) + } + env := parseTypedEnvelope(t, stderr) + assertCheckStrictModeEnvelope(t, env, strictModeBotMessage) } func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) { @@ -315,22 +361,14 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE "im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run", }) - assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{ - OK: false, - Error: &output.ErrDetail{ - Type: "command_denied", - Message: strictModeUserMessage, - Hint: strictModeHint, - Detail: map[string]any{ - "path": "im/images/create", - "layer": "strict_mode", - "policy_source": "strict-mode", - "rule_name": "", - "reason_code": "identity_not_supported", - "reason": strictModeUserMessage, - }, - }, - }) + if code != output.ExitValidation { + t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) + } + if stdout.Len() != 0 { + t.Errorf("expected empty stdout, got:\n%s", stdout.String()) + } + env := parseTypedEnvelope(t, stderr) + assertStrictModeDenialEnvelope(t, env, strictModeUserMessage) } func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelope(t *testing.T) { @@ -341,15 +379,14 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop "api", "--as", "user", "GET", "/open-apis/im/v1/chats/oc_test", "--dry-run", }) - assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{ - OK: false, - Identity: "user", - Error: &output.ErrDetail{ - Type: "validation", - Message: `strict mode is "bot", only bot-identity commands are available`, - Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)", - }, - }) + if code != output.ExitValidation { + t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) + } + if stdout.Len() != 0 { + t.Errorf("expected empty stdout, got:\n%s", stdout.String()) + } + env := parseTypedEnvelope(t, stderr) + assertCheckStrictModeEnvelope(t, env, strictModeBotMessage) } // --- shortcut command --- @@ -372,16 +409,43 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) { "im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test", }) - // shortcut: typed error via DoAPIJSON path - assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{ - OK: false, - Identity: "bot", - Error: &output.ErrDetail{ - Type: "api", - Code: 230002, - Message: "Bot/User can NOT be out of the chat.", - }, - }) + // shortcut: typed errs.APIError via the CallAPITyped → BuildAPIError path. + if code != output.ExitAPI { + t.Errorf("exit code = %d, want %d (ExitAPI)", code, output.ExitAPI) + } + if stdout.Len() != 0 { + t.Errorf("expected empty stdout, got:\n%s", stdout.String()) + } + if stderr.Len() == 0 { + t.Fatal("expected non-empty stderr, got empty") + } + var raw struct { + OK bool `json:"ok"` + Identity string `json:"identity"` + Error struct { + Type string `json:"type"` + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(stderr.Bytes(), &raw); err != nil { + t.Fatalf("failed to parse typed envelope: %v\nstderr: %s", err, stderr.String()) + } + if raw.OK { + t.Errorf("envelope ok = true, want false") + } + if raw.Identity != "bot" { + t.Errorf("identity = %q, want bot", raw.Identity) + } + if raw.Error.Type != "api" { + t.Errorf("error.type = %q, want api", raw.Error.Type) + } + if raw.Error.Code != 230002 { + t.Errorf("error.code = %d, want 230002", raw.Error.Code) + } + if raw.Error.Message != "Bot/User can NOT be out of the chat." { + t.Errorf("error.message = %q, want %q", raw.Error.Message, "Bot/User can NOT be out of the chat.") + } } // TestSetupNotices_ColdStart_NoNotice verifies that missing state diff --git a/cmd/root_test.go b/cmd/root_test.go index fb1759e8f..57cdf07f2 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -137,9 +137,6 @@ func TestIsCompletionCommand(t *testing.T) { } } -// TestPromoteConfigError_* lives with the implementation in -// internal/errcompat/promote_test.go. - // TestHandleRootError_SecurityPolicyCanonicalEnvelope verifies that // *errs.SecurityPolicyError flows through the canonical typed envelope // (output.WriteTypedErrorEnvelope) — type=policy, numeric code, subtype, @@ -269,12 +266,11 @@ func (f *failingWriter) Write(p []byte) (int, error) { return len(p), nil } -// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins issue #4: a -// backward-compat alias that fails on a cobra-level required flag (which -// short-circuits before RunE) still routes through the structured envelope, -// because OnInvoke records the deprecation in PreRunE and the legacy fallback -// switches to WriteErrorEnvelope when a deprecation is pending — so the -// migration notice is no longer dropped on the plain "Error:" line. +// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins that a +// backward-compat alias failing on a cobra-level required flag (which +// short-circuits before RunE) routes through the structured envelope, so the +// deprecation notice OnInvoke records in PreRunE is carried on the wire instead +// of being dropped on a plain "Error:" line. func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) t.Cleanup(func() { deprecation.SetPending(nil) }) @@ -286,9 +282,9 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) { deprecation.SetPending(&deprecation.Notice{ Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets", }) - // The bare error shape cobra's ValidateRequiredFlags produces: neither typed - // nor an *output.ExitError, so it reaches the legacy fallback. - handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values")) + // The bare error shape cobra's ValidateRequiredFlags produces: not a typed + // errs.* error, so it reaches the deprecation fallback. + exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values")) out := errOut.String() if strings.HasPrefix(strings.TrimSpace(out), "Error:") { @@ -297,12 +293,97 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) { if !strings.Contains(out, `"message"`) || !strings.Contains(out, "values") { t.Errorf("expected a JSON error envelope carrying the failure message; got:\n%s", out) } + // The envelope is typed validation, so the exit code must derive from that + // category (2) — the wire type and the exit code must not disagree. + if exit != int(output.ExitValidation) { + t.Errorf("exit = %d, want %d (validation envelope → category-derived exit)", exit, int(output.ExitValidation)) + } } -// TestHandleRootError_NoDeprecationKeepsPlainError pins the other half: with no -// deprecation pending, the legacy fallback stays a plain "Error:" line, so the -// fix does not reshape every unrecognized cobra error. -func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) { +// TestHandleRootError_AuthConfigWireGolden is the wire-consistency regression +// baseline for the errcompat teardown: it pins the typed envelope and exit code +// that the dispatcher produces for the two source-of-truth auth/config error +// shapes. The construction sites move from the dispatcher boundary to the +// origin, but every asserted field below must remain byte-for-byte unchanged. +func TestHandleRootError_AuthConfigWireGolden(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + t.Run("token missing exits 3 with token_missing authentication envelope", func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + errOut := &bytes.Buffer{} + f.IOStreams.ErrOut = errOut + + exit := handleRootError(f, internalauth.NewNeedUserAuthorizationError("u_golden")) + if exit != int(output.ExitAuth) { + t.Errorf("exit = %d, want %d (ExitAuth)", exit, int(output.ExitAuth)) + } + + errObj := decodeErrorEnvelope(t, errOut.Bytes()) + if got := errObj["type"]; got != "authentication" { + t.Errorf("error.type = %v, want %q", got, "authentication") + } + if got := errObj["subtype"]; got != "token_missing" { + t.Errorf("error.subtype = %v, want %q", got, "token_missing") + } + if got, _ := errObj["message"].(string); !strings.Contains(got, "need_user_authorization") { + t.Errorf("error.message = %q, must keep the need_user_authorization marker", got) + } + if got, _ := errObj["message"].(string); !strings.Contains(got, "u_golden") { + t.Errorf("error.message = %q, must carry the user open id", got) + } + if got, _ := errObj["hint"].(string); !strings.Contains(got, "auth login") { + t.Errorf("error.hint = %q, must point at auth login", got) + } + if got := errObj["user_open_id"]; got != "u_golden" { + t.Errorf("error.user_open_id = %v, want %q", got, "u_golden") + } + }) + + t.Run("not configured exits 3 with not_configured config envelope", func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + errOut := &bytes.Buffer{} + f.IOStreams.ErrOut = errOut + + exit := handleRootError(f, core.NotConfiguredError()) + if exit != int(output.ExitAuth) { + t.Errorf("exit = %d, want %d (config shares ExitAuth)", exit, int(output.ExitAuth)) + } + + errObj := decodeErrorEnvelope(t, errOut.Bytes()) + if got := errObj["type"]; got != "config" { + t.Errorf("error.type = %v, want %q", got, "config") + } + if got := errObj["subtype"]; got != "not_configured" { + t.Errorf("error.subtype = %v, want %q", got, "not_configured") + } + if got, _ := errObj["message"].(string); !strings.Contains(got, "not configured") { + t.Errorf("error.message = %q, want the not-configured message", got) + } + if got, _ := errObj["hint"].(string); !strings.Contains(got, "config init") { + t.Errorf("error.hint = %q, must point at config init", got) + } + }) +} + +// decodeErrorEnvelope unmarshals a typed error envelope and returns its +// top-level "error" object, failing the test if the shape is unexpected. +func decodeErrorEnvelope(t *testing.T, raw []byte) map[string]any { + t.Helper() + var env map[string]any + if err := json.Unmarshal(raw, &env); err != nil { + t.Fatalf("envelope is not valid JSON: %v\n%s", err, raw) + } + errObj, ok := env["error"].(map[string]any) + if !ok { + t.Fatalf("envelope missing top-level error object: %s", raw) + } + return errObj +} + +// TestHandleRootError_NoDeprecationTypesUsageError pins that a residual cobra +// usage error (missing required flag) is typed as invalid_argument with exit 2 +// even with no deprecation pending — never cobra's plain "Error:" line. +func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) t.Cleanup(func() { deprecation.SetPending(nil) }) deprecation.SetPending(nil) @@ -311,9 +392,21 @@ func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) { errOut := &bytes.Buffer{} f.IOStreams.ErrOut = errOut - handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values")) - if !strings.HasPrefix(errOut.String(), "Error:") { - t.Errorf("no deprecation pending: want a plain 'Error:' line, got:\n%s", errOut.String()) + exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values")) + + out := errOut.String() + if strings.HasPrefix(strings.TrimSpace(out), "Error:") { + t.Fatalf("want a structured envelope, got a plain Error: line:\n%s", out) + } + errObj := decodeErrorEnvelope(t, errOut.Bytes()) + if got := errObj["type"]; got != "validation" { + t.Errorf("error.type = %v, want %q", got, "validation") + } + if got, _ := errObj["message"].(string); !strings.Contains(got, "values") { + t.Errorf("error.message = %q, must carry the failing flag name", got) + } + if exit != int(output.ExitValidation) { + t.Errorf("exit = %d, want %d (validation envelope → category-derived exit)", exit, int(output.ExitValidation)) } } @@ -337,12 +430,32 @@ func TestHandleRootError_PartialWritePreservesExitCode(t *testing.T) { } } -// TestHandleRootError_TypedOuterShortCircuitsPromote pins that when a typed -// *errs.AuthenticationError carries a legacy *NeedAuthorizationError in its -// Cause chain, the dispatcher does NOT run PromoteAuthError — doing so -// would replace the producer's TokenExpired subtype + custom hint with the -// promoted shape's TokenMissing. -func TestHandleRootError_TypedOuterShortCircuitsPromote(t *testing.T) { +// TestHandleRootError_BareErrorExitCodeNoStderr pins the silent-exit +// contract: a *output.BareError is honored for its exit code while stderr stays +// empty (stdout already carries the result, so the dispatcher must not layer a +// second envelope on top). +func TestHandleRootError_BareErrorExitCodeNoStderr(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + errOut := &bytes.Buffer{} + f.IOStreams.ErrOut = errOut + + exit := handleRootError(f, output.ErrBare(output.ExitAuth)) + if exit != int(output.ExitAuth) { + t.Errorf("exit = %d, want %d (BareError code propagated)", exit, int(output.ExitAuth)) + } + if errOut.Len() != 0 { + t.Errorf("stderr must stay empty for a bare predicate signal, got:\n%s", errOut.String()) + } +} + +// TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved pins that a typed +// *errs.AuthenticationError carrying a legacy *NeedAuthorizationError in its +// Cause chain renders the producer's TokenExpired subtype + custom hint +// verbatim — the legacy sentinel in the Cause chain never coarsens the wire +// shape. +func TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, _, _, _ := cmdutil.TestFactory(t, nil) @@ -494,136 +607,3 @@ func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) { t.Errorf("expected appended hint %q, got %q", want, authErr.Hint) } } - -// TestEnrichPermissionError_CanonicalConvergence pins that the legacy -// *output.ExitError dispatch path produces the same canonical Message + Hint -// + ConsoleURL as the typed *errs.PermissionError dispatch path. Both paths -// share errclass.CanonicalPermissionMessage / errclass.PermissionHint / -// errclass.ConsoleURL — so a wire consumer cannot tell which path produced -// the envelope. -func TestEnrichPermissionError_CanonicalConvergence(t *testing.T) { - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - - cases := []struct { - name string - larkCode int - legacyErrType string - wantMsgSubstrs []string - wantHintSubstrs []string - wantConsoleURL bool - wantNoAuthLogin bool // hint must not suggest `auth login` - }{ - { - name: "99991672 app_scope_not_applied", - larkCode: 99991672, - legacyErrType: "permission", - wantMsgSubstrs: []string{"access denied", "app cli_test", "drive:drive:read"}, - wantHintSubstrs: []string{"developer console", "open.feishu.cn"}, - wantConsoleURL: true, - wantNoAuthLogin: true, - }, - { - name: "99991679 missing_scope", - larkCode: 99991679, - legacyErrType: "permission", - wantMsgSubstrs: []string{"unauthorized", "user authorization"}, - wantHintSubstrs: []string{"lark-cli auth login"}, - }, - { - name: "99991673 app_unavailable", - larkCode: 99991673, - legacyErrType: "app_status", - wantMsgSubstrs: []string{"unauthorized app", "app cli_test", "not properly installed"}, - wantHintSubstrs: []string{"tenant admin", "install status"}, - }, - { - name: "99991662 app_disabled", - larkCode: 99991662, - legacyErrType: "app_status", - wantMsgSubstrs: []string{"app cli_test", "not in use", "currently disabled"}, - wantHintSubstrs: []string{"tenant admin", "re-enable"}, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "cli_test", AppSecret: "s", Brand: core.BrandFeishu, - }) - f.ResolvedIdentity = core.AsUser - - // Mimic the wire shape ErrAPI produces: legacy *ExitError with - // Detail.Type populated by ClassifyLarkError, Detail.Detail - // carrying the permission_violations block so ExtractRequiredScopes - // can recover the missing scope. - scopeForDetail := "drive:drive:read" - exitErr := &output.ExitError{ - Code: output.ExitAPI, - Detail: &output.ErrDetail{ - Type: tc.legacyErrType, - Code: tc.larkCode, - Message: "upstream raw message — must be replaced", - Detail: map[string]interface{}{ - "permission_violations": []interface{}{ - map[string]interface{}{"subject": scopeForDetail}, - }, - }, - }, - } - enrichPermissionError(f, exitErr) - - for _, sub := range tc.wantMsgSubstrs { - if !strings.Contains(exitErr.Detail.Message, sub) { - t.Errorf("Message %q missing substring %q", exitErr.Detail.Message, sub) - } - } - if exitErr.Detail.Message == "upstream raw message — must be replaced" { - t.Errorf("Message must be rewritten to canonical text; got upstream verbatim") - } - for _, sub := range tc.wantHintSubstrs { - if !strings.Contains(exitErr.Detail.Hint, sub) { - t.Errorf("Hint %q missing substring %q", exitErr.Detail.Hint, sub) - } - } - if tc.wantNoAuthLogin && strings.Contains(exitErr.Detail.Hint, "auth login") { - t.Errorf("Hint must not suggest `auth login` for this subtype; got %q", exitErr.Detail.Hint) - } - if tc.wantConsoleURL && exitErr.Detail.ConsoleURL == "" { - t.Error("ConsoleURL should be populated when missing scopes are present") - } - }) - } -} - -// TestEnrichPermissionError_SkipsUnrelatedTypes pins that an ExitError whose -// Detail.Type is neither "permission" nor "app_status" is left untouched — -// no Message rewrite, no Hint rewrite, no ConsoleURL injection. -func TestEnrichPermissionError_SkipsUnrelatedTypes(t *testing.T) { - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ - AppID: "cli_test", AppSecret: "s", Brand: core.BrandFeishu, - }) - f.ResolvedIdentity = core.AsUser - - for _, ty := range []string{"api_error", "validation", "rate_limit", "auth"} { - exitErr := &output.ExitError{ - Code: output.ExitAPI, - Detail: &output.ErrDetail{ - Type: ty, - Code: 99991400, - Message: "untouched", - Hint: "original hint", - }, - } - enrichPermissionError(f, exitErr) - if exitErr.Detail.Message != "untouched" { - t.Errorf("type=%q: Message was rewritten unexpectedly: %q", ty, exitErr.Detail.Message) - } - if exitErr.Detail.Hint != "original hint" { - t.Errorf("type=%q: Hint was rewritten unexpectedly: %q", ty, exitErr.Detail.Hint) - } - if exitErr.Detail.ConsoleURL != "" { - t.Errorf("type=%q: ConsoleURL should not be injected; got %q", ty, exitErr.Detail.ConsoleURL) - } - } -} diff --git a/cmd/schema/schema_test.go b/cmd/schema/schema_test.go index c4eea4c58..7e1762c8e 100644 --- a/cmd/schema/schema_test.go +++ b/cmd/schema/schema_test.go @@ -5,9 +5,11 @@ package schema import ( "encoding/json" + "errors" "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" ) @@ -209,6 +211,45 @@ func TestSchemaCmd_UnknownService(t *testing.T) { if !strings.Contains(err.Error(), "Unknown service") { t.Errorf("expected 'Unknown service' error, got: %v", err) } + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } + if ve.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument) + } + if !strings.Contains(ve.Hint, "Available:") { + t.Errorf("expected hint listing available services, got: %q", ve.Hint) + } +} + +// TestSchemaCmd_UnknownMethod_TypedValidation pins the typed envelope for the +// JSON-mode unknown-method path: *errs.ValidationError with +// subtype invalid_argument and a hint listing the available methods. +func TestSchemaCmd_UnknownMethod_TypedValidation(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + cmd := NewCmdSchema(f, nil) + cmd.SetArgs([]string{"calendar.events.nonexistent_method"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for unknown method") + } + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } + if ve.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument) + } + if !strings.Contains(err.Error(), "Unknown method") { + t.Errorf("expected 'Unknown method' error, got: %v", err) + } + if !strings.Contains(ve.Hint, "Available:") { + t.Errorf("expected hint listing available methods, got: %q", ve.Hint) + } } // Completion candidate generation (dotted + space forms, strict-mode filtering, diff --git a/cmd/unknown_subcommand_test.go b/cmd/unknown_subcommand_test.go index 6bff7e7e4..8c950c113 100644 --- a/cmd/unknown_subcommand_test.go +++ b/cmd/unknown_subcommand_test.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/output" ) @@ -126,29 +127,20 @@ func TestUnknownSubcommandRunE_FlagBeforeSubcommandIsStructured(t *testing.T) { t.Errorf("error = %q, want it to mention an unknown flag", err.Error()) } - // The detail must stay schema-compatible with flagDidYouMean's unknown_flag - // (same Type → same keys), so a consumer keyed on Type reads a stable shape. - exitErr, ok := err.(*output.ExitError) - if !ok || exitErr.Detail == nil { - t.Fatalf("expected *output.ExitError with Detail, got %T", err) + // Typed surface: a validation error (exit 2) whose Params carries the + // offending flag so an agent can recover the token without parsing prose. + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) } - if exitErr.Detail.Type != "unknown_flag" { - t.Errorf("detail.Type = %q, want unknown_flag", exitErr.Detail.Type) + if verr.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype = %q, want invalid_argument", verr.Subtype) } - detail, ok := exitErr.Detail.Detail.(map[string]any) - if !ok { - t.Fatalf("expected detail to be map[string]any, got %T", exitErr.Detail.Detail) + if output.ExitCodeOf(err) != output.ExitValidation { + t.Errorf("exit code = %d, want %d", output.ExitCodeOf(err), output.ExitValidation) } - if detail["unknown"] != "--badflag" { - t.Errorf("detail.unknown = %v, want --badflag", detail["unknown"]) - } - if got, _ := detail["unknown_flags"].([]string); len(got) != 1 || got[0] != "--badflag" { - t.Errorf("detail.unknown_flags = %v, want [--badflag]", detail["unknown_flags"]) - } - for _, key := range []string{"suggestions", "valid_flags"} { - if _, present := detail[key]; !present { - t.Errorf("detail.%s missing; must be present (empty) to match the unknown_flag schema", key) - } + if len(verr.Params) != 1 || verr.Params[0].Name != "--badflag" { + t.Errorf("params = %v, want one entry named --badflag", verr.Params) } } @@ -172,25 +164,21 @@ func TestUnknownSubcommandRunE_ValidFlagWithoutSubcommandIsStructured(t *testing if err == nil { t.Fatal("expected a structured missing_subcommand error, got nil (help fallthrough)") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T", err) + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) } - if exitErr.Code != output.ExitValidation { - t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) + if output.ExitCodeOf(err) != output.ExitValidation { + t.Errorf("exit code = %d, want %d", output.ExitCodeOf(err), output.ExitValidation) } - if exitErr.Detail == nil || exitErr.Detail.Type != "missing_subcommand" { - t.Fatalf("detail.Type = %v, want missing_subcommand", exitErr.Detail) + if !strings.Contains(verr.Message, "missing subcommand") { + t.Errorf("message = %q, want it to mention a missing subcommand", verr.Message) } - detail, ok := exitErr.Detail.Detail.(map[string]any) - if !ok { - t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail) + if len(verr.Params) != 1 || verr.Params[0].Name != "--query" { + t.Errorf("params = %v, want one entry named --query", verr.Params) } - if flags, _ := detail["flags"].([]string); len(flags) != 1 || flags[0] != "--query" { - t.Errorf("detail.flags = %v, want [--query]", detail["flags"]) - } - if detail["command_path"] != "lark-cli drive" { - t.Errorf("detail.command_path = %v, want lark-cli drive", detail["command_path"]) + if !strings.Contains(verr.Message, "lark-cli drive") { + t.Errorf("message = %q, want it to name the group path", verr.Message) } } @@ -241,45 +229,23 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) { t.Fatal("expected error for unknown subcommand") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T", err) - } - if exitErr.Code != output.ExitValidation { - t.Errorf("expected exit code %d, got %d", output.ExitValidation, exitErr.Code) + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) } - if exitErr.Detail == nil { - t.Fatal("expected ExitError to carry Detail") + if output.ExitCodeOf(err) != output.ExitValidation { + t.Errorf("expected exit code %d, got %d", output.ExitValidation, output.ExitCodeOf(err)) } - if exitErr.Detail.Type != "unknown_subcommand" { - t.Errorf("expected Detail.Type=unknown_subcommand, got %q", exitErr.Detail.Type) + if !strings.Contains(verr.Message, `"+bogus"`) { + t.Errorf("message should echo the unknown token, got %q", verr.Message) } - if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) { - t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message) + if !strings.Contains(verr.Message, "lark-cli drive") { + t.Errorf("message should name the group path, got %q", verr.Message) } // "+bogus" has no close neighbor among drive's subcommands, so the hint falls - // back to pointing at --help; the full machine-readable list lives in - // detail.available below (which also excludes hidden commands). - if !strings.Contains(exitErr.Detail.Hint, "--help") { - t.Errorf("hint should guide to --help when there is no suggestion, got %q", exitErr.Detail.Hint) - } - - detail, ok := exitErr.Detail.Detail.(map[string]any) - if !ok { - t.Fatalf("expected Detail.Detail to be map[string]any, got %T", exitErr.Detail.Detail) - } - if detail["unknown"] != "+bogus" { - t.Errorf("detail.unknown should be +bogus, got %v", detail["unknown"]) - } - if detail["command_path"] != "lark-cli drive" { - t.Errorf("detail.command_path should be %q, got %v", "lark-cli drive", detail["command_path"]) - } - available, ok := detail["available"].([]string) - if !ok { - t.Fatalf("detail.available should be []string, got %T", detail["available"]) - } - if len(available) != 3 { - t.Errorf("expected 3 available entries (hidden excluded), got %d: %v", len(available), available) + // back to pointing at --help (suggestions, when present, are folded into hint). + if !strings.Contains(verr.Hint, "--help") { + t.Errorf("hint should guide to --help when there is no suggestion, got %q", verr.Hint) } } @@ -288,13 +254,12 @@ func TestUnknownSubcommandRunE_NestedResourceGroup(t *testing.T) { installUnknownSubcommandGuard(root) err := files.RunE(files, []string{"bogus"}) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError on nested group, got %T", err) + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("expected *errs.ValidationError on nested group, got %T", err) } - if exitErr.Detail.Detail.(map[string]any)["command_path"] != "lark-cli drive files" { - t.Errorf("command_path should reflect the nested resource, got %v", - exitErr.Detail.Detail.(map[string]any)["command_path"]) + if !strings.Contains(verr.Message, "lark-cli drive files") { + t.Errorf("message should reflect the nested resource path, got %q", verr.Message) } } @@ -337,10 +302,10 @@ func TestAvailableSubcommandNames_SplitsDeprecatedGroup(t *testing.T) { } } -// unknownSubcommandRunE must split current vs deprecated subcommands into -// separate detail buckets, while suggestions still rank across both so a -// mistyped legacy alias resolves. -func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) { +// unknownSubcommandRunE ranks suggestions across both current and deprecated +// subcommands so a mistyped legacy alias resolves; the closest match is folded +// into the hint. +func TestUnknownSubcommandRunE_SuggestsAcrossDeprecatedBucket(t *testing.T) { svc := &cobra.Command{Use: "sheets"} svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"}) svc.AddCommand( @@ -349,31 +314,13 @@ func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) { ) err := unknownSubcommandRunE(svc, []string{"+reat"}) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T", err) - } - detail, ok := exitErr.Detail.Detail.(map[string]any) - if !ok { - t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail) - } - - if available, _ := detail["available"].([]string); len(available) != 1 || available[0] != "+cells-get" { - t.Errorf("available = %v, want [+cells-get]", available) - } - deprecated, ok := detail["deprecated"].([]string) - if !ok || len(deprecated) != 1 || deprecated[0] != "+read" { - t.Errorf("deprecated = %v, want [+read]", deprecated) - } - // suggestions rank across both buckets: "+reat" is closest to +read. - suggestions, _ := detail["suggestions"].([]string) - found := false - for _, s := range suggestions { - if s == "+read" { - found = true - } - } - if !found { - t.Errorf("suggestions %v should include +read (typo target)", suggestions) + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) + } + // "+reat" is closest to the deprecated +read: the suggestion must surface + // in the hint, proving ranking spans the deprecated bucket. + if !strings.Contains(verr.Hint, "+read") { + t.Errorf("hint %q should suggest +read (typo target across deprecated bucket)", verr.Hint) } } diff --git a/cmd/update/update.go b/cmd/update/update.go index 6b8ce5091..43a047b2e 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/output" @@ -132,12 +133,14 @@ func updateRun(opts *UpdateOptions) error { // 1. Fetch latest version latest, err := fetchLatest() if err != nil { - return reportError(opts, io, output.ExitNetwork, "network", "failed to check latest version: %s", err) + return reportError(opts, io, "network", + errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to check latest version: %s", err).WithCause(err)) } // 2. Validate version format if update.ParseVersion(latest) == nil { - return reportError(opts, io, output.ExitInternal, "update_error", "invalid version from registry: %s", latest) + return reportError(opts, io, "update_error", + errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid version from registry: %s", latest)) } // 3. Compare versions @@ -166,15 +169,18 @@ func updateRun(opts *UpdateOptions) error { // --- Output helpers --- -func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, exitCode int, errType, format string, args ...interface{}) error { - msg := fmt.Sprintf(format, args...) +// reportError emits the failure on the requested surface: JSON mode prints the +// {ok:false, error:{type, message}} envelope to stdout and signals the typed +// error's exit code bare; human mode returns the typed error for the +// dispatcher to render. +func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, errType string, typedErr errs.TypedError) error { if opts.JSON { output.PrintJson(io.Out, map[string]interface{}{ - "ok": false, "error": map[string]interface{}{"type": errType, "message": msg}, + "ok": false, "error": map[string]interface{}{"type": errType, "message": typedErr.ProblemDetail().Message}, }) - return output.ErrBare(exitCode) + return output.ErrBare(output.ExitCodeOf(typedErr)) } - return output.Errorf(exitCode, errType, "%s", msg) + return typedErr } func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, canAutoUpdate bool) error { @@ -228,7 +234,8 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error { restore, err := updater.PrepareSelfReplace() if err != nil { - return reportError(opts, io, output.ExitAPI, "update_error", "failed to prepare update: %s", err) + return reportError(opts, io, "update_error", + errs.NewAPIError(errs.SubtypeUnknown, "failed to prepare update: %s", err).WithCause(err)) } if !opts.JSON { diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go index 5ab102bec..faf2f7629 100644 --- a/cmd/update/update_test.go +++ b/cmd/update/update_test.go @@ -14,6 +14,7 @@ import ( "testing" "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" @@ -334,13 +335,88 @@ func TestUpdateFetchError_Human(t *testing.T) { if err == nil { t.Fatal("expected non-nil error, got nil") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + var netErr *errs.NetworkError + if !errors.As(err, &netErr) { + t.Fatalf("expected *errs.NetworkError, got %T: %v", err, err) } - if exitErr.Code != output.ExitNetwork { - t.Errorf("expected ExitNetwork (%d), got %d", output.ExitNetwork, exitErr.Code) + if netErr.Subtype != errs.SubtypeNetworkTransport { + t.Errorf("subtype = %q, want %q", netErr.Subtype, errs.SubtypeNetworkTransport) } + if got := output.ExitCodeOf(err); got != output.ExitNetwork { + t.Errorf("expected ExitNetwork (%d), got %d", output.ExitNetwork, got) + } +} + +// TestUpdateInvalidVersion_Human verifies a malformed registry version surfaces +// as a typed internal error in human mode, keeping the legacy exit code 5. +func TestUpdateInvalidVersion_Human(t *testing.T) { + f, _, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "not-a-version", nil } + defer func() { fetchLatest = origFetch }() + + cmd.SilenceErrors = true + cmd.SilenceUsage = true + + err := cmd.Execute() + if err == nil { + t.Fatal("expected non-nil error, got nil") + } + var intErr *errs.InternalError + if !errors.As(err, &intErr) { + t.Fatalf("expected *errs.InternalError, got %T: %v", err, err) + } + if intErr.Subtype != errs.SubtypeInvalidResponse { + t.Errorf("subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse) + } + if got := output.ExitCodeOf(err); got != output.ExitInternal { + t.Errorf("expected ExitInternal (%d), got %d", output.ExitInternal, got) + } +} + +// TestReportError pins reportError's two surfaces after the typed migration: +// human mode returns the typed error unchanged; JSON mode prints the legacy +// {ok:false, error:{type, message}} envelope and exits bare with the typed +// error's exit code (parity with the legacy explicit exit-code argument). +func TestReportError(t *testing.T) { + t.Run("human mode returns the typed error", func(t *testing.T) { + f, _, _ := newTestFactory(t) + typed := errs.NewAPIError(errs.SubtypeUnknown, "failed to prepare update: disk full") + err := reportError(&UpdateOptions{JSON: false}, f.IOStreams, "update_error", typed) + var apiErr *errs.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *errs.APIError, got %T: %v", err, err) + } + if apiErr != typed { + t.Errorf("reportError must return the typed error unchanged") + } + if got := output.ExitCodeOf(err); got != output.ExitAPI { + t.Errorf("exit code = %d, want %d (ExitAPI, legacy parity)", got, output.ExitAPI) + } + }) + + t.Run("json mode prints envelope and exits bare with typed code", func(t *testing.T) { + f, stdout, _ := newTestFactory(t) + typed := errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to check latest version: timeout") + err := reportError(&UpdateOptions{JSON: true}, f.IOStreams, "network", typed) + var bareErr *output.BareError + if !errors.As(err, &bareErr) { + t.Fatalf("expected bare *output.BareError, got %T: %v", err, err) + } + if bareErr.Code != output.ExitNetwork { + t.Errorf("bare exit code = %d, want %d", bareErr.Code, output.ExitNetwork) + } + out := stdout.String() + if !strings.Contains(out, `"type": "network"`) && !strings.Contains(out, `"type":"network"`) { + t.Errorf("JSON envelope missing type, got: %s", out) + } + if !strings.Contains(out, "failed to check latest version: timeout") { + t.Errorf("JSON envelope missing message, got: %s", out) + } + }) } func TestUpdateInvalidVersion_JSON(t *testing.T) { @@ -503,12 +579,12 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing. if err == nil { t.Fatal("expected verification failure") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + var bareErr *output.BareError + if !errors.As(err, &bareErr) { + t.Fatalf("expected *output.BareError, got %T: %v", err, err) } - if exitErr.Code != output.ExitAPI { - t.Fatalf("expected ExitAPI (%d), got %d", output.ExitAPI, exitErr.Code) + if bareErr.Code != output.ExitAPI { + t.Fatalf("expected ExitAPI (%d), got %d", output.ExitAPI, bareErr.Code) } out := stdout.String() diff --git a/errs/ERROR_CONTRACT.md b/errs/ERROR_CONTRACT.md index b13bea963..6577993ba 100644 --- a/errs/ERROR_CONTRACT.md +++ b/errs/ERROR_CONTRACT.md @@ -6,25 +6,16 @@ envelope on stderr; **protocol adapters** mapping CLI errors into MCP / OAuth shapes; and **framework + business code** producing errors. This file is the single source of truth for all three. -This document describes the **typed authoring target**. The refactor lands -in stages; some boundaries (e.g. `client.WrapDoAPIError`) still operate on -legacy shapes today — see **Migration** for what is live in each stage. - -Migrating an `*output.ExitError` call site? See **Migration**. Something off -in production? See **Troubleshooting**. +Something off in production? See **Troubleshooting**. ## Invariants 1. Every error belongs to exactly one **Category**. The set is closed (`errs/category.go`); adding a member requires deliberate review. -2. Every **newly constructed** typed error has a **Subtype** — a stable +2. Every typed error has a **Subtype** — a stable lowercase-with-underscores identifier declared in `errs/subtypes*.go`. - Undeclared subtypes fail CI. The constraint applies only to typed - `*errs.*` literals; stage-1 legacy `*core.ConfigError` flows via the - dispatcher's `asExitError` → legacy envelope path (not the typed - taxonomy) and is unaffected. `errcompat.PromoteConfigError` is a - stage-1 passthrough; its stage-2+ typed migration will subject the - promoted typed error to this Subtype constraint at that time. + Undeclared subtypes fail CI. Every error path constructs a typed + `*errs.*` error at its origin, so the constraint applies uniformly. 3. **`Category` + `Subtype`** are wire-stable identifiers consumers may branch on. Renaming either is a breaking change. 4. `Code` is the upstream numeric code when known (e.g. Lark API code). @@ -35,11 +26,10 @@ in production? See **Troubleshooting**. unchanged across the `errors.As` / `errors.Unwrap` chain. 7. For the typed-envelope path, exit codes derive from `Category` only via `output.ExitCodeForCategory` — including `SecurityPolicyError`, - which exits `6` via `CategoryPolicy`. Unmigrated `*output.ExitError` - producers still carry a hand-set `Code` until they finish migrating. - `output.ErrBare(code)` is the lone exception: a deliberate - predicate-command signal that bypasses the envelope (see - **Predicate commands** below). + which exits `6` via `CategoryPolicy`. `output.ErrBare(code)` is the + exception: it constructs an `*output.BareError`, a deliberate + silent-exit signal (stdout already carries the answer) that bypasses + the envelope (see **Predicate commands** below). ## Wire format @@ -73,13 +63,14 @@ Typed errors render to **stderr** as one JSON object per process exit: | `error.hint` | informational | actionable recovery guidance | | `error.log_id` | informational | upstream request id (server-side trace) | | `error.retryable` | wire-stable | `true` when present; omitted when `false` | +| `error.param` | per-Subtype-stable | single offending parameter (`ValidationError`); see **Validation parameters** | +| `error.params` | per-Subtype-stable | per-parameter validation detail array (`ValidationError`); see **Validation parameters** | | per-Subtype extension fields | per-Subtype-stable | e.g. `missing_scopes`, `console_url`, `challenge_url` | `SecurityPolicyError` renders through the same typed envelope as every other category. `error.type` is `"policy"`, `error.subtype` is one of `challenge_required` / `access_denied`, and process exit is `6` via -`CategoryPolicy`. The legacy `auth_error` envelope at exit `1` has been -retired. +`CategoryPolicy`. ## Categories @@ -119,20 +110,21 @@ Canonical mapping: `internal/output/exitcode.go` `ExitCodeForCategory`. │ ▼ cmd/root.go handleRootError dispatches: - ├─ output.ErrBare(code) → no envelope (stdout already written); exit = code ├─ typed (errs.ProblemOf) → typed JSON envelope; exit = ExitCodeOf(err) - │ (includes *errs.SecurityPolicyError → policy envelope, exit 6) - ├─ *core.ConfigError → promoted to typed via errcompat ↑ - ├─ *output.ExitError → legacy JSON envelope; exit = exitErr.Code - └─ untyped / Cobra error → plain "Error: " (no envelope); exit 1 + │ (includes *errs.SecurityPolicyError → policy envelope, exit 6; + │ *errs.ConfigError, constructed typed at origin) + ├─ *output.PartialFailureError → no stderr envelope (ok:false result already on stdout); exit = code + ├─ *output.BareError → no envelope (stdout already written); exit = code + └─ Cobra usage error → typed validation envelope (invalid_argument); exit 2 ``` -Only the typed and `*output.ExitError` branches emit a JSON envelope on -stderr. Untyped errors (including Cobra's "required flag missing" / unknown -subcommand messages) print plain text and exit `1` — consumers must -tolerate that fallback. +The dispatcher emits a JSON envelope on stderr for both the typed branch and +residual Cobra usage errors (missing required flag, unknown command, +argument validation): the latter are classified into a typed validation +envelope (`invalid_argument`) and exit `2`, matching the explicit flag and +subcommand guards. -### Predicate commands (`output.ErrBare`) +### Predicate commands (`output.BareError`) A small class of commands is **predicates**: they answer a yes/no question and signal the answer through the shell exit code so callers @@ -142,19 +134,27 @@ example — its `README` contract is `exit 0 = ok, 1 = missing`. These commands deliberately: 1. write a structured JSON answer to **stdout** themselves, and -2. return `output.ErrBare(exitCode)` to communicate the exit code to - the dispatcher without producing a `stderr` envelope. - -`output.ErrBare` is **not** an error in the typed-envelope sense — it -carries no category, subtype, or message. It is a one-bit output- -control signal that lives outside the contract for the same reason -`grep -q` / `diff` / `systemctl is-active` set non-zero exit codes -without printing anything to stderr: pollution of stderr by a +2. return `output.ErrBare(exitCode)` — an `*output.BareError` — to + communicate the exit code to the dispatcher without producing a + `stderr` envelope. + +`*output.BareError` is **not** an error in the typed-envelope sense — it +carries no category, subtype, or message, only an exit code. It is a +one-bit output-control signal that lives outside the contract for the +same reason `grep -q` / `diff` / `systemctl is-active` set non-zero exit +codes without printing anything to stderr: pollution of stderr by a predicate's negative answer would break `2>/dev/null` log hygiene in caller scripts. -New code should not reach for `ErrBare` unless the command is -genuinely a predicate. Anything carrying recoverable error content +A second class also uses `ErrBare`: a command that emits its own complete +structured result envelope on **stdout** under `--json` (e.g. `update`, whose +`{ok:false, error:{type, message}}` is its established output shape) and needs +only the exit code conveyed, with no `stderr` envelope. Like a predicate, its +answer is already on stdout; `ErrBare` carries the exit code alone. + +New code should not reach for `ErrBare` unless the command's full answer is +already on stdout — a predicate's yes/no, or a self-contained result envelope +as above. Anything whose error content must reach the caller on `stderr` belongs in a typed `*errs.XxxError` — or, for a batch result, in the partial-failure outcome below. @@ -214,7 +214,7 @@ exitCode := output.ExitCodeOf(err) // ExitInternal for non-typed errors out=$(lark-cli ... 2>&1) code=$? -# Untyped / Cobra errors print plain text — guard before jq. +# Defensive guard: tolerate any non-JSON output before parsing with jq. if ! jq -e . >/dev/null 2>&1 <<<"$out"; then printf '%s\n' "$out" >&2 exit "$code" @@ -303,9 +303,10 @@ Do not pick exit codes by hand in new typed producers — `ExitCodeForCategory` maps `Category` to the shell code. A new exit-code requirement means a new `Category`, not a one-off override at the call site. -(Legacy `*output.ExitError` retains hand-set codes until removal; -`SecurityPolicyError` retains a hand-set code on main until the framework -migration PR retires the carve-out — see **Migration**.) +(The only exits not derived from `Category` are the +`*output.BareError` and the `*output.PartialFailureError` signals, which +carry their own code by design and sit outside the typed-envelope contract — +see **Predicate commands**.) #### Split `Message`, `Hint`, and `Cause` @@ -340,15 +341,50 @@ Message: fmt.Sprintf("request failed: %v — retry later", ioErr) // conflates what + what-to-do + cause into one string ``` -#### `ValidationError.Param` uses the `--flag` form +#### Validation parameters: `Param` and `Params` -When a `*ValidationError` originates from a flag value, `Param` holds the -flag name with leading dashes (`"--priority"`, not `"priority"`). AI -agents grep this field literally to surface "the bad flag was `--X`". +`ValidationError` carries two additive parameter fields. Both are +optional; a producer sets whichever fits the failure. -For positional arguments, use the canonical name without dashes +**`Param string` (wire `param`)** — the single offending parameter. When a +`*ValidationError` originates from a flag value, `Param` holds the flag +name with leading dashes (`"--priority"`, not `"priority"`). AI agents +grep this field literally to surface "the bad flag was `--X`". For +positional arguments, use the canonical name without dashes (`"target_user_id"`). +**`Params []InvalidParam` (wire `params`)** — per-parameter validation +detail, for failures that need to report *which* parameters failed and +*why*, one entry each. Each `errs.InvalidParam` is `{Name, Reason string}`: +`Name` identifies the parameter, `Reason` states why it failed. This is +the CLI's rendering of the RFC 7807 `invalid-params` extension member +(RFC 7807 §3.1). The wire key is `params`, not `invalid_params`: the +enclosing envelope already carries `type:"validation"`, so the `invalid_` +qualifier would be redundant on the wire. + +`Param` and `Params` are independent additive fields, not alternates of a +single representation. Use `Param` for the common single-parameter error; +use `Params` when one failure spans several parameters or needs a +per-parameter reason. Set with `.WithParam("--flag")` / `.WithParams(...)`. + +A `params` wire example (multiple parameters each carrying a reason): + +```json +{ + "ok": false, + "identity": "user", + "error": { + "type": "validation", + "subtype": "invalid_argument", + "message": "2 parameters failed validation", + "params": [ + { "name": "--start", "reason": "expected RFC3339, got \"yesterday\"" }, + { "name": "--end", "reason": "must be after --start" } + ] + } +} +``` + ### Constructing typed errors Prefer the **builder API**. The constructor pins `Category` + `Subtype` + @@ -378,44 +414,11 @@ them on the dynamic dispatch path where a `Problem` value is composed once and wrapped per Category branch. Outside that pattern, new code should reach for the builder. -Legacy helpers (`output.ErrValidation`, `output.ErrAuth`, `output.ErrNetwork`) -remain callable during migration but are `// Deprecated:` — new code goes -through the builder. - -#### Shortcut `Execute` walkthrough - -Adapted from `shortcuts/calendar/calendar_suggestion.go:222`, whose legacy -form is `output.ErrValidation("--duration-minutes must be between 1 and -1440")`. The typed migration target (builder form): - -```go -Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - duration := runtime.Int("duration-minutes") - if duration < 1 || duration > 1440 { - return errs.NewValidationError(errs.SubtypeInvalidArgument, - "--duration-minutes must be between 1 and 1440, got %d", duration). - WithHint("pass a value in [1, 1440]"). - WithParam("--duration-minutes") - } - - _, err := runtime.DoAPI(req, opts) - if err != nil { - return err // already typed by the framework boundary; propagate - } - return nil -} -``` - -Two patterns visible: a producer site (the typed `*errs.ValidationError` -above) and a propagation site (the `return err` after `runtime.DoAPI`, -applying [Propagate typed errors unchanged](#propagate-typed-errors-unchanged)). - -When the validation logic outgrows a single range check — multiple -flags, format parsing, conditional rules — extract it into a helper that -also returns the typed `*errs.ValidationError`. The helper, not -`Execute`, sets `Param` (a helper bound to one shortcut is normal in -this codebase; see `parseTimeRange` in -`shortcuts/calendar/calendar_agenda.go:144`). +When the validation logic outgrows a single range check — multiple flags, +format parsing, conditional rules — extract it into a helper that also returns +the typed `*errs.ValidationError`; the helper, not `Execute`, sets `Param` (a +helper bound to one shortcut is normal in this codebase; see `parseTimeRange` +in `shortcuts/calendar/calendar_agenda.go`). ### Wrapping upstream errors @@ -479,7 +482,7 @@ Rare; the existing structs cover the 9 Categories with room. If you must: 1. In `errs/types.go`, add a new section with: the struct embedding `errs.Problem`, a nil-receiver-safe `Unwrap()` if it carries `Cause`, a `NewXxxError(subtype, format, args...)` constructor, and one chained `WithX` setter per extension field. 2. Add an `IsXxx` predicate in `errs/predicates.go`. -3. Add a wire-format pin in `errs/marshal_test.go` and a builder-chain pin in `errs/types_builder_test.go`. +3. Add a wire-format pin in `errs/marshal_test.go` and a builder-chain pin in `errs/types_test.go`. `CheckProblemEmbed` enforces the `Problem` embed at lint time. New top-level wire fields are forbidden — per-Subtype data goes into the @@ -488,19 +491,33 @@ top level. ## CI guards -| Check | Enforces | Where | -|-------|----------|-------| -| forbidigo | business path (`shortcuts/**`, `cmd/service/**`) must not call legacy `output.*` error constructors — route through the typed classifier | `.golangci.yml` | -| `CheckProblemEmbed` | every exported `*Error` embeds `errs.Problem` | `lint/errscontract/` AST | -| `CheckNoRegistrar` | no `mergeCodeMeta` / `RegisterServiceMap` from service code | `lint/errscontract/` AST | -| `CheckAdHocSubtype` | `ad_hoc_*` Subtypes labeled for promotion (warn) | `lint/errscontract/` AST | -| `CheckDeclaredSubtype` | every `Subtype:` value is a declared constant or `ad_hoc_*` | `lint/errscontract/` AST | -| `CheckTypedErrorCompleteness` | every `*errs.Error{Problem: errs.Problem{...}}` literal must set `Category`, `Subtype`, and `Message` | `lint/errscontract/` AST | +Two golangci-lint rules and the custom `errscontract` AST module enforce the +contract; CI runs all three on every PR. + +**golangci-lint** — scopes are defined in `.golangci.yml` (not duplicated here, +so this spec cannot drift from the lint config): + +| Rule | Enforces | +|------|----------| +| forbidigo `errs-no-bare-wrap` | a command / wire-boundary final error must be typed (`errs.NewXxxError`), never a bare `fmt.Errorf` / `errors.New`; a genuine intermediate wrap opts out with `//nolint:forbidigo` + a reason | +| errorlint | every error wrap uses `%w` and every comparison uses `errors.Is` / `errors.As` — interior wraps stay legal but cannot break the `errors.Unwrap` chain the typed boundary relies on | + +**errscontract** (`lint/errscontract/`, a separate Go module so its +`golang.org/x/tools` dependency stays out of the shipped binary; run locally +with `go run -C lint . ..`): + +| Check | Enforces | +|-------|----------| +| `CheckNoLegacyEnvelopeLiteral` / `CheckNoLegacyCommonHelperCall` / `CheckNoLegacyRuntimeAPICall` | the removed `output.*` legacy error surface cannot be reintroduced anywhere | +| `CheckProblemEmbed` | every exported `*Error` embeds `errs.Problem` | +| `CheckDeclaredSubtype` | every `Subtype:` value is a declared constant (or `ad_hoc_*`) | +| `CheckTypedErrorCompleteness` | every typed-error struct literal sets `Category`, `Subtype`, and `Message` | +| `CheckAdHocSubtype` | `ad_hoc_*` Subtypes flagged for promotion (warning) | +| `CheckNoRegistrar` | no `mergeCodeMeta` / `RegisterServiceMap` from service code | -CI runs `lint/` on every PR. Locally: `go run -C lint . ..`. The -lintcheck CLI lives in its own Go module so its `golang.org/x/tools` -dependency stays out of the shipped `lark-cli` binary's module graph; -see `lint/README.md` for how to add a new lint domain. +`errscontract` also carries framework-internal invariants (nil-safe `Unwrap`, +builder immutability, unwrap symmetry); see `lint/errscontract/` for the full +set and `lint/README.md` for adding a new lint domain. ## Stability @@ -510,67 +527,13 @@ see `lint/README.md` for how to add a new lint domain. | Additive | new Category, new declared Subtype, new extension field on an existing struct | minor release; consumers ignore unknown fields by contract | | Experimental | `ad_hoc_*` Subtypes; fields documented as such in `errs/types.go` | may change or be promoted/removed within one release | -The deprecated `*output.ExitError` surface is outside these tiers — it -will be removed once business migration completes. - -## Migration - -**Strategy shift (2026-05-26).** The original plan (`docs/design/errors-refactor/spec.md` v2.12 §9) was a centrally-driven 4-PR rollout — framework → auth domain → multi-pilot → full-repo + legacy removal. That plan is **superseded** by a hybrid model: framework owner ships framework-level hardening (including a typed `*errs.*Error` migration of `internal/**`) as one focused PR; business-domain typed migration is **self-service** via [`docs/errors-guide.md`](../docs/errors-guide.md) and the builder API, with no central sweep timeline. - -Why the shift: 800+ legacy call sites split across 8+ business domains do not all share a single reviewer's bandwidth, and the contract is now expressive enough that each domain owner can migrate their own code from the guide without coordinating with framework owner. - -### Current state - -1. **Framework slice — ✅ shipped (PR #984).** The `errs/` typed taxonomy, classifier (`internal/errclass`), promotion stub (`internal/errcompat`, passthrough), dispatcher hook (`WriteTypedErrorEnvelope`), and the `lint/errscontract` AST guards. Wire shapes preserved byte-for-byte versus pre-PR, with **one intentional semantic fix**: config-class errors (`*core.ConfigError`) now exit `3` instead of `2`, aligning with `ExitCodeForCategory` (config errors share the auth exit slot per the taxonomy). The classifier and promote helpers are *shipped but unused* in production paths — they exist so framework migration can plug in without re-architecting. - -2. **Builder API — ✅ shipped (this branch).** `errs/types.go` adds the canonical producer surface (`errs.NewXxxError(subtype, format, args...).WithX(...)`) for all 10 typed types, alongside each struct declaration. Constructor signature pins `Category` (via function name) and `Subtype` + `Message` (positional), so the producer cannot mis-specify any of the three identity fields. Optional fields chain through `.WithX(...)` setters that preserve the concrete pointer type. - -### Next: framework migration PR (planned) - -A single PR consolidates the work the original §9 spec split across PRs 2–4 — restricted to framework code, no business sweep: - -- **Migrate `internal/**` typed construction to the builder API.** ~16 call sites in `internal/errclass/classify.go` (BuildAPIError fanout), `internal/auth/transport.go` (SecurityPolicy), `internal/auth/uat_client.go`, `internal/errcompat/promote*.go`, `internal/client/client.go`, `internal/client/api_errors.go`. -- **Land the framework-side semantic changes** previously scoped to spec §9 PR 2: `SecurityPolicyError` exit `1→6`, `WrapDoAPIError` typed (`*NetworkError` with subtype timeout/tls/dns/server_error/transport, `*InternalError` for JSON-decode), `WrapJSONResponseParseError` typed, `errcompat.PromoteConfigError` real Type routing, `PromoteAuthError` helper + dispatcher wiring, 10 credential Lark codes registered in codeMeta, 99991543 config classification, `resolveAccessToken` typed `*AuthenticationError`, `BuildAPIError` filling `*PermissionError.MissingScopes` / `Identity` / `ConsoleURL`, deletion of `scopeAwareChecker`. -- **Add `forbidigo` rule** banning `output.Err*` constructors in `shortcuts/**` and `cmd/**` (mirrors the contract that new business code must use the builder). -- **CHANGELOG** lists the resulting ~10 shell-exit-code shifts in one release entry (vs the spec §1 spread of 11 — the remaining one site lives in `task` business code). - -### Business-domain migration (self-service, no central timeline) - -Each business package migrates its own `output.Err*` call sites to the builder when convenient — typically batched within one domain. The guide at [`docs/errors-guide.md`](../docs/errors-guide.md) walks owners through the 8 typical error modes (validation / authorization / authentication / config / network / api / internal / policy) with real `file:line` examples from main. The three-layer extension model (add Subtype / add field / add Category) handles cases the existing taxonomy does not cover. - -Helper assertions accept both shapes during migration (see `shortcuts/mail/mail_shortcut_validation_test.go` `assertValidationError`) so domain migrations stay green incrementally. - -### Legacy removal - -Deferred until business migration completion approaches the asymptote. `Errorf`, `ErrAPI`, `ErrAuth`, `ErrWithHint`, `ErrBare`, `ClassifyLarkError`, `ErrDetail`, `ExitError`, and `ErrorEnvelope` are `// Deprecated:` today and stay callable. No fixed removal date. - -### Before / after at a call site - -```go -// before (legacy) -return output.ErrAPI(larkCode, "create event failed", resp.RawBody()) - -// after (typed) — cc carries Brand / AppID / Identity from the caller's context -return errclass.BuildAPIError(parsedResp, cc) -``` - -```go -// before (legacy validation) -return output.ErrValidation("--duration-minutes must be between 1 and 1440") - -// after (builder) -return errs.NewValidationError(errs.SubtypeInvalidArgument, - "--duration-minutes must be between 1 and 1440, got %d", duration). - WithParam("--duration-minutes") -``` - ## Troubleshooting **Envelope shows `type=api subtype=unknown` for what should be a more specific category.** The Lark code is unknown to `LookupCodeMeta` and fell through to the generic bucket (`internal/errclass/classify.go`). Add the code to `internal/errclass/codemeta_.go` with the right Category -and Subtype, plus a dispatch test in `classify_test.go`. +and Subtype, plus a dispatch test in `internal/errclass/classify_test.go`. **Envelope shows `type=internal subtype=sdk_error`.** Origin is `client.WrapDoAPIError` taking the non-transport branch @@ -613,8 +576,6 @@ string cannot be classified retroactively. - *Add a new condition?* → **Add a Subtype** - *Consume from a shell script?* → **Consumers / Shell / AI** - *Understand or fix a CI failure?* → **CI guards** -- *Migrate a legacy `ExitError` call site?* → **Migration** + the - Deprecated note on the symbol being replaced. - *Read source.* → `errs/doc.go` → `errs/category.go` → `errs/types.go` → `errs/predicates.go` → `internal/errclass/` → `cmd/root.go` `handleRootError`. diff --git a/errs/raw.go b/errs/raw.go new file mode 100644 index 000000000..049f5c802 --- /dev/null +++ b/errs/raw.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errs + +import "errors" + +// rawPassthrough marks an error as raw passthrough: the dispatcher must not +// rewrite its message or hint with local enrichment. Raw is +// dispatcher-internal routing state, not a wire field. It is deliberately not +// a typed taxonomy error (no embedded Problem) — it only wraps one. +type rawPassthrough struct{ err error } + +func (e *rawPassthrough) Error() string { return e.err.Error() } +func (e *rawPassthrough) Unwrap() error { return e.err } + +// MarkRaw wraps err as raw passthrough. MarkRaw(nil) returns nil. +func MarkRaw(err error) error { + if err == nil { + return nil + } + return &rawPassthrough{err: err} +} + +// IsRaw reports whether err or any error in its chain is marked raw. +func IsRaw(err error) bool { + var raw *rawPassthrough + return errors.As(err, &raw) +} diff --git a/errs/raw_test.go b/errs/raw_test.go new file mode 100644 index 000000000..1330b49aa --- /dev/null +++ b/errs/raw_test.go @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errs_test + +import ( + "encoding/json" + "errors" + "fmt" + "testing" + + "github.com/larksuite/cli/errs" +) + +func TestMarkRawNilReturnsNil(t *testing.T) { + if got := errs.MarkRaw(nil); got != nil { + t.Fatalf("MarkRaw(nil) = %v, want nil", got) + } +} + +func TestIsRaw(t *testing.T) { + base := fmt.Errorf("boom") + + if !errs.IsRaw(errs.MarkRaw(base)) { + t.Errorf("IsRaw(MarkRaw(err)) = false, want true") + } + if errs.IsRaw(base) { + t.Errorf("IsRaw(bare err) = true, want false") + } + if errs.IsRaw(nil) { + t.Errorf("IsRaw(nil) = true, want false") + } + + // Raw marking survives further wrapping above it in the chain. + wrapped := fmt.Errorf("outer: %w", errs.MarkRaw(base)) + if !errs.IsRaw(wrapped) { + t.Errorf("IsRaw(wrap(MarkRaw(err))) = false, want true") + } +} + +func TestMarkRawPreservesErrorMessage(t *testing.T) { + base := fmt.Errorf("boom") + if got := errs.MarkRaw(base).Error(); got != "boom" { + t.Fatalf("MarkRaw(err).Error() = %q, want %q", got, "boom") + } +} + +func TestMarkRawPreservesErrorsIsChain(t *testing.T) { + sentinel := errors.New("sentinel") + wrapped := fmt.Errorf("ctx: %w", sentinel) + + if !errors.Is(errs.MarkRaw(wrapped), sentinel) { + t.Fatalf("errors.Is(MarkRaw(err), sentinel) = false, want true") + } +} + +func TestProblemOfPunchesThroughMarkRaw(t *testing.T) { + typed := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad flag") + raw := errs.MarkRaw(typed) + + p, ok := errs.ProblemOf(raw) + if !ok { + t.Fatalf("ProblemOf(MarkRaw(typed)) ok = false, want true") + } + if p.Category != errs.CategoryValidation { + t.Errorf("ProblemOf(MarkRaw(typed)).Category = %v, want %v", p.Category, errs.CategoryValidation) + } + + // errors.As still finds the concrete typed error through the raw wrapper. + var ve *errs.ValidationError + if !errors.As(raw, &ve) { + t.Errorf("errors.As(MarkRaw(typed), *ValidationError) = false, want true") + } +} + +// TestMarkRawUnwrapsToInnerTypedError pins the envelope-serialization +// contract: UnwrapTypedError must return the inner concrete typed error, +// not the rawPassthrough wrapper. The wrapper has no exported fields, so if it +// were returned the JSON envelope would marshal to an empty "{}" error. +func TestMarkRawUnwrapsToInnerTypedError(t *testing.T) { + base := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad flag") + typed, ok := errs.UnwrapTypedError(errs.MarkRaw(base)) + if !ok { + t.Fatal("UnwrapTypedError(MarkRaw(typed)) must find a typed error") + } + out, err := json.Marshal(typed) + if err != nil { + t.Fatal(err) + } + if string(out) == "{}" { + t.Fatalf("UnwrapTypedError returned the opaque rawPassthrough wrapper; envelope would be empty: %s", out) + } + if got := errs.CategoryOf(typed); got != errs.CategoryValidation { + t.Fatalf("unwrapped category = %q, want validation", got) + } +} diff --git a/errs/types_test.go b/errs/types_test.go index c59a862b1..52e0f32e1 100644 --- a/errs/types_test.go +++ b/errs/types_test.go @@ -101,9 +101,9 @@ func TestSecurityPolicyErrorUnwrap(t *testing.T) { // interface would panic when the root dispatcher or any caller walks the // errors.Is / errors.Unwrap chain. // -// The doc comments on these types claim "nil-receiver safe" but until this -// test landed nothing actually pinned that claim — exactly the -// behavioral-comment-without-test footgun caught in PR #984 review. +// The doc comments on these types claim "nil-receiver safe"; this test +// pins that claim so the behavioral comment cannot silently drift from the +// implementation. func TestTypedErrors_UnwrapNilReceiver(t *testing.T) { t.Helper() checks := []struct { diff --git a/extension/platform/abort.go b/extension/platform/abort.go index 9ec99d8b5..c3dcb622b 100644 --- a/extension/platform/abort.go +++ b/extension/platform/abort.go @@ -7,8 +7,8 @@ import "fmt" // AbortError is returned by a Wrapper that wants to short-circuit the // command chain (instead of calling next). The framework converts it -// to an *output.ExitError with type "hook" so the JSON envelope carries -// the structured fields agents expect. +// to a typed errs.* error so the JSON envelope carries the structured +// fields agents expect. // // HookName is the framework-namespaced name ("secaudit.approval"); the // Registrar adds the plugin-name prefix automatically. diff --git a/extension/platform/errors.go b/extension/platform/errors.go index 7bd99f2d2..1c36bcc9a 100644 --- a/extension/platform/errors.go +++ b/extension/platform/errors.go @@ -7,9 +7,9 @@ import "fmt" // CommandDeniedError is the structured error returned by a denyStub. Every // pruned-command execution path -- direct invocation, alias expansion, -// internal call -- returns this exact type. It is wire-compatible with the -// output.ExitError envelope via the Layer (== error.type) field and the -// detail map produced by ExitError(). +// internal call -- returns this exact type. The dispatcher converts it to a +// typed errs.* error; the Layer field carries the denial layer for the +// envelope. // // Layer values: // diff --git a/internal/auth/errors.go b/internal/auth/errors.go index 13b9eeeb1..eb326d547 100644 --- a/internal/auth/errors.go +++ b/internal/auth/errors.go @@ -6,7 +6,6 @@ package auth import ( "errors" "fmt" - "strings" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" @@ -22,7 +21,10 @@ var TokenRetryCodes = map[int]bool{ output.LarkErrTokenExpired: true, } -// NeedAuthorizationError is thrown when no valid UAT exists. +// NeedAuthorizationError is the sentinel preserved in the Cause chain of the +// typed missing-UAT error so existing errors.As(&NeedAuthorizationError{}) +// consumers keep matching after the construction site moved to the typed +// taxonomy. It is never surfaced on the wire on its own. type NeedAuthorizationError struct { UserOpenId string } @@ -32,24 +34,31 @@ func (e *NeedAuthorizationError) Error() string { return fmt.Sprintf("%s (user: %s)", needUserAuthorizationMarker, e.UserOpenId) } +// NewNeedUserAuthorizationError builds the typed *errs.AuthenticationError +// returned when no valid UAT exists for userOpenID. The Message keeps the +// need_user_authorization marker, the Hint converges on the same auth-login +// recovery vocabulary as the token-missing surface in internal/client, and the +// legacy *NeedAuthorizationError sentinel is preserved in the Cause chain for +// errors.As / errors.Is traversal. +func NewNeedUserAuthorizationError(userOpenID string) *errs.AuthenticationError { + return errs.NewAuthenticationError(errs.SubtypeTokenMissing, + "%s (user: %s)", needUserAuthorizationMarker, userOpenID). + WithUserOpenID(userOpenID). + WithHint("run: lark-cli auth login to re-authorize"). + WithCause(&NeedAuthorizationError{UserOpenId: userOpenID}) +} + // IsNeedUserAuthorizationError reports whether err represents a missing-UAT -// failure, either as the original auth error or as a wrapped ExitError. +// failure. It matches the legacy *NeedAuthorizationError sentinel, which is +// preserved in the Cause chain of the typed missing-UAT error, so errors.As +// traverses into the typed *errs.AuthenticationError as well. func IsNeedUserAuthorizationError(err error) bool { if err == nil { return false } var needAuthErr *NeedAuthorizationError - if errors.As(err, &needAuthErr) { - return true - } - - // Deprecated: legacy *output.ExitError / string-match branches; removed after typed migration. - var exitErr *output.ExitError - if errors.As(err, &exitErr) && exitErr.Detail != nil { - return strings.Contains(exitErr.Detail.Message, needUserAuthorizationMarker) - } - return strings.Contains(err.Error(), needUserAuthorizationMarker) + return errors.As(err, &needAuthErr) } // SecurityPolicyError is preserved as a Go type alias so existing diff --git a/internal/auth/errors_test.go b/internal/auth/errors_test.go index bb66e3793..5d04caf63 100644 --- a/internal/auth/errors_test.go +++ b/internal/auth/errors_test.go @@ -6,7 +6,7 @@ package auth import ( "testing" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" ) func TestIsNeedUserAuthorizationError(t *testing.T) { @@ -22,15 +22,16 @@ func TestIsNeedUserAuthorizationError(t *testing.T) { } }) - t.Run("wrapped exit error", func(t *testing.T) { - err := output.ErrNetwork("API call failed: %s", &NeedAuthorizationError{}) - if !IsNeedUserAuthorizationError(err) { - t.Fatal("expected wrapped ExitError to match") + t.Run("typed missing-UAT error carries sentinel in cause", func(t *testing.T) { + // The typed constructor preserves the legacy sentinel in the Cause + // chain, so errors.As traverses into it. + if !IsNeedUserAuthorizationError(NewNeedUserAuthorizationError("u_1")) { + t.Fatal("expected typed missing-UAT error to match via its cause chain") } }) t.Run("other error", func(t *testing.T) { - err := output.ErrNetwork("API call failed: timeout") + err := errs.NewNetworkError(errs.SubtypeNetworkTransport, "API call failed: timeout") if IsNeedUserAuthorizationError(err) { t.Fatal("expected unrelated error not to match") } diff --git a/internal/auth/uat_client.go b/internal/auth/uat_client.go index 0be897892..d1a068392 100644 --- a/internal/auth/uat_client.go +++ b/internal/auth/uat_client.go @@ -71,7 +71,7 @@ var refreshLocks sync.Map func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string, error) { stored := GetStoredToken(opts.AppId, opts.UserOpenId) if stored == nil { - return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId} + return "", NewNeedUserAuthorizationError(opts.UserOpenId) } status := TokenStatus(stored) @@ -86,7 +86,7 @@ func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string, return "", err } if refreshed == nil { - return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId} + return "", NewNeedUserAuthorizationError(opts.UserOpenId) } return refreshed.AccessToken, nil } @@ -99,7 +99,7 @@ func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string, fmt.Fprintf(os.Stderr, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err) } } - return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId} + return "", NewNeedUserAuthorizationError(opts.UserOpenId) } // refreshWithLock acquires a file lock before attempting to refresh the token. diff --git a/internal/client/api_errors_test.go b/internal/client/api_errors_test.go index 65845adc5..64fb59a73 100644 --- a/internal/client/api_errors_test.go +++ b/internal/client/api_errors_test.go @@ -16,7 +16,6 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/output" ) // ───────────────────────────────────────────────────────────────────────────── @@ -264,19 +263,16 @@ func TestWrapJSONResponseParseError_Nil(t *testing.T) { // Cross-cutting: existing tests already in this file (kept and adjusted below). // ───────────────────────────────────────────────────────────────────────────── -// TestWrapDoAPIError_LegacyExitErrorNoLongerPassesThrough pins that legacy -// *output.ExitError (auth/validation/api flavours) is NOT a problemCarrier -// and is therefore not pass-through — only typed *errs.* values are. -// Legacy values fall through to the network/JSON branches based on their -// inner shape. -func TestWrapDoAPIError_LegacyExitErrorNoLongerPassesThrough(t *testing.T) { - // An *output.ErrAuth has no embedded Problem and no JSON-decode chain; - // it routes to the network branch with the fallback transport subtype. - got := WrapDoAPIError(output.ErrAuth("no access token available for user")) +// TestWrapDoAPIError_UntypedErrorRoutesToNetwork pins that a plain untyped +// error (no embedded Problem, no JSON-decode chain) is NOT pass-through — +// only typed *errs.* values are. It routes to the network branch with the +// fallback transport subtype. +func TestWrapDoAPIError_UntypedErrorRoutesToNetwork(t *testing.T) { + got := WrapDoAPIError(errors.New("no access token available for user")) var ne *errs.NetworkError if !errors.As(got, &ne) { - t.Fatalf("expected *errs.NetworkError (legacy ExitError no longer pass-through), got %T (%v)", got, got) + t.Fatalf("expected *errs.NetworkError for an untyped error, got %T (%v)", got, got) } // Sanity: not silently re-classified as JSON-decode. var ie *errs.InternalError diff --git a/internal/client/client.go b/internal/client/client.go index dc4a0e89e..f4ec5e42a 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -19,11 +19,9 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" "github.com/larksuite/cli/errs" - internalauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/errclass" - "github.com/larksuite/cli/internal/errcompat" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/util" ) @@ -54,16 +52,11 @@ func (c *APIClient) resolveAccessToken(ctx context.Context, as core.Identity) (s if errors.As(err, &unavailableErr) { return "", newTokenMissingError(as, unavailableErr) } - // NeedAuthorizationError from the credential chain (e.g. UAT refresh - // returned need_user_authorization) must surface as typed - // AuthenticationError. Without this, WrapDoAPIError would wrap the - // raw err as NetworkError, and cmd/root.go's outer-typed gate would - // then skip PromoteAuthError — leaving the user with exit 4 and no - // auth-login hint instead of exit 3 typed authentication. - var needAuthErr *internalauth.NeedAuthorizationError - if errors.As(err, &needAuthErr) { - return "", errcompat.PromoteAuthError(needAuthErr) - } + // The credential chain already emits a typed *errs.AuthenticationError + // for the missing-UAT case (e.g. UAT refresh returned + // need_user_authorization), so it flows through unchanged: the + // outer-typed gate in cmd/root.go and the idempotent WrapDoAPIError + // both preserve its authentication category and exit 3. return "", err } if result.Token == "" { @@ -120,24 +113,22 @@ func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []lark // // SDK Do() failures are normalised through WrapDoAPIError so every caller // (cmd/api, RuntimeContext, shortcuts) gets the same wire shape without -// each one remembering to wrap. Today that wire shape is still the legacy -// *output.ExitError envelope (network / api_error); future framework- -// boundary migration flips WrapDoAPIError to typed *errs.NetworkError / -// *errs.InternalError per the contract in errs/ERROR_CONTRACT.md. -// Errors that arrive already-classified (legacy *output.ExitError from -// resolveAccessToken's missing-credential paths, or a typed *errs.*) flow -// through unchanged. +// each one remembering to wrap. WrapDoAPIError classifies a raw transport +// failure into a typed *errs.NetworkError / *errs.InternalError per the +// contract in errs/ERROR_CONTRACT.md. Errors that arrive already-classified +// (a typed *errs.* from resolveAccessToken's missing-credential paths or +// elsewhere) flow through unchanged. func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) { var opts []larkcore.RequestOptionFunc token, err := c.resolveAccessToken(ctx, as) if err != nil { // WrapDoAPIError is idempotent on already-classified errors: - // the *output.ExitError that resolveAccessToken returns for missing - // tokens (via output.ErrAuth) passes through with its auth category - // and exit 3 intact, and any future typed *errs.* error from the - // credential chain survives the same way. Only stray untyped errors - // (raw fmt.Errorf) get the transport-or-internal fallback. + // the typed *errs.AuthenticationError that resolveAccessToken returns + // for missing tokens passes through with its auth category and exit 3 + // intact, and any other typed *errs.* error from the credential chain + // survives the same way. Only stray untyped errors (raw fmt.Errorf) + // get the transport-or-internal fallback. return nil, WrapDoAPIError(err) } if as.IsBot() { @@ -162,7 +153,7 @@ func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as c // Auth is resolved via Credential (same as DoSDKRequest). Security headers and // any extra headers from opts are applied automatically. // HTTP errors (status >= 400) are handled internally: the body is read (up to 4 KB), -// closed, and returned as an output.ErrNetwork — callers only receive successful responses. +// closed, and returned as a typed *errs.NetworkError — callers only receive successful responses. func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.Identity, opts ...Option) (*http.Response, error) { cfg := buildConfig(opts) @@ -332,10 +323,10 @@ func (c *APIClient) DoAPI(ctx context.Context, request RawApiRequest) (*larkcore // // JSON parse failures are wrapped via WrapJSONResponseParseError so callers // (notably the pagination loop and --page-all paths in cmd/api / cmd/service) -// see an *output.ExitError envelope (api_error for malformed JSON, network -// for everything else) instead of a bare fmt.Errorf — otherwise an empty -// or malformed page body would surface to the root handler as a plain-text -// "Error: ..." line and bypass the JSON stderr envelope contract. +// see a typed *errs.InternalError (invalid_response) instead of a bare +// fmt.Errorf — otherwise an empty or malformed page body would surface to the +// root handler as a plain-text "Error: ..." line and bypass the JSON stderr +// envelope contract. func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) { resp, err := c.DoAPI(ctx, request) if err != nil { diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 8cf38d95b..1de646b4e 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -474,8 +474,7 @@ func (f *failingTokenResolver) ResolveToken(_ context.Context, spec credential.T // TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError pins that // the missing-token path of resolveAccessToken returns the typed -// *errs.AuthenticationError{Subtype: TokenMissing} rather than the legacy -// *output.ExitError envelope. +// *errs.AuthenticationError{Subtype: TokenMissing}. func TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError(t *testing.T) { ac := &APIClient{ HTTP: &http.Client{}, @@ -500,24 +499,22 @@ func TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError(t *testing.T } } -// needAuthTokenResolver returns *internalauth.NeedAuthorizationError to -// exercise the P1 regression path: a credential chain that signals -// "user must re-authorize" must surface as typed AuthenticationError, not -// fall through to the generic err return which WrapDoAPIError would then -// wrap as NetworkError (the outer-typed dispatcher gate would then skip -// PromoteAuthError and the user would see exit 4 with no auth-login hint). +// needAuthTokenResolver mirrors the production credential chain: the +// missing-UAT case is constructed typed at the source (internal/auth) and +// carries the legacy *NeedAuthorizationError sentinel in its Cause chain. It +// must surface as a typed AuthenticationError and flow through resolveAccessToken +// and WrapDoAPIError unchanged (never mis-classified as NetworkError). type needAuthTokenResolver struct { userOpenID string } func (f *needAuthTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) { - return nil, &internalauth.NeedAuthorizationError{UserOpenId: f.userOpenID} + return nil, internalauth.NewNeedUserAuthorizationError(f.userOpenID) } // TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication -// is the codex P1 regression test: without this branch, the credential -// chain's NeedAuthorizationError would propagate raw and WrapDoAPIError -// would mis-classify it as NetworkError. +// pins that the typed missing-UAT error from the credential chain reaches the +// caller as a typed AuthenticationError with the marker and sentinel intact. func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *testing.T) { ac := &APIClient{ HTTP: &http.Client{}, @@ -623,7 +620,7 @@ func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) { // *errs.InternalError{Subtype: invalid_response} with the rawAPIJSONHint // preserved on Problem.Hint. Pagination / cmd/api / cmd/service callers see // the typed JSON stderr envelope (exit 5/internal) — wire `type` is -// "internal", not the legacy "api_error". +// "internal". func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) { rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) { return &http.Response{ diff --git a/internal/client/response.go b/internal/client/response.go index fbd88f7c3..99a625d2d 100644 --- a/internal/client/response.go +++ b/internal/client/response.go @@ -89,6 +89,24 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error { if apiErr := check(result, identity); apiErr != nil { return apiErr } + // CheckResponse treats business code 0 as success, so a 4xx/5xx whose + // JSON body omits a non-zero code would otherwise be served as a + // successful result. Classify by HTTP status, mirroring the non-JSON + // branch above, so the status error is never swallowed. + if resp.StatusCode >= 400 { + body := util.TruncateStrWithEllipsis(strings.TrimSpace(string(resp.RawBody)), 500) + if resp.StatusCode >= 500 { + return errs.NewNetworkError(errs.SubtypeNetworkServer, + "HTTP %d: %s", resp.StatusCode, body). + WithCode(resp.StatusCode) + } + subtype := errs.SubtypeUnknown + if resp.StatusCode == 404 { + subtype = errs.SubtypeNotFound + } + return errs.NewAPIError(subtype, "HTTP %d: %s", resp.StatusCode, body). + WithCode(resp.StatusCode) + } // Content safety scanning scanResult := output.ScanForSafety(opts.CommandPath, result, opts.ErrOut) if scanResult.Blocked { diff --git a/internal/client/response_test.go b/internal/client/response_test.go index 0902e5556..6b0a0ea42 100644 --- a/internal/client/response_test.go +++ b/internal/client/response_test.go @@ -325,6 +325,32 @@ func TestHandleResponse_NonJSONError_502(t *testing.T) { } } +// TestHandleResponse_JSONErrorWithZeroBodyCodeNotSwallowed pins that an HTTP +// status error whose JSON body omits a non-zero business code (e.g. 400 + +// {"code":0,...}) still surfaces a typed error. CheckResponse treats code 0 as +// success, so without the HTTP-status fallback a 4xx would be served as a +// successful result and exit 0. +func TestHandleResponse_JSONErrorWithZeroBodyCodeNotSwallowed(t *testing.T) { + resp := newApiRespWithStatus(400, []byte(`{"code":0,"msg":"bad request"}`), + map[string]string{"Content-Type": "application/json"}) + + var out, errOut bytes.Buffer + err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}}) + if err == nil { + t.Fatalf("HTTP 400 with code:0 body must not be swallowed; got out=%q err=nil", out.String()) + } + var apiErr *errs.APIError + if !errors.As(err, &apiErr) { + t.Errorf("expected *errs.APIError, got %T", err) + } + if !strings.Contains(err.Error(), "HTTP 400") { + t.Errorf("expected 'HTTP 400' in error, got: %s", err.Error()) + } + if output.ExitCodeOf(err) != output.ExitAPI { + t.Errorf("expected ExitAPI (%d), got %d", output.ExitAPI, output.ExitCodeOf(err)) + } +} + func TestHandleResponse_200TextPlain_SavesFile(t *testing.T) { dir := t.TempDir() origWd, _ := os.Getwd() diff --git a/internal/cmdpolicy/aggregation_test.go b/internal/cmdpolicy/aggregation_test.go index 59384952a..9f1cab61b 100644 --- a/internal/cmdpolicy/aggregation_test.go +++ b/internal/cmdpolicy/aggregation_test.go @@ -4,13 +4,13 @@ package cmdpolicy_test import ( - "encoding/json" "errors" "strings" "testing" "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/platform" "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" @@ -168,10 +168,15 @@ func TestBuildDeniedByPath_hybridParentOwnAllowedKeepsAlive(t *testing.T) { } } -// Apply with the wrapped *output.ExitError exposes BOTH paths consumers -// rely on: -// 1. cmd/root.go's envelope writer (errors.As on *output.ExitError) -// 2. in-process consumers extracting the platform.CommandDeniedError +// Apply returns a typed *errs.ValidationError that exposes BOTH paths +// consumers rely on: +// 1. cmd/root.go's envelope writer (errs.ProblemOf / failed_precondition +// subtype + exit code 2) +// 2. in-process consumers extracting the platform.CommandDeniedError as +// the typed error's Cause via errors.As +// +// The policy metadata (layer / policy_source / rule_name / reason_code) +// is folded into the Hint text rather than a separate detail map. func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) { root := buildTree() denied := map[string]cmdpolicy.Denial{ @@ -191,31 +196,33 @@ func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) { t.Fatalf("denied command should return error") } - // Path 1: envelope-writer view. - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("error chain must contain *output.ExitError, got %T", err) + // Path 1: typed-envelope view. The denial is a failed_precondition + // ValidationError so cmd/root.go renders the structured envelope and + // the process exits 2 (ExitValidation). + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("error chain must contain *errs.ValidationError, got %T", err) } - if exitErr.Detail == nil { - t.Fatalf("ExitError.Detail required for envelope to render") + if ve.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition) } - if exitErr.Detail.Type != "command_denied" { - t.Errorf("envelope error.type = %q, want command_denied", exitErr.Detail.Type) + if code := output.ExitCodeOf(err); code != output.ExitValidation { + t.Errorf("exit code = %d, want ExitValidation (%d)", code, output.ExitValidation) } - // JSON envelope shape: detail.reason_code must be present and - // match the closed enum. - detailMap, ok := exitErr.Detail.Detail.(map[string]any) - if !ok { - t.Fatalf("envelope detail should be map[string]any, got %T", exitErr.Detail.Detail) + // The policy metadata is folded into the Hint text: reason_code, + // policy_source, and rule_name must all be discoverable there. + if !strings.Contains(ve.Hint, "write_not_allowed") { + t.Errorf("hint must carry reason_code write_not_allowed, got %q", ve.Hint) } - if detailMap["reason_code"] != "write_not_allowed" { - t.Errorf("detail.reason_code = %v, want write_not_allowed", detailMap["reason_code"]) + if !strings.Contains(ve.Hint, "plugin:secaudit") { + t.Errorf("hint must carry policy_source plugin:secaudit, got %q", ve.Hint) } - if detailMap["policy_source"] != "plugin:secaudit" { - t.Errorf("detail.policy_source = %v, want plugin:secaudit", detailMap["policy_source"]) + if !strings.Contains(ve.Hint, "secaudit-policy") { + t.Errorf("hint must carry rule_name secaudit-policy, got %q", ve.Hint) } - // Path 2: in-process typed-error view. + // Path 2: in-process typed-error view -- the *platform.CommandDeniedError + // is preserved as the Cause so errors.As still reaches it. var cd *platform.CommandDeniedError if !errors.As(err, &cd) { t.Fatalf("error chain must expose *platform.CommandDeniedError") @@ -223,21 +230,6 @@ func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) { if cd.Path != "docs/+update" || cd.ReasonCode != "write_not_allowed" { t.Errorf("CommandDeniedError = %+v", cd) } - - // Envelope round-trip sanity (the actual JSON cmd/root.go would emit). - var buf strings.Builder - output.WriteErrorEnvelope(&buf, exitErr, "user") - if !strings.Contains(buf.String(), `"type": "command_denied"`) { - t.Errorf("envelope JSON missing type=command_denied, got:\n%s", buf.String()) - } - if !strings.Contains(buf.String(), `"reason_code": "write_not_allowed"`) { - t.Errorf("envelope JSON missing reason_code, got:\n%s", buf.String()) - } - // Round-trip parse to verify it's well-formed JSON. - var parsed map[string]any - if err := json.Unmarshal([]byte(buf.String()), &parsed); err != nil { - t.Fatalf("envelope JSON malformed: %v\n%s", err, buf.String()) - } } // Regression: a pure parent group carrying AnnotationPureGroup must be diff --git a/internal/cmdpolicy/apply.go b/internal/cmdpolicy/apply.go index 744ad0ee3..c253ca798 100644 --- a/internal/cmdpolicy/apply.go +++ b/internal/cmdpolicy/apply.go @@ -6,8 +6,8 @@ package cmdpolicy import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/platform" - "github.com/larksuite/cli/internal/output" ) // Apply walks the command tree and installs denyStubs for every path in @@ -24,12 +24,11 @@ import ( // cobra would intercept the call // with "missing required flag" // before we can return our error -// 3. cmd.RunE = denyStub(denial) -- returns *output.ExitError so +// 3. cmd.RunE = denyStub(denial) -- returns a typed +// *errs.ValidationError so // cmd/root.go's envelope writer -// emits structured JSON (with -// error.type = denial.Layer and -// detail.reason_code = ReasonCode); -// the wrapped error chain still +// emits structured JSON; the +// wrapped error chain still // exposes *platform.CommandDeniedError // via errors.As for in-process // consumers @@ -112,42 +111,17 @@ func CommandDeniedFromDenial(path string, d Denial) *platform.CommandDeniedError } } -// DenialDetailMap is the canonical detail.* shape every `command_denied` -// envelope shares (see docs/extension/reason-codes.md). Use it as -// ErrDetail.Detail when constructing an envelope outside BuildDenialError. -func DenialDetailMap(cd *platform.CommandDeniedError) map[string]any { - return map[string]any{ - "path": cd.Path, - "layer": cd.Layer, - "policy_source": cd.PolicySource, - "rule_name": cd.RuleName, - "reason_code": cd.ReasonCode, - "reason": cd.Reason, - } -} - -// BuildDenialError is the default envelope for user-layer denials: -// Message comes from CommandDeniedError.Error(), no Hint. Callers that -// need a custom Message or an independent Hint (strict-mode) should -// compose CommandDeniedFromDenial + DenialDetailMap themselves. -// -// Deprecated: BuildDenialError produces a legacy *output.ExitError that -// predates the typed error contract introduced by errs/. New code MUST NOT -// use it — denial signals should move to a typed *errs.XxxError (a dedicated -// typed Error for policy denial is tracked for the cmdpolicy migration PR). -// This helper is retained only while existing call sites are migrated; it -// will be removed once they have moved to the typed surface. -func BuildDenialError(path string, d Denial) *output.ExitError { +// BuildDenialError is the default typed error for user-layer denials: +// Message comes from CommandDeniedError.Error(); the policy layer, source, +// rule name, and reason code are folded into the Hint. The +// *platform.CommandDeniedError is preserved as the Cause so errors.As +// works for in-process consumers. +func BuildDenialError(path string, d Denial) *errs.ValidationError { cd := CommandDeniedFromDenial(path, d) - return &output.ExitError{ - Code: output.ExitValidation, - Detail: &output.ErrDetail{ - Type: "command_denied", - Message: cd.Error(), - Detail: DenialDetailMap(cd), - }, - Err: cd, - } + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", cd.Error()). + WithHint("denied by %s policy (source %s, rule %q, reason_code %s); adjust the policy configuration to allow this command", + cd.Layer, cd.PolicySource, cd.RuleName, cd.ReasonCode). + WithCause(cd) } // installDenyStub mutates a cobra.Command in place. Unlike cmd/prune.go @@ -221,9 +195,9 @@ func installDenyStub(cmd *cobra.Command, path string, d Denial) bool { denial := d // capture by value for the closure cmd.RunE = func(c *cobra.Command, args []string) error { - // error.type is the user-facing semantic ("a command was denied by - // policy"). detail.layer carries the implementation distinction - // ("policy" vs "strict_mode") for debugging. + // The typed message carries the user-facing semantic ("a command + // was denied"); the hint carries the layer / source / rule + // distinction ("policy" vs "strict_mode") for debugging. return BuildDenialError(path, denial) } // Clear any pre-existing Run hook: cobra prefers RunE when both are diff --git a/internal/cmdpolicy/engine.go b/internal/cmdpolicy/engine.go index 3b0e2c1eb..2624f0017 100644 --- a/internal/cmdpolicy/engine.go +++ b/internal/cmdpolicy/engine.go @@ -9,9 +9,9 @@ // aggregation), which the Apply step consumes to install denyStubs. // // This package only implements the user-layer half. Strict-mode is handled -// by cmd/prune.go, which produces command_denied envelopes of the same -// shape via BuildDenialError so external agents can dispatch on -// detail.layer / reason_code uniformly regardless of which layer rejected +// by cmd/prune.go, which produces typed validation errors of the same shape +// (failed_precondition, *platform.CommandDeniedError preserved as Cause) so +// external agents see a uniform envelope regardless of which layer rejected // the call. package cmdpolicy diff --git a/internal/cmdpolicy/source_label_test.go b/internal/cmdpolicy/source_label_test.go index dbd31d560..4c32c3f40 100644 --- a/internal/cmdpolicy/source_label_test.go +++ b/internal/cmdpolicy/source_label_test.go @@ -10,9 +10,9 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/platform" "github.com/larksuite/cli/internal/cmdpolicy" - "github.com/larksuite/cli/internal/output" ) // The envelope's policy_source must never leak the absolute home path. @@ -39,25 +39,26 @@ func TestEnvelope_yamlPolicySourceDoesNotLeakHomePath(t *testing.T) { cmdpolicy.Apply(root, denied) err := leaf.RunE(leaf, nil) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected denial ExitError, got %v", err) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected denial *errs.ValidationError, got %T %v", err, err) } - detail := exitErr.Detail.Detail.(map[string]any) - src, _ := detail["policy_source"].(string) - if src != "yaml" { - t.Errorf("policy_source = %q, want %q (no path leak)", src, "yaml") + // The policy source is folded into the Hint as "yaml" -- the bare + // kind, never the absolute path. + if !strings.Contains(ve.Hint, "source yaml") { + t.Errorf("hint must carry policy_source %q (no path leak), got %q", "yaml", ve.Hint) } // rule_name carries the disambiguating identifier. - if detail["rule_name"] != "my-readonly-rule" { - t.Errorf("rule_name = %v, want my-readonly-rule", detail["rule_name"]) + if !strings.Contains(ve.Hint, "my-readonly-rule") { + t.Errorf("hint must carry rule_name my-readonly-rule, got %q", ve.Hint) } - // Direct probe: the absolute path must not appear anywhere in the - // envelope detail (key OR value). - for k, v := range detail { - if strings.Contains(k, "/Users/alice") || strings.Contains(asString(v), "/Users/alice") { - t.Errorf("envelope detail must not leak '/Users/alice', found in %s = %v", k, v) - } + // Direct privacy probe: the absolute home path must not appear + // anywhere in the user-facing message OR hint text. + if strings.Contains(ve.Message, "/Users/alice") { + t.Errorf("error message must not leak '/Users/alice', got %q", ve.Message) + } + if strings.Contains(ve.Hint, "/Users/alice") { + t.Errorf("error hint must not leak '/Users/alice', got %q", ve.Hint) } } @@ -80,17 +81,14 @@ func TestEnvelope_pluginPolicySourceCarriesName(t *testing.T) { cmdpolicy.Apply(root, denied) err := leaf.RunE(leaf, nil) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected ExitError") + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *errs.ValidationError, got %T", err) } - detail := exitErr.Detail.Detail.(map[string]any) - if detail["policy_source"] != "plugin:secaudit" { - t.Errorf("policy_source = %v, want plugin:secaudit", detail["policy_source"]) + // The plugin name IS surfaced (in-binary, part of the contract): it + // must appear in the Hint so an integrator debugging a denial knows + // which plugin fired. + if !strings.Contains(ve.Hint, "plugin:secaudit") { + t.Errorf("hint must carry policy_source plugin:secaudit, got %q", ve.Hint) } } - -func asString(v any) string { - s, _ := v.(string) - return s -} diff --git a/internal/cmdutil/confirm.go b/internal/cmdutil/confirm.go index b2c9cf575..05a6f8bde 100644 --- a/internal/cmdutil/confirm.go +++ b/internal/cmdutil/confirm.go @@ -4,39 +4,22 @@ package cmdutil import ( - "fmt" - - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" ) -// RequireConfirmation constructs a confirmation_required error with exit code -// ExitConfirmationRequired and a structured Risk envelope. Used by both -// shortcut and service command execution paths when a statically -// high-risk-write operation has not been confirmed with --yes. +// RequireConfirmation constructs a typed *errs.ConfirmationRequiredError +// (exit code ExitConfirmationRequired) carrying the risk level and action as +// typed extension fields. Used by both shortcut and service command execution +// paths when a statically high-risk-write operation has not been confirmed +// with --yes. // // action identifies the operation for the agent (e.g. "mail +send", // "drive.files.delete"). The envelope does not carry a pre-built retry // command: agents already know their original invocation and only need to // append --yes per the hint, which keeps the protocol free of shell-quoting // pitfalls. -// Deprecated: RequireConfirmation produces a legacy *output.ExitError that -// predates the typed error contract introduced by errs/. New code MUST NOT -// use it — confirmation-required signals should move to typed -// *errs.ConfirmationRequiredError carrying the same agent-protocol metadata -// (level/action) as typed extension fields. This helper is retained only -// while existing call sites are migrated; it will be removed once they have -// moved to the typed surface. func RequireConfirmation(action string) error { - return &output.ExitError{ - Code: output.ExitConfirmationRequired, - Detail: &output.ErrDetail{ - Type: "confirmation_required", - Message: fmt.Sprintf("%s requires confirmation", action), - Hint: "add --yes to confirm", - Risk: &output.RiskDetail{ - Level: RiskHighRiskWrite, - Action: action, - }, - }, - } + return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action, + "%s requires confirmation", action). + WithHint("add --yes to confirm") } diff --git a/internal/cmdutil/confirm_test.go b/internal/cmdutil/confirm_test.go index 3a9407e1a..c893a2e6e 100644 --- a/internal/cmdutil/confirm_test.go +++ b/internal/cmdutil/confirm_test.go @@ -9,53 +9,50 @@ import ( "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" ) -func TestRequireConfirmation_EnvelopeShape(t *testing.T) { +func TestRequireConfirmation_TypedShape(t *testing.T) { err := RequireConfirmation("drive +delete") if err == nil { t.Fatal("expected non-nil error") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T", err) + var cre *errs.ConfirmationRequiredError + if !errors.As(err, &cre) { + t.Fatalf("expected *errs.ConfirmationRequiredError, got %T", err) } - if exitErr.Code != output.ExitConfirmationRequired { - t.Errorf("Code = %d, want %d", exitErr.Code, output.ExitConfirmationRequired) + if cre.Category != errs.CategoryConfirmation { + t.Errorf("Category = %q, want %q", cre.Category, errs.CategoryConfirmation) } - if exitErr.Detail == nil { - t.Fatal("Detail is nil") + if cre.Subtype != errs.SubtypeConfirmationRequired { + t.Errorf("Subtype = %q, want %q", cre.Subtype, errs.SubtypeConfirmationRequired) } - d := exitErr.Detail - if d.Type != "confirmation_required" { - t.Errorf("Type = %q, want confirmation_required", d.Type) + if got := output.ExitCodeOf(err); got != output.ExitConfirmationRequired { + t.Errorf("ExitCodeOf = %d, want %d", got, output.ExitConfirmationRequired) } - if !strings.Contains(d.Message, "drive +delete") || !strings.Contains(d.Message, "requires confirmation") { - t.Errorf("Message = %q, want it to mention action and 'requires confirmation'", d.Message) + if !strings.Contains(cre.Message, "drive +delete") || !strings.Contains(cre.Message, "requires confirmation") { + t.Errorf("Message = %q, want it to mention action and 'requires confirmation'", cre.Message) } - if d.Hint != "add --yes to confirm" { - t.Errorf("Hint = %q, want 'add --yes to confirm'", d.Hint) + if cre.Hint != "add --yes to confirm" { + t.Errorf("Hint = %q, want 'add --yes to confirm'", cre.Hint) } - if d.Risk == nil { - t.Fatal("Risk is nil") + if cre.Risk != errs.RiskHighRiskWrite { + t.Errorf("Risk = %q, want %q", cre.Risk, errs.RiskHighRiskWrite) } - if d.Risk.Level != "high-risk-write" { - t.Errorf("Risk.Level = %q, want high-risk-write", d.Risk.Level) - } - if d.Risk.Action != "drive +delete" { - t.Errorf("Risk.Action = %q, want drive +delete", d.Risk.Action) + if cre.Action != "drive +delete" { + t.Errorf("Action = %q, want drive +delete", cre.Action) } } func TestRequireConfirmation_JSONShape(t *testing.T) { err := RequireConfirmation("mail +send") - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T", err) + var cre *errs.ConfirmationRequiredError + if !errors.As(err, &cre) { + t.Fatalf("expected *errs.ConfirmationRequiredError, got %T", err) } - raw, mErr := json.Marshal(exitErr.Detail) + raw, mErr := json.Marshal(cre) if mErr != nil { t.Fatalf("marshal: %v", mErr) } @@ -70,18 +67,14 @@ func TestRequireConfirmation_JSONShape(t *testing.T) { t.Errorf("unexpected fix_command present in JSON: %s", raw) } - risk, ok := back["risk"].(map[string]interface{}) - if !ok { - t.Fatalf("risk block missing in JSON: %s", raw) - } - if risk["level"] != "high-risk-write" { - t.Errorf("risk.level in JSON = %v", risk["level"]) + if back["risk"] != "high-risk-write" { + t.Errorf("risk in JSON = %v", back["risk"]) } - if risk["action"] != "mail +send" { - t.Errorf("risk.action in JSON = %v", risk["action"]) + if back["action"] != "mail +send" { + t.Errorf("action in JSON = %v", back["action"]) } // Action-only protocol: no UpgradedBy / fix_command / upgraded_by leak. - if _, has := risk["upgraded_by"]; has { + if _, has := back["upgraded_by"]; has { t.Errorf("unexpected upgraded_by present in JSON: %s", raw) } } diff --git a/internal/cmdutil/factory_default_test.go b/internal/cmdutil/factory_default_test.go index 9ec8e7ec3..1f47971ce 100644 --- a/internal/cmdutil/factory_default_test.go +++ b/internal/cmdutil/factory_default_test.go @@ -8,6 +8,7 @@ import ( "errors" "testing" + "github.com/larksuite/cli/errs" _ "github.com/larksuite/cli/extension/credential/env" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/core" @@ -107,9 +108,9 @@ func TestNewDefault_InvocationProfileMissingSticksAcrossEarlyStrictMode(t *testi if err == nil { t.Fatal("Config() error = nil, want non-nil") } - var cfgErr *core.ConfigError + var cfgErr *errs.ConfigError if !errors.As(err, &cfgErr) { - t.Fatalf("Config() error type = %T, want *core.ConfigError", err) + t.Fatalf("Config() error type = %T, want *errs.ConfigError", err) } if cfgErr.Message != `profile "missing" not found` { t.Fatalf("Config() error message = %q, want %q", cfgErr.Message, `profile "missing" not found`) diff --git a/internal/cmdutil/fileupload.go b/internal/cmdutil/fileupload.go index 9dfc22e2a..cf6a92d87 100644 --- a/internal/cmdutil/fileupload.go +++ b/internal/cmdutil/fileupload.go @@ -10,8 +10,8 @@ import ( "strconv" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/output" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" ) @@ -42,26 +42,41 @@ func ValidateFileFlag(file, params, data, outputPath string, pageAll bool, httpM _, filePath, isStdin := ParseFileFlag(file, "file") if !isStdin && filePath == "" { - return output.ErrValidation("--file: empty file path") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: empty file path"). + WithParam("--file") } if outputPath != "" { - return output.ErrValidation("--file and --output are mutually exclusive") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --output are mutually exclusive").WithParams( + errs.InvalidParam{Name: "--file", Reason: "mutually exclusive with --output"}, + errs.InvalidParam{Name: "--output", Reason: "mutually exclusive with --file"}, + ) } if pageAll { - return output.ErrValidation("--file and --page-all are mutually exclusive") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --page-all are mutually exclusive").WithParams( + errs.InvalidParam{Name: "--file", Reason: "mutually exclusive with --page-all"}, + errs.InvalidParam{Name: "--page-all", Reason: "mutually exclusive with --file"}, + ) } if isStdin && data == "-" { - return output.ErrValidation("--file and --data cannot both read from stdin") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --data cannot both read from stdin").WithParams( + errs.InvalidParam{Name: "--file", Reason: "only one flag may read from stdin"}, + errs.InvalidParam{Name: "--data", Reason: "only one flag may read from stdin"}, + ) } if isStdin && params == "-" { - return output.ErrValidation("--file and --params cannot both read from stdin") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --params cannot both read from stdin").WithParams( + errs.InvalidParam{Name: "--file", Reason: "only one flag may read from stdin"}, + errs.InvalidParam{Name: "--params", Reason: "only one flag may read from stdin"}, + ) } switch httpMethod { case "POST", "PUT", "PATCH", "DELETE": default: - return output.ErrValidation("--file requires POST, PUT, PATCH, or DELETE method") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file requires POST, PUT, PATCH, or DELETE method"). + WithParam("--file"). + WithHint("file upload only applies to write methods; remove --file for read methods") } return nil @@ -83,25 +98,35 @@ func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin boo if isStdin { if stdin == nil { - return nil, output.ErrValidation("--file: stdin is not available") + return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "--file: stdin is not available"). + WithParam("--file"). + WithHint("pipe the file content to stdin, or pass a file path instead of \"-\"") } data, err := io.ReadAll(stdin) if err != nil { - return nil, output.ErrValidation("--file: failed to read stdin: %v", err) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: failed to read stdin: %v", err). + WithParam("--file"). + WithCause(err) } if len(data) == 0 { - return nil, output.ErrValidation("--file: stdin is empty") + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: stdin is empty"). + WithParam("--file"). + WithHint("pipe non-empty file content to stdin") } fd.AddFile(fieldName, bytes.NewReader(data)) } else { f, err := fileIO.Open(filePath) if err != nil { - return nil, output.ErrValidation("cannot open file: %s", filePath) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot open file: %s", filePath). + WithParam("--file"). + WithCause(err) } defer f.Close() data, err := io.ReadAll(f) if err != nil { - return nil, output.ErrValidation("--file: failed to read %s: %v", filePath, err) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: failed to read %s: %v", filePath, err). + WithParam("--file"). + WithCause(err) } fd.AddFile(fieldName, bytes.NewReader(data)) } diff --git a/internal/cmdutil/fileupload_test.go b/internal/cmdutil/fileupload_test.go index 4e0ab1ca3..d6907cec9 100644 --- a/internal/cmdutil/fileupload_test.go +++ b/internal/cmdutil/fileupload_test.go @@ -5,14 +5,49 @@ package cmdutil import ( "bytes" + "errors" "os" "path/filepath" "strings" "testing" + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/vfs/localfileio" ) +// failingReader always errors on Read, to exercise stdin read-failure paths. +type failingReader struct{ err error } + +func (r *failingReader) Read([]byte) (int, error) { return 0, r.err } + +// requireFileValidationError asserts err is a typed *errs.ValidationError with +// the expected subtype, exit code 2 (legacy ErrValidation parity), and a +// param diagnostic referencing --file (either Param or one of Params). +func requireFileValidationError(t *testing.T, err error, wantSubtype errs.Subtype) *errs.ValidationError { + t.Helper() + var valErr *errs.ValidationError + if !errors.As(err, &valErr) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } + if valErr.Subtype != wantSubtype { + t.Errorf("subtype = %q, want %q", valErr.Subtype, wantSubtype) + } + if got := output.ExitCodeOf(err); got != output.ExitValidation { + t.Errorf("exit code = %d, want %d (ExitValidation, legacy parity)", got, output.ExitValidation) + } + mentionsFile := valErr.Param == "--file" + for _, p := range valErr.Params { + if p.Name == "--file" { + mentionsFile = true + } + } + if !mentionsFile { + t.Errorf("expected --file in Param/Params, got Param=%q Params=%v", valErr.Param, valErr.Params) + } + return valErr +} + func TestParseFileFlag(t *testing.T) { tests := []struct { name string @@ -222,6 +257,7 @@ func TestValidateFileFlag(t *testing.T) { if !strings.Contains(err.Error(), tt.wantErr) { t.Errorf("error = %q, want containing %q", err.Error(), tt.wantErr) } + requireFileValidationError(t, err, errs.SubtypeInvalidArgument) }) } } @@ -248,6 +284,19 @@ func TestBuildFormdata(t *testing.T) { if !strings.Contains(err.Error(), "stdin is not available") { t.Errorf("error = %q, want containing %q", err.Error(), "stdin is not available") } + requireFileValidationError(t, err, errs.SubtypeFailedPrecondition) + }) + + t.Run("stdin read failure", func(t *testing.T) { + readErr := errors.New("pipe closed") + _, err := BuildFormdata(fio, "file", "", true, &failingReader{err: readErr}, nil) + if err == nil { + t.Fatal("expected error for failing stdin reader") + } + requireFileValidationError(t, err, errs.SubtypeInvalidArgument) + if !errors.Is(err, readErr) { + t.Error("underlying read error not reachable via errors.Is; WithCause missing") + } }) t.Run("stdin empty", func(t *testing.T) { @@ -259,6 +308,7 @@ func TestBuildFormdata(t *testing.T) { if !strings.Contains(err.Error(), "stdin is empty") { t.Errorf("error = %q, want containing %q", err.Error(), "stdin is empty") } + requireFileValidationError(t, err, errs.SubtypeInvalidArgument) }) t.Run("file open success", func(t *testing.T) { @@ -289,6 +339,10 @@ func TestBuildFormdata(t *testing.T) { if !strings.Contains(err.Error(), "cannot open file:") { t.Errorf("error = %q, want containing %q", err.Error(), "cannot open file:") } + valErr := requireFileValidationError(t, err, errs.SubtypeInvalidArgument) + if valErr.Cause == nil { + t.Error("expected the os open error attached as Cause") + } }) t.Run("dataJSON fields added", func(t *testing.T) { diff --git a/internal/cmdutil/json.go b/internal/cmdutil/json.go index fef4ea4fe..c00be5bdf 100644 --- a/internal/cmdutil/json.go +++ b/internal/cmdutil/json.go @@ -7,8 +7,8 @@ import ( "encoding/json" "io" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/output" ) // ParseOptionalBody parses --data JSON for methods that accept a request body. @@ -22,14 +22,18 @@ func ParseOptionalBody(httpMethod, data string, stdin io.Reader, fileIO fileio.F } resolved, err := ResolveInput(data, stdin, fileIO) if err != nil { - return nil, output.ErrValidation("--data: %s", err) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data: %s", err). + WithParam("--data"). + WithCause(err) } if resolved == "" { return nil, nil } var body interface{} if err := json.Unmarshal([]byte(resolved), &body); err != nil { - return nil, output.ErrValidation("--data invalid JSON format") + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data invalid JSON format"). + WithParam("--data"). + WithCause(err) } return body, nil } @@ -41,14 +45,18 @@ func ParseOptionalBody(httpMethod, data string, stdin io.Reader, fileIO fileio.F func ParseJSONMap(input, label string, stdin io.Reader, fileIO fileio.FileIO) (map[string]any, error) { resolved, err := ResolveInput(input, stdin, fileIO) if err != nil { - return nil, output.ErrValidation("%s: %s", label, err) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %s", label, err). + WithParam(label). + WithCause(err) } if resolved == "" { return map[string]any{}, nil } var result map[string]any if err := json.Unmarshal([]byte(resolved), &result); err != nil { - return nil, output.ErrValidation("%s invalid format, expected JSON object", label) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s invalid format, expected JSON object", label). + WithParam(label). + WithCause(err) } if result == nil { // `null` unmarshals into a nil map without error; normalize it so the diff --git a/internal/cmdutil/json_test.go b/internal/cmdutil/json_test.go index 687c652c4..2999a5972 100644 --- a/internal/cmdutil/json_test.go +++ b/internal/cmdutil/json_test.go @@ -3,9 +3,40 @@ package cmdutil -import "testing" +import ( + "errors" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/vfs/localfileio" +) + +// requireJSONInputValidationError asserts err is a typed *errs.ValidationError +// with subtype invalid_argument, exit code 2 (legacy ErrValidation parity), +// and the offending flag recorded as Param. +func requireJSONInputValidationError(t *testing.T, err error, wantParam string) { + t.Helper() + var valErr *errs.ValidationError + if !errors.As(err, &valErr) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } + if valErr.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument) + } + if valErr.Param != wantParam { + t.Errorf("param = %q, want %q", valErr.Param, wantParam) + } + if got := output.ExitCodeOf(err); got != output.ExitValidation { + t.Errorf("exit code = %d, want %d (ExitValidation, legacy parity)", got, output.ExitValidation) + } + if valErr.Cause == nil { + t.Error("expected the underlying parse/resolve error attached as Cause") + } +} func TestParseOptionalBody(t *testing.T) { + fio := &localfileio.LocalFileIO{} tests := []struct { name string method string @@ -20,18 +51,23 @@ func TestParseOptionalBody(t *testing.T) { {"PATCH valid", "PATCH", `"hello"`, false, false}, {"DELETE valid", "DELETE", `{"id":"1"}`, false, false}, {"POST invalid json", "POST", `{bad}`, true, true}, + {"POST unreadable @file", "POST", "@/nonexistent/body.json", true, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := ParseOptionalBody(tt.method, tt.data, nil, nil) + got, err := ParseOptionalBody(tt.method, tt.data, nil, fio) if (err != nil) != tt.wantErr { t.Errorf("ParseOptionalBody() error = %v, wantErr %v", err, tt.wantErr) return } + if tt.wantErr { + requireJSONInputValidationError(t, err, "--data") + return + } if tt.wantNil && got != nil { t.Errorf("ParseOptionalBody() = %v, want nil", got) } - if !tt.wantNil && !tt.wantErr && got == nil { + if !tt.wantNil && got == nil { t.Error("ParseOptionalBody() = nil, want non-nil") } }) @@ -39,6 +75,7 @@ func TestParseOptionalBody(t *testing.T) { } func TestParseJSONMap(t *testing.T) { + fio := &localfileio.LocalFileIO{} tests := []struct { name string input string @@ -51,15 +88,20 @@ func TestParseJSONMap(t *testing.T) { {"valid json", `{"a":"1","b":"2"}`, "--params", 2, false}, {"invalid json", `{bad}`, "--params", 0, true}, {"json array", `[1,2]`, "--data", 0, true}, + {"unreadable @file", "@/nonexistent/params.json", "--params", 0, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := ParseJSONMap(tt.input, tt.label, nil, nil) + got, err := ParseJSONMap(tt.input, tt.label, nil, fio) if (err != nil) != tt.wantErr { t.Errorf("ParseJSONMap() error = %v, wantErr %v", err, tt.wantErr) return } - if !tt.wantErr && len(got) != tt.wantLen { + if tt.wantErr { + requireJSONInputValidationError(t, err, tt.label) + return + } + if len(got) != tt.wantLen { t.Errorf("ParseJSONMap() returned map with %d keys, want %d", len(got), tt.wantLen) } // A successful parse must yield a non-nil, writable map: callers diff --git a/internal/cmdutil/lang.go b/internal/cmdutil/lang.go index 4a6e514e6..ddbfa702d 100644 --- a/internal/cmdutil/lang.go +++ b/internal/cmdutil/lang.go @@ -6,8 +6,8 @@ package cmdutil import ( "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/i18n" - "github.com/larksuite/cli/internal/output" ) // ParseLangFlag validates and canonicalizes a --lang value, shared by config @@ -19,9 +19,10 @@ func ParseLangFlag(raw string) (i18n.Lang, error) { } lang, ok := i18n.Parse(raw) if !ok { - return "", output.ErrValidation( + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --lang %q; valid values: %s", - raw, strings.Join(i18n.Codes(), ", ")) + raw, strings.Join(i18n.Codes(), ", ")). + WithParam("--lang") } return lang, nil } diff --git a/internal/core/config.go b/internal/core/config.go index 846ee2bad..01c2be42b 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -5,15 +5,14 @@ package core import ( "encoding/json" - "errors" "fmt" "path/filepath" "strings" "unicode/utf8" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/keychain" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" ) @@ -237,28 +236,26 @@ func RequireConfigForProfile(kc keychain.KeychainAccess, profileOverride string) func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) { app := raw.CurrentAppConfig(profileOverride) if app == nil { - return nil, &ConfigError{ - Code: 3, - Type: "config", - Message: fmt.Sprintf("profile %q not found", profileOverride), - Hint: fmt.Sprintf("available profiles: %s", formatProfileNames(raw.ProfileNames())), - } + return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "profile %q not found", profileOverride). + WithHint("available profiles: %s", formatProfileNames(raw.ProfileNames())) } if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil { - return nil, &ConfigError{Code: 3, Type: "config", - Message: "appId and appSecret keychain key are out of sync", - Hint: err.Error()} + return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync"). + WithHint("%s", err.Error()). + WithCause(err) } secret, err := ResolveSecretInput(app.AppSecret, kc) if err != nil { - // Deprecated: legacy *output.ExitError passthrough; removed after typed migration. - var exitErr *output.ExitError - if errors.As(err, &exitErr) { - return nil, exitErr + if errs.IsTyped(err) { + return nil, err + } + subtype := errs.SubtypeNotConfigured + if isMalformedConfigError(err) { + subtype = errs.SubtypeInvalidConfig } - return nil, &ConfigError{Code: 3, Type: "config", Message: err.Error()} + return nil, errs.NewConfigError(subtype, "%s", err.Error()).WithCause(err) } cfg := &CliConfig{ ProfileName: app.ProfileName(), @@ -287,7 +284,8 @@ func RequireAuthForProfile(kc keychain.KeychainAccess, profileOverride string) ( return nil, err } if cfg.UserOpenId == "" { - return nil, &ConfigError{Code: 3, Type: "auth", Message: "not logged in", Hint: "run `lark-cli auth login` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login."} + return nil, errs.NewAuthenticationError(errs.SubtypeTokenMissing, "not logged in"). + WithHint("run `lark-cli auth login` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.") } return cfg, nil } diff --git a/internal/core/config_test.go b/internal/core/config_test.go index 4a501b68a..dffd9245e 100644 --- a/internal/core/config_test.go +++ b/internal/core/config_test.go @@ -8,6 +8,7 @@ import ( "errors" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/keychain" ) @@ -103,7 +104,7 @@ func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) { if err == nil { t.Fatal("expected error for mismatched appId and appSecret keychain key") } - var cfgErr *ConfigError + var cfgErr *errs.ConfigError if !errors.As(err, &cfgErr) { t.Fatalf("expected ConfigError, got %T: %v", err, err) } @@ -177,7 +178,7 @@ func TestResolveConfigFromMulti_MatchingKeychainRefPassesValidation(t *testing.T t.Fatal("expected error (keychain entry not found), got nil") } // The error should come from keychain resolution, NOT from our mismatch check. - var cfgErr *ConfigError + var cfgErr *errs.ConfigError if errors.As(err, &cfgErr) { if cfgErr.Message == "appId and appSecret keychain key are out of sync" { t.Fatal("error came from mismatch check, but keys should match") diff --git a/internal/core/errors.go b/internal/core/errors.go deleted file mode 100644 index b5ad13e89..000000000 --- a/internal/core/errors.go +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package core - -import "fmt" - -// ConfigError is a structured error from config resolution. -// It carries enough information for main.go to convert it into an output.ExitError. -type ConfigError struct { - Code int // exit code: 3 (config errors share the auth exit code) - Type string // "config" or "auth" - Message string - Hint string -} - -func (e *ConfigError) Error() string { - if e.Hint != "" { - return fmt.Sprintf("%s\n %s", e.Message, e.Hint) - } - return e.Message -} diff --git a/internal/core/notconfigured.go b/internal/core/notconfigured.go index 770c898d3..5b026ef45 100644 --- a/internal/core/notconfigured.go +++ b/internal/core/notconfigured.go @@ -5,10 +5,21 @@ package core import ( "errors" - "fmt" "os" + "strings" + + "github.com/larksuite/cli/errs" ) +// isMalformedConfigError reports whether a config load failure indicates a +// malformed file (parse / invalid format) rather than an absent or +// inaccessible one. Malformed files map to the invalid_config subtype so the +// user is told to fix the file instead of re-running init. +func isMalformedConfigError(err error) bool { + lower := strings.ToLower(err.Error()) + return strings.Contains(lower, "parse") || strings.Contains(lower, "invalid") +} + // LoadOrNotConfigured wraps LoadMultiAppConfig with the standard "not yet // configured vs. couldn't read" disambiguation that every config-required // command should use: @@ -27,14 +38,15 @@ func LoadOrNotConfigured() (*MultiAppConfig, error) { return nil, NotConfiguredError() } // Surface the real cause (parse error, permission denied, etc.) - // so the user can fix the broken file. Wrapping as ConfigError - // keeps it on the standard structured-envelope path at the root - // command's error sink. - return nil, &ConfigError{ - Code: 3, - Type: "config", - Message: fmt.Sprintf("failed to load config: %v", err), + // so the user can fix the broken file. A malformed file is + // invalid_config; anything else (permission denied, etc.) is + // not_configured. Both stay on the typed structured-envelope path + // at the root command's error sink. + subtype := errs.SubtypeNotConfigured + if isMalformedConfigError(err) { + subtype = errs.SubtypeInvalidConfig } + return nil, errs.NewConfigError(subtype, "failed to load config: %v", err).WithCause(err) } if multi == nil || len(multi.Apps) == 0 { return nil, NotConfiguredError() @@ -70,19 +82,14 @@ const ( func NotConfiguredError() error { ws := CurrentWorkspace() if ws.IsLocal() { - return &ConfigError{ - Code: 3, - Type: "config", - Message: "not configured", - Hint: localInitHint, - } - } - return &ConfigError{ - Code: 3, - Type: ws.Display(), - Message: fmt.Sprintf("%s context detected but lark-cli is not bound to it", ws.Display()), - Hint: agentBindHint, + return errs.NewConfigError(errs.SubtypeNotConfigured, "not configured"). + WithHint("%s", localInitHint) } + // Agent workspace: the workspace name appears only in the message, never + // in the wire subtype, which stays not_configured. + return errs.NewConfigError(errs.SubtypeNotConfigured, + "%s context detected but lark-cli is not bound to it", ws.Display()). + WithHint("%s", agentBindHint) } // reconfigureHint returns the workspace-aware "fix it from scratch" hint @@ -104,17 +111,10 @@ func reconfigureHint() string { func NoActiveProfileError() error { ws := CurrentWorkspace() if ws.IsLocal() { - return &ConfigError{ - Code: 3, - Type: "config", - Message: "no active profile", - Hint: localInitHint, - } - } - return &ConfigError{ - Code: 3, - Type: ws.Display(), - Message: fmt.Sprintf("no active profile in %s workspace", ws.Display()), - Hint: agentBindHint, + return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile"). + WithHint("%s", localInitHint) } + return errs.NewConfigError(errs.SubtypeNotConfigured, + "no active profile in %s workspace", ws.Display()). + WithHint("%s", agentBindHint) } diff --git a/internal/core/notconfigured_test.go b/internal/core/notconfigured_test.go index a65e3a270..d546fbe4a 100644 --- a/internal/core/notconfigured_test.go +++ b/internal/core/notconfigured_test.go @@ -8,6 +8,8 @@ import ( "os" "strings" "testing" + + "github.com/larksuite/cli/errs" ) // saveAndRestoreWorkspace ensures package-level currentWorkspace is reset @@ -24,12 +26,15 @@ func TestNotConfiguredError_Local(t *testing.T) { SetCurrentWorkspace(WorkspaceLocal) err := NotConfiguredError() - var cfgErr *ConfigError + var cfgErr *errs.ConfigError if !errors.As(err, &cfgErr) { - t.Fatalf("error type = %T, want *ConfigError", err) + t.Fatalf("error type = %T, want *errs.ConfigError", err) + } + if cfgErr.Category != errs.CategoryConfig || cfgErr.Subtype != errs.SubtypeNotConfigured { + t.Errorf("category/subtype = %q/%q, want config/not_configured", cfgErr.Category, cfgErr.Subtype) } - if cfgErr.Type != "config" || cfgErr.Message != "not configured" { - t.Errorf("unexpected detail: %+v", cfgErr) + if cfgErr.Message != "not configured" { + t.Errorf("message = %q, want %q", cfgErr.Message, "not configured") } if !strings.Contains(cfgErr.Hint, "config init --new") { t.Errorf("local hint should suggest config init --new; got %q", cfgErr.Hint) @@ -44,12 +49,17 @@ func TestNotConfiguredError_OpenClaw(t *testing.T) { SetCurrentWorkspace(WorkspaceOpenClaw) err := NotConfiguredError() - var cfgErr *ConfigError + var cfgErr *errs.ConfigError if !errors.As(err, &cfgErr) { - t.Fatalf("error type = %T, want *ConfigError", err) + t.Fatalf("error type = %T, want *errs.ConfigError", err) + } + // The wire subtype stays not_configured; the workspace name only appears + // in the message, never in the typed taxonomy. + if cfgErr.Subtype != errs.SubtypeNotConfigured { + t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype) } - if cfgErr.Type != "openclaw" { - t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw") + if !strings.Contains(cfgErr.Message, "openclaw") { + t.Errorf("message must name the openclaw workspace; got %q", cfgErr.Message) } // Hint must point at --help (read first, confirm with user, then bind), // NOT a directly-executable bind command — binding is policy-laden @@ -67,12 +77,15 @@ func TestNotConfiguredError_Hermes(t *testing.T) { SetCurrentWorkspace(WorkspaceHermes) err := NotConfiguredError() - var cfgErr *ConfigError + var cfgErr *errs.ConfigError if !errors.As(err, &cfgErr) { - t.Fatalf("error type = %T, want *ConfigError", err) + t.Fatalf("error type = %T, want *errs.ConfigError", err) } - if cfgErr.Type != "hermes" { - t.Errorf("type = %q, want %q", cfgErr.Type, "hermes") + if cfgErr.Subtype != errs.SubtypeNotConfigured { + t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype) + } + if !strings.Contains(cfgErr.Message, "hermes") { + t.Errorf("message must name the hermes workspace; got %q", cfgErr.Message) } if !strings.Contains(cfgErr.Hint, "config bind --help") { t.Errorf("hermes hint must point to `config bind --help`; got %q", cfgErr.Hint) @@ -84,9 +97,9 @@ func TestNoActiveProfileError_Local(t *testing.T) { SetCurrentWorkspace(WorkspaceLocal) err := NoActiveProfileError() - var cfgErr *ConfigError + var cfgErr *errs.ConfigError if !errors.As(err, &cfgErr) { - t.Fatalf("error type = %T, want *ConfigError", err) + t.Fatalf("error type = %T, want *errs.ConfigError", err) } if cfgErr.Message != "no active profile" { t.Errorf("message = %q, want %q", cfgErr.Message, "no active profile") @@ -98,9 +111,9 @@ func TestNoActiveProfileError_AgentSuggestsBind(t *testing.T) { SetCurrentWorkspace(WorkspaceOpenClaw) err := NoActiveProfileError() - var cfgErr *ConfigError + var cfgErr *errs.ConfigError if !errors.As(err, &cfgErr) { - t.Fatalf("error type = %T, want *ConfigError", err) + t.Fatalf("error type = %T, want *errs.ConfigError", err) } if !strings.Contains(cfgErr.Hint, "config bind --help") { t.Errorf("agent hint must point to `config bind --help`; got %q", cfgErr.Hint) @@ -136,9 +149,12 @@ func TestLoadOrNotConfigured_FileMissing_ReturnsNotConfigured(t *testing.T) { if err == nil { t.Fatal("expected error") } - var cfgErr *ConfigError + var cfgErr *errs.ConfigError if !errors.As(err, &cfgErr) { - t.Fatalf("error type = %T, want *ConfigError", err) + t.Fatalf("error type = %T, want *errs.ConfigError", err) + } + if cfgErr.Subtype != errs.SubtypeNotConfigured { + t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype) } if cfgErr.Message != "not configured" { t.Errorf("message = %q, want \"not configured\"", cfgErr.Message) @@ -164,9 +180,13 @@ func TestLoadOrNotConfigured_CorruptFile_PreservesCause(t *testing.T) { if err == nil { t.Fatal("expected error for corrupt config") } - var cfgErr *ConfigError + var cfgErr *errs.ConfigError if !errors.As(err, &cfgErr) { - t.Fatalf("error type = %T, want *ConfigError", err) + t.Fatalf("error type = %T, want *errs.ConfigError", err) + } + // A malformed file maps to invalid_config, not not_configured. + if cfgErr.Subtype != errs.SubtypeInvalidConfig { + t.Errorf("subtype = %q, want invalid_config", cfgErr.Subtype) } if !strings.Contains(cfgErr.Message, "failed to load config") { t.Errorf("corrupt-file message must say 'failed to load config'; got %q", cfgErr.Message) @@ -178,4 +198,8 @@ func TestLoadOrNotConfigured_CorruptFile_PreservesCause(t *testing.T) { if strings.Contains(cfgErr.Hint, "config init") || strings.Contains(cfgErr.Hint, "config bind") { t.Errorf("corrupt-file hint must not redirect to init/bind; got %q", cfgErr.Hint) } + // The underlying parse failure stays reachable through the unwrap chain. + if cfgErr.Cause == nil { + t.Error("Cause must wrap the underlying load error for errors.Is/Unwrap") + } } diff --git a/internal/errcompat/promote.go b/internal/errcompat/promote.go deleted file mode 100644 index 7f5cd6fa7..000000000 --- a/internal/errcompat/promote.go +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -// Package errcompat provides boundary helpers that bridge legacy error types -// to the typed errs/ taxonomy. These helpers run at the dispatcher boundary -// (cmd/root.go.handleRootError) before the typed envelope writer, converting -// pre-typed-taxonomy errors (*core.ConfigError, *internalauth.NeedAuthorizationError) -// into typed *errs.* errors while preserving the original error in the Cause -// chain so existing `errors.As` callers continue to match. -package errcompat - -import ( - "strings" - - "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/core" -) - -// PromoteConfigError converts a legacy *core.ConfigError into the matching -// typed errs.*Error based on cfgErr.Type. Called from cmd/root.go.handleRootError -// before the typed envelope writer. The original *core.ConfigError is preserved -// in the Cause chain so external `errors.As(&core.ConfigError{})` callers -// (cmd/auth/list.go, cmd/doctor/doctor.go, etc.) still match. -func PromoteConfigError(cfgErr *core.ConfigError) error { - if cfgErr == nil { - return nil - } - switch cfgErr.Type { - case "auth": - return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "%s", cfgErr.Message). - WithHint("%s", cfgErr.Hint). - WithCause(cfgErr) - case "config": - subtype := errs.SubtypeNotConfigured - lower := strings.ToLower(cfgErr.Message) - if strings.Contains(lower, "parse") || strings.Contains(lower, "invalid") { - subtype = errs.SubtypeInvalidConfig - } - return errs.NewConfigError(subtype, "%s", cfgErr.Message). - WithHint("%s", cfgErr.Hint). - WithCause(cfgErr) - default: - // dynamic Type (e.g. workspace name like "bind"/"hermes"/"openclaw") → NotConfigured - return errs.NewConfigError(errs.SubtypeNotConfigured, "%s", cfgErr.Message). - WithHint("%s", cfgErr.Hint). - WithCause(cfgErr) - } -} diff --git a/internal/errcompat/promote_auth.go b/internal/errcompat/promote_auth.go deleted file mode 100644 index b85457c61..000000000 --- a/internal/errcompat/promote_auth.go +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package errcompat - -import ( - "github.com/larksuite/cli/errs" - internalauth "github.com/larksuite/cli/internal/auth" -) - -// PromoteAuthError converts a legacy *internalauth.NeedAuthorizationError into -// *errs.AuthenticationError{Subtype: TokenMissing}. The Message field MUST -// contain "need_user_authorization" so the marker invariant guardrail in -// cmd/root_test.go and internal/auth/errors_test.go still holds. -// -// Hint mirrors newTokenMissingError in internal/client/client.go so both -// token-missing surfaces converge on the same recovery vocabulary. cmd's -// applyNeedAuthorizationHint appends per-command scopes onto this Hint with -// a "\n" join, so the action prompt is preserved even when scopes are added. -// -// Called from cmd/root.go.handleRootError when errors.As matches -// *NeedAuthorizationError, before WriteTypedErrorEnvelope. -func PromoteAuthError(err *internalauth.NeedAuthorizationError) error { - if err == nil { - return nil - } - return errs.NewAuthenticationError(errs.SubtypeTokenMissing, - "need_user_authorization (user: %s)", err.UserOpenId). - WithUserOpenID(err.UserOpenId). - WithHint("run: lark-cli auth login to re-authorize"). - WithCause(err) -} diff --git a/internal/errcompat/promote_auth_test.go b/internal/errcompat/promote_auth_test.go deleted file mode 100644 index 8e670c642..000000000 --- a/internal/errcompat/promote_auth_test.go +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package errcompat - -import ( - "errors" - "strings" - "testing" - - "github.com/larksuite/cli/errs" - internalauth "github.com/larksuite/cli/internal/auth" -) - -func TestPromoteAuthError_PromotesNeedAuthorizationError(t *testing.T) { - needAuth := &internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"} - got := PromoteAuthError(needAuth) - - var authErr *errs.AuthenticationError - if !errors.As(got, &authErr) { - t.Fatalf("expected *errs.AuthenticationError, got %T", got) - } - if authErr.Subtype != errs.SubtypeTokenMissing { - t.Errorf("subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing) - } - - // Cause chain must preserve original *NeedAuthorizationError so legacy - // consumers (auth.IsNeedUserAuthorizationError + errors.As pattern in - // internal/auth/errors.go:42) still match. - var preserved *internalauth.NeedAuthorizationError - if !errors.As(got, &preserved) { - t.Error("Unwrap chain lost *NeedAuthorizationError — breaks auth.IsNeedUserAuthorizationError consumer") - } -} - -func TestPromoteAuthError_PreservesNeedUserAuthorizationMarker(t *testing.T) { - needAuth := &internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"} - got := PromoteAuthError(needAuth) - if !strings.Contains(got.Error(), "need_user_authorization") { - t.Errorf("Message must contain need_user_authorization marker, got: %q", got.Error()) - } -} - -func TestPromoteAuthError_PreservesUserOpenID(t *testing.T) { - needAuth := &internalauth.NeedAuthorizationError{UserOpenId: "u_test_open_id"} - got := PromoteAuthError(needAuth) - - var authErr *errs.AuthenticationError - if !errors.As(got, &authErr) { - t.Fatalf("expected *errs.AuthenticationError, got %T", got) - } - if authErr.UserOpenID != "u_test_open_id" { - t.Errorf("UserOpenID = %q, want preserved", authErr.UserOpenID) - } -} - -// TestPromoteAuthError_CarriesAuthLoginHint pins that the recovery action -// prompt is attached at promotion time — without this Hint, downstream -// consumers see authentication/token_missing but no "run: lark-cli auth login" -// guidance, mirroring the pre-typed UX failure when NeedAuthorizationError -// surfaced as a bare network error. cmd's applyNeedAuthorizationHint relies -// on this Hint being non-empty so scope enrichment appends instead of -// overwrites the recovery prompt. -func TestPromoteAuthError_CarriesAuthLoginHint(t *testing.T) { - got := PromoteAuthError(&internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"}) - var authErr *errs.AuthenticationError - if !errors.As(got, &authErr) { - t.Fatalf("expected *errs.AuthenticationError, got %T", got) - } - if !strings.Contains(authErr.Hint, "lark-cli auth login") { - t.Errorf("Hint must guide user to re-authorize, got: %q", authErr.Hint) - } -} - -func TestPromoteAuthError_Nil_ReturnsNil(t *testing.T) { - if got := PromoteAuthError(nil); got != nil { - t.Errorf("nil input should return nil, got %v", got) - } -} diff --git a/internal/errcompat/promote_test.go b/internal/errcompat/promote_test.go deleted file mode 100644 index cebeb9b26..000000000 --- a/internal/errcompat/promote_test.go +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package errcompat_test - -import ( - "errors" - "strings" - "testing" - - "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/errcompat" -) - -func TestPromoteConfigError_TypeAuth_PromotesToAuthenticationError(t *testing.T) { - cfg := &core.ConfigError{ - Type: "auth", - Code: 3, - Message: "not logged in", - Hint: "run: lark-cli auth login", - } - got := errcompat.PromoteConfigError(cfg) - - var authErr *errs.AuthenticationError - if !errors.As(got, &authErr) { - t.Fatalf("expected *errs.AuthenticationError, got %T", got) - } - if authErr.Subtype != errs.SubtypeTokenMissing { - t.Errorf("subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing) - } - // Cause chain must preserve original *core.ConfigError for errors.As compat. - var cfgPreserved *core.ConfigError - if !errors.As(got, &cfgPreserved) { - t.Error("Unwrap chain lost *core.ConfigError — breaks cmd/auth/list.go consumer") - } -} - -func TestPromoteConfigError_TypeConfig_PromotesToConfigError(t *testing.T) { - cases := []struct { - name string - msg string - wantSubtype errs.Subtype - }{ - {"not_configured", "not configured", errs.SubtypeNotConfigured}, - {"invalid_config_parse", "failed to parse config", errs.SubtypeInvalidConfig}, - {"invalid_config_keyword", "invalid config file", errs.SubtypeInvalidConfig}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - cfg := &core.ConfigError{Type: "config", Code: 3, Message: tc.msg} - got := errcompat.PromoteConfigError(cfg) - - var ce *errs.ConfigError - if !errors.As(got, &ce) { - t.Fatalf("expected *errs.ConfigError, got %T", got) - } - if ce.Subtype != tc.wantSubtype { - t.Errorf("subtype = %v, want %v", ce.Subtype, tc.wantSubtype) - } - }) - } -} - -func TestPromoteConfigError_TypeDynamic_PromotesToConfigError(t *testing.T) { - for _, wsName := range []string{"openclaw", "hermes", "bind"} { - t.Run(wsName, func(t *testing.T) { - cfg := &core.ConfigError{Type: wsName, Code: 3, Message: "not configured"} - got := errcompat.PromoteConfigError(cfg) - - var ce *errs.ConfigError - if !errors.As(got, &ce) { - t.Fatalf("expected *errs.ConfigError, got %T", got) - } - if ce.Subtype != errs.SubtypeNotConfigured { - t.Errorf("subtype = %v, want %v", ce.Subtype, errs.SubtypeNotConfigured) - } - }) - } -} - -func TestPromoteConfigError_Nil_ReturnsNil(t *testing.T) { - if got := errcompat.PromoteConfigError(nil); got != nil { - t.Errorf("nil input should return nil, got %v", got) - } -} - -func TestPromoteConfigError_PreservesMessageHint(t *testing.T) { - cfg := &core.ConfigError{ - Type: "auth", - Message: "session expired (user: u_xxx)", - Hint: "re-authenticate", - } - got := errcompat.PromoteConfigError(cfg) - if !strings.Contains(got.Error(), "session expired") { - t.Errorf("message lost in promotion: %v", got) - } - var authErr *errs.AuthenticationError - if !errors.As(got, &authErr) { - t.Fatalf("expected *errs.AuthenticationError, got %T", got) - } - if authErr.Hint != "re-authenticate" { - t.Errorf("hint = %q, want preserved", authErr.Hint) - } -} diff --git a/internal/hook/install.go b/internal/hook/install.go index 76dad869e..4e741b46b 100644 --- a/internal/hook/install.go +++ b/internal/hook/install.go @@ -10,8 +10,8 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/platform" - "github.com/larksuite/cli/internal/output" ) // Install wraps every runnable command's RunE so the hook chain fires @@ -31,8 +31,9 @@ import ( // call), but Wrap is physically out of the path. // // - **After observers always fire**, even when RunE returned an -// error. Wrap short-circuits via AbortError get converted to -// *output.ExitError so cmd/root.go emits the right envelope. +// error. Wrap short-circuits via AbortError get converted to a +// typed *errs.ValidationError so cmd/root.go emits the right +// envelope. // // - **Denial layer / source are populated from cobra annotations // before any hook fires.** populateInvocationDenial reads the @@ -83,8 +84,8 @@ func wrapRunE(cmd *cobra.Command, reg *Registry, snapshot CommandViewSource) { inv := newInvocation(view, args) // Detect denial: a denied command's original RunE was already - // replaced by cmdpolicy.Apply with a denyStub that returns - // *output.ExitError wrapping *platform.CommandDeniedError. We + // replaced by cmdpolicy.Apply with a denyStub that returns a + // typed error wrapping *platform.CommandDeniedError. We // invoke originalRunE once with a probe-only context (no args // matter because DisableFlagParsing is set on denied commands) // to extract its CommandDeniedError, but for V1 we use a @@ -135,8 +136,8 @@ func wrapRunE(cmd *cobra.Command, reg *Registry, snapshot CommandViewSource) { err = finalHandler(ctx, inv) } - // Convert AbortError -> *output.ExitError so the envelope writer - // renders the structured "hook" type. + // Convert AbortError -> typed *errs.ValidationError so the + // envelope writer renders the structured envelope. err = wrapAbortError(err) inv.setErr(err) @@ -195,17 +196,13 @@ func runObserverSafe(ctx context.Context, obs ObserverEntry, inv platform.Invoca obs.Fn(ctx, inv) } -// wrapAbortError converts *platform.AbortError into the equivalent -// *output.ExitError so cmd/root.go's envelope writer emits the right -// JSON structure (type="hook"). Non-AbortError values pass through -// unchanged. +// wrapAbortError converts *platform.AbortError into a typed +// *errs.ValidationError (failed_precondition) so cmd/root.go's typed +// envelope writer emits the structured JSON envelope. Non-AbortError +// values pass through unchanged. // -// Deprecated: wrapAbortError converts to a legacy *output.ExitError that -// predates the typed error contract introduced by errs/. New code MUST NOT -// add producers of this shape — hook abort signals should move to a typed -// *errs.XxxError (typed hook error is tracked for the hook framework -// migration PR). This helper is retained only while existing call sites are -// migrated; it will be removed once they have moved to the typed surface. +// The AbortError is preserved as the Cause so errors.As consumers can +// still extract HookName / Reason / Detail in process. func wrapAbortError(err error) error { if err == nil { return nil @@ -214,27 +211,16 @@ func wrapAbortError(err error) error { if !errors.As(err, &ab) { return err } - return &output.ExitError{ - Code: output.ExitValidation, - Detail: &output.ErrDetail{ - Type: "hook", - Message: ab.Error(), - Detail: map[string]any{ - "hook_name": ab.HookName, - "reason": ab.Reason, - "reason_code": "aborted", - "detail": ab.Detail, - }, - }, - Err: ab, - } + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", ab.Error()). + WithHint("plugin hook %q aborted this command; adjust the request to satisfy the hook's policy, or remove the plugin", ab.HookName). + WithCause(ab) } // recoverWrap wraps a Wrapper so any panic anywhere in the plugin's // implementation -- including the wrapper FACTORY call (the // `func(next Handler) Handler` step) and the inner Handler call -- is -// recovered and surfaced as a structured *output.ExitError with -// type="hook" and reason_code="panic". Without this guard, a panicking +// recovered and surfaced as a typed *errs.ValidationError +// (failed_precondition). Without this guard, a panicking // plugin would crash the entire CLI process and break the structured- // error contract (downstream automation cannot parse a stack trace). // @@ -269,19 +255,17 @@ func recoverWrap(fullName string, w platform.Wrapper) platform.Wrapper { return func(ctx context.Context, inv platform.Invocation) (returned error) { defer func() { if r := recover(); r != nil { - returned = &output.ExitError{ - Code: output.ExitValidation, - Detail: &output.ErrDetail{ - Type: "hook", - Message: fmt.Sprintf("hook %q panicked: %v", fullName, r), - Detail: map[string]any{ - "hook_name": fullName, - "reason_code": "panic", - "reason": fmt.Sprintf("%v", r), - }, - }, - Err: fmt.Errorf("hook %q panic: %v", fullName, r), + // Preserve the panic value's error identity in the cause + // chain when it is an error, so errors.Is/As can still reach + // it; fall back to %v formatting for non-error panics. + cause := fmt.Errorf("hook %q panic: %v", fullName, r) + if e, ok := r.(error); ok { + cause = fmt.Errorf("hook %q panic: %w", fullName, e) } + returned = errs.NewValidationError(errs.SubtypeFailedPrecondition, + "hook %q panicked: %v", fullName, r). + WithHint("plugin hook %q crashed while handling this command; report the panic to the plugin author or remove the plugin", fullName). + WithCause(cause) } }() // Construct AFTER the recover is armed so a panicking diff --git a/internal/hook/install_test.go b/internal/hook/install_test.go index 7f11f2897..b33398f7b 100644 --- a/internal/hook/install_test.go +++ b/internal/hook/install_test.go @@ -8,10 +8,12 @@ import ( "context" "errors" "fmt" + "strings" "testing" "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/platform" "github.com/larksuite/cli/internal/hook" "github.com/larksuite/cli/internal/output" @@ -208,8 +210,10 @@ func TestInstall_observerPanicIsolated(t *testing.T) { } } -// A Wrapper returning AbortError surfaces as *output.ExitError with -// type="hook" so cmd/root.go's envelope writer can serialise it. +// A Wrapper returning AbortError surfaces as a typed +// *errs.ValidationError (failed_precondition, exit 2) so cmd/root.go's +// envelope writer can serialise it. The original AbortError is preserved +// as the Cause so errors.As consumers still reach HookName / Reason. func TestInstall_abortErrorBecomesExitError(t *testing.T) { root := &cobra.Command{Use: "lark-cli"} leaf := makeLeaf("+x") @@ -234,21 +238,28 @@ func TestInstall_abortErrorBecomesExitError(t *testing.T) { if err == nil { t.Fatalf("Wrap aborted; expected error") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("AbortError must convert to *output.ExitError, got %T %+v", err, err) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("AbortError must convert to *errs.ValidationError, got %T %+v", err, err) } - if exitErr.Detail.Type != "hook" { - t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type) + if ve.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition) } - detail := exitErr.Detail.Detail.(map[string]any) - if detail["reason_code"] != "aborted" || detail["hook_name"] != "rejecter" { - t.Errorf("detail = %+v", detail) + if code := output.ExitCodeOf(err); code != output.ExitValidation { + t.Errorf("exit code = %d, want ExitValidation (%d)", code, output.ExitValidation) } - // The original AbortError must still be reachable via errors.As. + // The hook name must be discoverable in the user-facing hint. + if !strings.Contains(ve.Hint, "rejecter") { + t.Errorf("hint must carry hook name rejecter, got %q", ve.Hint) + } + // The original AbortError must still be reachable via errors.As, with + // its attribution intact. var ab *platform.AbortError if !errors.As(err, &ab) { - t.Errorf("error chain should expose *platform.AbortError") + t.Fatalf("error chain should expose *platform.AbortError") + } + if ab.HookName != "rejecter" || ab.Reason != "policy says no" { + t.Errorf("AbortError = %+v, want HookName=rejecter Reason=%q", ab, "policy says no") } } @@ -317,13 +328,19 @@ func (fakeViewSourceByPath) View(c *cobra.Command) platform.CommandView { func checkHookName(t *testing.T, err error, want string) { t.Helper() - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected ExitError, got %T", err) + // The abort surfaces as a typed *errs.ValidationError; the original + // (namespaced copy of the) AbortError is preserved as its Cause, so + // errors.As reaches the attribution the framework wrote. + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *errs.ValidationError, got %T", err) + } + var ab *platform.AbortError + if !errors.As(err, &ab) { + t.Fatalf("error chain should expose *platform.AbortError, got %T", err) } - detail := exitErr.Detail.Detail.(map[string]any) - if detail["hook_name"] != want { - t.Errorf("hook_name = %v, want %v", detail["hook_name"], want) + if ab.HookName != want { + t.Errorf("hook_name = %v, want %v", ab.HookName, want) } } diff --git a/internal/keychain/keychain.go b/internal/keychain/keychain.go index 3af04ca50..334442efb 100644 --- a/internal/keychain/keychain.go +++ b/internal/keychain/keychain.go @@ -9,7 +9,7 @@ import ( "errors" "fmt" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" ) var ( @@ -28,8 +28,9 @@ const ( LarkCliService = "lark-cli" ) -// wrapError is a helper to wrap underlying errors into output.ExitError. -// It formats the error message and provides a hint for troubleshooting keychain access issues. +// wrapError wraps underlying keychain failures into a typed *errs.APIError +// (exit code 1) carrying a hint for troubleshooting keychain access issues. +// nil and ErrNotFound pass through unchanged. func wrapError(op string, err error) error { if err == nil || errors.Is(err, ErrNotFound) { return err @@ -48,7 +49,9 @@ func wrapError(op string, err error) error { LogAuthError("keychain", op, fmt.Errorf("keychain %s error: %w", op, err)) }() - return output.ErrWithHint(output.ExitAPI, "config", msg, hint) + return errs.NewAPIError(errs.SubtypeUnknown, "%s", msg). + WithHint("%s", hint). + WithCause(err) } // KeychainAccess abstracts keychain Get/Set/Remove for dependency injection. diff --git a/internal/keychain/keychain_darwin_test.go b/internal/keychain/keychain_darwin_test.go index 3d24ca759..52304c9da 100644 --- a/internal/keychain/keychain_darwin_test.go +++ b/internal/keychain/keychain_darwin_test.go @@ -13,7 +13,7 @@ import ( "strings" "testing" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/zalando/go-keyring" ) @@ -367,7 +367,7 @@ func TestPlatformGetSurfacesKeychainBlocked(t *testing.T) { // the blocked path used an anonymous errors.New string, so the extraHint // `errors.Is` check (only matched errNotInitialized) couldn't recognize it. // -// Asserts the full wrapError → ExitError.Detail.Hint pipeline: +// Asserts the full wrapError → typed APIError hint pipeline: // - errKeychainBlocked + errNotInitialized → hint mentions keychain-downgrade // - "keychain is corrupted" (downgrade would re-read the same bad bytes) → no mention // - generic errors → no mention @@ -388,13 +388,13 @@ func TestWrapErrorHintMentionsDowngradeForRecoverableCases(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := wrapError("Get", tc.err) - var ee *output.ExitError - if !errors.As(err, &ee) || ee.Detail == nil { - t.Fatalf("wrapError returned %#v; expected *output.ExitError with Detail", err) + var apiErr *errs.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("wrapError returned %#v; expected *errs.APIError", err) } - got := strings.Contains(ee.Detail.Hint, "keychain-downgrade") + got := strings.Contains(apiErr.Hint, "keychain-downgrade") if got != tc.wantHint { - t.Fatalf("hint mentions keychain-downgrade = %v, want %v\n full hint: %q", got, tc.wantHint, ee.Detail.Hint) + t.Fatalf("hint mentions keychain-downgrade = %v, want %v\n full hint: %q", got, tc.wantHint, apiErr.Hint) } }) } diff --git a/internal/keychain/keychain_typed_error_test.go b/internal/keychain/keychain_typed_error_test.go new file mode 100644 index 000000000..3e6ae3700 --- /dev/null +++ b/internal/keychain/keychain_typed_error_test.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package keychain + +import ( + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/output" +) + +// TestWrapErrorEmitsTypedAPIError pins the wrapError contract after the typed +// errs migration: keychain failures surface as *errs.APIError with subtype +// "unknown", exit code 1 (ExitAPI, unchanged from the legacy behavior), a +// non-empty troubleshooting hint, and the underlying error reachable via +// errors.Unwrap. +func TestWrapErrorEmitsTypedAPIError(t *testing.T) { + underlying := errors.New("keyring backend exploded") + err := wrapError("Set", underlying) + + var apiErr *errs.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("wrapError returned %T (%v); expected *errs.APIError", err, err) + } + if apiErr.Subtype != errs.SubtypeUnknown { + t.Errorf("subtype = %q, want %q", apiErr.Subtype, errs.SubtypeUnknown) + } + if got := output.ExitCodeOf(err); got != output.ExitAPI { + t.Errorf("exit code = %d, want %d (ExitAPI, legacy parity)", got, output.ExitAPI) + } + if !strings.Contains(apiErr.Message, "keychain Set failed") { + t.Errorf("message = %q, want it to contain %q", apiErr.Message, "keychain Set failed") + } + if apiErr.Hint == "" { + t.Error("hint is empty; wrapError must carry a troubleshooting hint") + } + if !errors.Is(err, underlying) { + t.Error("underlying error not reachable via errors.Is; WithCause missing") + } +} + +// TestWrapErrorPassthrough pins the non-wrapping paths: nil stays nil and +// ErrNotFound is forwarded untouched so callers can keep using errors.Is. +func TestWrapErrorPassthrough(t *testing.T) { + if err := wrapError("Get", nil); err != nil { + t.Errorf("wrapError(nil) = %v, want nil", err) + } + if err := wrapError("Get", ErrNotFound); !errors.Is(err, ErrNotFound) { + t.Errorf("wrapError(ErrNotFound) = %v, want ErrNotFound passthrough", err) + } + var apiErr *errs.APIError + if err := wrapError("Get", ErrNotFound); errors.As(err, &apiErr) { + t.Errorf("wrapError(ErrNotFound) wrapped into %T; want passthrough", apiErr) + } +} diff --git a/internal/output/bare.go b/internal/output/bare.go new file mode 100644 index 000000000..6e90f43a3 --- /dev/null +++ b/internal/output/bare.go @@ -0,0 +1,19 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import "fmt" + +// BareError is the silent-exit signal for commands whose stdout already +// carries the complete answer and that only need the matching exit code +// without a stderr envelope. Two cases use it: a predicate writing its yes/no +// JSON (e.g. `auth check` exiting non-zero on a no-token state), and a command +// emitting its own structured result envelope under `--json` (e.g. `update`). +// Deliberately outside the typed-envelope contract. +type BareError struct{ Code int } + +func (e *BareError) Error() string { return fmt.Sprintf("bare exit %d", e.Code) } + +// ErrBare builds the silent-exit signal with the given code. +func ErrBare(code int) *BareError { return &BareError{Code: code} } diff --git a/internal/output/bare_test.go b/internal/output/bare_test.go new file mode 100644 index 000000000..907768167 --- /dev/null +++ b/internal/output/bare_test.go @@ -0,0 +1,23 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output_test + +import ( + "testing" + + "github.com/larksuite/cli/internal/output" +) + +func TestExitCodeOfBareError(t *testing.T) { + if got := output.ExitCodeOf(output.ErrBare(3)); got != 3 { + t.Errorf("ExitCodeOf(ErrBare(3)) = %d, want 3", got) + } +} + +// TestErrBareReturnsBareError pins that the silent-exit signal is the +// dedicated *output.BareError type, keeping that contract on its own +// narrow signal type. +func TestErrBareReturnsBareError(t *testing.T) { + var _ *output.BareError = output.ErrBare(1) +} diff --git a/internal/output/emit.go b/internal/output/emit.go index dfc4598b1..3c85a247c 100644 --- a/internal/output/emit.go +++ b/internal/output/emit.go @@ -9,6 +9,7 @@ import ( "io" "strings" + "github.com/larksuite/cli/errs" extcs "github.com/larksuite/cli/extension/contentsafety" ) @@ -35,19 +36,15 @@ func ScanForSafety(cmdPath string, data any, errOut io.Writer) ScanResult { return ScanResult{Alert: alert} } -// wrapBlockError creates an ExitError for content-safety block. +// wrapBlockError creates a typed content-safety error for a block-mode alert. func wrapBlockError(alert *extcs.Alert) error { - rules := "" + var rules []string if alert != nil { - rules = strings.Join(alert.MatchedRules, ", ") - } - return &ExitError{ - Code: ExitContentSafety, - Detail: &ErrDetail{ - Type: "content_safety_blocked", - Message: fmt.Sprintf("content safety violation detected (rules: %s)", rules), - }, + rules = alert.MatchedRules } + e := errs.NewContentSafetyError(errs.SubtypeUnknown, "content safety violation detected (rules: %s)", strings.Join(rules, ", ")) + e.Rules = rules + return e } // WriteAlertWarning writes a human-readable content-safety warning to w. diff --git a/internal/output/emit_test.go b/internal/output/emit_test.go index a25c1e620..ef3f05957 100644 --- a/internal/output/emit_test.go +++ b/internal/output/emit_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/larksuite/cli/errs" extcs "github.com/larksuite/cli/extension/contentsafety" ) @@ -72,12 +73,15 @@ func TestScanForSafety_ModeBlock_WithAlert(t *testing.T) { if result.BlockErr == nil { t.Error("block mode with alert should have BlockErr") } - var exitErr *ExitError - if !errors.As(result.BlockErr, &exitErr) { - t.Fatalf("BlockErr should be *ExitError, got %T", result.BlockErr) + var csErr *errs.ContentSafetyError + if !errors.As(result.BlockErr, &csErr) { + t.Fatalf("BlockErr should be *errs.ContentSafetyError, got %T", result.BlockErr) } - if exitErr.Code != ExitContentSafety { - t.Errorf("exit code = %d, want %d", exitErr.Code, ExitContentSafety) + if got := ExitCodeOf(result.BlockErr); got != ExitContentSafety { + t.Errorf("exit code = %d, want %d", got, ExitContentSafety) + } + if len(csErr.Rules) != 1 || csErr.Rules[0] != "r1" { + t.Errorf("rules = %v, want [r1]", csErr.Rules) } } diff --git a/internal/output/envelope.go b/internal/output/envelope.go index b94e823b9..d25730133 100644 --- a/internal/output/envelope.go +++ b/internal/output/envelope.go @@ -13,58 +13,6 @@ type Envelope struct { Notice map[string]interface{} `json:"_notice,omitempty"` } -// ErrorEnvelope is the standard error response wrapper. -// -// Deprecated: ErrorEnvelope belongs to the legacy *output.ExitError surface -// that predates the typed error contract introduced by errs/. New code MUST -// NOT use it — the typed envelope shape is owned by -// internal/output.WriteTypedErrorEnvelope which marshals typed errs.* errors -// directly via JSON reflection (no wrapper struct needed). This struct is -// retained only while existing *ExitError call sites are migrated; it will -// be removed once they have moved to the typed surface. -type ErrorEnvelope struct { - OK bool `json:"ok"` - Identity string `json:"identity,omitempty"` - Error *ErrDetail `json:"error"` - Meta *Meta `json:"meta,omitempty"` - Notice map[string]interface{} `json:"_notice,omitempty"` -} - -// ErrDetail describes a structured error. -// -// Deprecated: ErrDetail belongs to the legacy *output.ExitError surface that -// predates the typed error contract introduced by errs/. New code MUST NOT -// use it — typed errs.* structs embed errs.Problem and own their wire shape -// via JSON tags (Category, Subtype, Hint, etc. promote to the top level). -// This struct is retained only while existing *ExitError call sites are -// migrated; it will be removed once they have moved to the typed surface. -type ErrDetail struct { - Type string `json:"type"` - Code int `json:"code,omitempty"` - Message string `json:"message"` - Hint string `json:"hint,omitempty"` - ConsoleURL string `json:"console_url,omitempty"` - Risk *RiskDetail `json:"risk,omitempty"` - Detail interface{} `json:"detail,omitempty"` -} - -// RiskDetail carries agent-protocol risk information alongside -// confirmation_required errors. Level is one of "read" | "write" | -// "high-risk-write". Action identifies the command for the agent (e.g. -// "mail +send", "drive.files.delete"). -// -// Deprecated: RiskDetail is reachable only via *output.ExitError.Detail.Risk, -// part of the legacy envelope surface that predates the typed error contract -// introduced by errs/. New code MUST NOT use it — confirmation-required -// signals belong on *errs.ConfirmationRequiredError (its own typed extension -// fields can carry agent-protocol metadata directly). This struct is -// retained only while existing *ExitError call sites are migrated; it will -// be removed once they have moved to the typed surface. -type RiskDetail struct { - Level string `json:"level"` - Action string `json:"action"` -} - // Meta carries optional metadata in envelope responses. type Meta struct { Count int `json:"count,omitempty"` diff --git a/internal/output/errors.go b/internal/output/errors.go index 69fcd8339..688644663 100644 --- a/internal/output/errors.go +++ b/internal/output/errors.go @@ -6,177 +6,19 @@ package output import ( "bytes" "encoding/json" - "errors" "fmt" "io" "github.com/larksuite/cli/errs" ) -// ExitError is a structured error that carries an exit code and optional detail. -// It is propagated up the call chain and handled by main.go to produce -// a JSON error envelope on stderr and the correct exit code. -// -// Deprecated: legacy error type. Return a typed *errs.XxxError instead -// (see errs/types.go). -type ExitError struct { - Code int - Detail *ErrDetail - Err error - Raw bool // when true, the dispatcher skips enrichment (e.g. enrichPermissionError) and preserves the original error detail -} - -func (e *ExitError) Error() string { - if e.Detail != nil { - return e.Detail.Message - } - if e.Err != nil { - return e.Err.Error() - } - return fmt.Sprintf("exit %d", e.Code) -} - -func (e *ExitError) Unwrap() error { - return e.Err -} - -// MarkRaw sets Raw=true on an ExitError so that the dispatcher skips -// enrichment (e.g. enrichPermissionError, enrichMissingScopeError) and -// preserves the upstream message verbatim. Returns the original error -// unchanged if it is not (or does not wrap) an ExitError. -// -// Used by `cmd/api` and other "passthrough" call sites where the caller -// wants the original Lark response wording rather than the enriched -// message/hint variant. -func MarkRaw(err error) error { - var exitErr *ExitError - if errors.As(err, &exitErr) { - exitErr.Raw = true - } - return err -} - -// WriteErrorEnvelope writes a JSON error envelope for the given ExitError to w. -// -// Deprecated: legacy envelope writer. Typed errors are dispatched by -// cmd/root.go through WriteTypedErrorEnvelope. -func WriteErrorEnvelope(w io.Writer, err *ExitError, identity string) { - if err.Detail == nil { - return - } - env := &ErrorEnvelope{ - OK: false, - Identity: identity, - Error: err.Detail, - Notice: GetNotice(), - } - var buf bytes.Buffer - enc := json.NewEncoder(&buf) - enc.SetEscapeHTML(false) - enc.SetIndent("", " ") - if err := enc.Encode(env); err != nil { - return - } - // Encode appends a trailing newline; write directly. - buf.WriteTo(w) -} - -// --- Convenience constructors --- - -// Errorf creates an ExitError with the given code, type, and formatted message. -// -// Deprecated: construct a typed *errs.XxxError directly -// (e.g. errs.NewValidationError, errs.NewInternalError). -func Errorf(code int, errType, format string, args ...any) *ExitError { - var err error - for _, arg := range args { - if e, ok := arg.(error); ok { - err = e - break - } - } - return &ExitError{ - Code: code, - Detail: &ErrDetail{Type: errType, Message: fmt.Sprintf(format, args...)}, - Err: err, - } -} - -// ErrValidation creates a validation ExitError (exit 2, wire type -// "validation"). The legacy envelope emits only `type`+`message`; for -// `subtype` / `param` extension fields, construct a typed -// *errs.ValidationError directly. -func ErrValidation(format string, args ...any) *ExitError { - return Errorf(ExitValidation, "validation", format, args...) -} - -// ErrAuth creates an authentication ExitError (exit 3, wire type "auth"). -// -// New code should construct a typed *errs.AuthenticationError directly; -// the typed envelope emits the canonical `type: "authentication"`. -// Migrating an existing call site flips a user-visible wire field. -func ErrAuth(format string, args ...any) *ExitError { - return Errorf(ExitAuth, "auth", format, args...) -} - -// ErrNetwork creates a network ExitError (exit 4, wire type "network"). -// The legacy envelope emits only `type`+`message`; for `subtype` -// ("transport" / "timeout" / "tls" / "dns") and retryable hint extension -// fields, construct a typed *errs.NetworkError directly. -func ErrNetwork(format string, args ...any) *ExitError { - return Errorf(ExitNetwork, "network", format, args...) -} - -// ErrAPI creates an API ExitError using ClassifyLarkError. -// For permission errors, uses a concise message; the raw API response is preserved in Detail. -// -// Deprecated: route through errclass.BuildAPIError, which emits typed -// *errs.PermissionError / *errs.AuthenticationError / etc. with -// MissingScopes, ConsoleURL, and Identity at the source. -func ErrAPI(larkCode int, msg string, detail any) *ExitError { - exitCode, errType, hint := ClassifyLarkError(larkCode, msg) - if errType == "permission" { - msg = fmt.Sprintf("Permission denied [%d]", larkCode) - } - return &ExitError{ - Code: exitCode, - Detail: &ErrDetail{ - Type: errType, - Code: larkCode, - Message: msg, - Hint: hint, - Detail: detail, - }, - } -} - -// ErrWithHint creates an ExitError with a hint string. -// -// Deprecated: construct a typed *errs.XxxError directly and set its Hint -// field; the typed envelope promotes Problem.Hint to the wire. -func ErrWithHint(code int, errType, msg, hint string) *ExitError { - return &ExitError{ - Code: code, - Detail: &ErrDetail{Type: errType, Message: msg, Hint: hint}, - } -} - -// ErrBare creates an ExitError with only an exit code and no envelope. -// The predicate-command silent-exit signal: stdout has already been -// written and the caller wants the matching exit code without a stderr -// envelope (e.g. `auth check` emitting its JSON result and then exiting -// non-zero on a no-token state). Outside the typed-envelope contract. -func ErrBare(code int) *ExitError { - return &ExitError{Code: code} -} - // PartialFailureError is the exit signal for a batch / multi-status command that // has already written an ok:false result envelope to stdout. The per-item // outcomes are the primary, machine-readable output and live on stdout, so the // dispatcher sets only the exit code and writes nothing to stderr. // -// It is deliberately distinct from ErrBare (the predicate silent-exit signal) -// so the predicate contract stays narrow, and from a typed *errs.XxxError +// It is deliberately distinct from ErrBare (the stdout-carries-the-answer +// silent-exit signal) so that contract stays narrow, and from a typed *errs.XxxError // (which owns the stderr error envelope): a partial failure is a result, not an // error envelope. type PartialFailureError struct { @@ -211,8 +53,8 @@ func PartialFailure(code int) *PartialFailureError { // parse-or-skip on malformed JSON. // // Returns true when err was a typed error and serialization succeeded. -// Returns false only when err carries no Problem (caller should fall back -// to WriteErrorEnvelope) or when JSON encoding itself failed. +// Returns false only when err carries no Problem (the dispatcher then handles +// it via its signal / usage-error branches) or when JSON encoding itself failed. func WriteTypedErrorEnvelope(w io.Writer, err error, identity string) bool { typed, ok := errs.UnwrapTypedError(err) if !ok { @@ -229,8 +71,8 @@ func WriteTypedErrorEnvelope(w io.Writer, err error, identity string) bool { enc.SetEscapeHTML(false) enc.SetIndent("", " ") if encErr := enc.Encode(env); encErr != nil { - // Encoding failed — emit nothing here and let the dispatcher fall - // back to the legacy envelope writer so stderr is never blank. + // Encoding failed — emit nothing here; the dispatcher's fall-through + // branches still surface the error, so stderr is never blank. return false } // Best-effort write. Partial-write does not downgrade the success status: @@ -243,7 +85,7 @@ func WriteTypedErrorEnvelope(w io.Writer, err error, identity string) bool { // typedEnvelope wraps a typed error for wire emission. Error is `error` so the // underlying typed error's own json tags determine the inner shape via -// encoding/json reflection; Notice mirrors the existing ErrorEnvelope (see +// encoding/json reflection; Notice mirrors the success Envelope's notice (see // GetNotice in envelope.go). type typedEnvelope struct { OK bool `json:"ok"` diff --git a/internal/output/errors_test.go b/internal/output/errors_test.go index 9d4fe9e6a..148db564a 100644 --- a/internal/output/errors_test.go +++ b/internal/output/errors_test.go @@ -4,9 +4,6 @@ package output import ( - "bytes" - "encoding/json" - "errors" "io" "testing" @@ -36,10 +33,9 @@ func (f *failingWriter) Write(p []byte) (int, error) { // TestWriteTypedErrorEnvelope_PartialWritePreservesSuccessStatus pins that // when serialization succeeds but the underlying write fails mid-envelope, -// WriteTypedErrorEnvelope returns true so the dispatcher does NOT fall -// through to the legacy "Error:" path and clobber the typed exit code with -// 1. Exit code is preserved separately by handleRootError computing -// ExitCodeOf(err) before the write. +// WriteTypedErrorEnvelope returns true so the dispatcher honors the typed +// exit code instead of reclassifying the error. Exit code is preserved +// separately by handleRootError computing ExitCodeOf(err) before the write. func TestWriteTypedErrorEnvelope_PartialWritePreservesSuccessStatus(t *testing.T) { err := errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired") w := &failingWriter{limit: 20} // dies mid-envelope @@ -48,89 +44,6 @@ func TestWriteTypedErrorEnvelope_PartialWritePreservesSuccessStatus(t *testing.T } } -func TestWriteErrorEnvelope_WithNotice(t *testing.T) { - // Set up PendingNotice - origNotice := PendingNotice - PendingNotice = func() map[string]interface{} { - return map[string]interface{}{ - "update": map[string]interface{}{ - "current": "1.0.0", - "latest": "2.0.0", - }, - } - } - defer func() { PendingNotice = origNotice }() - - exitErr := &ExitError{ - Code: 1, - Detail: &ErrDetail{Type: "api_error", Message: "something failed"}, - } - - var buf bytes.Buffer - WriteErrorEnvelope(&buf, exitErr, "user") - - var env map[string]interface{} - if err := json.Unmarshal(buf.Bytes(), &env); err != nil { - t.Fatalf("failed to parse output: %v", err) - } - - // Verify _notice is present - notice, ok := env["_notice"].(map[string]interface{}) - if !ok { - t.Fatal("expected _notice field in output") - } - update, ok := notice["update"].(map[string]interface{}) - if !ok { - t.Fatal("expected _notice.update field") - } - if update["latest"] != "2.0.0" { - t.Errorf("expected latest=2.0.0, got %v", update["latest"]) - } - - // Verify standard fields - if env["ok"] != false { - t.Error("expected ok=false") - } - if env["identity"] != "user" { - t.Errorf("expected identity=user, got %v", env["identity"]) - } -} - -func TestWriteErrorEnvelope_WithoutNotice(t *testing.T) { - // Ensure PendingNotice is nil - origNotice := PendingNotice - PendingNotice = nil - defer func() { PendingNotice = origNotice }() - - exitErr := &ExitError{ - Code: 1, - Detail: &ErrDetail{Type: "api_error", Message: "something failed"}, - } - - var buf bytes.Buffer - WriteErrorEnvelope(&buf, exitErr, "bot") - - var env map[string]interface{} - if err := json.Unmarshal(buf.Bytes(), &env); err != nil { - t.Fatalf("failed to parse output: %v", err) - } - - if _, ok := env["_notice"]; ok { - t.Error("expected no _notice field when PendingNotice is nil") - } -} - -func TestWriteErrorEnvelope_NilDetail(t *testing.T) { - exitErr := &ExitError{Code: 1} - - var buf bytes.Buffer - WriteErrorEnvelope(&buf, exitErr, "user") - - if buf.Len() != 0 { - t.Errorf("expected no output for nil Detail, got: %s", buf.String()) - } -} - func TestGetNotice(t *testing.T) { // Nil PendingNotice → nil origNotice := PendingNotice @@ -156,89 +69,3 @@ func TestGetNotice(t *testing.T) { PendingNotice = origNotice } - -// TestErrValidation_LegacyExitErrorShape pins the wire contract for -// output.ErrValidation: the helper MUST return *output.ExitError (so -// callers using errors.As(&exitErr) continue to work), with wire fields -// restricted to type+message — no `subtype` emission. Typed -// *errs.ValidationError carries the extension fields when needed. -func TestErrValidation_LegacyExitErrorShape(t *testing.T) { - err := ErrValidation("bad arg: %s", "x") - - var exitErr *ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("ErrValidation must return *ExitError, got %T", err) - } - if exitErr.Code != ExitValidation { - t.Errorf("Code = %d, want ExitValidation (%d)", exitErr.Code, ExitValidation) - } - if exitErr.Detail == nil { - t.Fatal("Detail must be populated") - } - if exitErr.Detail.Type != "validation" { - t.Errorf("Detail.Type = %q, want %q", exitErr.Detail.Type, "validation") - } - if exitErr.Detail.Message != "bad arg: x" { - t.Errorf("Detail.Message = %q, want %q", exitErr.Detail.Message, "bad arg: x") - } - - // Wire envelope must have only type+message — no subtype field. - var buf bytes.Buffer - WriteErrorEnvelope(&buf, exitErr, "user") - var wire map[string]any - if err := json.Unmarshal(buf.Bytes(), &wire); err != nil { - t.Fatalf("envelope JSON parse failed: %v\nraw: %s", err, buf.String()) - } - errObj, ok := wire["error"].(map[string]any) - if !ok { - t.Fatalf("envelope missing 'error' object; got: %s", buf.String()) - } - if _, hasSubtype := errObj["subtype"]; hasSubtype { - t.Errorf("legacy ErrValidation envelope must NOT emit `subtype`; got: %s", buf.String()) - } - if errObj["type"] != "validation" { - t.Errorf("envelope error.type = %v, want \"validation\"", errObj["type"]) - } -} - -// TestErrNetwork_LegacyExitErrorShape pins the wire contract for -// output.ErrNetwork: same legacy *output.ExitError shape as ErrValidation — -// no subtype field, errors.As(&exitErr) must succeed, exit code ExitNetwork. -func TestErrNetwork_LegacyExitErrorShape(t *testing.T) { - err := ErrNetwork("conn refused: %s", "10.0.0.1") - - var exitErr *ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("ErrNetwork must return *ExitError, got %T", err) - } - if exitErr.Code != ExitNetwork { - t.Errorf("Code = %d, want ExitNetwork (%d)", exitErr.Code, ExitNetwork) - } - if exitErr.Detail == nil { - t.Fatal("Detail must be populated") - } - if exitErr.Detail.Type != "network" { - t.Errorf("Detail.Type = %q, want %q", exitErr.Detail.Type, "network") - } - if exitErr.Detail.Message != "conn refused: 10.0.0.1" { - t.Errorf("Detail.Message = %q, want %q", exitErr.Detail.Message, "conn refused: 10.0.0.1") - } - - // Wire envelope must have only type+message — no subtype field. - var buf bytes.Buffer - WriteErrorEnvelope(&buf, exitErr, "user") - var wire map[string]any - if err := json.Unmarshal(buf.Bytes(), &wire); err != nil { - t.Fatalf("envelope JSON parse failed: %v\nraw: %s", err, buf.String()) - } - errObj, ok := wire["error"].(map[string]any) - if !ok { - t.Fatalf("envelope missing 'error' object; got: %s", buf.String()) - } - if _, hasSubtype := errObj["subtype"]; hasSubtype { - t.Errorf("legacy ErrNetwork envelope must NOT emit `subtype`; got: %s", buf.String()) - } - if errObj["type"] != "network" { - t.Errorf("envelope error.type = %v, want \"network\"", errObj["type"]) - } -} diff --git a/internal/output/exitcode.go b/internal/output/exitcode.go index 953a31042..641f9e8e2 100644 --- a/internal/output/exitcode.go +++ b/internal/output/exitcode.go @@ -47,12 +47,9 @@ func ExitCodeForCategory(cat errs.Category) int { } // ExitCodeOf returns the shell exit code for any error. -// - typed errors (*errs.PermissionError, *errs.APIError, ...) → routed by Category -// - legacy *output.ExitError → uses its own Code field -// - *core.ConfigError → reaches the dispatcher as a legacy -// *output.ExitError via cmd/root asExitError (stage 1); the typed -// promotion path through internal/errcompat.PromoteConfigError is -// reserved for stage 2+. +// - typed errors (*errs.PermissionError, *errs.APIError, *errs.ConfigError, +// *errs.AuthenticationError, ...) → routed by Category +// - *PartialFailureError / *BareError signals → their own Code field // - untyped → ExitInternal func ExitCodeOf(err error) int { if err == nil { @@ -65,9 +62,9 @@ func ExitCodeOf(err error) int { if errors.As(err, &pfErr) { return pfErr.Code } - var exitErr *ExitError - if errors.As(err, &exitErr) { - return exitErr.Code + var bare *BareError + if errors.As(err, &bare) { + return bare.Code } return ExitInternal } diff --git a/internal/output/jq.go b/internal/output/jq.go index 176c68987..414a8cde6 100644 --- a/internal/output/jq.go +++ b/internal/output/jq.go @@ -10,6 +10,8 @@ import ( "math/big" "github.com/itchyny/gojq" + + "github.com/larksuite/cli/errs" ) // JqFilter applies a jq expression to data and writes the results to w. @@ -31,11 +33,11 @@ func JqFilterRaw(w io.Writer, data interface{}, expr string) error { func jqFilter(w io.Writer, data interface{}, expr string, raw bool) error { query, err := gojq.Parse(expr) if err != nil { - return ErrValidation("invalid jq expression: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid jq expression: %s", err).WithCause(err) } code, err := gojq.Compile(query) if err != nil { - return ErrValidation("invalid jq expression: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid jq expression: %s", err).WithCause(err) } // Normalize data through toGeneric so typed structs become map[string]any. @@ -50,7 +52,7 @@ func jqFilter(w io.Writer, data interface{}, expr string, raw bool) error { break } if err, isErr := v.(error); isErr { - return Errorf(ExitAPI, "jq_error", "jq error: %s", err) + return errs.NewAPIError(errs.SubtypeUnknown, "jq error: %s", err).WithCause(err) } if err := writeJqValue(w, v, raw); err != nil { return err @@ -66,10 +68,10 @@ func ValidateJqFlags(jqExpr, outputFlag, format string) error { return nil } if outputFlag != "" { - return ErrValidation("--jq and --output are mutually exclusive") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--jq and --output are mutually exclusive") } if format != "" && format != "json" { - return ErrValidation("--jq and --format %s are mutually exclusive", format) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--jq and --format %s are mutually exclusive", format) } return ValidateJqExpression(jqExpr) } @@ -78,11 +80,11 @@ func ValidateJqFlags(jqExpr, outputFlag, format string) error { func ValidateJqExpression(expr string) error { query, err := gojq.Parse(expr) if err != nil { - return ErrValidation("invalid jq expression: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid jq expression: %s", err).WithCause(err) } _, err = gojq.Compile(query) if err != nil { - return ErrValidation("invalid jq expression: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid jq expression: %s", err).WithCause(err) } return nil } @@ -114,13 +116,13 @@ func writeJqValue(w io.Writer, v interface{}, raw bool) error { enc.SetEscapeHTML(false) enc.SetIndent("", " ") if err := enc.Encode(v); err != nil { - return Errorf(ExitInternal, "jq_error", "failed to marshal jq result: %s", err) + return errs.NewInternalError(errs.SubtypeSDKError, "failed to marshal jq result: %s", err).WithCause(err) } return nil } b, err := json.MarshalIndent(v, "", " ") if err != nil { - return Errorf(ExitInternal, "jq_error", "failed to marshal jq result: %s", err) + return errs.NewInternalError(errs.SubtypeSDKError, "failed to marshal jq result: %s", err).WithCause(err) } fmt.Fprintln(w, string(b)) } diff --git a/internal/output/lark_errors.go b/internal/output/lark_errors.go index 137c342f2..0ba8a2bd4 100644 --- a/internal/output/lark_errors.go +++ b/internal/output/lark_errors.go @@ -3,19 +3,13 @@ package output -import ( - "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/errclass" -) - // Lark API generic error code constants. // ref: https://open.feishu.cn/document/server-docs/api-call-guide/generic-error-code // // Kept as exported identifiers because external shortcut packages reference // them by name (e.g. LarkErrOwnershipMismatch). The canonical Category / // Subtype / Retryable metadata for each code lives in internal/errclass and -// must remain the single source of truth — ClassifyLarkError below resolves -// classification through errclass.LookupCodeMeta. +// must remain the single source of truth. const ( // Auth: token missing / invalid / expired. LarkErrTokenMissing = 99991661 // Authorization header missing or empty @@ -78,7 +72,8 @@ const ( // Mail send: account / mailbox-level failures returned by // POST /open-apis/mail/v1/user_mailboxes/:user_mailbox_id/drafts/:draft_id/send. // Mail v1 uses service-scoped 123xxxx codes; keep the full upstream code - // because ErrAPI preserves Detail.Code exactly as returned by the server. + // because the typed envelope preserves Problem.Code exactly as returned by + // the server. // These codes indicate the entire batch will keep failing identically and // are consumed by shortcuts/mail.isFatalSendErr to abort early. LarkErrMailboxNotFound = 1234013 // mailbox not found or not active @@ -88,147 +83,3 @@ const ( LarkErrMailQuota = 1236010 // mail quota limit LarkErrTenantStorageLimit = 1236013 // tenant storage limit exceeded ) - -// legacyHints supplies the per-code actionable hint string for the legacy -// (exitCode, errType, hint) tuple returned by ClassifyLarkError. Hint -// composition is not yet centralized in errclass (the canonical -// PermissionHint lives there but the long-form per-code hints below are -// still wire-stable strings), so this small lookup remains here. Codes -// absent from this map fall back to "". -var legacyHints = map[int]string{ - LarkErrTokenMissing: "run: lark-cli auth login to re-authorize", - LarkErrTokenBadFmt: "run: lark-cli auth login to re-authorize", - LarkErrTokenInvalid: "run: lark-cli auth login to re-authorize", - LarkErrATInvalid: "run: lark-cli auth login to re-authorize", - LarkErrTokenExpired: "run: lark-cli auth login to re-authorize", - - LarkErrAppScopeNotEnabled: "the app developer must apply for the required scope(s) at the developer console", - LarkErrTokenNoPermission: "check the token's granted scopes; run `lark-cli auth login` to refresh if the scope was added after the token was issued", - LarkErrUserScopeInsufficient: "run `lark-cli auth login` to re-authorize the user with the updated scope set", - LarkErrUserNotAuthorized: "run `lark-cli auth login` to re-authorize this user; if re-auth does not help, the operation may be blocked by external-chat or admin policy", - - LarkErrAppCredInvalid: "run `lark-cli config init` to set valid app_id and app_secret", - LarkErrTATInvalidSecret: "run `lark-cli config init` to set valid app_id and app_secret", - LarkErrAppNotInUse: "ask the tenant admin to re-enable the app in the Lark admin console", - LarkErrAppUnauthorized: "ask the tenant admin to check the app's install status in the Lark admin console", - - LarkErrRateLimit: "please try again later", - LarkErrDriveResourceContention: "please retry later and avoid concurrent duplicate requests", - LarkErrWikiLockContention: "wiki write lock contention on this parent node; retry with exponential backoff or serialize sibling-node writes", - LarkErrDriveCrossTenantUnit: "operate on source and target within the same tenant and region/unit", - LarkErrDriveCrossBrand: "operate on source and target within the same brand environment", - LarkErrSheetsFloatImageInvalidDims: "check --width / --height / --offset-x / --offset-y: " + - "width/height must be >= 20 px; offsets must be >= 0 and less than the anchor cell's width/height", - LarkErrDrivePermApplyRateLimit: "permission-apply quota reached: each user may request access on the same document at most 5 times per day; wait or ask the owner directly", - LarkErrDrivePermApplyNotApplicable: "this document does not accept a permission-apply request (common causes: the document is configured to disallow access requests, the caller already holds the permission, or the target type does not support apply); contact the owner directly", -} - -// ClassifyLarkError maps a Lark API error code + message to the legacy -// (exitCode, errType, hint) tuple consumed by the *ExitError path. -// -// Classification is sourced from errclass.LookupCodeMeta (the single source -// of truth). exitCode follows legacyExitCode below, which differs from -// ExitCodeForCategory in two preserved-legacy quirks: Authorization + -// permission subtypes return ExitAPI (legacy treated "permission" as -// exit 1), and Config returns ExitAuth (legacy bundled "check -// app_id/secret" under exit 3). errType maps to a legacy short string; -// unknown subtypes fall back to "api_error". Unknown codes classify as -// (ExitAPI, "api_error", ""). -// -// Deprecated: route Lark API responses through errclass.BuildAPIError, -// which emits a typed *errs.XxxError with Category, Subtype, and -// identity-aware extension fields populated at the source. -func ClassifyLarkError(code int, msg string) (int, string, string) { - meta, ok := errclass.LookupCodeMeta(code) - if !ok { - return ExitAPI, "api_error", "" - } - exitCode := legacyExitCode(meta.Category, meta.Subtype) - errType := legacyErrType(meta.Category, meta.Subtype) - hint := legacyHints[code] - // IM ownership mismatch keeps its dynamic recovery hint. - if code == LarkErrOwnershipMismatch { - hint = buildOwnershipRecoveryHint() - } - return exitCode, errType, hint -} - -// legacyExitCode maps (Category, Subtype) to the legacy *ExitError exit -// code. It diverges from ExitCodeForCategory in two places to preserve the -// historic wire: -// -// - CategoryAuthorization with a "permission" subtype (missing_scope, -// app_scope_not_enabled, token_no_permission) → ExitAPI (1), not -// ExitAuth (3). Legacy considered permission failures a generic API -// refusal. -// - CategoryConfig → ExitAuth (3). Legacy bundled "check app_id/secret" -// under the auth bucket. -func legacyExitCode(cat errs.Category, sub errs.Subtype) int { - switch cat { - case errs.CategoryAuthentication: - return ExitAuth - case errs.CategoryAuthorization: - switch sub { - case errs.SubtypeMissingScope, - errs.SubtypeUserUnauthorized, - errs.SubtypeAppScopeNotApplied, - errs.SubtypeTokenScopeInsufficient: - return ExitAPI - case errs.SubtypeAppUnavailable, - errs.SubtypeAppDisabled: - return ExitAuth - } - return ExitAPI - case errs.CategoryConfig: - return ExitAuth - } - return ExitAPI -} - -// legacyErrType maps (Category, Subtype) to the legacy *ExitError errType -// string (e.g. "permission", "rate_limit"). Subtypes outside the -// historically-classified set fall back to "api_error", matching the prior -// default-case behavior. -func legacyErrType(cat errs.Category, sub errs.Subtype) string { - switch cat { - case errs.CategoryAuthentication: - return "auth" - case errs.CategoryAuthorization: - switch sub { - case errs.SubtypeMissingScope, - errs.SubtypeUserUnauthorized, - errs.SubtypeAppScopeNotApplied, - errs.SubtypeTokenScopeInsufficient: - return "permission" - case errs.SubtypeAppUnavailable, - errs.SubtypeAppDisabled: - return "app_status" - } - return "permission" - case errs.CategoryConfig: - switch sub { - case errs.SubtypeInvalidClient, - errs.SubtypeNotConfigured, - errs.SubtypeInvalidConfig: - return "config" - } - return "config" - case errs.CategoryAPI: - switch sub { - case errs.SubtypeRateLimit: - return "rate_limit" - case errs.SubtypeConflict: - return "conflict" - case errs.SubtypeCrossTenant: - return "cross_tenant" - case errs.SubtypeCrossBrand: - return "cross_brand" - case errs.SubtypeInvalidParameters: - return "invalid_parameters" - case errs.SubtypeOwnershipMismatch: - return "ownership_mismatch" - } - return "api_error" - } - return "api_error" -} diff --git a/internal/output/lark_errors_test.go b/internal/output/lark_errors_test.go index 9f7fae8d2..4fc3b7fa0 100644 --- a/internal/output/lark_errors_test.go +++ b/internal/output/lark_errors_test.go @@ -4,93 +4,9 @@ package output import ( - "strings" "testing" ) -// TestClassifyLarkError_DriveCreateShortcutConstraints verifies known Drive shortcut errors map to actionable hints. -func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - code int - wantExitCode int - wantType string - wantHint string - }{ - { - name: "resource contention", - code: LarkErrDriveResourceContention, - wantExitCode: ExitAPI, - wantType: "conflict", - wantHint: "avoid concurrent duplicate requests", - }, - { - name: "cross tenant unit", - code: LarkErrDriveCrossTenantUnit, - wantExitCode: ExitAPI, - wantType: "cross_tenant", - wantHint: "same tenant and region/unit", - }, - { - name: "cross brand", - code: LarkErrDriveCrossBrand, - wantExitCode: ExitAPI, - wantType: "cross_brand", - wantHint: "same brand environment", - }, - { - name: "sheets float image invalid dims", - code: LarkErrSheetsFloatImageInvalidDims, - wantExitCode: ExitAPI, - wantType: "invalid_parameters", - wantHint: "--width / --height / --offset-x / --offset-y", - }, - { - name: "drive permission apply rate limit", - code: LarkErrDrivePermApplyRateLimit, - wantExitCode: ExitAPI, - wantType: "rate_limit", - wantHint: "5 times per day", - }, - { - name: "drive permission apply not applicable", - code: LarkErrDrivePermApplyNotApplicable, - wantExitCode: ExitAPI, - wantType: "invalid_parameters", - wantHint: "does not accept a permission-apply request", - }, - { - name: "ownership mismatch", - code: LarkErrOwnershipMismatch, - wantExitCode: ExitAPI, - wantType: "ownership_mismatch", - wantHint: "messages-resources-download", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - gotExitCode, gotType, gotHint := ClassifyLarkError(tt.code, "raw msg") - if gotExitCode != tt.wantExitCode { - t.Fatalf("exitCode=%d, want %d", gotExitCode, tt.wantExitCode) - } - if gotType != tt.wantType { - t.Fatalf("type=%q, want %q", gotType, tt.wantType) - } - if gotHint == "" { - t.Fatal("expected non-empty hint") - } - if !strings.Contains(gotHint, tt.wantHint) { - t.Fatalf("hint=%q, want substring %q", gotHint, tt.wantHint) - } - }) - } -} - func TestMailSendErrorConstantsUseServiceScopedCodes(t *testing.T) { t.Parallel() @@ -116,24 +32,3 @@ func TestMailSendErrorConstantsUseServiceScopedCodes(t *testing.T) { }) } } - -// TestClassifyLarkError_WikiLockContention verifies the wiki write-lock -// contention error (131009) maps to an actionable retry hint instead of -// a generic "api_error". Surfaces during concurrent wiki +node-create -// against the same parent (see larksuite/cli#1012). -func TestClassifyLarkError_WikiLockContention(t *testing.T) { - t.Parallel() - gotExitCode, gotType, gotHint := ClassifyLarkError(LarkErrWikiLockContention, "raw msg") - if gotExitCode != ExitAPI { - t.Fatalf("exitCode=%d, want %d", gotExitCode, ExitAPI) - } - if gotType != "conflict" { - t.Fatalf("type=%q, want %q", gotType, "conflict") - } - if !strings.Contains(gotHint, "wiki write lock") { - t.Fatalf("hint=%q, want substring %q", gotHint, "wiki write lock") - } - if !strings.Contains(gotHint, "backoff") { - t.Fatalf("hint=%q, want substring %q", gotHint, "backoff") - } -} diff --git a/internal/output/ownership_recovery.go b/internal/output/ownership_recovery.go deleted file mode 100644 index d927530ee..000000000 --- a/internal/output/ownership_recovery.go +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package output - -func buildOwnershipRecoveryHint() string { - return "This resource belongs to another user — you can't send it directly. Download it with 'im +messages-resources-download --output ', then send the local file via 'im +send..'. For post or interactive, upload first and use the new image_key or file_key." -} diff --git a/internal/output/ownership_recovery_test.go b/internal/output/ownership_recovery_test.go deleted file mode 100644 index 43b09c9ff..000000000 --- a/internal/output/ownership_recovery_test.go +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package output - -import ( - "strings" - "testing" -) - -func checkOwnershipRecoveryHint(t *testing.T, hint string) { - t.Helper() - - for _, part := range []string{ - "im +messages-resources-download", - "--output ", - "This resource belongs to another user", - "download", - "send", - "image_key", - "file_key", - } { - if !strings.Contains(hint, part) { - t.Fatalf("hint %q missing %q", hint, part) - } - } - if len(hint) > 360 { - t.Fatalf("hint is too long: %d bytes", len(hint)) - } - for _, noisy := range []string{ - "Step 1", - "Step 2", - "Step 3", - "--message-id ", - "--file-key ", - "--type ", - "identity", - "do not keep retrying alternative download methods", - "POST /open-apis", - } { - if strings.Contains(hint, noisy) { - t.Fatalf("hint %q should not contain noisy phrase %q", hint, noisy) - } - } -} - -func TestBuildOwnershipRecoveryHint(t *testing.T) { - checkOwnershipRecoveryHint(t, buildOwnershipRecoveryHint()) -} - -func TestErrAPI_OwnershipMismatch(t *testing.T) { - upstreamMessage := "Bot or User is NOT the owner of the uat resource." - err := ErrAPI(LarkErrOwnershipMismatch, upstreamMessage, map[string]any{"log_id": "test-log"}) - - if err.Code != ExitAPI { - t.Fatalf("exit code = %d, want %d", err.Code, ExitAPI) - } - if err.Detail == nil { - t.Fatal("expected detail") - } - if err.Detail.Type != "ownership_mismatch" { - t.Fatalf("type = %q, want %q", err.Detail.Type, "ownership_mismatch") - } - if got, want := err.Detail.Message, upstreamMessage; got != want { - t.Fatalf("message = %q, want %q", got, want) - } - checkOwnershipRecoveryHint(t, err.Detail.Hint) - if err.Detail.Detail == nil { - t.Fatal("expected upstream detail to be preserved") - } -} diff --git a/internal/output/print.go b/internal/output/print.go index c26c2edbd..104a56da9 100644 --- a/internal/output/print.go +++ b/internal/output/print.go @@ -27,9 +27,9 @@ func PrintJson(w io.Writer, data interface{}) { // Only modifies map[string]interface{} values that have an "ok" key // (e.g. doctor, auth, config commands that build map envelopes directly). // -// Struct-based envelopes (Envelope, ErrorEnvelope) are NOT handled here — -// callers must set the Notice field explicitly via GetNotice(). -// See: shortcuts/common/runner.go Out(), output/errors.go WriteErrorEnvelope(). +// Struct-based envelopes (Envelope, the typed error envelope) are NOT handled +// here — callers must set the Notice field explicitly via GetNotice(). +// See: shortcuts/common/runner.go Out(), output/errors.go WriteTypedErrorEnvelope(). func injectNotice(data interface{}) { if PendingNotice == nil { return diff --git a/lint/errscontract/rule_no_legacy_common_helper_call.go b/lint/errscontract/rule_no_legacy_common_helper_call.go index c34fffc46..198c2a87b 100644 --- a/lint/errscontract/rule_no_legacy_common_helper_call.go +++ b/lint/errscontract/rule_no_legacy_common_helper_call.go @@ -10,60 +10,38 @@ import ( "strings" ) -// migratedCommonHelperPaths lists source-tree prefixes whose command validation -// has migrated to typed errs.* envelopes. On these paths, calls to common's -// legacy validation/save helpers are forbidden; callers must use the typed -// common replacements or construct an errs.* typed error directly. -var migratedCommonHelperPaths = []string{ - "cmd/event/", - "events/", - "internal/event/consume/", - "shortcuts/apps/", - "shortcuts/base/", - "shortcuts/calendar/", - "shortcuts/contact/", - "shortcuts/doc/", - "shortcuts/drive/", - "shortcuts/event/", - "shortcuts/im/", - "shortcuts/mail/", - "shortcuts/markdown/", - "shortcuts/minutes/", - "shortcuts/note/", - "shortcuts/okr/", - "shortcuts/sheets/", - "shortcuts/slides/", - "shortcuts/task/", - "shortcuts/vc/", - "shortcuts/whiteboard/", - "shortcuts/wiki/", -} - const commonImportPath = "github.com/larksuite/cli/shortcuts/common" +// legacyCommonHelperReplacements maps each deleted legacy common helper to its +// typed replacement. The helper bodies are gone, so these names should never +// reappear; the map entries are kept as a relapse guard so that re-introducing +// a same-named legacy helper is rejected with a pointer to the typed form. var legacyCommonHelperReplacements = map[string]string{ - "FlagErrorf": "common.ValidationErrorf", - "MutuallyExclusive": "common.MutuallyExclusiveTyped", - "AtLeastOne": "common.AtLeastOneTyped", - "ExactlyOne": "common.ExactlyOneTyped", - "ValidatePageSize": "common.ValidatePageSizeTyped", - "ValidateChatID": "common.ValidateChatIDTyped", - "ValidateUserID": "common.ValidateUserIDTyped", - "ValidateSafePath": "common.ValidateSafePathTyped", - "RejectDangerousChars": "common.RejectDangerousCharsTyped", - "WrapInputStatError": "common.WrapInputStatErrorTyped", - "WrapSaveErrorByCategory": "common.WrapSaveErrorTyped", - "ResolveOpenIDs": "common.ResolveOpenIDsTyped", - "HandleApiResult": "runtime.CallAPITyped", + "FlagErrorf": "common.ValidationErrorf", + "MutuallyExclusive": "common.MutuallyExclusiveTyped", + "AtLeastOne": "common.AtLeastOneTyped", + "ExactlyOne": "common.ExactlyOneTyped", + "ValidatePageSize": "common.ValidatePageSizeTyped", + "ValidateChatID": "common.ValidateChatIDTyped", + "ValidateUserID": "common.ValidateUserIDTyped", + "ValidateSafePath": "common.ValidateSafePathTyped", + "RejectDangerousChars": "common.RejectDangerousCharsTyped", + "WrapInputStatError": "common.WrapInputStatErrorTyped", + "WrapSaveErrorByCategory": "common.WrapSaveErrorTyped", + "ResolveOpenIDs": "common.ResolveOpenIDsTyped", + "HandleApiResult": "runtime.CallAPITyped", + "UploadDriveMediaAll": "common.UploadDriveMediaAllTyped", + "UploadDriveMediaMultipart": "common.UploadDriveMediaMultipartTyped", + "CallAPI": "runtime.CallAPITyped", } -// CheckNoLegacyCommonHelperCall flags any reference to common's legacy helper -// APIs on migrated paths — direct calls and function-value references alike, -// so `f := common.FlagErrorf; f(...)` cannot slip past the guard. These -// helpers return legacy output envelopes or bare errors, so migrated domains -// should use their typed-aware replacements. +// CheckNoLegacyCommonHelperCall is a relapse guard against re-introducing +// common's deleted legacy helper APIs — direct calls and function-value +// references alike, so `f := common.FlagErrorf; f(...)` cannot slip past the +// guard. These helpers returned legacy output envelopes or bare errors; the +// whole repo is now typed, so the guard applies everywhere. func CheckNoLegacyCommonHelperCall(path, src string) []Violation { - if !isMigratedCommonHelperPath(path) || strings.HasSuffix(path, "_test.go") { + if strings.HasSuffix(path, "_test.go") { return nil } fset := token.NewFileSet() @@ -79,7 +57,7 @@ func CheckNoLegacyCommonHelperCall(path, src string) []Violation { Action: ActionReject, File: path, Line: fset.Position(pos).Line, - Message: "common." + name + " returns a legacy error shape and is forbidden on migrated paths", + Message: "common." + name + " returns a legacy error shape and is forbidden", Suggestion: "replace common." + name + " with " + replacement + " or a typed errs.* constructor", }) } @@ -124,16 +102,6 @@ func CheckNoLegacyCommonHelperCall(path, src string) []Violation { return out } -func isMigratedCommonHelperPath(path string) bool { - p := strings.ReplaceAll(path, "\\", "/") - for _, prefix := range migratedCommonHelperPaths { - if strings.HasPrefix(p, prefix) || strings.Contains(p, "/"+prefix) { - return true - } - } - return false -} - func resolveCommonNames(file *ast.File) (map[string]struct{}, bool) { names := make(map[string]struct{}) dotImported := false diff --git a/lint/errscontract/rule_no_legacy_envelope_literal.go b/lint/errscontract/rule_no_legacy_envelope_literal.go index ffb87c554..ad010840c 100644 --- a/lint/errscontract/rule_no_legacy_envelope_literal.go +++ b/lint/errscontract/rule_no_legacy_envelope_literal.go @@ -10,52 +10,24 @@ import ( "strings" ) -// migratedEnvelopePaths lists the source-tree prefixes that have been migrated -// to the typed errs.* taxonomy. On these paths, constructing a legacy -// output.ExitError / output.ErrDetail envelope literal directly is forbidden — -// call sites must return a typed errs.* error instead. Future domains opt in by -// appending their path prefix here. -var migratedEnvelopePaths = []string{ - "cmd/event/", - "events/", - "internal/event/consume/", - "shortcuts/apps/", - "shortcuts/base/", - "shortcuts/calendar/", - "shortcuts/contact/", - "shortcuts/doc/", - "shortcuts/drive/", - "shortcuts/event/", - "shortcuts/im/", - "shortcuts/mail/", - "shortcuts/markdown/", - "shortcuts/minutes/", - "shortcuts/note/", - "shortcuts/okr/", - "shortcuts/sheets/", - "shortcuts/slides/", - "shortcuts/task/", - "shortcuts/vc/", - "shortcuts/whiteboard/", - "shortcuts/wiki/", -} - // legacyOutputImportPath is the import path of the package that declares the // legacy ExitError / ErrDetail envelope types. The rule resolves whatever local // name (default or alias) this path is bound to in each file, so an aliased // import cannot bypass the check. const legacyOutputImportPath = "github.com/larksuite/cli/internal/output" -// CheckNoLegacyEnvelopeLiteral flags direct construction of legacy -// output.ExitError / output.ErrDetail composite literals on migrated paths. -// forbidigo can ban identifiers but not composite literals, so this AST rule -// covers the gap left after a path is migrated to typed errs.* errors. +// CheckNoLegacyEnvelopeLiteral is a relapse guard against re-introducing +// the deleted legacy output.ExitError / output.ErrDetail envelope literals. +// The whole repo is now typed; constructing one of these composite literals +// directly is forbidden everywhere — call sites must return a typed errs.* +// error instead. forbidigo can ban identifiers but not composite literals, so +// this AST rule covers that gap repo-wide. // -// Path-scoped to migratedEnvelopePaths (mirrors how CheckProblemEmbed restricts -// by path); skips _test.go fixtures. output.ErrBare(...) is a CallExpr, not a -// CompositeLit, so the predicate exit-signal helper is naturally not flagged. +// Applies to every .go path; skips _test.go fixtures. output.ErrBare(...) is a +// CallExpr, not a CompositeLit, so the predicate exit-signal helper is +// naturally not flagged. func CheckNoLegacyEnvelopeLiteral(path, src string) []Violation { - if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") { + if strings.HasSuffix(path, "_test.go") { return nil } fset := token.NewFileSet() @@ -80,7 +52,7 @@ func CheckNoLegacyEnvelopeLiteral(path, src string) []Violation { Action: ActionReject, File: path, Line: fset.Position(lit.Pos()).Line, - Message: "direct construction of legacy output." + name + " is forbidden on migrated paths; return a typed errs.* error (output.ErrBare remains allowed for predicate exit signals)", + Message: "direct construction of legacy output." + name + " is forbidden; return a typed errs.* error (output.ErrBare remains allowed for predicate exit signals)", Suggestion: "replace the &output." + name + "{...} literal with a typed errs.* constructor " + "(e.g. errs.NewValidationError / errs.NewAPIError / errs.NewNetworkError)", }) @@ -90,18 +62,6 @@ func CheckNoLegacyEnvelopeLiteral(path, src string) []Violation { return out } -// isMigratedEnvelopePath reports whether path falls under any migrated path -// prefix in migratedEnvelopePaths. -func isMigratedEnvelopePath(path string) bool { - p := strings.ReplaceAll(path, "\\", "/") - for _, prefix := range migratedEnvelopePaths { - if strings.HasPrefix(p, prefix) || strings.Contains(p, "/"+prefix) { - return true - } - } - return false -} - // resolveLegacyOutputNames walks the file's import declarations and returns the // set of local names bound to legacyOutputImportPath, plus whether the path was // dot-imported. Default imports bind the package's own name ("output"); aliased diff --git a/lint/errscontract/rule_no_legacy_runtime_api_call.go b/lint/errscontract/rule_no_legacy_runtime_api_call.go index 4a7d05e84..688aa41a3 100644 --- a/lint/errscontract/rule_no_legacy_runtime_api_call.go +++ b/lint/errscontract/rule_no_legacy_runtime_api_call.go @@ -10,30 +10,28 @@ import ( "strings" ) -// CheckNoLegacyRuntimeAPICall flags calls to the runtime's legacy -// auto-classifying API helpers (CallAPI / DoAPIJSON / DoAPIJSONWithLogID) on -// migrated paths. Those helpers route failures through common.HandleApiResult / -// doAPIJSON, which emit a legacy output.ExitError "api_error" envelope and -// downgrade an already-typed network / auth boundary error into an API error. -// forbidigo's errs-typed-only ban does not see them because they are method -// calls, not output.Err* identifiers — this AST rule covers that gap. +// CheckNoLegacyRuntimeAPICall flags calls to the runtime's auto-classifying +// API helpers (CallAPI / DoAPIJSON / DoAPIJSONWithLogID). Those helpers +// classify a response without the running command's identity context, so a +// Lark authorization failure cannot carry MissingScopes / ConsoleURL / +// Identity. Code must call the domain's typed wrapper or runtime.DoAPIJSONTyped +// / runtime.DoAPI + errclass.BuildAPIError so failures classify into +// fully-populated typed errs.* errors. forbidigo cannot see these because they +// are method calls, not output.Err* identifiers — this AST rule covers that gap +// repo-wide. // -// Migrated code must call the domain's typed API wrapper or use -// runtime.DoAPI + errclass.BuildAPIError directly, so failures classify into -// typed errs.* errors. -// -// Path-scoped to migratedEnvelopePaths; skips _test.go fixtures. A typed wrapper -// like driveCallAPI is an unqualified call (*ast.Ident), not a selector, so it -// is not matched. runtime.DoAPI / runtime.RawAPI are intentionally not listed: -// they return the raw response for the caller to classify and do not emit a -// legacy envelope themselves. +// Applies to every .go path; skips _test.go fixtures. A typed wrapper like +// driveCallAPI is an unqualified call (*ast.Ident), not a selector, so it is not +// matched. runtime.DoAPI / runtime.RawAPI are intentionally not listed: they +// return the raw response for the caller to classify and do not emit a legacy +// envelope themselves. // // Files that do not import shortcuts/common are skipped: the legacy helpers // are methods on common.RuntimeContext, so a same-named method on another // receiver (for example the event domain's APIClient interface, whose // implementation classifies into typed errs.* errors) is not a legacy call. func CheckNoLegacyRuntimeAPICall(path, src string) []Violation { - if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") { + if strings.HasSuffix(path, "_test.go") { return nil } fset := token.NewFileSet() @@ -60,7 +58,7 @@ func CheckNoLegacyRuntimeAPICall(path, src string) []Violation { Action: ActionReject, File: path, Line: fset.Position(call.Pos()).Line, - Message: "runtime." + name + " emits a legacy output.ExitError api_error envelope and downgrades typed network/auth boundary errors; it is forbidden on migrated paths", + Message: "runtime." + name + " classifies the response without the command's identity context (no MissingScopes/ConsoleURL/Identity on authorization failures); it is forbidden", Suggestion: "call the domain's typed API wrapper (for example driveCallAPI or callTaskAPITyped) or runtime.DoAPI + errclass.BuildAPIError " + "so failures classify into typed errs.* errors", }) diff --git a/lint/errscontract/rule_no_registrar.go b/lint/errscontract/rule_no_registrar.go index 4435ad8a9..8236737c2 100644 --- a/lint/errscontract/rule_no_registrar.go +++ b/lint/errscontract/rule_no_registrar.go @@ -88,8 +88,8 @@ func isServiceScope(path string) bool { case strings.HasPrefix(p, "internal/errclass/") || strings.Contains(p, "/internal/errclass/"): return false case strings.HasPrefix(p, "internal/output/") || strings.Contains(p, "/internal/output/"): - // CheckNoRegistrar carves out internal/output: it is the typed-envelope writer - // and legacy ExitError producer, not a service. Without this guard + // CheckNoRegistrar carves out internal/output: it is the typed-envelope + // writer, not a service. Without this guard // any legitimate registrar-shaped symbol there would trigger a // false-positive REJECT. return false diff --git a/lint/errscontract/rule_typed_error_completeness.go b/lint/errscontract/rule_typed_error_completeness.go index 263bdf7fd..099591ddc 100644 --- a/lint/errscontract/rule_typed_error_completeness.go +++ b/lint/errscontract/rule_typed_error_completeness.go @@ -22,8 +22,8 @@ import ( // unqualified `Error` ident. // // This intentionally excludes legacy *Error types in other packages -// (core.ConfigError, internal/auth.NeedAuthorizationError, etc.) which -// are not part of the typed taxonomy. +// (e.g. internal/auth.NeedAuthorizationError) which are not part of the +// typed taxonomy. // // When the inner `Problem:` value is a variable reference (e.g. // `Problem: base`) instead of a composite literal, the check trusts that diff --git a/lint/errscontract/rules_test.go b/lint/errscontract/rules_test.go index 309918721..76274ac45 100644 --- a/lint/errscontract/rules_test.go +++ b/lint/errscontract/rules_test.go @@ -682,9 +682,17 @@ func boom() error { } } -func TestCheckNoLegacyEnvelopeLiteral_IgnoresNonMigratedPath(t *testing.T) { - // Same offending literal, but outside the migrated path set → not flagged. - src := `package other +func TestCheckNoLegacyEnvelopeLiteral_FiresOnAnyPath(t *testing.T) { + // The guard is now repo-wide: any .go path that re-introduces the legacy + // literal is flagged, regardless of domain. + for _, path := range []string{ + "shortcuts/im/im_send.go", + "shortcuts/some_new_domain/foo.go", + "internal/auth/login.go", + "cmd/config/bind.go", + } { + t.Run(path, func(t *testing.T) { + src := `package other import "github.com/larksuite/cli/internal/output" @@ -692,9 +700,14 @@ func boom() error { return &output.ExitError{Code: 1} } ` - v := CheckNoLegacyEnvelopeLiteral("shortcuts/unmigrated/foo.go", src) - if len(v) != 0 { - t.Errorf("non-migrated path should pass, got: %+v", v) + v := CheckNoLegacyEnvelopeLiteral(path, src) + if len(v) != 1 { + t.Fatalf("expected 1 violation on %s, got %d: %+v", path, len(v), v) + } + if v[0].Action != ActionReject { + t.Errorf("action = %q, want REJECT", v[0].Action) + } + }) } } @@ -906,7 +919,38 @@ func boom(runtime *common.RuntimeContext) error { } } -func TestCheckNoLegacyRuntimeAPICall_IgnoresNonMigratedPath(t *testing.T) { +func TestCheckNoLegacyRuntimeAPICall_FiresOnAnyCommonImportingPath(t *testing.T) { + // The guard is now repo-wide: any path importing shortcuts/common that + // re-introduces a legacy runtime call is flagged, regardless of domain. + for _, path := range []string{ + "shortcuts/im/im_send.go", + "shortcuts/some_new_domain/sample.go", + "internal/cmdutil/helper.go", + } { + t.Run(path, func(t *testing.T) { + src := `package contact + +import "github.com/larksuite/cli/shortcuts/common" + +func boom(runtime *common.RuntimeContext) error { + _, err := runtime.CallAPI("POST", "/x", nil, nil) + return err +} +` + v := CheckNoLegacyRuntimeAPICall(path, src) + if len(v) != 1 { + t.Fatalf("expected 1 violation on %s, got %d: %+v", path, len(v), v) + } + if v[0].Action != ActionReject { + t.Errorf("action = %q, want REJECT", v[0].Action) + } + }) + } +} + +func TestCheckNoLegacyRuntimeAPICall_SkipsFilesWithoutCommonImport(t *testing.T) { + // The import gate stays: without a shortcuts/common import, a same-named + // CallAPI method on another receiver is not the legacy RuntimeContext helper. src := `package contact func boom(runtime *common.RuntimeContext) error { @@ -914,9 +958,9 @@ func boom(runtime *common.RuntimeContext) error { return err } ` - v := CheckNoLegacyRuntimeAPICall("shortcuts/unmigrated/sample.go", src) + v := CheckNoLegacyRuntimeAPICall("shortcuts/some_new_domain/sample.go", src) if len(v) != 0 { - t.Errorf("non-migrated path must not fire, got: %+v", v) + t.Errorf("file without shortcuts/common import must not fire, got: %+v", v) } } @@ -989,18 +1033,6 @@ common.` + helper + `() } } -func TestMigratedCommonHelperPaths_CoverMigratedEnvelopePaths(t *testing.T) { - commonPaths := make(map[string]struct{}, len(migratedCommonHelperPaths)) - for _, path := range migratedCommonHelperPaths { - commonPaths[path] = struct{}{} - } - for _, path := range migratedEnvelopePaths { - if _, ok := commonPaths[path]; !ok { - t.Fatalf("migratedEnvelopePaths contains %q but migratedCommonHelperPaths does not", path) - } - } -} - func TestCheckNoLegacyCommonHelperCall_RejectsDangerousCharsOnCalendarPath(t *testing.T) { src := `package calendar @@ -1107,18 +1139,63 @@ func boom() { } } -func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) { - src := `package contact +func TestCheckNoLegacyCommonHelperCall_FiresOnAnyPath(t *testing.T) { + // The guard is now repo-wide: re-introducing a legacy common helper is + // flagged regardless of domain. + for _, path := range []string{ + "shortcuts/im/im_send.go", + "shortcuts/some_new_domain/sample.go", + "internal/cmdutil/helper.go", + } { + t.Run(path, func(t *testing.T) { + src := `package contact import "github.com/larksuite/cli/shortcuts/common" func boom() { - common.FlagErrorf("legacy allowed until domain migrates") + common.FlagErrorf("relapse") } ` - v := CheckNoLegacyCommonHelperCall("shortcuts/unmigrated/sample.go", src) - if len(v) != 0 { - t.Errorf("non-migrated path must pass, got: %+v", v) + v := CheckNoLegacyCommonHelperCall(path, src) + if len(v) != 1 { + t.Fatalf("expected 1 violation on %s, got %d: %+v", path, len(v), v) + } + if v[0].Action != ActionReject { + t.Errorf("action = %q, want REJECT", v[0].Action) + } + }) + } +} + +func TestCheckNoLegacyCommonHelperCall_RejectsReintroducedUploadAndCallAPIHelpers(t *testing.T) { + // The three relapse-guard entries added when the legacy bodies were deleted: + // re-introducing a same-named helper must be rejected with a typed pointer. + cases := []struct { + helper string + wantInSugg string + }{ + {"UploadDriveMediaAll", "common.UploadDriveMediaAllTyped"}, + {"UploadDriveMediaMultipart", "common.UploadDriveMediaMultipartTyped"}, + {"CallAPI", "runtime.CallAPITyped"}, + } + for _, tc := range cases { + t.Run(tc.helper, func(t *testing.T) { + src := `package drive + +import "github.com/larksuite/cli/shortcuts/common" + +func boom() { + common.` + tc.helper + `() +} +` + v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_upload.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation for %s, got %d: %+v", tc.helper, len(v), v) + } + if !strings.Contains(v[0].Suggestion, tc.wantInSugg) { + t.Errorf("suggestion should name typed replacement %q, got: %s", tc.wantInSugg, v[0].Suggestion) + } + }) } } diff --git a/lint/errscontract/scan.go b/lint/errscontract/scan.go index d7953ae0a..7a43cc076 100644 --- a/lint/errscontract/scan.go +++ b/lint/errscontract/scan.go @@ -81,9 +81,13 @@ func ScanRepo(root string) ([]Violation, error) { return walkErr } if d.IsDir() { - // Skip well-known noise directories. name := d.Name() - if name == ".git" || name == "node_modules" || name == "vendor" || + // Skip hidden dirs (.git, .claude/worktrees, …): gitignored tooling + // state, not repo source. The walk root itself is exempt. + if path != root && strings.HasPrefix(name, ".") { + return filepath.SkipDir + } + if name == "node_modules" || name == "vendor" || name == "tests_e2e" || name == "skill-template" || name == "skills" || name == "docs" || name == "specs" { return filepath.SkipDir diff --git a/shortcuts/apps/apps_callapi_typed_test.go b/shortcuts/apps/apps_callapi_typed_test.go index 90ae70013..76608a33e 100644 --- a/shortcuts/apps/apps_callapi_typed_test.go +++ b/shortcuts/apps/apps_callapi_typed_test.go @@ -12,11 +12,9 @@ import ( "github.com/larksuite/cli/internal/httpmock" ) -// TestAppsList_503IsRetryableTypedError pins the typed-error upgrade: a 5xx -// response from the apps list endpoint must surface as a typed errs.Problem with -// Retryable == true (via CallAPITyped → httpStatusError). The pre-migration -// CallAPI path produced a legacy *output.ExitError with no Retryable field, so -// this test fails until AppsList is migrated to CallAPITyped. +// TestAppsList_503IsRetryableTypedError pins that a 5xx response from the apps +// list endpoint surfaces as a typed errs.Problem with Retryable == true (via +// CallAPITyped → httpStatusError). func TestAppsList_503IsRetryableTypedError(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ diff --git a/shortcuts/apps/apps_db_table_list_test.go b/shortcuts/apps/apps_db_table_list_test.go index 0f3c54851..b9c5a352b 100644 --- a/shortcuts/apps/apps_db_table_list_test.go +++ b/shortcuts/apps/apps_db_table_list_test.go @@ -14,12 +14,11 @@ import ( // TestAppsDBTableList_BusinessErrorSurfacedAsTypedEnvelope 验证 server 业务错误 // (code != 0,如单环境 app 查 env=dev 返 "Invalid DB Branch")被 CLI 透出成 -// typed error —— 用 BOE 实测的错误码 / 文案做输入。 +// typed error —— 用真实观测到的错误码 / 文案做输入。 // -// 迁移到 runtime.CallAPITyped 后,非零 code 的业务错误由 errclass.BuildAPIError -// 归类为 typed errs.* error(wire type 为 "api" 类别,不再是 legacy 的 -// *output.ExitError / "api_error"),但仍保留 code 与 message。与 drive/okr 等 -// 已迁移域一致:用 errs.ProblemOf 读 typed envelope,断言不弱化。 +// 非零 code 的业务错误由 errclass.BuildAPIError 归类为 typed errs.* error +// (wire type 为 "api" 类别),保留 code 与 message。与 drive/okr 等域一致: +// 用 errs.ProblemOf 读 typed envelope,断言不弱化。 func TestAppsDBTableList_BusinessErrorSurfacedAsTypedEnvelope(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ diff --git a/shortcuts/base/record_upload_attachment.go b/shortcuts/base/record_upload_attachment.go index c469194df..ce87c0be7 100644 --- a/shortcuts/base/record_upload_attachment.go +++ b/shortcuts/base/record_upload_attachment.go @@ -488,7 +488,7 @@ func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName s ) if fileSize <= common.MaxDriveMediaUploadSinglePartSize { parentNode := target.ParentNode - fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ + fileToken, err = common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{ FilePath: filePath, FileName: fileName, FileSize: fileSize, @@ -497,7 +497,7 @@ func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName s Extra: target.Extra, }) } else { - fileToken, err = common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{ + fileToken, err = common.UploadDriveMediaMultipartTyped(runtime, common.DriveMediaMultipartUploadConfig{ FilePath: filePath, FileName: fileName, FileSize: fileSize, diff --git a/shortcuts/calendar/calendar_test.go b/shortcuts/calendar/calendar_test.go index bc2fadd0c..67c386e6f 100644 --- a/shortcuts/calendar/calendar_test.go +++ b/shortcuts/calendar/calendar_test.go @@ -49,7 +49,7 @@ func warmTokenCache(t *testing.T) { Command: "+warm", AuthTypes: []string{"bot"}, Execute: func(_ context.Context, rctx *common.RuntimeContext) error { - _, err := rctx.CallAPI("GET", "/open-apis/test/v1/warm", nil, nil) + _, err := rctx.CallAPITyped("GET", "/open-apis/test/v1/warm", nil, nil) return err }, } diff --git a/shortcuts/common/call_api_typed_test.go b/shortcuts/common/call_api_typed_test.go index d05144487..5f5ffcd04 100644 --- a/shortcuts/common/call_api_typed_test.go +++ b/shortcuts/common/call_api_typed_test.go @@ -128,6 +128,40 @@ func TestAPIClassifyContext(t *testing.T) { // TestCallAPITyped_NonJSON5xx pins that a non-JSON HTTP 5xx (e.g. a gateway 502 // text/html page) is a retryable network/server_error carrying the header // log_id — not a mis-parsed internal/invalid_response. +// TestDoAPIJSON_HTTPErrorWithZeroBodyCodeNotSwallowed pins that an HTTP status +// error whose body omits a non-zero business code (e.g. 400 + {"code":0,...}) +// still surfaces a typed error. BuildAPIError treats code 0 as success and +// returns nil, so the HTTP-status fallback must kick in — otherwise a 4xx +// would be swallowed as (nil, nil). +func TestDoAPIJSON_HTTPErrorWithZeroBodyCodeNotSwallowed(t *testing.T) { + rt, reg := newCallAPITypedRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/x/y", + Status: 400, + Headers: http.Header{"Content-Type": []string{"application/json"}}, + RawBody: []byte(`{"code":0,"msg":"bad request"}`), + }) + + data, err := rt.DoAPIJSON("POST", "/open-apis/x/y", nil, map[string]any{}) + if err == nil { + t.Fatalf("HTTP 400 with code:0 body must not be swallowed; got data=%v err=nil", data) + } + if data != nil { + t.Errorf("data must be nil on HTTP error, got %v", data) + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected a typed errs.* error, got %T: %v", err, err) + } + if p.Category != errs.CategoryAPI { + t.Errorf("category = %s, want api", p.Category) + } + if p.Code != 400 { + t.Errorf("code = %d, want 400 (HTTP status used as code when body code is 0)", p.Code) + } +} + func TestCallAPITyped_NonJSON5xx(t *testing.T) { rt, reg := newCallAPITypedRuntime(t) reg.Register(&httpmock.Stub{ @@ -236,7 +270,7 @@ func TestDoAPIJSONTyped_RawClientErrorBecomesTypedInternal(t *testing.T) { } // TestDoAPIJSONTyped_NonZeroCode classifies a non-zero API code into a typed -// errs.* error (carrying log_id), never a legacy output.ExitError envelope. +// errs.* error (carrying log_id). func TestDoAPIJSONTyped_NonZeroCode(t *testing.T) { rt, reg := newCallAPITypedRuntime(t) reg.Register(&httpmock.Stub{ diff --git a/shortcuts/common/common.go b/shortcuts/common/common.go index 42ca4affb..03d6794ee 100644 --- a/shortcuts/common/common.go +++ b/shortcuts/common/common.go @@ -163,26 +163,6 @@ func CheckApiError(w io.Writer, result interface{}, action string) bool { return false } -// HandleApiResult checks for network/API errors and returns the "data" field. -// -// Deprecated: use RuntimeContext.CallAPITyped (or ClassifyAPIResponse for -// self-driven requests) for typed error envelopes. -func HandleApiResult(result interface{}, err error, action string) (map[string]interface{}, error) { - if err != nil { - return nil, output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err) - } - resultMap, _ := result.(map[string]interface{}) - code, _ := util.ToFloat64(resultMap["code"]) - if code != 0 { - msg, _ := resultMap["msg"].(string) - larkCode := int(code) - fullMsg := fmt.Sprintf("%s: [%d] %s", action, larkCode, msg) - return nil, output.ErrAPI(larkCode, fullMsg, resultMap["error"]) - } - data, _ := resultMap["data"].(map[string]interface{}) - return data, nil -} - // TruncateStr truncates s to at most n runes. func TruncateStr(s string, n int) string { r := []rune(s) diff --git a/shortcuts/common/drive_media_upload.go b/shortcuts/common/drive_media_upload.go index e81bcd24c..91930bb52 100644 --- a/shortcuts/common/drive_media_upload.go +++ b/shortcuts/common/drive_media_upload.go @@ -5,18 +5,14 @@ package common import ( "bytes" - "encoding/json" - "errors" "fmt" "io" "net/http" - "strings" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/client" - "github.com/larksuite/cli/internal/output" ) const MaxDriveMediaUploadSinglePartSize int64 = 20 * 1024 * 1024 // 20MB @@ -42,7 +38,7 @@ type DriveMediaUploadAllConfig struct { Extra string // Reader, when non-nil, is used as the upload source instead of opening // FilePath. Callers must set FileName and FileSize explicitly. The reader - // is NOT closed by UploadDriveMediaAll; the caller owns its lifetime. + // is NOT closed by UploadDriveMediaAllTyped; the caller owns its lifetime. // Used by the clipboard path in docs +media-insert. Reader io.Reader } @@ -58,52 +54,10 @@ type DriveMediaMultipartUploadConfig struct { Reader io.Reader } -// Deprecated: use UploadDriveMediaAllTyped for typed error envelopes. -func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) { - var fileReader io.Reader - if cfg.Reader != nil { - fileReader = cfg.Reader - } else { - f, err := runtime.FileIO().Open(cfg.FilePath) - if err != nil { - return "", WrapInputStatError(err) - } - defer f.Close() - fileReader = f - } - - fd := larkcore.NewFormdata() - fd.AddField("file_name", cfg.FileName) - fd.AddField("parent_type", cfg.ParentType) - fd.AddField("size", fmt.Sprintf("%d", cfg.FileSize)) - if cfg.ParentNode != nil { - fd.AddField("parent_node", *cfg.ParentNode) - } - if cfg.Extra != "" { - fd.AddField("extra", cfg.Extra) - } - fd.AddFile("file", fileReader) - - apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ - HttpMethod: http.MethodPost, - ApiPath: "/open-apis/drive/v1/medias/upload_all", - Body: fd, - }, larkcore.WithFileUpload()) - if err != nil { - return "", WrapDriveMediaUploadRequestError(err, driveMediaUploadAllAction) - } - - data, err := ParseDriveMediaUploadResponse(apiResp, driveMediaUploadAllAction) - if err != nil { - return "", err - } - return ExtractDriveMediaUploadFileToken(data, driveMediaUploadAllAction) -} - -// UploadDriveMediaAllTyped is the typed-error counterpart of -// UploadDriveMediaAll: file-open failures surface as typed validation errors, -// transport failures as typed network errors, and API failures are classified -// via ClassifyAPIResponse so subtype / code / log_id survive on the error. +// UploadDriveMediaAllTyped uploads a file in a single request: file-open +// failures surface as typed validation errors, transport failures as typed +// network errors, and API failures are classified via ClassifyAPIResponse so +// subtype / code / log_id survive on the error. func UploadDriveMediaAllTyped(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) { var fileReader io.Reader if cfg.Reader != nil { @@ -145,43 +99,10 @@ func UploadDriveMediaAllTyped(runtime *RuntimeContext, cfg DriveMediaUploadAllCo return extractDriveMediaUploadFileTokenTyped(data, driveMediaUploadAllAction) } -// Deprecated: use UploadDriveMediaMultipartTyped for typed error envelopes. -func UploadDriveMediaMultipart(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig) (string, error) { - // upload_prepare expects parent_node to be present even when the caller wants - // the service default/root behavior, so multipart callers pass an explicit - // string instead of relying on field omission like upload_all does. - prepareBody := map[string]interface{}{ - "file_name": cfg.FileName, - "parent_type": cfg.ParentType, - "parent_node": cfg.ParentNode, - "size": cfg.FileSize, - } - if cfg.Extra != "" { - prepareBody["extra"] = cfg.Extra - } - - data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/medias/upload_prepare", nil, prepareBody) - if err != nil { - return "", err - } - - session, err := ParseDriveMediaMultipartUploadSession(data) - if err != nil { - return "", err - } - fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, FormatSize(session.BlockSize)) - - if err = uploadDriveMediaMultipartParts(runtime, cfg, session); err != nil { - return "", err - } - - return finishDriveMediaMultipartUpload(runtime, session.UploadID, session.BlockNum) -} - -// UploadDriveMediaMultipartTyped is the typed-error counterpart of -// UploadDriveMediaMultipart: prepare/finish failures come back typed from -// CallAPITyped, malformed session plans surface as invalid-response internal -// errors, and per-part transport/API failures are classified the same way as +// UploadDriveMediaMultipartTyped uploads a file in server-planned chunks: +// prepare/finish failures come back typed from CallAPITyped, malformed session +// plans surface as invalid-response internal errors, and per-part +// transport/API failures are classified the same way as // UploadDriveMediaAllTyped. func UploadDriveMediaMultipartTyped(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig) (string, error) { // upload_prepare expects parent_node to be present even when the caller wants @@ -215,157 +136,6 @@ func UploadDriveMediaMultipartTyped(runtime *RuntimeContext, cfg DriveMediaMulti return finishDriveMediaMultipartUploadTyped(runtime, session.UploadID, session.BlockNum) } -func ParseDriveMediaMultipartUploadSession(data map[string]interface{}) (DriveMediaMultipartUploadSession, error) { - // The backend chooses both chunk size and chunk count. Validate them once so - // the streaming loop can follow the returned plan without re-checking shape. - session := DriveMediaMultipartUploadSession{ - UploadID: GetString(data, "upload_id"), - BlockSize: int64(GetFloat(data, "block_size")), - BlockNum: int(GetFloat(data, "block_num")), - } - if session.UploadID == "" { - return DriveMediaMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: no upload_id returned") - } - if session.BlockSize <= 0 { - return DriveMediaMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned") - } - if session.BlockNum <= 0 { - return DriveMediaMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_num returned") - } - return session, nil -} - -func WrapDriveMediaUploadRequestError(err error, action string) error { - // Preserve any already-classified error: legacy *output.ExitError or any - // typed errs.* error. Only un-classified errors get wrapped as network. - var exitErr *output.ExitError - if errors.As(err, &exitErr) { - return err - } - if _, ok := errs.ProblemOf(err); ok { - return err - } - return output.ErrNetwork("%s: %v", action, err) -} - -func ParseDriveMediaUploadResponse(apiResp *larkcore.ApiResp, action string) (map[string]interface{}, error) { - var result map[string]interface{} - if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { - return nil, output.Errorf(output.ExitAPI, "api_error", "%s: invalid response JSON: %v", action, err) - } - - if larkCode := int(GetFloat(result, "code")); larkCode != 0 { - msg, _ := result["msg"].(string) - return nil, output.ErrAPI(larkCode, fmt.Sprintf("%s: [%d] %s", action, larkCode, msg), driveMediaUploadErrorDetail(apiResp, result["error"])) - } - - data, _ := result["data"].(map[string]interface{}) - return data, nil -} - -func driveMediaUploadErrorDetail(apiResp *larkcore.ApiResp, detail interface{}) interface{} { - logID := "" - if apiResp != nil { - logID = strings.TrimSpace(apiResp.LogId()) - } - if logID == "" { - return detail - } - detailMap, ok := detail.(map[string]interface{}) - if !ok { - if detail == nil { - return map[string]interface{}{"log_id": logID} - } - return map[string]interface{}{"error": detail, "log_id": logID} - } - if _, exists := detailMap["log_id"]; !exists { - detailMap["log_id"] = logID - } - return detailMap -} - -func ExtractDriveMediaUploadFileToken(data map[string]interface{}, action string) (string, error) { - fileToken := GetString(data, "file_token") - if fileToken == "" { - return "", output.Errorf(output.ExitAPI, "api_error", "%s: no file_token returned", action) - } - return fileToken, nil -} - -func uploadDriveMediaMultipartParts(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig, session DriveMediaMultipartUploadSession) error { - var r io.Reader - if cfg.Reader != nil { - r = cfg.Reader - } else { - f, err := runtime.FileIO().Open(cfg.FilePath) - if err != nil { - return WrapInputStatError(err) - } - defer f.Close() - r = f - } - - maxInt := int64(^uint(0) >> 1) - bufferSize := session.BlockSize - if bufferSize <= 0 || bufferSize > maxInt { - return output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned") - } - buffer := make([]byte, int(bufferSize)) - remaining := cfg.FileSize - // Follow the server-declared block plan exactly; upload_finish expects the - // same block count returned by upload_prepare. - for seq := 0; seq < session.BlockNum; seq++ { - chunkSize := session.BlockSize - if remaining > 0 && chunkSize > remaining { - chunkSize = remaining - } - - n, readErr := io.ReadFull(r, buffer[:int(chunkSize)]) - if readErr != nil { - return output.ErrValidation("cannot read file: %s", readErr) - } - - if err := uploadDriveMediaMultipartPart(runtime, session.UploadID, seq, buffer[:n]); err != nil { - return err - } - fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, session.BlockNum, FormatSize(int64(n))) - remaining -= int64(n) - } - - return nil -} - -func uploadDriveMediaMultipartPart(runtime *RuntimeContext, uploadID string, seq int, chunk []byte) error { - fd := larkcore.NewFormdata() - fd.AddField("upload_id", uploadID) - fd.AddField("seq", fmt.Sprintf("%d", seq)) - fd.AddField("size", fmt.Sprintf("%d", len(chunk))) - fd.AddFile("file", bytes.NewReader(chunk)) - - apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ - HttpMethod: http.MethodPost, - ApiPath: "/open-apis/drive/v1/medias/upload_part", - Body: fd, - }, larkcore.WithFileUpload()) - if err != nil { - return WrapDriveMediaUploadRequestError(err, driveMediaUploadPartAction) - } - - _, err = ParseDriveMediaUploadResponse(apiResp, driveMediaUploadPartAction) - return err -} - -func finishDriveMediaMultipartUpload(runtime *RuntimeContext, uploadID string, blockNum int) (string, error) { - data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/medias/upload_finish", nil, map[string]interface{}{ - "upload_id": uploadID, - "block_num": blockNum, - }) - if err != nil { - return "", err - } - return ExtractDriveMediaUploadFileToken(data, driveMediaUploadFinishAction) -} - // prefixDriveMediaUploadProblem prepends the upload action to a typed error's // message so callers see which upload step failed. Non-typed errors are // returned unchanged. @@ -377,9 +147,11 @@ func prefixDriveMediaUploadProblem(err error, action string) error { } // parseDriveMediaMultipartUploadSessionTyped validates the upload_prepare -// session plan like ParseDriveMediaMultipartUploadSession, but reports a -// malformed plan as a typed invalid-response internal error. +// session plan, reporting a malformed plan as a typed invalid-response +// internal error. func parseDriveMediaMultipartUploadSessionTyped(data map[string]interface{}) (DriveMediaMultipartUploadSession, error) { + // The backend chooses both chunk size and chunk count. Validate them once so + // the streaming loop can follow the returned plan without re-checking shape. session := DriveMediaMultipartUploadSession{ UploadID: GetString(data, "upload_id"), BlockSize: int64(GetFloat(data, "block_size")), @@ -397,8 +169,9 @@ func parseDriveMediaMultipartUploadSessionTyped(data map[string]interface{}) (Dr return session, nil } -// extractDriveMediaUploadFileTokenTyped mirrors ExtractDriveMediaUploadFileToken -// with a typed invalid-response internal error for a missing file_token. +// extractDriveMediaUploadFileTokenTyped reads the file_token from a successful +// upload response, reporting a missing file_token as a typed invalid-response +// internal error. func extractDriveMediaUploadFileTokenTyped(data map[string]interface{}, action string) (string, error) { fileToken := GetString(data, "file_token") if fileToken == "" { @@ -407,8 +180,9 @@ func extractDriveMediaUploadFileTokenTyped(data map[string]interface{}, action s return fileToken, nil } -// uploadDriveMediaMultipartPartsTyped mirrors uploadDriveMediaMultipartParts -// with typed errors for file-open, file-read, and per-part upload failures. +// uploadDriveMediaMultipartPartsTyped streams the file in server-planned +// chunks, with typed errors for file-open, file-read, and per-part upload +// failures. func uploadDriveMediaMultipartPartsTyped(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig, session DriveMediaMultipartUploadSession) error { var r io.Reader if cfg.Reader != nil { diff --git a/shortcuts/common/drive_media_upload_test.go b/shortcuts/common/drive_media_upload_test.go index 062b343a0..e3abf0c84 100644 --- a/shortcuts/common/drive_media_upload_test.go +++ b/shortcuts/common/drive_media_upload_test.go @@ -7,28 +7,23 @@ import ( "bytes" "context" "encoding/json" - "errors" "fmt" "io" "mime" "mime/multipart" - "net/http" "os" - "strings" "sync/atomic" "testing" - larkcore "github.com/larksuite/oapi-sdk-go/v3/core" - + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" - "github.com/larksuite/cli/internal/output" ) var commonDriveMediaUploadTestSeq atomic.Int64 -func TestUploadDriveMediaAllBuildsMultipartBody(t *testing.T) { +func TestUploadDriveMediaAllTypedBuildsMultipartBody(t *testing.T) { tests := []struct { name string parentNode *string @@ -64,7 +59,7 @@ func TestUploadDriveMediaAllBuildsMultipartBody(t *testing.T) { reg.Register(uploadStub) filePath := writeDriveMediaUploadTestFile(t, "small.bin", 3) - fileToken, err := UploadDriveMediaAll(runtime, DriveMediaUploadAllConfig{ + fileToken, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{ FilePath: filePath, FileName: "small.bin", FileSize: 3, @@ -73,7 +68,7 @@ func TestUploadDriveMediaAllBuildsMultipartBody(t *testing.T) { Extra: `{"drive_route_token":"doxcn123"}`, }) if err != nil { - t.Fatalf("UploadDriveMediaAll() error: %v", err) + t.Fatalf("UploadDriveMediaAllTyped() error: %v", err) } if fileToken != "file_all_123" { t.Fatalf("fileToken = %q, want %q", fileToken, "file_all_123") @@ -107,99 +102,7 @@ func TestUploadDriveMediaAllBuildsMultipartBody(t *testing.T) { } } -func TestUploadDriveMediaAllWithInMemoryContent(t *testing.T) { - // When Content is provided, FilePath is ignored — the in-memory reader - // is streamed directly into the multipart form. Used by the clipboard - // upload path. - runtime, reg := newDriveMediaUploadTestRuntime(t) - withDriveMediaUploadWorkingDir(t, t.TempDir()) - - uploadStub := &httpmock.Stub{ - Method: "POST", - URL: "/open-apis/drive/v1/medias/upload_all", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{"file_token": "file_mem_123"}, - }, - } - reg.Register(uploadStub) - - payload := []byte{0x89, 0x50, 0x4e, 0x47, 0xde, 0xad} - fileToken, err := UploadDriveMediaAll(runtime, DriveMediaUploadAllConfig{ - Reader: bytes.NewReader(payload), - FileName: "clipboard.png", - FileSize: int64(len(payload)), - ParentType: "docx_image", - ParentNode: strPtr("blk_parent"), - }) - if err != nil { - t.Fatalf("UploadDriveMediaAll() error: %v", err) - } - if fileToken != "file_mem_123" { - t.Fatalf("fileToken = %q, want %q", fileToken, "file_mem_123") - } - - body := decodeCapturedDriveMediaMultipartBody(t, uploadStub) - if got := body.Fields["file_name"]; got != "clipboard.png" { - t.Fatalf("file_name = %q, want %q", got, "clipboard.png") - } - if got := body.Files["file"]; !bytes.Equal(got, payload) { - t.Fatalf("uploaded file bytes mismatch; got %v, want %v", got, payload) - } -} - -func TestUploadDriveMediaMultipartWithInMemoryContent(t *testing.T) { - // Clipboard multipart upload: Content reader replaces FilePath, and the - // server-declared block plan is honored exactly. - runtime, reg := newDriveMediaUploadTestRuntime(t) - withDriveMediaUploadWorkingDir(t, t.TempDir()) - - size := MaxDriveMediaUploadSinglePartSize + 1 - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/drive/v1/medias/upload_prepare", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{ - "upload_id": "upload_mem_1", - "block_size": float64(4 * 1024 * 1024), - "block_num": float64(6), - }, - }, - }) - for i := 0; i < 6; i++ { - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/drive/v1/medias/upload_part", - Body: map[string]interface{}{"code": 0, "msg": "ok"}, - }) - } - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/drive/v1/medias/upload_finish", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{"file_token": "file_mem_multi"}, - }, - }) - - payload := bytes.Repeat([]byte{0xAB}, int(size)) - fileToken, err := UploadDriveMediaMultipart(runtime, DriveMediaMultipartUploadConfig{ - Reader: bytes.NewReader(payload), - FileName: "clipboard.png", - FileSize: size, - ParentType: "docx_image", - ParentNode: "", - }) - if err != nil { - t.Fatalf("UploadDriveMediaMultipart() error: %v", err) - } - if fileToken != "file_mem_multi" { - t.Fatalf("fileToken = %q, want %q", fileToken, "file_mem_multi") - } -} - -func TestUploadDriveMediaMultipartBuildsPreparePartsAndFinish(t *testing.T) { +func TestUploadDriveMediaMultipartTypedBuildsRequestBodies(t *testing.T) { runtime, reg := newDriveMediaUploadTestRuntime(t) withDriveMediaUploadWorkingDir(t, t.TempDir()) @@ -242,7 +145,7 @@ func TestUploadDriveMediaMultipartBuildsPreparePartsAndFinish(t *testing.T) { reg.Register(finishStub) filePath := writeDriveMediaUploadSizedFile(t, "large.bin", MaxDriveMediaUploadSinglePartSize+1) - fileToken, err := UploadDriveMediaMultipart(runtime, DriveMediaMultipartUploadConfig{ + fileToken, err := UploadDriveMediaMultipartTyped(runtime, DriveMediaMultipartUploadConfig{ FilePath: filePath, FileName: "large.bin", FileSize: MaxDriveMediaUploadSinglePartSize + 1, @@ -251,7 +154,7 @@ func TestUploadDriveMediaMultipartBuildsPreparePartsAndFinish(t *testing.T) { Extra: `{"obj_type":"sheet","file_extension":"xlsx"}`, }) if err != nil { - t.Fatalf("UploadDriveMediaMultipart() error: %v", err) + t.Fatalf("UploadDriveMediaMultipartTyped() error: %v", err) } if fileToken != "file_multi_123" { t.Fatalf("fileToken = %q, want %q", fileToken, "file_multi_123") @@ -306,78 +209,20 @@ func TestUploadDriveMediaMultipartBuildsPreparePartsAndFinish(t *testing.T) { } } -func TestParseDriveMediaMultipartUploadSessionValidatesResponseFields(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - data map[string]interface{} - wantText string - }{ - { - name: "missing upload id", - data: map[string]interface{}{ - "block_size": 4 * 1024 * 1024, - "block_num": 6, - }, - wantText: "upload prepare failed: no upload_id returned", - }, - { - name: "missing block size", - data: map[string]interface{}{ - "upload_id": "upload_123", - "block_num": 6, - }, - wantText: "upload prepare failed: invalid block_size returned", - }, - { - name: "missing block num", - data: map[string]interface{}{ - "upload_id": "upload_123", - "block_size": 4 * 1024 * 1024, - }, - wantText: "upload prepare failed: invalid block_num returned", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - _, err := ParseDriveMediaMultipartUploadSession(tt.data) - if err == nil || !strings.Contains(err.Error(), tt.wantText) { - t.Fatalf("err = %v, want substring %q", err, tt.wantText) - } - }) - } -} - -func TestUploadDriveMediaMultipartPartAPIFailure(t *testing.T) { +func TestUploadDriveMediaMultipartTypedPrepareAPIFailure(t *testing.T) { runtime, reg := newDriveMediaUploadTestRuntime(t) withDriveMediaUploadWorkingDir(t, t.TempDir()) reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/drive/v1/medias/upload_prepare", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{ - "upload_id": "upload_123", - "block_size": float64(4 * 1024 * 1024), - "block_num": float64(6), - }, - }, - }) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/drive/v1/medias/upload_part", Body: map[string]interface{}{ "code": 999, - "msg": "chunk rejected", + "msg": "prepare rejected", }, }) filePath := writeDriveMediaUploadSizedFile(t, "large.bin", MaxDriveMediaUploadSinglePartSize+1) - _, err := UploadDriveMediaMultipart(runtime, DriveMediaMultipartUploadConfig{ + _, err := UploadDriveMediaMultipartTyped(runtime, DriveMediaMultipartUploadConfig{ FilePath: filePath, FileName: "large.bin", FileSize: MaxDriveMediaUploadSinglePartSize + 1, @@ -387,12 +232,16 @@ func TestUploadDriveMediaMultipartPartAPIFailure(t *testing.T) { if err == nil { t.Fatal("expected error, got nil") } - if !strings.Contains(err.Error(), "upload media part failed: [999] chunk rejected") { - t.Fatalf("unexpected error: %v", err) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed problem, got %T (%v)", err, err) + } + if p.Category != errs.CategoryAPI || p.Code != 999 { + t.Fatalf("category/code = %s/%d, want api/999", p.Category, p.Code) } } -func TestUploadDriveMediaMultipartFinishRequiresFileToken(t *testing.T) { +func TestUploadDriveMediaMultipartTypedFinishAPIFailure(t *testing.T) { runtime, reg := newDriveMediaUploadTestRuntime(t) withDriveMediaUploadWorkingDir(t, t.TempDir()) reg.Register(&httpmock.Stub{ @@ -421,13 +270,13 @@ func TestUploadDriveMediaMultipartFinishRequiresFileToken(t *testing.T) { Method: "POST", URL: "/open-apis/drive/v1/medias/upload_finish", Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{}, + "code": 999, + "msg": "finish rejected", }, }) filePath := writeDriveMediaUploadSizedFile(t, "large.bin", MaxDriveMediaUploadSinglePartSize+1) - _, err := UploadDriveMediaMultipart(runtime, DriveMediaMultipartUploadConfig{ + _, err := UploadDriveMediaMultipartTyped(runtime, DriveMediaMultipartUploadConfig{ FilePath: filePath, FileName: "large.bin", FileSize: MaxDriveMediaUploadSinglePartSize + 1, @@ -437,57 +286,12 @@ func TestUploadDriveMediaMultipartFinishRequiresFileToken(t *testing.T) { if err == nil { t.Fatal("expected error, got nil") } - if !strings.Contains(err.Error(), "upload media finish failed: no file_token returned") { - t.Fatalf("unexpected error: %v", err) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed problem, got %T (%v)", err, err) } -} - -func TestParseDriveMediaUploadResponseErrors(t *testing.T) { - t.Parallel() - - t.Run("invalid json", func(t *testing.T) { - t.Parallel() - - _, err := ParseDriveMediaUploadResponse(&larkcore.ApiResp{RawBody: []byte("{")}, "upload media failed") - if err == nil || !strings.Contains(err.Error(), "invalid response JSON") { - t.Fatalf("expected invalid JSON error, got %v", err) - } - }) - - t.Run("api code error", func(t *testing.T) { - t.Parallel() - - _, err := ParseDriveMediaUploadResponse(&larkcore.ApiResp{RawBody: []byte(`{"code":999,"msg":"boom","error":{"detail":"x"}}`)}, "upload media failed") - if err == nil || !strings.Contains(err.Error(), "upload media failed: [999] boom") { - t.Fatalf("expected API error, got %v", err) - } - }) - - t.Run("api code error includes log_id", func(t *testing.T) { - t.Parallel() - - resp := &larkcore.ApiResp{ - RawBody: []byte(`{"code":999,"msg":"boom","error":{"detail":"x"}}`), - Header: http.Header{"X-Tt-Logid": []string{"202605270002"}}, - } - _, err := ParseDriveMediaUploadResponse(resp, "upload media failed") - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured error, got %T %v", err, err) - } - detail, _ := exitErr.Detail.Detail.(map[string]interface{}) - if detail["log_id"] != "202605270002" { - t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail) - } - }) -} - -func TestExtractDriveMediaUploadFileTokenRequiresToken(t *testing.T) { - t.Parallel() - - _, err := ExtractDriveMediaUploadFileToken(map[string]interface{}{}, "upload media failed") - if err == nil || !strings.Contains(err.Error(), "upload media failed: no file_token returned") { - t.Fatalf("err = %v, want missing file_token error", err) + if p.Category != errs.CategoryAPI || p.Code != 999 { + t.Fatalf("category/code = %s/%d, want api/999", p.Category, p.Code) } } diff --git a/shortcuts/common/drive_media_upload_typed_test.go b/shortcuts/common/drive_media_upload_typed_test.go index 4f2b2bafc..e8f8ff2c2 100644 --- a/shortcuts/common/drive_media_upload_typed_test.go +++ b/shortcuts/common/drive_media_upload_typed_test.go @@ -17,14 +17,15 @@ func TestUploadDriveMediaAllTypedWithInMemoryContent(t *testing.T) { runtime, reg := newDriveMediaUploadTestRuntime(t) withDriveMediaUploadWorkingDir(t, t.TempDir()) - reg.Register(&httpmock.Stub{ + uploadStub := &httpmock.Stub{ Method: "POST", URL: "/open-apis/drive/v1/medias/upload_all", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{"file_token": "file_typed_123"}, }, - }) + } + reg.Register(uploadStub) payload := []byte{0x89, 0x50, 0x4e, 0x47} fileToken, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{ @@ -40,6 +41,15 @@ func TestUploadDriveMediaAllTypedWithInMemoryContent(t *testing.T) { if fileToken != "file_typed_123" { t.Fatalf("fileToken = %q, want %q", fileToken, "file_typed_123") } + + // The in-memory reader is streamed directly into the multipart form. + body := decodeCapturedDriveMediaMultipartBody(t, uploadStub) + if got := body.Fields["file_name"]; got != "clipboard.png" { + t.Fatalf("file_name = %q, want %q", got, "clipboard.png") + } + if got := body.Files["file"]; !bytes.Equal(got, payload) { + t.Fatalf("uploaded file bytes mismatch; got %v, want %v", got, payload) + } } func TestUploadDriveMediaAllTypedClassifiesAPIFailure(t *testing.T) { diff --git a/shortcuts/common/permission_grant.go b/shortcuts/common/permission_grant.go index 3556b86c6..4b6fd890e 100644 --- a/shortcuts/common/permission_grant.go +++ b/shortcuts/common/permission_grant.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/internal/validate" ) @@ -67,7 +67,7 @@ func autoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourc body["perm_type"] = permType } - _, err := runtime.CallAPI( + _, err := runtime.CallAPITyped( "POST", fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(token)), map[string]interface{}{ @@ -84,11 +84,11 @@ func autoGrantCurrentUserDrivePermission(runtime *RuntimeContext, token, resourc fmt.Sprintf("Resource was created, but granting current user %s failed: %s. You can retry later or continue using bot identity.", permissionGrantPermMessage(), errMsg), fmt.Sprintf("Auto-grant failed: %s. The app may lack the required scope or the resource restricts permission changes.", errMsg), ) - // Best-effort: when the underlying error is a structured permission - // ExitError (lark code 99991672/99991679), surface lark_code, - // required_scope and console_url so agents can guide users straight - // to the dev console. Overrides the generic hint with a more - // actionable one when console_url is available. + // Best-effort: when the underlying error is permission-class + // (lark code 99991672/99991679), surface lark_code, required_scope + // and console_url so agents can guide users straight to the dev + // console. Overrides the generic hint with a more actionable one + // when console_url is available. annotateGrantPermissionError(runtime, result, err) fmt.Fprintf(runtime.IO().ErrOut, "Warning: resource was created, but auto-grant failed: %s. Retry later or grant permission manually.\n", errMsg) return result @@ -163,10 +163,10 @@ func compactPermissionGrantError(err error) string { // annotateGrantPermissionError enriches a failed permission_grant result with // structured fields (lark_code / required_scope / console_url) when the -// underlying error is a permission-class *output.ExitError. The CLI's main -// permission-error path (cmd/root.go::enrichPermissionError) handles the same -// case for top-level failures; this helper covers best-effort sub-calls whose -// error is folded into a result map instead of propagated as ExitError. +// underlying error is a typed *errs.PermissionError. The typed error produced +// by errclass.BuildAPIError already carries MissingScopes + ConsoleURL for +// top-level failures; this helper covers best-effort sub-calls whose error is +// folded into a result map instead of propagated. // // When console_url is available, the existing generic hint is overridden with // a more actionable one pointing at the developer console — that's the @@ -175,18 +175,14 @@ func annotateGrantPermissionError(runtime *RuntimeContext, result map[string]int if runtime == nil || result == nil || err == nil { return } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { + code, scopes, ok := permissionGrantErrorFacts(err) + if !ok { return } - if exitErr.Detail.Type != "permission" { - return - } - if exitErr.Detail.Code != 0 { - result["lark_code"] = exitErr.Detail.Code + if code != 0 { + result["lark_code"] = code } - scopes := registry.ExtractRequiredScopes(exitErr.Detail.Detail) if len(scopes) == 0 { return } @@ -211,3 +207,14 @@ func annotateGrantPermissionError(runtime *RuntimeContext, result map[string]int recommended, ) } + +// permissionGrantErrorFacts extracts the Lark code and missing scopes from a +// permission-class error. A typed *errs.PermissionError carries both directly. +// Non-permission errors report ok=false. +func permissionGrantErrorFacts(err error) (code int, scopes []string, ok bool) { + var permErr *errs.PermissionError + if errors.As(err, &permErr) { + return permErr.Code, permErr.MissingScopes, true + } + return 0, nil, false +} diff --git a/shortcuts/common/permission_grant_test.go b/shortcuts/common/permission_grant_test.go index 8e87e8fd8..15f327102 100644 --- a/shortcuts/common/permission_grant_test.go +++ b/shortcuts/common/permission_grant_test.go @@ -11,10 +11,26 @@ import ( "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" ) +// apiErrWithScopes builds the typed error errclass.BuildAPIError produces for a +// Lark API failure carrying permission_violations. For authorization codes this +// yields an *errs.PermissionError with MissingScopes populated; for other codes +// it yields the corresponding typed error. +func apiErrWithScopes(code int, msg string, subjects ...string) error { + resp := map[string]any{"code": code, "msg": msg} + if len(subjects) > 0 { + violations := make([]any, 0, len(subjects)) + for _, s := range subjects { + violations = append(violations, map[string]any{"subject": s}) + } + resp["error"] = map[string]any{"permission_violations": violations} + } + return errclass.BuildAPIError(resp, errclass.ClassifyContext{}) +} + func TestAutoGrantStderrWarning_SkippedNoUser(t *testing.T) { config := &core.CliConfig{ AppID: "perm-grant-test-skip", @@ -117,11 +133,7 @@ func TestAnnotateGrantPermissionError_AppScopeNotEnabled(t *testing.T) { "hint": "generic fallback hint", } - err := output.ErrAPI(99991672, "Permission denied [99991672]", map[string]interface{}{ - "permission_violations": []interface{}{ - map[string]interface{}{"subject": "docs:permission.member:create"}, - }, - }) + err := apiErrWithScopes(99991672, "Permission denied [99991672]", "docs:permission.member:create") annotateGrantPermissionError(rt, result, err) @@ -150,11 +162,7 @@ func TestAnnotateGrantPermissionError_AppScopeNotEnabled(t *testing.T) { func TestAnnotateGrantPermissionError_LarkBrand(t *testing.T) { rt := newAnnotateRuntime(core.BrandLark, "cli_demo") result := map[string]interface{}{} - err := output.ErrAPI(99991679, "Permission denied [99991679]", map[string]interface{}{ - "permission_violations": []interface{}{ - map[string]interface{}{"subject": "docs:permission.member:create"}, - }, - }) + err := apiErrWithScopes(99991679, "Permission denied [99991679]", "docs:permission.member:create") annotateGrantPermissionError(rt, result, err) @@ -170,14 +178,8 @@ func TestAnnotateGrantPermissionError_NonPermissionErrorNoOp(t *testing.T) { cases := []error{ errors.New("plain error"), - output.ErrNetwork("connection reset"), - output.ErrValidation("bad request"), - // Non-permission API errors (e.g. 230001) — type is "api_error" not "permission" - output.ErrAPI(230001, "no permission", map[string]interface{}{ - "permission_violations": []interface{}{ - map[string]interface{}{"subject": "docs:doc"}, - }, - }), + apiErrWithScopes(230001, "no permission", "docs:doc"), // unknown code → *errs.APIError, not permission + } for i, e := range cases { result := map[string]interface{}{ @@ -203,7 +205,7 @@ func TestAnnotateGrantPermissionError_NoViolations(t *testing.T) { result := map[string]interface{}{ "hint": "untouched fallback", } - err := output.ErrAPI(99991672, "Permission denied [99991672]", nil) + err := apiErrWithScopes(99991672, "Permission denied [99991672]") annotateGrantPermissionError(rt, result, err) @@ -222,11 +224,7 @@ func TestAnnotateGrantPermissionError_NoViolations(t *testing.T) { func TestAnnotateGrantPermissionError_EmptyAppID(t *testing.T) { rt := newAnnotateRuntime(core.BrandFeishu, "") result := map[string]interface{}{} - err := output.ErrAPI(99991672, "Permission denied", map[string]interface{}{ - "permission_violations": []interface{}{ - map[string]interface{}{"subject": "docs:doc"}, - }, - }) + err := apiErrWithScopes(99991672, "Permission denied", "docs:doc") annotateGrantPermissionError(rt, result, err) if _, ok := result["console_url"]; ok { diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index d8be762cd..dd9c1d083 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -165,10 +165,15 @@ func (ctx *RuntimeContext) getAPIClient() (*client.APIClient, error) { func (ctx *RuntimeContext) AccessToken() (string, error) { result, err := ctx.Factory.Credential.ResolveToken(ctx.ctx, credential.NewTokenSpec(ctx.As(), ctx.Config.AppID)) if err != nil { - return "", output.ErrAuth("failed to get access token: %s", err) + // ResolveToken classifies its own failures (config/api); pass those + // through so a typed lower-layer error is not flattened to token_invalid. + if _, ok := errs.ProblemOf(err); ok { + return "", err + } + return "", errs.NewAuthenticationError(errs.SubtypeTokenInvalid, "failed to get access token: %s", err).WithCause(err) } if result == nil || result.Token == "" { - return "", output.ErrAuth("no access token available for %s", ctx.As()) + return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing, "no access token available for %s", ctx.As()) } return result.Token, nil } @@ -241,25 +246,14 @@ func (ctx *RuntimeContext) Changed(name string) bool { // ── API helpers ── -// CallAPI uses an internal HTTP wrapper with limited control over request/response. -// -// Prefer DoAPI for new code — it calls the Lark SDK directly and supports file upload/download options. -// -// CallAPI calls the Lark API using the current identity (ctx.As()) and auto-handles errors. -func (ctx *RuntimeContext) CallAPI(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) { - result, err := ctx.callRaw(method, url, params, data) - return HandleApiResult(result, err, "API call failed") -} - -// CallAPITyped is the typed-only replacement for CallAPI: it performs the same -// SDK request (buildRequest → APIClient.DoAPI → DoSDKRequest, identical -// transport and query model to CallAPI) and returns the "data" object, but -// classifies failures into typed errs.* errors via errclass.BuildAPIError. +// CallAPITyped calls the Lark API using the current identity (ctx.As()) via +// the SDK request path (buildRequest → APIClient.DoAPI → DoSDKRequest) and +// returns the "data" object, classifying failures into typed errs.* errors via +// errclass.BuildAPIError. // // A transport / auth error from the client boundary is already typed and passes // through unchanged; a non-zero API response code is classified into a typed -// error carrying subtype / code / log_id. Unlike CallAPI it never emits a legacy -// output.ExitError envelope, and never downgrades a typed network/auth error. +// error carrying subtype / code / log_id. // // It lifts x-tt-logid from the response header (which the body-only parse drops) // so log_id surfaces on the typed error even when the server returns it only in @@ -386,7 +380,7 @@ func (ctx *RuntimeContext) APIClassifyContext() errclass.ClassifyContext { } } -// Deprecated: RawAPI uses an internal HTTP wrapper with limited control over request/response. +// RawAPI uses an internal HTTP wrapper with limited control over request/response. // Prefer DoAPI for new code — it calls the Lark SDK directly and supports file upload/download options. // // RawAPI calls the Lark API using the current identity (ctx.As()) and returns raw result for manual error handling. @@ -500,12 +494,13 @@ func (ctx *RuntimeContext) DoAPIJSONWithLogID(method, apiPath string, query lark return ctx.doAPIJSON(method, apiPath, query, body, true) } -// DoAPIJSONTyped is the typed-only replacement for DoAPIJSON: it issues the same -// larkcore.ApiReq request (identical method / path / query / body model) but -// classifies failures into typed errs.* errors via ClassifyAPIResponse instead -// of emitting a legacy output.ExitError "api_error" envelope. A transport / auth +// DoAPIJSONTyped issues a larkcore.ApiReq request and classifies failures into +// typed errs.* errors via ClassifyAPIResponse, which lifts MissingScopes / +// ConsoleURL / Identity onto the typed error at the source. A transport / auth // error from the client boundary is already typed and passes through unchanged; -// a non-zero API code is classified with subtype / code / log_id. +// a non-zero API code is classified with subtype / code / log_id. Prefer this +// over DoAPIJSON, which routes the same response through errclass.BuildAPIError +// but cannot attach identity-aware fields. func (ctx *RuntimeContext) DoAPIJSONTyped(method, apiPath string, query larkcore.QueryParams, body any) (map[string]any, error) { req := &larkcore.ApiReq{ HttpMethod: method, @@ -539,6 +534,8 @@ func (ctx *RuntimeContext) doAPIJSON(method, apiPath string, query larkcore.Quer if includeLogID { detail = logIDFromHeader(resp) } + logID, _ := detail["log_id"].(string) + cc := ctx.APIClassifyContext() if resp.StatusCode >= 400 { if len(resp.RawBody) > 0 { var errEnv struct { @@ -546,10 +543,25 @@ func (ctx *RuntimeContext) doAPIJSON(method, apiPath string, query larkcore.Quer Msg string `json:"msg"` } if json.Unmarshal(resp.RawBody, &errEnv) == nil && errEnv.Msg != "" { - return nil, output.ErrAPI(errEnv.Code, fmt.Sprintf("HTTP %d: %s", resp.StatusCode, errEnv.Msg), detail) + // BuildAPIError treats code 0 as success and returns nil; an HTTP + // status error must never be swallowed, so fall back to the HTTP + // status code when the body omits a non-zero business code. + code := errEnv.Code + if code == 0 { + code = resp.StatusCode + } + return nil, errclass.BuildAPIError(map[string]any{ + "code": code, + "msg": fmt.Sprintf("HTTP %d: %s", resp.StatusCode, errEnv.Msg), + "log_id": logID, + }, cc) } } - return nil, output.ErrAPI(resp.StatusCode, fmt.Sprintf("HTTP %d", resp.StatusCode), detail) + return nil, errclass.BuildAPIError(map[string]any{ + "code": resp.StatusCode, + "msg": fmt.Sprintf("HTTP %d", resp.StatusCode), + "log_id": logID, + }, cc) } if len(resp.RawBody) == 0 { return nil, fmt.Errorf("empty response body") @@ -563,7 +575,11 @@ func (ctx *RuntimeContext) doAPIJSON(method, apiPath string, query larkcore.Quer return nil, fmt.Errorf("unmarshal response: %w", err) } if envelope.Code != 0 { - return nil, output.ErrAPI(envelope.Code, envelope.Msg, detail) + return nil, errclass.BuildAPIError(map[string]any{ + "code": envelope.Code, + "msg": envelope.Msg, + "log_id": logID, + }, cc) } if detail != nil { if envelope.Data == nil { @@ -645,29 +661,12 @@ func WrapOpenError(err error, pathMsg, readMsg string) error { return fmt.Errorf("%s: %w", readMsg, err) } -// WrapInputStatError wraps a FileIO.Stat/Open error for input file validation, -// returning output.ErrValidation with the appropriate message: +// WrapInputStatErrorTyped wraps a FileIO.Stat/Open error for input file +// validation, returning a typed validation error with the appropriate message: // - Path validation failures → "unsafe file path: ..." // - Other errors → readMsg prefix (default "cannot read file") // // Pass an optional readMsg to override the non-path-validation message prefix. -// -// Deprecated: use WrapInputStatErrorTyped for typed error envelopes. -func WrapInputStatError(err error, readMsg ...string) error { - if err == nil { - return nil - } - if errors.Is(err, fileio.ErrPathValidation) { - return output.ErrValidation("unsafe file path: %s", err) - } - msg := "cannot read file" - if len(readMsg) > 0 && readMsg[0] != "" { - msg = readMsg[0] - } - return output.ErrValidation("%s: %s", msg, err) -} - -// WrapInputStatErrorTyped wraps a FileIO.Stat/Open error for input file validation. func WrapInputStatErrorTyped(err error, readMsg ...string) error { if err == nil { return nil @@ -750,7 +749,8 @@ func (ctx *RuntimeContext) OutRaw(data interface{}, meta *output.Meta) { // // It is the typed alternative to `Out(...)` + `output.ErrBare(...)` — the // envelope's ok field honestly reports failure instead of a misleading -// ok:true, and the exit signal is distinct from the predicate-only ErrBare. +// ok:true, and the exit signal is distinct from ErrBare (the +// stdout-carries-the-answer silent-exit signal). func (ctx *RuntimeContext) OutPartialFailure(data interface{}, meta *output.Meta) error { ctx.emit(data, meta, false, false) if ctx.outputErr != nil { @@ -882,40 +882,22 @@ func checkScopePrereqs(f *cmdutil.Factory, ctx context.Context, appID string, id // enhancePermissionError enriches a permission / auth error with the // shortcut's declared required scopes so the user knows exactly what to do. // -// Detection is typed: an error qualifies when it (or any error in its -// Unwrap chain) is *errs.PermissionError, or — for legacy bridge paths — -// when it is an *output.ExitError carrying Detail.Type "permission" or -// "missing_scope". The previous implementation scanned the upstream -// message text for keywords like "permission" / "scope" / "unauthorized", -// which was brittle to canonical-message rewrites; routing on the typed -// shape decouples this helper from the wording. +// Detection is typed: an error qualifies when it (or any error in its Unwrap +// chain) is *errs.PermissionError. The previous implementation scanned the +// upstream message text for keywords like "permission" / "scope" / +// "unauthorized", which was brittle to canonical-message rewrites; routing on +// the typed shape decouples this helper from the wording. func enhancePermissionError(err error, requiredScopes []string) error { var permErr *errs.PermissionError - if errors.As(err, &permErr) { - scopeDisplay := strings.Join(requiredScopes, ", ") - scopeArg := strings.Join(requiredScopes, " ") - hint := fmt.Sprintf( - "this command requires scope(s): %s\nrun `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", - scopeDisplay, scopeArg) - permErr.Hint = hint + if !errors.As(err, &permErr) { return err } - - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - return err - } - if exitErr.Detail.Type != "permission" && exitErr.Detail.Type != "missing_scope" { - return err - } - scopeDisplay := strings.Join(requiredScopes, ", ") scopeArg := strings.Join(requiredScopes, " ") - hint := fmt.Sprintf( + permErr.Hint = fmt.Sprintf( "this command requires scope(s): %s\nrun `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", scopeDisplay, scopeArg) - // Return a new error instead of mutating the original's Detail in place. - return output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint) + return err } // ── Mounting ── @@ -992,11 +974,11 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo out, err := s.PrintFlagSchema(strings.TrimSpace(flagName)) if err != nil { // PrintFlagSchema implementations return bare errors; wrap as a - // structured ExitError so --print-schema (an agent-facing + // typed validation error so --print-schema (an agent-facing // introspection path) yields a parseable envelope, not a plain // string. - if _, ok := err.(*output.ExitError); !ok { - err = output.Errorf(output.ExitValidation, "print_schema_error", "%s", err.Error()) + if !errs.IsTyped(err) { + err = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err) } return err } @@ -1234,9 +1216,9 @@ func handleShortcutDryRun(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut) } // rejectPositionalArgs returns a cobra.PositionalArgs that rejects any -// positional arguments. The error is intentionally a plain error (not -// ExitError) so that cobra prints usage and the root handler prints a -// simple "Error:" line instead of a JSON envelope. +// positional arguments. It returns a plain cobra usage error; the root +// handler classifies it into the typed validation envelope (exit 2), the +// same path as other cobra usage failures. func rejectPositionalArgs() cobra.PositionalArgs { return func(cmd *cobra.Command, args []string) error { if len(args) == 0 { diff --git a/shortcuts/common/runner_partial_failure_test.go b/shortcuts/common/runner_partial_failure_test.go index 1be71c255..3147abbe3 100644 --- a/shortcuts/common/runner_partial_failure_test.go +++ b/shortcuts/common/runner_partial_failure_test.go @@ -19,7 +19,7 @@ import ( // TestOutPartialFailure pins the batch / multi-status contract: the result // rides on stdout as an ok:false envelope (carrying the full payload), and the // returned error is the typed partial-failure exit signal (ExitAPI), distinct -// from the predicate-only ErrBare. +// from ErrBare (the silent-exit signal). func TestOutPartialFailure(t *testing.T) { cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"} f, stdout, _, _ := cmdutil.TestFactory(t, cfg) diff --git a/shortcuts/common/runner_scope_test.go b/shortcuts/common/runner_scope_test.go index 9b1602d92..c3313eaf4 100644 --- a/shortcuts/common/runner_scope_test.go +++ b/shortcuts/common/runner_scope_test.go @@ -14,7 +14,6 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" - "github.com/larksuite/cli/internal/output" ) type scopeCheckTokenResolver struct { @@ -26,25 +25,6 @@ func (r *scopeCheckTokenResolver) ResolveToken(ctx context.Context, req credenti return r.result, r.err } -func TestEnhancePermissionError_MissingScopeType(t *testing.T) { - scopes := []string{"calendar:calendar:read"} - err := &output.ExitError{ - Code: 1, - Detail: &output.ErrDetail{Type: "missing_scope", Message: "missing scope"}, - } - got := enhancePermissionError(err, scopes) - var exitErr *output.ExitError - if !errors.As(got, &exitErr) { - t.Fatalf("expected ExitError, got %T", got) - } - if exitErr.Detail.Hint == "" { - t.Error("expected hint for missing_scope type") - } - if !strings.Contains(exitErr.Detail.Hint, "calendar:calendar:read") { - t.Errorf("hint %q missing scope info", exitErr.Detail.Hint) - } -} - // TestEnhancePermissionError_TypedPermissionErrorRouted pins typed routing: // an *errs.PermissionError gets enhanced regardless of its Message text, // decoupling this helper from canonical-message rewrites that would @@ -68,119 +48,59 @@ func TestEnhancePermissionError_TypedPermissionErrorRouted(t *testing.T) { } } -// TestEnhancePermissionError_KeywordScanRemoved pins that an *output.ExitError -// whose Detail.Type is NOT "permission" / "missing_scope" is no longer -// matched by upstream-message keyword scan. This is the contract change in -// T15: typed routing replaces the brittle keyword scan, so canonical -// message rewrites cannot accidentally flip an unrelated api_error into +// TestEnhancePermissionError_NonPermissionErrorsPassThrough pins that any +// error that is not an *errs.PermissionError is returned unchanged. Typed +// routing means the upstream message text never flips an unrelated error into // the permission-enhancement path. -func TestEnhancePermissionError_KeywordScanRemoved(t *testing.T) { +func TestEnhancePermissionError_NonPermissionErrorsPassThrough(t *testing.T) { scopes := []string{"contact:contact:read"} cases := []struct { name string - msg string + err error }{ - {"permission keyword", "Permission denied for resource"}, - {"scope keyword", "Insufficient scope for operation"}, - {"authorization keyword", "Authorization required"}, - {"unauthorized keyword", "request unauthorized by server"}, + {"api error with permission keyword", errs.NewAPIError(errs.SubtypeUnknown, "Permission denied for resource")}, + {"api error with scope keyword", errs.NewAPIError(errs.SubtypeUnknown, "Insufficient scope for operation")}, + {"network error", errs.NewNetworkError(errs.SubtypeNetworkTransport, "request unauthorized by server")}, + {"plain error", fmt.Errorf("plain error")}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - err := &output.ExitError{ - Code: 1, - Detail: &output.ErrDetail{Type: "api_error", Message: tc.msg}, - } - got := enhancePermissionError(err, scopes) - if got != err { - t.Errorf("expected original error returned (type=api_error must not match), got %T: %v", got, got) + got := enhancePermissionError(tc.err, scopes) + if got != tc.err { + t.Errorf("expected original error returned, got %T: %v", got, got) } }) } } -func TestEnhancePermissionError(t *testing.T) { +// TestEnhancePermissionError_PermissionErrorGetsScopeHint pins that an +// *errs.PermissionError is enhanced with a hint that names the required +// scopes and the `auth login --scope ...` recovery action. +func TestEnhancePermissionError_PermissionErrorGetsScopeHint(t *testing.T) { scopes := []string{"calendar:calendar:read", "drive:drive:read"} - - tests := []struct { - name string - err error - wantHint bool - hintSubstr string - }{ - { - name: "permission type gets enhanced", - err: &output.ExitError{ - Code: 1, - Detail: &output.ErrDetail{Type: "permission", Message: "no permission"}, - }, - wantHint: true, - hintSubstr: "scope", - }, - { - name: "mcp_error with unauthorized keyword not enhanced (keyword scan removed)", - err: &output.ExitError{ - Code: 1, - Detail: &output.ErrDetail{Type: "mcp_error", Message: "request unauthorized by server"}, - }, - wantHint: false, - }, - { - name: "api_error without keyword not modified", - err: &output.ExitError{ - Code: 1, - Detail: &output.ErrDetail{Type: "api_error", Message: "timeout"}, - }, - wantHint: false, - }, - { - name: "plain error not modified", - err: fmt.Errorf("plain error"), - wantHint: false, - }, - { - name: "nil Detail not modified", - err: &output.ExitError{ - Code: 1, - Detail: nil, - }, - wantHint: false, + err := &errs.PermissionError{ + Problem: errs.Problem{ + Category: errs.CategoryAuthorization, + Subtype: errs.SubtypeMissingScope, + Message: "no permission", }, } + got := enhancePermissionError(err, scopes) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := enhancePermissionError(tt.err, scopes) - - if !tt.wantHint { - // Should return original error unchanged - if got != tt.err { - t.Errorf("expected original error returned, got different error: %v", got) - } - return - } - - // Should return an enhanced ExitError with a hint - var exitErr *output.ExitError - if !errors.As(got, &exitErr) { - t.Fatalf("expected ExitError, got %T: %v", got, got) - } - if exitErr.Detail == nil { - t.Fatal("expected Detail to be non-nil") - } - if exitErr.Detail.Hint == "" { - t.Fatal("expected non-empty hint") - } - if !strings.Contains(exitErr.Detail.Hint, tt.hintSubstr) { - t.Errorf("hint %q does not contain %q", exitErr.Detail.Hint, tt.hintSubstr) - } - // Verify the hint includes the actual scopes - for _, s := range scopes { - if !strings.Contains(exitErr.Detail.Hint, s) { - t.Errorf("hint %q does not contain scope %q", exitErr.Detail.Hint, s) - } - } - }) + var permErr *errs.PermissionError + if !errors.As(got, &permErr) { + t.Fatalf("expected *errs.PermissionError, got %T: %v", got, got) + } + if permErr.Hint == "" { + t.Fatal("expected non-empty hint") + } + if !strings.Contains(permErr.Hint, "scope") { + t.Errorf("hint %q does not mention scope", permErr.Hint) + } + for _, s := range scopes { + if !strings.Contains(permErr.Hint, s) { + t.Errorf("hint %q does not contain scope %q", permErr.Hint, s) + } } } diff --git a/shortcuts/common/validate.go b/shortcuts/common/validate.go index bff04e3bd..a0687d431 100644 --- a/shortcuts/common/validate.go +++ b/shortcuts/common/validate.go @@ -9,38 +9,13 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/output" ) -// FlagErrorf returns a validation error with flag context (exit code 2). -// -// Deprecated: use ValidationErrorf for typed error envelopes. -func FlagErrorf(format string, args ...any) error { - return output.ErrValidation(format, args...) -} - // ValidationErrorf returns a typed validation error with invalid_argument subtype. func ValidationErrorf(format string, args ...any) *errs.ValidationError { return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...) } -// MutuallyExclusive checks that at most one of the given flags is set. -// -// Deprecated: use MutuallyExclusiveTyped for typed error envelopes. -func MutuallyExclusive(rt *RuntimeContext, flags ...string) error { - var set []string - for _, f := range flags { - val := rt.Str(f) - if val != "" { - set = append(set, "--"+f) - } - } - if len(set) > 1 { - return FlagErrorf("%s are mutually exclusive", strings.Join(set, " and ")) - } - return nil -} - // MutuallyExclusiveTyped checks that at most one of the given flags is set. func MutuallyExclusiveTyped(rt *RuntimeContext, flags ...string) error { var set []string @@ -57,22 +32,6 @@ func MutuallyExclusiveTyped(rt *RuntimeContext, flags ...string) error { return nil } -// AtLeastOne checks that at least one of the given flags is set. -// -// Deprecated: use AtLeastOneTyped for typed error envelopes. -func AtLeastOne(rt *RuntimeContext, flags ...string) error { - for _, f := range flags { - if rt.Str(f) != "" { - return nil - } - } - names := make([]string, len(flags)) - for i, f := range flags { - names[i] = "--" + f - } - return FlagErrorf("specify at least one of %s", strings.Join(names, " or ")) -} - // AtLeastOneTyped checks that at least one of the given flags is set. func AtLeastOneTyped(rt *RuntimeContext, flags ...string) error { for _, f := range flags { @@ -88,16 +47,6 @@ func AtLeastOneTyped(rt *RuntimeContext, flags ...string) error { WithParams(invalidParams(names, "required; specify at least one")...) } -// ExactlyOne checks that exactly one of the given flags is set. -// -// Deprecated: use ExactlyOneTyped for typed error envelopes. -func ExactlyOne(rt *RuntimeContext, flags ...string) error { - if err := AtLeastOne(rt, flags...); err != nil { - return err - } - return MutuallyExclusive(rt, flags...) -} - // ExactlyOneTyped checks that exactly one of the given flags is set. func ExactlyOneTyped(rt *RuntimeContext, flags ...string) error { if err := AtLeastOneTyped(rt, flags...); err != nil { @@ -137,18 +86,10 @@ func ParseIntBounded(rt *RuntimeContext, name string, min, max int) int { return v } -// ValidateSafePath ensures path is relative and resolves within the current -// working directory. It catches traversal, symlink escape, and control +// ValidateSafePathTyped ensures path is relative and resolves within the +// current working directory. It catches traversal, symlink escape, and control // characters by delegating to FileIO.ResolvePath. Works for both file and // directory paths. -// -// Deprecated: use ValidateSafePathTyped for typed error envelopes. -func ValidateSafePath(fio fileio.FileIO, path string) error { - _, err := fio.ResolvePath(path) - return err -} - -// ValidateSafePathTyped ensures path resolves within the current working directory. func ValidateSafePathTyped(fio fileio.FileIO, path string) error { _, err := fio.ResolvePath(path) if err != nil { diff --git a/shortcuts/common/validate_test.go b/shortcuts/common/validate_test.go index c05226caa..c40f5315f 100644 --- a/shortcuts/common/validate_test.go +++ b/shortcuts/common/validate_test.go @@ -48,7 +48,7 @@ func assertValidationParam(t *testing.T, err error, param string) *errs.Validati return validationErr } -func TestMutuallyExclusive(t *testing.T) { +func TestMutuallyExclusiveTyped_FlagCombinations(t *testing.T) { tests := []struct { name string flags map[string]string @@ -83,9 +83,9 @@ func TestMutuallyExclusive(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { rt := newTestRuntime(tt.flags) - err := MutuallyExclusive(rt, tt.check...) + err := MutuallyExclusiveTyped(rt, tt.check...) if (err != nil) != tt.wantErr { - t.Errorf("MutuallyExclusive() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("MutuallyExclusiveTyped() error = %v, wantErr %v", err, tt.wantErr) } }) } @@ -209,7 +209,7 @@ func TestWrapSaveErrorTyped_PreservesTypedWriteCause(t *testing.T) { } } -func TestAtLeastOne(t *testing.T) { +func TestAtLeastOneTyped_FlagCombinations(t *testing.T) { tests := []struct { name string flags map[string]string @@ -238,15 +238,15 @@ func TestAtLeastOne(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { rt := newTestRuntime(tt.flags) - err := AtLeastOne(rt, tt.check...) + err := AtLeastOneTyped(rt, tt.check...) if (err != nil) != tt.wantErr { - t.Errorf("AtLeastOne() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("AtLeastOneTyped() error = %v, wantErr %v", err, tt.wantErr) } }) } } -func TestExactlyOne(t *testing.T) { +func TestExactlyOneTyped_FlagCombinations(t *testing.T) { tests := []struct { name string flags map[string]string @@ -275,9 +275,9 @@ func TestExactlyOne(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { rt := newTestRuntime(tt.flags) - err := ExactlyOne(rt, tt.check...) + err := ExactlyOneTyped(rt, tt.check...) if (err != nil) != tt.wantErr { - t.Errorf("ExactlyOne() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("ExactlyOneTyped() error = %v, wantErr %v", err, tt.wantErr) } }) } @@ -312,7 +312,7 @@ func TestParseIntBounded(t *testing.T) { } // --------------------------------------------------------------------------- -// ValidateSafePath — symlink escape prevention +// ValidateSafePathTyped — symlink escape prevention // --------------------------------------------------------------------------- // chdirForTest changes CWD to dir and restores the original CWD on cleanup. @@ -328,26 +328,9 @@ func chdirForTest(t *testing.T, dir string) { t.Cleanup(func() { os.Chdir(orig) }) } -// TestValidateSafePath_RejectsSymlinkEscape verifies that a relative path -// that resolves to a symlink pointing outside CWD is rejected. -func TestValidateSafePath_RejectsSymlinkEscape(t *testing.T) { - outside := t.TempDir() // target outside CWD - workDir := t.TempDir() - chdirForTest(t, workDir) - - // Create a symlink inside CWD pointing to outside. - if err := os.Symlink(outside, filepath.Join(workDir, "evil_out")); err != nil { - t.Fatalf("Symlink: %v", err) - } - - if err := ValidateSafePath(&localfileio.LocalFileIO{}, "evil_out"); err == nil { - t.Fatal("expected error for symlink pointing outside CWD, got nil") - } -} - -// TestValidateSafePath_RejectsDanglingSymlink verifies that a dangling +// TestValidateSafePathTyped_RejectsDanglingSymlink verifies that a dangling // symlink (target does not exist) is rejected to prevent future escapes. -func TestValidateSafePath_RejectsDanglingSymlink(t *testing.T) { +func TestValidateSafePathTyped_RejectsDanglingSymlink(t *testing.T) { workDir := t.TempDir() chdirForTest(t, workDir) @@ -355,14 +338,14 @@ func TestValidateSafePath_RejectsDanglingSymlink(t *testing.T) { t.Fatalf("Symlink: %v", err) } - if err := ValidateSafePath(&localfileio.LocalFileIO{}, "dangling"); err == nil { + if err := ValidateSafePathTyped(&localfileio.LocalFileIO{}, "dangling"); err == nil { t.Fatal("expected error for dangling symlink, got nil") } } -// TestValidateSafePath_AllowsNormalSubdir verifies that an existing real +// TestValidateSafePathTyped_AllowsNormalSubdir verifies that an existing real // subdirectory within CWD is accepted. -func TestValidateSafePath_AllowsNormalSubdir(t *testing.T) { +func TestValidateSafePathTyped_AllowsNormalSubdir(t *testing.T) { workDir := t.TempDir() chdirForTest(t, workDir) @@ -371,22 +354,11 @@ func TestValidateSafePath_AllowsNormalSubdir(t *testing.T) { t.Fatalf("Mkdir: %v", err) } - if err := ValidateSafePath(&localfileio.LocalFileIO{}, "output"); err != nil { + if err := ValidateSafePathTyped(&localfileio.LocalFileIO{}, "output"); err != nil { t.Fatalf("expected no error for real subdir, got: %v", err) } } -// TestValidateSafePath_AllowsNonExistentPath verifies that a path that -// does not yet exist (new output directory) is accepted. -func TestValidateSafePath_AllowsNonExistentPath(t *testing.T) { - workDir := t.TempDir() - chdirForTest(t, workDir) - - if err := ValidateSafePath(&localfileio.LocalFileIO{}, "new_output_dir"); err != nil { - t.Fatalf("expected no error for non-existent path, got: %v", err) - } -} - // TestValidateSafePathTyped_ReturnsTypedValidation verifies that an escaping // path is rejected with a typed validation error and a safe path passes. func TestValidateSafePathTyped_ReturnsTypedValidation(t *testing.T) { diff --git a/shortcuts/doc/doc_media_test.go b/shortcuts/doc/doc_media_test.go index ffbcf5d49..b7495dd83 100644 --- a/shortcuts/doc/doc_media_test.go +++ b/shortcuts/doc/doc_media_test.go @@ -249,7 +249,7 @@ func TestDocMediaInsertDryRunUsesMultipartForLargeFile(t *testing.T) { func TestUploadDocMediaFileWithContentUsesSinglePartUpload(t *testing.T) { // Clipboard path: in-memory bytes (no FilePath) route through - // UploadDriveMediaAll when small enough. This also exercises the + // UploadDriveMediaAllTyped when small enough. This also exercises the // drive_route_token extra built from docID. f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-upload-content-app")) uploadStub := &httpmock.Stub{ @@ -292,7 +292,7 @@ func TestUploadDocMediaFileWithContentUsesSinglePartUpload(t *testing.T) { } func TestUploadDocMediaFileWithContentUsesMultipart(t *testing.T) { - // Clipboard path: in-memory bytes route through UploadDriveMediaMultipart + // Clipboard path: in-memory bytes route through UploadDriveMediaMultipartTyped // when size exceeds the single-part threshold. f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-upload-content-multi")) reg.Register(&httpmock.Stub{ diff --git a/shortcuts/doc/doc_media_upload.go b/shortcuts/doc/doc_media_upload.go index 449dbb40b..67ef5341a 100644 --- a/shortcuts/doc/doc_media_upload.go +++ b/shortcuts/doc/doc_media_upload.go @@ -153,7 +153,7 @@ func uploadDocMediaFile(runtime *common.RuntimeContext, cfg UploadDocMediaFileCo // Doc media uploads share the generic Drive media transport. The doc-specific // routing only shows up in parent_type/parent_node and optional route extra. if cfg.FileSize <= common.MaxDriveMediaUploadSinglePartSize { - return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ + return common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{ FilePath: cfg.FilePath, Reader: cfg.Reader, FileName: cfg.FileName, @@ -163,7 +163,7 @@ func uploadDocMediaFile(runtime *common.RuntimeContext, cfg UploadDocMediaFileCo Extra: extra, }) } - return common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{ + return common.UploadDriveMediaMultipartTyped(runtime, common.DriveMediaMultipartUploadConfig{ FilePath: cfg.FilePath, Reader: cfg.Reader, FileName: cfg.FileName, diff --git a/shortcuts/drive/drive_add_comment_test.go b/shortcuts/drive/drive_add_comment_test.go index 5d39d5d34..533825810 100644 --- a/shortcuts/drive/drive_add_comment_test.go +++ b/shortcuts/drive/drive_add_comment_test.go @@ -16,8 +16,8 @@ import ( // assertContentValidationHint asserts err is a typed *errs.ValidationError // carrying SubtypeInvalidArgument, Param "--content", and a Hint containing -// the given substring. The over-cap message now flows through a typed -// ValidationError instead of the legacy *output.ExitError.Detail shape. +// the given substring. The over-cap message flows through a typed +// ValidationError. func assertContentValidationHint(t *testing.T, err error, wantHint string) { t.Helper() var valErr *errs.ValidationError diff --git a/shortcuts/drive/drive_errors.go b/shortcuts/drive/drive_errors.go index 4184d158b..a49137989 100644 --- a/shortcuts/drive/drive_errors.go +++ b/shortcuts/drive/drive_errors.go @@ -9,7 +9,6 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/output" ) // wrapDriveNetworkErr returns err unchanged when it is already a typed errs.* @@ -55,8 +54,8 @@ func driveSaveError(err error) error { } // appendDriveExportRecoveryHint attaches a recovery hint to err while preserving -// its original classification (typed subtype/code or legacy detail), only falling -// back to a typed internal error when err is unclassified. +// its original classification (typed subtype/code), only falling back to a typed +// internal error when err is unclassified. func appendDriveExportRecoveryHint(err error, hint string) error { if err == nil { return nil @@ -73,17 +72,5 @@ func appendDriveExportRecoveryHint(err error, hint string) error { } return err } - // Legacy *output.ExitError fallback: preserve the original error's - // class/exit code by appending the hint in place rather than downgrading - // to api/server_error. - var exitErr *output.ExitError - if errors.As(err, &exitErr) && exitErr.Detail != nil { - if strings.TrimSpace(exitErr.Detail.Hint) != "" { - exitErr.Detail.Hint = exitErr.Detail.Hint + "\n" + hint - } else { - exitErr.Detail.Hint = hint - } - return err - } return errs.NewInternalError(errs.SubtypeSDKError, "%s", err.Error()).WithHint(hint).WithCause(err) } diff --git a/shortcuts/drive/drive_import_common.go b/shortcuts/drive/drive_import_common.go index 25340915d..daea5e050 100644 --- a/shortcuts/drive/drive_import_common.go +++ b/shortcuts/drive/drive_import_common.go @@ -112,7 +112,7 @@ func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, f fmt.Fprintf(runtime.IO().ErrOut, "Uploading media for import: %s (%s)\n", fileName, common.FormatSize(fileSize)) // upload_all for import works without parent_node; omitting it preserves // the existing root-level import staging behavior. - return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ + return common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{ FilePath: filePath, FileName: fileName, FileSize: fileSize, @@ -124,7 +124,7 @@ func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, f fmt.Fprintf(runtime.IO().ErrOut, "Uploading media for import via multipart upload: %s (%s)\n", fileName, common.FormatSize(fileSize)) // upload_prepare is stricter than upload_all here and expects parent_node to // be sent explicitly, even when import uses the implicit root staging area. - return common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{ + return common.UploadDriveMediaMultipartTyped(runtime, common.DriveMediaMultipartUploadConfig{ FilePath: filePath, FileName: fileName, FileSize: fileSize, diff --git a/shortcuts/drive/drive_push.go b/shortcuts/drive/drive_push.go index 63bbf7c3d..351ef5601 100644 --- a/shortcuts/drive/drive_push.go +++ b/shortcuts/drive/drive_push.go @@ -5,7 +5,6 @@ package drive import ( "context" - "errors" "fmt" "io" "io/fs" @@ -19,7 +18,6 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -640,8 +638,7 @@ func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file Body: fd, }, larkcore.WithFileUpload()) if err != nil { - var exitErr *output.ExitError - if errors.As(err, &exitErr) { + if errs.IsTyped(err) { return "", "", err } return "", "", wrapDriveNetworkErr(err, "upload failed: %v", err) @@ -735,8 +732,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext, Body: fd, }, larkcore.WithFileUpload()) if doErr != nil { - var exitErr *output.ExitError - if errors.As(doErr, &exitErr) { + if errs.IsTyped(doErr) { return "", doErr } return "", wrapDriveNetworkErr(doErr, "upload part %d/%d failed: %v", seq+1, blockNum, doErr) diff --git a/shortcuts/drive/drive_upload.go b/shortcuts/drive/drive_upload.go index f74220bc5..5d2763b45 100644 --- a/shortcuts/drive/drive_upload.go +++ b/shortcuts/drive/drive_upload.go @@ -5,7 +5,6 @@ package drive import ( "context" - "errors" "fmt" "io" "net/http" @@ -15,7 +14,6 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -261,8 +259,7 @@ func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, file Body: fd, }, larkcore.WithFileUpload()) if err != nil { - var exitErr *output.ExitError - if errors.As(err, &exitErr) { + if errs.IsTyped(err) { return driveUploadResult{}, err } return driveUploadResult{}, wrapDriveNetworkErr(err, "upload failed: %v", err) @@ -343,8 +340,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file }, larkcore.WithFileUpload()) partFile.Close() if err != nil { - var exitErr *output.ExitError - if errors.As(err, &exitErr) { + if errs.IsTyped(err) { return driveUploadResult{}, err } return driveUploadResult{}, wrapDriveNetworkErr(err, "upload part %d/%d failed: %v", seq+1, blockNum, err) diff --git a/shortcuts/mail/flag_suggest.go b/shortcuts/mail/flag_suggest.go index 1f3c1a5b0..b0538bbcc 100644 --- a/shortcuts/mail/flag_suggest.go +++ b/shortcuts/mail/flag_suggest.go @@ -233,7 +233,7 @@ func suggestShorthand(c string, names []flagName) []Candidate { return out } -// buildHint returns a one-line hint suitable for the ErrorEnvelope. +// buildHint returns a one-line hint suitable for a typed error's Hint field. // When at least one candidate exists, the top hit is named; otherwise // the user is directed to --help. func buildHint(c *cobra.Command, matches []Candidate) string { diff --git a/shortcuts/minutes/minutes_download_test.go b/shortcuts/minutes/minutes_download_test.go index 3a97ea629..38428e7bc 100644 --- a/shortcuts/minutes/minutes_download_test.go +++ b/shortcuts/minutes/minutes_download_test.go @@ -43,7 +43,7 @@ func warmTokenCache(t *testing.T) { Command: "+warm", AuthTypes: []string{"bot"}, Execute: func(_ context.Context, rctx *common.RuntimeContext) error { - _, err := rctx.CallAPI("GET", "/open-apis/test/v1/warm", nil, nil) + _, err := rctx.CallAPITyped("GET", "/open-apis/test/v1/warm", nil, nil) return err }, } diff --git a/shortcuts/register.go b/shortcuts/register.go index 894842782..2fd445829 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -11,11 +11,11 @@ import ( "github.com/larksuite/cli/shortcuts/okr" "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/deprecation" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/shortcuts/apps" "github.com/larksuite/cli/shortcuts/base" @@ -175,7 +175,7 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f func installBrandRestrictionGuard(svc *cobra.Command, service string, brand core.LarkBrand) { stub := func(c *cobra.Command, _ []string) error { c.SilenceUsage = true - return output.ErrValidation( + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "the %q feature is not yet supported on the %s brand", service, brand, ) diff --git a/shortcuts/register_brand_guard_test.go b/shortcuts/register_brand_guard_test.go index d3a67e191..4e87a4d91 100644 --- a/shortcuts/register_brand_guard_test.go +++ b/shortcuts/register_brand_guard_test.go @@ -5,14 +5,15 @@ package shortcuts import ( "context" + "errors" "strings" "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/output" ) func newFactoryWithBrand(brand core.LarkBrand) *cmdutil.Factory { @@ -70,15 +71,15 @@ func TestBrandGuard_AppsExecuteReturnsBrandError(t *testing.T) { if err == nil { t.Fatal("expected brand-restriction error, got nil") } - exitErr, ok := err.(*output.ExitError) - if !ok { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) } - if exitErr.Code != output.ExitValidation { - t.Errorf("expected ExitValidation (%d), got %d", output.ExitValidation, exitErr.Code) + if validationErr.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("expected subtype %q, got %q", errs.SubtypeFailedPrecondition, validationErr.Subtype) } - if !strings.Contains(exitErr.Error(), "apps") || !strings.Contains(exitErr.Error(), "lark") { - t.Errorf("expected error to mention apps + lark, got: %s", exitErr.Error()) + if !strings.Contains(validationErr.Error(), "apps") || !strings.Contains(validationErr.Error(), "lark") { + t.Errorf("expected error to mention apps + lark, got: %s", validationErr.Error()) } } @@ -112,11 +113,11 @@ func TestBrandGuard_DispatchHitsStubViaCobra(t *testing.T) { if err == nil { t.Fatal("expected error from dispatching apps +create on Lark brand") } - exitErr, ok := err.(*output.ExitError) - if !ok { - t.Fatalf("expected *output.ExitError from cobra dispatch, got %T: %v", err, err) + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError from cobra dispatch, got %T: %v", err, err) } - if !strings.Contains(exitErr.Error(), "lark") { - t.Errorf("dispatched error should mention lark brand, got: %s", exitErr.Error()) + if !strings.Contains(validationErr.Error(), "lark") { + t.Errorf("dispatched error should mention lark brand, got: %s", validationErr.Error()) } } diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 52ed75645..04d2e40fd 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -19,7 +19,6 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/deprecation" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) @@ -447,10 +446,9 @@ func TestRegisterShortcutsLeavesNonMailFlagErrorUntouched(t *testing.T) { in := errors.New("unknown flag: --bogus") got := baseCmd.FlagErrorFunc()(baseCmd, in) // Default cobra hook is identity — anything else means the mail hook - // leaked across domains. - var exitErr *output.ExitError - if errors.As(got, &exitErr) { - t.Fatalf("base service unexpectedly produced *output.ExitError: %#v", exitErr) + // (which wraps into a typed *errs.ValidationError) leaked across domains. + if errs.IsTyped(got) { + t.Fatalf("base service unexpectedly produced a typed error: %#v", got) } if got != in { t.Fatalf("base service should pass through original error pointer, got %T (%v)", got, got) diff --git a/shortcuts/sheets/backward/lark_sheets_float_images.go b/shortcuts/sheets/backward/lark_sheets_float_images.go index a06cb7f20..b1c62331f 100644 --- a/shortcuts/sheets/backward/lark_sheets_float_images.go +++ b/shortcuts/sheets/backward/lark_sheets_float_images.go @@ -143,7 +143,7 @@ func resolveSheetMediaUploadParent(runtime *common.RuntimeContext) (string, erro func uploadSheetMediaFile(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, parentNode string) (string, error) { if fileSize <= common.MaxDriveMediaUploadSinglePartSize { pn := parentNode - return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ + return common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{ FilePath: filePath, FileName: fileName, FileSize: fileSize, @@ -151,7 +151,7 @@ func uploadSheetMediaFile(runtime *common.RuntimeContext, filePath, fileName str ParentNode: &pn, }) } - return common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{ + return common.UploadDriveMediaMultipartTyped(runtime, common.DriveMediaMultipartUploadConfig{ FilePath: filePath, FileName: fileName, FileSize: fileSize, diff --git a/shortcuts/sheets/flag_schema_test.go b/shortcuts/sheets/flag_schema_test.go index 69e882da4..0310e0374 100644 --- a/shortcuts/sheets/flag_schema_test.go +++ b/shortcuts/sheets/flag_schema_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" ) // TestFlagSchemas_EmbedParses asserts the synced flag-schemas.json @@ -175,9 +175,9 @@ func TestPrintSchema_SystemFlagAbsentForReadOnlyShortcut(t *testing.T) { } // TestPrintSchema_UnknownFlagNameIsStructured pins issue #6: an unregistered -// --flag-name passed to --print-schema must surface as a structured -// *output.ExitError (type print_schema_error), not a bare error string, so the -// agent-facing introspection path stays machine-parseable. +// --flag-name passed to --print-schema must surface as a typed +// *errs.ValidationError, not a bare error string, so the agent-facing +// introspection path stays machine-parseable. func TestPrintSchema_UnknownFlagNameIsStructured(t *testing.T) { t.Parallel() // PrintFlagSchema is wired during registration (shortcuts.go), not on the @@ -191,12 +191,9 @@ func TestPrintSchema_UnknownFlagNameIsStructured(t *testing.T) { if err == nil { t.Fatal("expected an error for --print-schema with an unregistered flag name") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("error type = %T, want a structured *output.ExitError", err) - } - if exitErr.Detail == nil || exitErr.Detail.Type != "print_schema_error" { - t.Errorf("error detail = %+v, want type print_schema_error", exitErr.Detail) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("error type = %T, want a typed *errs.ValidationError", err) } } diff --git a/shortcuts/sheets/lark_sheet_object_crud.go b/shortcuts/sheets/lark_sheet_object_crud.go index 410be3d87..5cad2d1b6 100644 --- a/shortcuts/sheets/lark_sheet_object_crud.go +++ b/shortcuts/sheets/lark_sheet_object_crud.go @@ -774,7 +774,7 @@ func uploadFloatImageIfLocal(runtime *common.RuntimeContext, spreadsheetToken st if err != nil { return "", sheetsInputStatError("image", err) } - return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ + return common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{ FilePath: img, FileName: floatImageName(runtime), FileSize: info.Size(), diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index 09030e506..320f65e5e 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -829,7 +829,7 @@ var CellsSetImage = common.Shortcut{ WithParam("--image"). WithCause(err) } - fileToken, err := common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ + fileToken, err := common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{ FilePath: imgPath, FileName: fileName, FileSize: info.Size(), diff --git a/shortcuts/slides/slides_media_upload.go b/shortcuts/slides/slides_media_upload.go index 8834cd0a9..bd611f177 100644 --- a/shortcuts/slides/slides_media_upload.go +++ b/shortcuts/slides/slides_media_upload.go @@ -128,7 +128,7 @@ func uploadSlidesMedia(runtime *common.RuntimeContext, filePath, fileName string fileName, common.FormatSize(fileSize)) } parent := presentationID - return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ + return common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{ FilePath: filePath, FileName: fileName, FileSize: fileSize, diff --git a/shortcuts/task/task_shortcut_test.go b/shortcuts/task/task_shortcut_test.go index 4d8d7465d..de0c35b99 100644 --- a/shortcuts/task/task_shortcut_test.go +++ b/shortcuts/task/task_shortcut_test.go @@ -45,7 +45,7 @@ func warmTenantToken(t *testing.T, f *cmdutil.Factory, reg *httpmock.Registry) { Command: "+warm-token", AuthTypes: []string{"bot"}, Execute: func(_ context.Context, rctx *common.RuntimeContext) error { - _, err := rctx.CallAPI("GET", "/open-apis/test/v1/warm", nil, nil) + _, err := rctx.CallAPITyped("GET", "/open-apis/test/v1/warm", nil, nil) return err }, } diff --git a/shortcuts/task/tasklist_add_task_test.go b/shortcuts/task/tasklist_add_task_test.go index c300e438d..5a19c6d23 100644 --- a/shortcuts/task/tasklist_add_task_test.go +++ b/shortcuts/task/tasklist_add_task_test.go @@ -50,8 +50,7 @@ func TestAddTaskToTasklist_Success(t *testing.T) { // land in stdout as an ok:false envelope, and the command returns the typed // partial-failure exit signal (exit 1) via runtime.OutPartialFailure. The // failed_tasks[].type carries the typed subtype (e.g. "permission_denied", -// "not_found") read off errs.ProblemOf, not the legacy -// *output.ExitError.Detail.Type ("permission_error" etc). +// "not_found") read off errs.ProblemOf. func TestAddTaskToTasklist_PartialFailure(t *testing.T) { f, stdout, _, reg := taskShortcutTestFactory(t) warmTenantToken(t, f, reg) diff --git a/shortcuts/vc/vc_notes_test.go b/shortcuts/vc/vc_notes_test.go index 14f281a86..a5b55ca6f 100644 --- a/shortcuts/vc/vc_notes_test.go +++ b/shortcuts/vc/vc_notes_test.go @@ -45,7 +45,7 @@ func warmTokenCache(t *testing.T) { Command: "+warm", AuthTypes: []string{"bot"}, Execute: func(_ context.Context, rctx *common.RuntimeContext) error { - _, err := rctx.CallAPI("GET", "/open-apis/test/v1/warm", nil, nil) + _, err := rctx.CallAPITyped("GET", "/open-apis/test/v1/warm", nil, nil) return err }, } diff --git a/tests/cli_e2e/apps/apps_access_scope_get_dryrun_test.go b/tests/cli_e2e/apps/apps_access_scope_get_dryrun_test.go index 9cd03d349..f22aaa960 100644 --- a/tests/cli_e2e/apps/apps_access_scope_get_dryrun_test.go +++ b/tests/cli_e2e/apps/apps_access_scope_get_dryrun_test.go @@ -51,10 +51,7 @@ func TestAppsAccessScopeGetDryRun(t *testing.T) { DefaultAs: "user", }) require.NoError(t, err) - // cobra Required failures exit with code 1 (distinct from output.ErrValidation - // at code 2). Message goes to stderr as plain text, but we read combined output - // to stay robust to future runner changes. - result.AssertExitCode(t, 1) - assert.Contains(t, result.Stdout+result.Stderr, `required flag(s) "app-id" not set`) + result.AssertExitCode(t, 2) + assert.Contains(t, validateErrorMessage(result), `required flag(s) "app-id" not set`) }) } diff --git a/tests/cli_e2e/apps/apps_create_dryrun_test.go b/tests/cli_e2e/apps/apps_create_dryrun_test.go index a66ce5b61..49857d8a5 100644 --- a/tests/cli_e2e/apps/apps_create_dryrun_test.go +++ b/tests/cli_e2e/apps/apps_create_dryrun_test.go @@ -82,11 +82,8 @@ func TestAppsCreateDryRun(t *testing.T) { DefaultAs: "user", }) require.NoError(t, err) - // cobra Required failures exit with code 1 (distinct from output.ErrValidation - // at code 2). Message goes to stderr as plain text, but we read combined output - // to stay robust to future runner changes. - result.AssertExitCode(t, 1) - assert.Contains(t, result.Stdout+result.Stderr, `required flag(s) "name" not set`) + result.AssertExitCode(t, 2) + assert.Contains(t, validateErrorMessage(result), `required flag(s) "name" not set`) }) t.Run("RejectsBlankName", func(t *testing.T) { @@ -121,8 +118,8 @@ func TestAppsCreateDryRun(t *testing.T) { DefaultAs: "user", }) require.NoError(t, err) - result.AssertExitCode(t, 1) - assert.Contains(t, result.Stdout+result.Stderr, `required flag(s) "app-type" not set`) + result.AssertExitCode(t, 2) + assert.Contains(t, validateErrorMessage(result), `required flag(s) "app-type" not set`) }) t.Run("RejectsInvalidAppType", func(t *testing.T) { diff --git a/tests/cli_e2e/apps/apps_html_publish_dryrun_test.go b/tests/cli_e2e/apps/apps_html_publish_dryrun_test.go index 367959dbc..124204cb0 100644 --- a/tests/cli_e2e/apps/apps_html_publish_dryrun_test.go +++ b/tests/cli_e2e/apps/apps_html_publish_dryrun_test.go @@ -194,8 +194,8 @@ func TestAppsHTMLPublishDryRun(t *testing.T) { WorkDir: dir, }) require.NoError(t, err) - result.AssertExitCode(t, 1) - assert.Contains(t, result.Stdout+result.Stderr, `required flag(s) "app-id" not set`) + result.AssertExitCode(t, 2) + assert.Contains(t, validateErrorMessage(result), `required flag(s) "app-id" not set`) }) t.Run("RejectsMissingPath", func(t *testing.T) { @@ -211,8 +211,8 @@ func TestAppsHTMLPublishDryRun(t *testing.T) { DefaultAs: "user", }) require.NoError(t, err) - result.AssertExitCode(t, 1) - assert.Contains(t, result.Stdout+result.Stderr, `required flag(s) "path" not set`) + result.AssertExitCode(t, 2) + assert.Contains(t, validateErrorMessage(result), `required flag(s) "path" not set`) }) t.Run("RejectsSensitivePathsByDefault", func(t *testing.T) { diff --git a/tests/cli_e2e/apps/apps_update_dryrun_test.go b/tests/cli_e2e/apps/apps_update_dryrun_test.go index e644dd72f..dc5d535c2 100644 --- a/tests/cli_e2e/apps/apps_update_dryrun_test.go +++ b/tests/cli_e2e/apps/apps_update_dryrun_test.go @@ -76,8 +76,8 @@ func TestAppsUpdateDryRun(t *testing.T) { DefaultAs: "user", }) require.NoError(t, err) - result.AssertExitCode(t, 1) - assert.Contains(t, result.Stdout+result.Stderr, `required flag(s) "app-id" not set`) + result.AssertExitCode(t, 2) + assert.Contains(t, validateErrorMessage(result), `required flag(s) "app-id" not set`) }) t.Run("RejectsNoFields", func(t *testing.T) { diff --git a/tests/cli_e2e/config/bind_test.go b/tests/cli_e2e/config/bind_test.go index d66749df5..298001ea3 100644 --- a/tests/cli_e2e/config/bind_test.go +++ b/tests/cli_e2e/config/bind_test.go @@ -125,8 +125,8 @@ func TestBind_MissingSource_NonTTY(t *testing.T) { }) require.NoError(t, err) // finalizeSource emits a CategoryValidation typed error - // (subtype=invalid_argument, param=--source); this path never goes - // through *core.ConfigError so PromoteConfigError does not apply. + // (subtype=invalid_argument, param=--source); this is a distinct path + // from the typed config errors below. assertStderrError(t, result, 2, "validation", "cannot determine Agent source: no --source flag and no Agent environment detected", "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context") @@ -184,8 +184,8 @@ func TestBind_Hermes_MissingEnvFile(t *testing.T) { Args: []string{"config", "bind", "--source", "hermes"}, }) require.NoError(t, err) - // PromoteConfigError flattens *core.ConfigError{Type:"hermes"} to - // wire error.type="config"; CategoryConfig → exit 3. + // The hermes config error is constructed typed at its origin with + // subtype=not_configured; CategoryConfig → exit 3. assertStderrError(t, result, 3, "config", "failed to read Hermes config: open "+envPath+": no such file or directory", "verify Hermes is installed and configured at "+envPath) @@ -210,8 +210,8 @@ func TestBind_Hermes_MissingAppID(t *testing.T) { Args: []string{"config", "bind", "--source", "hermes"}, }) require.NoError(t, err) - // PromoteConfigError flattens *core.ConfigError{Type:"hermes"} to - // wire error.type="config"; CategoryConfig → exit 3. + // The hermes config error is constructed typed at its origin with + // subtype=not_configured; CategoryConfig → exit 3. assertStderrError(t, result, 3, "config", "FEISHU_APP_ID not found in "+envPath, "run 'hermes setup' to configure Feishu credentials") @@ -290,8 +290,8 @@ func TestBind_ConfigShow_UnboundWorkspace(t *testing.T) { Args: []string{"config", "show"}, }) require.NoError(t, err) - // PromoteConfigError flattens *core.ConfigError{Type:"openclaw"} to - // wire error.type="config"; CategoryConfig → exit 3. + // The openclaw config error is constructed typed at its origin with + // subtype=not_configured; CategoryConfig → exit 3. assertStderrError(t, result, 3, "config", "openclaw context detected but lark-cli is not bound to it", "read `lark-cli config bind --help`, then ask the user to confirm intent and identity preset (bot-only or user-default); only after both are confirmed, run `lark-cli config bind`") @@ -312,8 +312,8 @@ func TestBind_OpenClaw_MissingFile(t *testing.T) { Args: []string{"config", "bind", "--source", "openclaw"}, }) require.NoError(t, err) - // PromoteConfigError flattens *core.ConfigError{Type:"openclaw"} to - // wire error.type="config"; CategoryConfig → exit 3. + // The openclaw config error is constructed typed at its origin with + // subtype=not_configured; CategoryConfig → exit 3. assertStderrError(t, result, 3, "config", "cannot read "+configPath+": open "+configPath+": no such file or directory", "verify OpenClaw is installed and configured") @@ -414,8 +414,8 @@ func TestBind_LarkChannel_MissingFile(t *testing.T) { Args: []string{"config", "bind", "--source", "lark-channel"}, }) require.NoError(t, err) - // PromoteConfigError flattens *core.ConfigError{Type:"lark-channel"} to - // wire error.type="config"; CategoryConfig → exit 3. + // The lark-channel config error is constructed typed at its origin with + // subtype=not_configured; CategoryConfig → exit 3. assertStderrError(t, result, 3, "config", "cannot read "+configPath+": open "+configPath+": no such file or directory", "verify lark-channel-bridge is installed and configured") diff --git a/tests/cli_e2e/drive/drive_push_dryrun_test.go b/tests/cli_e2e/drive/drive_push_dryrun_test.go index 7533b7999..8e5186019 100644 --- a/tests/cli_e2e/drive/drive_push_dryrun_test.go +++ b/tests/cli_e2e/drive/drive_push_dryrun_test.go @@ -226,15 +226,11 @@ func TestDrive_PushDryRunRejectsMissingFolderToken(t *testing.T) { DefaultAs: "user", }) require.NoError(t, err) - // This is a cobra-level required-flag check that fires BEFORE our - // Validate callback, so the exit code is cobra's generic flag-error - // (1) — distinct from ExitValidation (2). Asserting the exact code - // pins which layer rejected the run, which matters because a - // regression that pushed required-flag validation into our own - // Validate (changing the exit class to 2) would silently slip - // through a loose `!= 0` check. - if result.ExitCode != 1 { - t.Fatalf("missing --folder-token must be rejected with exit=1 (cobra required-flag), got exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr) + // A missing cobra required-flag is routed through the typed validation + // envelope (exit 2, invalid_argument) — the same class as the explicit + // flag/subcommand guards, not cobra's plain-text exit 1. + if result.ExitCode != 2 { + t.Fatalf("missing --folder-token must be rejected with exit=2 (typed validation), got exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr) } combined := result.Stdout + "\n" + result.Stderr if !strings.Contains(combined, "folder-token") {