pi-checkup 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -77,9 +77,10 @@ Packages
77
77
  Binaries
78
78
  ✓ dangling links 19 links all resolve
79
79
  · shadowed names no package command resolved on PATH
80
- ! unreachable bins 6 package command(s) not on PATH: pi-web-access-mcp,
81
- pi-subagents, pi-mcp-adapter, pi-lens, pi-lens-mcp,
82
- pi-lens-analyze
80
+ · unreachable bins 6 package command(s) live in
81
+ /root/.pi/agent/npm/node_modules/.bin, which is not on
82
+ PATH: pi-web-access-mcp, pi-subagents, pi-mcp-adapter,
83
+ pi-lens, pi-lens-mcp, pi-lens-analyze
83
84
  Network
84
85
  ✓ providers 2 endpoint(s) reachable
85
86
  ✓ dns 1 provider host(s) resolve normally
@@ -94,20 +95,20 @@ Config
94
95
  Runtime
95
96
  ✓ node v22.23.3
96
97
  ✓ pi launcher /root/.pi/agent/bin/pi
97
- ✓ disk 6% used on /, 950.7 GB free
98
+ ✓ disk 6% used on /, 950.4 GB free
98
99
  ✓ proxy http://127.0.0.1:10808 accepting connections
99
100
 
100
- 19 checks: 14 ok, 1 warning, 0 failed, 4 skipped
101
+ 19 checks: 14 ok, 0 warnings, 0 failed, 5 skipped
101
102
  Run `npx pi-checkup --verbose` to show what to do about each finding.
102
103
 
103
- ! 1 warning, 4 skipped · run /checkup inside Pi for registry checks
104
+ ✓ 14 ok, 5 skipped · run /checkup inside Pi for registry checks
104
105
  ```
105
106
 
106
107
  Both captures below and above come from a real machine, taken with pi-checkup 0.1.0; free space, Node versions and which packages are installed are properties of that machine, not of the tool, and the row counts grow when checks are added.
107
108
 
108
109
  ## Real output
109
110
 
110
- This is an actual run on a working install, copied out of the TUI at 80 columns — not a mock-up. The two `Network` rows describe the moment it ran; everything else is a property of the install. 20 of 24 checks pass, three are skipped because there is genuinely nothing to look at, and the one warning is the expected state on this machine — the package commands are installed but their directory is not on `PATH`, which the hint explains and `PI_CHECKUP_IGNORE=unreachable` silences.
111
+ This is an actual run on a working install, copied out of the TUI at 80 columns — not a mock-up. The two `Network` rows describe the moment it ran; everything else is a property of the install. 20 of 24 checks pass and the other four are skipped because there is genuinely nothing to look at: no live registry to audit, no project-level settings, no `$VAR` credentials, and no package command on `PATH` to compare. Nothing warns, which is the point — the package commands live in a `.bin` directory Pi never adds to `PATH`, that is Pi's own layout rather than a fault, and the row says so instead of putting a permanent amber mark on a healthy install.
111
112
 
112
113
  ```text
113
114
  Registry
@@ -126,9 +127,10 @@ Packages
126
127
  Binaries
127
128
  ✓ dangling links 19 links all resolve
128
129
  · shadowed names no package command resolved on PATH
129
- ! unreachable bins 6 package command(s) not on PATH: pi-web-access-mcp,
130
- pi-subagents, pi-mcp-adapter, pi-lens, pi-lens-mcp,
131
- pi-lens-analyze
130
+ · unreachable bins 6 package command(s) live in
131
+ /root/.pi/agent/npm/node_modules/.bin, which is not on
132
+ PATH: pi-web-access-mcp, pi-subagents, pi-mcp-adapter,
133
+ pi-lens, pi-lens-mcp, pi-lens-analyze
132
134
  Network
133
135
  ✓ providers 2 endpoint(s) reachable
134
136
  ✓ dns 1 provider host(s) resolve normally
@@ -143,10 +145,10 @@ Config
143
145
  Runtime
144
146
  ✓ node v22.23.3
145
147
  ✓ pi launcher /root/.pi/agent/bin/pi
146
- ✓ disk 6% used on /, 950.7 GB free
148
+ ✓ disk 6% used on /, 950.4 GB free
147
149
  ✓ proxy http://127.0.0.1:10808 accepting connections
148
150
 
149
- 24 checks: 20 ok, 1 warning, 0 failed, 3 skipped
151
+ 24 checks: 20 ok, 0 warnings, 0 failed, 4 skipped
150
152
  Run `/checkup verbose` to show what to do about each finding.
151
153
  ```
152
154
 
@@ -164,7 +166,7 @@ The `attribution` row is the part no other tool gives you: which package owns wh
164
166
  | **Packages** | Package list | Whether `settings.json` could be read at all — a hand-edited file that does not parse is reported as unreadable, not as "no packages" |
165
167
  | | Missing directories | In `settings.json` but gone from `node_modules`. A version pin (`npm:name@1.2.3`) is resolved to the directory it installs into, not taken as part of the name |
166
168
  | | Version pins | `settings.json` pins an exact version and a different one is installed — the code that runs is not the code that was asked for. Ranges and tags are not compared, because the installer is free to satisfy them |
167
- | | Corrupt manifests | A `package.json` that parses as nothing — reported as a failure, because the package cannot load |
169
+ | | Corrupt manifests | A `package.json` that does not parse, or that parses to something other than an object (`null`, `"nope"`, `[]`) — reported as a failure, because the package cannot load. An empty object still counts as readable |
168
170
  | | Missing manifests | The package directory exists but holds no `package.json` — an interrupted install, or a leftover folder. Its own row, because telling someone to check ownership of a directory that has no manifest sends them to fix the wrong thing |
169
171
  | | Unreadable manifests | A `package.json` this process could not read (permissions, or larger than the 4 MB limit). Reported as a warning: unproven, not broken |
170
172
  | | Missing entry files | The manifest points at a build output that was never shipped |
@@ -179,7 +181,7 @@ The `attribution` row is the part no other tool gives you: which package owns wh
179
181
  | | Install leftovers | `.<name>-<random>` staging links in `.bin`: an install is running right now, or was interrupted. Its own finding, not a broken package |
180
182
  | | Missing shims | The package declares a `bin` but npm never linked it into `.bin` — usually an install run with `--ignore-scripts` |
181
183
  | | Shadowed names | A package's command name resolves to an unrelated program earlier on `PATH`. Skipped when no declared command resolved at all: there is nothing to compare, and a green tick there would sit next to a row saying the commands are missing |
182
- | | Unreachable bins | A package ships a CLI you cannot invoke by name |
184
+ | | Unreachable bins | A package ships a CLI that is not reachable by name. A `skip` when `.bin` itself is not on `PATH` — that is Pi's own layout and Pi calls these by full path — and a warning only when the directory *is* on `PATH` and the command still did not resolve, which points at PATH order or an unreadable link |
183
185
  | **Network** | Endpoint reachability | Probes `<baseUrl>/models` for every provider. `401`/`403` counts as alive — this never validates your key and never spends tokens. An endpoint that answers with something other than life — a refused connection, a rejected certificate, an HTTP error — is a failure, and fails the run; only a probe that was cut off locally by its own timeout stays a warning, because the deadline is this process's, not the endpoint's |
184
186
  | | Cleartext auth | A provider on `http://` to another machine with a credential configured — an `apiKey`, a password embedded in the `baseUrl`, a credential-named header value, or a query string, which cannot be inspected for secrets. The `apiKey` and `baseUrl` credentials are withheld from the probe; a query string is never sent, so that endpoint is reported unprobed. Loopback hosts are exempt |
