pi-checkup 0.0.0-stage → 0.1.1

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/src/render.js ADDED
@@ -0,0 +1,159 @@
1
+ // Rendering. One formatter feeds both the human report and the JSON payload so
2
+ // the two can never disagree about what was found.
3
+ const MARKS = { ok: "✓", warn: "!", fail: "✗", skip: "·" };
4
+
5
+ const GROUP_ORDER = ["Registry", "Packages", "Binaries", "Network", "Config", "Runtime"];
6
+
7
+ export function summarize(results) {
8
+ const counts = { ok: 0, warn: 0, fail: 0, skip: 0 };
9
+ for (const r of results) {
10
+ // An unrecognised status would otherwise be counted in `total` and in
11
+ // nothing else, letting a malformed row render as "all clear".
12
+ const key = Object.hasOwn(counts, r.status) ? r.status : "fail";
13
+ counts[key] += 1;
14
+ }
15
+ return { total: results.length, ...counts };
16
+ }
17
+
18
+ /** Machine-readable payload for `/checkup json`. */
19
+ export function toJson(results, extra = {}) {
20
+ return {
21
+ summary: summarize(results),
22
+ ...extra,
23
+ checks: results.map(({ fix, fixTarget, ...rest }) => rest),
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Greedy word wrap. `indent` is the column the text starts at, so every line has
29
+ * the same available width and continuation lines line up under the text they
30
+ * continue.
31
+ *
32
+ * A token longer than the whole column — a URL, a hash, a deep path — is cut into
33
+ * chunks rather than left to overflow: the terminal would wrap it anyway, and it
34
+ * would do so with no indent, which breaks the column alignment that makes the
35
+ * report scannable.
36
+ */
37
+ function wrapText(text, width, indent) {
38
+ const body = String(text ?? "");
39
+ if (!body) return [""];
40
+ const avail = Math.max(8, width - indent);
41
+ if (body.length <= avail) return [body];
42
+
43
+ const lines = [];
44
+ let line = "";
45
+ const flush = () => {
46
+ if (line) {
47
+ lines.push(line);
48
+ line = "";
49
+ }
50
+ };
51
+ for (const word of body.split(/\s+/).filter(Boolean)) {
52
+ if (word.length > avail) {
53
+ flush();
54
+ for (let i = 0; i < word.length; i += avail) lines.push(word.slice(i, i + avail));
55
+ continue;
56
+ }
57
+ if (!line) line = word;
58
+ else if (line.length + 1 + word.length <= avail) line += ` ${word}`;
59
+ else {
60
+ flush();
61
+ line = word;
62
+ }
63
+ }
64
+ flush();
65
+ return lines.length ? lines : [body];
66
+ }
67
+
68
+ /**
69
+ * Human report. Plain text on purpose: it renders identically in the TUI, over
70
+ * RPC, and in a CI log.
71
+ *
72
+ * Details are wrapped rather than left to the terminal, because the longest of
73
+ * them (a list of unreachable package commands) runs well past 80 columns and
74
+ * terminal wrapping breaks it mid-word with no indent, which reads as a new
75
+ * finding. `width` is supplied by the CLI from the real terminal; the default
76
+ * keeps RPC and log output stable regardless of who is reading it.
77
+ */
78
+ export function renderReport(results, { verbose = false, hintCommand = "/checkup verbose", width = 80 } = {}) {
79
+ const byGroup = new Map();
80
+ for (const r of results) {
81
+ const g = r.group ?? "Other";
82
+ if (!byGroup.has(g)) byGroup.set(g, []);
83
+ byGroup.get(g).push(r);
84
+ }
85
+ const groups = [...byGroup.keys()].sort((a, b) => {
86
+ const ia = GROUP_ORDER.indexOf(a);
87
+ const ib = GROUP_ORDER.indexOf(b);
88
+ return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
89
+ });
90
+
91
+ // Wide enough for the longest title in this report, so one long name does not
92
+ // push a single row's detail out of alignment with every other row. A title
93
+ // longer than the cap gets its detail on its own line rather than shoving it
94
+ // past the column every wrapped continuation line uses.
95
+ const titleWidth = Math.min(22, Math.max(8, ...results.map((r) => r.title.length)));
96
+ // Below this width the title column plus one word of detail leaves a gutter
97
+ // too narrow to read ("package dirs 2 missing from" then two words a line), so
98
+ // the detail moves under the title instead of beside it.
99
+ const stacked = width < 56;
100
+ const detailColumn = stacked ? 6 : 4 + titleWidth + 1;
101
+
102
+ const lines = [];
103
+ for (const g of groups) {
104
+ lines.push(g);
105
+ for (const r of byGroup.get(g)) {
106
+ const mark = ` ${MARKS[r.status] ?? "?"} `;
107
+ const title = r.title.padEnd(titleWidth);
108
+ if (stacked || r.title.length > titleWidth) {
109
+ // The title gets the same treatment as the detail: a name that does not
110
+ // fit would otherwise set the width for the whole report.
111
+ const titleParts = wrapText(r.title, width, mark.length);
112
+ lines.push(`${mark}${titleParts[0]}`);
113
+ for (const p of titleParts.slice(1)) lines.push("".padEnd(mark.length) + p);
114
+ for (const p of wrapText(r.detail, width, detailColumn)) lines.push("".padEnd(detailColumn) + p);
115
+ } else {
116
+ const parts = wrapText(r.detail, width, detailColumn);
117
+ lines.push(`${mark}${title} ${parts[0]}`);
118
+ for (const p of parts.slice(1)) lines.push("".padEnd(detailColumn) + p);
119
+ }
120
+ // Hints are shown for anything the user can act on, including an ok row
121
+ // whose detail carries a caveat.
122
+ if (verbose && r.hint) {
123
+ for (const p of wrapText(r.hint, width, detailColumn)) {
124
+ lines.push("".padEnd(detailColumn) + p);
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ const s = summarize(results);
131
+ lines.push("");
132
+ const warnWord = s.warn === 1 ? "warning" : "warnings";
133
+ lines.push(`${s.total} checks: ${s.ok} ok, ${s.warn} ${warnWord}, ${s.fail} failed${s.skip ? `, ${s.skip} skipped` : ""}`);
134
+ // Any hint, on any row. Tying this to non-ok rows meant a report whose only
135
+ // finding carried no hint never mentioned --verbose at all, so the user had
136
+ // no way to learn the flag exists.
137
+ if (!verbose && results.some((r) => r.hint)) {
138
+ lines.push(`Run \`${hintCommand}\` to show what to do about each finding.`);
139
+ }
140
+ return lines.join("\n");
141
+ }
142
+
143
+ /** One-line verdict suitable for a status bar or a commit message. */
144
+ export function renderVerdict(results) {
145
+ const s = summarize(results);
146
+ // The skipped count travels with every verdict, not just the clean one. It
147
+ // used to disappear the moment anything was non-ok, so `! 1 warning` was the
148
+ // last line of a report in which 12 of 15 checks had looked at nothing — the
149
+ // same false confidence the branches below exist to avoid, one level up.
150
+ const tail = s.skip ? `, ${s.skip} skipped` : "";
151
+ if (s.fail) return `✗ ${s.fail} failed${tail}`;
152
+ if (s.warn) return `! ${s.warn} ${s.warn === 1 ? "warning" : "warnings"}${tail}`;
153
+ // "all clear" over a report where nothing could be checked is the exact false
154
+ // confidence this package exists to remove, so the skipped count is part of
155
+ // the verdict rather than a footnote.
156
+ if (!s.ok) return `· nothing verified (${s.skip} skipped)`;
157
+ if (s.skip) return `✓ ${s.ok} ok, ${s.skip} skipped`;
158
+ return "✓ all clear";
159
+ }
package/src/repair.js ADDED
@@ -0,0 +1,367 @@
1
+ // The only module in this package that writes to disk.
2
+ //
3
+ // Everything under ./checks/ reads and reports; repairs live here so the write
4
+ // surface stays small, auditable, and shared by the in-session `/checkup fix`
5
+ // and the standalone CLI. Every repair backs up the file it is about to change,
6
+ // and none of them deletes a package or disables an extension.
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { isMissing, newestBackup, readJson, redactSource, resolvePackageDir, scrubCredentials, sourceOf } from "./util.js";
10
+
11
+ function stamp() {
12
+ // Milliseconds are kept: two repairs in the same second would otherwise
13
+ // write the same backup name and the second copy would clobber the first.
14
+ return new Date().toISOString().replace(/[:.]/g, "-").slice(0, 23);
15
+ }
16
+
17
+ /**
18
+ * Copy `target` to a fresh timestamped sibling, never overwriting an existing one.
19
+ *
20
+ * Two repairs can land in the same millisecond, and two concurrent checkup
21
+ * processes can collide on the name entirely; COPYFILE_EXCL turns that from a
22
+ * silent loss of a recovery point into a retry.
23
+ */
24
+ function copyBackup(target, label = "checkup-bak") {
25
+ const base = `${target}.${label}-${stamp()}`;
26
+ for (let i = 0; i < 50; i++) {
27
+ const candidate = i === 0 ? base : `${base}-${i}`;
28
+ try {
29
+ fs.copyFileSync(target, candidate, fs.constants.COPYFILE_EXCL);
30
+ return candidate;
31
+ } catch (e) {
32
+ if (e?.code !== "EEXIST") throw e;
33
+ }
34
+ }
35
+ throw new Error(`could not find a free backup name for ${path.basename(target)}`);
36
+ }
37
+
38
+ /** Flush a directory entry so a rename survives a power loss. Best effort. */
39
+ function syncDir(dir) {
40
+ let fd;
41
+ try {
42
+ fd = fs.openSync(dir, "r");
43
+ fs.fsyncSync(fd);
44
+ } catch {
45
+ // Not supported on Windows, and not worth failing a successful repair over.
46
+ } finally {
47
+ if (fd !== undefined) {
48
+ try {
49
+ fs.closeSync(fd);
50
+ } catch {
51
+ /* already gone */
52
+ }
53
+ }
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Write a file without ever leaving a half-written one behind.
59
+ *
60
+ * `writeFileSync` truncates first, so a crash or a full disk mid-write leaves a
61
+ * corrupt config — the exact failure this package exists to detect, caused by
62
+ * the repair. Writing a sibling temp file and renaming over the target is
63
+ * atomic on POSIX and on Windows (rename replaces an existing file there too).
64
+ * The original mode is carried over, because a models.json that was chmod 600
65
+ * must not come back 644.
66
+ *
67
+ * The temp file is fsynced before the rename and the directory after it, so a
68
+ * power loss cannot leave the rename visible while the new content is not.
69
+ */
70
+ export function writeAtomic(target, content) {
71
+ // A dotfiles-style install keeps settings.json as a symlink into a repo. A
72
+ // plain rename over `target` would replace the symlink with a regular file and
73
+ // silently detach the config from its repository, so the write lands on the
74
+ // real file instead.
75
+ let real = target;
76
+ try {
77
+ real = fs.realpathSync(target);
78
+ } catch {
79
+ // Missing or dangling: the target itself is the only path there is.
80
+ }
81
+
82
+ let mode;
83
+ try {
84
+ mode = fs.statSync(real).mode;
85
+ } catch {
86
+ mode = undefined;
87
+ }
88
+
89
+ const base = `${real}.checkup-tmp-${stamp()}`;
90
+ let fd;
91
+ let tmp;
92
+ for (let i = 0; i < 50 && fd === undefined; i++) {
93
+ const candidate = i === 0 ? base : `${base}-${i}`;
94
+ try {
95
+ fd = fs.openSync(candidate, "wx", mode ?? 0o600);
96
+ tmp = candidate;
97
+ } catch (e) {
98
+ if (e?.code !== "EEXIST") throw e;
99
+ }
100
+ }
101
+ if (fd === undefined) throw new Error(`could not create a temp file next to ${path.basename(real)}`);
102
+
103
+ try {
104
+ fs.writeFileSync(fd, content);
105
+ fs.fsyncSync(fd);
106
+ // A short write (ENOSPC on a full volume, EIO on a dying disk) can leave the
107
+ // temp file with fewer bytes than were handed in — writeFileSync's throw is
108
+ // not guaranteed to surface every partial-write shape. Renaming a truncated
109
+ // file over the config would turn a repairable fault into a corrupt config,
110
+ // so the byte count is verified before the rename, not trusted after it.
111
+ if (fs.fstatSync(fd).size !== Buffer.byteLength(content)) {
112
+ throw new Error(`temp file is truncated (${fs.fstatSync(fd).size} of ${Buffer.byteLength(content)} bytes)`);
113
+ }
114
+ fs.closeSync(fd);
115
+ fd = undefined;
116
+ if (mode !== undefined) fs.chmodSync(tmp, mode);
117
+ fs.renameSync(tmp, real);
118
+ syncDir(path.dirname(real));
119
+ } catch (e) {
120
+ if (fd !== undefined) {
121
+ try {
122
+ fs.closeSync(fd);
123
+ } catch {
124
+ /* already closed */
125
+ }
126
+ }
127
+ try {
128
+ fs.rmSync(tmp, { force: true });
129
+ } catch {
130
+ // Nothing useful to do; the rename failure below is the real error.
131
+ }
132
+ throw e;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Replace a config file that is no longer valid JSON with its newest backup.
138
+ * The broken copy is kept beside it, so this is reversible by hand.
139
+ */
140
+ /**
141
+ * Why a config file could not be used, in the terms the user needs.
142
+ *
143
+ * "is not valid JSON" for a file that simply is not there sends the reader
144
+ * looking for a syntax error that does not exist.
145
+ */
146
+ function unreadable(target, parsed) {
147
+ const name = path.basename(target);
148
+ if (parsed.missing) return { ok: false, message: `${name} does not exist` };
149
+ if (parsed.invalid) return { ok: false, message: `${name}: ${parsed.error}` };
150
+ return { ok: false, message: `${name} could not be read: ${parsed.error}` };
151
+ }
152
+
153
+ export function applyRestoreConfig(target) {
154
+ // Re-read before overwriting. The decision to restore was made when the check
155
+ // ran, and in-session the confirmation prompt can sit open indefinitely: if the
156
+ // user repaired the file by hand in that window, writing the older backup over
157
+ // it would undo the fix.
158
+ const current = readJson(target);
159
+ if (current.ok) return { ok: false, message: `${path.basename(target)} is valid JSON again — nothing to restore` };
160
+ const backup = newestBackup(target);
161
+ if (!backup) return { ok: false, message: `no backup found for ${path.basename(target)}` };
162
+ const parsed = readJson(backup);
163
+ // `!ok` covers oversized and unreadable too, and telling the user a valid
164
+ // 5 MB backup "is not valid JSON" is the same false claim the checks avoid.
165
+ if (!parsed.ok) return { ok: false, message: `nothing to restore from: ${unreadable(backup, parsed).message}` };
166
+ const aside = copyBackup(target, "broken");
167
+ // The backup is valid JSON (checked above), so restoring its bytes verbatim
168
+ // keeps any formatting the user had. If the write itself fails after the
169
+ // rename (the directory sync is the one step that can), the backup made two
170
+ // lines ago is copied back over the target — rethrowing alone would leave the
171
+ // config in whatever state the interrupted write left it in.
172
+ try {
173
+ writeAtomic(target, fs.readFileSync(backup, "utf8"));
174
+ } catch (e) {
175
+ try {
176
+ fs.copyFileSync(backup, target);
177
+ } catch {
178
+ // The rollback failed too; surface the original error, which is what the
179
+ // user needs, and let the kept broken copy + backup be the recovery path.
180
+ }
181
+ throw e;
182
+ }
183
+ return {
184
+ ok: true,
185
+ changed: true,
186
+ message: `restored ${path.basename(target)} from ${path.basename(backup)} (broken copy kept as ${path.basename(aside)})`,
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Collapse duplicate `packages` entries, preserving the first occurrence.
192
+ *
193
+ * Only an entry byte-identical to the one already kept is dropped. Two entries
194
+ * for the same source in different shapes — `"npm:a"` next to
195
+ * `{"source": "npm:a", "enabled": false}` — carry different intent, and deleting
196
+ * the second one silently changed what Pi loads. The backup would have made it
197
+ * recoverable, but a repair that needs its backup is not a repair.
198
+ */
199
+ export function applyDedupePackages(target) {
200
+ const settings = readJson(target);
201
+ if (!settings.ok) return unreadable(target, settings);
202
+ if (!Array.isArray(settings.data?.packages)) return { ok: false, message: "no packages array to dedupe" };
203
+
204
+ const plan = dedupePlan(settings.data.packages);
205
+ const suffix = (mixed) =>
206
+ mixed ? ` (${mixed} entr${mixed === 1 ? "y" : "ies"} share a source but differ in shape and were left alone)` : "";
207
+ // `changed` is separate from `ok`: a repair can succeed at doing nothing, and
208
+ // the caller must not tell the user to restart Pi for a file it never wrote.
209
+ if (!plan.removed) return { ok: true, changed: false, message: `nothing to dedupe${suffix(plan.mixed)}` };
210
+
211
+ // The read above already happened after the confirmation prompt, and every
212
+ // step from here to writeAtomic is synchronous, so the file cannot change in
213
+ // between — a second read would prove nothing. Backing up before the plan was
214
+ // known left a stray `.checkup-bak-*` beside a healthy config on runs that
215
+ // repaired nothing.
216
+ const backup = copyBackup(target);
217
+ settings.data.packages = plan.kept;
218
+ writeAtomic(target, `${JSON.stringify(settings.data, null, 2)}\n`);
219
+ return {
220
+ ok: true,
221
+ changed: true,
222
+ message: `removed ${plan.removed} duplicate entr${plan.removed === 1 ? "y" : "ies"} from ${path.basename(target)}${suffix(plan.mixed)} (backup: ${path.basename(backup)})`,
223
+ };
224
+ }
225
+
226
+ /** Which entries a dedupe would keep, without touching the file. */
227
+ function dedupePlan(packages) {
228
+ const seen = new Map();
229
+ const kept = [];
230
+ let removed = 0;
231
+ let mixed = 0;
232
+ for (const spec of packages) {
233
+ const source = sourceOf(spec);
234
+ if (typeof source !== "string") {
235
+ kept.push(spec);
236
+ continue;
237
+ }
238
+ if (!seen.has(source)) {
239
+ seen.set(source, spec);
240
+ kept.push(spec);
241
+ continue;
242
+ }
243
+ if (JSON.stringify(seen.get(source)) === JSON.stringify(spec)) {
244
+ removed++;
245
+ continue;
246
+ }
247
+ mixed++;
248
+ kept.push(spec);
249
+ }
250
+ return { kept, removed, mixed };
251
+ }
252
+
253
+ /**
254
+ * Drop settings entries whose package directory is not on disk.
255
+ *
256
+ * `root` is the agent directory Pi resolves `npm:` and `./` sources against. It
257
+ * is passed in rather than derived from the target path: guessing it from
258
+ * `dirname(target)` would resolve every entry under the wrong root if this were
259
+ * ever pointed at a project-level settings file.
260
+ */
261
+ export function applyPruneMissingPackages(target, root) {
262
+ // Not defaulted: a wrong root makes every entry look missing, and this repair
263
+ // deletes what it cannot resolve.
264
+ if (!root) throw new Error("applyPruneMissingPackages needs the agent directory to resolve packages against");
265
+ const settings = readJson(target);
266
+ if (!settings.ok) return unreadable(target, settings);
267
+ if (!Array.isArray(settings.data?.packages)) return { ok: false, message: "no packages array to prune" };
268
+
269
+ const plan = prunePlan(settings.data.packages, root);
270
+ if (!plan.removed.length) return { ok: true, changed: false, message: "nothing to prune" };
271
+ // A wrong agent directory — a mistyped PI_CODING_AGENT_DIR, a second profile,
272
+ // a Pi layout that keeps packages elsewhere — makes every entry look missing.
273
+ // Emptying the list would leave Pi booting with no packages and no error, so
274
+ // the repair refuses instead of trusting its own resolution blindly.
275
+ //
276
+ // The test is the state after the write, not a count of what could be
277
+ // resolved: comparing `removed` with the number of resolvable entries made a
278
+ // list of one vanished local package plus one `git:` source refuse to prune,
279
+ // because the git source is kept and therefore never counted — the list would
280
+ // not have been empty at all, and the message blamed a mistyped
281
+ // PI_CODING_AGENT_DIR for a repair that was simply never attempted.
282
+ if (!plan.kept.length) {
283
+ return {
284
+ ok: false,
285
+ message: `all ${plan.removed.length} package entr${plan.removed.length === 1 ? "y looks" : "ies look"} missing — refusing to empty the list. Check that the agent directory is the one Pi uses (PI_CODING_AGENT_DIR).`,
286
+ };
287
+ }
288
+
289
+ // Same reasoning as dedupe: the read above is post-prompt, and the rest of
290
+ // this function is synchronous.
291
+ const backup = copyBackup(target);
292
+ settings.data.packages = plan.kept;
293
+ writeAtomic(target, `${JSON.stringify(settings.data, null, 2)}\n`);
294
+ return {
295
+ ok: true,
296
+ changed: true,
297
+ // A package source can be a URL with userinfo in it, and this message goes
298
+ // to the terminal and the TUI notification. The check modules redact their
299
+ // sources; this one prints the same strings.
300
+ message: `removed ${plan.removed.length} entr${plan.removed.length === 1 ? "y" : "ies"} with no directory on disk (${plan.removed.slice(0, 3).map(redactSource).join(", ")}${plan.removed.length > 3 ? ", ..." : ""}); backup: ${path.basename(backup)}`,
301
+ };
302
+ }
303
+
304
+ /** Which entries a prune would drop, without touching the file. */
305
+ function prunePlan(packages, root) {
306
+ const kept = [];
307
+ const removed = [];
308
+ for (const spec of packages) {
309
+ const source = sourceOf(spec);
310
+ const dir = resolvePackageDir(source, root);
311
+ // Keep anything that cannot be resolved statically: git:/https: sources are
312
+ // legitimate and dropping them would uninstall a working package.
313
+ if (!dir) {
314
+ kept.push(spec);
315
+ continue;
316
+ }
317
+ if (!isMissing(dir)) {
318
+ // Present, or present-but-unreadable. Only a path that is genuinely gone
319
+ // justifies deleting the user's settings entry.
320
+ kept.push(spec);
321
+ continue;
322
+ }
323
+ removed.push(String(source));
324
+ }
325
+ return { kept, removed };
326
+ }
327
+
328
+ /** Named repairs, keyed by the `fix` field a check emits. */
329
+ const REPAIRS = {
330
+ "restore-config": (target) => applyRestoreConfig(target),
331
+ "dedupe-packages": (target) => applyDedupePackages(target),
332
+ "prune-missing-packages": applyPruneMissingPackages,
333
+ };
334
+
335
+
336
+ /**
337
+ * Apply every fixable finding, at most once per (repair, file) pair.
338
+ * A repair that throws is reported, not propagated — one failed write must not
339
+ * stop the remaining repairs.
340
+ *
341
+ * `agentDir` is threaded through to the prune repair so package directories are
342
+ * resolved against the same root the checks inspected.
343
+ */
344
+ export async function applyRepairs(findings, { agentDir } = {}) {
345
+ const applied = [];
346
+ const seen = new Set();
347
+ for (const finding of findings) {
348
+ const key = `${finding.fix}:${finding.fixTarget}`;
349
+ if (seen.has(key)) continue;
350
+ seen.add(key);
351
+
352
+ const repair = REPAIRS[finding.fix];
353
+ if (!repair) {
354
+ applied.push({ ok: false, message: `${finding.title}: unknown repair "${finding.fix}"` });
355
+ continue;
356
+ }
357
+ try {
358
+ applied.push(repair(finding.fixTarget, agentDir));
359
+ } catch (e) {
360
+ // Scrubbed like every other error path: fs errors embed absolute paths and
361
+ // the prune guard's throw can quote a package source, which may carry a
362
+ // token. This string reaches --fix stdout and the /checkup notification.
363
+ applied.push({ ok: false, message: `${finding.title}: ${scrubCredentials(String(e?.message ?? e))}` });
364
+ }
365
+ }
366
+ return applied;
367
+ }