mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
docs(cli): error-message conventions + sign-in copy (PR3, MUL-3104) (#3900)
* docs(cli): add Error Messages conventions + refine sign-in copy (PR3) Final pass of the CLI error-message work (MUL-3104). - CLI_AND_DAEMON.md: new "Error Messages" section documenting the user-facing contract — friendly single-line messages, server validation passthrough, English default with automatic Chinese on a zh locale, the tiered exit codes (0/1/2/3/4/5), --debug / MULTICA_DEBUG for the full chain, and MULTICA_HTTP_TIMEOUT. - cmd_auth.go: clarify three high-frequency sign-in errors so the message states what failed and the next step — local login-callback server start (hints at port/firewall), access-token creation, and token verification (suggests retrying `multica login` and checking the token is valid/not expired). All keep %w so exit-code tiering and --debug detail are preserved. cmd_id_resolver.go is left as-is — its not-found / ambiguous-prefix messages already point at `list --full-id` and need no change. The user-facing FormatError layer is unchanged, so its existing PR1/PR2 test coverage still applies; no test asserted the old verb strings. Refs MUL-3104. PR3 of 3 (final). Co-authored-by: multica-agent <github@multica.ai> * fix(cli): make login failure guidance visible via typed user-message wrapper Addresses 张大彪's PR3 review: the refined sign-in copy was wrapped with %w, so FormatError returned the centralized *HTTPError/*NetworkError copy and the new guidance only appeared under --debug. - Add cli.UserMessageError + cli.WithUserMessage: a typed wrapper carrying a user-facing message that FormatError surfaces by default, recognized before the network/http branches. Unwrap() is preserved, so ExitCodeFor still classifies by the underlying typed error and --debug still prints the full original chain. - cmd_auth.go: wrap the OAuth access-token-creation and PAT-verification failures with WithUserMessage (OAuth copy no longer mentions a passed token, since that flow has none), and move the token-specific 'valid / not expired' hint to the real Enter your personal access token: verification site (was the generic 'invalid token: %w'). - Focused tests: under a wrapped *HTTPError(401) the default FormatError shows the login hint, ExitCodeFor returns ExitAuth, and --debug retains the raw chain; a wrapped *NetworkError still classifies as ExitNetwork. - CLI_AND_DAEMON.md: narrow 'every error' to command errors returned to the top-level handler, noting commands like setup's fast /health probe bypass it. Refs MUL-3104, PR #3900. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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: <server message>`); 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
|
||||
```
|
||||
|
||||
@@ -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 <token>` again.", err)
|
||||
}
|
||||
|
||||
profile := resolveProfile(cmd)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 <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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user