Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ The project publishes 0.x prerelease versions; a stable release line is not yet

## [Unreleased]

### Added

- `mem doctor` — a read-only diagnosis of why the CLI cannot talk to a working
server (`#112`). It reports four checks in a fixed order: reachability of the
configured server URL, whether a credential exists, the workspace the server
resolved for that credential, and CLI/server version skew. Each finding carries
the SPEC §7.1 exit code it contributes (`0` ok · `2` not_found · `3` auth ·
`4` plan/quota · `5` provider/timeout), and a check that an earlier failure made
impossible is reported as `skipped` instead of guessed. It issues only `GET`
requests and never writes configuration, starts a container, or installs a
dependency; `--format json` emits the `mem.doctor` v1 document described by
`docs/schemas/mem-doctor.v1.schema.json`. A token is described only by where it
came from, and a configured URL has its userinfo and its query parameter values
replaced by `REDACTED` — a credential in a query parameter is the shape pgx
accepts as a real password — or, when the URL cannot be proven to be a
credential-free transport URL, is withheld whole. See `docs/DEPLOYMENT.md`.
- First-run guidance: a command that fails because no credential exists now says
so on a machine with no configuration at all by naming the documented
deployment path (`deploy/compose`, `docs/DEPLOYMENT.md`), instead of telling
somebody to log in against a server that is not running yet. Hosts that already
have a configuration keep the previous, shorter hint.

### Changed

- Migrate GitHub repository, Release, issue, badge, and raw-content coordinates
Expand Down Expand Up @@ -53,6 +75,22 @@ The project publishes 0.x prerelease versions; a stable release line is not yet

### Fixed

- A configured URL that carries credentials in a shape `url.Parse` does not
report as userinfo no longer reaches output. `admin:pw@host` parses as
`Scheme="admin"` with the credential in `Opaque` and `User` unset, so an
implementation that gates on `User != nil` echoes it verbatim. On this base it
leaked from the CLI API client — at request construction and at all four
`http.Client.Do` sites, which the previous error path did not cover — and from
`memd`'s startup log line and its fatal log line, the last of which additionally
carries third-party errors that embed a whole DSN. Both now route through one
shared gate that redacts a value it can prove is a transport URL and
**withholds the value whole** otherwise. It does not scrub credentials out of
error text, which cannot be made tight: `url.Error` renders with `%q`, so a
quote inside a password arrives escaped and a scanner that pairs quotes
mis-pairs and replaces nothing. Withholding costs some diagnosability by design;
why a request failed is still reported, and a DSN still names the parameters it
sets — every query parameter *value* is replaced by `REDACTED`, including
`?password=`, which pgx honours as the real password.
- The npm installer no longer aborts a concurrent first run on Windows. The
per-asset cache lock previously treated only `EEXIST` as contention, but a
contended `mkdir` on Windows may raise `EPERM` or `EACCES`, so a process
Expand Down
36 changes: 36 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,42 @@ docker compose --env-file .env -f compose.yaml ps
docker compose --env-file .env -f compose.yaml logs --tail=200 migrate memd worker web
```

### Diagnose from the client side

`mem doctor` answers the client half of the same question: why the CLI cannot
reach a working server. It issues only `GET` requests, and it never writes
configuration, starts or stops a container, or installs a dependency — a failed
diagnosis changes nothing on the machine.

```bash
mem doctor
mem doctor --format json
```

It reports four checks in a fixed order and stops guessing after the first
failure: reachability of the configured server URL (`/healthz`, probed without a
credential so a bad token is not misread as an outage), whether a credential
exists, the workspace the server resolved for that credential
(`/v1/capabilities`), and CLI/server version skew (`/v1/version`). A check that
an earlier failure made impossible is reported as `skipped`, naming the blocking
check, rather than as an inferred pass.

The process exits with the first failing check's SPEC §7.1 code — `0` ok ·
`2` not_found · `3` auth · `4` plan/quota · `5` provider/timeout — so a wrapper
can branch on it. Version skew is advisory and contributes `0`; it is also not
computable in builds that do not inject a CLI version, which today includes
release builds, so the check reports that limit instead of claiming agreement.

`--format json` emits the `mem.doctor` v1 document validated by
[`schemas/mem-doctor.v1.schema.json`](schemas/mem-doctor.v1.schema.json), and a
token is described only by where it came from. For a configured URL, userinfo and
every query parameter **value** are replaced by `REDACTED` — the parameter names
survive so the report still says which settings are on — and a URL that cannot be
proven to be a credential-free transport URL is withheld whole as `[withheld]`
rather than partially trimmed. A secret supplied as a query parameter
(`http://mem.internal:8787?password=…`) is therefore not reported, which matters
because pgx accepts `postgres://host/db?password=…` as the real password.

### First account and login

