pi-checkup 0.1.2 → 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 +19 -0
- package/docs/demo.svg +44 -0
- package/package.json +4 -2
- package/src/checks/network.js +36 -9
- package/src/repair.js +35 -0
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
|
|
@@ -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/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/repair.js
CHANGED
|
@@ -170,11 +170,35 @@ function unreadable(target, parsed) {
|
|
|
170
170
|
return { ok: false, message: `${name} could not be read: ${parsed.error}` };
|
|
171
171
|
}
|
|
172
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
|
+
|
|
173
196
|
export function applyRestoreConfig(target) {
|
|
174
197
|
// Re-read before overwriting. The decision to restore was made when the check
|
|
175
198
|
// ran, and in-session the confirmation prompt can sit open indefinitely: if the
|
|
176
199
|
// user repaired the file by hand in that window, writing the older backup over
|
|
177
200
|
// it would undo the fix.
|
|
201
|
+
const before = fileStamp(target);
|
|
178
202
|
const current = readJson(target);
|
|
179
203
|
if (current.ok) return { ok: false, message: `${path.basename(target)} is valid JSON again — nothing to restore` };
|
|
180
204
|
const candidates = backupsNewestFirst(target);
|
|
@@ -199,6 +223,9 @@ export function applyRestoreConfig(target) {
|
|
|
199
223
|
return { ok: false, message: `nothing to restore from: ${rejected.join("; ")}` };
|
|
200
224
|
}
|
|
201
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
|
+
}
|
|
202
229
|
const aside = copyBackup(target, "broken");
|
|
203
230
|
// The backup is valid JSON (checked above), so restoring its bytes verbatim
|
|
204
231
|
// keeps any formatting the user had. If the write itself fails after the
|
|
@@ -233,6 +260,7 @@ export function applyRestoreConfig(target) {
|
|
|
233
260
|
* recoverable, but a repair that needs its backup is not a repair.
|
|
234
261
|
*/
|
|
235
262
|
export function applyDedupePackages(target, options = {}) {
|
|
263
|
+
const before = fileStamp(target);
|
|
236
264
|
const settings = readJson(target);
|
|
237
265
|
if (!settings.ok) return unreadable(target, settings);
|
|
238
266
|
if (!Array.isArray(settings.data?.packages)) return { ok: false, message: "no packages array to dedupe" };
|
|
@@ -249,6 +277,9 @@ export function applyDedupePackages(target, options = {}) {
|
|
|
249
277
|
// between — a second read would prove nothing. Backing up before the plan was
|
|
250
278
|
// known left a stray `.checkup-bak-*` beside a healthy config on runs that
|
|
251
279
|
// repaired nothing.
|
|
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
|
+
}
|
|
252
283
|
const backup = backupFor(target, options.backups);
|
|
253
284
|
settings.data.packages = plan.kept;
|
|
254
285
|
writeAtomic(target, `${JSON.stringify(settings.data, null, 2)}\n`);
|
|
@@ -298,6 +329,7 @@ export function applyPruneMissingPackages(target, root, options = {}) {
|
|
|
298
329
|
// Not defaulted: a wrong root makes every entry look missing, and this repair
|
|
299
330
|
// deletes what it cannot resolve.
|
|
300
331
|
if (!root) throw new Error("applyPruneMissingPackages needs the agent directory to resolve packages against");
|
|
332
|
+
const before = fileStamp(target);
|
|
301
333
|
const settings = readJson(target);
|
|
302
334
|
if (!settings.ok) return unreadable(target, settings);
|
|
303
335
|
if (!Array.isArray(settings.data?.packages)) return { ok: false, message: "no packages array to prune" };
|
|
@@ -324,6 +356,9 @@ export function applyPruneMissingPackages(target, root, options = {}) {
|
|
|
324
356
|
|
|
325
357
|
// Same reasoning as dedupe: the read above is post-prompt, and the rest of
|
|
326
358
|
// this function is synchronous.
|
|
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
|
+
}
|
|
327
362
|
const backup = backupFor(target, options.backups);
|
|
328
363
|
settings.data.packages = plan.kept;
|
|
329
364
|
writeAtomic(target, `${JSON.stringify(settings.data, null, 2)}\n`);
|