mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
1 Commits
sun/remove
...
feat/calen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a9f554a68 |
30
.github/workflows/semantic-review.yml
vendored
30
.github/workflows/semantic-review.yml
vendored
@@ -47,13 +47,10 @@ jobs:
|
||||
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||
}
|
||||
let prNumber = Number(runPRs[0]?.number || 0);
|
||||
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
let eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||
const targetHeadSha = run.head_sha;
|
||||
const targetHeadSha = eventHeadSha || run.head_sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
core.notice("PR quality summary using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||
}
|
||||
|
||||
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
@@ -74,11 +71,11 @@ jobs:
|
||||
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||
factsArtifactName = "";
|
||||
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
|
||||
factsArtifactName = "";
|
||||
} else {
|
||||
artifactBaseSha = parsedBaseSha;
|
||||
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
core.notice("PR quality summary using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
@@ -126,7 +123,7 @@ jobs:
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
|
||||
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
|
||||
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
|
||||
core.notice("PR quality summary skipped: workflow_run is stale for this PR base");
|
||||
@@ -258,13 +255,10 @@ jobs:
|
||||
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||
}
|
||||
let prNumber = Number(runPRs[0]?.number || 0);
|
||||
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
let eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||
const targetHeadSha = run.head_sha;
|
||||
const targetHeadSha = eventHeadSha || run.head_sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
core.notice("semantic review using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||
}
|
||||
|
||||
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
@@ -285,11 +279,11 @@ jobs:
|
||||
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||
factsArtifactName = "";
|
||||
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
|
||||
factsArtifactName = "";
|
||||
} else {
|
||||
artifactBaseSha = parsedBaseSha;
|
||||
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
core.notice("semantic review using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
@@ -337,7 +331,7 @@ jobs:
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
|
||||
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
|
||||
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
|
||||
core.notice("semantic review skipped: workflow_run is stale for this PR base");
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -7,11 +7,6 @@ bin/
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Python (skill-bundled helper scripts)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
@@ -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,16 +49,9 @@ linters:
|
||||
- gocritic
|
||||
- depguard
|
||||
- forbidigo
|
||||
- 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
|
||||
# Paths that run forbidigo. Add an entry when a path joins one of
|
||||
# the rules below.
|
||||
- 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/
|
||||
@@ -84,14 +77,25 @@ linters:
|
||||
text: shortcuts-no-raw-http
|
||||
linters:
|
||||
- forbidigo
|
||||
# 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/)
|
||||
# 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/)
|
||||
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:
|
||||
@@ -110,6 +114,22 @@ 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: >-
|
||||
|
||||
49
CHANGELOG.md
49
CHANGELOG.md
@@ -2,53 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.57] - 2026-06-23
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: Add `+screenshot` to capture slide page images (or render a single `<slide>` XML snippet), returning the local file path instead of Base64 (#1358)
|
||||
- **base**: Support record comments (#1043)
|
||||
- **search**: Surface search API notices (#1413)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **mail**: Resolve folder/label filter once per `+triage list` call (#1512)
|
||||
- **meta**: Backfill enum value descriptions from options (#1541)
|
||||
- **cli**: Add missing CLI headers for git credential helper (#1539)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Refine rich block, path, and block ID guidance (#1508)
|
||||
- **mail**: Trim lark-mail skill context (#1527)
|
||||
- **drive**: Add permission governance workflow guidance (#1292)
|
||||
|
||||
### Build
|
||||
|
||||
- **ci**: Bind semantic review to workflow run head (#1551)
|
||||
|
||||
## [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
|
||||
@@ -1236,8 +1189,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
|
||||
[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,7 +10,6 @@ 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"
|
||||
@@ -124,13 +123,7 @@ 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, 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 (-)"},
|
||||
)
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
}
|
||||
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
|
||||
@@ -160,10 +153,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
if _, ok := dataFields.(map[string]any); !ok {
|
||||
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")
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,13 +196,7 @@ func apiRun(opts *APIOptions) error {
|
||||
}
|
||||
|
||||
if opts.PageAll && opts.Output != "" {
|
||||
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"},
|
||||
)
|
||||
return output.ErrValidation("--output and --page-all are mutually exclusive")
|
||||
}
|
||||
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
|
||||
return err
|
||||
@@ -259,7 +243,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 errs.MarkRaw(err)
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
err = client.HandleResponse(resp, client.ResponseOptions{
|
||||
OutputPath: opts.Output,
|
||||
@@ -279,7 +263,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 errs.MarkRaw(err)
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -296,11 +280,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 errs.MarkRaw(err)
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return errs.MarkRaw(apiErr)
|
||||
return output.MarkRaw(apiErr)
|
||||
}
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
@@ -329,10 +313,10 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
|
||||
return nil
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return errs.MarkRaw(err)
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
return errs.MarkRaw(apiErr)
|
||||
return output.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)
|
||||
@@ -347,11 +331,11 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
|
||||
default:
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return errs.MarkRaw(err)
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return errs.MarkRaw(apiErr)
|
||||
return output.MarkRaw(apiErr)
|
||||
}
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
|
||||
@@ -33,9 +33,12 @@ 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.BareError
|
||||
var bare *output.ExitError
|
||||
if !errors.As(err, &bare) {
|
||||
t.Fatalf("expected *output.BareError (ErrBare), got %T: %v", err, err)
|
||||
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)
|
||||
}
|
||||
|
||||
if stderr.Len() != 0 {
|
||||
|
||||
@@ -9,7 +9,6 @@ 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"
|
||||
@@ -60,7 +59,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 *errs.ConfigError
|
||||
var cfgErr *core.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
|
||||
// BareError with ExitAuth so the dispatcher only propagates the exit code
|
||||
// ExitError 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,13 +945,16 @@ 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.BareError signal (no envelope).
|
||||
var bareErr *output.BareError
|
||||
if !errors.As(err, &bareErr) {
|
||||
t.Fatalf("expected *output.BareError, got %T: %v", err, err)
|
||||
// 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)
|
||||
}
|
||||
if bareErr.Code != output.ExitAuth {
|
||||
t.Fatalf("BareError.Code = %d, want %d", bareErr.Code, output.ExitAuth)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
package completion
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -31,9 +32,7 @@ func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command {
|
||||
case "powershell":
|
||||
return root.GenPowerShellCompletionWithDesc(out)
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unsupported shell: %s", args[0]).
|
||||
WithHint("supported shells: bash, zsh, fish, powershell")
|
||||
return fmt.Errorf("unsupported shell: %s", args[0])
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -212,7 +212,10 @@ func finalizeSource(opts *BindOptions) (string, error) {
|
||||
if opts.IsTUI && !opts.langExplicit {
|
||||
lang, err := promptLangSelection()
|
||||
if err != nil {
|
||||
return "", langSelectionError(err)
|
||||
if err == huh.ErrUserAborted {
|
||||
return "", output.ErrBare(1)
|
||||
}
|
||||
return "", output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
|
||||
}
|
||||
opts.Lang = string(lang)
|
||||
opts.UILang = lang
|
||||
|
||||
@@ -20,29 +20,35 @@ import (
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
// 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) {
|
||||
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 := wantErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
|
||||
gotDetail := output.ErrDetail{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)
|
||||
}
|
||||
@@ -53,13 +59,13 @@ func assertExitError(t *testing.T, err error, wantCode int, wantDetail wantErrDe
|
||||
if got := output.ExitCodeOf(err); got != wantCode {
|
||||
t.Errorf("exit code = %d, want %d", got, wantCode)
|
||||
}
|
||||
gotDetail := wantErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint}
|
||||
gotDetail := output.ErrDetail{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 *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
|
||||
t.Fatalf("error type = %T, want *output.ExitError or *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
|
||||
}
|
||||
|
||||
// assertEnvelope decodes stdout and checks it matches want exactly — every key
|
||||
@@ -173,21 +179,15 @@ func TestConfigBindRun_InvalidLang(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
|
||||
}
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
exitErr, ok := err.(*output.ExitError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if valErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
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())
|
||||
if !strings.Contains(exitErr.Error(), "invalid --lang") {
|
||||
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.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, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
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, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
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, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
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, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
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, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
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, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
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, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
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, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
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, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
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, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "accounts.app.secret is empty in " + configPath,
|
||||
Hint: "run lark-channel-bridge's setup to populate the app credential",
|
||||
@@ -835,19 +835,17 @@ 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 *errs.ConfigError
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
// Config errors share ExitAuth (3); the workspace is detected but no
|
||||
// binding exists yet, which is a config error.
|
||||
if got := output.ExitCodeOf(err); got != output.ExitAuth {
|
||||
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth)
|
||||
if cfgErr.Code != output.ExitAuth {
|
||||
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
|
||||
}
|
||||
// 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 cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
}
|
||||
if !strings.Contains(cfgErr.Message, "openclaw context detected") {
|
||||
t.Errorf("message missing 'openclaw context detected': %q", cfgErr.Message)
|
||||
@@ -1189,7 +1187,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 := wantErrDetail{
|
||||
base := output.ErrDetail{
|
||||
Type: "validation",
|
||||
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
|
||||
}
|
||||
@@ -1205,7 +1203,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 := wantErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
|
||||
got := output.ErrDetail{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)
|
||||
@@ -1232,7 +1230,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, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "validation",
|
||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||
Hint: "available app IDs:\n cli_only_one",
|
||||
@@ -1252,7 +1250,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, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "validation",
|
||||
Message: `invalid --identity "invalid"; valid values: bot-only, user-default`,
|
||||
})
|
||||
@@ -1538,7 +1536,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, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "FEISHU_APP_ID not found in " + envPath,
|
||||
Hint: "run 'hermes setup' to configure Feishu credentials",
|
||||
@@ -1558,7 +1556,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, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "FEISHU_APP_SECRET not found in " + envPath,
|
||||
Hint: "run 'hermes setup' to configure Feishu credentials",
|
||||
@@ -1584,7 +1582,7 @@ func TestConfigBindRun_OpenClawMissingFeishu(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "openclaw.json missing channels.feishu section",
|
||||
Hint: "configure Feishu in OpenClaw first",
|
||||
@@ -1612,7 +1610,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, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "appSecret is empty for app cli_no_secret in " + openclawPath,
|
||||
Hint: "configure channels.feishu.appSecret in openclaw.json",
|
||||
@@ -1674,7 +1672,7 @@ func TestConfigBindRun_OpenClawDisabledAccount(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||
assertExitError(t, err, output.ExitAuth, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
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, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
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, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
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, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
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, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
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, wantErrDetail{
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "validation",
|
||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||
Hint: "available app IDs:\n cli_only",
|
||||
|
||||
@@ -12,7 +12,6 @@ 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"
|
||||
@@ -93,16 +92,16 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
// Config errors share ExitAuth (3), not ExitValidation.
|
||||
if got := output.ExitCodeOf(err); got != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth)
|
||||
if cfgErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeNotConfigured || cfgErr.Message != "not configured" {
|
||||
t.Fatalf("detail = %+v, want not_configured/not configured", cfgErr)
|
||||
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
|
||||
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,21 +233,15 @@ func TestConfigInitCmd_InvalidLang(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
|
||||
}
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
exitErr, ok := err.(*output.ExitError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if valErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
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())
|
||||
if !strings.Contains(exitErr.Error(), "invalid --lang") {
|
||||
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -392,38 +385,8 @@ func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T
|
||||
if err == nil {
|
||||
t.Fatal("expected conflict error")
|
||||
}
|
||||
// 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")
|
||||
if !strings.Contains(err.Error(), "conflicts with existing appId") {
|
||||
t.Fatalf("error = %v, want conflict with existing appId", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,13 @@ package config
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -125,9 +127,12 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
|
||||
if ws.IsLocal() {
|
||||
return nil
|
||||
}
|
||||
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.")
|
||||
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.",
|
||||
}
|
||||
}
|
||||
|
||||
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
|
||||
@@ -178,20 +183,6 @@ 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.
|
||||
@@ -216,9 +207,7 @@ 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 errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"profile name %q conflicts with existing appId", profileName).
|
||||
WithParam("--name")
|
||||
return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
|
||||
}
|
||||
// Append new profile
|
||||
multi.Apps = append(multi.Apps, core.AppConfig{
|
||||
@@ -260,8 +249,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; everything else (filesystem, keychain, etc.) is wrapped as
|
||||
// InternalError.
|
||||
// survive; legacy *output.ExitError also passes through; everything else
|
||||
// (filesystem, keychain, etc.) is wrapped as InternalError.
|
||||
func wrapUpdateExistingProfileErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -269,6 +258,10 @@ 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)
|
||||
}
|
||||
|
||||
@@ -343,7 +336,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 wrapSaveConfigError(err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
printLangPreferenceConfirmation(opts)
|
||||
@@ -360,7 +353,10 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
|
||||
lang, err := promptLangSelection()
|
||||
if err != nil {
|
||||
return langSelectionError(err)
|
||||
if err == huh.ErrUserAborted {
|
||||
return output.ErrBare(1)
|
||||
}
|
||||
return output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
|
||||
}
|
||||
opts.Lang = string(lang)
|
||||
opts.UILang = lang
|
||||
@@ -383,7 +379,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 wrapSaveConfigError(err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
printLangPreferenceConfirmation(opts)
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
||||
@@ -413,7 +409,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 wrapSaveConfigError(err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
} else if result.Mode == "existing" && result.AppID != "" {
|
||||
// Existing app with unchanged secret — update app ID and brand only
|
||||
@@ -518,7 +514,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 wrapSaveConfigError(err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(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/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
|
||||
@@ -26,15 +26,12 @@ func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected refusal in OpenClaw context, got nil")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
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 cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("hint must point to config bind --help; got %q", cfgErr.Hint)
|
||||
@@ -51,15 +48,12 @@ func TestGuardAgentWorkspace_HermesRefuses(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected refusal in Hermes context, got nil")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
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 cfgErr.Type != "hermes" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,10 @@
|
||||
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 {
|
||||
@@ -101,12 +97,3 @@ 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: a typed ValidationError must keep ExitValidation rather than
|
||||
// being downgraded to InternalError.
|
||||
// exit semantics (regression: typed ValidationError was being downgraded to
|
||||
// InternalError by the legacy *output.ExitError-only passthrough).
|
||||
|
||||
func TestWrapUpdateExistingProfileErr_NilPassesThrough(t *testing.T) {
|
||||
if got := wrapUpdateExistingProfileErr(nil); got != nil {
|
||||
@@ -90,6 +90,18 @@ 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,7 +14,6 @@ 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"
|
||||
@@ -95,7 +94,7 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
// underlying problem is still visible.
|
||||
msg, hint := err.Error(), ""
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
msg, hint = cfgErr.Message, cfgErr.Hint
|
||||
}
|
||||
@@ -109,7 +108,7 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
hint := ""
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
hint = cfgErr.Hint
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ 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"
|
||||
@@ -48,6 +49,32 @@ 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") //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
|
||||
errInvalidParamFormat = errors.New("invalid --param format")
|
||||
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
|
||||
errOutputDirUnsafe = errors.New("unsafe --output-dir")
|
||||
)
|
||||
|
||||
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.BareError
|
||||
var exit *output.ExitError
|
||||
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.BareError); ok {
|
||||
if t, ok := target.(**output.BareError); ok {
|
||||
if e, ok := err.(*output.ExitError); ok {
|
||||
if t, ok := target.(**output.ExitError); ok {
|
||||
*t = e
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ func TestRunList_TextOutput(t *testing.T) {
|
||||
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
|
||||
"im.message.receive_v1",
|
||||
"im.message.message_read_v1",
|
||||
"task.task.update_user_access_v2",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("list output missing %q; full output:\n%s", want, out)
|
||||
@@ -56,17 +55,4 @@ func TestRunList_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var foundTask bool
|
||||
for _, row := range rows {
|
||||
if row["key"] == "task.task.update_user_access_v2" {
|
||||
foundTask = true
|
||||
if row["single_consumer"] != true {
|
||||
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundTask {
|
||||
t.Fatal("event list JSON missing task.task.update_user_access_v2")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,34 +96,6 @@ func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "task.task.update_user_access_v2", true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["jq_root_path"] != ".event" {
|
||||
t.Errorf("jq_root_path = %v, want .event", payload["jq_root_path"])
|
||||
}
|
||||
if payload["single_consumer"] != true {
|
||||
t.Errorf("single_consumer = %v, want true", payload["single_consumer"])
|
||||
}
|
||||
resolved := payload["resolved_output_schema"].(map[string]interface{})
|
||||
props := resolved["properties"].(map[string]interface{})
|
||||
eventProps := props["event"].(map[string]interface{})["properties"].(map[string]interface{})
|
||||
if got := eventProps["task_guid"].(map[string]interface{})["format"]; got != "task_guid" {
|
||||
t.Errorf("task_guid format = %v, want task_guid", got)
|
||||
}
|
||||
if _, ok := eventProps["event_types"].(map[string]interface{})["items"].(map[string]interface{})["enum"]; !ok {
|
||||
t.Fatalf("event_types enum missing in schema: %#v", eventProps["event_types"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
||||
const syntheticKey = "test.evt_sub"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
@@ -19,12 +19,12 @@ func TestExitForOrphan_Orphan(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error when failOnOrphan=true and orphan present")
|
||||
}
|
||||
var bareErr *output.BareError
|
||||
if !errors.As(err, &bareErr) {
|
||||
t.Fatalf("expected *output.BareError, got %T", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if bareErr.Code != output.ExitValidation {
|
||||
t.Errorf("Code = %d, want %d", bareErr.Code, output.ExitValidation)
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("Code = %d, want %d", exitErr.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,65 +40,31 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
|
||||
c.Flags().Bool("dry-run", false, "")
|
||||
|
||||
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
|
||||
if exitErr.Detail.Type != "unknown_flag" {
|
||||
t.Errorf("type = %q, want unknown_flag", exitErr.Detail.Type)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--range") {
|
||||
t.Errorf("hint should suggest --range, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
// Non-unknown-flag errors stay generic: invalid_argument subtype, no
|
||||
// structured param, generic --help hint (no "did you mean" suggestion).
|
||||
if verr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want invalid_argument (non-unknown-flag errors stay generic)", verr.Subtype)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
if verr.Param != "" || len(verr.Params) != 0 {
|
||||
t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params)
|
||||
}
|
||||
if strings.Contains(verr.Hint, "did you mean") {
|
||||
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
|
||||
if exitErr.Detail.Type != "flag_error" {
|
||||
t.Errorf("type = %q, want flag_error (non-unknown-flag errors stay generic)", exitErr.Detail.Type)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,10 @@ 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"
|
||||
@@ -104,7 +102,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 error envelope; allowed commands are
|
||||
// command's RunE returns a typed ExitError envelope; allowed commands are
|
||||
// untouched.
|
||||
func TestApplyUserPolicyPruning_appliesValidPolicy(t *testing.T) {
|
||||
cfgDir := tmpHome(t)
|
||||
@@ -129,27 +127,13 @@ max_risk: write
|
||||
if err == nil {
|
||||
t.Fatalf("+delete-doc RunE should return an error")
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
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)
|
||||
}
|
||||
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)
|
||||
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"])
|
||||
}
|
||||
|
||||
// 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,8 +34,16 @@ import (
|
||||
// lands directly on their RunE, which now carries the guard.
|
||||
//
|
||||
// makeErr is called for every guarded dispatch; it must return a fresh
|
||||
// typed error each time.
|
||||
func installFatalGuard(rootCmd *cobra.Command, makeErr func() error) {
|
||||
// *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) {
|
||||
// Two cobra subcommands are injected lazily at Execute() time and
|
||||
// would otherwise slip past walkGuard. We pre-register both so
|
||||
// walkGuard catches them.
|
||||
@@ -72,65 +80,120 @@ func installFatalGuard(rootCmd *cobra.Command, makeErr func() error) {
|
||||
}
|
||||
|
||||
// installPluginInstallErrorGuard surfaces a FailClosed plugin install
|
||||
// failure as a typed validation error (failed_precondition) before any
|
||||
// command runs.
|
||||
// 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.
|
||||
func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) {
|
||||
makeErr := func() error {
|
||||
makeErr := func() *output.ExitError {
|
||||
var pi *internalplatform.PluginInstallError
|
||||
if errors.As(installErr, &pi) {
|
||||
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 &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", 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 hint separates the two failure modes by reason code:
|
||||
// Restrict). The design separates the envelope type:
|
||||
//
|
||||
// - "invalid_rule" - single bad rule
|
||||
// - "multiple_restrict_plugins" - multiple Restrict plugins conflict
|
||||
// - "plugin_install" with reason_code "invalid_rule" - single bad rule
|
||||
// - "plugin_conflict" with reason_code "multiple_restrict_plugins" - multi
|
||||
//
|
||||
// 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() error {
|
||||
makeErr := func() *output.ExitError {
|
||||
envelopeType := "plugin_install"
|
||||
reasonCode := internalplatform.ReasonInvalidRule
|
||||
if errors.Is(err, cmdpolicy.ErrMultipleRestricts) {
|
||||
envelopeType = "plugin_conflict"
|
||||
reasonCode = internalplatform.ReasonMultipleRestricts
|
||||
}
|
||||
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)
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: envelopeType,
|
||||
Message: err.Error(),
|
||||
Detail: map[string]any{
|
||||
"reason_code": reasonCode,
|
||||
},
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
|
||||
// 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.
|
||||
// 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.
|
||||
func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
|
||||
makeErr := func() error {
|
||||
makeErr := func() *output.ExitError {
|
||||
reasonCode := "lifecycle_failed"
|
||||
hookName := ""
|
||||
detail := map[string]any{
|
||||
"reason_code": reasonCode,
|
||||
}
|
||||
var le *hook.LifecycleError
|
||||
if errors.As(err, &le) {
|
||||
if le.Panic {
|
||||
reasonCode = "lifecycle_panic"
|
||||
}
|
||||
hookName = le.HookName
|
||||
detail = map[string]any{
|
||||
"reason_code": reasonCode,
|
||||
"hook_name": le.HookName,
|
||||
"event": "startup",
|
||||
}
|
||||
}
|
||||
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 &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "plugin_lifecycle",
|
||||
Message: err.Error(),
|
||||
Detail: detail,
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
return typed.WithHint("a plugin startup hook failed (reason_code %s); fix or remove the plugin before running commands", reasonCode)
|
||||
}
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
@@ -156,7 +219,14 @@ 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.
|
||||
func walkGuard(cmd *cobra.Command, makeErr func() error) {
|
||||
// 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) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,14 +6,12 @@ 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"
|
||||
@@ -34,7 +32,7 @@ func (failClosedAbortingPlugin) Install(platform.Registrar) error {
|
||||
}
|
||||
|
||||
// When a FailClosed plugin fails to install, buildInternal must
|
||||
// install a PersistentPreRunE that returns a typed *errs.ValidationError.
|
||||
// install a PersistentPreRunE that returns a structured *output.ExitError.
|
||||
// The user must NEVER see a silent partial-install state.
|
||||
//
|
||||
// This pins the build.go fix for codex's NEW ISSUE about
|
||||
@@ -95,31 +93,26 @@ func TestBuildInternal_failClosedAbortsCLI(t *testing.T) {
|
||||
checkGuardError(t, leaf.RunE(leaf, nil))
|
||||
}
|
||||
|
||||
// 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).
|
||||
// checkGuardError asserts that err is the structured plugin_install
|
||||
// ExitError the guard produces.
|
||||
func checkGuardError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("PersistentPreRunE must surface the install error, got nil")
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
if exitErr.Detail.Type != "plugin_install" {
|
||||
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
|
||||
}
|
||||
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)
|
||||
if detail["plugin"] != "policy" {
|
||||
t.Errorf("detail.plugin = %v, want policy", detail["plugin"])
|
||||
}
|
||||
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)
|
||||
if detail["reason_code"] != internalplatform.ReasonInstallFailed {
|
||||
t.Errorf("detail.reason_code = %v, want install_failed", detail["reason_code"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,13 +8,11 @@ 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"
|
||||
@@ -158,23 +156,19 @@ func TestPluginPipeline_wrapAbortReachesEnvelope(t *testing.T) {
|
||||
}
|
||||
|
||||
err = leaf.RunE(leaf, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
if exitErr.Detail.Type != "hook" {
|
||||
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
if detail["reason_code"] != "aborted" {
|
||||
t.Errorf("detail.reason_code = %v, want aborted", detail["reason_code"])
|
||||
}
|
||||
// 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)
|
||||
if detail["hook_name"] != "policy-plugin.policy" {
|
||||
t.Errorf("detail.hook_name = %v, want policy-plugin.policy", detail["hook_name"])
|
||||
}
|
||||
|
||||
// errors.As must still reach the original AbortError so consumers
|
||||
@@ -415,20 +409,15 @@ func TestPluginConflictGuard_MultipleRestrictAbortsCLI(t *testing.T) {
|
||||
t.Fatalf("no runnable leaf in command tree")
|
||||
}
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
if exitErr.Detail.Type != "plugin_conflict" {
|
||||
t.Errorf("envelope type = %q, want plugin_conflict", exitErr.Detail.Type)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
// 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)
|
||||
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "multiple_restrict_plugins" {
|
||||
t.Errorf("reason_code = %v, want multiple_restrict_plugins", rc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,20 +447,15 @@ func TestPluginConflictGuard_InvalidRuleAbortsCLI(t *testing.T) {
|
||||
t.Fatalf("no runnable leaf in command tree")
|
||||
}
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
if exitErr.Detail.Type != "plugin_install" {
|
||||
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
|
||||
}
|
||||
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)
|
||||
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "invalid_rule" {
|
||||
t.Errorf("reason_code = %v, want invalid_rule", rc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,24 +484,19 @@ func TestPluginLifecycleGuard_StartupErrorAbortsCLI(t *testing.T) {
|
||||
|
||||
leaf := findRunnableLeaf(root)
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
if exitErr.Detail.Type != "plugin_lifecycle" {
|
||||
t.Errorf("envelope type = %q, want plugin_lifecycle", exitErr.Detail.Type)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
d := exitErr.Detail.Detail.(map[string]any)
|
||||
if d["reason_code"] != "lifecycle_failed" {
|
||||
t.Errorf("reason_code = %v, want lifecycle_failed", d["reason_code"])
|
||||
}
|
||||
// reason_code lifecycle_failed (vs lifecycle_panic) and the failing
|
||||
// hook name are folded into the hint so audit / on-call can tell the
|
||||
// failure mode and which hook failed.
|
||||
if !strings.Contains(verr.Hint, "lifecycle_failed") {
|
||||
t.Errorf("hint should surface reason_code lifecycle_failed, got %q", verr.Hint)
|
||||
}
|
||||
if !strings.Contains(verr.Hint, "lc.start") {
|
||||
t.Errorf("hint should name the failing hook lc.start, got %q", verr.Hint)
|
||||
if d["hook_name"] != "lc.start" {
|
||||
t.Errorf("hook_name = %v, want lc.start", d["hook_name"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,20 +520,12 @@ func TestPluginLifecycleGuard_StartupPanicAbortsCLI(t *testing.T) {
|
||||
}
|
||||
leaf := findRunnableLeaf(root)
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
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)
|
||||
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "lifecycle_panic" {
|
||||
t.Errorf("reason_code = %v, want lifecycle_panic", rc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,24 +579,19 @@ func TestWrapperPanic_BecomesHookPanicEnvelope(t *testing.T) {
|
||||
}()
|
||||
|
||||
err = leaf.RunE(leaf, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
if exitErr.Detail.Type != "hook" {
|
||||
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
d := exitErr.Detail.Detail.(map[string]any)
|
||||
if d["reason_code"] != "panic" {
|
||||
t.Errorf("reason_code = %v, want panic", d["reason_code"])
|
||||
}
|
||||
// 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)
|
||||
if d["hook_name"] != "p.boom" {
|
||||
t.Errorf("hook_name = %v, want p.boom (namespaced)", d["hook_name"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -687,24 +653,19 @@ func TestWrapperFactoryPanic_BecomesHookPanicEnvelope(t *testing.T) {
|
||||
}()
|
||||
|
||||
err = leaf.RunE(leaf, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
if exitErr.Detail.Type != "hook" {
|
||||
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
d := exitErr.Detail.Detail.(map[string]any)
|
||||
if d["reason_code"] != "panic" {
|
||||
t.Errorf("reason_code = %v, want panic", d["reason_code"])
|
||||
}
|
||||
// A panic in the wrapper FACTORY (not just the inner handler) is
|
||||
// recovered into the same structured panic error, naming the
|
||||
// namespaced hook fac.bad-factory.
|
||||
if !strings.Contains(verr.Message, "fac.bad-factory") {
|
||||
t.Errorf("message should name the namespaced hook fac.bad-factory, got %q", verr.Message)
|
||||
}
|
||||
if !strings.Contains(verr.Message, "panic") {
|
||||
t.Errorf("message should describe the panic, got %q", verr.Message)
|
||||
if d["hook_name"] != "fac.bad-factory" {
|
||||
t.Errorf("hook_name = %v, want fac.bad-factory (namespaced)", d["hook_name"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ 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"
|
||||
@@ -54,9 +53,7 @@ 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 errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).
|
||||
WithCause(err).
|
||||
WithParam("--name")
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
|
||||
langPref, err := cmdutil.ParseLangFlag(lang)
|
||||
@@ -67,57 +64,46 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
|
||||
|
||||
// Read secret from stdin
|
||||
if !appSecretStdin {
|
||||
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")
|
||||
return output.ErrValidation("app secret must be provided via stdin: use --app-secret-stdin and pipe the secret")
|
||||
}
|
||||
scanner := bufio.NewScanner(f.IOStreams.In)
|
||||
if !scanner.Scan() {
|
||||
if err := scanner.Err(); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "failed to read secret from stdin: %v", err).
|
||||
WithCause(err).
|
||||
WithParam("--app-secret-stdin")
|
||||
return output.ErrValidation("failed to read secret from stdin: %v", err)
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "stdin is empty, expected app secret").
|
||||
WithHint("pipe the app secret to stdin").
|
||||
WithParam("--app-secret-stdin")
|
||||
return output.ErrValidation("stdin is empty, expected app secret")
|
||||
}
|
||||
appSecret := strings.TrimSpace(scanner.Text())
|
||||
if appSecret == "" {
|
||||
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")
|
||||
return output.ErrValidation("app secret read from stdin is empty")
|
||||
}
|
||||
|
||||
// Load or create config
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "failed to load config: %v", err).WithCause(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to load config: %v", err)
|
||||
}
|
||||
multi = &core.MultiAppConfig{}
|
||||
}
|
||||
|
||||
// Check name uniqueness
|
||||
if multi.FindApp(name) != nil {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "profile %q already exists", name).
|
||||
WithHint("choose a different name, or remove the existing profile first").
|
||||
WithParam("--name")
|
||||
return output.ErrValidation("profile %q already exists", 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 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")
|
||||
return output.ErrValidation("app-id %q is already used by profile %q; each profile must have a unique app-id", appID, a.ProfileName())
|
||||
}
|
||||
}
|
||||
|
||||
// Store secret securely
|
||||
secret, err := core.ForStorage(appID, core.PlainSecret(appSecret), f.Keychain)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "%v", err).WithCause(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
}
|
||||
|
||||
parsedBrand := core.ParseBrand(brand)
|
||||
@@ -148,7 +134,7 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile %q added (%s, %s)", name, appID, parsedBrand))
|
||||
|
||||
@@ -9,7 +9,6 @@ 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"
|
||||
@@ -46,7 +45,7 @@ func profileListRun(f *cmdutil.Factory) error {
|
||||
output.PrintJson(f.IOStreams.Out, []profileListItem{})
|
||||
return nil
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "failed to load config: %v", err).WithCause(err)
|
||||
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
|
||||
}
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
output.PrintJson(f.IOStreams.Out, []profileListItem{})
|
||||
|
||||
@@ -11,7 +11,6 @@ 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"
|
||||
@@ -51,16 +50,6 @@ 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:
|
||||
@@ -106,9 +95,9 @@ func TestProfileAddRun_Lang(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for --lang ZH, got nil")
|
||||
}
|
||||
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)
|
||||
exitErr, ok := err.(*output.ExitError)
|
||||
if !ok || exitErr.Code != output.ExitValidation {
|
||||
t.Fatalf("expected ExitValidation, got %T: %v", err, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -417,226 +406,17 @@ func TestProfileUseRun_SaveFailureReturnsStructuredError(t *testing.T) {
|
||||
func assertInternalExitError(t *testing.T, err error, wantMsg string) {
|
||||
t.Helper()
|
||||
|
||||
var internalErr *errs.InternalError
|
||||
if !errors.As(err, &internalErr) {
|
||||
t.Fatalf("error type = %T, want *errs.InternalError; err=%v", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError; err=%v", err, err)
|
||||
}
|
||||
if internalErr.Subtype != errs.SubtypeStorage {
|
||||
t.Fatalf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeStorage)
|
||||
if exitErr.Code != output.ExitInternal {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
|
||||
}
|
||||
if internalErr.Cause == nil {
|
||||
t.Fatalf("cause = nil, want wrapped underlying error")
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "internal" {
|
||||
t.Fatalf("detail = %#v, want internal detail", exitErr.Detail)
|
||||
}
|
||||
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")
|
||||
if !strings.Contains(exitErr.Detail.Message, wantMsg) {
|
||||
t.Fatalf("message = %q, want contains %q", exitErr.Detail.Message, wantMsg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ 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"
|
||||
@@ -41,12 +40,11 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
|
||||
|
||||
idx := multi.FindAppIndex(name)
|
||||
if idx < 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
|
||||
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
|
||||
}
|
||||
|
||||
if len(multi.Apps) == 1 {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "cannot remove the only profile").
|
||||
WithHint("add another profile first: lark-cli profile add")
|
||||
return output.ErrValidation("cannot remove the only profile")
|
||||
}
|
||||
|
||||
app := &multi.Apps[idx]
|
||||
@@ -67,7 +65,7 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
// Best-effort credential cleanup after config commit
|
||||
|
||||
@@ -9,7 +9,6 @@ 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"
|
||||
@@ -31,7 +30,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 errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err)
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
@@ -41,7 +40,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
|
||||
|
||||
idx := multi.FindAppIndex(oldName)
|
||||
if idx < 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", oldName, strings.Join(multi.ProfileNames(), ", "))
|
||||
return output.ErrValidation("profile %q not found, available profiles: %s", oldName, strings.Join(multi.ProfileNames(), ", "))
|
||||
}
|
||||
|
||||
// Check new name uniqueness across other profiles, allowing renames to this
|
||||
@@ -51,8 +50,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
|
||||
continue
|
||||
}
|
||||
if multi.Apps[i].Name == newName || multi.Apps[i].AppId == newName {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "profile %q already exists", newName).
|
||||
WithHint("choose a different name")
|
||||
return output.ErrValidation("profile %q already exists", newName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +66,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile renamed: %q -> %q", oldProfileName, newName))
|
||||
|
||||
@@ -9,7 +9,6 @@ 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"
|
||||
@@ -41,15 +40,14 @@ func profileUseRun(f *cmdutil.Factory, name string) error {
|
||||
// Handle "-" for toggle-back
|
||||
if name == "-" {
|
||||
if multi.PreviousApp == "" {
|
||||
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>")
|
||||
return output.ErrValidation("no previous profile to switch back to")
|
||||
}
|
||||
name = multi.PreviousApp
|
||||
}
|
||||
|
||||
app := multi.FindApp(name)
|
||||
if app == nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
|
||||
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
|
||||
}
|
||||
|
||||
targetName := app.ProfileName()
|
||||
@@ -68,7 +66,7 @@ func profileUseRun(f *cmdutil.Factory, name string) error {
|
||||
multi.CurrentApp = targetName
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", 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 wraps
|
||||
// the CommandDeniedError as the Cause by hand; BuildDenialError
|
||||
// would override Message with the CommandDeniedError.Error() long
|
||||
// form.
|
||||
// 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.
|
||||
stubMessage := fmt.Sprintf(
|
||||
"strict mode is %q, only %s-identity commands are available",
|
||||
mode, mode.ForcedIdentity())
|
||||
@@ -105,9 +105,20 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
|
||||
},
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", stubMessage).
|
||||
WithHint("denied by %s policy (reason_code %s); %s", cd.Layer, cd.ReasonCode, stubHint).
|
||||
WithCause(cd)
|
||||
// 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,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ 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"
|
||||
@@ -248,12 +247,9 @@ func TestStrictModeStub_BypassesArgsValidator(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Pins the strict-mode envelope shape: structured detail.* / wrapped
|
||||
// CommandDeniedError for external agents, AND the historical short
|
||||
// Message + independent Hint for existing consumers.
|
||||
func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
@@ -266,33 +262,30 @@ func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
|
||||
t.Fatalf("strict-mode stub RunE should return error")
|
||||
}
|
||||
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("err is not *errs.ValidationError: %T", err)
|
||||
var ee *output.ExitError
|
||||
if !errors.As(err, &ee) {
|
||||
t.Fatalf("err is not *output.ExitError: %T", err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
|
||||
if ee.Detail == nil {
|
||||
t.Fatalf("ExitError.Detail is nil; envelope writer cannot emit JSON")
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
if ee.Detail.Type != "command_denied" {
|
||||
t.Errorf("Detail.Type = %q, want command_denied", ee.Detail.Type)
|
||||
}
|
||||
// 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)
|
||||
dm, ok := ee.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("Detail.Detail = %T, want map[string]any", ee.Detail.Detail)
|
||||
}
|
||||
// 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["layer"].(string); got != cmdpolicy.LayerStrictMode {
|
||||
t.Errorf("Detail.Detail[layer] = %q, want %q", got, cmdpolicy.LayerStrictMode)
|
||||
}
|
||||
if !strings.Contains(verr.Hint, "identity_not_supported") {
|
||||
t.Errorf("Hint = %q, want substring identity_not_supported (reason code)", verr.Hint)
|
||||
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, "if the user explicitly wants to switch policy") {
|
||||
t.Errorf("Hint = %q, want historical switch-policy guidance", verr.Hint)
|
||||
if got, _ := dm["policy_source"].(string); got != "strict-mode" {
|
||||
t.Errorf("Detail.Detail[policy_source] = %q, want strict-mode", got)
|
||||
}
|
||||
|
||||
// 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")
|
||||
@@ -303,12 +296,15 @@ 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,12 +13,17 @@ 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"
|
||||
@@ -212,37 +217,56 @@ func configureFlagCompletions(args []string) {
|
||||
// and returns the process exit code.
|
||||
//
|
||||
// Dispatch order:
|
||||
// 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.
|
||||
// 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.
|
||||
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.
|
||||
if !errs.IsRaw(err) {
|
||||
applyNeedAuthorizationHint(f, 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 plain "Error:" path with exit 1.
|
||||
// typed exits 3/4/6/10 to the legacy "Error:" path with exit 1.
|
||||
// WriteTypedErrorEnvelope still returns false when err carries no
|
||||
// Problem; in that case we fall through to the signal / plain-text paths.
|
||||
// Problem; in that case we fall through to the legacy bridge below.
|
||||
typedExit := output.ExitCodeOf(err)
|
||||
if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) {
|
||||
return typedExit
|
||||
@@ -255,63 +279,58 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
return pfErr.Code
|
||||
}
|
||||
|
||||
// Silent-exit signal (e.g. `auth check` predicate, or `update --json`):
|
||||
// stdout already carries the result; honor the requested exit code and
|
||||
// write nothing to stderr.
|
||||
var bareErr *output.BareError
|
||||
if errors.As(err, &bareErr) {
|
||||
return bareErr.Code
|
||||
}
|
||||
|
||||
// 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
|
||||
if exitErr := asExitError(err); exitErr != nil {
|
||||
if !exitErr.Raw {
|
||||
// Raw errors (e.g. from `api` command via output.MarkRaw)
|
||||
// preserve the original API error detail; skip enrichment
|
||||
// which would clear it.
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
enrichPermissionError(f, exitErr)
|
||||
}
|
||||
output.WriteErrorEnvelope(errOut, exitErr, string(f.ResolvedIdentity))
|
||||
return exitErr.Code
|
||||
}
|
||||
return false
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
|
||||
@@ -342,10 +361,13 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
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
|
||||
@@ -361,13 +383,28 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
}
|
||||
if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 {
|
||||
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 &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{},
|
||||
},
|
||||
},
|
||||
}
|
||||
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
|
||||
@@ -379,13 +416,19 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
if len(misplaced) == 0 {
|
||||
return cmd.Help()
|
||||
}
|
||||
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 &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{},
|
||||
},
|
||||
},
|
||||
}
|
||||
return verr
|
||||
}
|
||||
unknown := args[0]
|
||||
available, deprecated := availableSubcommandNames(cmd)
|
||||
@@ -399,12 +442,27 @@ 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())
|
||||
}
|
||||
// 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)
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// flagTokensInArgs returns the flag-like tokens (-x, --foo, --foo=bar) in
|
||||
@@ -530,34 +588,47 @@ 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 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.
|
||||
// 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.
|
||||
func flagDidYouMean(c *cobra.Command, ferr error) error {
|
||||
name, isUnknown := unknownFlagName(ferr)
|
||||
if !isUnknown {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
|
||||
WithHint("run `%s --help` for valid flags", c.CommandPath())
|
||||
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()),
|
||||
},
|
||||
}
|
||||
}
|
||||
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())
|
||||
}
|
||||
// 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)
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
|
||||
@@ -627,3 +698,56 @@ 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,6 +8,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -26,12 +27,12 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Canonical strict-mode envelope messages shared across fixtures. The
|
||||
// switch-policy hint text is asserted by substring in
|
||||
// assertStrictModeDenialEnvelope.
|
||||
// Canonical strict-mode envelope strings shared across fixtures
|
||||
// (reflect.DeepEqual pins them; keep in sync with strictModeStubFrom).
|
||||
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
|
||||
@@ -62,46 +63,37 @@ func executeRootIntegration(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Com
|
||||
return 0
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// parseEnvelope parses stderr bytes into an ErrorEnvelope.
|
||||
func parseEnvelope(t *testing.T, stderr *bytes.Buffer) output.ErrorEnvelope {
|
||||
t.Helper()
|
||||
if stderr.Len() == 0 {
|
||||
t.Fatal("expected non-empty stderr, got empty")
|
||||
}
|
||||
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
|
||||
var env output.ErrorEnvelope
|
||||
if err := json.Unmarshal(stderr.Bytes(), &env); err != nil {
|
||||
t.Fatalf("failed to parse stderr as typed envelope: %v\nstderr: %s", err, stderr.String())
|
||||
t.Fatalf("failed to parse stderr as ErrorEnvelope: %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"}
|
||||
@@ -213,71 +205,23 @@ 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). 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)
|
||||
}
|
||||
// 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,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnvelope(t *testing.T) {
|
||||
@@ -288,14 +232,22 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnve
|
||||
"im", "+messages-search", "--chat-id", "oc_xxx", "--query", "hello",
|
||||
})
|
||||
|
||||
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)
|
||||
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,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t *testing.T) {
|
||||
@@ -325,14 +277,15 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
|
||||
"im", "+chat-create", "--name", "probe", "--as", "bot", "--dry-run",
|
||||
})
|
||||
|
||||
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)
|
||||
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)",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
|
||||
@@ -343,14 +296,15 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
|
||||
})
|
||||
|
||||
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)
|
||||
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)",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
||||
@@ -361,14 +315,22 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE
|
||||
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
|
||||
})
|
||||
|
||||
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)
|
||||
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,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelope(t *testing.T) {
|
||||
@@ -379,14 +341,15 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop
|
||||
"api", "--as", "user", "GET", "/open-apis/im/v1/chats/oc_test", "--dry-run",
|
||||
})
|
||||
|
||||
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)
|
||||
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)",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- shortcut command ---
|
||||
@@ -409,43 +372,16 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
"im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
|
||||
})
|
||||
|
||||
// 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.")
|
||||
}
|
||||
// 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.",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestSetupNotices_ColdStart_NoNotice verifies that missing state
|
||||
|
||||
319
cmd/root_test.go
319
cmd/root_test.go
@@ -137,6 +137,9 @@ 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,
|
||||
@@ -266,11 +269,12 @@ func (f *failingWriter) Write(p []byte) (int, error) {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
@@ -282,9 +286,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: not a typed
|
||||
// errs.* error, so it reaches the deprecation fallback.
|
||||
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
// 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"))
|
||||
|
||||
out := errOut.String()
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
|
||||
@@ -293,96 +297,12 @@ 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_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) {
|
||||
// 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) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
@@ -391,45 +311,9 @@ func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
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))
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,32 +337,12 @@ func TestHandleRootError_PartialWritePreservesExitCode(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) {
|
||||
// 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) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
@@ -630,3 +494,136 @@ 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,11 +5,9 @@ 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"
|
||||
)
|
||||
@@ -211,45 +209,6 @@ 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,7 +11,6 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
@@ -127,20 +126,29 @@ func TestUnknownSubcommandRunE_FlagBeforeSubcommandIsStructured(t *testing.T) {
|
||||
t.Errorf("error = %q, want it to mention an unknown flag", err.Error())
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
|
||||
if exitErr.Detail.Type != "unknown_flag" {
|
||||
t.Errorf("detail.Type = %q, want unknown_flag", exitErr.Detail.Type)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", output.ExitCodeOf(err), output.ExitValidation)
|
||||
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 len(verr.Params) != 1 || verr.Params[0].Name != "--badflag" {
|
||||
t.Errorf("params = %v, want one entry named --badflag", verr.Params)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,21 +172,25 @@ func TestUnknownSubcommandRunE_ValidFlagWithoutSubcommandIsStructured(t *testing
|
||||
if err == nil {
|
||||
t.Fatal("expected a structured missing_subcommand error, got nil (help fallthrough)")
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", output.ExitCodeOf(err), output.ExitValidation)
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if !strings.Contains(verr.Message, "missing subcommand") {
|
||||
t.Errorf("message = %q, want it to mention a missing subcommand", verr.Message)
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "missing_subcommand" {
|
||||
t.Fatalf("detail.Type = %v, want missing_subcommand", exitErr.Detail)
|
||||
}
|
||||
if len(verr.Params) != 1 || verr.Params[0].Name != "--query" {
|
||||
t.Errorf("params = %v, want one entry named --query", verr.Params)
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
if !strings.Contains(verr.Message, "lark-cli drive") {
|
||||
t.Errorf("message = %q, want it to name the group path", verr.Message)
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,23 +241,45 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
|
||||
t.Fatal("expected error for unknown subcommand")
|
||||
}
|
||||
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitValidation {
|
||||
t.Errorf("expected exit code %d, got %d", output.ExitValidation, output.ExitCodeOf(err))
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("expected exit code %d, got %d", output.ExitValidation, exitErr.Code)
|
||||
}
|
||||
if !strings.Contains(verr.Message, `"+bogus"`) {
|
||||
t.Errorf("message should echo the unknown token, got %q", verr.Message)
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected ExitError to carry Detail")
|
||||
}
|
||||
if !strings.Contains(verr.Message, "lark-cli drive") {
|
||||
t.Errorf("message should name the group path, 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)
|
||||
}
|
||||
// "+bogus" has no close neighbor among drive's subcommands, so the hint falls
|
||||
// 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)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,12 +288,13 @@ func TestUnknownSubcommandRunE_NestedResourceGroup(t *testing.T) {
|
||||
installUnknownSubcommandGuard(root)
|
||||
|
||||
err := files.RunE(files, []string{"bogus"})
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError on nested group, got %T", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError on nested group, got %T", err)
|
||||
}
|
||||
if !strings.Contains(verr.Message, "lark-cli drive files") {
|
||||
t.Errorf("message should reflect the nested resource path, got %q", verr.Message)
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,10 +337,10 @@ func TestAvailableSubcommandNames_SplitsDeprecatedGroup(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) {
|
||||
// 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) {
|
||||
svc := &cobra.Command{Use: "sheets"}
|
||||
svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
|
||||
svc.AddCommand(
|
||||
@@ -314,26 +349,31 @@ func TestUnknownSubcommandRunE_SuggestsAcrossDeprecatedBucket(t *testing.T) {
|
||||
)
|
||||
|
||||
err := unknownSubcommandRunE(svc, []string{"+reat"})
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
// "+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)
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
foundSuggestion := false
|
||||
for _, s := range verr.Params[0].Suggestions {
|
||||
|
||||
if available, _ := detail["available"].([]string); len(available) != 1 || available[0] != "+cells-get" {
|
||||
t.Errorf("available = %v, want [+cells-get]", available)
|
||||
}
|
||||
deprecated, ok := detail["deprecated"].([]string)
|
||||
if !ok || len(deprecated) != 1 || deprecated[0] != "+read" {
|
||||
t.Errorf("deprecated = %v, want [+read]", deprecated)
|
||||
}
|
||||
// suggestions rank across both buckets: "+reat" is closest to +read.
|
||||
suggestions, _ := detail["suggestions"].([]string)
|
||||
found := false
|
||||
for _, s := range suggestions {
|
||||
if s == "+read" {
|
||||
foundSuggestion = true
|
||||
found = true
|
||||
}
|
||||
}
|
||||
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)
|
||||
if !found {
|
||||
t.Errorf("suggestions %v should include +read (typo target)", suggestions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ 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"
|
||||
@@ -133,14 +132,12 @@ func updateRun(opts *UpdateOptions) error {
|
||||
// 1. Fetch latest version
|
||||
latest, err := fetchLatest()
|
||||
if err != nil {
|
||||
return reportError(opts, io, "network",
|
||||
errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to check latest version: %s", err).WithCause(err))
|
||||
return reportError(opts, io, output.ExitNetwork, "network", "failed to check latest version: %s", err)
|
||||
}
|
||||
|
||||
// 2. Validate version format
|
||||
if update.ParseVersion(latest) == nil {
|
||||
return reportError(opts, io, "update_error",
|
||||
errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid version from registry: %s", latest))
|
||||
return reportError(opts, io, output.ExitInternal, "update_error", "invalid version from registry: %s", latest)
|
||||
}
|
||||
|
||||
// 3. Compare versions
|
||||
@@ -169,18 +166,15 @@ func updateRun(opts *UpdateOptions) error {
|
||||
|
||||
// --- Output helpers ---
|
||||
|
||||
// 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 {
|
||||
func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, exitCode int, errType, format string, args ...interface{}) error {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
if opts.JSON {
|
||||
output.PrintJson(io.Out, map[string]interface{}{
|
||||
"ok": false, "error": map[string]interface{}{"type": errType, "message": typedErr.ProblemDetail().Message},
|
||||
"ok": false, "error": map[string]interface{}{"type": errType, "message": msg},
|
||||
})
|
||||
return output.ErrBare(output.ExitCodeOf(typedErr))
|
||||
return output.ErrBare(exitCode)
|
||||
}
|
||||
return typedErr
|
||||
return output.Errorf(exitCode, errType, "%s", msg)
|
||||
}
|
||||
|
||||
func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, canAutoUpdate bool) error {
|
||||
@@ -234,8 +228,7 @@ 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, "update_error",
|
||||
errs.NewAPIError(errs.SubtypeUnknown, "failed to prepare update: %s", err).WithCause(err))
|
||||
return reportError(opts, io, output.ExitAPI, "update_error", "failed to prepare update: %s", err)
|
||||
}
|
||||
|
||||
if !opts.JSON {
|
||||
|
||||
@@ -14,7 +14,6 @@ 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"
|
||||
@@ -335,88 +334,13 @@ func TestUpdateFetchError_Human(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error, got nil")
|
||||
}
|
||||
var netErr *errs.NetworkError
|
||||
if !errors.As(err, &netErr) {
|
||||
t.Fatalf("expected *errs.NetworkError, got %T: %v", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if netErr.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Errorf("subtype = %q, want %q", netErr.Subtype, errs.SubtypeNetworkTransport)
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Errorf("expected ExitNetwork (%d), got %d", output.ExitNetwork, exitErr.Code)
|
||||
}
|
||||
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) {
|
||||
@@ -579,12 +503,12 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
|
||||
if err == nil {
|
||||
t.Fatal("expected verification failure")
|
||||
}
|
||||
var bareErr *output.BareError
|
||||
if !errors.As(err, &bareErr) {
|
||||
t.Fatalf("expected *output.BareError, got %T: %v", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if bareErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI (%d), got %d", output.ExitAPI, bareErr.Code)
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI (%d), got %d", output.ExitAPI, exitErr.Code)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
|
||||
@@ -6,16 +6,25 @@ 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.
|
||||
|
||||
Something off in production? See **Troubleshooting**.
|
||||
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**.
|
||||
|
||||
## Invariants
|
||||
|
||||
1. Every error belongs to exactly one **Category**. The set is closed
|
||||
(`errs/category.go`); adding a member requires deliberate review.
|
||||
2. Every typed error has a **Subtype** — a stable
|
||||
2. Every **newly constructed** typed error has a **Subtype** — a stable
|
||||
lowercase-with-underscores identifier declared in `errs/subtypes*.go`.
|
||||
Undeclared subtypes fail CI. Every error path constructs a typed
|
||||
`*errs.*` error at its origin, so the constraint applies uniformly.
|
||||
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.
|
||||
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).
|
||||
@@ -26,10 +35,11 @@ Something off 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`. `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).
|
||||
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).
|
||||
|
||||
## Wire format
|
||||
|
||||
@@ -63,14 +73,13 @@ 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`.
|
||||
`CategoryPolicy`. The legacy `auth_error` envelope at exit `1` has been
|
||||
retired.
|
||||
|
||||
## Categories
|
||||
|
||||
@@ -110,21 +119,20 @@ 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;
|
||||
│ *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
|
||||
│ (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
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Predicate commands (`output.BareError`)
|
||||
### Predicate commands (`output.ErrBare`)
|
||||
|
||||
A small class of commands is **predicates**: they answer a yes/no
|
||||
question and signal the answer through the shell exit code so callers
|
||||
@@ -134,27 +142,19 @@ 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)` — an `*output.BareError` — to
|
||||
communicate the exit code to the dispatcher without producing a
|
||||
`stderr` envelope.
|
||||
2. return `output.ErrBare(exitCode)` to communicate the exit code to
|
||||
the dispatcher without producing a `stderr` envelope.
|
||||
|
||||
`*output.BareError` is **not** an error in the typed-envelope sense — it
|
||||
carries no category, subtype, or message, only an exit code. It is a
|
||||
one-bit output-control signal that lives outside the contract for the
|
||||
same reason `grep -q` / `diff` / `systemctl is-active` set non-zero exit
|
||||
codes without printing anything to stderr: pollution of stderr by a
|
||||
`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
|
||||
predicate's negative answer would break `2>/dev/null` log hygiene in
|
||||
caller scripts.
|
||||
|
||||
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`
|
||||
New code should not reach for `ErrBare` unless the command is
|
||||
genuinely a predicate. Anything carrying recoverable error content
|
||||
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=$?
|
||||
|
||||
# Defensive guard: tolerate any non-JSON output before parsing with jq.
|
||||
# Untyped / Cobra errors print plain text — guard before jq.
|
||||
if ! jq -e . >/dev/null 2>&1 <<<"$out"; then
|
||||
printf '%s\n' "$out" >&2
|
||||
exit "$code"
|
||||
@@ -303,10 +303,9 @@ 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.
|
||||
|
||||
(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**.)
|
||||
(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**.)
|
||||
|
||||
#### Split `Message`, `Hint`, and `Cause`
|
||||
|
||||
@@ -341,54 +340,15 @@ Message: fmt.Sprintf("request failed: %v — retry later", ioErr)
|
||||
// conflates what + what-to-do + cause into one string
|
||||
```
|
||||
|
||||
#### Validation parameters: `Param` and `Params`
|
||||
#### `ValidationError.Param` uses the `--flag` form
|
||||
|
||||
`ValidationError` carries two additive parameter fields. Both are
|
||||
optional; a producer sets whichever fits the failure.
|
||||
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`".
|
||||
|
||||
**`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
|
||||
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` +
|
||||
@@ -418,11 +378,44 @@ 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.
|
||||
|
||||
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`).
|
||||
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`).
|
||||
|
||||
### Wrapping upstream errors
|
||||
|
||||
@@ -486,7 +479,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_test.go`.
|
||||
3. Add a wire-format pin in `errs/marshal_test.go` and a builder-chain pin in `errs/types_builder_test.go`.
|
||||
|
||||
`CheckProblemEmbed` enforces the `Problem` embed at lint time. New
|
||||
top-level wire fields are forbidden — per-Subtype data goes into the
|
||||
@@ -495,33 +488,19 @@ top level.
|
||||
|
||||
## CI guards
|
||||
|
||||
Two golangci-lint rules and the custom `errscontract` AST module enforce the
|
||||
contract; CI runs all three on every PR.
|
||||
| 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 |
|
||||
|
||||
**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.
|
||||
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.
|
||||
|
||||
## Stability
|
||||
|
||||
@@ -531,13 +510,67 @@ set and `lint/README.md` for adding 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 `internal/errclass/classify_test.go`.
|
||||
and Subtype, plus a dispatch test in `classify_test.go`.
|
||||
|
||||
**Envelope shows `type=internal subtype=sdk_error`.** Origin is
|
||||
`client.WrapDoAPIError` taking the non-transport branch
|
||||
@@ -580,6 +613,8 @@ 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
29
errs/raw.go
@@ -1,29 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
// 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,10 +77,6 @@ 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"; this test
|
||||
// pins that claim so the behavioral comment cannot silently drift from the
|
||||
// implementation.
|
||||
// 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.
|
||||
func TestTypedErrors_UnwrapNilReceiver(t *testing.T) {
|
||||
t.Helper()
|
||||
checks := []struct {
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// CardActionTriggerOutput is the flattened shape for card.action.trigger.
|
||||
type CardActionTriggerOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always card.action.trigger"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string)" kind:"timestamp_ms"`
|
||||
OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id" kind:"open_id"`
|
||||
MessageID string `json:"message_id,omitempty" desc:"Message ID of the card" kind:"message_id"`
|
||||
ChatID string `json:"chat_id,omitempty" desc:"Chat ID" kind:"chat_id"`
|
||||
Host string `json:"host,omitempty" desc:"Host type: im_message / im_top_notice"`
|
||||
Token string `json:"token,omitempty" desc:"Token for delay card update (valid 30 min, max 2 updates)"`
|
||||
ActionTag string `json:"action_tag,omitempty" desc:"Triggered element type: button/select_static/input/checker/etc"`
|
||||
ActionValue string `json:"action_value,omitempty" desc:"Developer-defined action value as JSON string"`
|
||||
ActionName string `json:"action_name,omitempty" desc:"Element name attribute"`
|
||||
FormValue string `json:"form_value,omitempty" desc:"Form submission values as JSON string (only on form submit)"`
|
||||
InputValue string `json:"input_value,omitempty" desc:"Input field value (only for input elements)"`
|
||||
Option string `json:"option,omitempty" desc:"Selected option value (for single-select dropdown)"`
|
||||
Options string `json:"options,omitempty" desc:"Selected options, comma-separated (for multi-select)"`
|
||||
Checked bool `json:"checked" desc:"Checkbox state (for checkbox elements)"`
|
||||
Timezone string `json:"timezone,omitempty" desc:"User timezone for date/time picker interactions"`
|
||||
CardContent string `json:"card_content,omitempty" desc:"Original card JSON content (body.content) auto-fetched via message get API at consume time using message_id; empty if message_id absent or fetch fails"`
|
||||
}
|
||||
|
||||
func processCardAction(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
Operator struct {
|
||||
OpenID string `json:"open_id"`
|
||||
} `json:"operator"`
|
||||
Token string `json:"token"`
|
||||
Host string `json:"host"`
|
||||
Action struct {
|
||||
Tag string `json:"tag"`
|
||||
Value map[string]interface{} `json:"value"`
|
||||
Name string `json:"name"`
|
||||
FormValue map[string]interface{} `json:"form_value"`
|
||||
InputValue string `json:"input_value"`
|
||||
Option string `json:"option"`
|
||||
Options []string `json:"options"`
|
||||
Checked bool `json:"checked"`
|
||||
Timezone string `json:"timezone"`
|
||||
} `json:"action"`
|
||||
Context struct {
|
||||
OpenMessageID string `json:"open_message_id"`
|
||||
OpenChatID string `json:"open_chat_id"`
|
||||
} `json:"context"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload
|
||||
}
|
||||
|
||||
actionValue := marshalToString(envelope.Event.Action.Value)
|
||||
formValue := marshalToString(envelope.Event.Action.FormValue)
|
||||
options := strings.Join(envelope.Event.Action.Options, ",")
|
||||
|
||||
out := &CardActionTriggerOutput{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
OperatorID: envelope.Event.Operator.OpenID,
|
||||
MessageID: envelope.Event.Context.OpenMessageID,
|
||||
ChatID: envelope.Event.Context.OpenChatID,
|
||||
Host: envelope.Event.Host,
|
||||
Token: envelope.Event.Token,
|
||||
ActionTag: envelope.Event.Action.Tag,
|
||||
ActionValue: actionValue,
|
||||
ActionName: envelope.Event.Action.Name,
|
||||
FormValue: formValue,
|
||||
InputValue: envelope.Event.Action.InputValue,
|
||||
Option: envelope.Event.Action.Option,
|
||||
Options: options,
|
||||
Checked: envelope.Event.Action.Checked,
|
||||
Timezone: envelope.Event.Action.Timezone,
|
||||
}
|
||||
|
||||
if out.MessageID != "" && rt != nil {
|
||||
out.CardContent = fetchCardUserDSL(ctx, rt, out.MessageID)
|
||||
}
|
||||
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
// fetchCardUserDSL gets the card message content via message get API.
|
||||
// Returns empty string on any failure — never blocks event consumption.
|
||||
func fetchCardUserDSL(ctx context.Context, rt event.APIClient, messageID string) string {
|
||||
path := "/open-apis/im/v1/messages/" + messageID + "?card_msg_content_type=user_card_content"
|
||||
resp, err := rt.CallAPI(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Items []struct {
|
||||
Body struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"body"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if json.Unmarshal(resp, &result) != nil || result.Code != 0 || len(result.Data.Items) == 0 {
|
||||
return ""
|
||||
}
|
||||
return result.Data.Items[0].Body.Content
|
||||
}
|
||||
|
||||
func marshalToString(m map[string]interface{}) string {
|
||||
if len(m) == 0 {
|
||||
return ""
|
||||
}
|
||||
b, _ := json.Marshal(m)
|
||||
return string(b)
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestCardActionTriggerRegistered(t *testing.T) {
|
||||
def, ok := event.Lookup("card.action.trigger")
|
||||
if !ok {
|
||||
t.Fatal("card.action.trigger should be registered via Keys()")
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Error("card.action.trigger must set Schema.Custom")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Error("card.action.trigger must set Process")
|
||||
}
|
||||
if len(def.Scopes) == 0 {
|
||||
t.Error("Scopes must not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_Button(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_btn_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469273"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_operator"},
|
||||
"token": "c-token-btn",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {"key": "approve"},
|
||||
"name": "approve_btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_msg_001",
|
||||
"open_chat_id": "oc_chat_001"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.Type != "card.action.trigger" {
|
||||
t.Errorf("Type = %q, want card.action.trigger", out.Type)
|
||||
}
|
||||
if out.EventID != "ev_btn_001" {
|
||||
t.Errorf("EventID = %q", out.EventID)
|
||||
}
|
||||
if out.OperatorID != "ou_operator" {
|
||||
t.Errorf("OperatorID = %q", out.OperatorID)
|
||||
}
|
||||
if out.ActionTag != "button" {
|
||||
t.Errorf("ActionTag = %q, want button", out.ActionTag)
|
||||
}
|
||||
if out.ActionValue != `{"key":"approve"}` {
|
||||
t.Errorf("ActionValue = %q", out.ActionValue)
|
||||
}
|
||||
if out.ActionName != "approve_btn" {
|
||||
t.Errorf("ActionName = %q", out.ActionName)
|
||||
}
|
||||
if out.Token != "c-token-btn" {
|
||||
t.Errorf("Token = %q", out.Token)
|
||||
}
|
||||
if out.MessageID != "om_msg_001" {
|
||||
t.Errorf("MessageID = %q", out.MessageID)
|
||||
}
|
||||
if out.ChatID != "oc_chat_001" {
|
||||
t.Errorf("ChatID = %q", out.ChatID)
|
||||
}
|
||||
if out.Host != "im_message" {
|
||||
t.Errorf("Host = %q", out.Host)
|
||||
}
|
||||
if out.Timestamp != "1776409469273" {
|
||||
t.Errorf("Timestamp = %q", out.Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_FormSubmit(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_form_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469274"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_form_user"},
|
||||
"token": "c-token-form",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {},
|
||||
"name": "submit_btn",
|
||||
"form_value": {"name": "test-user", "reason": "testing"},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_form_001",
|
||||
"open_chat_id": "oc_chat_002"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.FormValue != `{"name":"test-user","reason":"testing"}` {
|
||||
t.Errorf("FormValue = %q", out.FormValue)
|
||||
}
|
||||
if out.ActionTag != "button" {
|
||||
t.Errorf("ActionTag = %q, want button", out.ActionTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MultiSelect(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_ms_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469275"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_ms_user"},
|
||||
"token": "c-token-ms",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "multi_select_static",
|
||||
"value": {},
|
||||
"name": "multi_select",
|
||||
"options": ["opt_1", "opt_3"],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_ms_001",
|
||||
"open_chat_id": "oc_chat_003"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.Options != "opt_1,opt_3" {
|
||||
t.Errorf("Options = %q, want opt_1,opt_3", out.Options)
|
||||
}
|
||||
if out.ActionTag != "multi_select_static" {
|
||||
t.Errorf("ActionTag = %q", out.ActionTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_Input(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_input_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469276"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_input_user"},
|
||||
"token": "c-token-input",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "input",
|
||||
"value": {},
|
||||
"name": "text_input",
|
||||
"input_value": "hello world",
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_input_001",
|
||||
"open_chat_id": "oc_chat_004"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.InputValue != "hello world" {
|
||||
t.Errorf("InputValue = %q", out.InputValue)
|
||||
}
|
||||
if out.ActionTag != "input" {
|
||||
t.Errorf("ActionTag = %q", out.ActionTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_DatePicker(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_date_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469277"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_date_user"},
|
||||
"token": "c-token-date",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "date_picker",
|
||||
"value": {},
|
||||
"name": "date_selector",
|
||||
"option": "2024-04-01 +0800",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_date_001",
|
||||
"open_chat_id": "oc_chat_005"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.Option != "2024-04-01 +0800" {
|
||||
t.Errorf("Option = %q", out.Option)
|
||||
}
|
||||
if out.Timezone != "Asia/Shanghai" {
|
||||
t.Errorf("Timezone = %q", out.Timezone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MalformedPayload(t *testing.T) {
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_bad",
|
||||
EventType: "card.action.trigger",
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processCardAction(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MessageGetSuccess(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_mg_ok",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469278"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_mg_user"},
|
||||
"token": "c-token-mg",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {"key": "click"},
|
||||
"name": "btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_mg_001",
|
||||
"open_chat_id": "oc_chat_mg"
|
||||
}
|
||||
}
|
||||
}`
|
||||
cardContent := `{"header":{"title":{"tag":"plain_text","content":"A card"}}}`
|
||||
mock := &mockAPIClient{resp: `{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"items": [{
|
||||
"body": {"content": "` + escapeJSON(cardContent) + `"}
|
||||
}]
|
||||
}
|
||||
}`}
|
||||
out := runCardAction(t, payload, mock)
|
||||
|
||||
if out.CardContent == "" {
|
||||
t.Error("CardContent should not be empty when message get succeeds")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MessageGetErrorCode(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_mg_ec",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469279"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_mg_user2"},
|
||||
"token": "c-token-mg2",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {},
|
||||
"name": "btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_mg_002",
|
||||
"open_chat_id": "oc_chat_mg2"
|
||||
}
|
||||
}
|
||||
}`
|
||||
mock := &mockAPIClient{resp: `{"code": 1, "msg": "error", "data": {"items": []}}`}
|
||||
out := runCardAction(t, payload, mock)
|
||||
|
||||
if out.CardContent != "" {
|
||||
t.Errorf("CardContent should be empty when code != 0, got %q", out.CardContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MessageGetFailure(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_mg_fail",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469280"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_mg_user3"},
|
||||
"token": "c-token-mg3",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {},
|
||||
"name": "btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_mg_003",
|
||||
"open_chat_id": "oc_chat_mg3"
|
||||
}
|
||||
}
|
||||
}`
|
||||
mock := &mockAPIClient{errResp: true}
|
||||
out := runCardAction(t, payload, mock)
|
||||
|
||||
if out.CardContent != "" {
|
||||
t.Errorf("CardContent should be empty when message get fails, got %q", out.CardContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_EmptyMessageID(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_no_msg",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469281"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_no_msg"},
|
||||
"token": "c-token-nm",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {},
|
||||
"name": "btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "",
|
||||
"open_chat_id": "oc_chat_nm"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.CardContent != "" {
|
||||
t.Errorf("CardContent should be empty when message_id is absent, got %q", out.CardContent)
|
||||
}
|
||||
}
|
||||
|
||||
type mockAPIClient struct {
|
||||
resp string
|
||||
errResp bool
|
||||
}
|
||||
|
||||
func (m *mockAPIClient) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
|
||||
if m.errResp {
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
return json.RawMessage(m.resp), nil
|
||||
}
|
||||
|
||||
func runCardAction(t *testing.T, payload string, rt event.APIClient) CardActionTriggerOutput {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_test",
|
||||
EventType: "card.action.trigger",
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processCardAction(context.Background(), rt, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
var out CardActionTriggerOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid CardActionTriggerOutput JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func escapeJSON(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b[1 : len(b)-1])
|
||||
}
|
||||
@@ -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."`
|
||||
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."`
|
||||
}
|
||||
|
||||
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
@@ -55,10 +55,8 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
|
||||
}
|
||||
|
||||
msg := envelope.Event.Message
|
||||
var content string
|
||||
if msg.MessageType == "interactive" {
|
||||
content = convertlib.ConvertInteractiveEventContent(msg.Content, msg.Mentions)
|
||||
} else {
|
||||
content := msg.Content
|
||||
if msg.MessageType != "interactive" {
|
||||
content = convertlib.ConvertBodyContent(msg.MessageType, &convertlib.ConvertContext{
|
||||
RawContent: msg.Content,
|
||||
MentionMap: convertlib.BuildMentionKeyMap(msg.Mentions),
|
||||
|
||||
@@ -27,21 +27,6 @@ func Keys() []event.KeyDefinition {
|
||||
AuthTypes: []string{"bot"},
|
||||
RequiredConsoleEvents: []string{"im.message.receive_v1"},
|
||||
},
|
||||
{
|
||||
Key: "card.action.trigger",
|
||||
DisplayName: "Card action",
|
||||
Description: "Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).",
|
||||
EventType: "card.action.trigger",
|
||||
SubscriptionType: event.SubTypeCallback,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(CardActionTriggerOutput{})},
|
||||
},
|
||||
Process: processCardAction,
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
AuthTypes: []string{"bot"},
|
||||
SingleConsumer: true,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, rk := range nativeIMKeys {
|
||||
|
||||
@@ -7,7 +7,6 @@ package events
|
||||
import (
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/events/minutes"
|
||||
"github.com/larksuite/cli/events/task"
|
||||
"github.com/larksuite/cli/events/vc"
|
||||
"github.com/larksuite/cli/events/whiteboard"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
@@ -18,7 +17,6 @@ func init() {
|
||||
all := [][]event.KeyDefinition{
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
task.Keys(),
|
||||
vc.Keys(),
|
||||
whiteboard.Keys(),
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
// TaskUpdateUserAccessV2Data is the Task v2 update event payload under the
|
||||
// standard Lark V2 event envelope.
|
||||
type TaskUpdateUserAccessV2Data struct {
|
||||
EventTypes []string `json:"event_types,omitempty" desc:"Task commit types included in this event" enum:"task_create,task_deleted,task_summary_update,task_desc_update,task_assignees_update,task_followers_update,task_reminders_update,task_start_due_update,task_completed_update"`
|
||||
TaskGUID string `json:"task_guid,omitempty" desc:"Task GUID that changed" kind:"task_guid"`
|
||||
}
|
||||
|
||||
var taskUpdateUserAccessCommitTypes = []string{
|
||||
"task_create",
|
||||
"task_deleted",
|
||||
"task_summary_update",
|
||||
"task_desc_update",
|
||||
"task_assignees_update",
|
||||
"task_followers_update",
|
||||
"task_reminders_update",
|
||||
"task_start_due_update",
|
||||
"task_completed_update",
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const taskSubscriptionPath = "/open-apis/task/v2/task_v2/task_subscription?user_id_type=open_id"
|
||||
|
||||
func taskSubscriptionPreConsume(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
|
||||
if rt == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
if _, err := rt.CallAPI(ctx, "POST", taskSubscriptionPath, nil); err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewNetworkError(
|
||||
errs.SubtypeNetworkTransport,
|
||||
"failed to subscribe task event",
|
||||
).WithCause(err)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
type stubAPIClient struct {
|
||||
err error
|
||||
|
||||
method string
|
||||
path string
|
||||
body interface{}
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *stubAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||
s.method = method
|
||||
s.path = path
|
||||
s.body = body
|
||||
s.calls++
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
|
||||
}
|
||||
|
||||
func TestTaskSubscriptionPreConsumeCallsSubscribeAPI(t *testing.T) {
|
||||
rt := &stubAPIClient{}
|
||||
cleanup, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("taskSubscriptionPreConsume error = %v", err)
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatal("cleanup = non-nil, want nil because task subscription has no unsubscribe API")
|
||||
}
|
||||
if rt.calls != 1 {
|
||||
t.Fatalf("calls = %d, want 1", rt.calls)
|
||||
}
|
||||
if rt.method != "POST" {
|
||||
t.Errorf("method = %q, want POST", rt.method)
|
||||
}
|
||||
if rt.path != taskSubscriptionPath {
|
||||
t.Errorf("path = %q, want %q", rt.path, taskSubscriptionPath)
|
||||
}
|
||||
if rt.body != nil {
|
||||
t.Errorf("body = %#v, want nil", rt.body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskSubscriptionPreConsumeRequiresRuntime(t *testing.T) {
|
||||
_, err := taskSubscriptionPreConsume(context.Background(), nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeUnknown {
|
||||
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskSubscriptionPreConsumePassesThroughAPIError(t *testing.T) {
|
||||
wantErr := errs.NewValidationError(errs.SubtypeFailedPrecondition, "subscription already exists")
|
||||
rt := &stubAPIClient{err: wantErr}
|
||||
|
||||
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
|
||||
if err != wantErr {
|
||||
t.Fatalf("err identity changed: got %T %v, want original %T %v", err, err, wantErr, wantErr)
|
||||
}
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("err = %v, want %v", err, wantErr)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryValidation)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskSubscriptionPreConsumeWrapsUntypedAPIError(t *testing.T) {
|
||||
cause := errors.New("connection reset")
|
||||
rt := &stubAPIClient{err: cause}
|
||||
|
||||
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("err = %v, want cause %v", err, cause)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryNetwork)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package task registers Task-domain EventKeys.
|
||||
package task
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const eventTypeTaskUpdateUserAccessV2 = "task.task.update_user_access_v2"
|
||||
|
||||
// Keys returns all Task-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeTaskUpdateUserAccessV2,
|
||||
DisplayName: "Task updated",
|
||||
Description: "Triggered when tasks visible to the current user or app are created, deleted, or updated",
|
||||
EventType: eventTypeTaskUpdateUserAccessV2,
|
||||
Schema: event.SchemaDef{
|
||||
Native: &event.SchemaSpec{Type: reflect.TypeOf(TaskUpdateUserAccessV2Data{})},
|
||||
},
|
||||
PreConsume: taskSubscriptionPreConsume,
|
||||
Scopes: []string{"task:task:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
RequiredConsoleEvents: []string{eventTypeTaskUpdateUserAccessV2},
|
||||
SingleConsumer: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
)
|
||||
|
||||
func TestKeysTaskUpdateUserAccessMetadata(t *testing.T) {
|
||||
keys := Keys()
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
|
||||
}
|
||||
|
||||
def := keys[0]
|
||||
if def.Key != eventTypeTaskUpdateUserAccessV2 {
|
||||
t.Errorf("Key = %q, want %q", def.Key, eventTypeTaskUpdateUserAccessV2)
|
||||
}
|
||||
if def.EventType != eventTypeTaskUpdateUserAccessV2 {
|
||||
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeTaskUpdateUserAccessV2)
|
||||
}
|
||||
if def.Schema.Native == nil {
|
||||
t.Fatal("Schema.Native is nil")
|
||||
}
|
||||
if def.Schema.Native.Type != reflect.TypeOf(TaskUpdateUserAccessV2Data{}) {
|
||||
t.Errorf("native type = %v, want TaskUpdateUserAccessV2Data", def.Schema.Native.Type)
|
||||
}
|
||||
if def.Process != nil {
|
||||
t.Fatal("Native Task EventKey must not set Process")
|
||||
}
|
||||
if def.PreConsume == nil {
|
||||
t.Fatal("PreConsume is nil")
|
||||
}
|
||||
if !def.SingleConsumer {
|
||||
t.Fatal("SingleConsumer = false, want true")
|
||||
}
|
||||
if !reflect.DeepEqual(def.Scopes, []string{"task:task:read"}) {
|
||||
t.Errorf("Scopes = %#v", def.Scopes)
|
||||
}
|
||||
if !reflect.DeepEqual(def.AuthTypes, []string{"user", "bot"}) {
|
||||
t.Errorf("AuthTypes = %#v", def.AuthTypes)
|
||||
}
|
||||
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeTaskUpdateUserAccessV2}) {
|
||||
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskUpdateUserAccessSchemaAnnotations(t *testing.T) {
|
||||
raw := schemas.WrapV2Envelope(schemas.FromType(reflect.TypeOf(TaskUpdateUserAccessV2Data{})))
|
||||
var schema map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
t.Fatalf("unmarshal schema: %v", err)
|
||||
}
|
||||
|
||||
eventProps := schema["properties"].(map[string]interface{})["event"].(map[string]interface{})["properties"].(map[string]interface{})
|
||||
taskGUID := eventProps["task_guid"].(map[string]interface{})
|
||||
if got := taskGUID["format"]; got != "task_guid" {
|
||||
t.Errorf("task_guid format = %v, want task_guid", got)
|
||||
}
|
||||
|
||||
eventTypes := eventProps["event_types"].(map[string]interface{})
|
||||
items := eventTypes["items"].(map[string]interface{})
|
||||
rawEnum, ok := items["enum"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("event_types item enum missing: %#v", items["enum"])
|
||||
}
|
||||
got := make(map[string]bool, len(rawEnum))
|
||||
for _, v := range rawEnum {
|
||||
got[v.(string)] = true
|
||||
}
|
||||
for _, want := range taskUpdateUserAccessCommitTypes {
|
||||
if !got[want] {
|
||||
t.Errorf("event_types enum missing %q; enum=%v", want, rawEnum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskUpdateUserAccessRegistersCleanly(t *testing.T) {
|
||||
const key = eventTypeTaskUpdateUserAccessV2
|
||||
event.UnregisterKeyForTest(key)
|
||||
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
|
||||
|
||||
for _, def := range Keys() {
|
||||
event.RegisterKey(def)
|
||||
}
|
||||
if _, ok := event.Lookup(key); !ok {
|
||||
t.Fatalf("event.Lookup(%q) not registered", key)
|
||||
}
|
||||
}
|
||||
@@ -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 a typed errs.* error so the JSON envelope carries the structured
|
||||
// fields agents expect.
|
||||
// to an *output.ExitError with type "hook" 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. The dispatcher converts it to a
|
||||
// typed errs.* error; the Layer field carries the denial layer for the
|
||||
// envelope.
|
||||
// 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().
|
||||
//
|
||||
// Layer values:
|
||||
//
|
||||
|
||||
@@ -6,6 +6,7 @@ package auth
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -21,10 +22,7 @@ var TokenRetryCodes = map[int]bool{
|
||||
output.LarkErrTokenExpired: true,
|
||||
}
|
||||
|
||||
// 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.
|
||||
// NeedAuthorizationError is thrown when no valid UAT exists.
|
||||
type NeedAuthorizationError struct {
|
||||
UserOpenId string
|
||||
}
|
||||
@@ -34,31 +32,24 @@ 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. 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.
|
||||
// failure, either as the original auth error or as a wrapped ExitError.
|
||||
func IsNeedUserAuthorizationError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var needAuthErr *NeedAuthorizationError
|
||||
return errors.As(err, &needAuthErr)
|
||||
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)
|
||||
}
|
||||
|
||||
// SecurityPolicyError is preserved as a Go type alias so existing
|
||||
|
||||
@@ -6,7 +6,7 @@ package auth
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestIsNeedUserAuthorizationError(t *testing.T) {
|
||||
@@ -22,16 +22,15 @@ func TestIsNeedUserAuthorizationError(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
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("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("other error", func(t *testing.T) {
|
||||
err := errs.NewNetworkError(errs.SubtypeNetworkTransport, "API call failed: timeout")
|
||||
err := output.ErrNetwork("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 "", NewNeedUserAuthorizationError(opts.UserOpenId)
|
||||
return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId}
|
||||
}
|
||||
|
||||
status := TokenStatus(stored)
|
||||
@@ -86,7 +86,7 @@ func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string,
|
||||
return "", err
|
||||
}
|
||||
if refreshed == nil {
|
||||
return "", NewNeedUserAuthorizationError(opts.UserOpenId)
|
||||
return "", &NeedAuthorizationError{UserOpenId: 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 "", NewNeedUserAuthorizationError(opts.UserOpenId)
|
||||
return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId}
|
||||
}
|
||||
|
||||
// refreshWithLock acquires a file lock before attempting to refresh the token.
|
||||
|
||||
@@ -131,3 +131,31 @@ func requireInTrustedDirs(effectivePath string, trustedDirs []string, label stri
|
||||
}
|
||||
return fmt.Errorf("%s: path %q is not inside any trusted directory", label, effectivePath)
|
||||
}
|
||||
|
||||
// auditFilePermissions rejects world/group-writable modes (always) and
|
||||
// world/group-readable modes (unless allowReadableByOthers is true, which
|
||||
// exec commands typically need for their usual 755 mode).
|
||||
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
|
||||
info, err := vfs.Stat(effectivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
|
||||
}
|
||||
mode := info.Mode().Perm()
|
||||
|
||||
if mode&0o002 != 0 {
|
||||
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if mode&0o020 != 0 {
|
||||
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if allowReadableByOthers {
|
||||
return nil
|
||||
}
|
||||
if mode&0o004 != 0 {
|
||||
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if mode&0o040 != 0 {
|
||||
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -29,31 +29,3 @@ func checkOwnerUID(path, label string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// auditFilePermissions rejects world/group-writable modes (always) and
|
||||
// world/group-readable modes (unless allowReadableByOthers is true, which
|
||||
// exec commands typically need for their usual 755 mode).
|
||||
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
|
||||
info, err := vfs.Stat(effectivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
|
||||
}
|
||||
mode := info.Mode().Perm()
|
||||
|
||||
if mode&0o002 != 0 {
|
||||
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if mode&0o020 != 0 {
|
||||
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if allowReadableByOthers {
|
||||
return nil
|
||||
}
|
||||
if mode&0o004 != 0 {
|
||||
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if mode&0o040 != 0 {
|
||||
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,22 +5,7 @@
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// checkOwnerUID is a no-op on Windows where Unix UID semantics don't apply.
|
||||
func checkOwnerUID(path, label string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// auditFilePermissions skips POSIX permission-bit auditing on Windows because
|
||||
// Go synthesizes mode bits from file attributes rather than NTFS ACLs.
|
||||
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
|
||||
if _, err := vfs.Stat(effectivePath); err != nil {
|
||||
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAssertSecurePath_WindowsIgnoresSyntheticUnixPermissionBits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "secrets-getter.cmd")
|
||||
if err := os.WriteFile(p, []byte("@echo off\r\n"), 0o600); err != nil {
|
||||
t.Fatalf("write temp command: %v", err)
|
||||
}
|
||||
|
||||
got, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: p,
|
||||
Label: "exec provider command",
|
||||
AllowInsecurePath: false,
|
||||
AllowReadableByOthers: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for Windows synthetic mode bits: %v", err)
|
||||
}
|
||||
if got != p {
|
||||
t.Errorf("got %q, want %q", got, p)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -263,16 +264,19 @@ func TestWrapJSONResponseParseError_Nil(t *testing.T) {
|
||||
// Cross-cutting: existing tests already in this file (kept and adjusted below).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// 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"))
|
||||
// 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"))
|
||||
|
||||
var ne *errs.NetworkError
|
||||
if !errors.As(got, &ne) {
|
||||
t.Fatalf("expected *errs.NetworkError for an untyped error, got %T (%v)", got, got)
|
||||
t.Fatalf("expected *errs.NetworkError (legacy ExitError no longer pass-through), got %T (%v)", got, got)
|
||||
}
|
||||
// Sanity: not silently re-classified as JSON-decode.
|
||||
var ie *errs.InternalError
|
||||
|
||||
@@ -19,9 +19,11 @@ 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"
|
||||
)
|
||||
@@ -52,11 +54,16 @@ func (c *APIClient) resolveAccessToken(ctx context.Context, as core.Identity) (s
|
||||
if errors.As(err, &unavailableErr) {
|
||||
return "", newTokenMissingError(as, unavailableErr)
|
||||
}
|
||||
// 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.
|
||||
// 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)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if result.Token == "" {
|
||||
@@ -113,22 +120,24 @@ 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. 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.
|
||||
// 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.
|
||||
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 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.
|
||||
// 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.
|
||||
return nil, WrapDoAPIError(err)
|
||||
}
|
||||
if as.IsBot() {
|
||||
@@ -153,7 +162,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 a typed *errs.NetworkError — callers only receive successful responses.
|
||||
// closed, and returned as an output.ErrNetwork — 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)
|
||||
|
||||
@@ -323,10 +332,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 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.
|
||||
// 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.
|
||||
func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) {
|
||||
resp, err := c.DoAPI(ctx, request)
|
||||
if err != nil {
|
||||
|
||||
@@ -528,7 +528,8 @@ 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}.
|
||||
// *errs.AuthenticationError{Subtype: TokenMissing} rather than the legacy
|
||||
// *output.ExitError envelope.
|
||||
func TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError(t *testing.T) {
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{},
|
||||
@@ -553,22 +554,24 @@ func TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
// 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).
|
||||
type needAuthTokenResolver struct {
|
||||
userOpenID string
|
||||
}
|
||||
|
||||
func (f *needAuthTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, internalauth.NewNeedUserAuthorizationError(f.userOpenID)
|
||||
return nil, &internalauth.NeedAuthorizationError{UserOpenId: f.userOpenID}
|
||||
}
|
||||
|
||||
// TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication
|
||||
// pins that the typed missing-UAT error from the credential chain reaches the
|
||||
// caller as a typed AuthenticationError with the marker and sentinel intact.
|
||||
// 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.
|
||||
func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *testing.T) {
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{},
|
||||
@@ -674,7 +677,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".
|
||||
// "internal", not the legacy "api_error".
|
||||
func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) {
|
||||
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
|
||||
@@ -41,26 +41,6 @@ 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.
|
||||
@@ -82,33 +62,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.
|
||||
// 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).
|
||||
if resp.StatusCode >= 400 && !IsJSONContentType(ct) && ct != "" {
|
||||
return httpStatusError(resp.StatusCode, resp.RawBody)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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,76 +372,6 @@ 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,15 +168,10 @@ func TestBuildDeniedByPath_hybridParentOwnAllowedKeepsAlive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
|
||||
root := buildTree()
|
||||
denied := map[string]cmdpolicy.Denial{
|
||||
@@ -196,33 +191,31 @@ func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
|
||||
t.Fatalf("denied command should return error")
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition)
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatalf("ExitError.Detail required for envelope to render")
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want ExitValidation (%d)", code, output.ExitValidation)
|
||||
if exitErr.Detail.Type != "command_denied" {
|
||||
t.Errorf("envelope error.type = %q, want command_denied", exitErr.Detail.Type)
|
||||
}
|
||||
// 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)
|
||||
// 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)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "plugin:secaudit") {
|
||||
t.Errorf("hint must carry policy_source plugin:secaudit, 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, "secaudit-policy") {
|
||||
t.Errorf("hint must carry rule_name secaudit-policy, got %q", ve.Hint)
|
||||
if detailMap["policy_source"] != "plugin:secaudit" {
|
||||
t.Errorf("detail.policy_source = %v, want plugin:secaudit", detailMap["policy_source"])
|
||||
}
|
||||
|
||||
// Path 2: in-process typed-error view -- the *platform.CommandDeniedError
|
||||
// is preserved as the Cause so errors.As still reaches it.
|
||||
// Path 2: in-process typed-error view.
|
||||
var cd *platform.CommandDeniedError
|
||||
if !errors.As(err, &cd) {
|
||||
t.Fatalf("error chain must expose *platform.CommandDeniedError")
|
||||
@@ -230,6 +223,21 @@ 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,11 +24,12 @@ import (
|
||||
// cobra would intercept the call
|
||||
// with "missing required flag"
|
||||
// before we can return our error
|
||||
// 3. cmd.RunE = denyStub(denial) -- returns a typed
|
||||
// *errs.ValidationError so
|
||||
// 3. cmd.RunE = denyStub(denial) -- returns *output.ExitError so
|
||||
// cmd/root.go's envelope writer
|
||||
// emits structured JSON; the
|
||||
// wrapped error chain still
|
||||
// emits structured JSON (with
|
||||
// error.type = denial.Layer and
|
||||
// detail.reason_code = ReasonCode);
|
||||
// the wrapped error chain still
|
||||
// exposes *platform.CommandDeniedError
|
||||
// via errors.As for in-process
|
||||
// consumers
|
||||
@@ -111,17 +112,42 @@ func CommandDeniedFromDenial(path string, d Denial) *platform.CommandDeniedError
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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 {
|
||||
cd := CommandDeniedFromDenial(path, d)
|
||||
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)
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "command_denied",
|
||||
Message: cd.Error(),
|
||||
Detail: DenialDetailMap(cd),
|
||||
},
|
||||
Err: cd,
|
||||
}
|
||||
}
|
||||
|
||||
// installDenyStub mutates a cobra.Command in place. Unlike cmd/prune.go
|
||||
@@ -195,9 +221,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 {
|
||||
// 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.
|
||||
// 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.
|
||||
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 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
|
||||
// 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
|
||||
// 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,26 +39,25 @@ func TestEnvelope_yamlPolicySourceDoesNotLeakHomePath(t *testing.T) {
|
||||
cmdpolicy.Apply(root, denied)
|
||||
err := leaf.RunE(leaf, nil)
|
||||
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected denial *errs.ValidationError, got %T %v", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected denial ExitError, got %v", err)
|
||||
}
|
||||
// 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)
|
||||
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")
|
||||
}
|
||||
// rule_name carries the disambiguating identifier.
|
||||
if !strings.Contains(ve.Hint, "my-readonly-rule") {
|
||||
t.Errorf("hint must carry rule_name my-readonly-rule, got %q", ve.Hint)
|
||||
if detail["rule_name"] != "my-readonly-rule" {
|
||||
t.Errorf("rule_name = %v, want my-readonly-rule", detail["rule_name"])
|
||||
}
|
||||
// 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)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,14 +80,17 @@ func TestEnvelope_pluginPolicySourceCarriesName(t *testing.T) {
|
||||
cmdpolicy.Apply(root, denied)
|
||||
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError")
|
||||
}
|
||||
// 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)
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
if detail["policy_source"] != "plugin:secaudit" {
|
||||
t.Errorf("policy_source = %v, want plugin:secaudit", detail["policy_source"])
|
||||
}
|
||||
}
|
||||
|
||||
func asString(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -4,22 +4,39 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// 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 errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
|
||||
"%s requires confirmation", action).
|
||||
WithHint("add --yes to confirm")
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,50 +9,53 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestRequireConfirmation_TypedShape(t *testing.T) {
|
||||
func TestRequireConfirmation_EnvelopeShape(t *testing.T) {
|
||||
err := RequireConfirmation("drive +delete")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error")
|
||||
}
|
||||
|
||||
var cre *errs.ConfirmationRequiredError
|
||||
if !errors.As(err, &cre) {
|
||||
t.Fatalf("expected *errs.ConfirmationRequiredError, got %T", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if cre.Category != errs.CategoryConfirmation {
|
||||
t.Errorf("Category = %q, want %q", cre.Category, errs.CategoryConfirmation)
|
||||
if exitErr.Code != output.ExitConfirmationRequired {
|
||||
t.Errorf("Code = %d, want %d", exitErr.Code, output.ExitConfirmationRequired)
|
||||
}
|
||||
if cre.Subtype != errs.SubtypeConfirmationRequired {
|
||||
t.Errorf("Subtype = %q, want %q", cre.Subtype, errs.SubtypeConfirmationRequired)
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("Detail is nil")
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitConfirmationRequired {
|
||||
t.Errorf("ExitCodeOf = %d, want %d", got, output.ExitConfirmationRequired)
|
||||
d := exitErr.Detail
|
||||
if d.Type != "confirmation_required" {
|
||||
t.Errorf("Type = %q, want confirmation_required", d.Type)
|
||||
}
|
||||
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 !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 cre.Hint != "add --yes to confirm" {
|
||||
t.Errorf("Hint = %q, want 'add --yes to confirm'", cre.Hint)
|
||||
if d.Hint != "add --yes to confirm" {
|
||||
t.Errorf("Hint = %q, want 'add --yes to confirm'", d.Hint)
|
||||
}
|
||||
if cre.Risk != errs.RiskHighRiskWrite {
|
||||
t.Errorf("Risk = %q, want %q", cre.Risk, errs.RiskHighRiskWrite)
|
||||
if d.Risk == nil {
|
||||
t.Fatal("Risk is nil")
|
||||
}
|
||||
if cre.Action != "drive +delete" {
|
||||
t.Errorf("Action = %q, want drive +delete", cre.Action)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireConfirmation_JSONShape(t *testing.T) {
|
||||
err := RequireConfirmation("mail +send")
|
||||
var cre *errs.ConfirmationRequiredError
|
||||
if !errors.As(err, &cre) {
|
||||
t.Fatalf("expected *errs.ConfirmationRequiredError, got %T", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
raw, mErr := json.Marshal(cre)
|
||||
raw, mErr := json.Marshal(exitErr.Detail)
|
||||
if mErr != nil {
|
||||
t.Fatalf("marshal: %v", mErr)
|
||||
}
|
||||
@@ -67,14 +70,18 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
|
||||
t.Errorf("unexpected fix_command present in JSON: %s", raw)
|
||||
}
|
||||
|
||||
if back["risk"] != "high-risk-write" {
|
||||
t.Errorf("risk in JSON = %v", back["risk"])
|
||||
risk, ok := back["risk"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("risk block missing in JSON: %s", raw)
|
||||
}
|
||||
if back["action"] != "mail +send" {
|
||||
t.Errorf("action in JSON = %v", back["action"])
|
||||
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"])
|
||||
}
|
||||
// Action-only protocol: no UpgradedBy / fix_command / upgraded_by leak.
|
||||
if _, has := back["upgraded_by"]; has {
|
||||
if _, has := risk["upgraded_by"]; has {
|
||||
t.Errorf("unexpected upgraded_by present in JSON: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ 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"
|
||||
@@ -108,9 +107,9 @@ func TestNewDefault_InvocationProfileMissingSticksAcrossEarlyStrictMode(t *testi
|
||||
if err == nil {
|
||||
t.Fatal("Config() error = nil, want non-nil")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("Config() error type = %T, want *errs.ConfigError", err)
|
||||
t.Fatalf("Config() error type = %T, want *core.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,41 +42,26 @@ func ValidateFileFlag(file, params, data, outputPath string, pageAll bool, httpM
|
||||
|
||||
_, filePath, isStdin := ParseFileFlag(file, "file")
|
||||
if !isStdin && filePath == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: empty file path").
|
||||
WithParam("--file")
|
||||
return output.ErrValidation("--file: empty file path")
|
||||
}
|
||||
|
||||
if outputPath != "" {
|
||||
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"},
|
||||
)
|
||||
return output.ErrValidation("--file and --output are mutually exclusive")
|
||||
}
|
||||
if pageAll {
|
||||
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"},
|
||||
)
|
||||
return output.ErrValidation("--file and --page-all are mutually exclusive")
|
||||
}
|
||||
if isStdin && data == "-" {
|
||||
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"},
|
||||
)
|
||||
return output.ErrValidation("--file and --data cannot both read from stdin")
|
||||
}
|
||||
if isStdin && params == "-" {
|
||||
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"},
|
||||
)
|
||||
return output.ErrValidation("--file and --params cannot both read from stdin")
|
||||
}
|
||||
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
default:
|
||||
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 output.ErrValidation("--file requires POST, PUT, PATCH, or DELETE method")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -98,35 +83,25 @@ func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin boo
|
||||
|
||||
if isStdin {
|
||||
if stdin == nil {
|
||||
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 \"-\"")
|
||||
return nil, output.ErrValidation("--file: stdin is not available")
|
||||
}
|
||||
data, err := io.ReadAll(stdin)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: failed to read stdin: %v", err).
|
||||
WithParam("--file").
|
||||
WithCause(err)
|
||||
return nil, output.ErrValidation("--file: failed to read stdin: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: stdin is empty").
|
||||
WithParam("--file").
|
||||
WithHint("pipe non-empty file content to stdin")
|
||||
return nil, output.ErrValidation("--file: stdin is empty")
|
||||
}
|
||||
fd.AddFile(fieldName, bytes.NewReader(data))
|
||||
} else {
|
||||
f, err := fileIO.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot open file: %s", filePath).
|
||||
WithParam("--file").
|
||||
WithCause(err)
|
||||
return nil, output.ErrValidation("cannot open file: %s", filePath)
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: failed to read %s: %v", filePath, err).
|
||||
WithParam("--file").
|
||||
WithCause(err)
|
||||
return nil, output.ErrValidation("--file: failed to read %s: %v", filePath, err)
|
||||
}
|
||||
fd.AddFile(fieldName, bytes.NewReader(data))
|
||||
}
|
||||
|
||||
@@ -5,49 +5,14 @@ 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
|
||||
@@ -257,7 +222,6 @@ 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -284,19 +248,6 @@ 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) {
|
||||
@@ -308,7 +259,6 @@ 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) {
|
||||
@@ -339,10 +289,6 @@ 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,18 +22,14 @@ func ParseOptionalBody(httpMethod, data string, stdin io.Reader, fileIO fileio.F
|
||||
}
|
||||
resolved, err := ResolveInput(data, stdin, fileIO)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data: %s", err).
|
||||
WithParam("--data").
|
||||
WithCause(err)
|
||||
return nil, output.ErrValidation("--data: %s", err)
|
||||
}
|
||||
if resolved == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var body interface{}
|
||||
if err := json.Unmarshal([]byte(resolved), &body); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data invalid JSON format").
|
||||
WithParam("--data").
|
||||
WithCause(err)
|
||||
return nil, output.ErrValidation("--data invalid JSON format")
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
@@ -45,18 +41,14 @@ 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, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %s", label, err).
|
||||
WithParam(label).
|
||||
WithCause(err)
|
||||
return nil, output.ErrValidation("%s: %s", label, err)
|
||||
}
|
||||
if resolved == "" {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal([]byte(resolved), &result); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s invalid format, expected JSON object", label).
|
||||
WithParam(label).
|
||||
WithCause(err)
|
||||
return nil, output.ErrValidation("%s invalid format, expected JSON object", label)
|
||||
}
|
||||
if result == nil {
|
||||
// `null` unmarshals into a nil map without error; normalize it so the
|
||||
|
||||
@@ -3,40 +3,9 @@
|
||||
|
||||
package cmdutil
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
import "testing"
|
||||
|
||||
func TestParseOptionalBody(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
@@ -51,23 +20,18 @@ 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, fio)
|
||||
got, err := ParseOptionalBody(tt.method, tt.data, nil, nil)
|
||||
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 && got == nil {
|
||||
if !tt.wantNil && !tt.wantErr && got == nil {
|
||||
t.Error("ParseOptionalBody() = nil, want non-nil")
|
||||
}
|
||||
})
|
||||
@@ -75,7 +39,6 @@ func TestParseOptionalBody(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseJSONMap(t *testing.T) {
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
@@ -88,20 +51,15 @@ 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, fio)
|
||||
got, err := ParseJSONMap(tt.input, tt.label, nil, nil)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseJSONMap() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if tt.wantErr {
|
||||
requireJSONInputValidationError(t, err, tt.label)
|
||||
return
|
||||
}
|
||||
if len(got) != tt.wantLen {
|
||||
if !tt.wantErr && 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,10 +19,9 @@ func ParseLangFlag(raw string) (i18n.Lang, error) {
|
||||
}
|
||||
lang, ok := i18n.Parse(raw)
|
||||
if !ok {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
return "", output.ErrValidation(
|
||||
"invalid --lang %q; valid values: %s",
|
||||
raw, strings.Join(i18n.Codes(), ", ")).
|
||||
WithParam("--lang")
|
||||
raw, strings.Join(i18n.Codes(), ", "))
|
||||
}
|
||||
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,12 +187,6 @@ 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())
|
||||
@@ -202,10 +196,10 @@ func LoadMultiAppConfig() (*MultiAppConfig, error) {
|
||||
|
||||
var multi MultiAppConfig
|
||||
if err := json.Unmarshal(data, &multi); err != nil {
|
||||
return nil, fmt.Errorf("invalid config format: %w: %w", ErrMalformedConfig, err)
|
||||
return nil, fmt.Errorf("invalid config format: %w", err)
|
||||
}
|
||||
if len(multi.Apps) == 0 {
|
||||
return nil, fmt.Errorf("invalid config format: no apps: %w", ErrMalformedConfig)
|
||||
return nil, fmt.Errorf("invalid config format: no apps")
|
||||
}
|
||||
return &multi, nil
|
||||
}
|
||||
@@ -243,26 +237,28 @@ 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, errs.NewConfigError(errs.SubtypeNotConfigured, "profile %q not found", profileOverride).
|
||||
WithHint("available profiles: %s", formatProfileNames(raw.ProfileNames()))
|
||||
return nil, &ConfigError{
|
||||
Code: 3,
|
||||
Type: "config",
|
||||
Message: fmt.Sprintf("profile %q not found", profileOverride),
|
||||
Hint: fmt.Sprintf("available profiles: %s", formatProfileNames(raw.ProfileNames())),
|
||||
}
|
||||
}
|
||||
|
||||
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync").
|
||||
WithHint("%s", err.Error()).
|
||||
WithCause(err)
|
||||
return nil, &ConfigError{Code: 3, Type: "config",
|
||||
Message: "appId and appSecret keychain key are out of sync",
|
||||
Hint: err.Error()}
|
||||
}
|
||||
|
||||
secret, err := ResolveSecretInput(app.AppSecret, kc)
|
||||
if err != nil {
|
||||
if errs.IsTyped(err) {
|
||||
return nil, err
|
||||
// Deprecated: legacy *output.ExitError passthrough; removed after typed migration.
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return nil, exitErr
|
||||
}
|
||||
subtype := errs.SubtypeNotConfigured
|
||||
if isMalformedConfigError(err) {
|
||||
subtype = errs.SubtypeInvalidConfig
|
||||
}
|
||||
return nil, errs.NewConfigError(subtype, "%s", err.Error()).WithCause(err)
|
||||
return nil, &ConfigError{Code: 3, Type: "config", Message: err.Error()}
|
||||
}
|
||||
cfg := &CliConfig{
|
||||
ProfileName: app.ProfileName(),
|
||||
@@ -291,8 +287,7 @@ func RequireAuthForProfile(kc keychain.KeychainAccess, profileOverride string) (
|
||||
return nil, err
|
||||
}
|
||||
if cfg.UserOpenId == "" {
|
||||
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 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 cfg, nil
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
)
|
||||
|
||||
@@ -104,7 +103,7 @@ func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for mismatched appId and appSecret keychain key")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("expected ConfigError, got %T: %v", err, err)
|
||||
}
|
||||
@@ -178,7 +177,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 *errs.ConfigError
|
||||
var cfgErr *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")
|
||||
|
||||
22
internal/core/errors.go
Normal file
22
internal/core/errors.go
Normal file
@@ -0,0 +1,22 @@
|
||||
// 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,20 +5,10 @@ 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:
|
||||
@@ -37,15 +27,14 @@ func LoadOrNotConfigured() (*MultiAppConfig, error) {
|
||||
return nil, NotConfiguredError()
|
||||
}
|
||||
// Surface the real cause (parse error, permission denied, etc.)
|
||||
// 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
|
||||
// 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),
|
||||
}
|
||||
return nil, errs.NewConfigError(subtype, "failed to load config: %v", err).WithCause(err)
|
||||
}
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
return nil, NotConfiguredError()
|
||||
@@ -81,14 +70,19 @@ const (
|
||||
func NotConfiguredError() error {
|
||||
ws := CurrentWorkspace()
|
||||
if ws.IsLocal() {
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "not configured").
|
||||
WithHint("%s", localInitHint)
|
||||
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,
|
||||
}
|
||||
// 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
|
||||
@@ -110,10 +104,17 @@ func reconfigureHint() string {
|
||||
func NoActiveProfileError() error {
|
||||
ws := CurrentWorkspace()
|
||||
if ws.IsLocal() {
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").
|
||||
WithHint("%s", localInitHint)
|
||||
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 in %s workspace", ws.Display()).
|
||||
WithHint("%s", agentBindHint)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// saveAndRestoreWorkspace ensures package-level currentWorkspace is reset
|
||||
@@ -26,15 +24,12 @@ func TestNotConfiguredError_Local(t *testing.T) {
|
||||
SetCurrentWorkspace(WorkspaceLocal)
|
||||
|
||||
err := NotConfiguredError()
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if cfgErr.Category != errs.CategoryConfig || cfgErr.Subtype != errs.SubtypeNotConfigured {
|
||||
t.Errorf("category/subtype = %q/%q, want config/not_configured", cfgErr.Category, cfgErr.Subtype)
|
||||
}
|
||||
if cfgErr.Message != "not configured" {
|
||||
t.Errorf("message = %q, want %q", cfgErr.Message, "not configured")
|
||||
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
|
||||
t.Errorf("unexpected detail: %+v", cfgErr)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config init --new") {
|
||||
t.Errorf("local hint should suggest config init --new; got %q", cfgErr.Hint)
|
||||
@@ -49,17 +44,12 @@ func TestNotConfiguredError_OpenClaw(t *testing.T) {
|
||||
SetCurrentWorkspace(WorkspaceOpenClaw)
|
||||
|
||||
err := NotConfiguredError()
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
// The wire subtype stays not_configured; the workspace name only appears
|
||||
// in the message, never in the typed taxonomy.
|
||||
if cfgErr.Subtype != errs.SubtypeNotConfigured {
|
||||
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Message, "openclaw") {
|
||||
t.Errorf("message must name the openclaw workspace; got %q", cfgErr.Message)
|
||||
if cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
}
|
||||
// Hint must point at --help (read first, confirm with user, then bind),
|
||||
// NOT a directly-executable bind command — binding is policy-laden
|
||||
@@ -77,15 +67,12 @@ func TestNotConfiguredError_Hermes(t *testing.T) {
|
||||
SetCurrentWorkspace(WorkspaceHermes)
|
||||
|
||||
err := NotConfiguredError()
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
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 cfgErr.Type != "hermes" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("hermes hint must point to `config bind --help`; got %q", cfgErr.Hint)
|
||||
@@ -97,9 +84,9 @@ func TestNoActiveProfileError_Local(t *testing.T) {
|
||||
SetCurrentWorkspace(WorkspaceLocal)
|
||||
|
||||
err := NoActiveProfileError()
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if cfgErr.Message != "no active profile" {
|
||||
t.Errorf("message = %q, want %q", cfgErr.Message, "no active profile")
|
||||
@@ -111,9 +98,9 @@ func TestNoActiveProfileError_AgentSuggestsBind(t *testing.T) {
|
||||
SetCurrentWorkspace(WorkspaceOpenClaw)
|
||||
|
||||
err := NoActiveProfileError()
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("agent hint must point to `config bind --help`; got %q", cfgErr.Hint)
|
||||
@@ -149,12 +136,9 @@ func TestLoadOrNotConfigured_FileMissing_ReturnsNotConfigured(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeNotConfigured {
|
||||
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if cfgErr.Message != "not configured" {
|
||||
t.Errorf("message = %q, want \"not configured\"", cfgErr.Message)
|
||||
@@ -180,13 +164,9 @@ func TestLoadOrNotConfigured_CorruptFile_PreservesCause(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for corrupt config")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
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)
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Message, "failed to load config") {
|
||||
t.Errorf("corrupt-file message must say 'failed to load config'; got %q", cfgErr.Message)
|
||||
@@ -198,8 +178,4 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
48
internal/errcompat/promote.go
Normal file
48
internal/errcompat/promote.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
32
internal/errcompat/promote_auth.go
Normal file
32
internal/errcompat/promote_auth.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// 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)
|
||||
}
|
||||
79
internal/errcompat/promote_auth_test.go
Normal file
79
internal/errcompat/promote_auth_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
105
internal/errcompat/promote_test.go
Normal file
105
internal/errcompat/promote_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// 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,9 +31,8 @@ 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 a
|
||||
// typed *errs.ValidationError so cmd/root.go emits the right
|
||||
// envelope.
|
||||
// error. Wrap short-circuits via AbortError get converted to
|
||||
// *output.ExitError so cmd/root.go emits the right envelope.
|
||||
//
|
||||
// - **Denial layer / source are populated from cobra annotations
|
||||
// before any hook fires.** populateInvocationDenial reads the
|
||||
@@ -84,8 +83,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 a
|
||||
// typed error wrapping *platform.CommandDeniedError. We
|
||||
// replaced by cmdpolicy.Apply with a denyStub that returns
|
||||
// *output.ExitError 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
|
||||
@@ -136,8 +135,8 @@ func wrapRunE(cmd *cobra.Command, reg *Registry, snapshot CommandViewSource) {
|
||||
err = finalHandler(ctx, inv)
|
||||
}
|
||||
|
||||
// Convert AbortError -> typed *errs.ValidationError so the
|
||||
// envelope writer renders the structured envelope.
|
||||
// Convert AbortError -> *output.ExitError so the envelope writer
|
||||
// renders the structured "hook" type.
|
||||
err = wrapAbortError(err)
|
||||
|
||||
inv.setErr(err)
|
||||
@@ -196,13 +195,17 @@ func runObserverSafe(ctx context.Context, obs ObserverEntry, inv platform.Invoca
|
||||
obs.Fn(ctx, inv)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// The AbortError is preserved as the Cause so errors.As consumers can
|
||||
// still extract HookName / Reason / Detail in process.
|
||||
// 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.
|
||||
func wrapAbortError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -211,16 +214,27 @@ func wrapAbortError(err error) error {
|
||||
if !errors.As(err, &ab) {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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 typed *errs.ValidationError
|
||||
// (failed_precondition). Without this guard, a panicking
|
||||
// recovered and surfaced as a structured *output.ExitError with
|
||||
// type="hook" and reason_code="panic". 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).
|
||||
//
|
||||
@@ -255,17 +269,19 @@ 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 {
|
||||
// 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 = &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),
|
||||
}
|
||||
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,12 +8,10 @@ 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"
|
||||
@@ -210,10 +208,8 @@ func TestInstall_observerPanicIsolated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// A Wrapper returning AbortError surfaces as *output.ExitError with
|
||||
// type="hook" so cmd/root.go's envelope writer can serialise it.
|
||||
func TestInstall_abortErrorBecomesExitError(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
leaf := makeLeaf("+x")
|
||||
@@ -238,28 +234,21 @@ func TestInstall_abortErrorBecomesExitError(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("Wrap aborted; expected error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("AbortError must convert to *errs.ValidationError, got %T %+v", err, err)
|
||||
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)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition)
|
||||
if exitErr.Detail.Type != "hook" {
|
||||
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want ExitValidation (%d)", code, output.ExitValidation)
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
if detail["reason_code"] != "aborted" || detail["hook_name"] != "rejecter" {
|
||||
t.Errorf("detail = %+v", detail)
|
||||
}
|
||||
// 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.
|
||||
// The original AbortError must still be reachable via errors.As.
|
||||
var ab *platform.AbortError
|
||||
if !errors.As(err, &ab) {
|
||||
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")
|
||||
t.Errorf("error chain should expose *platform.AbortError")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,19 +317,13 @@ func (fakeViewSourceByPath) View(c *cobra.Command) platform.CommandView {
|
||||
|
||||
func checkHookName(t *testing.T, err error, want string) {
|
||||
t.Helper()
|
||||
// The abort surfaces as a typed *errs.ValidationError; the original
|
||||
// (namespaced copy of the) AbortError is preserved as its Cause, so
|
||||
// errors.As reaches the attribution the framework wrote.
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError, got %T", err)
|
||||
}
|
||||
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)
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
if detail["hook_name"] != want {
|
||||
t.Errorf("hook_name = %v, want %v", detail["hook_name"], want)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -28,9 +28,8 @@ const (
|
||||
LarkCliService = "lark-cli"
|
||||
)
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
func wrapError(op string, err error) error {
|
||||
if err == nil || errors.Is(err, ErrNotFound) {
|
||||
return err
|
||||
@@ -49,9 +48,7 @@ func wrapError(op string, err error) error {
|
||||
LogAuthError("keychain", op, fmt.Errorf("keychain %s error: %w", op, err))
|
||||
}()
|
||||
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "%s", msg).
|
||||
WithHint("%s", hint).
|
||||
WithCause(err)
|
||||
return output.ErrWithHint(output.ExitAPI, "config", msg, hint)
|
||||
}
|
||||
|
||||
// KeychainAccess abstracts keychain Get/Set/Remove for dependency injection.
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"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 → typed APIError hint pipeline:
|
||||
// Asserts the full wrapError → ExitError.Detail.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 apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("wrapError returned %#v; expected *errs.APIError", err)
|
||||
var ee *output.ExitError
|
||||
if !errors.As(err, &ee) || ee.Detail == nil {
|
||||
t.Fatalf("wrapError returned %#v; expected *output.ExitError with Detail", err)
|
||||
}
|
||||
got := strings.Contains(apiErr.Hint, "keychain-downgrade")
|
||||
got := strings.Contains(ee.Detail.Hint, "keychain-downgrade")
|
||||
if got != tc.wantHint {
|
||||
t.Fatalf("hint mentions keychain-downgrade = %v, want %v\n full hint: %q", got, tc.wantHint, apiErr.Hint)
|
||||
t.Fatalf("hint mentions keychain-downgrade = %v, want %v\n full hint: %q", got, tc.wantHint, ee.Detail.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -113,8 +113,7 @@ type EnumOption struct {
|
||||
}
|
||||
|
||||
// EnumOptions returns the field's allowed values paired with their descriptions
|
||||
// — from enum (with descriptions backfilled from options when the field carries
|
||||
// both forms), or from options when enum is absent — coerced to the canonical
|
||||
// — from enum, or from options when enum is absent — coerced to the canonical
|
||||
// type and ordered: numeric and boolean values are sorted; string values keep
|
||||
// source order (which can encode priority). Uncoercible literals are dropped.
|
||||
// Returns nil when the field declares no enum constraint.
|
||||
@@ -123,14 +122,9 @@ func (f Field) EnumOptions() []EnumOption {
|
||||
var out []EnumOption
|
||||
switch {
|
||||
case len(f.Enum) > 0:
|
||||
// key by raw literal so enum "1" and option 1 align across JSON types
|
||||
desc := make(map[string]string, len(f.Options))
|
||||
for _, o := range f.Options {
|
||||
desc[fmt.Sprintf("%v", o.Value)] = o.Description
|
||||
}
|
||||
for _, e := range f.Enum {
|
||||
if v, ok := coerceLiteral(ct, e); ok {
|
||||
out = append(out, EnumOption{Value: v, Description: desc[fmt.Sprintf("%v", e)]})
|
||||
out = append(out, EnumOption{Value: v})
|
||||
}
|
||||
}
|
||||
case len(f.Options) > 0:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user