The default `MEM_REGISTRATION_MODE=first_user` atomically allows exactly one
Expand Down
84 changes: 84 additions & 0 deletions docs/schemas/mem-doctor.v1.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://getmem.dev/schemas/mem-doctor.v1.schema.json",
"title": "mem doctor report v1",
"description": "Read-only diagnosis emitted by `mem doctor --format json`. Mirrors doctorReport and doctorCheck in server/cmd/mem/cmds_doctor.go. Contains no credential, token or DSN value: the server URL is reported with userinfo removed.",
"type": "object",
"additionalProperties": false,
"required": [
"contract",
"schema_version",
"server",
"cli_version",
"exit_code",
"checks"
],
"properties": {
"contract": {
"const": "mem.doctor"
},
"schema_version": {
"const": 1
},
"server": {
"type": "string",
"description": "Configured memd base URL, userinfo redacted.",
"minLength": 1
},
"cli_version": {
"type": "string",
"description": "Version this CLI build reports; \"dev\" when none was injected at build time."
},
"server_version": {
"type": "string",
"description": "Version the server reported. Absent when the version probe did not run."
},
"exit_code": {
"type": "integer",
"description": "SPEC 7.1 process exit code: first failing check's code, else 0.",
"enum": [0, 2, 3, 4, 5]
},
"checks": {
"type": "array",
"minItems": 4,
"maxItems": 4,
"description": "Fixed ordered list, never a wizard: server_reachability, credential, workspace, version_skew.",
"prefixItems": [
{ "allOf": [ { "$ref": "#/$defs/check" }, { "properties": { "name": { "const": "server_reachability" } } } ] },
{ "allOf": [ { "$ref": "#/$defs/check" }, { "properties": { "name": { "const": "credential" } } } ] },
{ "allOf": [ { "$ref": "#/$defs/check" }, { "properties": { "name": { "const": "workspace" } } } ] },
{ "allOf": [ { "$ref": "#/$defs/check" }, { "properties": { "name": { "const": "version_skew" } } } ] }
],
"items": { "$ref": "#/$defs/check" }
}
},
"$defs": {
"check": {
"type": "object",
"additionalProperties": false,
"required": ["name", "status", "exit_code", "detail"],
"properties": {
"name": {
"enum": ["server_reachability", "credential", "workspace", "version_skew"]
},
"status": {
"enum": ["ok", "warn", "fail", "skipped"],
"description": "\"skipped\" means an earlier failure made this check unrunnable; it is never an inferred pass."
},
"exit_code": {
"type": "integer",
"description": "This finding's contribution to the process exit code. Advisory and skipped findings contribute 0.",
"enum": [0, 2, 3, 4, 5]
},
"detail": {
"type": "string",
"minLength": 1
},
"hint": {
"type": "string",
"description": "Actionable next step. First-run hints name the documented container path."
}
}
}
}
}
19 changes: 19 additions & 0 deletions server/cmd/mem/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,25 @@ func newCliError(code int, msg, hint string) *cliError {
return &cliError{code: code, msg: msg, hint: hint}
}

// notLoggedInHint is the credential guidance that always applies.
const notLoggedInHint = "run `mem auth login` first"

// firstRunDeployHint names the documented deployment path rather than a
// host-specific install recipe, so a machine that has never been configured is
// not sent off to build the bare-metal stack by hand.
const firstRunDeployHint = "no server configured yet — the documented path is deploy/compose, see docs/DEPLOYMENT.md"

// errNotLoggedIn is the one fail-closed auth error for commands that need a
// credential. When no config file exists at all, the run is a first run: the
// hint additionally names the documented deployment path, because telling
// somebody to log in against a server that does not exist yet is not guidance.
func errNotLoggedIn() error {
if configFileExists() {
return newCliError(3, "not logged in", notLoggedInHint)
}
return newCliError(3, "not logged in", notLoggedInHint+"; "+firstRunDeployHint)
}

// fromAPIError maps an *apiclient.APIError to a *cliError with the SPEC §7.1
// exit code. Any other error is returned unchanged.
func fromAPIError(err error) error {
Expand Down
4 changes: 2 additions & 2 deletions server/cmd/mem/cmds_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ func newAuthStatusCmd() *cobra.Command {
return err
}
if cfg.Token == "" {
return newCliError(3, "not logged in", "run `mem auth login` first")
return errNotLoggedIn()
}

var capabilities struct {
Expand Down Expand Up @@ -241,7 +241,7 @@ func newTokenCreateCmd() *cobra.Command {
return err
}
if cfg.Token == "" {
return newCliError(3, "not logged in", "run `mem auth login` first")
return errNotLoggedIn()
}
scopeList := splitCommas(scopes)
body := map[string]any{
Expand Down
13 changes: 11 additions & 2 deletions server/cmd/mem/cmds_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,17 @@ func TestAuthStatusWithoutTokenReturnsAuthExitCode(t *testing.T) {
if !errors.As(err, &cliErr) {
t.Fatalf("error type = %T, want *cliError", err)
}
if cliErr.code != 3 || cliErr.hint != "run `mem auth login` first" {
t.Fatalf("cli error = %#v", cliErr)
// #112 REQ-002 changed this hint's text for a host with no config file at
// all, so the old exact-equality assertion is intentionally widened: the
// login step must stay, and the documented deployment path must now appear.
if cliErr.code != 3 {
t.Fatalf("cli error code = %d, want 3 (%#v)", cliErr.code, cliErr)
}
if !strings.HasPrefix(cliErr.hint, "run `mem auth login` first") {
t.Errorf("hint = %q, want it to keep the login step", cliErr.hint)
}
if !strings.Contains(cliErr.hint, "deploy/compose") || !strings.Contains(cliErr.hint, "docs/DEPLOYMENT.md") {
t.Errorf("hint = %q, want first-run guidance naming the documented path", cliErr.hint)
}
}

Expand Down
2 changes: 1 addition & 1 deletion server/cmd/mem/cmds_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Examples:
return err
}
if cfg.Token == "" {
return newCliError(3, "not logged in", "run `mem auth login` first")
return errNotLoggedIn()
}
body := map[string]any{"query": strings.Join(args, " ")}
if scope != "" {
Expand Down
Loading
Loading