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 CHANGED
@@ -1,7 +1,15 @@
1
1
  # pi-checkup
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/pi-checkup.svg)](https://www.npmjs.com/package/pi-checkup)
4
+ [![CI](https://github.com/xinghe-labs/pi-checkup/actions/workflows/ci.yml/badge.svg)](https://github.com/xinghe-labs/pi-checkup/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/npm/l/pi-checkup.svg)](./LICENSE)
6
+ [![node](https://img.shields.io/node/v/pi-checkup.svg)](https://nodejs.org)
7
+ [![zero dependencies](https://img.shields.io/badge/dependencies-0-brightgreen.svg)](./package.json)
8
+
3
9
  **`pi list` tells you what you installed. `pi-checkup` tells you what actually works.**
4
10
 
11
+ ![pi-checkup report](./docs/demo.svg)
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.2",
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
  ],
@@ -34,7 +34,7 @@ function hasCredentialHeader(headers) {
34
34
  );
35
35
  }
36
36
 
37
- export async function run({ offline = false, agentDir: root, modelsFile, env = process.env, hasRuntime = 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 === "") continue;
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
- : "no provider with a baseUrl in models.json",
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
- } else if (!unhealthy.length) {
336
+ } else if (!unhealthy.length) {
310
337
  results.push({
311
338
  group,
312
339
  id: "providers",
313
340
  title: "providers",
314
- status: "ok",
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`);