From 1ee4cb2b5bb22bf834290c0f989dbfc6957761a2 Mon Sep 17 00:00:00 2001 From: "zhaoyukun.yk" Date: Mon, 1 Jun 2026 19:27:07 +0800 Subject: [PATCH] feat(drive): emit typed error envelopes across the drive domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive-domain errors now leave the CLI as typed, machine-branchable envelopes — a stable `type` plus `subtype` and named fields (param, params, retryable, log_id, hint) — so scripts and AI agents can branch on structure and act on a recovery hint instead of parsing prose. Changes: - Every error produced in the drive domain — validation, file I/O, and the failures returned from its Lark API calls — is emitted as a typed errs.* error; the exit code is derived from the error category. Drive's API calls now go through a shared typed classifier, so failures carry subtype, troubleshooter, a recovery hint, and the request's log_id whether the server returns it in the response body or the x-tt-logid header; an already-typed network/auth error is never downgraded into a generic API error. - Known API conditions (resource conflict, cross-tenant, cross-brand, ...) carry a recovery hint keyed by their error class; a command can refine that hint with command-specific guidance. - Batch partial failures (+push / +pull / +sync, where some items succeed and some fail) now report an honest ok:false multi-status result on stdout — the summary and every per-item outcome stay machine-readable — and exit non-zero, instead of a misleading ok:true success envelope. - Duplicate rel_path conflicts report each colliding path as a structured params entry (RFC 7807 invalid-params style). - Static guards lock the drive path so legacy error construction — direct envelopes or the auto-classifying API helpers — cannot be reintroduced, making drive the template for the remaining domains. Output changes worth noting for consumers: - Error envelopes now carry typed type/subtype and named fields; exit codes follow the error category (malformed or incomplete API responses are reported as internal errors rather than generic API errors). - Batch partial failures (+push / +pull / +sync) emit an ok:false result envelope on stdout (summary + per-item items[]) and exit non-zero; the per-item results stay on stdout rather than in a stderr error envelope. Errors surfaced through shared cross-domain helpers (scope precheck, media import upload, metadata lookup, save-path resolution) are not yet typed; they migrate with the shared layer in a follow-up change. --- .golangci.yml | 32 +- cmd/root.go | 7 + errs/ERROR_CONTRACT.md | 25 +- errs/subtypes.go | 3 +- errs/types.go | 23 +- errs/types_test.go | 65 ++++ internal/errclass/classify.go | 17 ++ internal/errclass/codemeta_drive.go | 17 ++ internal/errclass/codemeta_drive_test.go | 43 +++ internal/output/errors.go | 22 ++ internal/output/exitcode.go | 4 + .../rule_no_legacy_envelope_literal.go | 146 +++++++++ .../rule_no_legacy_runtime_api_call.go | 73 +++++ lint/errscontract/rules_test.go | 284 ++++++++++++++++++ lint/errscontract/scan.go | 2 + shortcuts/common/call_api_typed_test.go | 200 ++++++++++++ shortcuts/common/runner.go | 159 +++++++++- .../common/runner_partial_failure_test.go | 63 ++++ shortcuts/drive/drive_add_comment.go | 130 ++++---- shortcuts/drive/drive_add_comment_test.go | 41 ++- shortcuts/drive/drive_apply_permission.go | 14 +- shortcuts/drive/drive_create_folder.go | 12 +- shortcuts/drive/drive_create_shortcut.go | 14 +- shortcuts/drive/drive_create_shortcut_test.go | 27 +- shortcuts/drive/drive_delete.go | 12 +- shortcuts/drive/drive_download.go | 12 +- .../drive/drive_duplicate_remote_test.go | 71 ++--- shortcuts/drive/drive_errors.go | 89 ++++++ shortcuts/drive/drive_export.go | 26 +- shortcuts/drive/drive_export_common.go | 52 ++-- shortcuts/drive/drive_export_download.go | 4 +- shortcuts/drive/drive_export_test.go | 85 ++++-- shortcuts/drive/drive_import.go | 6 +- shortcuts/drive/drive_import_common.go | 38 +-- shortcuts/drive/drive_inspect.go | 12 +- shortcuts/drive/drive_io_test.go | 44 ++- shortcuts/drive/drive_move.go | 12 +- shortcuts/drive/drive_move_common.go | 14 +- shortcuts/drive/drive_pull.go | 65 ++-- shortcuts/drive/drive_pull_test.go | 114 +++---- shortcuts/drive/drive_push.go | 96 +++--- shortcuts/drive/drive_push_test.go | 41 +-- shortcuts/drive/drive_search.go | 59 ++-- shortcuts/drive/drive_search_test.go | 83 ++--- shortcuts/drive/drive_secure_label.go | 8 +- shortcuts/drive/drive_status.go | 28 +- shortcuts/drive/drive_status_test.go | 13 +- shortcuts/drive/drive_sync.go | 93 ++---- shortcuts/drive/drive_sync_test.go | 188 ++++++------ shortcuts/drive/drive_task_result.go | 41 +-- shortcuts/drive/drive_task_result_test.go | 35 +++ shortcuts/drive/drive_upload.go | 58 ++-- shortcuts/drive/drive_version.go | 30 +- shortcuts/drive/drive_version_test.go | 20 ++ shortcuts/drive/list_remote.go | 46 +-- .../drive_duplicate_sync_workflow_test.go | 10 +- 56 files changed, 2115 insertions(+), 813 deletions(-) create mode 100644 internal/errclass/codemeta_drive.go create mode 100644 internal/errclass/codemeta_drive_test.go create mode 100644 lint/errscontract/rule_no_legacy_envelope_literal.go create mode 100644 lint/errscontract/rule_no_legacy_runtime_api_call.go create mode 100644 shortcuts/common/call_api_typed_test.go create mode 100644 shortcuts/common/runner_partial_failure_test.go create mode 100644 shortcuts/drive/drive_errors.go diff --git a/.golangci.yml b/.golangci.yml index 2a16994f5..f9a51eae8 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -65,10 +65,23 @@ linters: - forbidigo # errs-typed-only enforced on paths already migrated to errs.NewXxxError. # Add a path when its migration is complete. - - path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go) + - 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/calendar/helpers\.go|shortcuts/drive/) 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/drive/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go) + text: errs-no-bare-wrap + linters: + - forbidigo + # errs-no-legacy-helper is drive-only: the shared helpers it bans are + # still used by other domains until their later migration phase. + - path-except: (shortcuts/drive/) + text: errs-no-legacy-helper + linters: + - forbidigo settings: depguard: @@ -94,6 +107,23 @@ linters: msg: >- [errs-typed-only] use errs.NewXxxError(...) builder (see errs/types.go). + # ── legacy shared error helpers banned on drive ── + # These helpers internally produce legacy output.Err* shapes, so they + # are invisible to the errs-typed-only ban above. Drive has migrated its + # calls to typed errs.* (drive-local driveInputStatError / driveSaveError); + # this prevents reintroduction. Other domains still use the shared + # helpers (migrated globally in a later phase), so this is drive-scoped. + - pattern: (common\.FlagErrorf|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b + msg: >- + [errs-no-legacy-helper] these shared helpers emit legacy output.Err* + shapes. Use the typed errs.NewXxxError builders or the drive-local + driveInputStatError / driveSaveError helpers (shortcuts/drive/drive_errors.go). + # ── bare error wraps banned on fully-typed paths ── + - pattern: (fmt\.Errorf|errors\.New)\b + msg: >- + [errs-no-bare-wrap] final errors must be typed (errs.NewXxxError); + wrap a cause with .WithCause(err). Genuine intermediate wraps: + //nolint:forbidigo with a reason. # ── http: shortcuts must not construct raw HTTP requests ── # Bans request / client construction; constants (http.MethodPost, # http.StatusOK) and pure helpers (http.StatusText, http.Header) are diff --git a/cmd/root.go b/cmd/root.go index e86ff069e..af655a394 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -255,6 +255,13 @@ func handleRootError(f *cmdutil.Factory, err error) int { return typedExit } + // Partial-failure (batch / multi-status): the ok:false result envelope is + // already on stdout; set the exit code and write nothing to stderr. + var pfErr *output.PartialFailureError + if errors.As(err, &pfErr) { + return pfErr.Code + } + if exitErr := asExitError(err); exitErr != nil { if !exitErr.Raw { // Raw errors (e.g. from `api` command via output.MarkRaw) diff --git a/errs/ERROR_CONTRACT.md b/errs/ERROR_CONTRACT.md index db9169834..b13bea963 100644 --- a/errs/ERROR_CONTRACT.md +++ b/errs/ERROR_CONTRACT.md @@ -155,7 +155,30 @@ caller scripts. 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`. +belongs in a typed `*errs.XxxError` — or, for a batch result, in the +partial-failure outcome below. + +### Partial failure (batch / multi-status) + +A batch command (e.g. `drive +push` / `+pull` / `+sync`) that processes +many items can finish in a third state, neither full success nor a single +error: some items succeeded and some failed. Its primary output is the +per-item result, so it does **not** belong in a `stderr` error envelope. + +Such a command returns `runtime.OutPartialFailure(data, meta)`, which: + +1. writes the full result to **stdout** as an `ok:false` envelope — the + summary and every per-item outcome (succeeded *and* failed) stay + machine-readable, exactly as a successful `Out(...)` would carry them, + but with `ok` honestly reporting failure; and +2. returns `*output.PartialFailureError`, a typed exit signal the + dispatcher maps to a non-zero exit code while writing nothing further + to `stderr`. + +This is distinct from `ErrBare` (a predicate's one-bit answer) and from a +typed `*errs.XxxError` (a `stderr` error envelope): a partial failure is a +*result*, reported on stdout, that also failed. Consumers branch on +`ok == false` and then read `data.summary` / `data.items[]`. ## Consumers diff --git a/errs/subtypes.go b/errs/subtypes.go index 21a9561b6..68a5dd377 100644 --- a/errs/subtypes.go +++ b/errs/subtypes.go @@ -12,7 +12,8 @@ const ( // CategoryValidation subtypes const ( - SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment) + SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment) + SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment) ) // CategoryAuthentication subtypes diff --git a/errs/types.go b/errs/types.go index e292098af..a253aa9e6 100644 --- a/errs/types.go +++ b/errs/types.go @@ -61,8 +61,22 @@ type TypedError interface { // it is intentionally not serialized. type ValidationError struct { Problem - Param string `json:"param,omitempty"` - Cause error `json:"-"` + Param string `json:"param,omitempty"` + Params []InvalidParam `json:"params,omitempty"` + Cause error `json:"-"` +} + +// InvalidParam is one structured validation diagnostic: the parameter that +// failed (Name) and why (Reason). It mirrors an RFC 7807 "invalid-params" +// item (RFC 7807 §3.1 extension members). +// +// The wire key on ValidationError is "params" rather than "invalid_params" +// because the enclosing envelope already carries type:"validation", so the +// "invalid" qualifier would be redundant on the wire. The Go type keeps the +// InvalidParam prefix because, at package level, the name must self-describe. +type InvalidParam struct { + Name string `json:"name"` + Reason string `json:"reason"` } // Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse @@ -122,6 +136,11 @@ func (e *ValidationError) WithParam(param string) *ValidationError { return e } +func (e *ValidationError) WithParams(params ...InvalidParam) *ValidationError { + e.Params = append(e.Params, params...) + return e +} + func (e *ValidationError) WithCause(cause error) *ValidationError { e.Cause = cause return e diff --git a/errs/types_test.go b/errs/types_test.go index b27d83733..c59a862b1 100644 --- a/errs/types_test.go +++ b/errs/types_test.go @@ -558,6 +558,71 @@ func TestTypedError_UnwrapSymmetry(t *testing.T) { }) } +// TestValidationError_WithParams covers the structured-validation extension: +// WithParams appends InvalidParam items, the scalar Param setter is unaffected, +// and the wire shape nests {name, reason} under "params" (omitted when empty). +func TestValidationError_WithParams(t *testing.T) { + t.Run("appends and exposes fields", func(t *testing.T) { + e := errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rel_path"). + WithParams(errs.InvalidParam{Name: "a.md", Reason: "duplicate"}) + if len(e.Params) != 1 { + t.Fatalf("len(Params) = %d, want 1", len(e.Params)) + } + if e.Params[0].Name != "a.md" { + t.Errorf("Params[0].Name = %q, want %q", e.Params[0].Name, "a.md") + } + if e.Params[0].Reason != "duplicate" { + t.Errorf("Params[0].Reason = %q, want %q", e.Params[0].Reason, "duplicate") + } + }) + + t.Run("appends across multiple calls and returns receiver", func(t *testing.T) { + e := errs.NewValidationError(errs.SubtypeInvalidArgument, "x") + returned := e.WithParams(errs.InvalidParam{Name: "a.md", Reason: "dup"}) + if returned != e { + t.Errorf("WithParams returned different pointer; want same as receiver") + } + e.WithParams( + errs.InvalidParam{Name: "b.md", Reason: "dup"}, + errs.InvalidParam{Name: "c.md", Reason: "dup"}, + ) + if len(e.Params) != 3 { + t.Fatalf("len(Params) = %d after two calls, want 3", len(e.Params)) + } + }) + + t.Run("wire shape nests name and reason under params", func(t *testing.T) { + e := errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rel_path"). + WithParam("--rel-path"). + WithParams(errs.InvalidParam{Name: "a.md", Reason: "duplicate"}) + b, err := json.Marshal(e) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + got := string(b) + for _, want := range []string{ + `"type":"validation"`, + `"param":"--rel-path"`, + `"params":[{"name":"a.md","reason":"duplicate"}]`, + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in %s", want, got) + } + } + }) + + t.Run("empty Params omitted from wire", func(t *testing.T) { + e := errs.NewValidationError(errs.SubtypeInvalidArgument, "x") + b, err := json.Marshal(e) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + if strings.Contains(string(b), `"params"`) { + t.Errorf("empty Params should be omitted from wire; got %s", b) + } + }) +} + func TestBuilderSetter_DefensiveCopy(t *testing.T) { t.Run("WithMissingScopes clones input", func(t *testing.T) { scopes := []string{"docx:document", "im:message:send"} diff --git a/internal/errclass/classify.go b/internal/errclass/classify.go index 177d6952d..a0176b3ec 100644 --- a/internal/errclass/classify.go +++ b/internal/errclass/classify.go @@ -129,6 +129,7 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error { Action: action, } case errs.CategoryAPI: + base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default return &errs.APIError{Problem: base} default: // Fail closed: an unrecognized Category routes to InternalError @@ -231,6 +232,22 @@ func ConfigHint(subtype errs.Subtype) string { return "" } +// APIHint returns the canonical per-subtype recovery hint for a typed APIError +// emitted via BuildAPIError, for API subtypes whose recovery is context-free. +// Context-specific guidance (e.g. a command's flags, an API's own quota) is +// layered on by the caller after BuildAPIError returns and overrides this. +func APIHint(subtype errs.Subtype) string { + switch subtype { + case errs.SubtypeConflict: + return "retry later and avoid concurrent duplicate requests on the same resource" + case errs.SubtypeCrossTenant: + return "operate on source and target within the same tenant and region/unit" + case errs.SubtypeCrossBrand: + return "operate on source and target within the same brand environment" + } + return "" +} + func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError { missing := extractMissingScopes(resp) identity := cc.Identity diff --git a/internal/errclass/codemeta_drive.go b/internal/errclass/codemeta_drive.go new file mode 100644 index 000000000..fe231633b --- /dev/null +++ b/internal/errclass/codemeta_drive.go @@ -0,0 +1,17 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errclass + +import "github.com/larksuite/cli/errs" + +// driveCodeMeta holds drive/docs-service Lark code → CodeMeta mappings. +// Only codes whose meaning is verifiable from repo evidence are registered; +// ambiguous codes fall back to CategoryAPI via BuildAPIError. +// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta. +var driveCodeMeta = map[int]CodeMeta{ + 1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload) + 1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters" +} + +func init() { mergeCodeMeta(driveCodeMeta, "drive") } diff --git a/internal/errclass/codemeta_drive_test.go b/internal/errclass/codemeta_drive_test.go new file mode 100644 index 000000000..a65a94f2c --- /dev/null +++ b/internal/errclass/codemeta_drive_test.go @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errclass + +import ( + "fmt" + "testing" + + "github.com/larksuite/cli/errs" +) + +// TestLookupCodeMeta_DriveCodes pins each drive-service code registered via the +// codemeta_drive.go init() merge to its expected Category/Subtype/Retryable. +// Each case traces to repo evidence (see codemeta_drive.go comments). +func TestLookupCodeMeta_DriveCodes(t *testing.T) { + cases := []struct { + code int + wantCat errs.Category + wantSubtype errs.Subtype + wantRetry bool + }{ + // 1061044: upload with a nonexistent parent folder token. The drive E2E + // (tests_e2e/drive/2026_06_01_errs_migrate_drive_test.go) drives this + // producer via a nonexistent parent folder → referenced resource missing. + {1061044, errs.CategoryAPI, errs.SubtypeNotFound, false}, + // 1069302: comment endpoint's opaque "Invalid or missing parameters" + // (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection. + {1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false}, + } + for _, tc := range cases { + t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) { + meta, ok := LookupCodeMeta(tc.code) + if !ok { + t.Fatalf("code %d not registered in codeMeta", tc.code) + } + if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry { + t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v", + tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry) + } + }) + } +} diff --git a/internal/output/errors.go b/internal/output/errors.go index 0ed2c44a0..69fcd8339 100644 --- a/internal/output/errors.go +++ b/internal/output/errors.go @@ -170,6 +170,28 @@ func ErrBare(code int) *ExitError { return &ExitError{Code: code} } +// PartialFailureError is the exit signal for a batch / multi-status command that +// has already written an ok:false result envelope to stdout. The per-item +// outcomes are the primary, machine-readable output and live on stdout, so the +// dispatcher sets only the exit code and writes nothing to stderr. +// +// It is deliberately distinct from ErrBare (the predicate silent-exit signal) +// so the predicate contract stays narrow, and from a typed *errs.XxxError +// (which owns the stderr error envelope): a partial failure is a result, not an +// error envelope. +type PartialFailureError struct { + Code int +} + +func (e *PartialFailureError) Error() string { + return fmt.Sprintf("partial failure (exit %d)", e.Code) +} + +// PartialFailure builds the partial-failure exit signal with the given code. +func PartialFailure(code int) *PartialFailureError { + return &PartialFailureError{Code: code} +} + // WriteTypedErrorEnvelope writes the JSON error envelope for a typed error. // Each typed error owns its wire shape via its own struct tags: Problem fields // are promoted to the top level through embedding, and extension fields diff --git a/internal/output/exitcode.go b/internal/output/exitcode.go index 6b8c2310b..953a31042 100644 --- a/internal/output/exitcode.go +++ b/internal/output/exitcode.go @@ -61,6 +61,10 @@ func ExitCodeOf(err error) int { if _, ok := errs.ProblemOf(err); ok { return ExitCodeForCategory(errs.CategoryOf(err)) } + var pfErr *PartialFailureError + if errors.As(err, &pfErr) { + return pfErr.Code + } var exitErr *ExitError if errors.As(err, &exitErr) { return exitErr.Code diff --git a/lint/errscontract/rule_no_legacy_envelope_literal.go b/lint/errscontract/rule_no_legacy_envelope_literal.go new file mode 100644 index 000000000..b995b27f2 --- /dev/null +++ b/lint/errscontract/rule_no_legacy_envelope_literal.go @@ -0,0 +1,146 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errscontract + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" +) + +// migratedEnvelopePaths lists the source-tree prefixes that have been migrated +// to the typed errs.* taxonomy. On these paths, constructing a legacy +// output.ExitError / output.ErrDetail envelope literal directly is forbidden — +// call sites must return a typed errs.* error instead. Future domains opt in by +// appending their path prefix here. +var migratedEnvelopePaths = []string{ + "shortcuts/drive/", +} + +// legacyOutputImportPath is the import path of the package that declares the +// legacy ExitError / ErrDetail envelope types. The rule resolves whatever local +// name (default or alias) this path is bound to in each file, so an aliased +// import cannot bypass the check. +const legacyOutputImportPath = "github.com/larksuite/cli/internal/output" + +// CheckNoLegacyEnvelopeLiteral flags direct construction of legacy +// output.ExitError / output.ErrDetail composite literals on migrated paths. +// forbidigo can ban identifiers but not composite literals, so this AST rule +// covers the gap left after a path is migrated to typed errs.* errors. +// +// Path-scoped to migratedEnvelopePaths (mirrors how CheckProblemEmbed restricts +// by path); skips _test.go fixtures. output.ErrBare(...) is a CallExpr, not a +// CompositeLit, so the predicate exit-signal helper is naturally not flagged. +func CheckNoLegacyEnvelopeLiteral(path, src string) []Violation { + if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") { + return nil + } + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, src, parser.ParseComments) + if err != nil { + return nil + } + // Resolve the local name(s) bound to the legacy output import path. A file + // may bind it as the default `output`, an alias (`legacy "...output"`), or a + // dot-import (qualifier becomes ""), in which case ExitError/ErrDetail appear + // as bare unqualified idents. + localNames, dotImported := resolveLegacyOutputNames(file) + var out []Violation + ast.Inspect(file, func(n ast.Node) bool { + lit, ok := n.(*ast.CompositeLit) + if !ok { + return true + } + if name, ok := legacyEnvelopeTypeName(lit.Type, localNames, dotImported); ok { + out = append(out, Violation{ + Rule: "no_legacy_envelope_literal", + Action: ActionReject, + File: path, + Line: fset.Position(lit.Pos()).Line, + Message: "direct construction of legacy output." + name + " is forbidden on migrated paths; return a typed errs.* error (output.ErrBare remains allowed for predicate exit signals)", + Suggestion: "replace the &output." + name + "{...} literal with a typed errs.* constructor " + + "(e.g. errs.NewValidationError / errs.NewAPIError / errs.NewNetworkError)", + }) + } + return true + }) + return out +} + +// isMigratedEnvelopePath reports whether path falls under any migrated path +// prefix in migratedEnvelopePaths. +func isMigratedEnvelopePath(path string) bool { + p := strings.ReplaceAll(path, "\\", "/") + for _, prefix := range migratedEnvelopePaths { + if strings.HasPrefix(p, prefix) || strings.Contains(p, "/"+prefix) { + return true + } + } + return false +} + +// resolveLegacyOutputNames walks the file's import declarations and returns the +// set of local names bound to legacyOutputImportPath, plus whether the path was +// dot-imported. Default imports bind the package's own name ("output"); aliased +// imports bind the alias; dot-imports bind names into the file scope. +func resolveLegacyOutputNames(file *ast.File) (map[string]struct{}, bool) { + names := make(map[string]struct{}) + dotImported := false + for _, imp := range file.Imports { + if imp.Path == nil { + continue + } + p := strings.Trim(imp.Path.Value, "`\"") + if p != legacyOutputImportPath { + continue + } + switch { + case imp.Name == nil: + // Default import: local name is the package name "output". + names["output"] = struct{}{} + case imp.Name.Name == ".": + dotImported = true + case imp.Name.Name == "_": + // Blank import cannot reference the types; ignore. + default: + names[imp.Name.Name] = struct{}{} + } + } + return names, dotImported +} + +// legacyEnvelopeTypeName reports whether a composite-literal Type names the +// legacy ExitError / ErrDetail envelope and returns the bare type name. It +// matches a qualified selector (pkg.ExitError) when pkg is one of the resolved +// local names for the legacy output import, and — when the package was +// dot-imported — also matches a bare unqualified ExitError / ErrDetail ident. +func legacyEnvelopeTypeName(expr ast.Expr, localNames map[string]struct{}, dotImported bool) (string, bool) { + if sel, ok := expr.(*ast.SelectorExpr); ok { + x, ok := sel.X.(*ast.Ident) + if !ok || sel.Sel == nil { + return "", false + } + if _, bound := localNames[x.Name]; !bound { + return "", false + } + return matchLegacyEnvelopeName(sel.Sel.Name) + } + if dotImported { + if ident, ok := expr.(*ast.Ident); ok { + return matchLegacyEnvelopeName(ident.Name) + } + } + return "", false +} + +// matchLegacyEnvelopeName returns the name when it is one of the legacy +// envelope type names. +func matchLegacyEnvelopeName(name string) (string, bool) { + switch name { + case "ExitError", "ErrDetail": + return name, true + } + return "", false +} diff --git a/lint/errscontract/rule_no_legacy_runtime_api_call.go b/lint/errscontract/rule_no_legacy_runtime_api_call.go new file mode 100644 index 000000000..517087142 --- /dev/null +++ b/lint/errscontract/rule_no_legacy_runtime_api_call.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errscontract + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" +) + +// CheckNoLegacyRuntimeAPICall flags calls to the runtime's legacy +// auto-classifying API helpers (CallAPI / DoAPIJSON / DoAPIJSONWithLogID) on +// migrated paths. Those helpers route failures through common.HandleApiResult / +// doAPIJSON, which emit a legacy output.ExitError "api_error" envelope and +// downgrade an already-typed network / auth boundary error into an API error. +// forbidigo's errs-typed-only ban does not see them because they are method +// calls, not output.Err* identifiers — this AST rule covers that gap. +// +// Migrated code must call a typed API wrapper (e.g. drive's driveCallAPI) or use +// runtime.DoAPI + errclass.BuildAPIError directly, so failures classify into +// typed errs.* errors. +// +// Path-scoped to migratedEnvelopePaths; skips _test.go fixtures. A typed wrapper +// like driveCallAPI is an unqualified call (*ast.Ident), not a selector, so it +// is not matched. runtime.DoAPI / runtime.RawAPI are intentionally not listed: +// they return the raw response for the caller to classify and do not emit a +// legacy envelope themselves. +func CheckNoLegacyRuntimeAPICall(path, src string) []Violation { + if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") { + return nil + } + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, src, parser.ParseComments) + if err != nil { + return nil + } + var out []Violation + ast.Inspect(file, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel == nil { + return true + } + if name, ok := matchLegacyRuntimeAPIMethod(sel.Sel.Name); ok { + out = append(out, Violation{ + Rule: "no_legacy_runtime_api_call", + Action: ActionReject, + File: path, + Line: fset.Position(call.Pos()).Line, + Message: "runtime." + name + " emits a legacy output.ExitError api_error envelope and downgrades typed network/auth boundary errors; it is forbidden on migrated paths", + Suggestion: "call the domain's typed API wrapper (e.g. driveCallAPI) or runtime.DoAPI + errclass.BuildAPIError " + + "so failures classify into typed errs.* errors", + }) + } + return true + }) + return out +} + +// matchLegacyRuntimeAPIMethod returns the name when it is one of the runtime's +// legacy auto-classifying API helper methods. +func matchLegacyRuntimeAPIMethod(name string) (string, bool) { + switch name { + case "CallAPI", "DoAPIJSON", "DoAPIJSONWithLogID": + return name, true + } + return "", false +} diff --git a/lint/errscontract/rules_test.go b/lint/errscontract/rules_test.go index ddb77ed28..9beed919d 100644 --- a/lint/errscontract/rules_test.go +++ b/lint/errscontract/rules_test.go @@ -593,3 +593,287 @@ func FooRegisterServiceMapBar(name string, _ interface{}) {} t.Errorf("message must name the offending call: %s", v[0].Message) } } + +// (F) direct legacy output.ExitError / output.ErrDetail literals on migrated +// paths → REJECT; output.ErrBare(...) calls and non-migrated paths pass. + +func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnDrivePath(t *testing.T) { + src := `package drive + +import "github.com/larksuite/cli/internal/output" + +func boom() error { + return &output.ExitError{Code: 1} +} +` + v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation, got %d: %+v", len(v), v) + } + if v[0].Action != ActionReject { + t.Errorf("action = %q, want REJECT", v[0].Action) + } + if !strings.Contains(v[0].Message, "ExitError") { + t.Errorf("message should name the legacy type: %s", v[0].Message) + } +} + +func TestCheckNoLegacyEnvelopeLiteral_RejectsErrDetailLiteralOnDrivePath(t *testing.T) { + src := `package drive + +import "github.com/larksuite/cli/internal/output" + +func boom() *output.ErrDetail { + return &output.ErrDetail{Code: 7} +} +` + v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export_common.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation, got %d: %+v", len(v), v) + } + if !strings.Contains(v[0].Message, "ErrDetail") { + t.Errorf("message should name the legacy type: %s", v[0].Message) + } +} + +func TestCheckNoLegacyEnvelopeLiteral_AllowsErrBareCallOnDrivePath(t *testing.T) { + // output.ErrBare(...) is a CallExpr, not a CompositeLit — must NOT fire. + src := `package drive + +import "github.com/larksuite/cli/internal/output" + +func boom() error { + return output.ErrBare(output.ExitAPI) +} +` + v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src) + if len(v) != 0 { + t.Errorf("ErrBare call should pass, got: %+v", v) + } +} + +func TestCheckNoLegacyEnvelopeLiteral_IgnoresNonMigratedPath(t *testing.T) { + // Same offending literal, but outside the migrated path set → not flagged. + src := `package other + +import "github.com/larksuite/cli/internal/output" + +func boom() error { + return &output.ExitError{Code: 1} +} +` + v := CheckNoLegacyEnvelopeLiteral("shortcuts/calendar/foo.go", src) + if len(v) != 0 { + t.Errorf("non-migrated path should pass, got: %+v", v) + } +} + +func TestCheckNoLegacyEnvelopeLiteral_SkipsTestFiles(t *testing.T) { + src := `package drive + +import "github.com/larksuite/cli/internal/output" + +func boom() error { + return &output.ExitError{Code: 1} +} +` + v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export_test.go", src) + if len(v) != 0 { + t.Errorf("_test.go file should be skipped, got: %+v", v) + } +} + +// TestCheckNoLegacyEnvelopeLiteral_RejectsAliasedImport pins that an aliased +// import of internal/output cannot bypass the rule: the qualifier is resolved +// from the import declaration, not matched against the literal string "output". +func TestCheckNoLegacyEnvelopeLiteral_RejectsAliasedImport(t *testing.T) { + src := `package drive + +import legacy "github.com/larksuite/cli/internal/output" + +func boom() error { + return &legacy.ExitError{Code: 1} +} +` + v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation for aliased import, got %d: %+v", len(v), v) + } + if v[0].Action != ActionReject { + t.Errorf("action = %q, want REJECT", v[0].Action) + } + if !strings.Contains(v[0].Message, "ExitError") { + t.Errorf("message should name the legacy type: %s", v[0].Message) + } +} + +// TestCheckNoLegacyEnvelopeLiteral_NormalImportStillRejected guards against a +// regression where resolving by import path accidentally drops the default +// (non-aliased) `output` case. +func TestCheckNoLegacyEnvelopeLiteral_NormalImportStillRejected(t *testing.T) { + src := `package drive + +import "github.com/larksuite/cli/internal/output" + +func boom() error { + return &output.ExitError{Code: 1} +} +` + v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation for default import, got %d: %+v", len(v), v) + } +} + +// TestCheckNoLegacyEnvelopeLiteral_ErrBareAliasedStillAllowed: output.ErrBare is +// a CallExpr, not a composite literal — even under an alias it must not fire. +func TestCheckNoLegacyEnvelopeLiteral_ErrBareAliasedStillAllowed(t *testing.T) { + src := `package drive + +import legacy "github.com/larksuite/cli/internal/output" + +func boom() error { + return legacy.ErrBare(legacy.ExitAPI) +} +` + v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src) + if len(v) != 0 { + t.Errorf("ErrBare call should pass, got: %+v", v) + } +} + +// TestCheckNoLegacyEnvelopeLiteral_RejectsDotImport: a dot-import surfaces +// ExitError / ErrDetail as bare unqualified idents; the rule must still catch +// the composite literal. +func TestCheckNoLegacyEnvelopeLiteral_RejectsDotImport(t *testing.T) { + src := `package drive + +import . "github.com/larksuite/cli/internal/output" + +func boom() error { + return &ExitError{Code: 1} +} +` + v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation for dot-import, got %d: %+v", len(v), v) + } + if !strings.Contains(v[0].Message, "ExitError") { + t.Errorf("message should name the legacy type: %s", v[0].Message) + } +} + +// TestCheckNoLegacyEnvelopeLiteral_UnrelatedSelectorPasses: a same-named +// selector on an unrelated package (not the legacy output import path) must not +// trigger a false positive. +func TestCheckNoLegacyEnvelopeLiteral_UnrelatedSelectorPasses(t *testing.T) { + src := `package drive + +import "example.com/other/output" + +func boom() error { + return &output.ExitError{Code: 1} +} +` + v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src) + if len(v) != 0 { + t.Errorf("unrelated package selector must not fire, got: %+v", v) + } +} + +func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnDrivePath(t *testing.T) { + src := `package drive + +func boom(runtime *common.RuntimeContext) error { + _, err := runtime.CallAPI("POST", "/x", nil, nil) + return err +} +` + v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation, got %d: %+v", len(v), v) + } + if v[0].Action != ActionReject { + t.Errorf("action = %q, want REJECT", v[0].Action) + } + if !strings.Contains(v[0].Message, "CallAPI") { + t.Errorf("message should name the legacy method: %s", v[0].Message) + } +} + +func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) { + src := `package drive + +func boom(runtime *common.RuntimeContext) error { + _, err := runtime.DoAPIJSONWithLogID("POST", "/x", nil, nil) + return err +} +` + v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_export.go", src) + if len(v) != 1 { + t.Fatalf("expected 1 violation, got %d: %+v", len(v), v) + } + if !strings.Contains(v[0].Message, "DoAPIJSONWithLogID") { + t.Errorf("message should name the legacy method: %s", v[0].Message) + } +} + +func TestCheckNoLegacyRuntimeAPICall_AllowsTypedWrapperCall(t *testing.T) { + // driveCallAPI is an unqualified call (*ast.Ident), not a selector — must NOT fire. + src := `package drive + +func boom(runtime *common.RuntimeContext) error { + _, err := driveCallAPI(runtime, "POST", "/x", nil, nil) + return err +} +` + v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder.go", src) + if len(v) != 0 { + t.Errorf("typed wrapper call must not fire, got: %+v", v) + } +} + +func TestCheckNoLegacyRuntimeAPICall_AllowsRawAPIAndDoAPI(t *testing.T) { + // RawAPI / DoAPI return the raw response for the caller to classify and do + // not emit a legacy envelope — they are not banned. + src := `package drive + +func boom(runtime *common.RuntimeContext) error { + _, _ = runtime.RawAPI("POST", "/x", nil, nil) + _, err := runtime.DoAPI(nil) + return err +} +` + v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_api.go", src) + if len(v) != 0 { + t.Errorf("RawAPI / DoAPI must not fire, got: %+v", v) + } +} + +func TestCheckNoLegacyRuntimeAPICall_IgnoresNonMigratedPath(t *testing.T) { + src := `package im + +func boom(runtime *common.RuntimeContext) error { + _, err := runtime.CallAPI("POST", "/x", nil, nil) + return err +} +` + v := CheckNoLegacyRuntimeAPICall("shortcuts/im/im_send.go", src) + if len(v) != 0 { + t.Errorf("non-migrated path must not fire, got: %+v", v) + } +} + +func TestCheckNoLegacyRuntimeAPICall_SkipsTestFiles(t *testing.T) { + src := `package drive + +func boom(runtime *common.RuntimeContext) error { + _, err := runtime.CallAPI("POST", "/x", nil, nil) + return err +} +` + v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder_test.go", src) + if len(v) != 0 { + t.Errorf("test files must be skipped, got: %+v", v) + } +} diff --git a/lint/errscontract/scan.go b/lint/errscontract/scan.go index bb920d855..dd4c54821 100644 --- a/lint/errscontract/scan.go +++ b/lint/errscontract/scan.go @@ -106,6 +106,8 @@ func ScanRepo(root string) ([]Violation, error) { all = append(all, CheckNoRegistrar(rel, string(src))...) all = append(all, CheckAdHocSubtype(rel, string(src))...) all = append(all, CheckTypedErrorCompleteness(rel, string(src))...) + all = append(all, CheckNoLegacyEnvelopeLiteral(rel, string(src))...) + all = append(all, CheckNoLegacyRuntimeAPICall(rel, string(src))...) // Typed-error invariants — self-scope to errs/ + classify.go. all = append(all, CheckNilSafeError(rel, string(src))...) all = append(all, CheckUnwrapSymmetry(rel, string(src))...) diff --git a/shortcuts/common/call_api_typed_test.go b/shortcuts/common/call_api_typed_test.go new file mode 100644 index 000000000..40925e029 --- /dev/null +++ b/shortcuts/common/call_api_typed_test.go @@ -0,0 +1,200 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" +) + +func newCallAPITypedRuntime(t *testing.T) (*RuntimeContext, *httpmock.Registry) { + t.Helper() + cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"} + f, _, _, reg := cmdutil.TestFactory(t, cfg) + rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+x"}, cfg, f, core.AsUser) + return rt, reg +} + +// TestCallAPITyped_HeaderOnlyLogID pins the P1 fix: when the server returns +// log_id only in the x-tt-logid response header (not in the JSON body), the +// typed error still carries it. The legacy runtime.CallAPI path (body-only) +// dropped it. +func TestCallAPITyped_HeaderOnlyLogID(t *testing.T) { + rt, reg := newCallAPITypedRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/x/y", + Headers: http.Header{ + "Content-Type": []string{"application/json"}, + "X-Tt-Logid": []string{"hdr-log-123"}, + }, + Body: map[string]interface{}{"code": float64(1061044), "msg": "boom"}, // no log_id in body + }) + + _, err := rt.CallAPITyped("POST", "/open-apis/x/y", nil, map[string]any{}) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected a typed errs.* error, got %T: %v", err, err) + } + if p.LogID != "hdr-log-123" { + t.Errorf("LogID = %q, want %q (lifted from x-tt-logid header)", p.LogID, "hdr-log-123") + } +} + +// TestCallAPITyped_BodyLogID confirms body-level log_id still surfaces. +func TestCallAPITyped_BodyLogID(t *testing.T) { + rt, reg := newCallAPITypedRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/x/y", + Body: map[string]interface{}{"code": float64(1061044), "msg": "boom", "log_id": "body-log-9"}, + }) + + _, err := rt.CallAPITyped("POST", "/open-apis/x/y", nil, map[string]any{}) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T: %v", err, err) + } + if p.LogID != "body-log-9" { + t.Errorf("LogID = %q, want body-log-9", p.LogID) + } +} + +// TestCallAPITyped_Success returns the data object on code 0, and does not leak +// the header log_id into the success payload (log_id surfacing is error-path +// only — success output stays identical to the legacy CallAPI). +func TestCallAPITyped_Success(t *testing.T) { + rt, reg := newCallAPITypedRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/x/y", + Headers: http.Header{ + "Content-Type": []string{"application/json"}, + "X-Tt-Logid": []string{"hdr-log-ok"}, + }, + Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{"token": "tok1"}}, + }) + + data, err := rt.CallAPITyped("POST", "/open-apis/x/y", nil, map[string]any{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if data["token"] != "tok1" { + t.Errorf("data[token] = %v, want tok1", data["token"]) + } + if _, leaked := data["log_id"]; leaked { + t.Errorf("success data must not carry log_id, got: %v", data) + } +} + +// TestAPIClassifyContext verifies the classify context is built from the +// runtime: Brand / AppID from config, Identity from the resolved caller, and +// LarkCmd from the running command path. +func TestAPIClassifyContext(t *testing.T) { + cfg := &core.CliConfig{Brand: core.BrandLark, AppID: "cli_x"} + rt := TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "+upload"}, cfg, core.AsUser) + + cc := rt.APIClassifyContext() + if cc.Brand != "lark" { + t.Errorf("Brand = %q, want lark", cc.Brand) + } + if cc.AppID != "cli_x" { + t.Errorf("AppID = %q, want cli_x", cc.AppID) + } + if cc.Identity != "user" { + t.Errorf("Identity = %q, want user", cc.Identity) + } + if cc.LarkCmd != "+upload" { + t.Errorf("LarkCmd = %q, want +upload", cc.LarkCmd) + } + + bot := TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "+push"}, &core.CliConfig{Brand: core.BrandFeishu, AppID: "y"}, core.AsBot) + if got := bot.APIClassifyContext().Identity; got != "bot" { + t.Errorf("bot Identity = %q, want bot", got) + } +} + +// TestCallAPITyped_NonJSON5xx pins that a non-JSON HTTP 5xx (e.g. a gateway 502 +// text/html page) is a retryable network/server_error carrying the header +// log_id — not a mis-parsed internal/invalid_response. +func TestCallAPITyped_NonJSON5xx(t *testing.T) { + rt, reg := newCallAPITypedRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/x/y", + Status: 502, + Headers: http.Header{ + "Content-Type": []string{"text/html"}, + "X-Tt-Logid": []string{"hdr-502"}, + }, + RawBody: []byte("502 Bad Gateway"), + }) + + _, err := rt.CallAPITyped("POST", "/open-apis/x/y", nil, map[string]any{}) + var netErr *errs.NetworkError + if !errors.As(err, &netErr) { + t.Fatalf("expected *errs.NetworkError for non-JSON 5xx, got %T: %v", err, err) + } + if netErr.Subtype != errs.SubtypeNetworkServer { + t.Errorf("subtype = %q, want %q", netErr.Subtype, errs.SubtypeNetworkServer) + } + if !netErr.Retryable { + t.Error("5xx network error must be retryable") + } + if netErr.LogID != "hdr-502" { + t.Errorf("LogID = %q, want hdr-502 (from header)", netErr.LogID) + } +} + +// TestCallAPITyped_5xxNoContentType pins that a 5xx with no Content-Type (which +// the body-only parse would mis-classify as invalid_response) is still a +// retryable network/server_error. +func TestCallAPITyped_5xxNoContentType(t *testing.T) { + rt, reg := newCallAPITypedRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/x/y", + Status: 503, + Headers: http.Header{}, // explicitly no Content-Type header + RawBody: []byte("service unavailable"), + }) + + _, err := rt.CallAPITyped("POST", "/open-apis/x/y", nil, map[string]any{}) + var netErr *errs.NetworkError + if !errors.As(err, &netErr) || netErr.Subtype != errs.SubtypeNetworkServer { + t.Fatalf("expected retryable network/server_error, got %T: %v", err, err) + } + if !netErr.Retryable { + t.Error("5xx network error must be retryable") + } +} + +// TestCallAPITyped_NonObjectJSON pins that a top-level non-object JSON body +// (e.g. "[]") is rejected as an invalid response, never a silent success ack. +func TestCallAPITyped_NonObjectJSON(t *testing.T) { + rt, reg := newCallAPITypedRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/x/y", + RawBody: []byte("[]"), + }) + + _, err := rt.CallAPITyped("POST", "/open-apis/x/y", nil, map[string]any{}) + var intErr *errs.InternalError + if !errors.As(err, &intErr) { + t.Fatalf("expected *errs.InternalError for non-object JSON, got %T: %v", err, err) + } + if intErr.Subtype != errs.SubtypeInvalidResponse { + t.Errorf("subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse) + } +} diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 2a795d550..7b023e6e9 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -26,6 +26,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/output" "github.com/spf13/cobra" @@ -233,6 +234,133 @@ func (ctx *RuntimeContext) CallAPI(method, url string, params map[string]interfa return HandleApiResult(result, err, "API call failed") } +// CallAPITyped is the typed-only replacement for CallAPI: it performs the same +// SDK request (buildRequest → APIClient.DoAPI → DoSDKRequest, identical +// transport and query model to CallAPI) and returns the "data" object, but +// classifies failures into typed errs.* errors via errclass.BuildAPIError. +// +// A transport / auth error from the client boundary is already typed and passes +// through unchanged; a non-zero API response code is classified into a typed +// error carrying subtype / code / log_id. Unlike CallAPI it never emits a legacy +// output.ExitError envelope, and never downgrades a typed network/auth error. +// +// It lifts x-tt-logid from the response header (which the body-only parse drops) +// so log_id surfaces on the typed error even when the server returns it only in +// the header. +func (ctx *RuntimeContext) CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) { + ac, err := ctx.getAPIClient() + if err != nil { + return nil, typedOrInternal(err) + } + resp, err := ac.DoAPI(ctx.ctx, ctx.buildRequest(method, url, params, data)) + if err != nil { + return nil, typedOrInternal(err) + } + return ctx.ClassifyAPIResponse(resp) +} + +// ClassifyAPIResponse turns a raw *larkcore.ApiResp into the "data" object or a +// typed errs.* error. It is the shared response classifier for typed API paths +// — used by CallAPITyped and by callers that drive the request themselves +// (e.g. file upload via DoAPI). It: +// +// 1. parses the JSON body; an unparseable body on an HTTP error status (a +// gateway 5xx text/html page, an empty body, a missing Content-Type) is +// classified by status — 5xx → retryable network/server_error, 404 → +// not_found, other 4xx → api error — not a misleading invalid-response +// internal error; +// 2. rejects a top-level non-object JSON ([], null, scalar) as an +// invalid-response internal error — never a silent success ack; +// 3. lifts x-tt-logid from the response header onto the typed error so log_id +// surfaces even when the body omits it; +// 4. classifies a non-zero API code via errclass.BuildAPIError, and treats any +// HTTP error status that parsed to code==0 as a status error. +// +// The success "data" object is returned untouched. On a non-zero API code the +// data is returned alongside the typed error, since the response can still +// carry fields a caller needs on failure (e.g. the file_token an overwrite +// returned, for token-stability handling). +func (ctx *RuntimeContext) ClassifyAPIResponse(resp *larkcore.ApiResp) (map[string]interface{}, error) { + logID, _ := logIDFromHeader(resp)["log_id"].(string) + + result, parseErr := client.ParseJSONResponse(resp) + if parseErr != nil { + if resp.StatusCode >= 400 { + return nil, httpStatusError(resp.StatusCode, resp.RawBody, logID) + } + return nil, client.WrapJSONResponseParseError(parseErr, resp.RawBody) + } + resultMap, ok := result.(map[string]interface{}) + if !ok { + e := errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned a non-object JSON response") + if logID != "" { + e = e.WithLogID(logID) + } + return nil, e + } + if logID != "" { + if _, present := resultMap["log_id"]; !present { + resultMap["log_id"] = logID + } + } + out, _ := resultMap["data"].(map[string]interface{}) + if apiErr := errclass.BuildAPIError(resultMap, ctx.APIClassifyContext()); apiErr != nil { + return out, apiErr + } + if resp.StatusCode >= 400 { + return out, httpStatusError(resp.StatusCode, resp.RawBody, logID) + } + return out, nil +} + +// httpStatusError classifies an HTTP error status whose body is not a usable +// API envelope: 5xx → retryable network/server_error, 404 → not_found, other +// 4xx → api error. The x-tt-logid (when present) is attached for diagnosis. +func httpStatusError(status int, rawBody []byte, logID string) error { + body := TruncateStr(strings.TrimSpace(string(rawBody)), 500) + if status >= 500 { + e := errs.NewNetworkError(errs.SubtypeNetworkServer, "HTTP %d: %s", status, body).WithCode(status).WithRetryable() + if logID != "" { + e = e.WithLogID(logID) + } + return e + } + subtype := errs.SubtypeUnknown + if status == http.StatusNotFound { + subtype = errs.SubtypeNotFound + } + e := errs.NewAPIError(subtype, "HTTP %d: %s", status, body).WithCode(status) + if logID != "" { + e = e.WithLogID(logID) + } + return e +} + +// typedOrInternal passes an already-typed errs.* error through unchanged and +// lifts a still-untyped one to a typed internal error, so CallAPITyped never +// returns a bare/legacy error. +func typedOrInternal(err error) error { + if _, ok := errs.ProblemOf(err); ok { + return err + } + return errs.WrapInternal(err) +} + +// APIClassifyContext builds the errclass.ClassifyContext for the running command +// from the runtime config and resolved identity. +func (ctx *RuntimeContext) APIClassifyContext() errclass.ClassifyContext { + larkCmd := "" + if ctx.Cmd != nil { + larkCmd = strings.TrimPrefix(ctx.Cmd.CommandPath(), "lark ") + } + return errclass.ClassifyContext{ + Brand: string(ctx.Config.Brand), + AppID: ctx.Config.AppID, + Identity: string(ctx.As()), + LarkCmd: larkCmd, + } +} + // Deprecated: RawAPI uses an internal HTTP wrapper with limited control over request/response. // Prefer DoAPI for new code — it calls the Lark SDK directly and supports file upload/download options. // @@ -552,28 +680,47 @@ func (ctx *RuntimeContext) ValidatePath(path string) error { // Out prints a success JSON envelope to stdout. func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) { - ctx.emit(data, meta, false) + ctx.emit(data, meta, false, true) } // OutRaw prints a success JSON envelope to stdout with HTML escaping disabled. // Use this instead of Out when the data contains XML/HTML content (e.g. document bodies) // that should be preserved as-is in JSON output. func (ctx *RuntimeContext) OutRaw(data interface{}, meta *output.Meta) { - ctx.emit(data, meta, true) + ctx.emit(data, meta, true, true) +} + +// OutPartialFailure writes an ok:false multi-status result envelope to stdout +// and returns the partial-failure exit signal. Use it for batch operations +// where some items failed but the per-item outcomes are the primary output: +// the full result (summary + per-item statuses) stays machine-readable on +// stdout, the process exits non-zero, and nothing is written to stderr. +// +// It is the typed alternative to `Out(...)` + `output.ErrBare(...)` — the +// envelope's ok field honestly reports failure instead of a misleading +// ok:true, and the exit signal is distinct from the predicate-only ErrBare. +func (ctx *RuntimeContext) OutPartialFailure(data interface{}, meta *output.Meta) error { + ctx.emit(data, meta, false, false) + if ctx.outputErr != nil { + return ctx.outputErr + } + return output.PartialFailure(output.ExitAPI) } -// emit is the shared success-path emitter. raw=true disables JSON HTML escaping so -// XML/HTML payloads (e.g. DocxXML bodies) are preserved verbatim; otherwise behavior +// emit is the shared stdout envelope emitter; ok sets the envelope's ok field +// (true for success, false for a partial-failure result). raw=true disables JSON +// HTML escaping so XML/HTML payloads (e.g. DocxXML bodies) are preserved +// verbatim; otherwise behavior // is identical — content-safety scanning and race-safe first-error capture via // outputErrOnce apply in both modes. -func (ctx *RuntimeContext) emit(data interface{}, meta *output.Meta, raw bool) { +func (ctx *RuntimeContext) emit(data interface{}, meta *output.Meta, raw, ok bool) { scanResult := output.ScanForSafety(ctx.Cmd.CommandPath(), data, ctx.IO().ErrOut) if scanResult.Blocked { ctx.outputErrOnce.Do(func() { ctx.outputErr = scanResult.BlockErr }) return } - env := output.Envelope{OK: true, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()} + env := output.Envelope{OK: ok, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()} if scanResult.Alert != nil { env.ContentSafetyAlert = scanResult.Alert } diff --git a/shortcuts/common/runner_partial_failure_test.go b/shortcuts/common/runner_partial_failure_test.go new file mode 100644 index 000000000..1be71c255 --- /dev/null +++ b/shortcuts/common/runner_partial_failure_test.go @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +// TestOutPartialFailure pins the batch / multi-status contract: the result +// rides on stdout as an ok:false envelope (carrying the full payload), and the +// returned error is the typed partial-failure exit signal (ExitAPI), distinct +// from the predicate-only ErrBare. +func TestOutPartialFailure(t *testing.T) { + cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"} + f, stdout, _, _ := cmdutil.TestFactory(t, cfg) + rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+push"}, cfg, f, core.AsUser) + + payload := map[string]interface{}{ + "summary": map[string]interface{}{"uploaded": 1, "failed": 1}, + "items": []map[string]interface{}{ + {"rel_path": "a.txt", "action": "uploaded"}, + {"rel_path": "b.txt", "action": "failed", "error": "boom"}, + }, + } + + err := rt.OutPartialFailure(payload, nil) + + // 1) typed partial-failure exit signal + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err) + } + if pfErr.Code != output.ExitAPI { + t.Errorf("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI) + } + + // 2) stdout envelope reports ok:false but still carries the full payload + // (both the succeeded and failed items) — consistent with a success Out(). + var env struct { + OK bool `json:"ok"` + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("unmarshal stdout envelope: %v\nstdout: %s", err, stdout.String()) + } + if env.OK { + t.Errorf("ok must be false on partial failure, got ok:true\nstdout: %s", stdout.String()) + } + items, _ := env.Data["items"].([]interface{}) + if len(items) != 2 { + t.Fatalf("both succeeded and failed items must ride on stdout, got %d items\nstdout: %s", len(items), stdout.String()) + } +} diff --git a/shortcuts/drive/drive_add_comment.go b/shortcuts/drive/drive_add_comment.go index b40ca959d..337ed27b3 100644 --- a/shortcuts/drive/drive_add_comment.go +++ b/shortcuts/drive/drive_add_comment.go @@ -11,7 +11,7 @@ import ( "strings" "unicode/utf8" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -152,13 +152,13 @@ var DriveAddComment = common.Shortcut{ if docRef.Kind == "sheet" { blockID := strings.TrimSpace(runtime.Str("block-id")) if blockID == "" { - return output.ErrValidation("--block-id is required for sheet comments (format: !, e.g. a281f9!D6)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id is required for sheet comments (format: !, e.g. a281f9!D6)").WithParam("--block-id") } if _, err := parseSheetCellRef(blockID); err != nil { return err } if runtime.Bool("full-comment") || strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" { - return output.ErrValidation("--full-comment and --selection-with-ellipsis are not applicable for sheet comments; use --block-id with ! format") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--full-comment and --selection-with-ellipsis are not applicable for sheet comments; use --block-id with ! format") } return nil } @@ -167,20 +167,20 @@ var DriveAddComment = common.Shortcut{ return err } if runtime.Bool("full-comment") { - return output.ErrValidation("--full-comment is not applicable for slide comments; use --block-id !") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--full-comment is not applicable for slide comments; use --block-id !") } if strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" { - return output.ErrValidation("--selection-with-ellipsis is not applicable for slide comments; use --block-id !") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--selection-with-ellipsis is not applicable for slide comments; use --block-id !") } return nil } selection := runtime.Str("selection-with-ellipsis") blockID := strings.TrimSpace(runtime.Str("block-id")) if strings.TrimSpace(selection) != "" && blockID != "" { - return output.ErrValidation("--selection-with-ellipsis and --block-id are mutually exclusive") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--selection-with-ellipsis and --block-id are mutually exclusive") } if runtime.Bool("full-comment") && (strings.TrimSpace(selection) != "" || blockID != "") { - return output.ErrValidation("--full-comment cannot be used with --selection-with-ellipsis or --block-id") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--full-comment cannot be used with --selection-with-ellipsis or --block-id") } mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID) @@ -188,7 +188,7 @@ var DriveAddComment = common.Shortcut{ return validateFileCommentMode(mode, "") } if mode == commentModeLocal && docRef.Kind == "doc" { - return output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, and slides; old doc format only supports full comments") } return nil @@ -398,7 +398,7 @@ var DriveAddComment = common.Shortcut{ } blockID = match.AnchorBlockID if strings.TrimSpace(blockID) == "" { - return output.Errorf(output.ExitAPI, "api_error", "locate-doc response missing anchor_block_id") + return errs.NewInternalError(errs.SubtypeInvalidResponse, "locate-doc response missing anchor_block_id") } selectedMatch = idx fmt.Fprintf(runtime.IO().ErrOut, "Locate-doc matched %d block(s); using match #%d (%s)\n", len(locateResult.Matches), idx, blockID) @@ -418,7 +418,7 @@ var DriveAddComment = common.Shortcut{ fmt.Fprintf(runtime.IO().ErrOut, "Creating full comment in %s\n", common.MaskToken(target.FileToken)) } - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "POST", requestPath, nil, @@ -473,7 +473,7 @@ func resolveCommentMode(explicitFullComment bool, selection, blockID string) com func parseCommentDocRef(input, docType string) (commentDocRef, error) { raw := strings.TrimSpace(input) if raw == "" { - return commentDocRef{}, output.ErrValidation("--doc cannot be empty") + return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc cannot be empty").WithParam("--doc") } if token, ok := extractURLToken(raw, "/wiki/"); ok { @@ -495,16 +495,16 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) { return commentDocRef{Kind: "doc", Token: token}, nil } if strings.Contains(raw, "://") { - return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx/file/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides", raw) + return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a doc/docx/file/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides", raw).WithParam("--doc") } if strings.ContainsAny(raw, "/?#") { - return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a token with --type, or a wiki URL", raw) + return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a token with --type, or a wiki URL", raw).WithParam("--doc") } // Bare token: --type is required. docType = strings.TrimSpace(docType) if docType == "" { - return commentDocRef{}, output.ErrValidation("--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides)") + return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides)").WithParam("--type") } return commentDocRef{Kind: docType, Token: raw}, nil } @@ -519,7 +519,7 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i if mode == commentModeLocal { switch docRef.Kind { case "doc": - return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments") + return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, and slides; old doc format only supports full comments") case "file": if err := validateFileCommentMode(mode, ""); err != nil { return resolvedCommentTarget{}, err @@ -535,7 +535,7 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i } fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(docRef.Token)) - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": docRef.Token}, @@ -549,13 +549,13 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i objType := common.GetString(node, "obj_type") objToken := common.GetString(node, "obj_token") if objType == "" || objToken == "" { - return resolvedCommentTarget{}, output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data") + return resolvedCommentTarget{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data") } if objType == "slides" && mode == commentModeFull { - return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but slide comments require --block-id !; --full-comment is not applicable", objType) + return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but slide comments require --block-id !; --full-comment is not applicable", objType) } if objType == "slides" && strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" { - return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but --selection-with-ellipsis is not applicable for slide comments; use --block-id !", objType) + return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but --selection-with-ellipsis is not applicable for slide comments; use --block-id !", objType) } if objType == "sheet" { // Sheet comments are handled via the sheet fast path in Execute. @@ -592,10 +592,10 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i }, nil } if mode == commentModeLocal && objType != "docx" { - return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but local comments only support docx, sheet, and slides; for sheet use --block-id !, for slides use --block-id !", objType) + return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but local comments only support docx, sheet, and slides; for sheet use --block-id !, for slides use --block-id !", objType) } if mode == commentModeFull && objType != "docx" && objType != "doc" { - return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/file/sheet/slides", objType) + return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but comments only support doc/docx/file/sheet/slides", objType) } fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken)) @@ -663,16 +663,14 @@ func parseLocateDocResult(result map[string]interface{}) locateDocResult { func selectLocateMatch(result locateDocResult) (locateDocMatch, int, error) { if len(result.Matches) == 0 { - return locateDocMatch{}, 0, output.ErrValidation("locate-doc did not find any matching block") + return locateDocMatch{}, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "locate-doc did not find any matching block").WithParam("--selection-with-ellipsis") } if len(result.Matches) > 1 { - return locateDocMatch{}, 0, output.ErrWithHint( - output.ExitValidation, - "ambiguous_match", - fmt.Sprintf("locate-doc matched %d blocks:\n%s", len(result.Matches), formatLocateCandidates(result.Matches)), - "narrow --selection-with-ellipsis until only one block matches", - ) + return locateDocMatch{}, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, + "locate-doc matched %d blocks:\n%s", len(result.Matches), formatLocateCandidates(result.Matches)). + WithHint("narrow --selection-with-ellipsis until only one block matches"). + WithParam("--selection-with-ellipsis") } return result.Matches[0], 1, nil @@ -705,15 +703,15 @@ func summarizeLocateMatch(match locateDocMatch) string { func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) { if strings.TrimSpace(raw) == "" { - return nil, output.ErrValidation("--content cannot be empty") + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content cannot be empty").WithParam("--content") } var inputs []commentReplyElementInput if err := json.Unmarshal([]byte(raw), &inputs); err != nil { - return nil, output.ErrValidation("--content is not valid JSON: %s\nexample: --content '[{\"type\":\"text\",\"text\":\"文本信息\"}]'", err) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '[{\"type\":\"text\",\"text\":\"文本信息\"}]'", err).WithParam("--content") } if len(inputs) == 0 { - return nil, output.ErrValidation("--content must contain at least one reply element") + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must contain at least one reply element").WithParam("--content") } replyElements := make([]map[string]interface{}, 0, len(inputs)) @@ -724,7 +722,7 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) { switch elementType { case "text": if strings.TrimSpace(input.Text) == "" { - return nil, output.ErrValidation("--content element #%d type=text requires non-empty text", index) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content element #%d type=text requires non-empty text", index).WithParam("--content") } // Measure the raw rune count of the user input — that is what // the server actually counts. byte width and post-escape form @@ -734,13 +732,11 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) { runes := utf8.RuneCountInString(input.Text) totalRunes += runes if totalRunes > maxCommentTotalRunes { - return nil, output.ErrWithHint( - output.ExitValidation, - "text_too_long", - fmt.Sprintf("--content reply_elements text totals %d characters at element #%d (this element: %d); the server caps the combined length at %d characters across ALL reply_elements", - totalRunes, index, runes, maxCommentTotalRunes), - fmt.Sprintf("shorten the comment so the combined text across all reply_elements fits within %d characters. The server enforces this cap on the TOTAL — splitting one long element into multiple smaller text elements does NOT help (they all add up against the same %d-rune budget). Server returns an opaque [1069302] on overflow, so this check is pre-flight; no escape transform changes the count (server reads raw runes).", maxCommentTotalRunes, maxCommentTotalRunes), - ) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--content reply_elements text totals %d characters at element #%d (this element: %d); the server caps the combined length at %d characters across ALL reply_elements", + totalRunes, index, runes, maxCommentTotalRunes). + WithHint("shorten the comment so the combined text across all reply_elements fits within %d characters. The server enforces this cap on the TOTAL — splitting one long element into multiple smaller text elements does NOT help (they all add up against the same %d-rune budget). Server returns an opaque [1069302] on overflow, so this check is pre-flight; no escape transform changes the count (server reads raw runes).", maxCommentTotalRunes, maxCommentTotalRunes). + WithParam("--content") } // Escape '<' and '>' so the rendered comment displays them as // literal characters instead of being interpreted as markup @@ -754,7 +750,7 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) { case "mention_user": mentionUser := firstNonEmptyString(input.MentionUser, input.Text) if mentionUser == "" { - return nil, output.ErrValidation("--content element #%d type=mention_user requires text or mention_user", index) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content element #%d type=mention_user requires text or mention_user", index).WithParam("--content") } replyElements = append(replyElements, map[string]interface{}{ "type": "mention_user", @@ -763,14 +759,14 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) { case "link": link := firstNonEmptyString(input.Link, input.Text) if link == "" { - return nil, output.ErrValidation("--content element #%d type=link requires text or link", index) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content element #%d type=link requires text or link", index).WithParam("--content") } replyElements = append(replyElements, map[string]interface{}{ "type": "link", "link": link, }) default: - return nil, output.ErrValidation("--content element #%d has unsupported type %q; allowed values: text, mention_user, link", index, input.Type) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content element #%d has unsupported type %q; allowed values: text, mention_user, link", index, input.Type).WithParam("--content") } } @@ -827,17 +823,17 @@ func anchorBlockIDForDryRun(blockID string) string { func parseSlidesBlockRef(blockID string) (string, string, error) { blockID = strings.TrimSpace(blockID) if blockID == "" { - return "", "", output.ErrValidation("slide comments require --block-id in ! format") + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "slide comments require --block-id in ! format").WithParam("--block-id") } parts := strings.SplitN(blockID, "!", 2) if len(parts) != 2 { - return "", "", output.ErrValidation("slide --block-id must be ! (e.g. shape!bPq), got %q", blockID) + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "slide --block-id must be ! (e.g. shape!bPq), got %q", blockID).WithParam("--block-id") } parsedType := strings.TrimSpace(parts[0]) parsedID := strings.TrimSpace(parts[1]) if parsedType == "" || parsedID == "" { - return "", "", output.ErrValidation("slide --block-id must be ! (e.g. shape!bPq), got %q", blockID) + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "slide --block-id must be ! (e.g. shape!bPq), got %q", blockID).WithParam("--block-id") } return parsedID, parsedType, nil } @@ -865,7 +861,7 @@ func firstPresentValue(m map[string]interface{}, keys ...string) interface{} { func parseSheetCellRef(input string) (*sheetAnchor, error) { parts := strings.SplitN(input, "!", 2) if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return nil, output.ErrValidation("--block-id for sheet must be ! (e.g. a281f9!D6), got %q", input) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id for sheet must be ! (e.g. a281f9!D6), got %q", input).WithParam("--block-id") } sheetID := parts[0] cell := strings.TrimSpace(parts[1]) @@ -876,7 +872,7 @@ func parseSheetCellRef(input string) (*sheetAnchor, error) { i++ } if i == 0 || i >= len(cell) { - return nil, output.ErrValidation("--block-id cell reference %q is invalid (expected e.g. D6)", cell) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id cell reference %q is invalid (expected e.g. D6)", cell).WithParam("--block-id") } colStr := strings.ToUpper(cell[:i]) rowStr := cell[i:] @@ -890,7 +886,7 @@ func parseSheetCellRef(input string) (*sheetAnchor, error) { row, err := strconv.Atoi(rowStr) if err != nil || row < 1 { - return nil, output.ErrValidation("--block-id row %q is invalid (must be >= 1)", rowStr) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id row %q is invalid (must be >= 1)", rowStr).WithParam("--block-id") } row-- // convert to 0-based @@ -898,7 +894,7 @@ func parseSheetCellRef(input string) (*sheetAnchor, error) { } func fetchCommentTargetFileTitle(runtime *common.RuntimeContext, fileToken string) (string, error) { - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "POST", "/open-apis/drive/v1/metas/batch_query", nil, @@ -917,11 +913,11 @@ func fetchCommentTargetFileTitle(runtime *common.RuntimeContext, fileToken strin metas := common.GetSlice(data, "metas") if len(metas) == 0 { - return "", output.Errorf(output.ExitAPI, "api_error", "drive metas.batch_query returned no metadata for file %s", common.MaskToken(fileToken)) + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "drive metas.batch_query returned no metadata for file %s", common.MaskToken(fileToken)) } meta, ok := metas[0].(map[string]interface{}) if !ok { - return "", output.Errorf(output.ExitAPI, "api_error", "drive metas.batch_query returned unexpected metadata format for file %s", common.MaskToken(fileToken)) + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "drive metas.batch_query returned unexpected metadata format for file %s", common.MaskToken(fileToken)) } return common.GetString(meta, "title"), nil } @@ -936,23 +932,19 @@ func ensureSupportedFileCommentTarget(runtime *common.RuntimeContext, fileToken return title, extension, nil } if strings.TrimSpace(title) == "" { - return "", "", output.ErrWithHint( - output.ExitValidation, - "unsupported_file_comment_type", - "drive +add-comment does not support comments for this Drive file type yet; the file metadata did not return a title", - "file comments currently support full comments only for these extensions: "+supportedFileCommentExtensionsText(), - ) + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, + "drive +add-comment does not support comments for this Drive file type yet; the file metadata did not return a title"). + WithHint("file comments currently support full comments only for these extensions: " + supportedFileCommentExtensionsText()). + WithParam("--doc") } extensionLabel := extension if extensionLabel == "" { extensionLabel = "no extension" } - return "", "", output.ErrWithHint( - output.ExitValidation, - "unsupported_file_comment_type", - fmt.Sprintf("drive +add-comment does not support comments for this Drive file type yet; got %q (%s)", title, extensionLabel), - "file comments currently support full comments only for these extensions: "+supportedFileCommentExtensionsText(), - ) + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, + "drive +add-comment does not support comments for this Drive file type yet; got %q (%s)", title, extensionLabel). + WithHint("file comments currently support full comments only for these extensions: " + supportedFileCommentExtensionsText()). + WithParam("--doc") } func fileCommentExtension(title string) string { @@ -993,9 +985,9 @@ func validateFileCommentMode(mode commentMode, resolvedObjType string) error { return nil } if resolvedObjType != "" { - return output.ErrValidation("wiki resolved to %q, but file comments only support full comments; omit --block-id and --selection-with-ellipsis", resolvedObjType) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but file comments only support full comments; omit --block-id and --selection-with-ellipsis", resolvedObjType) } - return output.ErrValidation("file comments only support full comments; omit --block-id and --selection-with-ellipsis") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "file comments only support full comments; omit --block-id and --selection-with-ellipsis") } func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) error { @@ -1006,7 +998,7 @@ func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) e blockID := strings.TrimSpace(runtime.Str("block-id")) if blockID == "" { - return output.ErrValidation("--block-id is required for sheet comments (format: !, e.g. a281f9!D6)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id is required for sheet comments (format: !, e.g. a281f9!D6)").WithParam("--block-id") } anchor, err := parseSheetCellRef(blockID) if err != nil { @@ -1019,7 +1011,7 @@ func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) e fmt.Fprintf(runtime.IO().ErrOut, "Creating sheet comment in %s (sheet=%s, col=%d, row=%d)\n", common.MaskToken(docRef.Token), anchor.SheetID, anchor.Col, anchor.Row) - data, err := runtime.CallAPI("POST", requestPath, nil, requestBody) + data, err := runtime.CallAPITyped("POST", requestPath, nil, requestBody) if err != nil { return err } @@ -1054,7 +1046,7 @@ func executeFileComment(runtime *common.RuntimeContext, target resolvedCommentTa fmt.Fprintf(runtime.IO().ErrOut, "Creating file comment in %s (%s)\n", common.MaskToken(target.FileToken), extension) - data, err := runtime.CallAPI("POST", requestPath, nil, requestBody) + data, err := runtime.CallAPITyped("POST", requestPath, nil, requestBody) if err != nil { return err } @@ -1097,7 +1089,7 @@ func executeSlidesComment(runtime *common.RuntimeContext, docRef commentDocRef) fmt.Fprintf(runtime.IO().ErrOut, "Creating slide block comment in %s (block_id=%s, slide_block_type=%s)\n", common.MaskToken(docRef.Token), blockID, slideBlockType) - data, err := runtime.CallAPI("POST", requestPath, nil, requestBody) + data, err := runtime.CallAPITyped("POST", requestPath, nil, requestBody) if err != nil { return err } diff --git a/shortcuts/drive/drive_add_comment_test.go b/shortcuts/drive/drive_add_comment_test.go index 7875893eb..5d39d5d34 100644 --- a/shortcuts/drive/drive_add_comment_test.go +++ b/shortcuts/drive/drive_add_comment_test.go @@ -9,11 +9,32 @@ import ( "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/httpmock" - "github.com/larksuite/cli/internal/output" ) +// assertContentValidationHint asserts err is a typed *errs.ValidationError +// carrying SubtypeInvalidArgument, Param "--content", and a Hint containing +// the given substring. The over-cap message now flows through a typed +// ValidationError instead of the legacy *output.ExitError.Detail shape. +func assertContentValidationHint(t *testing.T, err error, wantHint 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 != "--content" { + t.Errorf("Param = %q, want %q", valErr.Param, "--content") + } + if !strings.Contains(valErr.Hint, wantHint) { + t.Errorf("expected hint substring %q, got %q", wantHint, valErr.Hint) + } +} + func decodeJSONMap(t *testing.T, raw string) map[string]interface{} { t.Helper() @@ -421,14 +442,8 @@ func TestParseCommentReplyElementsTextLength(t *testing.T) { t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) } if tt.wantHint != "" { - // Hint lives on ExitError.Detail.Hint, not err.Error(). - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected ExitError with Detail, got %T (%v)", err, err) - } - if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) { - t.Errorf("expected hint substring %q, got %q", tt.wantHint, exitErr.Detail.Hint) - } + // Hint lives on the typed ValidationError, not err.Error(). + assertContentValidationHint(t, err, tt.wantHint) } return } @@ -458,11 +473,11 @@ func TestParseCommentReplyElementsHintForbidsSplitAdvice(t *testing.T) { if err == nil { t.Fatal("expected over-cap error, got nil") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected ExitError with Detail, got %T (%v)", err, err) + var valErr *errs.ValidationError + if !errors.As(err, &valErr) { + t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err) } - hint := exitErr.Detail.Hint + hint := valErr.Hint // The hint must explicitly call out that splitting does NOT help. if !strings.Contains(hint, "does NOT help") { diff --git a/shortcuts/drive/drive_apply_permission.go b/shortcuts/drive/drive_apply_permission.go index dff8a8474..2fd76b37b 100644 --- a/shortcuts/drive/drive_apply_permission.go +++ b/shortcuts/drive/drive_apply_permission.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -44,7 +44,7 @@ var permApplyURLMarkers = []struct { func resolvePermApplyTarget(raw, explicitType string) (token, docType string, err error) { raw = strings.TrimSpace(raw) if raw == "" { - return "", "", output.ErrValidation("--token is required") + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token") } if strings.Contains(raw, "://") { @@ -58,10 +58,10 @@ func resolvePermApplyTarget(raw, explicitType string) (token, docType string, er } } if token == "" { - return "", "", output.ErrValidation( + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "could not infer token from URL %q: supported paths are /docx/, /sheets/, /base/, /bitable/, /file/, /wiki/, /doc/, /mindnote/, /slides/. Pass a bare token with --type instead if the URL shape is unusual", raw, - ) + ).WithParam("--token") } } else { token = raw @@ -71,10 +71,10 @@ func resolvePermApplyTarget(raw, explicitType string) (token, docType string, er docType = explicitType } if docType == "" { - return "", "", output.ErrValidation( + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --token is a bare token; accepted values: %s", strings.Join(permApplyTypes, ", "), - ) + ).WithParam("--type") } return token, docType, nil } @@ -125,7 +125,7 @@ var DriveApplyPermission = common.Shortcut{ fmt.Fprintf(runtime.IO().ErrOut, "Requesting %s access on %s %s...\n", runtime.Str("perm"), docType, common.MaskToken(token)) - data, err := runtime.CallAPI("POST", + data, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members/apply", validate.EncodePathSegment(token)), map[string]interface{}{"type": docType}, body, diff --git a/shortcuts/drive/drive_create_folder.go b/shortcuts/drive/drive_create_folder.go index 75c50aee6..38507d491 100644 --- a/shortcuts/drive/drive_create_folder.go +++ b/shortcuts/drive/drive_create_folder.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -72,7 +72,7 @@ var DriveCreateFolder = common.Shortcut{ } fmt.Fprintf(runtime.IO().ErrOut, "Creating folder %q in %s...\n", spec.Name, target) - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "POST", "/open-apis/drive/v1/files/create_folder", nil, @@ -84,7 +84,7 @@ var DriveCreateFolder = common.Shortcut{ folderToken := common.GetString(data, "token") if folderToken == "" { - return output.Errorf(output.ExitAPI, "api_error", "drive create_folder succeeded but returned no folder token (data.token)") + return errs.NewInternalError(errs.SubtypeInvalidResponse, "drive create_folder succeeded but returned no folder token (data.token)") } out := map[string]interface{}{ "created": true, @@ -108,14 +108,14 @@ var DriveCreateFolder = common.Shortcut{ func validateDriveCreateFolderSpec(spec driveCreateFolderSpec) error { if spec.Name == "" { - return output.ErrValidation("--name must not be empty") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name must not be empty").WithParam("--name") } if nameBytes := len([]byte(spec.Name)); nameBytes > 256 { - return output.ErrValidation("--name exceeds the maximum of 256 bytes (got %d)", nameBytes) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name exceeds the maximum of 256 bytes (got %d)", nameBytes).WithParam("--name") } if spec.FolderToken != "" { if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token") } } return nil diff --git a/shortcuts/drive/drive_create_shortcut.go b/shortcuts/drive/drive_create_shortcut.go index 92d0c5dc5..b79499631 100644 --- a/shortcuts/drive/drive_create_shortcut.go +++ b/shortcuts/drive/drive_create_shortcut.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -84,7 +84,7 @@ var DriveCreateShortcut = common.Shortcut{ common.MaskToken(spec.FolderToken), ) - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "POST", "/open-apis/drive/v1/files/create_shortcut", nil, @@ -118,19 +118,19 @@ var DriveCreateShortcut = common.Shortcut{ // validateDriveCreateShortcutSpec validates shortcut creation inputs before API execution. func validateDriveCreateShortcutSpec(spec driveCreateShortcutSpec) error { if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token") } if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token") } if spec.FileType == "wiki" { - return output.ErrValidation("unsupported file type: wiki. This shortcut only supports Drive file tokens; wiki documents must be resolved to their underlying file token first") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: wiki. This shortcut only supports Drive file tokens; wiki documents must be resolved to their underlying file token first").WithParam("--type") } if spec.FileType == "folder" { - return output.ErrValidation("unsupported file type: folder. The create_shortcut API only supports Drive files, not folders") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: folder. The create_shortcut API only supports Drive files, not folders").WithParam("--type") } if !driveCreateShortcutAllowedTypes[spec.FileType] { - return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, slides", spec.FileType) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, slides", spec.FileType).WithParam("--type") } return nil } diff --git a/shortcuts/drive/drive_create_shortcut_test.go b/shortcuts/drive/drive_create_shortcut_test.go index 942e079d8..14d83048d 100644 --- a/shortcuts/drive/drive_create_shortcut_test.go +++ b/shortcuts/drive/drive_create_shortcut_test.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/output" @@ -312,24 +313,24 @@ func TestDriveCreateShortcutClassifiesKnownAPIConstraints(t *testing.T) { t.Fatal("expected API error, got nil") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured exit error, got %v", err) + var apiErr *errs.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *errs.APIError, got %T (%v)", err, err) } - if exitErr.Code != output.ExitAPI { - t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAPI) + if output.ExitCodeOf(err) != output.ExitAPI { + t.Fatalf("exit code = %d, want %d", output.ExitCodeOf(err), output.ExitAPI) } - if exitErr.Detail.Type != tt.wantType { - t.Fatalf("type = %q, want %q", exitErr.Detail.Type, tt.wantType) + if string(apiErr.Subtype) != tt.wantType { + t.Fatalf("subtype = %q, want %q", apiErr.Subtype, tt.wantType) } - if exitErr.Detail.Code != tt.code { - t.Fatalf("detail code = %d, want %d", exitErr.Detail.Code, tt.code) + if apiErr.Code != tt.code { + t.Fatalf("code = %d, want %d", apiErr.Code, tt.code) } - if !strings.Contains(exitErr.Detail.Message, tt.wantMsgPart) { - t.Fatalf("message = %q, want substring %q", exitErr.Detail.Message, tt.wantMsgPart) + if !strings.Contains(apiErr.Message, tt.wantMsgPart) { + t.Fatalf("message = %q, want substring %q", apiErr.Message, tt.wantMsgPart) } - if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) { - t.Fatalf("hint = %q, want substring %q", exitErr.Detail.Hint, tt.wantHint) + if !strings.Contains(apiErr.Hint, tt.wantHint) { + t.Fatalf("hint = %q, want substring %q", apiErr.Hint, tt.wantHint) } }) } diff --git a/shortcuts/drive/drive_delete.go b/shortcuts/drive/drive_delete.go index 98a331e6e..d9190a793 100644 --- a/shortcuts/drive/drive_delete.go +++ b/shortcuts/drive/drive_delete.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -81,7 +81,7 @@ var DriveDelete = common.Shortcut{ fmt.Fprintf(runtime.IO().ErrOut, "Deleting %s %s...\n", spec.FileType, common.MaskToken(spec.FileToken)) - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "DELETE", fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(spec.FileToken)), map[string]interface{}{"type": spec.FileType}, @@ -94,7 +94,7 @@ var DriveDelete = common.Shortcut{ if spec.FileType == "folder" { taskID := common.GetString(data, "task_id") if taskID == "" { - return output.Errorf(output.ExitAPI, "api_error", "delete folder returned no task_id") + return errs.NewInternalError(errs.SubtypeInvalidResponse, "delete folder returned no task_id") } fmt.Fprintf(runtime.IO().ErrOut, "Folder delete is async, polling task %s...\n", taskID) @@ -136,13 +136,13 @@ var DriveDelete = common.Shortcut{ func validateDriveDeleteSpec(spec driveDeleteSpec) error { if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token") } if spec.FileType == "wiki" { - return output.ErrValidation("unsupported file type: wiki. This shortcut only supports Drive files and folders; wiki documents are not supported") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: wiki. This shortcut only supports Drive files and folders; wiki documents are not supported").WithParam("--type") } if !driveDeleteAllowedTypes[spec.FileType] { - return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, shortcut, slides", spec.FileType) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, shortcut, slides", spec.FileType).WithParam("--type") } return nil } diff --git a/shortcuts/drive/drive_download.go b/shortcuts/drive/drive_download.go index 13d96d619..2a29e16a3 100644 --- a/shortcuts/drive/drive_download.go +++ b/shortcuts/drive/drive_download.go @@ -10,8 +10,8 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -44,7 +44,7 @@ var DriveDownload = common.Shortcut{ overwrite := runtime.Bool("overwrite") if err := validate.ResourceName(fileToken, "--file-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token") } if outputPath == "" { @@ -53,10 +53,10 @@ var DriveDownload = common.Shortcut{ // Early path validation + overwrite check if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil { - return output.ErrValidation("unsafe output path: %s", resolveErr) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", resolveErr).WithParam("--output") } if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !overwrite { - return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --overwrite to replace)", outputPath).WithParam("--output") } fmt.Fprintf(runtime.IO().ErrOut, "Downloading: %s\n", common.MaskToken(fileToken)) @@ -66,7 +66,7 @@ var DriveDownload = common.Shortcut{ ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)), }) if err != nil { - return output.ErrNetwork("download failed: %s", err) + return wrapDriveNetworkErr(err, "download failed: %s", err) } defer resp.Body.Close() @@ -75,7 +75,7 @@ var DriveDownload = common.Shortcut{ ContentLength: resp.ContentLength, }, resp.Body) if err != nil { - return common.WrapSaveErrorByCategory(err, "io") + return driveSaveError(err) } savedPath, _ := runtime.ResolveSavePath(outputPath) diff --git a/shortcuts/drive/drive_duplicate_remote_test.go b/shortcuts/drive/drive_duplicate_remote_test.go index 6c8a391f6..574c1c1bb 100644 --- a/shortcuts/drive/drive_duplicate_remote_test.go +++ b/shortcuts/drive/drive_duplicate_remote_test.go @@ -17,9 +17,9 @@ import ( "testing" "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/httpmock" - "github.com/larksuite/cli/internal/output" ) const ( @@ -823,64 +823,37 @@ func registerDownload(reg *httpmock.Registry, fileToken, body string) { func assertDuplicateRemotePathError(t *testing.T, err error, relPath string, tokens ...string) { t.Helper() if err == nil { - t.Fatal("expected duplicate_remote_path error, got nil") + t.Fatal("expected duplicate rel_path validation error, got nil") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) } - if exitErr.Code != output.ExitAPI { - t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAPI) + if validationErr.Subtype != errs.SubtypeFailedPrecondition { + t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeFailedPrecondition) } - if exitErr.Detail == nil || exitErr.Detail.Type != "duplicate_remote_path" { - t.Fatalf("error detail = %#v, want duplicate_remote_path", exitErr.Detail) + if validationErr.Hint == "" { + t.Fatal("duplicate validation error should carry a recovery hint so AI consumers know the next action") } - detailMap, ok := exitErr.Detail.Detail.(map[string]interface{}) - if !ok { - t.Fatalf("duplicate detail type = %T, want map[string]interface{}", exitErr.Detail.Detail) + if len(validationErr.Params) == 0 { + t.Fatal("duplicate validation error should carry at least one param") } - duplicates, ok := detailMap["duplicates_remote"].([]driveDuplicateRemotePath) - if !ok { - t.Fatalf("duplicate detail duplicates_remote type = %T, want []driveDuplicateRemotePath", detailMap["duplicates_remote"]) - } - if len(duplicates) == 0 { - t.Fatal("duplicate detail should include at least one rel_path group") - } - if _, hasLegacyFilesKey := detailMap["files"]; hasLegacyFilesKey { - t.Fatalf("duplicate detail should not expose legacy files key: %#v", detailMap) - } - var matched bool - for _, duplicate := range duplicates { - if duplicate.RelPath != relPath { - continue + var matched *errs.InvalidParam + for i := range validationErr.Params { + if validationErr.Params[i].Name == relPath { + matched = &validationErr.Params[i] + break } - matched = true - if len(duplicate.Entries) != len(tokens) { - t.Fatalf("duplicate entry count = %d, want %d for rel_path %q", len(duplicate.Entries), len(tokens), relPath) - } - for i, token := range tokens { - if duplicate.Entries[i].FileToken != token { - t.Fatalf("duplicate entry %d file_token = %q, want %q", i, duplicate.Entries[i].FileToken, token) - } - if duplicate.Entries[i].Type == "" { - t.Fatalf("duplicate entry %d missing type for rel_path %q", i, relPath) - } - } - } - if !matched { - t.Fatalf("duplicate detail missing rel_path group %q: %#v", relPath, duplicates) } - raw, marshalErr := json.Marshal(exitErr.Detail.Detail) - if marshalErr != nil { - t.Fatalf("marshal detail: %v", marshalErr) + if matched == nil { + t.Fatalf("duplicate params missing rel_path group %q: %#v", relPath, validationErr.Params) } - text := string(raw) - if !strings.Contains(text, relPath) { - t.Fatalf("duplicate detail missing rel_path %q: %s", relPath, text) + if matched.Reason == "" { + t.Fatalf("duplicate param for rel_path %q missing reason", relPath) } for _, token := range tokens { - if !strings.Contains(text, token) { - t.Fatalf("duplicate detail missing token %q: %s", token, text) + if !strings.Contains(matched.Reason, token) { + t.Fatalf("duplicate param reason missing token %q: %s", token, matched.Reason) } } } diff --git a/shortcuts/drive/drive_errors.go b/shortcuts/drive/drive_errors.go new file mode 100644 index 000000000..4184d158b --- /dev/null +++ b/shortcuts/drive/drive_errors.go @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "errors" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/output" +) + +// wrapDriveNetworkErr returns err unchanged when it is already a typed errs.* +// error (preserving its subtype / code / log_id from the runtime boundary), +// and only wraps a raw, unclassified error as a transport-level network error. +func wrapDriveNetworkErr(err error, format string, args ...any) error { + if _, ok := errs.ProblemOf(err); ok { + return err + } + return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err) +} + +// driveInputStatError maps a FileIO.Stat/Open error for input file validation +// to a typed validation error: +// - Path validation failures → "unsafe file path: ..." +// - Other errors → "cannot read file: ..." +func driveInputStatError(err error) error { + if err == nil { + return nil + } + if errors.Is(err, fileio.ErrPathValidation) { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).WithCause(err) + } + return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read file: %s", err).WithCause(err) +} + +// driveSaveError maps a FileIO.Save error to a typed error. Path validation +// failures are validation errors (exit code 2); mkdir / write failures are +// internal file-I/O errors (exit code 5). +func driveSaveError(err error) error { + if err == nil { + return nil + } + var me *fileio.MkdirError + switch { + case errors.Is(err, fileio.ErrPathValidation): + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithCause(err) + case errors.As(err, &me): + return errs.NewInternalError(errs.SubtypeFileIO, "cannot create parent directory: %s", err).WithCause(err) + default: + return errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).WithCause(err) + } +} + +// appendDriveExportRecoveryHint attaches a recovery hint to err while preserving +// its original classification (typed subtype/code or legacy detail), only falling +// back to a typed internal error when err is unclassified. +func appendDriveExportRecoveryHint(err error, hint string) error { + if err == nil { + return nil + } + // An already-typed error keeps its own category/subtype/code/log_id + // (per ERROR_CONTRACT.md "propagate typed errors unchanged"); we only + // append the recovery hint. p points at the embedded Problem, so the + // mutation is reflected in the returned err. + if p, ok := errs.ProblemOf(err); ok { + if strings.TrimSpace(p.Hint) != "" { + p.Hint = p.Hint + "\n" + hint + } else { + p.Hint = hint + } + return err + } + // Legacy *output.ExitError fallback: preserve the original error's + // class/exit code by appending the hint in place rather than downgrading + // to api/server_error. + var exitErr *output.ExitError + if errors.As(err, &exitErr) && exitErr.Detail != nil { + if strings.TrimSpace(exitErr.Detail.Hint) != "" { + exitErr.Detail.Hint = exitErr.Detail.Hint + "\n" + hint + } else { + exitErr.Detail.Hint = hint + } + return err + } + return errs.NewInternalError(errs.SubtypeSDKError, "%s", err.Error()).WithHint(hint).WithCause(err) +} diff --git a/shortcuts/drive/drive_export.go b/shortcuts/drive/drive_export.go index e0031fdb0..86e7c99a5 100644 --- a/shortcuts/drive/drive_export.go +++ b/shortcuts/drive/drive_export.go @@ -5,13 +5,12 @@ package drive import ( "context" - "errors" "fmt" "path/filepath" "strings" "time" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -107,7 +106,7 @@ var DriveExport = common.Shortcut{ if spec.FileExtension == "markdown" { fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token)) apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token)) - data, err := runtime.DoAPIJSONWithLogID( + data, err := runtime.CallAPITyped( "POST", apiPath, nil, @@ -122,11 +121,11 @@ var DriveExport = common.Shortcut{ // Extract content from the V2 response: data.document.content doc, ok := data["document"].(map[string]interface{}) if !ok { - return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document object") + return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object") } content, ok := doc["content"].(string) if !ok { - return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document.content") + return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content") } fileName := preferredFileName @@ -207,11 +206,7 @@ var DriveExport = common.Shortcut{ status.FileToken, recoveryCommand, ) - var exitErr *output.ExitError - if errors.As(err, &exitErr) && exitErr.Detail != nil { - return output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint) - } - return output.ErrWithHint(output.ExitAPI, "api_error", err.Error(), hint) + return appendDriveExportRecoveryHint(err, hint) } out["ticket"] = ticket out["doc_type"] = spec.DocType @@ -225,7 +220,7 @@ var DriveExport = common.Shortcut{ if msg == "" { msg = status.StatusLabel() } - return output.Errorf(output.ExitAPI, "api_error", "export task failed: %s (ticket=%s)", msg, ticket) + return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket) } fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel()) @@ -238,14 +233,7 @@ var DriveExport = common.Shortcut{ ticket, nextCommand, ) - var exitErr *output.ExitError - if errors.As(lastPollErr, &exitErr) && exitErr.Detail != nil { - if strings.TrimSpace(exitErr.Detail.Hint) != "" { - hint = exitErr.Detail.Hint + "\n" + hint - } - return output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint) - } - return output.ErrWithHint(output.ExitAPI, "api_error", lastPollErr.Error(), hint) + return appendDriveExportRecoveryHint(lastPollErr, hint) } failed := false diff --git a/shortcuts/drive/drive_export_common.go b/shortcuts/drive/drive_export_common.go index 529c01b0e..187ac3e73 100644 --- a/shortcuts/drive/drive_export_common.go +++ b/shortcuts/drive/drive_export_common.go @@ -15,9 +15,9 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/client" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -127,48 +127,48 @@ func (s driveExportStatus) StatusLabel() string { // backend request is sent. func validateDriveExportSpec(spec driveExportSpec) error { if err := validate.ResourceName(spec.Token, "--token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token") } switch spec.DocType { case "doc", "docx", "sheet", "bitable", "slides": default: - return output.ErrValidation("invalid --doc-type %q: allowed values are doc, docx, sheet, bitable, slides", spec.DocType) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc-type %q: allowed values are doc, docx, sheet, bitable, slides", spec.DocType).WithParam("--doc-type") } switch spec.FileExtension { case "docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx": default: - return output.ErrValidation("invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base, pptx", spec.FileExtension) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base, pptx", spec.FileExtension).WithParam("--file-extension") } if spec.FileExtension == "markdown" && spec.DocType != "docx" { - return output.ErrValidation("--file-extension markdown only supports --doc-type docx") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension markdown only supports --doc-type docx") } if spec.FileExtension == "base" && spec.DocType != "bitable" { - return output.ErrValidation("--file-extension base only supports --doc-type bitable") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension base only supports --doc-type bitable") } if spec.FileExtension == "pptx" && spec.DocType != "slides" { - return output.ErrValidation("--file-extension pptx only supports --doc-type slides") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension pptx only supports --doc-type slides") } if spec.DocType == "slides" && spec.FileExtension != "pptx" && spec.FileExtension != "pdf" { - return output.ErrValidation("--doc-type slides only supports --file-extension pptx or pdf") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-type slides only supports --file-extension pptx or pdf") } if strings.TrimSpace(spec.SubID) != "" { if spec.FileExtension != "csv" || (spec.DocType != "sheet" && spec.DocType != "bitable") { - return output.ErrValidation("--sub-id is only used when exporting sheet/bitable as csv") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").WithParam("--sub-id") } if err := validate.ResourceName(spec.SubID, "--sub-id"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--sub-id") } } if spec.FileExtension == "csv" && (spec.DocType == "sheet" || spec.DocType == "bitable") && strings.TrimSpace(spec.SubID) == "" { - return output.ErrValidation("--sub-id is required when exporting sheet/bitable as csv") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is required when exporting sheet/bitable as csv").WithParam("--sub-id") } return nil @@ -186,14 +186,14 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec) body["sub_id"] = spec.SubID } - data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, body) + data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, body) if err != nil { return "", err } ticket := common.GetString(data, "ticket") if ticket == "" { - return "", output.Errorf(output.ExitAPI, "api_error", "export task created but ticket is missing") + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "export task created but ticket is missing") } return ticket, nil } @@ -201,7 +201,7 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec) // getDriveExportStatus fetches the current backend state for a previously // created export task. func getDriveExportStatus(runtime *common.RuntimeContext, token, ticket string) (driveExportStatus, error) { - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "GET", fmt.Sprintf("/open-apis/drive/v1/export_tasks/%s", validate.EncodePathSegment(ticket)), map[string]interface{}{"token": token}, @@ -251,12 +251,12 @@ func saveContentToOutputDir(fio fileio.FileIO, outputDir, fileName string, paylo // Overwrite check via FileIO.Stat if !overwrite { if _, statErr := fio.Stat(target); statErr == nil { - return "", output.ErrValidation("output file already exists: %s (use --overwrite to replace)", target) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --overwrite to replace)", target) } } if _, err := fio.Save(target, fileio.SaveOptions{}, bytes.NewReader(payload)); err != nil { - return "", common.WrapSaveErrorByCategory(err, "io") + return "", driveSaveError(err) } resolvedPath, _ := fio.ResolvePath(target) if resolvedPath == "" { @@ -269,7 +269,7 @@ func saveContentToOutputDir(fio fileio.FileIO, outputDir, fileName string, paylo // file name, and returns metadata about the saved file. func downloadDriveExportFile(ctx context.Context, runtime *common.RuntimeContext, fileToken, outputDir, preferredName string, overwrite bool) (map[string]interface{}, error) { if err := validate.ResourceName(fileToken, "--file-token"); err != nil { - return nil, output.ErrValidation("%s", err) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token") } apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ @@ -277,10 +277,24 @@ func downloadDriveExportFile(ctx context.Context, runtime *common.RuntimeContext ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)), }, larkcore.WithFileDownload()) if err != nil { - return nil, output.ErrNetwork("download failed: %s", err) + return nil, wrapDriveNetworkErr(err, "download failed: %s", err) } if apiResp.StatusCode >= 400 { - return nil, output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody)) + subtype := errs.SubtypeNetworkTransport + if apiResp.StatusCode >= 500 { + subtype = errs.SubtypeNetworkServer + } + e := errs.NewNetworkError(subtype, "download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody)).WithCode(apiResp.StatusCode) + // Mirror internal/client streamLogID: fall back to the request-id header + // when log-id is absent so the diagnostic ID is still populated. + logID := strings.TrimSpace(apiResp.Header.Get(larkcore.HttpHeaderKeyLogId)) + if logID == "" { + logID = strings.TrimSpace(apiResp.Header.Get(larkcore.HttpHeaderKeyRequestId)) + } + if logID != "" { + e = e.WithLogID(logID) + } + return nil, e } fileName := strings.TrimSpace(preferredName) diff --git a/shortcuts/drive/drive_export_download.go b/shortcuts/drive/drive_export_download.go index 62ddd9220..daed6cad2 100644 --- a/shortcuts/drive/drive_export_download.go +++ b/shortcuts/drive/drive_export_download.go @@ -6,7 +6,7 @@ package drive import ( "context" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -30,7 +30,7 @@ var DriveExportDownload = common.Shortcut{ }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token") } return nil }, diff --git a/shortcuts/drive/drive_export_test.go b/shortcuts/drive/drive_export_test.go index 74f94efdb..619c9d43d 100644 --- a/shortcuts/drive/drive_export_test.go +++ b/shortcuts/drive/drive_export_test.go @@ -13,6 +13,7 @@ import ( "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/output" @@ -360,12 +361,18 @@ func TestDriveExportMarkdownRejectsMissingDocumentObject(t *testing.T) { t.Fatal("expected error for missing document object, got nil") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured exit error, got %v", err) + var intErr *errs.InternalError + if !errors.As(err, &intErr) { + t.Fatalf("expected *errs.InternalError, got %T", err) } - if !strings.Contains(exitErr.Detail.Message, "missing document object") { - t.Fatalf("error message = %q, want mention of missing document object", exitErr.Detail.Message) + if intErr.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("Subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse) + } + if !strings.Contains(intErr.Message, "missing document object") { + t.Fatalf("error message = %q, want mention of missing document object", intErr.Message) + } + if got := output.ExitCodeOf(err); got != output.ExitInternal { + t.Fatalf("exit code = %d, want %d", got, output.ExitInternal) } } @@ -396,12 +403,18 @@ func TestDriveExportMarkdownRejectsMissingDocumentContent(t *testing.T) { t.Fatal("expected error for missing document.content, got nil") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured exit error, got %v", err) + var intErr *errs.InternalError + if !errors.As(err, &intErr) { + t.Fatalf("expected *errs.InternalError, got %T", err) + } + if intErr.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("Subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse) } - if !strings.Contains(exitErr.Detail.Message, "missing document.content") { - t.Fatalf("error message = %q, want mention of missing document.content", exitErr.Detail.Message) + if !strings.Contains(intErr.Message, "missing document.content") { + t.Fatalf("error message = %q, want mention of missing document.content", intErr.Message) + } + if got := output.ExitCodeOf(err); got != output.ExitInternal { + t.Fatalf("exit code = %d, want %d", got, output.ExitInternal) } } @@ -688,21 +701,25 @@ func TestDriveExportReadyDownloadFailureIncludesRecoveryHint(t *testing.T) { t.Fatal("expected download recovery error, got nil") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured exit error, got %v", err) + // The download itself succeeds; the local "file already exists" failure is a + // validation error. The recovery-hint wrapper must preserve that typed class + // (exit 2) instead of downgrading it to api/server_error (exit 1), per + // ERROR_CONTRACT.md "propagate typed errors unchanged". + var valErr *errs.ValidationError + if !errors.As(err, &valErr) { + t.Fatalf("expected *errs.ValidationError (preserved class), got %T", err) } - if !strings.Contains(exitErr.Detail.Message, "already exists") { - t.Fatalf("message missing overwrite guidance: %q", exitErr.Detail.Message) + if !strings.Contains(valErr.Message, "already exists") { + t.Fatalf("message missing overwrite guidance: %q", valErr.Message) } - if !strings.Contains(exitErr.Detail.Hint, "ticket=tk_ready") { - t.Fatalf("hint missing ticket: %q", exitErr.Detail.Hint) + if !strings.Contains(valErr.Hint, "ticket=tk_ready") { + t.Fatalf("hint missing ticket: %q", valErr.Hint) } - if !strings.Contains(exitErr.Detail.Hint, "file_token=box_ready") { - t.Fatalf("hint missing file token: %q", exitErr.Detail.Hint) + if !strings.Contains(valErr.Hint, "file_token=box_ready") { + t.Fatalf("hint missing file token: %q", valErr.Hint) } - if !strings.Contains(exitErr.Detail.Hint, `lark-cli drive +export-download --file-token "box_ready" --file-name "report.pdf"`) { - t.Fatalf("hint missing recovery command: %q", exitErr.Detail.Hint) + if !strings.Contains(valErr.Hint, `lark-cli drive +export-download --file-token "box_ready" --file-name "report.pdf"`) { + t.Fatalf("hint missing recovery command: %q", valErr.Hint) } } @@ -856,18 +873,26 @@ func TestDriveExportPollErrorsReturnLastErrorWithRecoveryHint(t *testing.T) { t.Fatalf("stdout should stay empty on persistent poll error: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured exit error, got %v", err) + // The poll error is now a typed *errs.APIError (runtime.CallAPITyped). + // The recovery-hint wrapper must preserve that error's class and exit code + // (NOT downgrade it) and only append the recovery hint to the Problem in place. + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected a typed errs.* error, got %T (%v)", err, err) + } + // Lark code 999 is unknown to the classifier, so it maps to CategoryAPI → + // ExitAPI — the wrapper must keep that, not force a different exit code. + if output.ExitCodeOf(err) != output.ExitAPI { + t.Fatalf("exit code = %d, want preserved %d (ExitAPI)", output.ExitCodeOf(err), output.ExitAPI) } - if !strings.Contains(exitErr.Detail.Message, "temporary backend failure") { - t.Fatalf("message missing last poll error: %q", exitErr.Detail.Message) + if !strings.Contains(p.Message, "temporary backend failure") { + t.Fatalf("message missing last poll error: %q", p.Message) } - if !strings.Contains(exitErr.Detail.Hint, "ticket=tk_poll_fail") { - t.Fatalf("hint missing ticket: %q", exitErr.Detail.Hint) + if !strings.Contains(p.Hint, "ticket=tk_poll_fail") { + t.Fatalf("hint missing ticket: %q", p.Hint) } - if !strings.Contains(exitErr.Detail.Hint, "lark-cli drive +task_result --scenario export --ticket tk_poll_fail --file-token docx123") { - t.Fatalf("hint missing recovery command: %q", exitErr.Detail.Hint) + if !strings.Contains(p.Hint, "lark-cli drive +task_result --scenario export --ticket tk_poll_fail --file-token docx123") { + t.Fatalf("hint missing recovery command: %q", p.Hint) } } diff --git a/shortcuts/drive/drive_import.go b/shortcuts/drive/drive_import.go index a9f6e32f9..8c5c9a2c8 100644 --- a/shortcuts/drive/drive_import.go +++ b/shortcuts/drive/drive_import.go @@ -9,8 +9,8 @@ import ( "path/filepath" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -161,10 +161,10 @@ func preflightDriveImportFile(fio fileio.FileIO, spec *driveImportSpec) (int64, // and format-specific size limits before planning the upload path. info, err := fio.Stat(spec.FilePath) if err != nil { - return 0, common.WrapInputStatError(err) + return 0, driveInputStatError(err) } if !info.Mode().IsRegular() { - return 0, output.ErrValidation("file must be a regular file: %s", spec.FilePath) + return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", spec.FilePath).WithParam("--file") } if err = validateDriveImportFileSize(spec.FilePath, spec.DocType, info.Size()); err != nil { return 0, err diff --git a/shortcuts/drive/drive_import_common.go b/shortcuts/drive/drive_import_common.go index e4abe0b84..525051359 100644 --- a/shortcuts/drive/drive_import_common.go +++ b/shortcuts/drive/drive_import_common.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -95,7 +95,7 @@ func (s driveImportSpec) CreateTaskBody(fileToken string) map[string]interface{} func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName, docType string) (string, error) { importInfo, err := runtime.FileIO().Stat(filePath) if err != nil { - return "", common.WrapInputStatError(err) + return "", driveInputStatError(err) } fileSize := importInfo.Size() @@ -142,7 +142,7 @@ func buildImportMediaExtra(filePath, docType string) (string, error) { "file_extension": strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), "."), }) if err != nil { - return "", output.Errorf(output.ExitInternal, "json_error", "build upload extra failed: %v", err) + return "", errs.NewInternalError(errs.SubtypeUnknown, "build upload extra failed: %v", err).WithCause(err) } return string(extraBytes), nil } @@ -178,20 +178,20 @@ func validateDriveImportFileSize(filePath, docType string, fileSize int64) error ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") if ext == "csv" { // CSV is the only source format whose limit depends on the target type. - return output.ErrValidation( + return errs.NewValidationError(errs.SubtypeInvalidArgument, "file %s exceeds %s import limit for .csv when importing as %s", common.FormatSize(fileSize), common.FormatSize(limit), docType, - ) + ).WithParam("--file") } - return output.ErrValidation( + return errs.NewValidationError(errs.SubtypeInvalidArgument, "file %s exceeds %s import limit for .%s", common.FormatSize(fileSize), common.FormatSize(limit), ext, - ) + ).WithParam("--file") } // validateDriveImportSpec enforces the CLI-level compatibility rules before any @@ -199,18 +199,18 @@ func validateDriveImportFileSize(filePath, docType string, fileSize int64) error func validateDriveImportSpec(spec driveImportSpec) error { ext := spec.FileExtension() if ext == "" { - return output.ErrValidation("file must have an extension (e.g. .md, .docx, .xlsx, .pptx)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must have an extension (e.g. .md, .docx, .xlsx, .pptx)").WithParam("--file") } switch spec.DocType { case "docx", "sheet", "bitable", "slides": default: - return output.ErrValidation("unsupported target document type: %s. Supported types are: docx, sheet, bitable, slides", spec.DocType) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported target document type: %s. Supported types are: docx, sheet, bitable, slides", spec.DocType).WithParam("--type") } supportedTypes, ok := driveImportExtToDocTypes[ext] if !ok { - return output.ErrValidation("unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx", ext) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx", ext).WithParam("--file") } typeAllowed := false @@ -236,21 +236,21 @@ func validateDriveImportSpec(spec driveImportSpec) error { default: hint = fmt.Sprintf(".%s files can only be imported as 'docx', not '%s'", ext, spec.DocType) } - return output.ErrValidation("file type mismatch: %s", hint) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "file type mismatch: %s", hint) } if strings.TrimSpace(spec.FolderToken) != "" { if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token") } } if strings.TrimSpace(spec.TargetToken) != "" { if spec.DocType != "bitable" { - return output.ErrValidation("--target-token is only supported when --type is bitable") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-token is only supported when --type is bitable").WithParam("--target-token") } if err := validate.ResourceName(spec.TargetToken, "--target-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--target-token") } } @@ -308,14 +308,14 @@ func driveImportTaskResultCommand(ticket string) string { // createDriveImportTask creates the server-side import task after the media // upload has produced a reusable file token. func createDriveImportTask(runtime *common.RuntimeContext, spec driveImportSpec, fileToken string) (string, error) { - data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/import_tasks", nil, spec.CreateTaskBody(fileToken)) + data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/import_tasks", nil, spec.CreateTaskBody(fileToken)) if err != nil { return "", err } ticket := common.GetString(data, "ticket") if ticket == "" { - return "", output.Errorf(output.ExitAPI, "api_error", "no ticket returned from import_tasks") + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "no ticket returned from import_tasks") } return ticket, nil } @@ -323,10 +323,10 @@ func createDriveImportTask(runtime *common.RuntimeContext, spec driveImportSpec, // getDriveImportStatus fetches the current state of an import task by ticket. func getDriveImportStatus(runtime *common.RuntimeContext, ticket string) (driveImportStatus, error) { if err := validate.ResourceName(ticket, "--ticket"); err != nil { - return driveImportStatus{}, output.ErrValidation("%s", err) + return driveImportStatus{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--ticket") } - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "GET", fmt.Sprintf("/open-apis/drive/v1/import_tasks/%s", validate.EncodePathSegment(ticket)), nil, @@ -391,7 +391,7 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm if msg == "" { msg = status.StatusLabel() } - return status, false, output.Errorf(output.ExitAPI, "api_error", "import failed with status %d: %s", status.JobStatus, msg) + return status, false, errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg) } } if !hadSuccessfulPoll && lastErr != nil { diff --git a/shortcuts/drive/drive_inspect.go b/shortcuts/drive/drive_inspect.go index 7941d6b48..e448311cf 100644 --- a/shortcuts/drive/drive_inspect.go +++ b/shortcuts/drive/drive_inspect.go @@ -9,7 +9,7 @@ import ( "io" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -37,18 +37,18 @@ var DriveInspect = common.Shortcut{ Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { raw := strings.TrimSpace(runtime.Str("url")) if raw == "" { - return output.ErrValidation("--url cannot be empty") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url cannot be empty").WithParam("--url") } _, ok := common.ParseResourceURL(raw) if !ok { // Not a recognized URL pattern. if strings.Contains(raw, "://") { - return output.ErrValidation("unsupported --url %q: use a recognized Lark document URL or a bare token with --type", raw) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --url %q: use a recognized Lark document URL or a bare token with --type", raw).WithParam("--url") } // Bare token: --type is required. if strings.TrimSpace(runtime.Str("type")) == "" { - return output.ErrValidation("--type is required when --url is a bare token (allowed: doc, docx, sheet, bitable, wiki, file, folder, mindnote, slides)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --url is a bare token (allowed: doc, docx, sheet, bitable, wiki, file, folder, mindnote, slides)").WithParam("--type") } } return nil @@ -111,7 +111,7 @@ var DriveInspect = common.Shortcut{ // Step 2: If type is "wiki", unwrap via get_node API. if docType == "wiki" { fmt.Fprintf(runtime.IO().ErrOut, "Inspecting wiki node: %s\n", common.MaskToken(docToken)) - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": docToken}, @@ -128,7 +128,7 @@ var DriveInspect = common.Shortcut{ nodeToken := common.GetString(node, "node_token") if objType == "" || objToken == "" { - return output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data (obj_type=%q, obj_token=%q)", objType, objToken) + return errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data (obj_type=%q, obj_token=%q)", objType, objToken) } wikiNode = map[string]interface{}{ diff --git a/shortcuts/drive/drive_io_test.go b/shortcuts/drive/drive_io_test.go index a1b7807e8..6391cbf09 100644 --- a/shortcuts/drive/drive_io_test.go +++ b/shortcuts/drive/drive_io_test.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "mime" "mime/multipart" "net/http" @@ -17,6 +18,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" @@ -1338,9 +1340,20 @@ func TestDriveUploadValidateRejectsConflictingTargets(t *testing.T) { runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil) err := DriveUpload.Validate(context.Background(), runtime) - if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("Validate() error = %T %v, want *errs.ValidationError", err, err) + } + if verr.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("subtype = %q, want %q", verr.Subtype, errs.SubtypeInvalidArgument) + } + if !strings.Contains(verr.Error(), "mutually exclusive") { t.Fatalf("Validate() error = %v, want mutually exclusive error", err) } + // Multi-flag conflict carries no single Param. + if verr.Param != "" { + t.Fatalf("Param = %q, want empty for multi-flag conflict", verr.Param) + } } func TestDriveUploadValidateRejectsExplicitEmptyWikiToken(t *testing.T) { @@ -1361,9 +1374,7 @@ func TestDriveUploadValidateRejectsExplicitEmptyWikiToken(t *testing.T) { runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil) err := DriveUpload.Validate(context.Background(), runtime) - if err == nil || !strings.Contains(err.Error(), "--wiki-token cannot be empty") { - t.Fatalf("Validate() error = %v, want empty wiki-token error", err) - } + assertDriveValidationParam(t, err, "--wiki-token", "--wiki-token cannot be empty") } func TestDriveUploadValidateRejectsExplicitEmptyFileToken(t *testing.T) { @@ -1384,9 +1395,7 @@ func TestDriveUploadValidateRejectsExplicitEmptyFileToken(t *testing.T) { runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil) err := DriveUpload.Validate(context.Background(), runtime) - if err == nil || !strings.Contains(err.Error(), "--file-token cannot be empty") { - t.Fatalf("Validate() error = %v, want empty file-token error", err) - } + assertDriveValidationParam(t, err, "--file-token", "--file-token cannot be empty") } func TestDriveUploadValidateRejectsExplicitEmptyFolderToken(t *testing.T) { @@ -1407,8 +1416,25 @@ func TestDriveUploadValidateRejectsExplicitEmptyFolderToken(t *testing.T) { runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil) err := DriveUpload.Validate(context.Background(), runtime) - if err == nil || !strings.Contains(err.Error(), "--folder-token cannot be empty") { - t.Fatalf("Validate() error = %v, want empty folder-token error", err) + assertDriveValidationParam(t, err, "--folder-token", "--folder-token cannot be empty") +} + +// assertDriveValidationParam asserts err is a typed *errs.ValidationError with +// SubtypeInvalidArgument, the given Param, and a message containing wantMsg. +func assertDriveValidationParam(t *testing.T, err error, wantParam, wantMsg string) { + t.Helper() + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("error = %T %v, want *errs.ValidationError", err, err) + } + if verr.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("subtype = %q, want %q", verr.Subtype, errs.SubtypeInvalidArgument) + } + if verr.Param != wantParam { + t.Fatalf("Param = %q, want %q", verr.Param, wantParam) + } + if !strings.Contains(verr.Error(), wantMsg) { + t.Fatalf("error = %q, want substring %q", verr.Error(), wantMsg) } } diff --git a/shortcuts/drive/drive_move.go b/shortcuts/drive/drive_move.go index 8eed0bcf3..d61ae1a7b 100644 --- a/shortcuts/drive/drive_move.go +++ b/shortcuts/drive/drive_move.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -74,14 +74,14 @@ var DriveMove = common.Shortcut{ return err } if rootToken == "" { - return output.Errorf(output.ExitAPI, "api_error", "get root folder token failed, root folder is empty") + return errs.NewInternalError(errs.SubtypeInvalidResponse, "get root folder token failed, root folder is empty") } spec.FolderToken = rootToken } fmt.Fprintf(runtime.IO().ErrOut, "Moving %s %s to folder %s...\n", spec.FileType, common.MaskToken(spec.FileToken), common.MaskToken(spec.FolderToken)) - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "POST", fmt.Sprintf("/open-apis/drive/v1/files/%s/move", validate.EncodePathSegment(spec.FileToken)), nil, @@ -95,7 +95,7 @@ var DriveMove = common.Shortcut{ if spec.FileType == "folder" { taskID := common.GetString(data, "task_id") if taskID == "" { - return output.Errorf(output.ExitAPI, "api_error", "move folder returned no task_id") + return errs.NewInternalError(errs.SubtypeInvalidResponse, "move folder returned no task_id") } fmt.Fprintf(runtime.IO().ErrOut, "Folder move is async, polling task %s...\n", taskID) @@ -139,14 +139,14 @@ var DriveMove = common.Shortcut{ // getRootFolderToken resolves the caller's Drive root folder token so other // commands can safely use it as a default destination. func getRootFolderToken(ctx context.Context, runtime *common.RuntimeContext) (string, error) { - data, err := runtime.CallAPI("GET", "/open-apis/drive/explorer/v2/root_folder/meta", nil, nil) + data, err := runtime.CallAPITyped("GET", "/open-apis/drive/explorer/v2/root_folder/meta", nil, nil) if err != nil { return "", err } token := common.GetString(data, "token") if token == "" { - return "", output.Errorf(output.ExitAPI, "api_error", "root_folder/meta returned no token") + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "root_folder/meta returned no token") } return token, nil diff --git a/shortcuts/drive/drive_move_common.go b/shortcuts/drive/drive_move_common.go index d200d8cf5..175937e7d 100644 --- a/shortcuts/drive/drive_move_common.go +++ b/shortcuts/drive/drive_move_common.go @@ -8,7 +8,7 @@ import ( "strings" "time" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -47,15 +47,15 @@ func (s driveMoveSpec) RequestBody() map[string]interface{} { func validateDriveMoveSpec(spec driveMoveSpec) error { if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token") } if strings.TrimSpace(spec.FolderToken) != "" { if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token") } } if !driveMoveAllowedTypes[spec.FileType] { - return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, slides", spec.FileType) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, slides", spec.FileType).WithParam("--type") } return nil } @@ -109,10 +109,10 @@ func driveTaskCheckParams(taskID string) map[string]interface{} { // folder move or delete task. func getDriveTaskCheckStatus(runtime *common.RuntimeContext, taskID string) (driveTaskCheckStatus, error) { if err := validate.ResourceName(taskID, "--task-id"); err != nil { - return driveTaskCheckStatus{}, output.ErrValidation("%s", err) + return driveTaskCheckStatus{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id") } - data, err := runtime.CallAPI("GET", "/open-apis/drive/v1/files/task_check", driveTaskCheckParams(taskID), nil) + data, err := runtime.CallAPITyped("GET", "/open-apis/drive/v1/files/task_check", driveTaskCheckParams(taskID), nil) if err != nil { return driveTaskCheckStatus{}, err } @@ -163,7 +163,7 @@ func pollDriveTaskCheck(runtime *common.RuntimeContext, taskID string) (driveTas return status, true, nil } if status.Failed() { - return status, false, output.Errorf(output.ExitAPI, "api_error", "folder task failed") + return status, false, errs.NewAPIError(errs.SubtypeServerError, "folder task failed") } } diff --git a/shortcuts/drive/drive_pull.go b/shortcuts/drive/drive_pull.go index 04fb4509f..3fa27eda2 100644 --- a/shortcuts/drive/drive_pull.go +++ b/shortcuts/drive/drive_pull.go @@ -15,8 +15,8 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -88,26 +88,26 @@ var DrivePull = common.Shortcut{ localDir := strings.TrimSpace(runtime.Str("local-dir")) folderToken := strings.TrimSpace(runtime.Str("folder-token")) if localDir == "" { - return common.FlagErrorf("--local-dir is required") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is required").WithParam("--local-dir") } if folderToken == "" { - return common.FlagErrorf("--folder-token is required") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token is required").WithParam("--folder-token") } if err := validate.ResourceName(folderToken, "--folder-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token") } if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--local-dir") } info, err := runtime.FileIO().Stat(localDir) if err != nil { - return common.WrapInputStatError(err) + return driveInputStatError(err) } if !info.IsDir() { - return output.ErrValidation("--local-dir is not a directory: %s", localDir) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is not a directory: %s", localDir).WithParam("--local-dir") } if runtime.Bool("delete-local") && !runtime.Bool("yes") { - return output.ErrValidation("--delete-local requires --yes (high-risk: deletes local files absent from Drive)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--delete-local requires --yes (high-risk: deletes local files absent from Drive)").WithParam("--yes") } return nil }, @@ -143,18 +143,18 @@ var DrivePull = common.Shortcut{ // remove the wrong files outside cwd. safeRoot, err := validate.SafeInputPath(localDir) if err != nil { - return output.ErrValidation("--local-dir: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir: %s", err).WithParam("--local-dir") } cwdCanonical, err := validate.SafeInputPath(".") if err != nil { - return output.ErrValidation("could not resolve cwd: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "could not resolve cwd: %s", err) } // rootRelToCwd is the localDir form FileIO.Save accepts (it // rejects absolute paths). For cwd itself it becomes ".", which // joins cleanly with the rel_paths returned by the lister. rootRelToCwd, err := filepath.Rel(cwdCanonical, safeRoot) if err != nil { - return output.ErrValidation("--local-dir resolves outside cwd: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir resolves outside cwd: %s", err).WithParam("--local-dir") } fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken)) @@ -174,7 +174,7 @@ var DrivePull = common.Shortcut{ // treated as orphaned. remoteFiles, remotePaths, err := drivePullRemoteViews(entries, duplicateRemote) if err != nil { - return output.Errorf(output.ExitInternal, "internal", "%s", err) + return errs.WrapInternal(err) } var downloaded, skipped, failed, deletedLocal int @@ -293,26 +293,25 @@ var DrivePull = common.Shortcut{ // Item-level failures (download error, dir/file conflict, delete // error) must surface as a non-zero exit so AI / script callers // don't have to reach into summary.failed to detect a partial - // sync. The same structured payload rides along in error.detail - // so forensics aren't lost. When --delete-local was skipped - // because of an earlier download failure, callers see - // deleted_local=0 plus the download failure that aborted it, - // which is what makes the partial state self-explanatory. + // sync. On any failure the structured payload (summary + items + + // a "note" carrying the human guidance) is written to stdout as an + // ok:false result via OutPartialFailure, which also sets the exit + // code, so the per-item context is never lost. When --delete-local + // was skipped because + // of an earlier download failure, callers see deleted_local=0 + // plus the download failure that aborted it, which is what makes + // the partial state self-explanatory. if failed > 0 { - msg := fmt.Sprintf("%d item(s) failed during +pull; partial sync — re-run after resolving the failures", failed) + note := fmt.Sprintf("%d item(s) failed during +pull; partial sync — re-run after resolving the failures", failed) if deleteLocal && downloadFailed > 0 { - msg += " (--delete-local was skipped because the download pass had failures)" - } - return &output.ExitError{ - Code: output.ExitAPI, - Detail: &output.ErrDetail{ - Type: "partial_failure", - Message: msg, - Detail: payload, - }, + note += " (--delete-local was skipped because the download pass had failures)" } + payload["note"] = note } + if failed > 0 { + return runtime.OutPartialFailure(payload, nil) + } runtime.Out(payload, nil) return nil }, @@ -326,14 +325,14 @@ func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, file ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)), }) if err != nil { - return output.ErrNetwork("download %s: %s", common.MaskToken(fileToken), err) + return wrapDriveNetworkErr(err, "download %s: %s", common.MaskToken(fileToken), err) } defer resp.Body.Close() if _, err := runtime.FileIO().Save(target, fileio.SaveOptions{ ContentType: resp.Header.Get("Content-Type"), ContentLength: resp.ContentLength, }, resp.Body); err != nil { - return common.WrapSaveErrorByCategory(err, "io") + return driveSaveError(err) } if err := drivePullApplyRemoteModifiedTime(target, remoteModifiedTime, runtime); err != nil { fmt.Fprintf(runtime.IO().ErrOut, "Downloaded %s but could not preserve remote modified_time: %s\n", target, err) @@ -350,10 +349,10 @@ func drivePullApplyRemoteModifiedTime(target, remoteModifiedTime string, runtime } resolved, err := runtime.FileIO().ResolvePath(target) if err != nil { - return output.ErrValidation("unsafe output path: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err) } if err := drivePullChtimes(resolved, remoteTime, remoteTime); err != nil { - return output.Errorf(output.ExitInternal, "io", "cannot preserve remote modified_time on local file: %s", err) + return errs.NewInternalError(errs.SubtypeFileIO, "cannot preserve remote modified_time on local file: %s", err).WithCause(err) } return nil } @@ -437,7 +436,7 @@ func drivePullRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (m remoteFiles[rel] = drivePullTarget{DownloadToken: chosen.FileToken, ItemFileToken: chosen.FileToken, ModifiedTime: chosen.ModifiedTime} remotePaths[rel] = struct{}{} default: - return nil, nil, fmt.Errorf("unsupported duplicate remote strategy %q", duplicateRemote) + return nil, nil, errs.NewInternalError(errs.SubtypeUnknown, "unsupported duplicate remote strategy %q", duplicateRemote) } } return remoteFiles, remotePaths, nil @@ -467,7 +466,7 @@ func drivePullWalkLocal(root string) ([]string, error) { return nil }) if err != nil { - return nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err) + return nil, errs.NewInternalError(errs.SubtypeFileIO, "walk %s: %s", root, err).WithCause(err) } return paths, nil } diff --git a/shortcuts/drive/drive_pull_test.go b/shortcuts/drive/drive_pull_test.go index c47018481..5a6d6caa3 100644 --- a/shortcuts/drive/drive_pull_test.go +++ b/shortcuts/drive/drive_pull_test.go @@ -478,9 +478,9 @@ func TestDrivePullSkipsWhenSmartIgnoresRemoteSize(t *testing.T) { // already a directory locally. SafeOutputPath would refuse to overwrite // the directory at write time, but if --if-exists=skip silently swallows // the collision the caller sees "skipped" and assumes the mirror is -// in sync. The fix surfaces it as a structured `partial_failure` -// ExitError (non-zero exit + items[] in error.detail) under both skip -// and overwrite policies so callers can react via exit code. +// in sync. The fix surfaces it as a partial-failure (ok:false items[] payload +// on stdout + non-zero exit) under both skip and overwrite policies so callers +// can react via exit code. func TestDrivePullSurfacesDirectoryFileMirrorConflict(t *testing.T) { for _, policy := range []string{"overwrite", "skip"} { t.Run(policy, func(t *testing.T) { @@ -515,8 +515,8 @@ func TestDrivePullSurfacesDirectoryFileMirrorConflict(t *testing.T) { "--if-exists", policy, "--as", "bot", }, f, stdout) - detail := assertDrivePullPartialFailure(t, err) - summary, items := splitDrivePullDetail(t, detail) + assertDrivePullPartialFailure(t, err) + summary, items := splitDrivePullStdout(t, stdout.Bytes()) if got := summary["failed"]; got != float64(1) { t.Errorf("[%s] summary.failed = %v, want 1", policy, got) } @@ -529,9 +529,6 @@ func TestDrivePullSurfacesDirectoryFileMirrorConflict(t *testing.T) { if msg, _ := items[0]["error"].(string); !strings.Contains(msg, "is a directory") { t.Errorf("[%s] error message should mention the directory conflict, got: %q", policy, msg) } - if stdout.Len() != 0 { - t.Errorf("[%s] stdout should be empty on partial_failure, got: %s", policy, stdout.String()) - } }) } } @@ -900,8 +897,8 @@ func TestDrivePullDeleteLocalPreservesLocalFileShadowedByRemoteFolder(t *testing // TestDrivePullDeleteLocalCountsFailureInSummary pins the contract that // a failed delete shows up in summary.failed (not just in items[]) AND -// surfaces as a partial_failure ExitError so callers can detect the -// half-synced state via exit code. Before the fix, the delete_failed +// surfaces as a non-zero exit (partial-failure signal) so callers can detect +// the half-synced state via exit code. Before the fix, the delete_failed // branches appended an item but left `failed` at zero AND returned nil, // so the JSON envelope reported `ok=true`+`exit=0` even when the mirror // was incomplete. Setup forces os.Remove to fail by making the file's @@ -947,8 +944,8 @@ func TestDrivePullDeleteLocalCountsFailureInSummary(t *testing.T) { "--yes", "--as", "bot", }, f, stdout) - detail := assertDrivePullPartialFailure(t, err) - summary, items := splitDrivePullDetail(t, detail) + assertDrivePullPartialFailure(t, err) + summary, items := splitDrivePullStdout(t, stdout.Bytes()) if got := summary["failed"]; got != float64(1) { t.Errorf("summary.failed = %v, want 1 (delete_failed must increment failed)", got) } @@ -958,15 +955,12 @@ func TestDrivePullDeleteLocalCountsFailureInSummary(t *testing.T) { if len(items) != 1 || items[0]["action"] != "delete_failed" { t.Errorf("expected one items[] entry with action=delete_failed, got: %#v", items) } - if stdout.Len() != 0 { - t.Errorf("stdout should be empty on partial_failure, got: %s", stdout.String()) - } } // TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero pins the // gating contract for --delete-local: when the download pass produced // any failure, the delete walk MUST be skipped entirely and the command -// MUST exit non-zero with type=partial_failure. The half-synced state +// MUST exit non-zero via the partial-failure signal. The half-synced state // where some Drive files are missing locally AND some local-only files // have been removed is never observable. func TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero(t *testing.T) { @@ -1014,12 +1008,12 @@ func TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero(t *testing.T) { "--yes", "--as", "bot", }, f, stdout) - exitErr := assertDrivePullPartialFailure(t, err) - if !strings.Contains(exitErr.Detail.Message, "--delete-local was skipped") { - t.Errorf("expected message to mention --delete-local skip, got: %q", exitErr.Detail.Message) + assertDrivePullPartialFailure(t, err) + if note := drivePullStdoutNote(t, stdout.Bytes()); !strings.Contains(note, "--delete-local was skipped") { + t.Errorf("expected note to mention --delete-local skip, got: %q", note) } - summary, items := splitDrivePullDetail(t, exitErr) + summary, items := splitDrivePullStdout(t, stdout.Bytes()) if got := summary["failed"]; got != float64(1) { t.Errorf("summary.failed = %v, want 1", got) } @@ -1036,9 +1030,6 @@ func TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero(t *testing.T) { if _, statErr := os.Stat(stale); statErr != nil { t.Fatalf("stale.txt must survive when --delete-local is skipped after a download failure; stat err=%v", statErr) } - if stdout.Len() != 0 { - t.Errorf("stdout should be empty on partial_failure, got: %s", stdout.String()) - } } // TestDrivePullDeleteLocalDoesNotEscapeViaSymlinkParentRef is the @@ -1343,49 +1334,60 @@ func mustReadFile(t *testing.T, path, want string) { } } -// assertDrivePullPartialFailure asserts that err is the structured -// partial_failure ExitError +pull returns when any item-level failure -// happens, and returns the unwrapped *ExitError so the caller can drill -// into Detail.Detail without re-doing the type assertion. -func assertDrivePullPartialFailure(t *testing.T, err error) *output.ExitError { +// assertDrivePullPartialFailure asserts that err is the typed partial-failure +// exit signal +pull returns on any item-level failure. The structured +// {summary, items, note} payload rides on stdout as an ok:false envelope via +// runtime.OutPartialFailure (in alignment with +push/+sync), so this helper +// only checks the exit-code signal; callers read the payload from stdout via +// splitDrivePullStdout. +func assertDrivePullPartialFailure(t *testing.T, err error) { t.Helper() if err == nil { - t.Fatal("expected partial_failure ExitError, got nil") - } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) - } - if exitErr.Code != output.ExitAPI { - t.Errorf("exit code = %d, want %d (ExitAPI)", exitErr.Code, output.ExitAPI) + t.Fatal("expected partial-failure exit signal, got nil") } - if exitErr.Detail == nil { - t.Fatalf("ExitError.Detail must be set on partial_failure") + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err) } - if exitErr.Detail.Type != "partial_failure" { - t.Errorf("error.type = %q, want partial_failure", exitErr.Detail.Type) + if pfErr.Code != output.ExitAPI { + t.Errorf("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI) } - return exitErr } -// splitDrivePullDetail extracts the {summary, items[]} payload from the -// ExitError detail. We round-trip through JSON so test assertions don't -// depend on the concrete map types the production code happens to use. -func splitDrivePullDetail(t *testing.T, exitErr *output.ExitError) (map[string]interface{}, []map[string]interface{}) { +// splitDrivePullStdout extracts the {summary, items[]} payload from the +// stdout envelope written by runtime.Out. We round-trip through JSON so test +// assertions don't depend on the concrete map types the production code +// happens to use. +func splitDrivePullStdout(t *testing.T, stdout []byte) (map[string]interface{}, []map[string]interface{}) { t.Helper() - raw, err := json.Marshal(exitErr.Detail.Detail) - if err != nil { - t.Fatalf("marshal detail: %v", err) + var envelope struct { + Data struct { + Summary map[string]interface{} `json:"summary"` + Items []map[string]interface{} `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(stdout, &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout)) } - var got struct { - Summary map[string]interface{} `json:"summary"` - Items []map[string]interface{} `json:"items"` + if envelope.Data.Summary == nil { + t.Fatalf("stdout missing data.summary; raw=%s", string(stdout)) } - if err := json.Unmarshal(raw, &got); err != nil { - t.Fatalf("unmarshal detail: %v\nraw=%s", err, string(raw)) + return envelope.Data.Summary, envelope.Data.Items +} + +// drivePullStdoutNote extracts the partial-failure "note" guidance from the +// stdout envelope. The human-readable note that used to live in the +// partial_failure ExitError message now rides on stdout alongside the +// summary + items payload. +func drivePullStdoutNote(t *testing.T, stdout []byte) string { + t.Helper() + var envelope struct { + Data struct { + Note string `json:"note"` + } `json:"data"` } - if got.Summary == nil { - t.Fatalf("error.detail missing summary; raw=%s", string(raw)) + if err := json.Unmarshal(stdout, &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout)) } - return got.Summary, got.Items + return envelope.Data.Note } diff --git a/shortcuts/drive/drive_push.go b/shortcuts/drive/drive_push.go index 78c34e67c..63bbf7c3d 100644 --- a/shortcuts/drive/drive_push.go +++ b/shortcuts/drive/drive_push.go @@ -5,7 +5,6 @@ package drive import ( "context" - "encoding/json" "errors" "fmt" "io" @@ -19,6 +18,7 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" @@ -112,26 +112,26 @@ var DrivePush = common.Shortcut{ localDir := strings.TrimSpace(runtime.Str("local-dir")) folderToken := strings.TrimSpace(runtime.Str("folder-token")) if localDir == "" { - return common.FlagErrorf("--local-dir is required") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is required").WithParam("--local-dir") } if folderToken == "" { - return common.FlagErrorf("--folder-token is required") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token is required").WithParam("--folder-token") } if err := validate.ResourceName(folderToken, "--folder-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token") } if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--local-dir") } info, err := runtime.FileIO().Stat(localDir) if err != nil { - return common.WrapInputStatError(err) + return driveInputStatError(err) } if !info.IsDir() { - return output.ErrValidation("--local-dir is not a directory: %s", localDir) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is not a directory: %s", localDir).WithParam("--local-dir") } if runtime.Bool("delete-remote") && !runtime.Bool("yes") { - return output.ErrValidation("--delete-remote requires --yes (high-risk: deletes Drive files absent locally)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--delete-remote requires --yes (high-risk: deletes Drive files absent locally)").WithParam("--yes") } // Conditional scope pre-check: when --delete-remote --yes is set, the // run will issue DELETE /open-apis/drive/v1/files/ after the @@ -185,11 +185,11 @@ var DrivePush = common.Shortcut{ // FileIO.Open's SafeInputPath check still accepts. safeRoot, err := validate.SafeInputPath(localDir) if err != nil { - return output.ErrValidation("--local-dir: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir: %s", err).WithParam("--local-dir") } cwdCanonical, err := validate.SafeInputPath(".") if err != nil { - return output.ErrValidation("could not resolve cwd: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "could not resolve cwd: %s", err) } fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir) @@ -217,7 +217,7 @@ var DrivePush = common.Shortcut{ // reruns. remoteFiles, remoteFolders, remoteFileGroups, err := drivePushRemoteViews(entries, duplicateRemote) if err != nil { - return output.Errorf(output.ExitInternal, "internal", "%s", err) + return errs.WrapInternal(err) } var uploaded, skipped, failed, deletedRemote int @@ -374,7 +374,7 @@ var DrivePush = common.Shortcut{ } } - runtime.Out(map[string]interface{}{ + payload := map[string]interface{}{ "summary": map[string]interface{}{ "uploaded": uploaded, "skipped": skipped, @@ -382,15 +382,15 @@ var DrivePush = common.Shortcut{ "deleted_remote": deletedRemote, }, "items": items, - }, nil) - // Bump the exit code on any item-level failure (upload, overwrite, - // folder, or delete) so callers / scripts / agents can react. The - // summary + items[] envelope was just written to stdout via Out(), - // so ErrBare here only affects the exit code — the structured - // per-item context is still in the stdout JSON. + } + // On any item-level failure (upload, overwrite, folder, or delete) the + // command reports a partial failure: the summary + per-item items[] stay + // machine-readable on stdout (ok:false) and the process exits non-zero, + // so callers / scripts / agents can react. if failed > 0 { - return output.ErrBare(output.ExitAPI) + return runtime.OutPartialFailure(payload, nil) } + runtime.Out(payload, nil) return nil }, } @@ -466,7 +466,7 @@ func drivePushWalkLocal(root, cwdCanonical string) (map[string]drivePushLocalFil return nil }) if err != nil { - return nil, nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err) + return nil, nil, errs.NewInternalError(errs.SubtypeFileIO, "walk %s: %s", root, err).WithCause(err) } dirs := make([]string, 0, len(dirsSet)) for d := range dirsSet { @@ -543,7 +543,7 @@ func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (m } remoteFiles[rel] = chosen default: - return nil, nil, nil, fmt.Errorf("unsupported duplicate remote strategy %q", duplicateRemote) + return nil, nil, nil, errs.NewInternalError(errs.SubtypeUnknown, "unsupported duplicate remote strategy %q", duplicateRemote) } } return remoteFiles, remoteFolders, fileGroups, nil @@ -567,7 +567,7 @@ func drivePushEnsureFolder(ctx context.Context, runtime *common.RuntimeContext, return "", err } - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "POST", "/open-apis/drive/v1/files/create_folder", nil, @@ -581,7 +581,7 @@ func drivePushEnsureFolder(ctx context.Context, runtime *common.RuntimeContext, } token := common.GetString(data, "token") if token == "" { - return "", output.Errorf(output.ExitAPI, "api_error", "create_folder for %q returned no folder token", relDir) + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "create_folder for %q returned no folder token", relDir) } folderCache[relDir] = token return token, nil @@ -617,7 +617,7 @@ func drivePushUploadFile(ctx context.Context, runtime *common.RuntimeContext, fi func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) { f, err := runtime.FileIO().Open(file.OpenPath) if err != nil { - return "", "", common.WrapInputStatError(err) + return "", "", driveInputStatError(err) } defer f.Close() @@ -644,27 +644,22 @@ func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file if errors.As(err, &exitErr) { return "", "", err } - return "", "", output.ErrNetwork("upload failed: %v", err) + return "", "", wrapDriveNetworkErr(err, "upload failed: %v", err) } - var result map[string]interface{} - if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { - return "", "", output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err) - } - // Extract the token before the larkCode check: the backend can produce - // a partial-success response (code != 0 alongside a non-empty - // data.file_token) where bytes have already landed under that token. - // Returning "" here would force the caller to fall back to + // ClassifyAPIResponse returns the data even on a non-zero code, so the + // token is available on a partial-success response (code != 0 alongside a + // non-empty data.file_token) where bytes have already landed under that + // token. Returning "" would force the caller to fall back to // entry.FileToken and silently lose the token Drive actually used, // defeating the overwrite-error token-stability handling in Execute. - data, _ := result["data"].(map[string]interface{}) + data, err := runtime.ClassifyAPIResponse(apiResp) token := common.GetString(data, "file_token") - if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 { - msg, _ := result["msg"].(string) - return token, "", output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"]) + if err != nil { + return token, "", err } if token == "" { - return "", "", output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned") + return "", "", errs.NewInternalError(errs.SubtypeInvalidResponse, "upload failed: no file_token returned") } version := common.GetString(data, "version") if version == "" { @@ -677,7 +672,7 @@ func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file // deployed backend hasn't shipped the field yet we surface the gap // rather than report a phantom success — callers can downgrade to // --if-exists=skip in the meantime. - return token, "", output.Errorf(output.ExitAPI, "api_error", "overwrite for %q succeeded but no version was returned by upload_all", file.RelPath) + return token, "", errs.NewInternalError(errs.SubtypeInvalidResponse, "overwrite for %q succeeded but no version was returned by upload_all", file.RelPath) } return token, version, nil } @@ -692,7 +687,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext, if existingToken != "" { prepareBody["file_token"] = existingToken } - prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody) + prepareResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody) if err != nil { return "", err } @@ -701,7 +696,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext, blockSize := int64(common.GetFloat(prepareResult, "block_size")) blockNum := int(common.GetFloat(prepareResult, "block_num")) if uploadID == "" || blockSize <= 0 || blockNum <= 0 { - return "", output.Errorf(output.ExitAPI, "api_error", + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d", uploadID, blockSize, blockNum) } @@ -717,7 +712,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext, // one Open + Close + path-validation per block). partFile, err := runtime.FileIO().Open(file.OpenPath) if err != nil { - return "", common.WrapInputStatError(err) + return "", driveInputStatError(err) } defer partFile.Close() @@ -744,21 +739,16 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext, if errors.As(doErr, &exitErr) { return "", doErr } - return "", output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, doErr) + return "", wrapDriveNetworkErr(doErr, "upload part %d/%d failed: %v", seq+1, blockNum, doErr) } - var partResult map[string]interface{} - if err := json.Unmarshal(apiResp.RawBody, &partResult); err != nil { - return "", output.Errorf(output.ExitAPI, "api_error", "upload part %d/%d: invalid response JSON: %v", seq+1, blockNum, err) - } - if larkCode := int(common.GetFloat(partResult, "code")); larkCode != 0 { - msg, _ := partResult["msg"].(string) - return "", output.ErrAPI(larkCode, fmt.Sprintf("upload part %d/%d failed: [%d] %s", seq+1, blockNum, larkCode, msg), partResult["error"]) + if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil { + return "", err } fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, blockNum, common.FormatSize(partSize)) } - finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{ + finishResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{ "upload_id": uploadID, "block_num": blockNum, }) @@ -767,7 +757,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext, } token := common.GetString(finishResult, "file_token") if token == "" { - return "", output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned") + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "upload_finish succeeded but no file_token returned") } return token, nil } @@ -776,7 +766,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext, // never reached here because --delete-remote only iterates the type=file // subset of the remote listing. func drivePushDeleteFile(_ context.Context, runtime *common.RuntimeContext, fileToken string) error { - _, err := runtime.CallAPI( + _, err := runtime.CallAPITyped( "DELETE", fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(fileToken)), map[string]interface{}{"type": driveTypeFile}, diff --git a/shortcuts/drive/drive_push_test.go b/shortcuts/drive/drive_push_test.go index 3d5654ca2..fff10eebe 100644 --- a/shortcuts/drive/drive_push_test.go +++ b/shortcuts/drive/drive_push_test.go @@ -871,21 +871,19 @@ func TestDrivePushOverwriteWithoutVersionFails(t *testing.T) { "--if-exists", "overwrite", "--as", "bot", }, f, stdout) - // Item-level failures bump the exit code via output.ErrBare(ExitAPI), - // preserving the structured items[] envelope on stdout. Older behavior - // was to silently return nil; the assertion below pins the new contract. + // Item-level failures report a partial failure: an ok:false items[] + // envelope on stdout + a non-zero exit via the partial-failure signal. + // Older behavior was to silently return nil; the assertion below pins + // the new contract. if err == nil { t.Fatalf("expected non-zero exit on item-level failure, got nil\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err) } - if exitErr.Code != output.ExitAPI { - t.Errorf("expected ExitAPI (%d), got code=%d", output.ExitAPI, exitErr.Code) - } - if exitErr.Detail != nil { - t.Errorf("ErrBare should carry no Detail (the items[] envelope already covered the per-item error), got: %#v", exitErr.Detail) + if pfErr.Code != output.ExitAPI { + t.Errorf("expected ExitAPI (%d), got code=%d", output.ExitAPI, pfErr.Code) } out := stdout.String() @@ -959,12 +957,19 @@ func TestDrivePushOverwritePartialSuccessSurfacesReturnedToken(t *testing.T) { if err == nil { t.Fatalf("expected non-zero exit on item-level failure, got nil\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Code != output.ExitAPI { - t.Fatalf("expected ExitAPI from output.ExitError, got %T %v", err, err) + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) || pfErr.Code != output.ExitAPI { + t.Fatalf("expected ExitAPI from *output.PartialFailureError, got %T %v", err, err) } out := stdout.String() + // Partial failure reports an ok:false result envelope on stdout (not a + // misleading ok:true) while still carrying BOTH the succeeded and failed + // items — consistent with the pre-change payload. The failed side is + // asserted via "failed": 1 and the succeeded side via tok_keep_partial. + if !strings.Contains(out, `"ok": false`) { + t.Errorf("partial failure must emit an ok:false result envelope, got: %s", out) + } if !strings.Contains(out, `"failed": 1`) { t.Errorf("expected failed=1, got: %s", out) } @@ -1042,9 +1047,9 @@ func TestDrivePushSkipsDeleteAfterUploadFailure(t *testing.T) { if err == nil { t.Fatalf("expected non-zero exit on overwrite failure, got nil\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Code != output.ExitAPI { - t.Fatalf("expected ExitAPI ExitError, got %v", err) + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) || pfErr.Code != output.ExitAPI { + t.Fatalf("expected ExitAPI *output.PartialFailureError, got %v", err) } out := stdout.String() @@ -1065,7 +1070,7 @@ func TestDrivePushSkipsDeleteAfterUploadFailure(t *testing.T) { // TestDrivePushExitsZeroOnCleanRun pins the inverse: a successful run // with no failures must NOT bump the exit code. Without this the -// ErrBare-on-failure path could regress to "always non-zero" silently. +// partial-failure path could regress to "always non-zero" silently. func TestDrivePushExitsZeroOnCleanRun(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) diff --git a/shortcuts/drive/drive_search.go b/shortcuts/drive/drive_search.go index e1b618fe6..a17872512 100644 --- a/shortcuts/drive/drive_search.go +++ b/shortcuts/drive/drive_search.go @@ -6,7 +6,6 @@ package drive import ( "context" "encoding/json" - "errors" "fmt" "io" "math" @@ -15,6 +14,7 @@ import ( "strings" "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -219,13 +219,13 @@ func readDriveSearchSpec(runtime *common.RuntimeContext) driveSearchSpec { // that depends on the combination of flag values. func buildDriveSearchRequest(spec driveSearchSpec, userOpenID string, now time.Time) (map[string]interface{}, []string, error) { if spec.Mine && len(spec.CreatorIDs) > 0 { - return nil, nil, output.ErrValidation("cannot combine --mine and --creator-ids") + return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot combine --mine and --creator-ids") } if len(spec.FolderTokens) > 0 && len(spec.SpaceIDs) > 0 { - return nil, nil, output.ErrValidation("cannot combine --folder-tokens and --space-ids; doc and wiki scoped search cannot be combined") + return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot combine --folder-tokens and --space-ids; doc and wiki scoped search cannot be combined") } if spec.Mine && userOpenID == "" { - return nil, nil, output.ErrValidation("--mine requires a logged-in user open_id, but none is configured; run `lark-cli auth login` or set user open_id in config") + return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--mine requires a logged-in user open_id, but none is configured; run `lark-cli auth login` or set user open_id in config").WithParam("--mine") } if err := validateDocTypes(spec.DocTypes); err != nil { @@ -337,7 +337,7 @@ func parseDriveSearchPageSize(raw string) (int, error) { } n, err := strconv.Atoi(raw) if err != nil { - return 0, output.ErrValidation("--page-size must be a number, got %q", raw) + return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be a number, got %q", raw).WithParam("--page-size") } if n <= 0 { return 15, nil @@ -355,23 +355,23 @@ func parseDriveSearchPageSize(raw string) (int, error) { func validateDriveSearchIDs(spec driveSearchSpec) error { for _, id := range spec.CreatorIDs { if _, err := common.ValidateUserID(id); err != nil { - return output.ErrValidation("--creator-ids %q: %s", id, err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--creator-ids %q: %s", id, err).WithParam("--creator-ids") } } if n := len(spec.ChatIDs); n > driveSearchMaxChatIDs { - return output.ErrValidation("--chat-ids: max %d values per request, got %d", driveSearchMaxChatIDs, n) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-ids: max %d values per request, got %d", driveSearchMaxChatIDs, n).WithParam("--chat-ids") } for _, id := range spec.ChatIDs { if _, err := common.ValidateChatID(id); err != nil { - return output.ErrValidation("--chat-ids %q: %s", id, err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-ids %q: %s", id, err).WithParam("--chat-ids") } } if n := len(spec.SharerIDs); n > driveSearchMaxSharerIDs { - return output.ErrValidation("--sharer-ids: max %d values per request, got %d", driveSearchMaxSharerIDs, n) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sharer-ids: max %d values per request, got %d", driveSearchMaxSharerIDs, n).WithParam("--sharer-ids") } for _, id := range spec.SharerIDs { if _, err := common.ValidateUserID(id); err != nil { - return output.ErrValidation("--sharer-ids %q: %s", id, err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sharer-ids %q: %s", id, err).WithParam("--sharer-ids") } } return nil @@ -382,7 +382,7 @@ func validateDocTypes(values []string) error { // values are already upper-cased by readDriveSearchSpec; compare as-is // so the filter we emit to the server matches what we validated. if _, ok := driveSearchDocTypeSet[v]; !ok { - return output.ErrValidation("--doc-types contains unknown value %q (allowed: doc,sheet,bitable,mindnote,file,wiki,docx,folder,catalog,slides,shortcut)", v) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-types contains unknown value %q (allowed: doc,sheet,bitable,mindnote,file,wiki,docx,folder,catalog,slides,shortcut)", v).WithParam("--doc-types") } } return nil @@ -417,13 +417,13 @@ func clampOpenedTimeWindow(spec *driveSearchSpec, now time.Time) (string, error) } sinceUnix, err := parseTimeValue(spec.OpenedSince, now) if err != nil { - return "", output.ErrValidation("invalid --opened-since %q: %s", spec.OpenedSince, err) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --opened-since %q: %s", spec.OpenedSince, err).WithParam("--opened-since") } var untilUnix int64 if spec.OpenedUntil != "" { untilUnix, err = parseTimeValue(spec.OpenedUntil, now) if err != nil { - return "", output.ErrValidation("invalid --opened-until %q: %s", spec.OpenedUntil, err) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --opened-until %q: %s", spec.OpenedUntil, err).WithParam("--opened-until") } } else { untilUnix = now.Unix() @@ -440,7 +440,7 @@ func clampOpenedTimeWindow(spec *driveSearchSpec, now time.Time) (string, error) } maxSecs := int64(driveSearchMaxOpenedSpanDays) * 24 * 3600 if spanSecs > maxSecs { - return "", output.ErrValidation( + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--opened-* window spans %d days, exceeds the %d-day (1-year) maximum; narrow the range or run multiple queries", spanSecs/86400, driveSearchMaxOpenedSpanDays, ) @@ -505,7 +505,7 @@ func buildTimeRangeFilter(key, since, until string, now time.Time) (map[string]i if since != "" { unix, err := parseTimeValue(since, now) if err != nil { - return nil, nil, output.ErrValidation("invalid --%s-since %q: %s", timeDimCLIName(key), since, err) + return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --%s-since %q: %s", timeDimCLIName(key), since, err).WithParam(fmt.Sprintf("--%s-since", timeDimCLIName(key))) } if hourAggregated && unix%3600 != 0 { snapped := floorHour(unix) @@ -517,7 +517,7 @@ func buildTimeRangeFilter(key, since, until string, now time.Time) (map[string]i if until != "" { unix, err := parseTimeValue(until, now) if err != nil { - return nil, nil, output.ErrValidation("invalid --%s-until %q: %s", timeDimCLIName(key), until, err) + return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --%s-until %q: %s", timeDimCLIName(key), until, err).WithParam(fmt.Sprintf("--%s-until", timeDimCLIName(key))) } if hourAggregated && unix%3600 != 0 { snapped := ceilHour(unix) @@ -571,7 +571,7 @@ var driveSearchRelativeRe = regexp.MustCompile(`^(\d+)([dmy])$`) func parseTimeValue(input string, now time.Time) (int64, error) { s := strings.TrimSpace(input) if s == "" { - return 0, fmt.Errorf("empty value") + return 0, fmt.Errorf("empty value") //nolint:forbidigo // intermediate parse helper; caller wraps into typed ValidationError } if m := driveSearchRelativeRe.FindStringSubmatch(s); m != nil { @@ -616,34 +616,27 @@ func parseTimeValue(input string, now time.Time) (int64, error) { } } - return 0, fmt.Errorf("expected relative (7d/1m/1y), date (YYYY-MM-DD[ HH:MM:SS]), RFC3339, or unix seconds") + return 0, fmt.Errorf("expected relative (7d/1m/1y), date (YYYY-MM-DD[ HH:MM:SS]), RFC3339, or unix seconds") //nolint:forbidigo // intermediate parse helper; caller wraps into typed ValidationError } func callDriveSearchAPI(runtime *common.RuntimeContext, reqBody map[string]interface{}) (map[string]interface{}, error) { - data, err := runtime.CallAPI("POST", "/open-apis/search/v2/doc_wiki/search", nil, reqBody) + data, err := runtime.CallAPITyped("POST", "/open-apis/search/v2/doc_wiki/search", nil, reqBody) if err != nil { return nil, enrichDriveSearchError(err) } return data, nil } -// enrichDriveSearchError adds a +search-specific hint for known opaque Lark -// codes; other errors pass through unchanged. +// enrichDriveSearchError adds a +search-specific hint for a known opaque Lark +// code; other errors pass through unchanged. The hint is appended in place on +// the typed Problem, preserving its category / subtype / code / log_id. func enrichDriveSearchError(err error) error { - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { + p, ok := errs.ProblemOf(err) + if !ok || p.Code != driveSearchErrUserNotVisible { return err } - if exitErr.Detail.Code != driveSearchErrUserNotVisible { - return err - } - detail := *exitErr.Detail - detail.Hint = "one or more open_ids in --creator-ids / --sharer-ids are outside this app's user-visibility scope (this is the app's contact visibility, not the search:docs:read API scope); ask an admin to grant the app visibility to those users in the developer console, or drop the unreachable open_ids" - return &output.ExitError{ - Code: exitErr.Code, - Detail: &detail, - Err: exitErr.Err, - } + p.Hint = "one or more open_ids in --creator-ids / --sharer-ids are outside this app's user-visibility scope (this is the app's contact visibility, not the search:docs:read API scope); ask an admin to grant the app visibility to those users in the developer console, or drop the unreachable open_ids" + return err } func cloneDriveSearchFilter(src map[string]interface{}) map[string]interface{} { diff --git a/shortcuts/drive/drive_search_test.go b/shortcuts/drive/drive_search_test.go index a26faf3e6..7e9721e02 100644 --- a/shortcuts/drive/drive_search_test.go +++ b/shortcuts/drive/drive_search_test.go @@ -13,6 +13,8 @@ import ( "testing" "time" + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/output" ) @@ -258,6 +260,19 @@ func TestValidateDriveSearchIDs(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "--creator-ids") { t.Fatalf("expected --creator-ids error, got: %v", err) } + var vErr *errs.ValidationError + if !errors.As(err, &vErr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) + } + if vErr.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("Subtype = %q, want %q", vErr.Subtype, errs.SubtypeInvalidArgument) + } + if vErr.Param != "--creator-ids" { + t.Fatalf("Param = %q, want --creator-ids", vErr.Param) + } + if got := output.ExitCodeOf(err); got != output.ExitValidation { + t.Fatalf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation) + } }) t.Run("bad chat id format", func(t *testing.T) { @@ -625,51 +640,39 @@ func TestEnrichDriveSearchError(t *testing.T) { } }) - t.Run("ExitError without Detail passes through", func(t *testing.T) { - t.Parallel() - orig := &output.ExitError{Code: 1} - if got := enrichDriveSearchError(orig); got != orig { - t.Fatalf("ExitError without Detail should pass through unchanged") - } - }) - - t.Run("ExitError with non-matching code passes through", func(t *testing.T) { + t.Run("typed error with non-matching code passes through", func(t *testing.T) { t.Parallel() - orig := &output.ExitError{ - Code: 1, - Detail: &output.ErrDetail{Code: 12345, Message: "other"}, - } + orig := errclass.BuildAPIError( + map[string]any{"code": float64(12345), "msg": "other"}, + errclass.ClassifyContext{}, + ) if got := enrichDriveSearchError(orig); got != orig { t.Fatalf("non-matching code should pass through unchanged") } }) - t.Run("matching code rewrites Hint without mutating original", func(t *testing.T) { + t.Run("matching code decorates the typed error's hint in place", func(t *testing.T) { t.Parallel() - orig := &output.ExitError{ - Code: 1, - Detail: &output.ErrDetail{ - Code: driveSearchErrUserNotVisible, - Message: "[99992351] user not visible", - Hint: "", - }, - } + orig := errclass.BuildAPIError( + map[string]any{"code": float64(driveSearchErrUserNotVisible), "msg": "[99992351] user not visible"}, + errclass.ClassifyContext{}, + ) + // Terminal decoration of an upstream error: the hint is set in place on + // the existing typed Problem and that same error is returned (no new + // error is constructed). enriched := enrichDriveSearchError(orig) - eErr, ok := enriched.(*output.ExitError) - if !ok { - t.Fatalf("expected *output.ExitError, got %T", enriched) - } - if eErr == orig { - t.Fatal("should return a new ExitError, not mutate the original") + if enriched != orig { + t.Fatal("should decorate and return the upstream error, not construct a new one") } - if orig.Detail.Hint != "" { - t.Fatal("original Detail.Hint must remain unchanged") + p, ok := errs.ProblemOf(enriched) + if !ok { + t.Fatalf("expected a typed errs.* error, got %T", enriched) } - if !strings.Contains(eErr.Detail.Hint, "--creator-ids") { - t.Fatalf("hint should mention --creator-ids, got %q", eErr.Detail.Hint) + if !strings.Contains(p.Hint, "--creator-ids") { + t.Fatalf("hint should mention --creator-ids, got %q", p.Hint) } - if eErr.Detail.Message != orig.Detail.Message { - t.Fatalf("Message should be preserved, got %q", eErr.Detail.Message) + if p.Message != "[99992351] user not visible" { + t.Fatalf("Message should be preserved, got %q", p.Message) } }) } @@ -739,6 +742,18 @@ func TestBuildDriveSearchRequest(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "--mine") { t.Fatalf("expected exclusion error, got: %v", err) } + // Mutual-exclusion error: typed validation, but no single attributable + // flag, so Param stays empty. + var vErr *errs.ValidationError + if !errors.As(err, &vErr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) + } + if vErr.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("Subtype = %q, want %q", vErr.Subtype, errs.SubtypeInvalidArgument) + } + if vErr.Param != "" { + t.Fatalf("Param = %q, want empty for mutual-exclusion error", vErr.Param) + } }) t.Run("--folder-tokens + --space-ids mutually exclusive", func(t *testing.T) { diff --git a/shortcuts/drive/drive_secure_label.go b/shortcuts/drive/drive_secure_label.go index 3d2dee1a6..a7b8f95db 100644 --- a/shortcuts/drive/drive_secure_label.go +++ b/shortcuts/drive/drive_secure_label.go @@ -7,7 +7,7 @@ import ( "context" "fmt" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -36,7 +36,7 @@ var DriveSecureLabelList = common.Shortcut{ Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { pageSize := runtime.Int("page-size") if pageSize < 1 || pageSize > 10 { - return output.ErrValidation("--page-size must be between 1 and 10") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be between 1 and 10").WithParam("--page-size") } return nil }, @@ -47,7 +47,7 @@ var DriveSecureLabelList = common.Shortcut{ Params(buildSecureLabelListParams(runtime)) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - data, err := runtime.CallAPI("GET", + data, err := runtime.CallAPITyped("GET", "/open-apis/drive/v2/my_secure_labels", buildSecureLabelListParams(runtime), nil, @@ -95,7 +95,7 @@ var DriveSecureLabelUpdate = common.Shortcut{ return err } body := map[string]interface{}{"id": runtime.Str("label-id")} - data, err := runtime.CallAPI("PATCH", + data, err := runtime.CallAPITyped("PATCH", fmt.Sprintf("/open-apis/drive/v2/files/%s/secure_label", validate.EncodePathSegment(token)), map[string]interface{}{"type": docType}, body, diff --git a/shortcuts/drive/drive_status.go b/shortcuts/drive/drive_status.go index b3e9470c0..45554f384 100644 --- a/shortcuts/drive/drive_status.go +++ b/shortcuts/drive/drive_status.go @@ -17,7 +17,7 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -75,27 +75,27 @@ var DriveStatus = common.Shortcut{ localDir := strings.TrimSpace(runtime.Str("local-dir")) folderToken := strings.TrimSpace(runtime.Str("folder-token")) if localDir == "" { - return common.FlagErrorf("--local-dir is required") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is required").WithParam("--local-dir") } if folderToken == "" { - return common.FlagErrorf("--folder-token is required") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token is required").WithParam("--folder-token") } if err := validate.ResourceName(folderToken, "--folder-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token") } // Path safety (absolute paths, traversal, symlink escape) is enforced // upfront by the framework helper so the error message references the // correct flag name; FileIO().Stat below would do the same check, but // surface --file in its hint. if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--local-dir") } info, err := runtime.FileIO().Stat(localDir) if err != nil { - return common.WrapInputStatError(err) + return driveInputStatError(err) } if !info.IsDir() { - return output.ErrValidation("--local-dir is not a directory: %s", localDir) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is not a directory: %s", localDir).WithParam("--local-dir") } // Conditional scope pre-check: quick mode only compares local mtime with // Drive modified_time, so it must not be blocked on the download grant. @@ -144,11 +144,11 @@ var DriveStatus = common.Shortcut{ // only possible under a Validate↔Execute race. safeRoot, err := validate.SafeInputPath(localDir) if err != nil { - return output.ErrValidation("--local-dir: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir: %s", err).WithParam("--local-dir") } cwdCanonical, err := validate.SafeInputPath(".") if err != nil { - return output.ErrValidation("could not resolve cwd: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "could not resolve cwd: %s", err) } fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir) @@ -263,7 +263,7 @@ func walkLocalForStatus(root, cwdCanonical string) (map[string]driveStatusLocalF return nil }) if err != nil { - return nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err) + return nil, errs.NewInternalError(errs.SubtypeFileIO, "walk %s: %s", root, err).WithCause(err) } return files, nil } @@ -276,12 +276,12 @@ func driveStatusShouldTreatAsUnchangedQuick(remoteModified string, local time.Ti func hashLocalForStatus(runtime *common.RuntimeContext, path string) (string, error) { f, err := runtime.FileIO().Open(path) if err != nil { - return "", common.WrapInputStatError(err) + return "", driveInputStatError(err) } defer f.Close() h := sha256.New() if _, err := io.Copy(h, f); err != nil { - return "", output.Errorf(output.ExitInternal, "io", "hash %s: %s", path, err) + return "", errs.NewInternalError(errs.SubtypeFileIO, "hash %s: %s", path, err).WithCause(err) } return hex.EncodeToString(h.Sum(nil)), nil } @@ -292,12 +292,12 @@ func hashRemoteForStatus(ctx context.Context, runtime *common.RuntimeContext, fi ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)), }) if err != nil { - return "", output.ErrNetwork("download %s: %s", common.MaskToken(fileToken), err) + return "", wrapDriveNetworkErr(err, "download %s: %s", common.MaskToken(fileToken), err) } defer resp.Body.Close() h := sha256.New() if _, err := io.Copy(h, resp.Body); err != nil { - return "", output.ErrNetwork("hash remote %s: %s", common.MaskToken(fileToken), err) + return "", wrapDriveNetworkErr(err, "hash remote %s: %s", common.MaskToken(fileToken), err) } return hex.EncodeToString(h.Sum(nil)), nil } diff --git a/shortcuts/drive/drive_status_test.go b/shortcuts/drive/drive_status_test.go index 1a9210cc1..d00498460 100644 --- a/shortcuts/drive/drive_status_test.go +++ b/shortcuts/drive/drive_status_test.go @@ -822,12 +822,15 @@ func TestWalkLocalForStatusMissingRootReturnsInternalError(t *testing.T) { if err == nil { t.Fatal("expected walkLocalForStatus() to fail for missing root") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("expected structured ExitError, got %T", err) + var internalErr *errs.InternalError + if !errors.As(err, &internalErr) { + t.Fatalf("expected *errs.InternalError, got %T", err) } - if exitErr.Detail == nil || exitErr.Detail.Type != "io" { - t.Fatalf("expected io error detail, got %#v", exitErr.Detail) + 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) } if !strings.Contains(err.Error(), "walk") { t.Fatalf("expected walk-related error, got: %v", err) diff --git a/shortcuts/drive/drive_sync.go b/shortcuts/drive/drive_sync.go index 3c512cecf..ecb97e52d 100644 --- a/shortcuts/drive/drive_sync.go +++ b/shortcuts/drive/drive_sync.go @@ -13,7 +13,7 @@ import ( "path/filepath" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -72,23 +72,23 @@ var DriveSync = common.Shortcut{ localDir := strings.TrimSpace(runtime.Str("local-dir")) folderToken := strings.TrimSpace(runtime.Str("folder-token")) if localDir == "" { - return common.FlagErrorf("--local-dir is required") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is required").WithParam("--local-dir") } if folderToken == "" { - return common.FlagErrorf("--folder-token is required") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token is required").WithParam("--folder-token") } if err := validate.ResourceName(folderToken, "--folder-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token") } if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--local-dir") } info, err := runtime.FileIO().Stat(localDir) if err != nil { - return common.WrapInputStatError(err) + return driveInputStatError(err) } if !info.IsDir() { - return output.ErrValidation("--local-dir is not a directory: %s", localDir) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is not a directory: %s", localDir).WithParam("--local-dir") } return nil }, @@ -118,15 +118,15 @@ var DriveSync = common.Shortcut{ safeRoot, err := validate.SafeInputPath(localDir) if err != nil { - return output.ErrValidation("--local-dir: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir: %s", err).WithParam("--local-dir") } cwdCanonical, err := validate.SafeInputPath(".") if err != nil { - return output.ErrValidation("could not resolve cwd: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "could not resolve cwd: %s", err) } rootRelToCwd, err := filepath.Rel(cwdCanonical, safeRoot) if err != nil { - return output.ErrValidation("--local-dir resolves outside cwd: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir resolves outside cwd: %s", err).WithParam("--local-dir") } // --- Phase 1: Compute diff (same logic as +status) --- @@ -176,18 +176,18 @@ var DriveSync = common.Shortcut{ } } if len(typeConflicts) > 0 { - return output.ErrValidation("+sync cannot proceed: path type conflict — %s; remove the local entry or the remote entry and retry", strings.Join(typeConflicts, "; ")) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "+sync cannot proceed: path type conflict — %s; remove the local entry or the remote entry and retry", strings.Join(typeConflicts, "; ")) } // Build the exact remote-file views that later execution will use so the // diff phase classifies files against the same duplicate-resolution choice. pullRemoteFiles, _, err := drivePullRemoteViews(entries, duplicateRemote) if err != nil { - return output.Errorf(output.ExitInternal, "internal", "%s", err) + return errs.WrapInternal(err) } remoteEntriesForPush, remoteFolders, _, err := drivePushRemoteViews(entries, duplicateRemote) if err != nil { - return output.Errorf(output.ExitInternal, "internal", "%s", err) + return errs.WrapInternal(err) } remoteFiles := driveSyncStatusRemoteFiles(pullRemoteFiles) @@ -240,43 +240,19 @@ var DriveSync = common.Shortcut{ conflictResolutions := make(map[string]string, len(modified)) if onConflict == driveSyncOnConflictAsk && len(modified) > 0 && runtime.IO().In == nil { - return output.ErrValidation("--on-conflict=ask requires interactive stdin when modified files exist") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--on-conflict=ask requires interactive stdin when modified files exist").WithParam("--on-conflict") } for _, entry := range modified { resolved := onConflict if resolved == driveSyncOnConflictAsk { resolved, err = driveSyncAskConflict(entry.RelPath, runtime) if err != nil { - payload := map[string]interface{}{ - "detection": detection, - "diff": map[string]interface{}{ - "new_local": emptyIfNil(newLocal), - "new_remote": emptyIfNil(newRemote), - "modified": emptyIfNil(modified), - "unchanged": emptyIfNil(unchanged), - }, - "summary": map[string]interface{}{ - "pulled": 0, - "pushed": 0, - "skipped": 0, - "failed": 1, - }, - "items": []driveSyncItem{{ - RelPath: entry.RelPath, - FileToken: entry.FileToken, - Action: "failed", - Direction: "conflict", - Error: err.Error(), - }}, - } - return &output.ExitError{ - Code: output.ExitAPI, - Detail: &output.ErrDetail{ - Type: "partial_failure", - Message: fmt.Sprintf("cannot collect conflict decisions for +sync: %v", err), - Detail: payload, - }, - } + // Phase-1 setup abort: no sync operation ran yet, so this + // is not a batch partial-failure. driveSyncAskConflict + // already returns a typed *errs.ValidationError; propagate + // it unchanged rather than re-wrapping it as a synthetic + // partial_failure payload. + return err } } conflictResolutions[entry.RelPath] = resolved @@ -521,17 +497,12 @@ var DriveSync = common.Shortcut{ } if failed > 0 { - msg := fmt.Sprintf("%d item(s) failed during +sync", failed) - return &output.ExitError{ - Code: output.ExitAPI, - Detail: &output.ErrDetail{ - Type: "partial_failure", - Message: msg, - Detail: payload, - }, - } + payload["note"] = fmt.Sprintf("%d item(s) failed during +sync", failed) } + if failed > 0 { + return runtime.OutPartialFailure(payload, nil) + } runtime.Out(payload, nil) return nil }, @@ -555,7 +526,7 @@ func driveSyncStatusRemoteFiles(pullRemoteFiles map[string]drivePullTarget) map[ func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (string, error) { fmt.Fprintf(runtime.IO().ErrOut, "CONFLICT: both sides modified %q. Choose: [R]emote-wins / [L]ocal-wins / [K]eep-both / [S]kip (default: R): ", relPath) if runtime.IO().In == nil { - return "", output.ErrValidation("cannot resolve conflict for %q with --on-conflict=ask: stdin is not available", relPath) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot resolve conflict for %q with --on-conflict=ask: stdin is not available", relPath).WithParam("--on-conflict") } reader, ok := runtime.IO().In.(*bufio.Reader) if !ok { @@ -564,12 +535,12 @@ func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (strin } line, err := reader.ReadString('\n') if err != nil && !errors.Is(err, io.EOF) { - return "", output.ErrValidation("cannot read conflict choice for %q: %s", relPath, err) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read conflict choice for %q: %s", relPath, err).WithParam("--on-conflict") } answer := strings.TrimSpace(strings.ToLower(line)) if answer == "" { if errors.Is(err, io.EOF) { - return "", output.ErrValidation("cannot resolve conflict for %q with --on-conflict=ask: stdin reached EOF before any choice was provided", relPath) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot resolve conflict for %q with --on-conflict=ask: stdin reached EOF before any choice was provided", relPath).WithParam("--on-conflict") } return driveSyncOnConflictRemoteWins, nil } @@ -583,7 +554,7 @@ func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (strin case "r", "remote", "remote-wins": return driveSyncOnConflictRemoteWins, nil default: - return "", output.ErrValidation("invalid conflict choice for %q: %q (expected one of remote/local/keep/skip)", relPath, strings.TrimSpace(line)) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid conflict choice for %q: %q (expected one of remote/local/keep/skip)", relPath, strings.TrimSpace(line)).WithParam("--on-conflict") } } @@ -635,16 +606,16 @@ func driveSyncNeedsCreateScope(uploadPaths []string, localDirs []string, folderC func driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath string) error { if info, err := os.Stat(oldAbsPath); err == nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated. if info.IsDir() { - return output.Errorf(output.ExitInternal, "rollback", "original path became a directory during rollback: %s", oldAbsPath) + return errs.NewInternalError(errs.SubtypeFileIO, "original path became a directory during rollback: %s", oldAbsPath) } if err := os.Remove(oldAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated. - return output.Errorf(output.ExitInternal, "rollback", "remove partial restored path %q: %s", oldAbsPath, err) + return errs.NewInternalError(errs.SubtypeFileIO, "remove partial restored path %q: %s", oldAbsPath, err).WithCause(err) } } else if !os.IsNotExist(err) { - return output.Errorf(output.ExitInternal, "rollback", "stat original path %q during rollback: %s", oldAbsPath, err) + return errs.NewInternalError(errs.SubtypeFileIO, "stat original path %q during rollback: %s", oldAbsPath, err).WithCause(err) } if err := os.Rename(newAbsPath, oldAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated. - return output.Errorf(output.ExitInternal, "rollback", "restore renamed local file %q: %s", oldAbsPath, err) + return errs.NewInternalError(errs.SubtypeFileIO, "restore renamed local file %q: %s", oldAbsPath, err).WithCause(err) } return nil } diff --git a/shortcuts/drive/drive_sync_test.go b/shortcuts/drive/drive_sync_test.go index 7364397eb..1e1ea5afc 100644 --- a/shortcuts/drive/drive_sync_test.go +++ b/shortcuts/drive/drive_sync_test.go @@ -18,6 +18,7 @@ import ( "testing" "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" @@ -1434,14 +1435,15 @@ func TestDriveSyncAskConflictEOFDuringExecuteReportsFailedItem(t *testing.T) { if err == nil { t.Fatalf("expected EOF failure during ask execution\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured ExitError, got: %v", err) + // Collecting conflict decisions runs in the Phase-1 setup pass, before + // any sync operation executes, so the EOF abort propagates the typed + // *errs.ValidationError unchanged rather than a synthetic partial_failure. + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) } - detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) - items, _ := detailMap["items"].([]driveSyncItem) - if len(items) == 0 || !strings.Contains(items[0].Error, "stdin reached EOF") { - t.Fatalf("expected failed ask item, got detail: %#v", exitErr.Detail.Detail) + if !strings.Contains(validationErr.Error(), "stdin reached EOF") { + t.Fatalf("expected EOF failure, got: %v", validationErr) } data, readErr := os.ReadFile("local/a.txt") if readErr != nil { @@ -1503,12 +1505,15 @@ func TestDriveSyncAskConflictEOFDuringPlanningPreventsAnyWrites(t *testing.T) { if err == nil { t.Fatalf("expected EOF failure during ask planning\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured ExitError, got: %v", err) + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) } - if exitErr.Detail.Type != "partial_failure" || !strings.Contains(exitErr.Error(), "stdin reached EOF") { - t.Fatalf("expected planning failure detail mentioning EOF, got: %#v", exitErr.Detail) + if validationErr.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument) + } + if !strings.Contains(validationErr.Error(), "stdin reached EOF") { + t.Fatalf("expected planning failure mentioning EOF, got: %v", validationErr) } if data, readErr := os.ReadFile("local/a.txt"); readErr != nil || string(data) != "local-a" { t.Fatalf("a.txt should remain untouched, readErr=%v content=%q", readErr, string(data)) @@ -1706,14 +1711,10 @@ func TestDriveSyncReportsNewRemoteDownloadFailure(t *testing.T) { if err == nil { t.Fatalf("expected download failure\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured ExitError, got: %v", err) - } - detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) - items, _ := detailMap["items"].([]driveSyncItem) + assertDriveSyncPartialFailure(t, err) + items := driveSyncStdoutItems(t, stdout.Bytes()) if len(items) == 0 || items[0].Direction != "pull" || !strings.Contains(items[0].Error, "save failed") { - t.Fatalf("expected failed pull item, got detail: %#v", exitErr.Detail.Detail) + t.Fatalf("expected failed pull item, got detail: %#v", stdout.String()) } } @@ -1758,14 +1759,10 @@ func TestDriveSyncReportsNewLocalEnsureFailure(t *testing.T) { if err == nil { t.Fatalf("expected ensure failure\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured ExitError, got: %v", err) - } - detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) - items, _ := detailMap["items"].([]driveSyncItem) + assertDriveSyncPartialFailure(t, err) + items := driveSyncStdoutItems(t, stdout.Bytes()) if len(items) == 0 || items[0].Direction != "push" || !strings.Contains(items[0].Error, "create parent failed") { - t.Fatalf("expected failed push item, got detail: %#v", exitErr.Detail.Detail) + t.Fatalf("expected failed push item, got detail: %#v", stdout.String()) } } @@ -1810,14 +1807,10 @@ func TestDriveSyncReportsNewLocalUploadFailure(t *testing.T) { if err == nil { t.Fatalf("expected upload failure\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured ExitError, got: %v", err) - } - detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) - items, _ := detailMap["items"].([]driveSyncItem) + assertDriveSyncPartialFailure(t, err) + items := driveSyncStdoutItems(t, stdout.Bytes()) if len(items) == 0 || items[0].Direction != "push" || !strings.Contains(items[0].Error, "upload failed") { - t.Fatalf("expected failed upload item, got detail: %#v", exitErr.Detail.Detail) + t.Fatalf("expected failed upload item, got detail: %#v", stdout.String()) } } @@ -1875,14 +1868,10 @@ func TestDriveSyncLocalWinsReportsUploadFailure(t *testing.T) { if err == nil { t.Fatalf("expected local-wins upload failure\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured ExitError, got: %v", err) - } - detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) - items, _ := detailMap["items"].([]driveSyncItem) + assertDriveSyncPartialFailure(t, err) + items := driveSyncStdoutItems(t, stdout.Bytes()) if len(items) == 0 || items[0].Direction != "push" || !strings.Contains(items[0].Error, "overwrite failed") { - t.Fatalf("expected failed overwrite item, got detail: %#v", exitErr.Detail.Detail) + t.Fatalf("expected failed overwrite item, got detail: %#v", stdout.String()) } } @@ -1965,30 +1954,13 @@ func TestDriveSyncKeepBothReportsRenameFailure(t *testing.T) { if err == nil { t.Fatalf("expected keep-both suffix exhaustion error\nstdout: %s", stdout.String()) } - // The error may be a plain ExitError (no Detail.Detail) or a - // partial_failure with items. Either way it must mention the - // suffix exhaustion. - errMsg := err.Error() - // The suffix exhaustion message may be in the top-level error or - // inside a partial_failure detail item. Check both. - foundSuffixError := strings.Contains(errMsg, "could not generate a unique rel_path") - if !foundSuffixError { - var exitErr *output.ExitError - if errors.As(err, &exitErr) && exitErr.Detail != nil { - detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) - items, _ := detailMap["items"].([]driveSyncItem) - for _, item := range items { - if strings.Contains(item.Error, "could not generate a unique rel_path") { - foundSuffixError = true - break - } - } - if !foundSuffixError { - t.Fatalf("expected suffix exhaustion error, got: %s; detail: %#v", errMsg, exitErr.Detail.Detail) - } - } else { - t.Fatalf("expected suffix exhaustion error, got: %s", errMsg) - } + // The suffix-exhaustion failure is an item-level conflict failure, so + // it surfaces as the partial-failure signal: a typed PartialFailureError + // on the error channel and the ok:false items[] payload (carrying the + // suffix message) on stdout via OutPartialFailure. + assertDriveSyncPartialFailure(t, err) + if !strings.Contains(stdout.String(), "could not generate a unique rel_path") { + t.Fatalf("expected suffix exhaustion error in stdout items, got: %s", stdout.String()) } } @@ -2341,14 +2313,10 @@ func TestDriveSyncRemoteWinsReportsModifiedPullFailure(t *testing.T) { if err == nil { t.Fatalf("expected modified pull failure\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured ExitError, got: %v", err) - } - detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) - items, _ := detailMap["items"].([]driveSyncItem) + assertDriveSyncPartialFailure(t, err) + items := driveSyncStdoutItems(t, stdout.Bytes()) if len(items) == 0 || items[0].Direction != "pull" || !strings.Contains(items[0].Error, "save failed") { - t.Fatalf("expected failed modified pull item, got detail: %#v", exitErr.Detail.Detail) + t.Fatalf("expected failed modified pull item, got detail: %#v", stdout.String()) } } @@ -2411,14 +2379,10 @@ func TestDriveSyncKeepBothReportsRollbackFailureAfterPullError(t *testing.T) { if err == nil { t.Fatalf("expected keep-both rollback failure\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured ExitError, got: %v", err) - } - detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) - items, _ := detailMap["items"].([]driveSyncItem) + assertDriveSyncPartialFailure(t, err) + items := driveSyncStdoutItems(t, stdout.Bytes()) if len(items) == 0 || !strings.Contains(items[0].Error, "rollback failed") { - t.Fatalf("expected rollback failure in item error, got detail: %#v", exitErr.Detail.Detail) + t.Fatalf("expected rollback failure in item error, got detail: %#v", stdout.String()) } } @@ -2500,14 +2464,10 @@ func TestDriveSyncLocalWinsNestedFileReportsParentEnsureFailure(t *testing.T) { if err == nil { t.Fatalf("expected parent ensure failure\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured ExitError, got: %v", err) - } - detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) - items, _ := detailMap["items"].([]driveSyncItem) + assertDriveSyncPartialFailure(t, err) + items := driveSyncStdoutItems(t, stdout.Bytes()) if len(items) == 0 || !strings.Contains(items[0].Error, "create parent failed") { - t.Fatalf("expected failed item with create_folder error, got detail: %#v", exitErr.Detail.Detail) + t.Fatalf("expected failed item with create_folder error, got detail: %#v", stdout.String()) } } @@ -2704,7 +2664,7 @@ func TestDriveSyncKeepBothReportsSuffixError(t *testing.T) { // TestDriveSyncKeepBothRollbackSucceedsOnPullFailure verifies the full // keep-both rollback path: when the pull download fails after the local // file has been renamed, the rollback restores the original file and -// the error is reported as a partial_failure. +// the failure is reported via the partial-failure signal. func TestDriveSyncKeepBothRollbackSucceedsOnPullFailure(t *testing.T) { syncTestConfig := &core.CliConfig{ AppID: "drive-sync-keep-both-rollback-pull-fail", AppSecret: "test-secret", Brand: core.BrandFeishu, @@ -2762,14 +2722,10 @@ func TestDriveSyncKeepBothRollbackSucceedsOnPullFailure(t *testing.T) { if err == nil { t.Fatalf("expected keep-both pull failure with rollback\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured ExitError, got: %v", err) - } - detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) - items, _ := detailMap["items"].([]driveSyncItem) + assertDriveSyncPartialFailure(t, err) + items := driveSyncStdoutItems(t, stdout.Bytes()) if len(items) == 0 || !strings.Contains(items[0].Error, "save failed") { - t.Fatalf("expected save failure in item, got detail: %#v", exitErr.Detail.Detail) + t.Fatalf("expected save failure in item, got detail: %#v", stdout.String()) } // Rollback should have restored the original file. @@ -2978,14 +2934,10 @@ func TestDriveSyncLocalWinsUsesReturnedTokenOnUploadFailure(t *testing.T) { if err == nil { t.Fatalf("expected local-wins upload failure\nstdout: %s", stdout.String()) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected structured ExitError, got: %v", err) - } - detailMap, _ := exitErr.Detail.Detail.(map[string]interface{}) - items, _ := detailMap["items"].([]driveSyncItem) + assertDriveSyncPartialFailure(t, err) + items := driveSyncStdoutItems(t, stdout.Bytes()) if len(items) == 0 { - t.Fatalf("expected failed item, got detail: %#v", exitErr.Detail.Detail) + t.Fatalf("expected failed item, got detail: %#v", stdout.String()) } // The reported token should be the new one from the partial-success // response, not the stale existingToken ("tok_a"). @@ -3095,3 +3047,39 @@ func TestDriveSyncRejectsLocalDirVsRemoteFileTypeConflict(t *testing.T) { t.Fatalf("error should mention local directory, got: %v", err) } } + +// assertDriveSyncPartialFailure asserts that err is the typed partial-failure +// exit signal +sync returns on any item-level failure. The structured +// {detection, diff, summary, items, note} payload rides on stdout as an +// ok:false envelope via runtime.OutPartialFailure (in alignment with +// +push/+pull), so this helper only checks the exit-code signal; callers read +// the payload from stdout. +func assertDriveSyncPartialFailure(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("expected partial-failure exit signal, got nil") + } + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err) + } + if pfErr.Code != output.ExitAPI { + t.Errorf("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI) + } +} + +// driveSyncStdoutItems extracts the items[] payload from the stdout envelope +// written by runtime.Out. The per-item failure context that used to live in +// the partial_failure ExitError detail now rides on stdout. +func driveSyncStdoutItems(t *testing.T, stdout []byte) []driveSyncItem { + t.Helper() + var envelope struct { + Data struct { + Items []driveSyncItem `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(stdout, &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout)) + } + return envelope.Data.Items +} diff --git a/shortcuts/drive/drive_task_result.go b/shortcuts/drive/drive_task_result.go index d506e1b17..1bdb74cdf 100644 --- a/shortcuts/drive/drive_task_result.go +++ b/shortcuts/drive/drive_task_result.go @@ -9,8 +9,8 @@ import ( "fmt" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/credential" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -43,34 +43,34 @@ var DriveTaskResult = common.Shortcut{ "wiki_delete_node": true, } if !validScenarios[scenario] { - return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space, wiki_delete_node", scenario) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space, wiki_delete_node", scenario).WithParam("--scenario") } // Validate required params based on scenario switch scenario { case "import", "export": if runtime.Str("ticket") == "" { - return output.ErrValidation("--ticket is required for %s scenario", scenario) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--ticket is required for %s scenario", scenario).WithParam("--ticket") } if err := validate.ResourceName(runtime.Str("ticket"), "--ticket"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--ticket") } case "task_check", "wiki_move", "wiki_delete_space", "wiki_delete_node": if runtime.Str("task-id") == "" { - return output.ErrValidation("--task-id is required for %s scenario", scenario) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required for %s scenario", scenario).WithParam("--task-id") } if err := validate.ResourceName(runtime.Str("task-id"), "--task-id"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id") } } // For export scenario, file-token is required if scenario == "export" && runtime.Str("file-token") == "" { - return output.ErrValidation("--file-token is required for export scenario") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-token is required for export scenario").WithParam("--file-token") } if scenario == "export" { if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token") } } @@ -261,9 +261,10 @@ func requireDriveScopes(storedScopes string, required []string) error { return nil } - return output.ErrWithHint(output.ExitAuth, "missing_scope", - fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")), - fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " "))) + return errs.NewPermissionError(errs.SubtypeMissingScope, + "missing required scope(s): %s", strings.Join(missing, ", ")). + WithMissingScopes(missing...). + WithHint("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")) } func missingDriveScopes(storedScopes string, required []string) []string { @@ -408,10 +409,10 @@ func queryWikiMoveTask(runtime *common.RuntimeContext, taskID string) (map[strin func getWikiMoveTaskStatus(runtime *common.RuntimeContext, taskID string) (wikiMoveTaskQueryStatus, error) { if err := validate.ResourceName(taskID, "--task-id"); err != nil { - return wikiMoveTaskQueryStatus{}, output.ErrValidation("%s", err) + return wikiMoveTaskQueryStatus{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id") } - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "GET", fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)), map[string]interface{}{"task_type": "move"}, @@ -426,7 +427,7 @@ func getWikiMoveTaskStatus(runtime *common.RuntimeContext, taskID string) (wikiM func parseWikiMoveTaskQueryStatus(taskID string, task map[string]interface{}) (wikiMoveTaskQueryStatus, error) { if task == nil { - return wikiMoveTaskQueryStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task") + return wikiMoveTaskQueryStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task") } status := wikiMoveTaskQueryStatus{ @@ -490,10 +491,10 @@ func appendWikiMoveNodeFields(out, node map[string]interface{}) { // rather than the per-node array used by wiki move. func queryWikiDeleteSpaceTask(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) { if err := validate.ResourceName(taskID, "--task-id"); err != nil { - return nil, output.ErrValidation("%s", err) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id") } - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "GET", fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)), map[string]interface{}{"task_type": "delete_space"}, @@ -505,7 +506,7 @@ func queryWikiDeleteSpaceTask(runtime *common.RuntimeContext, taskID string) (ma task := common.GetMap(data, "task") if task == nil { - return nil, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task") + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task") } resolvedTaskID := common.GetString(task, "task_id") @@ -558,10 +559,10 @@ func queryWikiDeleteSpaceTask(runtime *common.RuntimeContext, taskID string) (ma // keep drive from depending on shortcuts/wiki. func queryWikiDeleteNodeTask(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) { if err := validate.ResourceName(taskID, "--task-id"); err != nil { - return nil, output.ErrValidation("%s", err) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id") } - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "GET", fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)), map[string]interface{}{"task_type": "delete_node"}, @@ -573,7 +574,7 @@ func queryWikiDeleteNodeTask(runtime *common.RuntimeContext, taskID string) (map task := common.GetMap(data, "task") if task == nil { - return nil, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task") + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task") } resolvedTaskID := common.GetString(task, "task_id") diff --git a/shortcuts/drive/drive_task_result_test.go b/shortcuts/drive/drive_task_result_test.go index 79e43d76d..b97fc0ef8 100644 --- a/shortcuts/drive/drive_task_result_test.go +++ b/shortcuts/drive/drive_task_result_test.go @@ -13,10 +13,12 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -86,6 +88,16 @@ func TestDriveTaskResultValidateErrorsByScenario(t *testing.T) { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) } + var vErr *errs.ValidationError + if !errors.As(err, &vErr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) + } + if vErr.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("Subtype = %q, want %q", vErr.Subtype, errs.SubtypeInvalidArgument) + } + if got := output.ExitCodeOf(err); got != output.ExitValidation { + t.Fatalf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation) + } }) } } @@ -428,6 +440,16 @@ func TestValidateDriveTaskResultScopesWikiScenariosRequireWikiScope(t *testing.T if err == nil || !strings.Contains(err.Error(), "missing required scope(s): wiki:space:read") { t.Fatalf("expected missing wiki scope error, got %v", err) } + var permErr *errs.PermissionError + if !errors.As(err, &permErr) { + t.Fatalf("expected *errs.PermissionError, got %T", err) + } + if permErr.Subtype != errs.SubtypeMissingScope { + t.Fatalf("Subtype = %q, want %q", permErr.Subtype, errs.SubtypeMissingScope) + } + if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != "wiki:space:read" { + t.Fatalf("MissingScopes = %v, want [wiki:space:read]", permErr.MissingScopes) + } }) t.Run(scenario+"/accepts wiki scope", func(t *testing.T) { t.Parallel() @@ -663,6 +685,19 @@ func TestParseWikiMoveTaskQueryStatusRejectsMissingTask(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "missing task") { t.Fatalf("expected missing task error, got %v", err) } + // A successful API call (code==0) that omits the `task` field is a + // malformed RESPONSE, not a user error: classify as internal / + // invalid_response (exit 5), not an API business error (exit 1). + var iErr *errs.InternalError + if !errors.As(err, &iErr) { + t.Fatalf("expected *errs.InternalError, got %T", err) + } + if iErr.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("Subtype = %q, want %q", iErr.Subtype, errs.SubtypeInvalidResponse) + } + if got := output.ExitCodeOf(err); got != output.ExitInternal { + t.Fatalf("exit code = %d, want ExitInternal (%d)", got, output.ExitInternal) + } } func TestWikiMoveTaskQueryStatusPrimarySurfacesFailureOverEarlierSuccess(t *testing.T) { diff --git a/shortcuts/drive/drive_upload.go b/shortcuts/drive/drive_upload.go index c2688208f..f74220bc5 100644 --- a/shortcuts/drive/drive_upload.go +++ b/shortcuts/drive/drive_upload.go @@ -5,7 +5,6 @@ package drive import ( "context" - "encoding/json" "errors" "fmt" "io" @@ -15,6 +14,7 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" @@ -151,7 +151,7 @@ var DriveUpload = common.Shortcut{ info, err := runtime.FileIO().Stat(spec.FilePath) if err != nil { - return common.WrapInputStatError(err) + return driveInputStatError(err) } fileSize := info.Size() @@ -194,13 +194,13 @@ var DriveUpload = common.Shortcut{ func validateDriveUploadSpec(runtime *common.RuntimeContext, spec driveUploadSpec) error { if driveUploadFlagExplicitlyEmpty(runtime, "file-token") { - return common.FlagErrorf("--file-token cannot be empty; omit --file-token for a new upload or pass an existing file token to overwrite") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-token cannot be empty; omit --file-token for a new upload or pass an existing file token to overwrite").WithParam("--file-token") } if driveUploadFlagExplicitlyEmpty(runtime, "folder-token") { - return common.FlagErrorf("--folder-token cannot be empty; omit --folder-token to upload into Drive root folder or pass a folder token") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token cannot be empty; omit --folder-token to upload into Drive root folder or pass a folder token").WithParam("--folder-token") } if driveUploadFlagExplicitlyEmpty(runtime, "wiki-token") { - return common.FlagErrorf("--wiki-token cannot be empty; omit --wiki-token to upload into Drive root folder or pass a wiki node token") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--wiki-token cannot be empty; omit --wiki-token to upload into Drive root folder or pass a wiki node token").WithParam("--wiki-token") } targets := 0 @@ -211,21 +211,21 @@ func validateDriveUploadSpec(runtime *common.RuntimeContext, spec driveUploadSpe targets++ } if targets > 1 { - return common.FlagErrorf("--folder-token and --wiki-token are mutually exclusive") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token and --wiki-token are mutually exclusive") } if spec.FolderToken != "" { if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token") } } if spec.WikiToken != "" { if err := validate.ResourceName(spec.WikiToken, "--wiki-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--wiki-token") } } if spec.FileToken != "" { if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token") } } return nil @@ -240,7 +240,7 @@ func driveUploadFlagExplicitlyEmpty(runtime *common.RuntimeContext, flagName str func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName string, target driveUploadTarget, fileSize int64, existingFileToken string) (driveUploadResult, error) { f, err := runtime.FileIO().Open(filePath) if err != nil { - return driveUploadResult{}, common.WrapInputStatError(err) + return driveUploadResult{}, driveInputStatError(err) } defer f.Close() @@ -265,23 +265,16 @@ func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, file if errors.As(err, &exitErr) { return driveUploadResult{}, err } - return driveUploadResult{}, output.ErrNetwork("upload failed: %v", err) + return driveUploadResult{}, wrapDriveNetworkErr(err, "upload failed: %v", err) } - var result map[string]interface{} - if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { - return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err) - } - - if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 { - msg, _ := result["msg"].(string) - return driveUploadResult{}, output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"]) + data, err := runtime.ClassifyAPIResponse(apiResp) + if err != nil { + return driveUploadResult{}, err } - - data, _ := result["data"].(map[string]interface{}) fileToken := common.GetString(data, "file_token") if fileToken == "" { - return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned") + return driveUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload failed: no file_token returned") } return driveUploadResult{ FileToken: fileToken, @@ -304,7 +297,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file if existingFileToken != "" { prepareBody["file_token"] = existingFileToken } - prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody) + prepareResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody) if err != nil { return driveUploadResult{}, err } @@ -316,7 +309,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file blockNum := int(blockNumF) if uploadID == "" || blockSize <= 0 || blockNum <= 0 { - return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", + return driveUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d", uploadID, blockSize, blockNum) } @@ -334,7 +327,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file partFile, err := runtime.FileIO().Open(filePath) if err != nil { - return driveUploadResult{}, common.WrapInputStatError(err) + return driveUploadResult{}, driveInputStatError(err) } fd := larkcore.NewFormdata() @@ -354,16 +347,11 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file if errors.As(err, &exitErr) { return driveUploadResult{}, err } - return driveUploadResult{}, output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, err) + return driveUploadResult{}, wrapDriveNetworkErr(err, "upload part %d/%d failed: %v", seq+1, blockNum, err) } - var partResult map[string]interface{} - if err := json.Unmarshal(apiResp.RawBody, &partResult); err != nil { - return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload part %d/%d: invalid response JSON: %v", seq+1, blockNum, err) - } - if larkCode := int(common.GetFloat(partResult, "code")); larkCode != 0 { - msg, _ := partResult["msg"].(string) - return driveUploadResult{}, output.ErrAPI(larkCode, fmt.Sprintf("upload part %d/%d failed: [%d] %s", seq+1, blockNum, larkCode, msg), partResult["error"]) + if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil { + return driveUploadResult{}, err } fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, blockNum, common.FormatSize(partSize)) @@ -374,14 +362,14 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file "upload_id": uploadID, "block_num": blockNum, } - finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, finishBody) + finishResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_finish", nil, finishBody) if err != nil { return driveUploadResult{}, err } fileToken := common.GetString(finishResult, "file_token") if fileToken == "" { - return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned") + return driveUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload_finish succeeded but no file_token returned") } return driveUploadResult{ diff --git a/shortcuts/drive/drive_version.go b/shortcuts/drive/drive_version.go index bcf3fcf0c..e560dde49 100644 --- a/shortcuts/drive/drive_version.go +++ b/shortcuts/drive/drive_version.go @@ -16,8 +16,8 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/util" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" @@ -34,10 +34,10 @@ type driveVersionHistorySpec struct { func validateDriveNumericValue(value, flagName, valueLabel string) error { value = strings.TrimSpace(value) if value == "" { - return output.ErrValidation("%s cannot be empty", flagName) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s cannot be empty", flagName).WithParam(flagName) } if !driveVersionNumberRe.MatchString(value) { - return output.ErrValidation("%s must be a numeric %s", flagName, valueLabel) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s must be a numeric %s", flagName, valueLabel).WithParam(flagName) } return nil } @@ -52,10 +52,10 @@ func validateDriveCursorValue(value, flagName string) error { func validateDriveVersionHistorySpec(spec driveVersionHistorySpec) error { if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token") } if spec.Limit < 1 || spec.Limit > 200 { - return output.ErrValidation("invalid --limit %d: must be between 1 and 200", spec.Limit) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --limit %d: must be between 1 and 200", spec.Limit).WithParam("--limit") } if spec.Cursor != "" { if err := validateDriveCursorValue(spec.Cursor, "--cursor"); err != nil { @@ -180,7 +180,7 @@ var DriveVersionHistory = common.Shortcut{ Cursor: strings.TrimSpace(runtime.Str("cursor")), } - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( http.MethodGet, fmt.Sprintf("/open-apis/drive/v1/files/%s/history", validate.EncodePathSegment(spec.FileToken)), driveVersionHistoryParams(spec), @@ -214,7 +214,7 @@ type driveVersionGetSpec struct { func validateDriveVersionGetSpec(runtime *common.RuntimeContext, spec driveVersionGetSpec) error { if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token") } if err := validateDriveVersionValue(spec.Version, "--version"); err != nil { return err @@ -223,7 +223,7 @@ func validateDriveVersionGetSpec(runtime *common.RuntimeContext, spec driveVersi return nil } if _, err := validate.SafeOutputPath(spec.Output); err != nil { - return output.ErrValidation("unsafe output path: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output") } return nil } @@ -299,7 +299,7 @@ var DriveVersionGet = common.Shortcut{ }, }) if err != nil { - return output.ErrNetwork("download failed: %s", err) + return wrapDriveNetworkErr(err, "download failed: %s", err) } defer resp.Body.Close() @@ -315,10 +315,10 @@ var DriveVersionGet = common.Shortcut{ outputPath, _ = common.AutoAppendDownloadExtension(outputPath, resp.Header, "") } if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil { - return output.ErrValidation("unsafe output path: %s", resolveErr) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", resolveErr).WithParam("--output") } if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !spec.Overwrite { - return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --overwrite to replace)", outputPath).WithParam("--output") } result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{ @@ -326,7 +326,7 @@ var DriveVersionGet = common.Shortcut{ ContentLength: resp.ContentLength, }, resp.Body) if err != nil { - return common.WrapSaveErrorByCategory(err, "io") + return driveSaveError(err) } savedPath, _ := runtime.ResolveSavePath(outputPath) @@ -354,7 +354,7 @@ type driveVersionMutationSpec struct { func validateDriveVersionMutationSpec(spec driveVersionMutationSpec) error { if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token") } return validateDriveVersionValue(spec.Version, "--version") } @@ -392,7 +392,7 @@ var DriveVersionRevert = common.Shortcut{ FileToken: strings.TrimSpace(runtime.Str("file-token")), Version: strings.TrimSpace(runtime.Str("version")), } - if _, err := runtime.CallAPI( + if _, err := runtime.CallAPITyped( http.MethodPost, fmt.Sprintf("/open-apis/drive/v1/files/%s/revert", validate.EncodePathSegment(spec.FileToken)), nil, @@ -439,7 +439,7 @@ var DriveVersionDelete = common.Shortcut{ FileToken: strings.TrimSpace(runtime.Str("file-token")), Version: strings.TrimSpace(runtime.Str("version")), } - if _, err := runtime.CallAPI( + if _, err := runtime.CallAPITyped( http.MethodPost, fmt.Sprintf("/open-apis/drive/v1/files/%s/version_del", validate.EncodePathSegment(spec.FileToken)), nil, diff --git a/shortcuts/drive/drive_version_test.go b/shortcuts/drive/drive_version_test.go index 309243ad4..34d595766 100644 --- a/shortcuts/drive/drive_version_test.go +++ b/shortcuts/drive/drive_version_test.go @@ -5,14 +5,17 @@ package drive import ( "encoding/json" + "errors" "net/http" "os" "path/filepath" "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -53,6 +56,16 @@ func TestValidateDriveVersionHistorySpec(t *testing.T) { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) } + var vErr *errs.ValidationError + if !errors.As(err, &vErr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) + } + if vErr.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("Subtype = %q, want %q", vErr.Subtype, errs.SubtypeInvalidArgument) + } + if got := output.ExitCodeOf(err); got != output.ExitValidation { + t.Fatalf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation) + } }) } } @@ -255,6 +268,13 @@ func TestDriveVersionGetRejectsExistingFileWithoutOverwrite(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "output file already exists") { t.Fatalf("expected output exists error, got %v", err) } + var vErr *errs.ValidationError + if !errors.As(err, &vErr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) + } + if vErr.Subtype != errs.SubtypeInvalidArgument || vErr.Param != "--output" { + t.Fatalf("typed shape = subtype %q param %q, want invalid_argument/--output", vErr.Subtype, vErr.Param) + } } func TestDriveVersionGetOverwritesExistingFileWhenRequested(t *testing.T) { diff --git a/shortcuts/drive/list_remote.go b/shortcuts/drive/list_remote.go index 5f773b02c..2eee7c62c 100644 --- a/shortcuts/drive/list_remote.go +++ b/shortcuts/drive/list_remote.go @@ -11,9 +11,10 @@ import ( "path" "sort" "strconv" + "strings" "time" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -85,7 +86,7 @@ func listRemoteFolderEntries(ctx context.Context, runtime *common.RuntimeContext if pageToken != "" { params["page_token"] = pageToken } - result, err := runtime.CallAPI("GET", "/open-apis/drive/v1/files", params, nil) + result, err := runtime.CallAPITyped("GET", "/open-apis/drive/v1/files", params, nil) if err != nil { return nil, err } @@ -176,24 +177,27 @@ func duplicateRemoteFilePaths(entries []driveRemoteEntry) []driveDuplicateRemote return duplicates } -// Deprecated: duplicateRemotePathError produces a legacy *output.ExitError -// that predates the typed error contract introduced by errs/. New code MUST -// NOT use it — duplicate-path signals should move to a typed -// *errs.ValidationError (with duplicates metadata as a typed extension -// field) when the drive shortcut migrates to typed errors. This helper is -// retained only while existing call sites are migrated; it will be removed -// once they have moved to the typed surface. -func duplicateRemotePathError(duplicates []driveDuplicateRemotePath) *output.ExitError { - return &output.ExitError{ - Code: output.ExitAPI, - Detail: &output.ErrDetail{ - Type: "duplicate_remote_path", - Message: "multiple Drive entries map to the same rel_path", - Detail: map[string]interface{}{ - "duplicates_remote": duplicates, - }, - }, +// duplicateRemotePathError reports that multiple Drive entries resolve to the +// same rel_path. Each colliding rel_path becomes one InvalidParam whose Name is +// the rel_path and whose Reason enumerates the colliding entries (type + +// file_token), so an AI agent reading the typed envelope can identify exactly +// which Drive objects collide without re-listing the folder. +func duplicateRemotePathError(duplicates []driveDuplicateRemotePath) error { + params := make([]errs.InvalidParam, 0, len(duplicates)) + for _, d := range duplicates { + descriptions := make([]string, 0, len(d.Entries)) + for _, entry := range d.Entries { + descriptions = append(descriptions, fmt.Sprintf("%s %s", entry.Type, entry.FileToken)) + } + params = append(params, errs.InvalidParam{ + Name: d.RelPath, + Reason: fmt.Sprintf("%d Drive entries collide here: %s", len(d.Entries), strings.Join(descriptions, ", ")), + }) } + return errs.NewValidationError(errs.SubtypeFailedPrecondition, + "%d rel_path(s) map to multiple Drive entries", len(duplicates)). + WithHint("resolve the duplicate remote files first: re-run +pull with --on-duplicate-remote=rename (downloads each with a hashed suffix), or use --on-duplicate-remote=newest|oldest (supported by +pull/+sync/+push) to pick one, or delete the extra remote files; a plain retry will not help"). + WithParams(params...) } const ( @@ -300,7 +304,7 @@ func compareDriveRemoteModifiedToLocal(remoteModified string, local time.Time) ( func chooseRemoteFile(files []driveRemoteEntry, strategy string) (driveRemoteEntry, error) { if len(files) == 0 { - return driveRemoteEntry{}, fmt.Errorf("no Drive entries available for strategy %q", strategy) + return driveRemoteEntry{}, errs.NewInternalError(errs.SubtypeUnknown, "no Drive entries available for strategy %q", strategy) } candidates := append([]driveRemoteEntry(nil), files...) sortRemoteFiles(candidates, strategy) @@ -385,7 +389,7 @@ func relPathWithUniqueFileTokenSuffix(relPath, fileToken string, occupied map[st return candidate, nil } } - return "", fmt.Errorf("could not generate a unique rel_path for %q after %d attempts", relPath, driveUniqueSuffixMaxSeq) + return "", errs.NewInternalError(errs.SubtypeUnknown, "could not generate a unique rel_path for %q after %d attempts", relPath, driveUniqueSuffixMaxSeq) } // joinRelDrive joins a rel_path base with an entry name using "/". diff --git a/tests/cli_e2e/drive/drive_duplicate_sync_workflow_test.go b/tests/cli_e2e/drive/drive_duplicate_sync_workflow_test.go index 9946a99a1..531044941 100644 --- a/tests/cli_e2e/drive/drive_duplicate_sync_workflow_test.go +++ b/tests/cli_e2e/drive/drive_duplicate_sync_workflow_test.go @@ -74,8 +74,9 @@ func TestDrive_DuplicateRemoteWorkflow(t *testing.T) { if statusResult.ExitCode == 0 { t.Fatalf("+status should fail on duplicate remote rel_path\nstdout:\n%s\nstderr:\n%s", statusResult.Stdout, statusResult.Stderr) } - if !strings.Contains(statusResult.Stderr, `"type": "duplicate_remote_path"`) { - t.Fatalf("+status stderr should contain duplicate_remote_path\nstdout:\n%s\nstderr:\n%s", statusResult.Stdout, statusResult.Stderr) + if !strings.Contains(statusResult.Stderr, `"type": "validation"`) || + !strings.Contains(statusResult.Stderr, "map to multiple Drive entries") { + t.Fatalf("+status stderr should be a typed validation error for duplicate rel_path\nstdout:\n%s\nstderr:\n%s", statusResult.Stdout, statusResult.Stderr) } pullFailResult, err := clie2e.RunCmd(ctx, clie2e.Request{ @@ -91,8 +92,9 @@ func TestDrive_DuplicateRemoteWorkflow(t *testing.T) { if pullFailResult.ExitCode == 0 { t.Fatalf("+pull should fail on duplicate remote rel_path by default\nstdout:\n%s\nstderr:\n%s", pullFailResult.Stdout, pullFailResult.Stderr) } - if !strings.Contains(pullFailResult.Stderr, `"type": "duplicate_remote_path"`) { - t.Fatalf("+pull stderr should contain duplicate_remote_path\nstdout:\n%s\nstderr:\n%s", pullFailResult.Stdout, pullFailResult.Stderr) + if !strings.Contains(pullFailResult.Stderr, `"type": "validation"`) || + !strings.Contains(pullFailResult.Stderr, "map to multiple Drive entries") { + t.Fatalf("+pull stderr should be a typed validation error for duplicate rel_path\nstdout:\n%s\nstderr:\n%s", pullFailResult.Stdout, pullFailResult.Stderr) } if _, statErr := os.Stat(filepath.Join(workDir, "local", "dup.txt")); !os.IsNotExist(statErr) { t.Fatalf("default duplicate failure must not write dup.txt; stat err=%v", statErr)