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:
LinYushen
2026-06-09 13:15:51 +08:00
committed by GitHub
parent 8ff68502fc
commit 9ff801f926
4 changed files with 185 additions and 4 deletions

View File

@@ -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
```

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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")
}
})
}