mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
8 Commits
feat/calen
...
v1.0.56
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bba13cfe0f | ||
|
|
815cdb8f1c | ||
|
|
4f3ae0c71a | ||
|
|
96d70143c5 | ||
|
|
83db15907f | ||
|
|
1f2164c7c2 | ||
|
|
76f5419a0d | ||
|
|
c5b5aece33 |
@@ -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/
|
||||
@@ -77,25 +84,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:
|
||||
@@ -114,22 +110,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: >-
|
||||
|
||||
24
CHANGELOG.md
24
CHANGELOG.md
@@ -2,6 +2,29 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.56] - 2026-06-18
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Add `+session-messages-list` for session turn reply messages (#1402)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **api**: Align API success envelopes (#1489)
|
||||
- **base**: Reject out-of-range pagination flags (#1495)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Retire legacy error envelopes and enforce typed contract (#1449)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Soften lark-doc style guidance (#1463)
|
||||
|
||||
### Build
|
||||
|
||||
- Add CI quality gate with semantic review
|
||||
|
||||
## [v1.0.55] - 2026-06-16
|
||||
|
||||
### Features
|
||||
@@ -1189,6 +1212,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
|
||||
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
|
||||
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
|
||||
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
|
||||
|
||||
@@ -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
|
||||
@@ -243,7 +259,7 @@ func apiRun(opts *APIOptions) error {
|
||||
// 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)
|
||||
return errs.MarkRaw(err)
|
||||
}
|
||||
err = client.HandleResponse(resp, client.ResponseOptions{
|
||||
OutputPath: opts.Output,
|
||||
@@ -263,7 +279,7 @@ func apiRun(opts *APIOptions) error {
|
||||
// MarkRaw: see comment above on the DoAPI path. Skips legacy
|
||||
// *ExitError enrichment; typed errors flow through unchanged.
|
||||
if err != nil {
|
||||
return output.MarkRaw(err)
|
||||
return errs.MarkRaw(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -280,11 +296,11 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
|
||||
if jqExpr != "" {
|
||||
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)
|
||||
}
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
@@ -313,10 +329,10 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
|
||||
return nil
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return output.MarkRaw(err)
|
||||
return errs.MarkRaw(err)
|
||||
}
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
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)
|
||||
@@ -331,11 +347,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)
|
||||
}
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 <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",
|
||||
|
||||
@@ -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 <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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -386,9 +386,9 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
|
||||
|
||||
// Sentinels for errors.Is checks; call sites wrap them as typed ValidationError causes.
|
||||
var (
|
||||
errInvalidParamFormat = errors.New("invalid --param format")
|
||||
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
|
||||
errOutputDirUnsafe = errors.New("unsafe --output-dir")
|
||||
errInvalidParamFormat = errors.New("invalid --param format") //nolint:forbidigo // sentinel, typed at call sites
|
||||
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion") //nolint:forbidigo // sentinel, typed at call sites
|
||||
errOutputDirUnsafe = errors.New("unsafe --output-dir") //nolint:forbidigo // sentinel, typed at call sites
|
||||
)
|
||||
|
||||
func parseParams(raw []string) (map[string]string, error) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,65 @@ 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 ranked candidate rides on the param as a machine-readable suggestion
|
||||
// so an agent can retry without parsing prose.
|
||||
if len(verr.Params) == 1 {
|
||||
found := false
|
||||
for _, s := range verr.Params[0].Suggestions {
|
||||
if s == "--range" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Params[0].Suggestions should include --range, got %v", verr.Params[0].Suggestions)
|
||||
}
|
||||
}
|
||||
// The same candidate is also carried in the human-facing hint.
|
||||
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)
|
||||
}
|
||||
if exitErr.Detail.Type != "flag_error" {
|
||||
t.Errorf("type = %q, want flag_error (non-unknown-flag errors stay generic)", exitErr.Detail.Type)
|
||||
// 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 strings.Contains(verr.Hint, "did you mean") {
|
||||
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 exitErr.Detail.Type != "plugin_conflict" {
|
||||
t.Errorf("envelope type = %q, want plugin_conflict", 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 != "multiple_restrict_plugins" {
|
||||
t.Errorf("reason_code = %v, want multiple_restrict_plugins", rc)
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
// 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 exitErr.Detail.Type != "plugin_lifecycle" {
|
||||
t.Errorf("envelope type = %q, want plugin_lifecycle", 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"] != "lifecycle_failed" {
|
||||
t.Errorf("reason_code = %v, want lifecycle_failed", 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"] != "lc.start" {
|
||||
t.Errorf("hook_name = %v, want lc.start", d["hook_name"])
|
||||
// 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 !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 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"] != "fac.bad-factory" {
|
||||
t.Errorf("hook_name = %v, want fac.bad-factory (namespaced)", d["hook_name"])
|
||||
// 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 !strings.Contains(verr.Message, "panic") {
|
||||
t.Errorf("message should describe the panic, got %q", verr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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 exitErr.Code != output.ExitInternal {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
|
||||
if internalErr.Subtype != errs.SubtypeStorage {
|
||||
t.Fatalf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeStorage)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "internal" {
|
||||
t.Fatalf("detail = %#v, want internal detail", exitErr.Detail)
|
||||
if internalErr.Cause == nil {
|
||||
t.Fatalf("cause = nil, want wrapped underlying error")
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, wantMsg) {
|
||||
t.Fatalf("message = %q, want contains %q", exitErr.Detail.Message, wantMsg)
|
||||
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 err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>")
|
||||
}
|
||||
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))
|
||||
|
||||
27
cmd/prune.go
27
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
348
cmd/root.go
348
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,63 @@ 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)
|
||||
// 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
|
||||
}
|
||||
|
||||
// Errors reaching here are untyped: every RunE returns a typed errs.* error
|
||||
// and flag-parse errors are typed by the root FlagErrorFunc. The remainder
|
||||
// is either a cobra usage mistake (missing required flag, unknown command,
|
||||
// wrong arg count), which cobra surfaces as a plain error identified by its
|
||||
// stable text — the same external contract unknownFlagName relies on — or an
|
||||
// untyped error that leaked past the typed boundary. Classify the former as
|
||||
// invalid_argument (exit 2, like the explicit guards); treat the latter as an
|
||||
// internal fault (exit 5) rather than blaming the user's input. The message
|
||||
// is preserved either way, and the typed envelope still carries any pending
|
||||
// deprecation notice.
|
||||
var fallback error
|
||||
if isCobraUsageError(err) {
|
||||
fallback = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error())
|
||||
} else {
|
||||
fallback = errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err)
|
||||
}
|
||||
output.WriteTypedErrorEnvelope(errOut, fallback, string(f.ResolvedIdentity))
|
||||
return output.ExitCodeOf(fallback)
|
||||
}
|
||||
|
||||
// cobraUsageErrorMarkers are the stable error-text fragments cobra / pflag
|
||||
// (pinned at v1.10.2) emit for usage mistakes — missing required flag, unknown
|
||||
// command / flag, wrong argument count. Cobra surfaces these as plain errors,
|
||||
// not a typed value we can match on, so the dispatcher recognizes them by text;
|
||||
// this is the same external contract unknownFlagName already depends on. A
|
||||
// residual error matching none of these has leaked the typed boundary and is
|
||||
// treated as an internal fault, not a user error.
|
||||
var cobraUsageErrorMarkers = []string{
|
||||
"unknown command ",
|
||||
"unknown flag: ",
|
||||
"unknown shorthand",
|
||||
"required flag(s) ",
|
||||
"flag needs an argument",
|
||||
"bad flag syntax:",
|
||||
"no such flag ",
|
||||
"invalid argument ",
|
||||
"arg(s), ", // accepts / requires N arg(s), received / only received M
|
||||
}
|
||||
|
||||
// isCobraUsageError reports whether err is a cobra / pflag usage mistake,
|
||||
// identified by the stable error text of the pinned cobra version.
|
||||
func isCobraUsageError(err error) bool {
|
||||
msg := err.Error()
|
||||
for _, m := range cobraUsageErrorMarkers {
|
||||
if strings.Contains(msg, m) {
|
||||
return true
|
||||
}
|
||||
output.WriteErrorEnvelope(errOut, exitErr, string(f.ResolvedIdentity))
|
||||
return exitErr.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
|
||||
return false
|
||||
}
|
||||
|
||||
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
|
||||
@@ -361,13 +342,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 +361,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 +379,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 +399,12 @@ 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,
|
||||
},
|
||||
}
|
||||
// Record the offending subcommand and its ranked candidates as a param with
|
||||
// machine-readable Suggestions so an agent can retry without parsing the
|
||||
// hint; the hint carries the same candidates as prose.
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
|
||||
WithParams(errs.InvalidParam{Name: unknown, Reason: "unknown subcommand", Suggestions: suggestions}).
|
||||
WithHint("%s", hint)
|
||||
}
|
||||
|
||||
// flagTokensInArgs returns the flag-like tokens (-x, --foo, --foo=bar) in
|
||||
@@ -588,47 +530,34 @@ 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)
|
||||
for i := range suggestions {
|
||||
suggestions[i] = "--" + suggestions[i]
|
||||
}
|
||||
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
|
||||
if len(suggestions) > 0 {
|
||||
for i := range suggestions {
|
||||
suggestions[i] = "--" + suggestions[i]
|
||||
}
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
// The ranked candidates ride on the param as machine-readable Suggestions so
|
||||
// an agent can retry without parsing the hint; the hint carries the same
|
||||
// candidates as prose. The full valid-flag list stays recoverable via --help.
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown flag %q for %q", "--"+name, c.CommandPath()).
|
||||
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
|
||||
WithHint("%s", hint)
|
||||
}
|
||||
|
||||
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
|
||||
@@ -698,56 +627,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
|
||||
}
|
||||
|
||||
@@ -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,37 +62,46 @@ 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
|
||||
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())
|
||||
}
|
||||
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())
|
||||
}
|
||||
}
|
||||
var env typedErrorEnvelope
|
||||
if err := json.Unmarshal(stderr.Bytes(), &env); err != nil {
|
||||
t.Fatalf("failed to parse stderr as ErrorEnvelope: %v\nstderr: %s", err, stderr.String())
|
||||
t.Fatalf("failed to parse stderr as typed envelope: %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())
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
t.Helper()
|
||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||
@@ -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
|
||||
|
||||
319
cmd/root_test.go
319
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,96 @@ 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 auth/config errors: it pins the typed envelope and exit code the
|
||||
// dispatcher produces for the two source-of-truth shapes, which are constructed
|
||||
// typed at their origin in internal/auth and internal/core.
|
||||
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 +391,45 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRootError_LeakedUntypedErrorBecomesInternal pins that an untyped
|
||||
// error that does NOT match a cobra usage shape (i.e. one that leaked past the
|
||||
// typed boundary from a helper) is classified as an internal fault (exit 5),
|
||||
// not blamed on the user's input as a validation error.
|
||||
func TestHandleRootError_LeakedUntypedErrorBecomesInternal(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
exit := handleRootError(f, fmt.Errorf("upstream helper exploded: %w", io.ErrUnexpectedEOF))
|
||||
|
||||
errObj := decodeErrorEnvelope(t, errOut.Bytes())
|
||||
if got := errObj["type"]; got != "internal" {
|
||||
t.Errorf("error.type = %v, want %q (leaked untyped error must not be mislabeled validation)", got, "internal")
|
||||
}
|
||||
if exit != int(output.ExitInternal) {
|
||||
t.Errorf("exit = %d, want %d (internal envelope → category-derived exit)", exit, int(output.ExitInternal))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,12 +453,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 +630,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("expected exit code %d, got %d", output.ExitValidation, exitErr.Code)
|
||||
if output.ExitCodeOf(err) != output.ExitValidation {
|
||||
t.Errorf("expected exit code %d, got %d", output.ExitValidation, output.ExitCodeOf(err))
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected ExitError to carry Detail")
|
||||
if !strings.Contains(verr.Message, `"+bogus"`) {
|
||||
t.Errorf("message should echo the unknown token, got %q", verr.Message)
|
||||
}
|
||||
if exitErr.Detail.Type != "unknown_subcommand" {
|
||||
t.Errorf("expected Detail.Type=unknown_subcommand, got %q", exitErr.Detail.Type)
|
||||
}
|
||||
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,26 @@ 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)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
|
||||
// "+reat" is closest to the deprecated +read: the candidate must surface
|
||||
// both as a machine-readable param suggestion (for agent retry) and in the
|
||||
// hint, proving ranking spans the deprecated bucket.
|
||||
if len(verr.Params) != 1 || verr.Params[0].Name != "+reat" {
|
||||
t.Fatalf("params = %v, want one entry named +reat (the offending subcommand)", verr.Params)
|
||||
}
|
||||
|
||||
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 {
|
||||
foundSuggestion := false
|
||||
for _, s := range verr.Params[0].Suggestions {
|
||||
if s == "+read" {
|
||||
found = true
|
||||
foundSuggestion = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("suggestions %v should include +read (typo target)", suggestions)
|
||||
if !foundSuggestion {
|
||||
t.Errorf("Params[0].Suggestions should include +read, got %v", verr.Params[0].Suggestions)
|
||||
}
|
||||
if !strings.Contains(verr.Hint, "+read") {
|
||||
t.Errorf("hint %q should suggest +read (typo target across deprecated bucket)", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: <msg>" (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.
|
||||
2. return `output.ErrBare(exitCode)` — an `*output.BareError` — 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
|
||||
`*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,54 @@ 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, Suggestions []string}`: `Name` identifies the
|
||||
parameter, `Reason` states why it failed, and the optional `Suggestions`
|
||||
(wire `suggestions`, omitted when empty) carries ranked candidate
|
||||
corrections an agent can retry with — the did-you-mean candidates for an
|
||||
unknown flag or subcommand — without parsing the human-facing `hint`. 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 +418,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 +486,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 +495,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.<X>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.
|
||||
|
||||
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.
|
||||
**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 |
|
||||
|
||||
`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 +531,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_<service>.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 +580,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`.
|
||||
|
||||
29
errs/raw.go
Normal file
29
errs/raw.go
Normal file
@@ -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)
|
||||
}
|
||||
96
errs/raw_test.go
Normal file
96
errs/raw_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,10 @@ type ValidationError struct {
|
||||
type InvalidParam struct {
|
||||
Name string `json:"name"`
|
||||
Reason string `json:"reason"`
|
||||
// Suggestions holds machine-readable, ranked candidate corrections for this
|
||||
// parameter (e.g. did-you-mean flags or subcommands), so an agent can retry
|
||||
// without parsing the human-facing hint. Omitted when there are none.
|
||||
Suggestions []string `json:"suggestions,omitempty"`
|
||||
}
|
||||
|
||||
// Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -23,7 +23,7 @@ type ImMessageReceiveOutput struct {
|
||||
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
|
||||
MessageType string `json:"message_type,omitempty" desc:"Message type"`
|
||||
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
|
||||
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text. For interactive (cards) it stays as the raw JSON string and callers must fromjson to parse it."`
|
||||
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
|
||||
}
|
||||
|
||||
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
@@ -55,8 +55,10 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
|
||||
}
|
||||
|
||||
msg := envelope.Event.Message
|
||||
content := msg.Content
|
||||
if msg.MessageType != "interactive" {
|
||||
var content string
|
||||
if msg.MessageType == "interactive" {
|
||||
content = convertlib.ConvertInteractiveEventContent(msg.Content, msg.Mentions)
|
||||
} else {
|
||||
content = convertlib.ConvertBodyContent(msg.MessageType, &convertlib.ConvertContext{
|
||||
RawContent: msg.Content,
|
||||
MentionMap: convertlib.BuildMentionKeyMap(msg.Mentions),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
//
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -528,8 +528,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{},
|
||||
@@ -554,24 +553,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{},
|
||||
@@ -677,7 +674,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{
|
||||
|
||||
@@ -41,6 +41,26 @@ type ResponseOptions struct {
|
||||
CheckError func(result interface{}, identity core.Identity) error
|
||||
}
|
||||
|
||||
// httpStatusError classifies an HTTP error response by status when the body
|
||||
// carries no usable business error: 5xx → NetworkError (server tier), 404 →
|
||||
// APIError/not_found, any other 4xx → APIError/unknown. Used wherever a
|
||||
// status >= 400 must not be swallowed — a non-JSON body, an unparseable body,
|
||||
// or a JSON body whose business code is 0.
|
||||
func httpStatusError(status int, rawBody []byte) error {
|
||||
body := util.TruncateStrWithEllipsis(strings.TrimSpace(string(rawBody)), 500)
|
||||
if status >= 500 {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkServer,
|
||||
"HTTP %d: %s", status, body).
|
||||
WithCode(status)
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if status == 404 {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "HTTP %d: %s", status, body).
|
||||
WithCode(status)
|
||||
}
|
||||
|
||||
// HandleResponse routes a raw *larkcore.ApiResp to the appropriate output:
|
||||
// 1. If Content-Type is JSON, check for business errors first (even with --output).
|
||||
// 2. If --output is set and response is not a JSON error, save to file.
|
||||
@@ -62,33 +82,33 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Non-JSON error responses (e.g. 404 text/plain from gateway): return error directly
|
||||
// instead of falling through to the binary-save path.
|
||||
// 5xx → typed NetworkError (server/transport tier); 4xx → typed APIError (client error).
|
||||
// Non-JSON error responses (e.g. 404 text/plain from gateway): return error
|
||||
// directly instead of falling through to the binary-save path.
|
||||
if resp.StatusCode >= 400 && !IsJSONContentType(ct) && ct != "" {
|
||||
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)
|
||||
return httpStatusError(resp.StatusCode, resp.RawBody)
|
||||
}
|
||||
|
||||
// JSON responses: always check for business errors before saving.
|
||||
if IsJSONContentType(ct) || ct == "" {
|
||||
result, err := ParseJSONResponse(resp)
|
||||
if err != nil {
|
||||
// An unparseable / empty body on an HTTP error (common with a
|
||||
// missing Content-Type) must be classified by status, not reported
|
||||
// as an internal decode failure, matching the non-JSON branch above.
|
||||
if resp.StatusCode >= 400 {
|
||||
return httpStatusError(resp.StatusCode, resp.RawBody)
|
||||
}
|
||||
return WrapJSONResponseParseError(err, resp.RawBody)
|
||||
}
|
||||
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 so it is never swallowed.
|
||||
if resp.StatusCode >= 400 {
|
||||
return httpStatusError(resp.StatusCode, resp.RawBody)
|
||||
}
|
||||
if opts.OutputPath != "" {
|
||||
// File downloads keep the existing raw-response scan path because the
|
||||
// saved payload is the API response body, not the success envelope.
|
||||
|
||||
@@ -372,6 +372,76 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleResponse_NoContentTypeError_404 pins that a 404 with an empty body
|
||||
// and no Content-Type header — which falls into the JSON branch and fails to
|
||||
// parse — is classified by HTTP status (api/not_found), not reported as an
|
||||
// internal decode failure.
|
||||
func TestHandleResponse_NoContentTypeError_404(t *testing.T) {
|
||||
resp := newApiRespWithStatus(404, []byte(""), nil)
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 404 with empty body and no Content-Type")
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Errorf("expected *errs.APIError, got %T", err)
|
||||
}
|
||||
if apiErr != nil && apiErr.Subtype != errs.SubtypeNotFound {
|
||||
t.Errorf("subtype = %q, want not_found", apiErr.Subtype)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitAPI {
|
||||
t.Errorf("expected ExitAPI (%d), got %d", output.ExitAPI, output.ExitCodeOf(err))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleResponse_NoContentTypeError_502 pins that a 5xx with a non-JSON
|
||||
// body and no Content-Type is classified as a NetworkError by status, not an
|
||||
// internal decode failure.
|
||||
func TestHandleResponse_NoContentTypeError_502(t *testing.T) {
|
||||
resp := newApiRespWithStatus(502, []byte("<html>Bad Gateway</html>"), nil)
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 502 with non-JSON body and no Content-Type")
|
||||
}
|
||||
var netErr *errs.NetworkError
|
||||
if !errors.As(err, &netErr) {
|
||||
t.Errorf("expected *errs.NetworkError, got %T", err)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitNetwork {
|
||||
t.Errorf("expected ExitNetwork (%d) for 5xx, got %d", output.ExitNetwork, output.ExitCodeOf(err))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_200TextPlain_SavesFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
origWd, _ := os.Getwd()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 back["risk"] != "high-risk-write" {
|
||||
t.Errorf("risk in JSON = %v", back["risk"])
|
||||
}
|
||||
if risk["level"] != "high-risk-write" {
|
||||
t.Errorf("risk.level in JSON = %v", risk["level"])
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -187,6 +187,12 @@ func GetConfigPath() string {
|
||||
return filepath.Join(GetConfigDir(), "config.json")
|
||||
}
|
||||
|
||||
// ErrMalformedConfig marks a config-load failure caused by malformed file
|
||||
// content (unparseable JSON, structurally empty) rather than a missing or
|
||||
// unreadable file. Callers classify with errors.Is rather than sniffing the
|
||||
// message text.
|
||||
var ErrMalformedConfig = errors.New("malformed config")
|
||||
|
||||
// LoadMultiAppConfig loads multi-app config from disk.
|
||||
func LoadMultiAppConfig() (*MultiAppConfig, error) {
|
||||
data, err := vfs.ReadFile(GetConfigPath())
|
||||
@@ -196,10 +202,10 @@ func LoadMultiAppConfig() (*MultiAppConfig, error) {
|
||||
|
||||
var multi MultiAppConfig
|
||||
if err := json.Unmarshal(data, &multi); err != nil {
|
||||
return nil, fmt.Errorf("invalid config format: %w", err)
|
||||
return nil, fmt.Errorf("invalid config format: %w: %w", ErrMalformedConfig, err)
|
||||
}
|
||||
if len(multi.Apps) == 0 {
|
||||
return nil, fmt.Errorf("invalid config format: no apps")
|
||||
return nil, fmt.Errorf("invalid config format: no apps: %w", ErrMalformedConfig)
|
||||
}
|
||||
return &multi, nil
|
||||
}
|
||||
@@ -237,28 +243,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
|
||||
}
|
||||
return nil, &ConfigError{Code: 3, Type: "config", Message: err.Error()}
|
||||
subtype := errs.SubtypeNotConfigured
|
||||
if isMalformedConfigError(err) {
|
||||
subtype = errs.SubtypeInvalidConfig
|
||||
}
|
||||
return nil, errs.NewConfigError(subtype, "%s", err.Error()).WithCause(err)
|
||||
}
|
||||
cfg := &CliConfig{
|
||||
ProfileName: app.ProfileName(),
|
||||
@@ -287,7 +291,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
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
)
|
||||
|
||||
@@ -103,7 +104,7 @@ func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for mismatched appId and appSecret keychain key")
|
||||
}
|
||||
var cfgErr *ConfigError
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("expected ConfigError, got %T: %v", err, err)
|
||||
}
|
||||
@@ -177,7 +178,7 @@ func TestResolveConfigFromMulti_MatchingKeychainRefPassesValidation(t *testing.T
|
||||
t.Fatal("expected error (keychain entry not found), got nil")
|
||||
}
|
||||
// The error should come from keychain resolution, NOT from our mismatch check.
|
||||
var cfgErr *ConfigError
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
if cfgErr.Message == "appId and appSecret keychain key are out of sync" {
|
||||
t.Fatal("error came from mismatch check, but keys should match")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -5,10 +5,20 @@ package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// isMalformedConfigError reports whether a config load failure indicates a
|
||||
// malformed file (unparseable / structurally empty) 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. Detection is by
|
||||
// ErrMalformedConfig sentinel, not message text.
|
||||
func isMalformedConfigError(err error) bool {
|
||||
return errors.Is(err, ErrMalformedConfig)
|
||||
}
|
||||
|
||||
// LoadOrNotConfigured wraps LoadMultiAppConfig with the standard "not yet
|
||||
// configured vs. couldn't read" disambiguation that every config-required
|
||||
// command should use:
|
||||
@@ -27,14 +37,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 +81,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 +110,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)
|
||||
}
|
||||
|
||||
@@ -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.Type != "config" || cfgErr.Message != "not configured" {
|
||||
t.Errorf("unexpected detail: %+v", cfgErr)
|
||||
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.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)
|
||||
}
|
||||
if cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
// 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 !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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
if detail["hook_name"] != want {
|
||||
t.Errorf("hook_name = %v, want %v", detail["hook_name"], want)
|
||||
var ab *platform.AbortError
|
||||
if !errors.As(err, &ab) {
|
||||
t.Fatalf("error chain should expose *platform.AbortError, got %T", err)
|
||||
}
|
||||
if ab.HookName != want {
|
||||
t.Errorf("hook_name = %v, want %v", ab.HookName, want)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
58
internal/keychain/keychain_typed_error_test.go
Normal file
58
internal/keychain/keychain_typed_error_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
19
internal/output/bare.go
Normal file
19
internal/output/bare.go
Normal file
@@ -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} }
|
||||
23
internal/output/bare_test.go
Normal file
23
internal/output/bare_test.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -80,6 +80,9 @@ func TestScanForSafety_ModeBlock_WithAlert(t *testing.T) {
|
||||
if safetyErr.Category != errs.CategoryPolicy || safetyErr.Subtype != errs.SubtypeContentSafety {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", safetyErr.Category, safetyErr.Subtype, errs.CategoryPolicy, errs.SubtypeContentSafety)
|
||||
}
|
||||
if got := ExitCodeOf(result.BlockErr); got != ExitContentSafety {
|
||||
t.Errorf("exit code = %d, want %d", got, ExitContentSafety)
|
||||
}
|
||||
if len(safetyErr.Rules) != 1 || safetyErr.Rules[0] != "r1" {
|
||||
t.Errorf("rules = %v, want [r1]", safetyErr.Rules)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
@@ -201,8 +43,8 @@ func PartialFailure(code int) *PartialFailureError {
|
||||
// Two-stage write:
|
||||
//
|
||||
// 1. Serialize the envelope into an in-memory buffer. If serialization
|
||||
// fails, return false so the dispatcher falls back to the legacy
|
||||
// envelope path; nothing is written to w.
|
||||
// fails, return false so the dispatcher handles it via its signal /
|
||||
// usage-error branches; nothing is written to w.
|
||||
// 2. Best-effort write of the serialized bytes to w. A partial write is
|
||||
// accepted (return value still true): the typed exit code has already
|
||||
// been determined upstream by handleRootError calling ExitCodeOf(err)
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <output_path>', then send the local file via 'im +send..'. For post or interactive, upload first and use the new image_key or file_key."
|
||||
}
|
||||
@@ -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 <output_path>",
|
||||
"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 <message_id>",
|
||||
"--file-key <resource_key>",
|
||||
"--type <image|file>",
|
||||
"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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
# file line owner reason added_at
|
||||
cmd/completion/completion.go 35 cli-owner legacy command boundary bare error 2026-06-05
|
||||
|
||||
@@ -252,6 +252,64 @@ func TestClientRejectsOversizedRequestWithDefaultLimitBeforeHTTP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPostsBroadChangedSurfaceWithinRequestLimit(t *testing.T) {
|
||||
var calls int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"{\"verdict\":\"pass\",\"findings\":[]}"}}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := Client{
|
||||
BaseURL: srv.URL,
|
||||
APIKey: "test-key",
|
||||
Model: "semantic-review-v1",
|
||||
Timeout: time.Second,
|
||||
MaxRequestBytes: 64 * 1024,
|
||||
AllowedModels: map[string]bool{"semantic-review-v1": true},
|
||||
}
|
||||
if _, err := c.Review(context.Background(), broadChangedFacts(434, 44)); err != nil {
|
||||
t.Fatalf("Review() broad changed surface error = %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("server calls = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPostsBroadOutputCandidatesWithinRequestLimit(t *testing.T) {
|
||||
var calls int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read request body: %v", err)
|
||||
}
|
||||
if len(body) > 64*1024 {
|
||||
t.Fatalf("request bytes = %d, want <= 65536", len(body))
|
||||
}
|
||||
if strings.Contains(string(body), "verbose_output_field_") {
|
||||
t.Fatalf("request leaked verbose output fields: %s", string(body))
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"{\"verdict\":\"pass\",\"findings\":[]}"}}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := Client{
|
||||
BaseURL: srv.URL,
|
||||
APIKey: "test-key",
|
||||
Model: "semantic-review-v1",
|
||||
Timeout: time.Second,
|
||||
MaxRequestBytes: 64 * 1024,
|
||||
AllowedModels: map[string]bool{"semantic-review-v1": true},
|
||||
}
|
||||
if _, err := c.Review(context.Background(), broadOutputCandidateFacts(40)); err != nil {
|
||||
t.Fatalf("Review() broad output candidates error = %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("server calls = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientFallsBackToJSONObjectWhenJSONSchemaIsRejected(t *testing.T) {
|
||||
var formats []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -22,6 +22,7 @@ func BuildPrompt(f facts.Facts) []Message {
|
||||
{Role: "system", Content: strings.Join([]string{
|
||||
"You review a projected lark-cli quality-gate semantic input view.",
|
||||
"Use only the provided JSON view.",
|
||||
"The changed_summary may summarize broad changed surfaces; review only listed facts, not omitted summarized items.",
|
||||
"Use fact_ref values exactly when writing finding evidence.",
|
||||
"Only facts.commands, facts.skills, facts.errors, and facts.outputs fact_ref values may be blocker evidence.",
|
||||
"Evidence entries must be exact fact_ref strings such as \"facts.commands[0]\" with no explanations, labels, or suffix text.",
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestBuildPromptContainsSemanticReviewContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInputViewSelectsChangedFactsWithStableRefs(t *testing.T) {
|
||||
func TestBuildInputViewSelectsChangedReviewCandidatesWithStableRefs(t *testing.T) {
|
||||
view := BuildInputView(facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
Commands: []facts.CommandFact{
|
||||
@@ -78,17 +78,17 @@ func TestBuildInputViewSelectsChangedFactsWithStableRefs(t *testing.T) {
|
||||
if got := singleRef(t, view.Skills); got != "facts.skills[1]" {
|
||||
t.Fatalf("skill ref = %q, want facts.skills[1]", got)
|
||||
}
|
||||
if got := singleRef(t, view.SkillQuality); got != "facts.skill_quality[1]" {
|
||||
t.Fatalf("skill quality ref = %q, want facts.skill_quality[1]", got)
|
||||
if len(view.SkillQuality) != 0 {
|
||||
t.Fatalf("skill quality len = %d, want 0 without diagnostics", len(view.SkillQuality))
|
||||
}
|
||||
if got := singleRef(t, view.Errors); got != "facts.errors[1]" {
|
||||
t.Fatalf("error ref = %q, want facts.errors[1]", got)
|
||||
}
|
||||
if got := singleRef(t, view.Outputs); got != "facts.outputs[1]" {
|
||||
t.Fatalf("output ref = %q, want facts.outputs[1]", got)
|
||||
if len(view.Outputs) != 0 {
|
||||
t.Fatalf("outputs len = %d, want 0 without reject diagnostics", len(view.Outputs))
|
||||
}
|
||||
if got := singleRef(t, view.Examples); got != "facts.examples[1]" {
|
||||
t.Fatalf("example ref = %q, want facts.examples[1]", got)
|
||||
if len(view.Examples) != 0 {
|
||||
t.Fatalf("examples len = %d, want 0 without diagnostics", len(view.Examples))
|
||||
}
|
||||
if view.ChangedSummary.Commands != 1 ||
|
||||
view.ChangedSummary.Skills != 1 ||
|
||||
|
||||
@@ -61,8 +61,6 @@ type SkillQualityInput struct {
|
||||
facts.SkillQualityFact
|
||||
}
|
||||
|
||||
func (i SkillQualityInput) ref() string { return i.FactRef }
|
||||
|
||||
type ErrorInput struct {
|
||||
FactRef string `json:"fact_ref"`
|
||||
facts.ErrorFact
|
||||
@@ -71,8 +69,14 @@ type ErrorInput struct {
|
||||
func (i ErrorInput) ref() string { return i.FactRef }
|
||||
|
||||
type OutputInput struct {
|
||||
FactRef string `json:"fact_ref"`
|
||||
facts.OutputFact
|
||||
FactRef string `json:"fact_ref"`
|
||||
Command string `json:"command"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Changed bool `json:"changed,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
IsList bool `json:"is_list"`
|
||||
HasDefaultLimit bool `json:"has_default_limit"`
|
||||
HasDecisionField bool `json:"has_decision_field"`
|
||||
}
|
||||
|
||||
func (i OutputInput) ref() string { return i.FactRef }
|
||||
@@ -82,11 +86,9 @@ type ExampleInput struct {
|
||||
facts.CommandExample
|
||||
}
|
||||
|
||||
func (i ExampleInput) ref() string { return i.FactRef }
|
||||
|
||||
func BuildInputView(f facts.Facts) InputView {
|
||||
selected := newInputSelection(f)
|
||||
selected.addChangedFacts()
|
||||
selected.addChangedReviewCandidates()
|
||||
|
||||
var viewDiagnostics []facts.DiagnosticFact
|
||||
for _, diag := range f.Diagnostics {
|
||||
@@ -115,37 +117,44 @@ func BuildInputView(f facts.Facts) InputView {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *inputSelection) addChangedFacts() {
|
||||
func (s *inputSelection) addChangedReviewCandidates() {
|
||||
for i, cmd := range s.f.Commands {
|
||||
if cmd.Changed {
|
||||
if cmd.Changed && commandReviewCandidate(cmd) {
|
||||
s.commands[i] = true
|
||||
}
|
||||
}
|
||||
for i, skill := range s.f.Skills {
|
||||
if skill.Changed {
|
||||
if skill.Changed && skillReviewCandidate(skill) {
|
||||
s.skills[i] = true
|
||||
}
|
||||
}
|
||||
for i, skill := range s.f.SkillQuality {
|
||||
if skill.Changed {
|
||||
s.skillQuality[i] = true
|
||||
}
|
||||
}
|
||||
for i, errFact := range s.f.Errors {
|
||||
if errFact.Changed {
|
||||
if errFact.Changed && errorReviewCandidate(errFact) {
|
||||
s.errors[i] = true
|
||||
}
|
||||
}
|
||||
for i, output := range s.f.Outputs {
|
||||
if output.Changed {
|
||||
if output.Changed && outputReviewCandidate(output) {
|
||||
s.outputs[i] = true
|
||||
}
|
||||
}
|
||||
for i, example := range s.f.Examples {
|
||||
if example.Changed {
|
||||
s.examples[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func commandReviewCandidate(cmd facts.CommandFact) bool {
|
||||
return cmd.NameConflictsExisting || cmd.FlagAliasConflict
|
||||
}
|
||||
|
||||
func skillReviewCandidate(skill facts.SkillFact) bool {
|
||||
return skill.ReferencesInvalidCommand
|
||||
}
|
||||
|
||||
func errorReviewCandidate(errFact facts.ErrorFact) bool {
|
||||
return errFact.Boundary && errFact.RequiredHint && errFact.HintActionCount == 0
|
||||
}
|
||||
|
||||
func outputReviewCandidate(_ facts.OutputFact) bool {
|
||||
// default_output is observe-first in the current rollout; reject diagnostics add exact output context.
|
||||
return false
|
||||
}
|
||||
|
||||
type inputSelection struct {
|
||||
@@ -172,6 +181,24 @@ func newInputSelection(f facts.Facts) *inputSelection {
|
||||
|
||||
func (s *inputSelection) diagnosticContext(diag facts.DiagnosticFact) *inputSelection {
|
||||
out := newInputSelection(s.f)
|
||||
switch {
|
||||
case diag.Rule == "command_naming" || diag.Rule == "flag_naming":
|
||||
s.addDiagnosticCommands(out, diag)
|
||||
case strings.HasPrefix(diag.Rule, "default_output"):
|
||||
s.addDiagnosticOutputs(out, diag)
|
||||
case strings.HasPrefix(diag.Rule, "skill_"):
|
||||
s.addDiagnosticSkills(out, diag)
|
||||
s.addDiagnosticSkillQuality(out, diag)
|
||||
s.addDiagnosticExamples(out, diag)
|
||||
case strings.HasPrefix(diag.Rule, "example_dry_run"):
|
||||
s.addDiagnosticExamples(out, diag)
|
||||
case diag.Rule == "no_bare_helper_error":
|
||||
s.addDiagnosticErrors(out, diag)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *inputSelection) addDiagnosticCommands(out *inputSelection, diag facts.DiagnosticFact) {
|
||||
for i, cmd := range s.f.Commands {
|
||||
if diagnosticCommandMatches(diag, cmd.Path, cmd.CanonicalPath) ||
|
||||
diagnosticMentions(diag, cmd.Path) ||
|
||||
@@ -179,6 +206,9 @@ func (s *inputSelection) diagnosticContext(diag facts.DiagnosticFact) *inputSele
|
||||
out.commands[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *inputSelection) addDiagnosticSkills(out *inputSelection, diag facts.DiagnosticFact) {
|
||||
for i, skill := range s.f.Skills {
|
||||
if diagnosticLocationMatches(diag.File, diag.Line, skill.SourceFile, skill.Line) ||
|
||||
diagnosticCommandMatches(diag, skill.CommandPath) ||
|
||||
@@ -186,11 +216,17 @@ func (s *inputSelection) diagnosticContext(diag facts.DiagnosticFact) *inputSele
|
||||
out.skills[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *inputSelection) addDiagnosticSkillQuality(out *inputSelection, diag facts.DiagnosticFact) {
|
||||
for i, skill := range s.f.SkillQuality {
|
||||
if samePath(diag.File, skill.SourceFile) {
|
||||
out.skillQuality[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *inputSelection) addDiagnosticErrors(out *inputSelection, diag facts.DiagnosticFact) {
|
||||
for i, errFact := range s.f.Errors {
|
||||
if diagnosticLocationMatches(diag.File, diag.Line, errFact.File, errFact.Line) ||
|
||||
diagnosticCommandMatches(diag, errFact.CommandPath, errFact.Command) ||
|
||||
@@ -199,12 +235,18 @@ func (s *inputSelection) diagnosticContext(diag facts.DiagnosticFact) *inputSele
|
||||
out.errors[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *inputSelection) addDiagnosticOutputs(out *inputSelection, diag facts.DiagnosticFact) {
|
||||
for i, output := range s.f.Outputs {
|
||||
if diagnosticCommandMatches(diag, output.Command) ||
|
||||
diagnosticMentions(diag, output.Command) {
|
||||
out.outputs[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *inputSelection) addDiagnosticExamples(out *inputSelection, diag facts.DiagnosticFact) {
|
||||
for i, example := range s.f.Examples {
|
||||
if diagnosticLocationMatches(diag.File, diag.Line, example.SourceFile, example.Line) ||
|
||||
diagnosticCommandMatches(diag, example.CommandPath) ||
|
||||
@@ -212,11 +254,10 @@ func (s *inputSelection) diagnosticContext(diag facts.DiagnosticFact) *inputSele
|
||||
out.examples[i] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func includeDiagnosticInView(diag facts.DiagnosticFact, selected, context *inputSelection) bool {
|
||||
if diag.Action != report.ActionWarning {
|
||||
if diag.Action == report.ActionReject {
|
||||
return true
|
||||
}
|
||||
return selected.intersects(context)
|
||||
@@ -284,7 +325,17 @@ func (s *inputSelection) outputInputs() []OutputInput {
|
||||
out := make([]OutputInput, 0, countSelected(s.outputs))
|
||||
for i, ok := range s.outputs {
|
||||
if ok {
|
||||
out = append(out, OutputInput{FactRef: factRef("outputs", i), OutputFact: s.f.Outputs[i]})
|
||||
output := s.f.Outputs[i]
|
||||
out = append(out, OutputInput{
|
||||
FactRef: factRef("outputs", i),
|
||||
Command: output.Command,
|
||||
Domain: output.Domain,
|
||||
Changed: output.Changed,
|
||||
Source: output.Source,
|
||||
IsList: output.IsList,
|
||||
HasDefaultLimit: output.HasDefaultLimit,
|
||||
HasDecisionField: output.HasDecisionField,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user