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.
@@ -0,0 +1,315 @@
1
+ // Config checks — is the configuration well-formed, backed up, and free of
2
+ // entries that silently cancel each other out?
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { exists, isMissing, makeRow, newestBackup, readJson, redactSource, sourceOf, truncateList } from "../util.js";
6
+
7
+ export const group = "Config";
8
+
9
+ // Pi interpolates $NAME and ${NAME} in models.json credential fields — `apiKey`
10
+ // and header values — and nowhere else. Scanning whole file text instead meant
11
+ // every literal dollar sign in a comment or note was reported as a missing
12
+ // environment variable, so the check cried wolf on healthy configs.
13
+ const ENV_REF = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
14
+
15
+ /** The models.json strings Pi will actually run through env interpolation. */
16
+ function interpolatedFields(models) {
17
+ const out = [];
18
+ const providers = models?.providers ?? models ?? {};
19
+ if (!providers || typeof providers !== "object") return out;
20
+ for (const [name, provider] of Object.entries(providers)) {
21
+ if (!provider || typeof provider !== "object") continue;
22
+ // The provider name travels with the value: "referenced but unset: FOO"
23
+ // without one leaves the reader to grep models.json for the variable before
24
+ // they can tell which provider is about to fail.
25
+ const key = provider.apiKey ?? provider.api_key;
26
+ if (typeof key === "string") out.push({ provider: name, value: key });
27
+ if (provider.headers && typeof provider.headers === "object") {
28
+ for (const value of Object.values(provider.headers)) {
29
+ if (typeof value === "string") out.push({ provider: name, value });
30
+ }
31
+ }
32
+ }
33
+ return out;
34
+ }
35
+
36
+ export async function run({
37
+ cwd,
38
+ agentDir: root,
39
+ settingsFile,
40
+ modelsFile,
41
+ projectSettingsFile = path.join(cwd ?? process.cwd(), ".pi", "settings.json"),
42
+ settings,
43
+ settingsLoaded,
44
+ settingsSource,
45
+ hasRuntime,
46
+ env = process.env,
47
+ }) {
48
+ const results = [];
49
+ const row = makeRow(group);
50
+ // The CLI cannot run a slash command, so a hint that names one is useless to
51
+ // the exact reader the CLI exists for: someone whose Pi will not boot.
52
+ const fixCommand = hasRuntime ? "/checkup fix" : "npx pi-checkup --fix --yes";
53
+
54
+ const files = [
55
+ { label: "settings.json", file: settingsFile, required: true },
56
+ { label: "models.json", file: modelsFile, required: false },
57
+ { label: "project settings", file: projectSettingsFile, required: false },
58
+ ];
59
+
60
+ // --- validity ----------------------------------------------------------
61
+ for (const { label, file, required } of files) {
62
+ // `isMissing`, not `exists`: a permission-denied settings.json used to be
63
+ // reported as "not found — Pi is running on defaults", which is a claim
64
+ // about a file nobody managed to open.
65
+ if (isMissing(file)) {
66
+ // `skip` is this package's word for "there was nothing to inspect", used
67
+ // the same way in every other module. An absent optional file is that,
68
+ // not a pass: reporting it as ok made the same absence read as `ok` here
69
+ // and `skip` in packages.js for no reason a reader could recover.
70
+ //
71
+ // A missing settings.json is not a failure either. Pi boots with
72
+ // built-in defaults and writes the file the first time you change a
73
+ // setting, so failing here turned a fresh install — or a container
74
+ // running `npx pi-checkup` before its first prompt — red for a state
75
+ // that works.
76
+ results.push(
77
+ required
78
+ ? row(`file:${label}`, label, "skip", `${file} not found — Pi is running on defaults`, {
79
+ hint: "Pi writes this file the first time you change a setting.",
80
+ })
81
+ : row(`file:${label}`, label, "skip", "none"),
82
+ );
83
+ continue;
84
+ }
85
+ const parsed = readJson(file);
86
+ if (parsed.ok) {
87
+ // In-session, settings come from the running process and the file on disk
88
+ // is not what the checks actually saw. Saying so prevents the report from
89
+ // implying it validated a file it never read.
90
+ const fromRuntime = label === "settings.json" && settingsSource === "runtime";
91
+ results.push(row(`file:${label}`, label, "ok", fromRuntime ? "valid JSON (values read from the running session)" : "valid JSON"));
92
+ continue;
93
+ }
94
+ // A parse failure and an unreadable file are different faults, and only one
95
+ // of them is corruption. `invalid` is set by readJson for a genuine parse
96
+ // error; a file over the size cap is reported as unreadable rather than as
97
+ // broken, because "not valid JSON" over a perfectly valid 5 MB file is a
98
+ // claim the check never established — and it offered to "restore" it.
99
+ const corrupt = parsed.invalid === true;
100
+ const backup = newestBackup(file);
101
+ let detail = `${file} could not be inspected: ${parsed.error}`;
102
+ let hint = "Nothing in this file was checked. Inspect it by hand if Pi misbehaves.";
103
+ if (corrupt) {
104
+ detail = `${file}: ${parsed.error}`;
105
+ // The full path, not the basename: a user reading this from another
106
+ // directory cannot find a bare filename.
107
+ // The parse error names a line and a column, so the hint can point at the
108
+ // file rather than at nothing: "fix it by hand" with no location left the
109
+ // reader to bisect a 40 KB models.json by eye.
110
+ hint = backup
111
+ ? `A backup exists at ${backup}. Run \`${fixCommand}\` to restore it.`
112
+ : `No backup to restore. Open ${file} at the line in the message above and fix the syntax — a trailing comma or an unquoted value is the usual cause.`;
113
+ }
114
+ const restorable = corrupt && Boolean(backup);
115
+ results.push(
116
+ row(`file:${label}`, label, corrupt ? "fail" : "warn", detail, {
117
+ hint,
118
+ fix: restorable ? "restore-config" : undefined,
119
+ fixTarget: restorable ? file : undefined,
120
+ }),
121
+ );
122
+ }
123
+
124
+ // --- backups -----------------------------------------------------------
125
+ // Having no backup is normal: one is written before the first repair, so a
126
+ // fresh install legitimately has none. A config edited since its newest
127
+ // backup is not a fault either — editing config is the point — so it is
128
+ // reported as detail on a passing row, not as a warning that can never be
129
+ // cleared. A broken file is already a failure above, and that is where the
130
+ // question "can I recover?" actually matters.
131
+ const stale = [];
132
+ let backedUp = 0;
133
+ for (const { label, file } of files) {
134
+ if (!exists(file)) continue;
135
+ const backup = newestBackup(file);
136
+ if (!backup) continue;
137
+ backedUp++;
138
+ try {
139
+ if (fs.statSync(file).mtimeMs > fs.statSync(backup).mtimeMs) stale.push(label);
140
+ } catch {
141
+ // Raced with another writer; say nothing rather than guess.
142
+ }
143
+ }
144
+ if (backedUp) {
145
+ // The caveat goes in the detail, not the hint: the default report only shows
146
+ // hints for non-ok rows, so a load-bearing hint on an ok row was invisible
147
+ // unless the user already knew to pass --verbose.
148
+ results.push(
149
+ row(
150
+ "backups",
151
+ "backups",
152
+ "ok",
153
+ stale.length
154
+ ? `${backedUp} file(s) backed up; edited since the newest backup: ${stale.join(", ")}`
155
+ : `${backedUp} file(s) backed up`,
156
+ {
157
+ // The named path is the point: "copy it somewhere safe" with no `it` left
158
+ // the reader to work out which file the paragraph was about. The count of
159
+ // backups is not claimed: "both backups are older" was hardcoded and
160
+ // wrong as soon as one file was stale and the other was not — and a
161
+ // file may have any number of backups.
162
+ hint: stale.length
163
+ ? `The newest backup of ${stale.join(" and ")} predates the current file${stale.length > 1 ? "s" : ""}, so restoring it would discard the edits made since. Copy ${stale
164
+ .map((label) => files.find((f) => f.label === label)?.file ?? label)
165
+ .join(" and ")} somewhere you can restore from before the next edit.`
166
+ : undefined,
167
+ },
168
+ ),
169
+ );
170
+ } else {
171
+ results.push(
172
+ row("backups", "backups", "skip", "none yet — created before the first repair", {
173
+ hint: `\`${fixCommand}\` writes one beside the file it changes.`,
174
+ }),
175
+ );
176
+ }
177
+
178
+ // --- duplicate package entries -----------------------------------------
179
+ // Two identical sources do not fail loudly; the package just loads twice and
180
+ // the second registration can shadow the first.
181
+ const packageList = settings?.packages;
182
+ if (!Array.isArray(packageList)) {
183
+ // A row that disappears reads as "fine" to anyone counting rows, so the
184
+ // absence of something to compare is stated rather than implied.
185
+ let why = "settings.json `packages` is not a list";
186
+ // "not found" and "could not be read" are different states, and packages.js
187
+ // already words them differently. One state should not get two explanations
188
+ // in one report.
189
+ let unreadable = false;
190
+ if (!settingsLoaded) {
191
+ unreadable = Boolean(settingsFile) && !isMissing(settingsFile);
192
+ why = unreadable ? "settings.json could not be inspected" : "settings.json not found — nothing to compare";
193
+ } else if (settings === null || typeof settings !== "object" || Array.isArray(settings)) {
194
+ // Valid JSON, wrong shape. The row above says "valid JSON", so saying
195
+ // "could not be inspected" here contradicted it.
196
+ unreadable = true;
197
+ why = "settings.json is valid JSON but not an object";
198
+ } else if (packageList === undefined) why = "no package list in settings.json";
199
+ // A `packages` value that is present and not a list is a fault in the
200
+ // config, not an absence — packages.js reports the same value as `warn`,
201
+ // and one defect with two statuses made the report contradict itself. A
202
+ // file that exists but would not parse is the same case: packages.js calls
203
+ // it `warn` too, so `skip` here contradicted the row two groups up.
204
+ const faulty = unreadable || (settings !== undefined && settings !== null && packageList !== undefined);
205
+ results.push(row("duplicates", "duplicate packages", faulty ? "warn" : "skip", why));
206
+ } else {
207
+ // Two entries for one source are not always the same entry twice:
208
+ // `"npm:a"` next to `{"source": "npm:a", "enabled": false}` says something
209
+ // the plain string does not. The repair only drops byte-identical repeats,
210
+ // so the check has to make the same distinction or its hint names a repair
211
+ // that then answers "nothing to dedupe".
212
+ const seen = new Map();
213
+ const duplicates = new Set();
214
+ const mixedShapes = new Set();
215
+ for (const spec of settings.packages) {
216
+ const source = sourceOf(spec);
217
+ if (typeof source !== "string") continue;
218
+ if (seen.has(source)) {
219
+ if (JSON.stringify(seen.get(source)) === JSON.stringify(spec)) duplicates.add(source);
220
+ else mixedShapes.add(source);
221
+ } else {
222
+ seen.set(source, spec);
223
+ }
224
+ }
225
+ // Deduping the file is only correct when the file is what the report read;
226
+ // in-session the list may have come from the running process. The hint has
227
+ // to follow the same rule, or it names a command that then answers
228
+ // "Nothing to repair automatically."
229
+ // Only a file this check read itself may be rewritten: "runtime" means the
230
+ // session's copy is authoritative, and "runtime-unreadable" means the file
231
+ // is a guess.
232
+ const canDedupe = settingsSource === "file" && Boolean(settingsFile);
233
+ const listed = [...duplicates, ...mixedShapes].map(redactSource);
234
+ if (!listed.length) {
235
+ // Zero entries compared is not a pass: "none" over an empty list is the
236
+ // same vacuous green tick the rest of this file avoids. Counted by
237
+ // comparable entries rather than by the list length, because
238
+ // `[null, 42]` has a length and nothing to compare.
239
+ results.push(
240
+ seen.size
241
+ ? row("duplicates", "duplicate packages", "ok", "none")
242
+ : row("duplicates", "duplicate packages", "skip", "no comparable package entries"),
243
+ );
244
+ } else {
245
+ const parts = [];
246
+ if (duplicates.size) parts.push(`${duplicates.size} listed twice`);
247
+ if (mixedShapes.size) parts.push(`${mixedShapes.size} listed in two different shapes`);
248
+ results.push(
249
+ row("duplicates", "duplicate packages", "warn", `${listed.join(", ")}: ${parts.join(", ")}`, {
250
+ hint:
251
+ mixedShapes.size && !duplicates.size
252
+ ? "Dedupe leaves entries that differ alone, because they say different things. Keep the one you meant."
253
+ : canDedupe
254
+ ? `Run \`${fixCommand}\` to collapse the identical repeats to one entry each.`
255
+ : "Remove the repeated entries from settings.json by hand, or run `npx pi-checkup --fix --yes` with Pi stopped.",
256
+ fix: canDedupe && duplicates.size ? "dedupe-packages" : undefined,
257
+ fixTarget: canDedupe && duplicates.size ? settingsFile : undefined,
258
+ }),
259
+ );
260
+ }
261
+ }
262
+
263
+ // --- environment references --------------------------------------------
264
+ // Only the credential fields that Pi interpolates, never the raw file text.
265
+ const models = readJson(modelsFile);
266
+ // Three states, three sentences: missing, corrupt, unreadable.
267
+ const modelsUnreadableDetail = (m) => {
268
+ if (m.missing) return "models.json is missing — no credentials to inspect";
269
+ return m.invalid ? `models.json: ${m.error}` : `models.json could not be inspected: ${m.error}`;
270
+ };
271
+ if (!models.ok) {
272
+ results.push(
273
+ row(
274
+ "env",
275
+ "env references",
276
+ "skip",
277
+ modelsUnreadableDetail(models),
278
+ ),
279
+ );
280
+ } else {
281
+ const missing = new Set();
282
+ const owners = new Set();
283
+ let references = 0;
284
+ for (const { provider, value } of interpolatedFields(models.data)) {
285
+ for (const [, braced, bare] of value.matchAll(ENV_REF)) {
286
+ const name = braced ?? bare;
287
+ if (!name) continue;
288
+ references++;
289
+ // An exported-but-empty variable is not usable as a credential, so
290
+ // `FOO=""` counts as unset rather than as set.
291
+ // Object.hasOwn, not a truthiness read: `"${constructor}"` resolved
292
+ // through the prototype chain and was reported as set.
293
+ if (!Object.hasOwn(env, name) || !env[name]) {
294
+ missing.add(name);
295
+ owners.add(provider);
296
+ }
297
+ }
298
+ }
299
+ if (!references) {
300
+ results.push(row("env", "env references", "skip", "no $VAR reference in models.json credentials"));
301
+ } else {
302
+ results.push(
303
+ missing.size
304
+ ? row("env", "env references", "warn", `referenced but unset: ${truncateList(missing, 6)}`, {
305
+ // Which provider, and where to put the variable: "export them before
306
+ // starting Pi" is advice only if the reader already knows both.
307
+ hint: `${owners.size ? `Named in ${truncateList([...owners], 3)}. ` : ""}Add it to the shell profile Pi starts from (or your .env), then start Pi again — a missing value fails at request time, not at load.`,
308
+ })
309
+ : row("env", "env references", "ok", `${references} reference(s) all set`),
310
+ );
311
+ }
312
+ }
313
+
314
+ return results;
315
+ }
@@ -0,0 +1,165 @@
1
+ // The check registry, shared by the in-session command and the standalone CLI.
2
+ //
3
+ // A module that throws becomes a reported finding instead of taking the whole
4
+ // report down — a broken diagnostic should still tell you which part broke.
5
+ // It is reported as a failure, not a warning: a check that could not run is a
6
+ // broken tool, and the CLI exit code has to reflect that or CI stays green over
7
+ // a package that reports nothing.
8
+ import * as registry from "./registry.js";
9
+ import * as packages from "./packages.js";
10
+ import * as binaries from "./binaries.js";
11
+ import * as network from "./network.js";
12
+ import * as config from "./config.js";
13
+ import * as runtime from "./runtime.js";
14
+
15
+ import { scrubCredentials } from "../util.js";
16
+
17
+ export const CHECKS = [registry, packages, binaries, network, config, runtime];
18
+
19
+ // A safety net, not the primary bound: every outbound call already has its own
20
+ // timeout (fetch 8s, DNS 4s, TCP 3s, subprocess 8s). This exists so a future
21
+ // check cannot reintroduce the unbounded-wait class of bug that a synchronous
22
+ // `dns.lookup` used to be.
23
+ export const MODULE_TIMEOUT_MS = 30_000;
24
+
25
+ /**
26
+ * Parse an ignore list: `PI_CHECKUP_IGNORE="unreachable,packages:entries"`.
27
+ * Entries match a row's id, its `Group:id` form, or its title.
28
+ *
29
+ * Split on commas only: a title is a phrase (`shadowed names`), so treating
30
+ * whitespace as a separator silently turned every multi-word title into two
31
+ * entries that match nothing.
32
+ */
33
+ export function parseIgnore(raw) {
34
+ return new Set(
35
+ String(raw ?? "")
36
+ .split(",")
37
+ .map((s) => s.trim().toLowerCase())
38
+ .filter(Boolean),
39
+ );
40
+ }
41
+
42
+ /**
43
+ * Downgrade matching rows to `skip`, keeping the original status visible.
44
+ *
45
+ * Without this a single benign finding — an event-only package that registers no
46
+ * tool, a launcher deliberately not on PATH — makes every future report
47
+ * permanently non-clean, and a health check you learn to ignore is worse than no
48
+ * health check. The suppression is never silent: the row keeps its detail and
49
+ * says which status it replaced.
50
+ */
51
+ export function applyIgnore(rows, ignore) {
52
+ if (!ignore?.size) return rows;
53
+ return rows.map((r) => {
54
+ // Lower-cased on both sides: the group in a report is "Binaries" while a
55
+ // user types `binaries:unreachable`, and a suppression that silently does
56
+ // nothing is worse than no suppression.
57
+ const keys = [r.id, `${r.group}:${r.id}`, r.title].map((k) => (k ? k.toLowerCase() : k));
58
+ if (!keys.some((k) => k && ignore.has(k))) return r;
59
+ const { fix, fixTarget, ...rest } = r;
60
+ return {
61
+ ...rest,
62
+ status: "skip",
63
+ detail: `ignored via PI_CHECKUP_IGNORE (would be ${r.status}): ${r.detail}`,
64
+ hint: undefined,
65
+ };
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Run every check module against a context built by buildContext().
71
+ * @param {object} ctx
72
+ * @param {Array<{group?: string, run: Function}>} [modules] overridable so tests can inject a crashing module
73
+ * @param {{ timeoutMs?: number, ignore?: Set<string> }} [options]
74
+ */
75
+ export async function runChecks(ctx, modules = CHECKS, { timeoutMs = MODULE_TIMEOUT_MS, ignore } = {}) {
76
+ const settled = await Promise.all(
77
+ modules.map(async (mod) => {
78
+ const name = mod.group ?? "Unknown";
79
+ let timer;
80
+ const timeout = new Promise((resolve) => {
81
+ timer = setTimeout(() => {
82
+ resolve([
83
+ {
84
+ group: name,
85
+ id: `timeout:${name}`,
86
+ title: name.toLowerCase(),
87
+ status: "fail",
88
+ detail: `check did not finish within ${Math.round(timeoutMs / 1000)}s`,
89
+ // The in-session command has no --offline flag, and the timeout is
90
+ // most often a blocked network call — point the session reader at the
91
+ // CLI that has the flag instead of at a flag that does nothing here.
92
+ hint: ctx?.hasRuntime
93
+ ? "Usually a blocked network call. Run `npx pi-checkup --offline` outside the session to confirm, or report this with the check name above."
94
+ : "Usually a blocked network call. Try --offline, or report this with the check name above.",
95
+ },
96
+ ]);
97
+ }, timeoutMs);
98
+ // Deliberately not unref'd. An unref'd timer lets the event loop empty
99
+ // while a check is still pending, so Node exits before the timeout
100
+ // fires: no report, exit code 0. The timer only extends the process's
101
+ // life while a check is genuinely hung, which is exactly when it must.
102
+ });
103
+ try {
104
+ const run = (async () => {
105
+ try {
106
+ return await mod.run(ctx);
107
+ } catch (e) {
108
+ return [
109
+ {
110
+ group: name,
111
+ id: `crash:${name}`,
112
+ title: name.toLowerCase(),
113
+ status: "fail",
114
+ // Scrubbed like every other error string: a check that throws
115
+ // while handling a credentialed URL must not print it.
116
+ detail: `check crashed: ${scrubCredentials(String(e?.message ?? e))}`,
117
+ hint: "This is a bug in pi-checkup, not necessarily in your setup. Please report it with the message above.",
118
+ },
119
+ ];
120
+ }
121
+ })();
122
+ const rows = await Promise.race([run, timeout]);
123
+ // A module that returns a non-array would reach the renderer and throw
124
+ // out of the whole report, turning one bad check into a crash with no
125
+ // findings at all. The file's contract is one crash row per module.
126
+ if (!Array.isArray(rows)) {
127
+ return [
128
+ {
129
+ group: name,
130
+ id: `crash:${name}`,
131
+ title: name.toLowerCase(),
132
+ status: "fail",
133
+ detail: `check returned ${rows === null ? "null" : typeof rows} instead of a list of rows`,
134
+ hint: "This is a bug in pi-checkup, not necessarily in your setup. Please report it.",
135
+ },
136
+ ];
137
+ }
138
+ // A `results.push(cond ? row(...) : undefined)` is easy to write and
139
+ // reaches the renderer as a hole. The rows that did come back are
140
+ // kept, and the hole is named rather than silently dropped.
141
+ const kept = rows.filter((r) => r && typeof r === "object");
142
+ if (kept.length !== rows.length) {
143
+ kept.push({
144
+ group: name,
145
+ id: `crash:${name}`,
146
+ title: name.toLowerCase(),
147
+ status: "fail",
148
+ detail: `check emitted ${rows.length - kept.length} empty row(s)`,
149
+ hint: "This is a bug in pi-checkup, not necessarily in your setup. Please report it.",
150
+ });
151
+ }
152
+ return kept;
153
+ } finally {
154
+ clearTimeout(timer);
155
+ }
156
+ }),
157
+ );
158
+ // ctx.env, not process.env: every other input a check sees goes through the
159
+ // context seam, and reading one of them straight from the process made the
160
+ // ignore list impossible to test without mutating the real environment.
161
+ return applyIgnore(
162
+ settled.flat(),
163
+ ignore ?? parseIgnore(ctx.env?.PI_CHECKUP_IGNORE),
164
+ );
165
+ }