pi-checkup 0.1.1 → 0.1.3
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 +22 -3
- package/docs/demo.svg +44 -0
- package/package.json +4 -2
- package/src/checks/config.js +11 -8
- package/src/checks/network.js +36 -9
- package/src/checks/packages.js +19 -12
- package/src/checks/registry.js +9 -3
- package/src/checks/runtime.js +29 -10
- package/src/context.js +6 -4
- package/src/render.js +80 -10
- package/src/repair.js +89 -15
- package/src/util.js +47 -11
package/README.md
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
# pi-checkup
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/pi-checkup)
|
|
4
|
+
[](https://github.com/xinghe-labs/pi-checkup/actions/workflows/ci.yml)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
[](https://nodejs.org)
|
|
7
|
+
[](./package.json)
|
|
8
|
+
|
|
3
9
|
**`pi list` tells you what you installed. `pi-checkup` tells you what actually works.**
|
|
4
10
|
|
|
11
|
+

|
|
12
|
+
|
|
5
13
|
A health-check extension for [Pi](https://pi.dev). It inspects the *running* session — not just your `settings.json` — and reports the failures that produce no error message anywhere: tools silently shadowed by another package, binaries that resolve to a completely different program, packages that are installed but registered nothing, endpoints that stopped answering.
|
|
6
14
|
|
|
7
15
|
## Install
|
|
@@ -166,7 +174,7 @@ The `attribution` row is the part no other tool gives you: which package owns wh
|
|
|
166
174
|
| **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" |
|
|
167
175
|
| | 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 |
|
|
168
176
|
| | 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 |
|
|
169
|
-
| | Corrupt manifests | A `package.json` that parses
|
|
177
|
+
| | 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 |
|
|
170
178
|
| | 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 |
|
|
171
179
|
| | 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 |
|
|
172
180
|
| | Missing entry files | The manifest points at a build output that was never shipped |
|
|
@@ -199,7 +207,7 @@ The groups above are the ones that appear in a report, in that order. Independen
|
|
|
199
207
|
|
|
200
208
|
Only reversible repairs are automated, and inside a session it normally offers only the first:
|
|
201
209
|
|
|
202
|
-
1. **Restore a config file** that is no longer valid JSON, from its newest sibling backup. The broken copy is kept.
|
|
210
|
+
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.
|
|
203
211
|
|
|
204
212
|
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.
|
|
205
213
|
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.
|
|
@@ -210,7 +218,7 @@ Repairs 2 and 3 rewrite `settings.json`, so they need a settings file checkup ac
|
|
|
210
218
|
npx pi-checkup --fix --yes
|
|
211
219
|
```
|
|
212
220
|
|
|
213
|
-
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.
|
|
221
|
+
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.
|
|
214
222
|
|
|
215
223
|
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.
|
|
216
224
|
|
|
@@ -305,6 +313,17 @@ npm run lint # node --check over every source file
|
|
|
305
313
|
node --test # the whole suite; no Pi session needed
|
|
306
314
|
```
|
|
307
315
|
|
|
316
|
+
Coverage, as of 0.1.2 (`node --test --experimental-test-coverage`):
|
|
317
|
+
|
|
318
|
+
```text
|
|
319
|
+
all files 95.76 % lines 86.66 % branches 95.96 % funcs
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
The one file well below that is `src/index.js` at 25.69 % — the in-session command handler.
|
|
323
|
+
Covering it needs a live Pi process, and the parts that matter (the confirmation prompt, the repair
|
|
324
|
+
call, the re-check) were driven end to end over the RPC extension-UI protocol by hand rather than
|
|
325
|
+
in the suite. A green number there would be worth less than saying so.
|
|
326
|
+
|
|
308
327
|
To try a local checkout without installing:
|
|
309
328
|
|
|
310
329
|
```bash
|
package/docs/demo.svg
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="690" height="726" viewBox="0 0 690 726" role="img" aria-label="pi-checkup report">
|
|
2
|
+
<title>pi-checkup report</title>
|
|
3
|
+
<rect width="690" height="726" rx="10" fill="#10141a"/>
|
|
4
|
+
<rect width="690" height="38" rx="10" fill="#1b222c"/>
|
|
5
|
+
<rect y="28" width="690" height="10" fill="#1b222c"/>
|
|
6
|
+
<circle cx="22" cy="19" r="6" fill="#e06c75"/>
|
|
7
|
+
<circle cx="42" cy="19" r="6" fill="#e0b25c"/>
|
|
8
|
+
<circle cx="62" cy="19" r="6" fill="#4ec9a0"/>
|
|
9
|
+
<text x="345.0" y="24" fill="#5c6b7f" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11.5" text-anchor="middle">npx pi-checkup</text>
|
|
10
|
+
<g font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="13.5" xml:space="preserve">
|
|
11
|
+
<text x="22" y="58" fill="#7aa2f7" font-weight="600">Registry</text>
|
|
12
|
+
<text x="22" y="77" fill="#5c6b7f" font-weight="400"> · live registry no Pi session — registry checks need a running process</text>
|
|
13
|
+
<text x="22" y="96" fill="#7aa2f7" font-weight="600">Packages</text>
|
|
14
|
+
<text x="22" y="115" fill="#4ec9a0" font-weight="400"> ✓ package dirs 15 present</text>
|
|
15
|
+
<text x="22" y="134" fill="#4ec9a0" font-weight="400"> ✓ manifests 15 readable</text>
|
|
16
|
+
<text x="22" y="153" fill="#4ec9a0" font-weight="400"> ✓ entry files 14 manifest(s) resolve</text>
|
|
17
|
+
<text x="22" y="172" fill="#7aa2f7" font-weight="600">Binaries</text>
|
|
18
|
+
<text x="22" y="191" fill="#4ec9a0" font-weight="400"> ✓ dangling links 19 links all resolve</text>
|
|
19
|
+
<text x="22" y="210" fill="#5c6b7f" font-weight="400"> · shadowed names no package command resolved on PATH</text>
|
|
20
|
+
<text x="22" y="229" fill="#5c6b7f" font-weight="400"> · unreachable bins 6 package command(s) live in</text>
|
|
21
|
+
<text x="22" y="248" fill="#c8d3e0" font-weight="400"> /root/.pi/agent/npm/node_modules/.bin, which is not on</text>
|
|
22
|
+
<text x="22" y="267" fill="#c8d3e0" font-weight="400"> PATH: pi-web-access-mcp, pi-subagents, pi-mcp-adapter,</text>
|
|
23
|
+
<text x="22" y="286" fill="#c8d3e0" font-weight="400"> pi-lens, pi-lens-mcp, pi-lens-analyze</text>
|
|
24
|
+
<text x="22" y="305" fill="#7aa2f7" font-weight="600">Network</text>
|
|
25
|
+
<text x="22" y="324" fill="#4ec9a0" font-weight="400"> ✓ providers 2 endpoint(s) reachable</text>
|
|
26
|
+
<text x="22" y="343" fill="#4ec9a0" font-weight="400"> ✓ dns 1 provider host(s) resolve normally</text>
|
|
27
|
+
<text x="22" y="362" fill="#7aa2f7" font-weight="600">Config</text>
|
|
28
|
+
<text x="22" y="381" fill="#4ec9a0" font-weight="400"> ✓ settings.json valid JSON</text>
|
|
29
|
+
<text x="22" y="400" fill="#4ec9a0" font-weight="400"> ✓ models.json valid JSON</text>
|
|
30
|
+
<text x="22" y="419" fill="#5c6b7f" font-weight="400"> · project settings none</text>
|
|
31
|
+
<text x="22" y="438" fill="#4ec9a0" font-weight="400"> ✓ backups 2 file(s) backed up; edited since the newest backup:</text>
|
|
32
|
+
<text x="22" y="457" fill="#c8d3e0" font-weight="400"> settings.json, models.json</text>
|
|
33
|
+
<text x="22" y="476" fill="#4ec9a0" font-weight="400"> ✓ duplicate packages none</text>
|
|
34
|
+
<text x="22" y="495" fill="#5c6b7f" font-weight="400"> · env references no $VAR reference in models.json credentials</text>
|
|
35
|
+
<text x="22" y="514" fill="#7aa2f7" font-weight="600">Runtime</text>
|
|
36
|
+
<text x="22" y="533" fill="#4ec9a0" font-weight="400"> ✓ node v22.23.3</text>
|
|
37
|
+
<text x="22" y="552" fill="#4ec9a0" font-weight="400"> ✓ pi launcher /root/.pi/agent/bin/pi</text>
|
|
38
|
+
<text x="22" y="571" fill="#4ec9a0" font-weight="400"> ✓ disk 6% used on /, 950.3 GB free</text>
|
|
39
|
+
<text x="22" y="590" fill="#4ec9a0" font-weight="400"> ✓ proxy http://127.0.0.1:10808 accepting connections</text>
|
|
40
|
+
<text x="22" y="628" fill="#7aa2f7" font-weight="600">19 checks: 14 ok, 0 warnings, 0 failed, 5 skipped</text>
|
|
41
|
+
<text x="22" y="647" fill="#7aa2f7" font-weight="600">Run `npx pi-checkup --verbose` to show what to do about each finding.</text>
|
|
42
|
+
<text x="22" y="685" fill="#4ec9a0" font-weight="400">✓ 14 ok, 5 skipped · run /checkup inside Pi for registry checks</text>
|
|
43
|
+
</g>
|
|
44
|
+
</svg>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-checkup",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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",
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"pi": {
|
|
22
22
|
"extensions": [
|
|
23
23
|
"./src/index.js"
|
|
24
|
-
]
|
|
24
|
+
],
|
|
25
|
+
"image": "https://raw.githubusercontent.com/xinghe-labs/pi-checkup/main/docs/demo.svg"
|
|
25
26
|
},
|
|
26
27
|
"bin": {
|
|
27
28
|
"pi-checkup": "bin/checkup.js"
|
|
@@ -30,6 +31,7 @@
|
|
|
30
31
|
"src/",
|
|
31
32
|
"bin/",
|
|
32
33
|
"scripts/",
|
|
34
|
+
"docs/",
|
|
33
35
|
"README.md",
|
|
34
36
|
"LICENSE"
|
|
35
37
|
],
|
package/src/checks/config.js
CHANGED
|
@@ -210,13 +210,16 @@ export async function run({
|
|
|
210
210
|
// so the check has to make the same distinction or its hint names a repair
|
|
211
211
|
// that then answers "nothing to dedupe".
|
|
212
212
|
const seen = new Map();
|
|
213
|
-
|
|
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();
|
|
214
217
|
const mixedShapes = new Set();
|
|
215
218
|
for (const spec of settings.packages) {
|
|
216
219
|
const source = sourceOf(spec);
|
|
217
220
|
if (typeof source !== "string") continue;
|
|
218
221
|
if (seen.has(source)) {
|
|
219
|
-
if (JSON.stringify(seen.get(source)) === JSON.stringify(spec)) duplicates.
|
|
222
|
+
if (JSON.stringify(seen.get(source)) === JSON.stringify(spec)) duplicates.set(source, (duplicates.get(source) ?? 0) + 1);
|
|
220
223
|
else mixedShapes.add(source);
|
|
221
224
|
} else {
|
|
222
225
|
seen.set(source, spec);
|
|
@@ -230,8 +233,7 @@ export async function run({
|
|
|
230
233
|
// session's copy is authoritative, and "runtime-unreadable" means the file
|
|
231
234
|
// is a guess.
|
|
232
235
|
const canDedupe = settingsSource === "file" && Boolean(settingsFile);
|
|
233
|
-
|
|
234
|
-
if (!listed.length) {
|
|
236
|
+
if (!duplicates.size && !mixedShapes.size) {
|
|
235
237
|
// Zero entries compared is not a pass: "none" over an empty list is the
|
|
236
238
|
// same vacuous green tick the rest of this file avoids. Counted by
|
|
237
239
|
// comparable entries rather than by the list length, because
|
|
@@ -242,11 +244,12 @@ export async function run({
|
|
|
242
244
|
: row("duplicates", "duplicate packages", "skip", "no comparable package entries"),
|
|
243
245
|
);
|
|
244
246
|
} else {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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`);
|
|
248
251
|
results.push(
|
|
249
|
-
row("duplicates", "duplicate packages", "warn",
|
|
252
|
+
row("duplicates", "duplicate packages", "warn", parts.join(", "), {
|
|
250
253
|
hint:
|
|
251
254
|
mixedShapes.size && !duplicates.size
|
|
252
255
|
? "Dedupe leaves entries that differ alone, because they say different things. Keep the one you meant."
|
package/src/checks/network.js
CHANGED
|
@@ -34,7 +34,7 @@ function hasCredentialHeader(headers) {
|
|
|
34
34
|
);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export async function run({ offline = false,
|
|
37
|
+
export async function run({ offline = false, modelsFile, env = process.env, hasRuntime = false }) {
|
|
38
38
|
const results = [];
|
|
39
39
|
const models = readJson(modelsFile);
|
|
40
40
|
if (!models.ok) {
|
|
@@ -91,6 +91,11 @@ export async function run({ offline = false, agentDir: root, modelsFile, env = p
|
|
|
91
91
|
// hole one level up.
|
|
92
92
|
const shape = [];
|
|
93
93
|
const queryWithheld = [];
|
|
94
|
+
// Providers that declare a credential but no `baseUrl` are not endpoints to
|
|
95
|
+
// probe — Pi may have a built-in default for them — but they were dropped in
|
|
96
|
+
// silence, so a user with five providers and one baseUrl read "1 endpoint(s)
|
|
97
|
+
// reachable" and had no way to tell the other four were never looked at.
|
|
98
|
+
const noBaseUrl = [];
|
|
94
99
|
const proxy = proxyFromEnv(env);
|
|
95
100
|
for (const [name, provider] of Object.entries(providers)) {
|
|
96
101
|
if (!provider || typeof provider !== "object" || Array.isArray(provider)) {
|
|
@@ -98,11 +103,24 @@ export async function run({ offline = false, agentDir: root, modelsFile, env = p
|
|
|
98
103
|
continue;
|
|
99
104
|
}
|
|
100
105
|
const baseUrl = provider.baseUrl ?? provider.base_url;
|
|
101
|
-
if (baseUrl === undefined || baseUrl === null || baseUrl === "")
|
|
106
|
+
if (baseUrl === undefined || baseUrl === null || baseUrl === "") {
|
|
107
|
+
noBaseUrl.push(name);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
102
110
|
if (typeof baseUrl !== "string") {
|
|
103
111
|
shape.push(`${name} (${Array.isArray(baseUrl) ? "array" : typeof baseUrl}) — baseUrl is not a string`);
|
|
104
112
|
continue;
|
|
105
113
|
}
|
|
114
|
+
// Pi interpolates `apiKey` and header values, not `baseUrl`. A `${HOST}` here
|
|
115
|
+
// is a literal string, and `new URL` accepts it — so the probe would ask DNS
|
|
116
|
+
// for a host literally named `${HOST}`, report "does not resolve", and leave
|
|
117
|
+
// the reader to work out that the interpolation they expected never happens.
|
|
118
|
+
// Only the part before the query is examined: `?$filter=x` is OData, not an
|
|
119
|
+
// environment reference, and flagging it was noise.
|
|
120
|
+
if (/\$\{[A-Za-z_][A-Za-z0-9_]*\}|\$[A-Za-z_][A-Za-z0-9_]*/.test(baseUrl.split("?")[0])) {
|
|
121
|
+
shape.push(`${name}: baseUrl contains an environment reference, but Pi interpolates only apiKey and header values — this is the literal string`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
106
124
|
let url;
|
|
107
125
|
try {
|
|
108
126
|
url = new URL(baseUrl);
|
|
@@ -208,6 +226,12 @@ export async function run({ offline = false, agentDir: root, modelsFile, env = p
|
|
|
208
226
|
|
|
209
227
|
// Built once so offline mode can emit it too: whether a baseUrl is even a
|
|
210
228
|
// string is a property of the config, not of the probe.
|
|
229
|
+
// Named, not silently dropped: a provider with a credential but no `baseUrl`
|
|
230
|
+
// is not an endpoint to probe, and Pi may have a built-in default for it — but
|
|
231
|
+
// saying only "1 endpoint(s) reachable" left the reader unable to tell whether
|
|
232
|
+
// the other four providers were fine or were never looked at.
|
|
233
|
+
const noBaseUrlNote = noBaseUrl.length ? `; ${noBaseUrl.length} provider(s) declare no baseUrl and were not probed: ${truncateList(noBaseUrl, 4)}` : "";
|
|
234
|
+
|
|
211
235
|
const shapeRow = shapeNotes.length
|
|
212
236
|
? {
|
|
213
237
|
group,
|
|
@@ -215,7 +239,8 @@ export async function run({ offline = false, agentDir: root, modelsFile, env = p
|
|
|
215
239
|
title: "baseUrl shape",
|
|
216
240
|
status: "warn",
|
|
217
241
|
detail: `${shapeNotes.join("; ")} — not probed`,
|
|
218
|
-
hint: "baseUrl has to be an absolute http(s) URL as a quoted string, like \"https://api.example.com/v1\"."
|
|
242
|
+
hint: "baseUrl has to be an absolute http(s) URL as a quoted string, like \"https://api.example.com/v1\"."
|
|
243
|
+
+ " An environment reference there is not expanded: Pi interpolates only apiKey and header values.",
|
|
219
244
|
}
|
|
220
245
|
: null;
|
|
221
246
|
|
|
@@ -228,7 +253,7 @@ export async function run({ offline = false, agentDir: root, modelsFile, env = p
|
|
|
228
253
|
status: "skip",
|
|
229
254
|
detail: shapeNotes.length
|
|
230
255
|
? `offline mode — ${planned.length} endpoint(s) not probed; ${shapeNotes.join("; ")}`
|
|
231
|
-
: `offline mode — ${planned.length} configured endpoint(s) not probed`,
|
|
256
|
+
: `offline mode — ${planned.length} configured endpoint(s) not probed${noBaseUrlNote}`,
|
|
232
257
|
hint: "Re-run without --offline to test reachability.",
|
|
233
258
|
},
|
|
234
259
|
// Emitted as a skip rather than omitted: a row that vanishes in offline
|
|
@@ -293,7 +318,9 @@ export async function run({ offline = false, agentDir: root, modelsFile, env = p
|
|
|
293
318
|
status: "skip",
|
|
294
319
|
detail: shapeNotes.length
|
|
295
320
|
? `no provider with a usable baseUrl — ${shapeNotes.join("; ")}`
|
|
296
|
-
:
|
|
321
|
+
: noBaseUrl.length
|
|
322
|
+
? `no provider with a baseUrl in models.json (${truncateList(noBaseUrl, 4)} declare${noBaseUrl.length === 1 ? "s" : ""} none)`
|
|
323
|
+
: "no provider with a baseUrl in models.json",
|
|
297
324
|
});
|
|
298
325
|
} else if (!unhealthy.length && unprobed.length) {
|
|
299
326
|
// Nothing was sent, so there is nothing to call healthy — but when some
|
|
@@ -306,13 +333,13 @@ export async function run({ offline = false, agentDir: root, modelsFile, env = p
|
|
|
306
333
|
status: "skip",
|
|
307
334
|
detail: `${answered ? `${answered} endpoint(s) reachable; ` : ""}${unprobed.map((o) => o.detail).join("; ")}`,
|
|
308
335
|
});
|
|
309
|
-
|
|
336
|
+
} else if (!unhealthy.length) {
|
|
310
337
|
results.push({
|
|
311
338
|
group,
|
|
312
339
|
id: "providers",
|
|
313
340
|
title: "providers",
|
|
314
|
-
|
|
315
|
-
detail: `${probed} endpoint(s) reachable`,
|
|
341
|
+
status: "ok",
|
|
342
|
+
detail: `${probed} endpoint(s) reachable${noBaseUrlNote}`,
|
|
316
343
|
});
|
|
317
344
|
} else {
|
|
318
345
|
results.push({
|
|
@@ -320,7 +347,7 @@ export async function run({ offline = false, agentDir: root, modelsFile, env = p
|
|
|
320
347
|
id: "providers",
|
|
321
348
|
title: "providers",
|
|
322
349
|
status: unhealthy.some((o) => o.status === "fail") ? "fail" : "warn",
|
|
323
|
-
detail: `${unhealthy.length} of ${probed} unhealthy: ${truncateList(unhealthy.map((o) => o.name), 6)}`,
|
|
350
|
+
detail: `${unhealthy.length} of ${probed} unhealthy: ${truncateList(unhealthy.map((o) => o.name), 6)}${noBaseUrlNote}`,
|
|
324
351
|
// An outcome that knows why it failed brings its own advice; the generic
|
|
325
352
|
// sentence is only for the ones that do not (a refused connection, a
|
|
326
353
|
// timeout) and for the ones that sit next to them.
|
package/src/checks/packages.js
CHANGED
|
@@ -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
|
-
|
|
279
|
-
"
|
|
280
|
-
"
|
|
281
|
-
|
|
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`));
|
package/src/checks/registry.js
CHANGED
|
@@ -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 ${
|
|
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 ${
|
|
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
|
}
|
package/src/checks/runtime.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
265
|
-
let port = Number(parsed
|
|
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
|
|
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
|
|
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
|
|
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
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
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
|
|
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
|
-
|
|
91
|
+
const wordWidth = displayWidth(word);
|
|
92
|
+
if (wordWidth > avail) {
|
|
53
93
|
flush();
|
|
54
|
-
|
|
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)
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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;
|
|
@@ -150,19 +170,62 @@ function unreadable(target, parsed) {
|
|
|
150
170
|
return { ok: false, message: `${name} could not be read: ${parsed.error}` };
|
|
151
171
|
}
|
|
152
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Identity of a file at one moment: size plus mtime, or null when it is gone.
|
|
175
|
+
*
|
|
176
|
+
* Two `--fix` processes that read the same file and then both write it leave
|
|
177
|
+
* whichever finished last as the content, computed from the older of the two
|
|
178
|
+
* reads. There is no lock, and a lock file brings its own problems (stale locks,
|
|
179
|
+
* a crash leaving one behind). This is the cheaper guard: capture the identity
|
|
180
|
+
* before reading, re-check it immediately before the write, and refuse when the
|
|
181
|
+
* file moved underneath.
|
|
182
|
+
*/
|
|
183
|
+
function fileStamp(target) {
|
|
184
|
+
try {
|
|
185
|
+
const st = fs.statSync(target);
|
|
186
|
+
return `${st.size}:${st.mtimeMs}`;
|
|
187
|
+
} catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function changedSince(target, stamp) {
|
|
193
|
+
return fileStamp(target) !== stamp;
|
|
194
|
+
}
|
|
195
|
+
|
|
153
196
|
export function applyRestoreConfig(target) {
|
|
154
197
|
// Re-read before overwriting. The decision to restore was made when the check
|
|
155
198
|
// ran, and in-session the confirmation prompt can sit open indefinitely: if the
|
|
156
199
|
// user repaired the file by hand in that window, writing the older backup over
|
|
157
200
|
// it would undo the fix.
|
|
201
|
+
const before = fileStamp(target);
|
|
158
202
|
const current = readJson(target);
|
|
159
203
|
if (current.ok) return { ok: false, message: `${path.basename(target)} is valid JSON again — nothing to restore` };
|
|
160
|
-
const
|
|
161
|
-
if (!
|
|
162
|
-
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
204
|
+
const candidates = backupsNewestFirst(target);
|
|
205
|
+
if (!candidates.length) return { ok: false, message: `no backup found for ${path.basename(target)}` };
|
|
206
|
+
// Newest first, and the first one that parses wins. Taking only the newest
|
|
207
|
+
// meant a corrupt newest backup — a copy taken while the file was already
|
|
208
|
+
// damaged — blocked the valid one sitting next to it, which is the exact
|
|
209
|
+
// situation restore exists for.
|
|
210
|
+
let backup = null;
|
|
211
|
+
const rejected = [];
|
|
212
|
+
for (const candidate of candidates) {
|
|
213
|
+
const parsed = readJson(candidate);
|
|
214
|
+
if (parsed.ok) {
|
|
215
|
+
backup = candidate;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
// `!ok` covers oversized and unreadable too, and telling the user a valid
|
|
219
|
+
// 5 MB backup "is not valid JSON" is the same false claim the checks avoid.
|
|
220
|
+
rejected.push(unreadable(candidate, parsed).message);
|
|
221
|
+
}
|
|
222
|
+
if (!backup) {
|
|
223
|
+
return { ok: false, message: `nothing to restore from: ${rejected.join("; ")}` };
|
|
224
|
+
}
|
|
225
|
+
const skipped = candidates.indexOf(backup);
|
|
226
|
+
if (changedSince(target, before)) {
|
|
227
|
+
return { ok: false, message: `${path.basename(target)} changed while checkup was running — not restoring over it. Re-run to see the current state.` };
|
|
228
|
+
}
|
|
166
229
|
const aside = copyBackup(target, "broken");
|
|
167
230
|
// The backup is valid JSON (checked above), so restoring its bytes verbatim
|
|
168
231
|
// keeps any formatting the user had. If the write itself fails after the
|
|
@@ -183,7 +246,7 @@ export function applyRestoreConfig(target) {
|
|
|
183
246
|
return {
|
|
184
247
|
ok: true,
|
|
185
248
|
changed: true,
|
|
186
|
-
message: `restored ${path.basename(target)} from ${path.basename(backup)} (broken copy kept as ${path.basename(aside)})`,
|
|
249
|
+
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
250
|
};
|
|
188
251
|
}
|
|
189
252
|
|
|
@@ -196,7 +259,8 @@ export function applyRestoreConfig(target) {
|
|
|
196
259
|
* the second one silently changed what Pi loads. The backup would have made it
|
|
197
260
|
* recoverable, but a repair that needs its backup is not a repair.
|
|
198
261
|
*/
|
|
199
|
-
export function applyDedupePackages(target) {
|
|
262
|
+
export function applyDedupePackages(target, options = {}) {
|
|
263
|
+
const before = fileStamp(target);
|
|
200
264
|
const settings = readJson(target);
|
|
201
265
|
if (!settings.ok) return unreadable(target, settings);
|
|
202
266
|
if (!Array.isArray(settings.data?.packages)) return { ok: false, message: "no packages array to dedupe" };
|
|
@@ -213,7 +277,10 @@ export function applyDedupePackages(target) {
|
|
|
213
277
|
// between — a second read would prove nothing. Backing up before the plan was
|
|
214
278
|
// known left a stray `.checkup-bak-*` beside a healthy config on runs that
|
|
215
279
|
// repaired nothing.
|
|
216
|
-
|
|
280
|
+
if (changedSince(target, before)) {
|
|
281
|
+
return { ok: false, message: `${path.basename(target)} changed while checkup was running — not writing over it. Re-run to see the current state.` };
|
|
282
|
+
}
|
|
283
|
+
const backup = backupFor(target, options.backups);
|
|
217
284
|
settings.data.packages = plan.kept;
|
|
218
285
|
writeAtomic(target, `${JSON.stringify(settings.data, null, 2)}\n`);
|
|
219
286
|
return {
|
|
@@ -258,10 +325,11 @@ function dedupePlan(packages) {
|
|
|
258
325
|
* `dirname(target)` would resolve every entry under the wrong root if this were
|
|
259
326
|
* ever pointed at a project-level settings file.
|
|
260
327
|
*/
|
|
261
|
-
export function applyPruneMissingPackages(target, root) {
|
|
328
|
+
export function applyPruneMissingPackages(target, root, options = {}) {
|
|
262
329
|
// Not defaulted: a wrong root makes every entry look missing, and this repair
|
|
263
330
|
// deletes what it cannot resolve.
|
|
264
331
|
if (!root) throw new Error("applyPruneMissingPackages needs the agent directory to resolve packages against");
|
|
332
|
+
const before = fileStamp(target);
|
|
265
333
|
const settings = readJson(target);
|
|
266
334
|
if (!settings.ok) return unreadable(target, settings);
|
|
267
335
|
if (!Array.isArray(settings.data?.packages)) return { ok: false, message: "no packages array to prune" };
|
|
@@ -288,7 +356,10 @@ export function applyPruneMissingPackages(target, root) {
|
|
|
288
356
|
|
|
289
357
|
// Same reasoning as dedupe: the read above is post-prompt, and the rest of
|
|
290
358
|
// this function is synchronous.
|
|
291
|
-
|
|
359
|
+
if (changedSince(target, before)) {
|
|
360
|
+
return { ok: false, message: `${path.basename(target)} changed while checkup was running — not writing over it. Re-run to see the current state.` };
|
|
361
|
+
}
|
|
362
|
+
const backup = backupFor(target, options.backups);
|
|
292
363
|
settings.data.packages = plan.kept;
|
|
293
364
|
writeAtomic(target, `${JSON.stringify(settings.data, null, 2)}\n`);
|
|
294
365
|
return {
|
|
@@ -328,8 +399,8 @@ function prunePlan(packages, root) {
|
|
|
328
399
|
/** Named repairs, keyed by the `fix` field a check emits. */
|
|
329
400
|
const REPAIRS = {
|
|
330
401
|
"restore-config": (target) => applyRestoreConfig(target),
|
|
331
|
-
"dedupe-packages": (target) => applyDedupePackages(target),
|
|
332
|
-
"prune-missing-packages": applyPruneMissingPackages,
|
|
402
|
+
"dedupe-packages": (target, ctx) => applyDedupePackages(target, ctx),
|
|
403
|
+
"prune-missing-packages": (target, ctx) => applyPruneMissingPackages(target, ctx?.agentDir, ctx),
|
|
333
404
|
};
|
|
334
405
|
|
|
335
406
|
|
|
@@ -344,6 +415,9 @@ const REPAIRS = {
|
|
|
344
415
|
export async function applyRepairs(findings, { agentDir } = {}) {
|
|
345
416
|
const applied = [];
|
|
346
417
|
const seen = new Set();
|
|
418
|
+
// Shared across every repair in this run: one backup per file, taken before
|
|
419
|
+
// the first write to it, so `restore` undoes the run rather than its last step.
|
|
420
|
+
const ctx = { agentDir, backups: new Map() };
|
|
347
421
|
for (const finding of findings) {
|
|
348
422
|
const key = `${finding.fix}:${finding.fixTarget}`;
|
|
349
423
|
if (seen.has(key)) continue;
|
|
@@ -355,7 +429,7 @@ export async function applyRepairs(findings, { agentDir } = {}) {
|
|
|
355
429
|
continue;
|
|
356
430
|
}
|
|
357
431
|
try {
|
|
358
|
-
applied.push(repair(finding.fixTarget,
|
|
432
|
+
applied.push(repair(finding.fixTarget, ctx));
|
|
359
433
|
} catch (e) {
|
|
360
434
|
// Scrubbed like every other error path: fs errors embed absolute paths and
|
|
361
435
|
// 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
|
|
306
|
+
return [];
|
|
295
307
|
}
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, "")
|