diff --git a/CLI_AND_DAEMON.md b/CLI_AND_DAEMON.md index f5f96f801..3c7fc9002 100644 --- a/CLI_AND_DAEMON.md +++ b/CLI_AND_DAEMON.md @@ -699,3 +699,79 @@ Most commands support `--output` with two formats: multica issue list --output json multica daemon status --output json ``` + +## Error Messages + +The CLI funnels command errors returned to the top-level handler through a +single user-facing translation layer (`server/internal/cli/errors.go`) so that +what you see on the terminal is a short, actionable sentence rather than a raw +Go error, an HTTP status line, or an internal `resolve issue: ...` chain. (A +few commands print their own output or run deliberate fast probes — for example +`setup`'s short `/health` reachability check — and don't go through this +layer.) The underlying detail is still available on demand (see `--debug`). + +### What you see + +- **Friendly, single-line message.** Transport failures (timeout, DNS, + connection refused, TLS) and HTTP status failures (401/403/404/409/400·422/ + 429/5xx) are each rendered as one clear sentence with a next step — for + example a timeout suggests checking the network or raising + `MULTICA_HTTP_TIMEOUT`, and a 401 tells you to run `multica login`. +- **Server-provided validation messages are preserved.** For a 400/422 that + carries a message from the server, that message is shown verbatim + (`Invalid request: `); only when there is none do you get the + generic "check your values / run with --help" hint. +- **No leaked internals by default.** Raw URLs, status lines, JSON bodies, and + the internal verb chain are hidden unless you ask for them. + +### Language + +Messages default to **English**, matching the rest of the CLI's help output. +If a Chinese locale is detected in `LC_ALL`, `LC_MESSAGES`, or `LANG` (in that +precedence order), messages switch to **Chinese**. No flag is needed; set the +locale as usual: + +```bash +LANG=zh_CN.UTF-8 multica issue get MUL-9999 # 错误信息显示为中文 +``` + +### Exit codes + +The process exit code is tiered so scripts can branch on the failure class: + +| Exit code | Meaning | +| --- | --- | +| `0` | success | +| `1` | generic / unclassified error | +| `2` | network error (timeout, DNS, connection refused, TLS, offline) | +| `3` | authentication / authorization (HTTP 401, 403) | +| `4` | not found (HTTP 404) | +| `5` | validation (HTTP 400, 422) | + +```bash +multica issue get MUL-9999 +if [ $? -eq 4 ]; then echo "no such issue"; fi +``` + +### Seeing the full detail (`--debug`) + +Pass the global `--debug` flag (or set `MULTICA_DEBUG=1`) to print the complete +original error chain — the internal verb chain, the request method/path/status, +and the raw server body — underneath the friendly message. Use it when you need +to file a bug or understand exactly what the server returned: + +```bash +multica issue list --debug +MULTICA_DEBUG=1 multica issue update MUL-1234 --title "x" +``` + +### Request timeout + +API requests use a default timeout of 30 seconds. Override it with +`MULTICA_HTTP_TIMEOUT` when you are on a slow network; it accepts a Go duration +(`45s`, `2m`) or a plain number of seconds (`45`). Command-level deadlines are +always at least this value, so raising it takes effect across all commands. + +```bash +MULTICA_HTTP_TIMEOUT=60s multica issue list +``` diff --git a/server/cmd/multica/cmd_auth.go b/server/cmd/multica/cmd_auth.go index 168193490..32cbf85ef 100644 --- a/server/cmd/multica/cmd_auth.go +++ b/server/cmd/multica/cmd_auth.go @@ -235,7 +235,7 @@ func runAuthLoginBrowser(cmd *cobra.Command) error { // so an IPv4 listener is what the browser actually needs. listener, err := net.Listen("tcp4", bindAddr+":0") if err != nil { - return fmt.Errorf("failed to start local server: %w", err) + return fmt.Errorf("could not start the local login callback server (used to receive the browser sign-in); a firewall or another process may be blocking local ports: %w", err) } defer listener.Close() @@ -318,7 +318,7 @@ func runAuthLoginBrowser(cmd *cobra.Command) error { "expires_in_days": expiresInDays, }, &patResp) if err != nil { - return fmt.Errorf("failed to create access token: %w", err) + return cli.WithUserMessage("Sign-in did not complete: the server could not issue an access token for the CLI. Run `multica login` again.", err) } // Verify the PAT works. @@ -328,7 +328,7 @@ func runAuthLoginBrowser(cmd *cobra.Command) error { Email string `json:"email"` } if err := patClient.GetJSON(ctx, "/api/me", &me); err != nil { - return fmt.Errorf("token verification failed: %w", err) + return cli.WithUserMessage("Sign-in did not complete: the server did not accept the new credential. Run `multica login` again.", err) } // Save to config. Reset workspace data on every login — the user or @@ -381,7 +381,7 @@ func runAuthLoginToken(cmd *cobra.Command, providedToken string) error { Email string `json:"email"` } if err := client.GetJSON(ctx, "/api/me", &me); err != nil { - return fmt.Errorf("invalid token: %w", err) + return cli.WithUserMessage("Could not sign in with that token — make sure it is valid and not expired, then run `multica login --token ` again.", err) } profile := resolveProfile(cmd) diff --git a/server/internal/cli/errors.go b/server/internal/cli/errors.go index 7851c9eaa..e095d7ec7 100644 --- a/server/internal/cli/errors.go +++ b/server/internal/cli/errors.go @@ -118,6 +118,39 @@ func (e *NetworkError) Error() string { func (e *NetworkError) Unwrap() error { return e.Err } +// UserMessageError attaches a command-specific, user-facing message to an +// underlying error. FormatError shows Msg verbatim (in preference to the +// generic kind-based copy it would otherwise derive from a wrapped +// *NetworkError / *HTTPError), so command-level guidance — e.g. a `multica +// login` failure that is more helpful than the generic 401/timeout line — is +// visible in the default (non-debug) output. +// +// It preserves Unwrap(), so ExitCodeFor still classifies by the underlying +// typed error and --debug still prints the full original chain. +type UserMessageError struct { + Msg string + Err error +} + +func (e *UserMessageError) Error() string { + if e.Err != nil { + return e.Msg + ": " + e.Err.Error() + } + return e.Msg +} + +func (e *UserMessageError) Unwrap() error { return e.Err } + +// WithUserMessage wraps err with a user-facing message that FormatError will +// surface by default. It returns nil when err is nil so it can be used inline +// in a `return` without an extra check. +func WithUserMessage(msg string, err error) error { + if err == nil { + return nil + } + return &UserMessageError{Msg: msg, Err: err} +} + // Kind maps an HTTPError's status code onto an ErrorKind. func (e *HTTPError) Kind() ErrorKind { switch e.StatusCode { @@ -339,6 +372,15 @@ func FormatError(err error, debug bool) string { // userMessage produces the friendly message for the root cause of err. func userMessage(err error, lang Language) string { + // A command-supplied user-facing message takes precedence over the generic + // kind-based copy, so command-specific guidance (e.g. sign-in failures) is + // visible by default. Unwrap() is preserved, so ExitCodeFor and --debug + // still see the underlying typed error. + var um *UserMessageError + if errors.As(err, &um) { + return um.Msg + } + // Transport-layer failure. var netErr *NetworkError if errors.As(err, &netErr) { diff --git a/server/internal/cli/errors_test.go b/server/internal/cli/errors_test.go index 85222b24a..9e7474d21 100644 --- a/server/internal/cli/errors_test.go +++ b/server/internal/cli/errors_test.go @@ -342,3 +342,66 @@ func TestFormatErrorActionableHints(t *testing.T) { } } } + +// TestUserMessageError proves the command-level user-facing wrapper: the +// custom message is shown by default (overriding the generic kind copy), +// ExitCodeFor still classifies by the underlying typed error, and --debug +// still exposes the full original chain. This is the mechanism that makes the +// `multica login` failure guidance visible without losing classification. +func TestUserMessageError(t *testing.T) { + withLang(t, "en_US.UTF-8") + const hint = "Could not sign in with that token — make sure it is valid and not expired, then run `multica login --token ` again." + + t.Run("wrapped HTTPError (invalid token -> 401)", func(t *testing.T) { + underlying := &HTTPError{Method: "GET", Path: "/api/me", StatusCode: 401, Body: `{"error":"unauthorized"}`} + err := WithUserMessage(hint, underlying) + + // Default output shows the command hint, not the generic 401 line. + got := FormatError(err, false) + if got != hint { + t.Errorf("FormatError(false) = %q, want the login hint", got) + } + if strings.Contains(got, "session has expired") { + t.Errorf("default output leaked the generic 401 copy: %q", got) + } + + // Exit code still classifies by the underlying *HTTPError (401 -> auth). + if code := ExitCodeFor(err); code != ExitAuth { + t.Errorf("ExitCodeFor = %d, want ExitAuth(%d)", code, ExitAuth) + } + + // --debug keeps the full original chain (verb + http detail). + dbg := FormatError(err, true) + if !strings.Contains(dbg, "[debug]") || !strings.Contains(dbg, "/api/me") || !strings.Contains(dbg, "401") { + t.Errorf("debug output lost the raw chain: %q", dbg) + } + + // errors.As still reaches the underlying typed error. + var he *HTTPError + if !errors.As(err, &he) || he.StatusCode != 401 { + t.Errorf("errors.As did not reach the underlying *HTTPError") + } + }) + + t.Run("wrapped NetworkError classifies as network", func(t *testing.T) { + underlying := &NetworkError{Kind: KindNetworkTimeout, Op: "GET /api/me", Err: errors.New("context deadline exceeded")} + err := WithUserMessage("Sign-in did not complete: the server did not accept the new credential. Run `multica login` again.", underlying) + + if code := ExitCodeFor(err); code != ExitNetwork { + t.Errorf("ExitCodeFor = %d, want ExitNetwork(%d)", code, ExitNetwork) + } + got := FormatError(err, false) + if !strings.Contains(got, "Sign-in did not complete") { + t.Errorf("FormatError(false) = %q, want the sign-in hint", got) + } + if strings.Contains(got, "timed out") { + t.Errorf("default output leaked the generic network copy: %q", got) + } + }) + + t.Run("nil error returns nil", func(t *testing.T) { + if WithUserMessage("x", nil) != nil { + t.Errorf("WithUserMessage(_, nil) should be nil") + } + }) +}