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/LICENSE +21 -0
- package/README.md +344 -2
- package/bin/checkup.js +233 -0
- package/package.json +47 -4
- package/scripts/lint.js +43 -0
- package/src/checks/binaries.js +371 -0
- package/src/checks/config.js +315 -0
- package/src/checks/index.js +165 -0
- package/src/checks/network.js +543 -0
- package/src/checks/packages.js +367 -0
- package/src/checks/registry.js +338 -0
- package/src/checks/runtime.js +360 -0
- package/src/context.js +79 -0
- package/src/index.js +109 -0
- package/src/render.js +159 -0
- package/src/repair.js +367 -0
- package/src/util.js +839 -0
|
@@ -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
|
+
}
|