185
187
  | | baseUrl shape | A `baseUrl` that is not a string or an object. A string that is not a URL — including the `localhost:11434/v1` paste with no scheme — is reported as an unhealthy provider, and also named in this row, because the endpoint is what would have to be probed |
@@ -197,7 +199,7 @@ The groups above are the ones that appear in a report, in that order. Independen
197
199
 
198
200
  Only reversible repairs are automated, and inside a session it normally offers only the first:
199
201
 
200
- 1. **Restore a config file** that is no longer valid JSON, from its newest sibling backup. The broken copy is kept.
202
+ 1. **Restore a config file** that is no longer valid JSON, from its newest sibling backup that still parses — a corrupt newest backup does not block the valid one beside it, and the message says how many newer ones were skipped. The broken copy is kept.
201
203
 
202
204
  2. **Collapse duplicate `packages` entries** in `settings.json`. Only byte-identical repeats are dropped: `"npm:a"` next to `{"source": "npm:a", "enabled": false}` carries different intent and is left alone.
203
205
  3. **Prune `packages` entries whose directory is gone** — the ones a reinstall did not bring back. Sources resolved by Pi itself (`git:`, `https:`) are never touched, and a prune that would leave the list empty is refused and never even offered: that pattern means the agent directory is wrong, not that every package vanished.
@@ -208,24 +210,23 @@ Repairs 2 and 3 rewrite `settings.json`, so they need a settings file checkup ac
208
210
  npx pi-checkup --fix --yes
209
211
  ```
210
212
 
211
- Every write is preceded by a timestamped backup, and neither path writes on its own: `/checkup fix` asks in the TUI, and the CLI refuses to write unless you pass `--yes`. Anything that would delete a package, disable an extension, or edit your shell environment is reported with a suggestion and left alone — a diagnostic tool should not be able to break your setup in the process of describing it.
213
+ Every write is preceded by a timestamped backup — one per file per run, taken before the first write, so `restore` hands back the file as it was before checkup ran rather than a half-repaired intermediate — and neither path writes on its own: `/checkup fix` asks in the TUI, and the CLI refuses to write unless you pass `--yes`. Anything that would delete a package, disable an extension, or edit your shell environment is reported with a suggestion and left alone — a diagnostic tool should not be able to break your setup in the process of describing it.
212
214
 
213
215
  A repair that rewrites `settings.json` re-serialises it with two-space indentation, so expect a larger diff than the change itself. Nothing is repaired unless a check found something wrong, so a healthy install writes nothing at all.
214
216
 
215
217
  ## Ignoring a finding you have decided to live with
216
218
 
217
- Some warnings are correct and permanent: a launcher you deliberately keep off `PATH`, an event-only package that registers no tool. A health check you have learned to ignore is worse than no health check, so rows can be suppressed by name.
219
+ Some findings are correct and permanent: a gateway you deliberately reach over plain `http` on your own network, a package installed with `--ignore-scripts` whose links you do not want. A health check you have learned to ignore is worse than no health check, so rows can be suppressed by name.
218
220
 
219
221
  ```bash
220
- PI_CHECKUP_IGNORE=unreachable,Config:env npx pi-checkup
222
+ PI_CHECKUP_IGNORE=Network:cleartext npx pi-checkup
221
223
  ```
222
224
 
223
- Each entry matches a row's `id`, its `Group:id` form, or its title. A suppressed row is downgraded to `skip`, keeps its original detail, and says what it replaced:
225
+ Each entry matches a row's `id`, its `Group:id` form, or its title. A suppressed row is downgraded to `skip`, keeps its original detail, and says what it replaced — here from a scratch install with one provider deliberately configured over plain `http`:
224
226
 
225
227
  ```text
