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 4b7e27c3d..4971151ad 100644 --- a/cmd/event/consume.go +++ b/cmd/event/consume.go @@ -349,9 +349,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 040b59b38..08abc08c1 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 3d727c504..c4f06d3f8 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) } @@ -156,7 +157,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 525051359..82b4888bb 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") {