226
- · unreachable bins ignored via PI_CHECKUP_IGNORE (would be warn): 6 package
227
- command(s) not on PATH: pi-web-access-mcp, pi-subagents,
228
- pi-mcp-adapter, pi-lens, pi-lens-mcp, pi-lens-analyze
228
+ · cleartext auth ignored via PI_CHECKUP_IGNORE (would be warn): gw use
229
+ http:// with credentials configured
229
230
  ```
230
231
 
231
232
  Suppression is never silent, and it drops the row's repair offer so a hidden finding cannot be fixed behind your back. It applies to failures too: a suppressed `fail` no longer affects the exit code, which is the point of the variable — use it for a state you have decided to accept, not to turn a red build green. Export it from your shell profile if you want it to apply inside Pi too.
@@ -239,9 +240,9 @@ Suppression is never silent, and it drops the row's repair offer so a hidden fin
239
240
  "summary": {
240
241
  "total": 24,
241
242
  "ok": 20,
242
- "warn": 1,
243
+ "warn": 0,
243
244
  "fail": 0,
244
- "skip": 3
245
+ "skip": 4
245
246
  },
246
247
  "version": "0.1.0",
247
248
  "node": "22.23.3",
@@ -251,9 +252,9 @@ Suppression is never silent, and it drops the row's repair offer so a hidden fin
251
252
  "group": "Binaries",
252
253
  "id": "unreachable",
253
254
  "title": "unreachable bins",
254
- "status": "warn",
255
- "detail": "6 package command(s) not on PATH: pi-web-access-mcp, pi-subagents, pi-mcp-adapter, pi-lens, pi-lens-mcp, pi-lens-analyze",
256
- "hint": "Pi installs package commands into /root/.pi/agent/npm/node_modules/.bin without adding it to PATH. Extensions that call them by full path still work; to use them by name, add that directory to PATH or run them with npx. If this is the state you want, silence the row with PI_CHECKUP_IGNORE=unreachable."
255
+ "status": "skip",
256
+ "detail": "6 package command(s) live in /root/.pi/agent/npm/node_modules/.bin, which is not on PATH: pi-web-access-mcp, pi-subagents, pi-mcp-adapter, pi-lens, pi-lens-mcp, pi-lens-analyze",
257
+ "hint": "Pi calls these by full path, so an extension that uses them still works. Add /root/.pi/agent/npm/node_modules/.bin to PATH only if you want to run them by name."
257
258
  },
258
259
  {
259
260
  "group": "Config",
@@ -268,7 +269,7 @@ Suppression is never silent, and it drops the row's repair offer so a hidden fin
268
269
 
269
270
  The `checks` array always has one entry per row of the text report, in the same order. The excerpt above omits the other 22 entries; real output is printed by `JSON.stringify(payload, null, 2)`.
270
271
 
271
- Exit-worthy statuses are `fail`; `warn` means something needs attention without by itself failing the run — a package command that is not on `PATH`, or an `apiKey` that no HTTP request can carry. Repair metadata is stripped from the JSON payload.
272
+ Exit-worthy statuses are `fail`; `warn` means something needs attention without by itself failing the run — an `apiKey` that no HTTP request can carry, or a credential on a plain-`http` endpoint. Repair metadata is stripped from the JSON payload.
272
273
 
273
274
  ## Design principles
274
275
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-checkup",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Health checks for your Pi setup: tool/command conflicts, broken packages, shadowed binaries, dead endpoints.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,6 +12,7 @@ import {
12
12
  exists,
13
13
  isMissing,
14
14
  isWindows,
15
+ normalizePath,
15
16
  readJson,
16
17
  resolvePackageDir,
17
18
  sameCommand,
@@ -334,20 +335,35 @@ export async function run({ settings, settingsLoaded = true, agentDir: root, bin
334
335
  );
335
336
 
336
337
  if (unreachable.length) {
338
+ // Is the .bin directory itself on PATH? Pi's default install does not add
339
+ // it, and Pi resolves package binaries by full path — so "unreachable"
340
+ // there is the layout, not a fault. Reporting it as a warning put a
341
+ // permanent amber row on every healthy install and taught the reader to
342
+ // skip the group that is supposed to mean something. It stays a warning
343
+ // only where the directory *is* on PATH and the command still did not
344
+ // resolve, which points at PATH order or an unreadable shim.
345
+ const wanted = normalizePath(binDir);
346
+ const binDirOnPath =
347
+ Boolean(wanted) &&
348
+ String(env.PATH ?? env.Path ?? env.path ?? "")
349
+ .split(path.delimiter)
350
+ .filter(Boolean)
351
+ .some((dir) => normalizePath(dir) === wanted);
352
+ const listed = names(unreachable, 6);
337
353
  results.push({
338
354
  group,
339
355
  id: "unreachable",
340
356
  title: "unreachable bins",
341
- status: "warn",
342
- detail: `${unreachable.length} package command(s) not on PATH: ${names(unreachable, 6)}`,
343
- // The file exists — it is just not on PATH — so the fix is the directory,
344
- // not a reinstall. Without this the row reads as "something is broken"
345
- // with no way to tell what to do about it.
346
- // The file exists — it is just not on PATH — so the fix is the directory,
347
- // not a reinstall. Without this the row reads as "something is broken"
348
- // with no way to tell what to do about it. The suppression is named here
349
- // because this is the one row a healthy install carries for good.
350
- hint: `Pi installs package commands into ${binDir} without adding it to PATH. Extensions that call them by full path still work; to use them by name, add that directory to PATH or run them with npx. If this is the state you want, silence the row with PI_CHECKUP_IGNORE=unreachable.`,
357
+ status: binDirOnPath ? "warn" : "skip",
358
+ detail: binDirOnPath
359
+ ? `${unreachable.length} package command(s) did not resolve even though ${binDir} is on PATH: ${listed}`
360
+ : `${unreachable.length} package command(s) live in ${binDir}, which is not on PATH: ${listed}`,
361
+ // The file exists — it is just not reachable — so the fix is the
362
+ // directory, not a reinstall. Without this the row reads as "something is
363
+ // broken" with no way to tell what to do about it.
364
+ hint: binDirOnPath
365
+ ? "The directory is on PATH, so a different program answers first or the link cannot be read. Check the order of PATH entries and the link's permissions."
366
+ : `Pi calls these by full path, so an extension that uses them still works. Add ${binDir} to PATH only if you want to run them by name.`,
351
367
  });
352
368
  }
353
369
 
@@ -155,9 +155,12 @@ export async function run({
155
155
  : `${backedUp} file(s) backed up`,
156
156
  {
157
157
  // The named path is the point: "copy it somewhere safe" with no `it` left
158
- // the reader to work out which file the paragraph was about.
158
+ // the reader to work out which file the paragraph was about. The count of
159
+ // backups is not claimed: "both backups are older" was hardcoded and
160
+ // wrong as soon as one file was stale and the other was not — and a
161
+ // file may have any number of backups.
159
162
  hint: stale.length
160
- ? `The current ${stale.join(" and ")} cannot be rolled back to, because both backups are older. Copy ${stale
163
+ ? `The newest backup of ${stale.join(" and ")} predates the current file${stale.length > 1 ? "s" : ""}, so restoring it would discard the edits made since. Copy ${stale
161
164
  .map((label) => files.find((f) => f.label === label)?.file ?? label)
162
165
  .join(" and ")} somewhere you can restore from before the next edit.`
163
166
  : undefined,
@@ -207,13 +210,16 @@ export async function run({
207
210
  // so the check has to make the same distinction or its hint names a repair
208
211
  // that then answers "nothing to dedupe".
209
212
  const seen = new Map();
210
- const duplicates = new Set();
213
+ // source -> how many byte-identical repeats beyond the first. A Set of
214
+ // sources counted distinct sources, so three copies of one entry reported
215
+ // "1 listed twice" when two entries were redundant.
216
+ const duplicates = new Map();
211
217
  const mixedShapes = new Set();
212
218
  for (const spec of settings.packages) {
213
219
  const source = sourceOf(spec);
214
220
  if (typeof source !== "string") continue;
215
221
  if (seen.has(source)) {
216
- if (JSON.stringify(seen.get(source)) === JSON.stringify(spec)) duplicates.add(source);
222
+ if (JSON.stringify(seen.get(source)) === JSON.stringify(spec)) duplicates.set(source, (duplicates.get(source) ?? 0) + 1);
217
223
  else mixedShapes.add(source);
218
224
  } else {
219
225
  seen.set(source, spec);
@@ -227,8 +233,7 @@ export async function run({
227
233
  // session's copy is authoritative, and "runtime-unreadable" means the file
228
234
  // is a guess.
229
235
  const canDedupe = settingsSource === "file" && Boolean(settingsFile);
230
- const listed = [...duplicates, ...mixedShapes].map(redactSource);
231
- if (!listed.length) {
236
+ if (!duplicates.size && !mixedShapes.size) {
232
237
  // Zero entries compared is not a pass: "none" over an empty list is the
233
238
  // same vacuous green tick the rest of this file avoids. Counted by
234
239
  // comparable entries rather than by the list length, because
@@ -239,11 +244,12 @@ export async function run({
239
244
  : row("duplicates", "duplicate packages", "skip", "no comparable package entries"),
240
245
  );
241
246
  } else {
242
- const parts = [];
243
- if (duplicates.size) parts.push(`${duplicates.size} listed twice`);
244
- if (mixedShapes.size) parts.push(`${mixedShapes.size} listed in two different shapes`);
247
+ // The count is per source and says how many times the entry actually
248
+ // appears: "listed twice" for one redundant copy, "listed 3 times" for two.
249
+ const parts = [...duplicates].map(([source, extra]) => `${redactSource(source)}: listed ${extra === 1 ? "twice" : `${extra + 1} times`}`);
250
+ for (const source of mixedShapes) parts.push(`${redactSource(source)}: listed in two different shapes`);
245
251
  results.push(
246
- row("duplicates", "duplicate packages", "warn", `${listed.join(", ")}: ${parts.join(", ")}`, {
252
+ row("duplicates", "duplicate packages", "warn", parts.join(", "), {
247
253
  hint:
248
254
  mixedShapes.size && !duplicates.size
249
255
  ? "Dedupe leaves entries that differ alone, because they say different things. Keep the one you meant."
@@ -96,6 +96,7 @@ export async function run({ settings, settingsLoaded, agentDir: root, settingsFi
96
96
  const unreadableDir = [];
97
97
  const notDir = [];
98
98
  const brokenManifest = [];
99
+ const nonObjectManifest = [];
99
100
  const unreadableManifest = [];
100
101
  // A directory with no package.json at all is not a permissions problem and the
101
102
  // hint for one ("check ownership") sent the reader looking in the wrong place.
@@ -161,6 +162,15 @@ export async function run({ settings, settingsLoaded, agentDir: root, settingsFi
161
162
  else unreadableManifest.push(`${redactSource(source)} (${manifest.error ?? "unreadable"})`);
162
163
  continue;
163
164
  }
165
+ // Valid JSON that is not an object — `null`, `"nope"`, `[]` — is not a
166
+ // manifest: nothing can read `.name`, `.version` or `.pi` off it, and the
167
+ // package cannot load. Counting it as "readable" put a green tick on a
168
+ // broken install, right next to an `entry files` row that skipped because
169
+ // the manifest declared nothing.
170
+ if (!manifest.data || typeof manifest.data !== "object" || Array.isArray(manifest.data)) {
171
+ nonObjectManifest.push(`${redactSource(source)} (${manifest.data === null ? "null" : Array.isArray(manifest.data) ? "an array" : typeof manifest.data})`);
172
+ continue;
173
+ }
164
174
  manifestRead++;
165
175
  // A pin and a different installed version is the gap between the code that
166
176
  // was asked for and the code that runs. Only exact pins are comparable.
@@ -272,19 +282,16 @@ export async function run({ settings, settingsLoaded, agentDir: root, settingsFi
272
282
  }),
273
283
  );
274
284
  }
275
- if (brokenManifest.length) {
285
+ if (brokenManifest.length || nonObjectManifest.length) {
286
+ const parts = [];
287
+ if (brokenManifest.length) parts.push(`${brokenManifest.length} package.json not valid JSON: ${preview(brokenManifest, 3)}`);
288
+ if (nonObjectManifest.length) parts.push(`${nonObjectManifest.length} package.json not a JSON object: ${preview(nonObjectManifest, 3)}`);
276
289
  results.push(
277
- row(
278
- "manifests",
279
- "manifests",
280
- "fail",
281
- `${brokenManifest.length} package.json not valid JSON: ${preview(brokenManifest, 3)}`,
282
- {
283
- // A run that overlaps an install sees a manifest mid-rename. Saying so
284
- // is the difference between "reinstall everything" and "wait 30 seconds".
285
- hint: "Reinstall the affected packages. If an install or update is running right now, re-run once it finishes.",
286
- },
287
- ),
290
+ row("manifests", "manifests", "fail", parts.join("; "), {
291
+ // A run that overlaps an install sees a manifest mid-rename. Saying so
292
+ // is the difference between "reinstall everything" and "wait 30 seconds".
293
+ hint: "Reinstall the affected packages. If an install or update is running right now, re-run once it finishes.",
294
+ }),
288
295
  );
289
296
  } else if (manifestRead) {
290
297
  results.push(row("manifests", "manifests", "ok", `${manifestRead} readable`));
@@ -72,6 +72,12 @@ export async function run({ pi, hasRuntime, settings, agentDir: root }) {
72
72
  commandsByOwner.get(owner).push(command.name);
73
73
  }
74
74
  const toolsNamed = [...toolsByOwner.values()].reduce((n, list) => n + list.length, 0);
75
+ // Counted from every entry, not from `toolsByOwner`: that map only holds the
76
+ // named ones, so a registry whose entries carry no `name` printed
77
+ // "2 registered ... across 0 sources" — two tools from no source at all. A
78
+ // partly-named registry understated the same number.
79
+ const toolsSources = new Set(tools.map((tool) => ownerOf(tool?.sourceInfo?.path)));
80
+ const commandsSources = new Set(commands.map((command) => ownerOf(command?.sourceInfo?.path)));
75
81
 
76
82
  // `sourceInfo.path` is a third-party field: a number here threw out of run()
77
83
  // and replaced the whole Registry group with one crash row.
@@ -86,7 +92,7 @@ export async function run({ pi, hasRuntime, settings, agentDir: root }) {
86
92
  // started with tools disabled, and "0 registered across 0 sources"
87
93
  // with a tick reads like everything is accounted for.
88
94
  status: tools.length ? "ok" : "skip",
89
- detail: `${tools.length} registered (${tools.length - builtinTools} extension, ${builtinTools} builtin) across ${toolsByOwner.size} sources`,
95
+ detail: `${tools.length} registered (${tools.length - builtinTools} extension, ${builtinTools} builtin) across ${toolsSources.size} sources`,
90
96
  }
91
97
  : {
92
98
  group,
@@ -111,7 +117,7 @@ export async function run({ pi, hasRuntime, settings, agentDir: root }) {
111
117
  // pass. "0 registered across 0 sources" with a tick claims an answer
112
118
  // the check does not have.
113
119
  status: commands.length ? "ok" : "skip",
114
- detail: `${commands.length} registered across ${commandsByOwner.size} sources, ${serverCount}`,
120
+ detail: `${commands.length} registered across ${commandsSources.size} sources, ${serverCount}`,
115
121
  });
116
122
  } else {
117
123
  results.push({
@@ -141,7 +147,7 @@ export async function run({ pi, hasRuntime, settings, agentDir: root }) {
141
147
  id: "attribution",
142
148
  title: "attribution",
143
149
  status: "ok",
144
- detail: `${toolsNamed} tool(s) from ${toolsByOwner.size} source(s): ${truncateList(ranked, 4)}`,
150
+ detail: `${toolsNamed} tool(s) from ${toolsByOwner.size} source(s): ${truncateList(ranked, 4)}${tools.length > toolsNamed ? ` (${tools.length - toolsNamed} entr${tools.length - toolsNamed === 1 ? "y carries" : "ies carry"} no name and cannot be attributed)` : ""}`,
145
151
  hint: `Run with --verbose for the tool names. ${attribution}`,
146
152
  });
147
153
  }
@@ -2,7 +2,7 @@
2
2
  import path from "node:path";
3
3
  import fs from "node:fs";
4
4
  import net from "node:net";
5
- import { bareHost, curlProbe, describeFsError, exists, humanBytes, isMissing, isWindows, proxyFromEnv, redactProxy, whichAll } from "../util.js";
5
+ import { bareHost, curlProbe, describeFsError, humanBytes, isMissing, isWindows, proxyFromEnv, redactProxy, whichAll } from "../util.js";
6
6
 
7
7
  export const group = "Runtime";
8
8
 
@@ -127,7 +127,17 @@ export async function run({ offline = false, agentDir: root, env = process.env,
127
127
  // --- pi binary ---------------------------------------------------------
128
128
  const piBin = path.join(root, "bin", isWindows ? "pi.cmd" : "pi");
129
129
  const piBinAlt = path.join(root, "bin", "pi");
130
- const found = [piBin, piBinAlt].find(exists);
130
+ // `isFile`, not `exists`: a directory named `bin/pi` satisfied the existence
131
+ // check and its 0755 mode satisfied the executable check, so the row showed a
132
+ // green tick over something that cannot launch anything.
133
+ const isFile = (p) => {
134
+ try {
135
+ return fs.statSync(p).isFile();
136
+ } catch {
137
+ return false;
138
+ }
139
+ };
140
+ const found = [piBin, piBinAlt].find(isFile);
131
141
  let executable = false;
132
142
  if (found && !isWindows) {
133
143
  try {
@@ -144,7 +154,7 @@ export async function run({ offline = false, agentDir: root, env = process.env,
144
154
  // A pi installed by npm lives on PATH, not under the agent dir, so warning
145
155
  // on the absence of ~/.pi/agent/bin/pi told those users their launcher was
146
156
  // missing when `pi` was one PATH lookup away.
147
- const onPath = whichAll("pi", env, { windows: isWindows }).find((c) => exists(c));
157
+ const onPath = whichAll("pi", env, { windows: isWindows }).find(isFile);
148
158
  if (onPath) {
149
159
  launcherStatus = "ok";
150
160
  launcherDetail = `${onPath} (on PATH)`;
@@ -254,15 +264,24 @@ export { redactProxy };
254
264
  /** Does something accept connections at this proxy's own address? */
255
265
  async function checkProxy(proxy, env = process.env) {
256
266
  const shown = redactProxy(proxy);
267
+ // `redactProxy` returns a parenthesised placeholder for a value that is nothing
268
+ // but a credential, so wrapping it again printed "configured ((configured))
269
+ // but not a parsable URL"; saying "value hidden" is clearer than repeating
270
+ // the word configured.
271
+ const shownInParens = shown.startsWith("(") ? "(value hidden)" : `(${shown})`;
257
272
  const row = (status, detail, hint) => ({ group, id: "proxy", title: "proxy", status, detail, hint });
258
- let parsed;
273
+ let parsed = null;
259
274
  try {
260
275
  parsed = new URL(proxy);
261
276
  } catch {
262
- return row("warn", `configured (${shown}) but not a parsable URL`, "Expected something like http://127.0.0.1:10808.");
277
+ // Not fatal here: `127.0.0.1:10808` and `proxy.corp:8080` are both shapes
278
+ // curl accepts, and only the second one parses as a URL (its "scheme" is a
279
+ // dotted name). Returning early on the throw judged the same env value two
280
+ // different ways depending on whether the host happened to start with a
281
+ // digit. The textual fallback below decides.
263
282
  }
264
- let hostname = parsed.hostname;
265
- let port = Number(parsed.port) || DEFAULT_PROXY_PORT[parsed.protocol] || 0;
283
+ let hostname = parsed?.hostname ?? "";
284
+ let port = Number(parsed?.port) || DEFAULT_PROXY_PORT[parsed?.protocol] || 0;
266
285
  if (!hostname || !port) {
267
286
  // `proxy.corp:8080` with no scheme is a shape curl accepts and this check
268
287
  // used to warn about — the same env value, judged two different ways.
@@ -281,7 +300,7 @@ async function checkProxy(proxy, env = process.env) {
281
300
  }
282
301
  }
283
302
  if (!hostname || !port) {
284
- return row("warn", `configured (${shown}) without a usable host and port`);
303
+ return row("warn", `configured ${shownInParens} but not a parsable URL`, "Expected something like http://127.0.0.1:10808.");
285
304
  }
286
305
 
287
306
  // Opt-in end-to-end test: an explicit probe URL means the user asked for
@@ -301,10 +320,10 @@ async function checkProxy(proxy, env = process.env) {
301
320
  if (probe.setupFailed) {
302
321
  // The request never left the machine, so accusing the proxy would be a
303
322
  // finding this check did not establish.
304
- return row("skip", `configured (${shown}); the probe could not be set up: ${probe.error}`);
323
+ return row("skip", `configured ${shownInParens}; the probe could not be set up: ${probe.error}`);
305
324
  }
306
325
  if (probe.missing) {
307
- return row("skip", `configured (${shown}); curl is not installed so forwarding was not tested`);
326
+ return row("skip", `configured ${shownInParens}; curl is not installed so forwarding was not tested`);
308
327
  }
309
328
  const code = probe.code;
310
329
  const numeric = Number(code);
package/src/context.js CHANGED
@@ -64,10 +64,12 @@ export function buildContext({ pi, cwd, offline = false, agentDir: rootOverride,
64
64
  settingsSource,
65
65
  hasRuntime,
66
66
  // Whether the agent directory came from an explicit `PI_CODING_AGENT_DIR`
67
- // rather than the default location. A directory that is missing answers two
68
- // different questions depending on that: on a machine that never ran Pi it is
69
- // simply the first use, but a path the user typed and got wrong is a problem
70
- // with this run — and the two must not both report as a warning.
67
+ // rather than the default location. A missing directory answers two different
68
+ // questions depending on that: on a machine that never ran Pi it is simply the
69
+ // first use, but a path the user typed and got wrong is a problem with this
70
+ // run. Both stay `warn` — a directory Pi has not created yet is not damage —
71
+ // but the row names the variable and tells the reader to check it, instead of
72
+ // sending them to create a default location this run never used.
71
73
  agentDirExplicit: Boolean(agentDirExplicit),
72
74
  // Checks read the environment through the context, not from process.env, so
73
75
  // a test can describe a proxy or an unset variable without touching the
package/src/render.js CHANGED
@@ -24,6 +24,41 @@ export function toJson(results, extra = {}) {
24
24
  };
25
25
  }
26
26
 
27
+ /**
28
+ * Columns a string occupies, not code units.
29
+ *
30
+ * A CJK path — `C:\Users\张三\.pi\agent` — is one code unit per character and
31
+ * two columns on every terminal, so measuring with `.length` wrapped one column
32
+ * short and let the line spill past the width the report promises to respect.
33
+ */
34
+ function displayWidth(text) {
35
+ let width = 0;
36
+ for (const ch of String(text ?? "")) {
37
+ const cp = ch.codePointAt(0);
38
+ // Combining marks and zero-width joiners occupy no column of their own.
39
+ if (cp === 0x200b || cp === 0xfeff || (cp >= 0x0300 && cp <= 0x036f)) continue;
40
+ const wide =
41
+ (cp >= 0x1100 && cp <= 0x115f) ||
42
+ (cp >= 0x2e80 && cp <= 0x303e) ||
43
+ (cp >= 0x3041 && cp <= 0x33ff) ||
44
+ (cp >= 0x3400 && cp <= 0x4dbf) ||
45
+ (cp >= 0x4e00 && cp <= 0x9fff) ||
46
+ (cp >= 0xa000 && cp <= 0xa4cf) ||
47
+ (cp >= 0xa960 && cp <= 0xa97f) ||
48
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
49
+ (cp >= 0xf900 && cp <= 0xfaff) ||
50
+ (cp >= 0xfe10 && cp <= 0xfe19) ||
51
+ (cp >= 0xfe30 && cp <= 0xfe6f) ||
52
+ (cp >= 0xff00 && cp <= 0xff60) ||
53
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
54
+ (cp >= 0x1f300 && cp <= 0x1f64f) ||
55
+ (cp >= 0x1f900 && cp <= 0x1f9ff) ||
56
+ (cp >= 0x20000 && cp <= 0x3fffd);
57
+ width += wide ? 2 : 1;
58
+ }
59
+ return width;
60
+ }
61
+
27
62
  /**
28
63
  * Greedy word wrap. `indent` is the column the text starts at, so every line has
29
64
  * the same available width and continuation lines line up under the text they
@@ -33,32 +68,59 @@ export function toJson(results, extra = {}) {
33
68
  * chunks rather than left to overflow: the terminal would wrap it anyway, and it
34
69
  * would do so with no indent, which breaks the column alignment that makes the
35
70
  * report scannable.
71
+ *
72
+ * Widths are display columns, not code units: see `displayWidth`.
36
73
  */
37
74
  function wrapText(text, width, indent) {
38
75
  const body = String(text ?? "");
39
76
  if (!body) return [""];
40
77
  const avail = Math.max(8, width - indent);
41
- if (body.length <= avail) return [body];
78
+ if (displayWidth(body) <= avail) return [body];
42
79
 
43
80
  const lines = [];
44
81
  let line = "";
82
+ let lineWidth = 0;
45
83
  const flush = () => {
46
84
  if (line) {
47
85
  lines.push(line);
48
86
  line = "";
87
+ lineWidth = 0;
49
88
  }
50
89
  };
51
90
  for (const word of body.split(/\s+/).filter(Boolean)) {
52
- if (word.length > avail) {
91
+ const wordWidth = displayWidth(word);
92
+ if (wordWidth > avail) {
53
93
  flush();
54
- for (let i = 0; i < word.length; i += avail) lines.push(word.slice(i, i + avail));
94
+ // Cut the long token into pieces that fit, measured in columns so a
95
+ // wide character cannot straddle the edge.
96
+ let piece = "";
97
+ let pieceWidth = 0;
98
+ for (const ch of word) {
99
+ const cw = displayWidth(ch);
100
+ if (pieceWidth + cw > avail) {
101
+ lines.push(piece);
102
+ piece = "";
103
+ pieceWidth = 0;
104
+ }
105
+ piece += ch;
106
+ pieceWidth += cw;
107
+ }
108
+ if (piece) {
109
+ line = piece;
110
+ lineWidth = pieceWidth;
111
+ }
55
112
  continue;
56
113
  }
57
- if (!line) line = word;
58
- else if (line.length + 1 + word.length <= avail) line += ` ${word}`;
59
- else {
114
+ if (!line) {
115
+ line = word;
116
+ lineWidth = wordWidth;
117
+ } else if (lineWidth + 1 + wordWidth <= avail) {
118
+ line += ` ${word}`;
119
+ lineWidth += 1 + wordWidth;
120
+ } else {
60
121
  flush();
61
122
  line = word;
123
+ lineWidth = wordWidth;
62
124
  }
63
125
  }
64
126
  flush();
@@ -92,7 +154,7 @@ export function renderReport(results, { verbose = false, hintCommand = "/checkup
92
154
  // push a single row's detail out of alignment with every other row. A title
93
155
  // longer than the cap gets its detail on its own line rather than shoving it
94
156
  // past the column every wrapped continuation line uses.
95
- const titleWidth = Math.min(22, Math.max(8, ...results.map((r) => r.title.length)));
157
+ const titleWidth = Math.min(22, Math.max(8, ...results.map((r) => displayWidth(r.title))));
96
158
  // Below this width the title column plus one word of detail leaves a gutter
97
159
  // too narrow to read ("package dirs 2 missing from" then two words a line), so
98
160
  // the detail moves under the title instead of beside it.
@@ -105,7 +167,7 @@ export function renderReport(results, { verbose = false, hintCommand = "/checkup
105
167
  for (const r of byGroup.get(g)) {
106
168
  const mark = ` ${MARKS[r.status] ?? "?"} `;
107
169
  const title = r.title.padEnd(titleWidth);
108
- if (stacked || r.title.length > titleWidth) {
170
+ if (stacked || displayWidth(r.title) > titleWidth) {
109
171
  // The title gets the same treatment as the detail: a name that does not
110
172
  // fit would otherwise set the width for the whole report.
111
173
  const titleParts = wrapText(r.title, width, mark.length);
@@ -130,12 +192,20 @@ export function renderReport(results, { verbose = false, hintCommand = "/checkup
130
192
  const s = summarize(results);
131
193
  lines.push("");
132
194
  const warnWord = s.warn === 1 ? "warning" : "warnings";
133
- lines.push(`${s.total} checks: ${s.ok} ok, ${s.warn} ${warnWord}, ${s.fail} failed${s.skip ? `, ${s.skip} skipped` : ""}`);
195
+ // Wrapped like every other line: the summary is the longest single sentence in
196
+ // a short report — "19 checks: 11 ok, 0 warnings, 0 failed, 8 skipped" is 51
197
+ // columns — and leaving it unwrapped broke the width the rest of the report
198
+ // respects on a narrow terminal or a split pane.
199
+ for (const part of wrapText(`${s.total} checks: ${s.ok} ok, ${s.warn} ${warnWord}, ${s.fail} failed${s.skip ? `, ${s.skip} skipped` : ""}`, width, 0)) {
200
+ lines.push(part);
201
+ }
134
202
  // Any hint, on any row. Tying this to non-ok rows meant a report whose only
135
203
  // finding carried no hint never mentioned --verbose at all, so the user had
136
204
  // no way to learn the flag exists.
137
205
  if (!verbose && results.some((r) => r.hint)) {
138
- lines.push(`Run \`${hintCommand}\` to show what to do about each finding.`);
206
+ for (const part of wrapText(`Run \`${hintCommand}\` to show what to do about each finding.`, width, 0)) {
207
+ lines.push(part);
208
+ }
139
209
  }
140
210
  return lines.join("\n");
141
211
  }
package/src/repair.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // and none of them deletes a package or disables an extension.
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
- import { isMissing, newestBackup, readJson, redactSource, resolvePackageDir, scrubCredentials, sourceOf } from "./util.js";
9
+ import { backupsNewestFirst, isMissing, readJson, redactSource, resolvePackageDir, scrubCredentials, sourceOf } from "./util.js";
10
10
 
11
11
  function stamp() {
12
12
  // Milliseconds are kept: two repairs in the same second would otherwise
@@ -35,6 +35,26 @@ function copyBackup(target, label = "checkup-bak") {
35
35
  throw new Error(`could not find a free backup name for ${path.basename(target)}`);
36
36
  }
37
37
 
38
+ /**
39
+ * The backup for `target` in this run, made once and reused.
40
+ *
41
+ * Two repairs can hit the same file: a prune and a dedupe both rewrite
42
+ * `settings.json`, and each used to write its own backup a few milliseconds
43
+ * apart. That left the newest backup holding the *intermediate* state — after
44
+ * the prune, before the dedupe — and `restore` picks the newest backup, so
45
+ * undoing a broken config handed back a half-repaired file instead of the one
46
+ * the user had before checkup touched it. One backup per file per run keeps
47
+ * "the backup" meaning "before this run".
48
+ */
49
+ function backupFor(target, backups) {
50
+ if (!backups) return copyBackup(target);
51
+ const existing = backups.get(target);
52
+ if (existing) return existing;
53
+ const made = copyBackup(target);
54
+ backups.set(target, made);
55
+ return made;
56
+ }
57
+
38
58
  /** Flush a directory entry so a rename survives a power loss. Best effort. */
39
59
  function syncDir(dir) {
40
60
  let fd;
@@ -157,12 +177,28 @@ export function applyRestoreConfig(target) {
157
177
  // it would undo the fix.
158
178
  const current = readJson(target);
159
179
  if (current.ok) return { ok: false, message: `${path.basename(target)} is valid JSON again — nothing to restore` };
160
- const backup = newestBackup(target);
161
- if (!backup) return { ok: false, message: `no backup found for ${path.basename(target)}` };
162
- const parsed = readJson(backup);
163
- // `!ok` covers oversized and unreadable too, and telling the user a valid
164
- // 5 MB backup "is not valid JSON" is the same false claim the checks avoid.
165
- if (!parsed.ok) return { ok: false, message: `nothing to restore from: ${unreadable(backup, parsed).message}` };
180
+ const candidates = backupsNewestFirst(target);
181
+ if (!candidates.length) return { ok: false, message: `no backup found for ${path.basename(target)}` };
182
+ // Newest first, and the first one that parses wins. Taking only the newest
183
+ // meant a corrupt newest backup — a copy taken while the file was already
184
+ // damaged — blocked the valid one sitting next to it, which is the exact
185
+ // situation restore exists for.
186
+ let backup = null;
187
+ const rejected = [];
188
+ for (const candidate of candidates) {
189
+ const parsed = readJson(candidate);
190
+ if (parsed.ok) {
191
+ backup = candidate;
192
+ break;
193
+ }
194
+ // `!ok` covers oversized and unreadable too, and telling the user a valid
195
+ // 5 MB backup "is not valid JSON" is the same false claim the checks avoid.
196
+ rejected.push(unreadable(candidate, parsed).message);
197
+ }
198
+ if (!backup) {
199
+ return { ok: false, message: `nothing to restore from: ${rejected.join("; ")}` };
200
+ }
201
+ const skipped = candidates.indexOf(backup);
166
202
  const aside = copyBackup(target, "broken");
167
203
  // The backup is valid JSON (checked above), so restoring its bytes verbatim
168
204
  // keeps any formatting the user had. If the write itself fails after the
@@ -183,7 +219,7 @@ export function applyRestoreConfig(target) {
183
219
  return {
184
220
  ok: true,
185
221
  changed: true,
186
- message: `restored ${path.basename(target)} from ${path.basename(backup)} (broken copy kept as ${path.basename(aside)})`,
222
+ message: `restored ${path.basename(target)} from ${path.basename(backup)}${skipped ? ` (skipped ${skipped} newer backup(s) that do not parse)` : ""} (broken copy kept as ${path.basename(aside)})`,
187
223
  };
188
224
  }
189
225
 
@@ -196,7 +232,7 @@ export function applyRestoreConfig(target) {
196
232
  * the second one silently changed what Pi loads. The backup would have made it
197
233
  * recoverable, but a repair that needs its backup is not a repair.
198
234
  */
199
- export function applyDedupePackages(target) {
235
+ export function applyDedupePackages(target, options = {}) {
200
236
  const settings = readJson(target);
201
237
  if (!settings.ok) return unreadable(target, settings);
202
238
  if (!Array.isArray(settings.data?.packages)) return { ok: false, message: "no packages array to dedupe" };
@@ -213,7 +249,7 @@ export function applyDedupePackages(target) {
213
249
  // between — a second read would prove nothing. Backing up before the plan was
214
250
  // known left a stray `.checkup-bak-*` beside a healthy config on runs that
215
251
  // repaired nothing.
216
- const backup = copyBackup(target);
252
+ const backup = backupFor(target, options.backups);
217
253
  settings.data.packages = plan.kept;
218
254
  writeAtomic(target, `${JSON.stringify(settings.data, null, 2)}\n`);
219
255
  return {
@@ -258,7 +294,7 @@ function dedupePlan(packages) {
258
294
  * `dirname(target)` would resolve every entry under the wrong root if this were
259
295
  * ever pointed at a project-level settings file.
260
296
  */
261
- export function applyPruneMissingPackages(target, root) {
297
+ export function applyPruneMissingPackages(target, root, options = {}) {
262
298
  // Not defaulted: a wrong root makes every entry look missing, and this repair
263
299
  // deletes what it cannot resolve.
264
300
  if (!root) throw new Error("applyPruneMissingPackages needs the agent directory to resolve packages against");
@@ -288,7 +324,7 @@ export function applyPruneMissingPackages(target, root) {
288
324
 
289
325
  // Same reasoning as dedupe: the read above is post-prompt, and the rest of
290
326
  // this function is synchronous.
291
- const backup = copyBackup(target);
327
+ const backup = backupFor(target, options.backups);
292
328
  settings.data.packages = plan.kept;
293
329
  writeAtomic(target, `${JSON.stringify(settings.data, null, 2)}\n`);
294
330
  return {
@@ -328,8 +364,8 @@ function prunePlan(packages, root) {
328
364
  /** Named repairs, keyed by the `fix` field a check emits. */
329
365
  const REPAIRS = {
330
366
  "restore-config": (target) => applyRestoreConfig(target),
331
- "dedupe-packages": (target) => applyDedupePackages(target),
332
- "prune-missing-packages": applyPruneMissingPackages,
367
+ "dedupe-packages": (target, ctx) => applyDedupePackages(target, ctx),
368
+ "prune-missing-packages": (target, ctx) => applyPruneMissingPackages(target, ctx?.agentDir, ctx),
333
369
  };
334
370
 
335
371
 
@@ -344,6 +380,9 @@ const REPAIRS = {
344
380
  export async function applyRepairs(findings, { agentDir } = {}) {
345
381
  const applied = [];
346
382
  const seen = new Set();
383
+ // Shared across every repair in this run: one backup per file, taken before
384
+ // the first write to it, so `restore` undoes the run rather than its last step.
385
+ const ctx = { agentDir, backups: new Map() };
347
386
  for (const finding of findings) {
348
387
  const key = `${finding.fix}:${finding.fixTarget}`;
349
388
  if (seen.has(key)) continue;
@@ -355,7 +394,7 @@ export async function applyRepairs(findings, { agentDir } = {}) {
355
394
  continue;
356
395
  }
357
396
  try {
358
- applied.push(repair(finding.fixTarget, agentDir));
397
+ applied.push(repair(finding.fixTarget, ctx));
359
398
  } catch (e) {
360
399
  // Scrubbed like every other error path: fs errors embed absolute paths and
361
400
  // the prune guard's throw can quote a package source, which may carry a
package/src/util.js CHANGED
@@ -285,16 +285,27 @@ export function normalizePath(p) {
285
285
  * two naming schemes do not sort chronologically against each other.
286
286
  */
287
287
  export function newestBackup(file) {
288
+ return backupsNewestFirst(file)[0] ?? null;
289
+ }
290
+
291
+ /**
292
+ * Every backup of `file`, newest first.
293
+ *
294
+ * `restore` used to take only the newest one and give up when it did not parse,
295
+ * which failed the one case recovery exists for: the newest backup was corrupt
296
+ * — a copy taken while the file was already damaged — and a valid older one sat
297
+ * right next to it. Callers walk this list and stop at the first that parses.
298
+ */
299
+ export function backupsNewestFirst(file) {
288
300
  const dir = path.dirname(file);
289
301
  const base = path.basename(file);
290
302
  let entries;
291
303
  try {
292
304
  entries = fs.readdirSync(dir);
293
305
  } catch {
294
- return null;
306
+ return [];
295
307
  }
296
- let best = null;
297
- let bestTime = Number.NEGATIVE_INFINITY;
308
+ const found = [];
298
309
  for (const entry of entries) {
299
310
  if (entry === base || !entry.startsWith(base)) continue;
300
311
  // Anchored: the marker has to start right after `settings.json`. Unanchored,
@@ -308,14 +319,12 @@ export function newestBackup(file) {
308
319
  } catch {
309
320
  continue; // dangling symlink or raced deletion
310
321
  }
311
- // A bulk copy can give several backups the same mtime; break the tie on
312
- // the name so repeated runs always pick the same file.
313
- if (mtimeMs > bestTime || (mtimeMs === bestTime && best !== null && full > best)) {
314
- best = full;
315
- bestTime = mtimeMs;
316
- }
322
+ found.push({ file: full, mtimeMs });
317
323
  }
318
- return best;
324
+ // A bulk copy can give several backups the same mtime; break the tie on the
325
+ // name so repeated runs always see the same order.
326
+ found.sort((a, b) => b.mtimeMs - a.mtimeMs || (a.file < b.file ? 1 : a.file > b.file ? -1 : 0));
327
+ return found.map((entry) => entry.file);
319
328
  }
320
329
 
321
330
  /**
@@ -679,7 +688,11 @@ export function redactUrl(raw) {
679
688
  // Printing `url.origin` would then print the string "null".
680
689
  if (!url.hostname) return scrubCredentials(text);
681
690
  const path = url.pathname === "/" ? "" : url.pathname;
682
- return `${url.protocol}//${url.host}${path}`;
691
+ // Scrubbed, not returned raw: a gateway that takes its key in the path —
692
+ // `https://host/ghp_…/v1` — put the credential back into the report through
693
+ // the one function whose job is to keep it out. The host itself cannot
694
+ // match a token shape, so scrubbing the assembled string is safe.
695
+ return scrubCredentials(`${url.protocol}//${url.host}${path}`);
683
696
  } catch {
684
697
  return scrubCredentials(text);
685
698
  }
@@ -803,6 +816,29 @@ export function scrubCredentials(text) {
803
816
  // `npm:alice:pw@host` and the single slash in `https:/alice:pw@host`
804
817
  // still start a match.
805
818
  .replace(/(^|[\s"'=(,:/])(?!(?:npm|git|file|https?|ssh|link):)([^\s/@:'"]+):([^\s/@]*?)@/g, "$1***@")
819
+ // A bare token has no userinfo and no query string for the rules above to
820
+ // find. An error message that quoted one — "extension load failed for
821
+ // provider openai with apiKey sk-…" — reached the report, the TUI
822
+ // notification and --json verbatim, minus whatever the caller's own
823
+ // truncation happened to cut. These are the shapes that carry enough
824
+ // entropy to be a real credential and enough of a fixed prefix to be
825
+ // recognised without guessing.
826
+ .replace(/\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}/g, "***")
827
+ .replace(/\bgh[pousr]_[A-Za-z0-9]{20,}/g, "***")
828
+ .replace(/\bgithub_pat_[A-Za-z0-9_]{20,}/g, "***")
829
+ .replace(/\bnpm_[A-Za-z0-9]{20,}/g, "***")
830
+ .replace(/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, "***")
831
+ .replace(/\bAKIA[0-9A-Z]{16}\b/g, "***")
832
+ .replace(/\bAIza[0-9A-Za-z_-]{30,}/g, "***")
833
+ // A JWT is three base64url segments and is what an `Authorization`
834
+ // header usually holds.
835
+ .replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, "***")
836
+ .replace(/\b(bearer)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "$1 ***")
837
+ // The value after a credential-named key, whatever shape it has: a token
838
+ // with no recognisable prefix is still a token when the line says
839
+ // `apiKey: …`. The value stops at whitespace and at the punctuation that
840
+ // ends a sentence, so surrounding prose survives.
841
+ .replace(/\b(api[-_]?key|access[-_]?token|auth[-_]?token|token|secret|password|passwd)\s*([:=])\s*[^\s,;)"']+/gi, "$1$2***")
806
842
  // A query string is dropped whole rather than masked: it is where a pasted
807
843
  // `?key=sk-…` lives, and nothing in a report needs it.
808
844
  .replace(/\?[^\s]*/g, "")