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,367 @@
|
|
|
1
|
+
// Package checks — are the packages in settings.json actually on disk, and do
|
|
2
|
+
// the files their manifests point at exist?
|
|
3
|
+
//
|
|
4
|
+
// This catches the failure that happens after a good install: a partial
|
|
5
|
+
// extraction, a manual `rm -rf` in node_modules, a git checkout that moved the
|
|
6
|
+
// entry file, or a package whose manifest points at a build output that was
|
|
7
|
+
// never committed.
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import {
|
|
11
|
+
describeFsError,
|
|
12
|
+
globExists,
|
|
13
|
+
isMissing,
|
|
14
|
+
makeRow,
|
|
15
|
+
pinnedVersion,
|
|
16
|
+
readJson,
|
|
17
|
+
redactSource,
|
|
18
|
+
resolvePackageDir,
|
|
19
|
+
scrubCredentials,
|
|
20
|
+
sourceOf,
|
|
21
|
+
truncateList,
|
|
22
|
+
} from "../util.js";
|
|
23
|
+
|
|
24
|
+
export const group = "Packages";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Describe a malformed `packages` entry without printing it.
|
|
28
|
+
*
|
|
29
|
+
* The entry can be any JSON value, including an object with a `token` field;
|
|
30
|
+
* `redactSource` scrubs URLs, not arbitrary objects, so serialising the value
|
|
31
|
+
* put whatever was in it into the report and into `--json`.
|
|
32
|
+
*/
|
|
33
|
+
function describeSpec(spec) {
|
|
34
|
+
if (spec === null) return "null";
|
|
35
|
+
if (spec === undefined) return "undefined";
|
|
36
|
+
if (Array.isArray(spec)) return "an array";
|
|
37
|
+
const kind = typeof spec;
|
|
38
|
+
if (kind === "function" || kind === "symbol" || kind === "bigint") return `a ${kind}`;
|
|
39
|
+
// A string here is not a source (sourceOf would have returned it), so it is a
|
|
40
|
+
// value someone pasted into the wrong field: its length is enough to find it
|
|
41
|
+
// and prints none of it.
|
|
42
|
+
if (kind === "string") return `a ${spec.length}-character string`;
|
|
43
|
+
if (kind !== "object") return `${kind} ${String(spec)}`.slice(0, 40);
|
|
44
|
+
const keys = Object.keys(spec);
|
|
45
|
+
return keys.length ? `an object with keys ${truncateList(keys, 4)}` : "an empty object";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function run({ settings, settingsLoaded, agentDir: root, settingsFile, settingsSource }) {
|
|
49
|
+
const results = [];
|
|
50
|
+
|
|
51
|
+
// A hand-edited settings.json can put a string here, and iterating that would
|
|
52
|
+
// walk it character by character and report seven unverifiable "sources".
|
|
53
|
+
const declared = settings?.packages;
|
|
54
|
+
const specs = Array.isArray(declared) ? declared : [];
|
|
55
|
+
|
|
56
|
+
if (!specs.length) {
|
|
57
|
+
// `[]` is truthy, so testing the value itself reported a fresh install with
|
|
58
|
+
// an empty package list as a malformed config. The question is whether it
|
|
59
|
+
// is a list, not whether it exists.
|
|
60
|
+
const malformed = declared !== undefined && !Array.isArray(declared);
|
|
61
|
+
// `settings` is absent when the file could not be parsed. Saying "no
|
|
62
|
+
// packages in settings.json" then asserted something the check never read,
|
|
63
|
+
// and pointed the user at the wrong problem.
|
|
64
|
+
// "Not loaded" and "loaded as null" are different files: a settings.json
|
|
65
|
+
// containing `null` parses fine and is still not a config.
|
|
66
|
+
const unreadable = !settingsLoaded;
|
|
67
|
+
const notObject = settingsLoaded && (settings === null || typeof settings !== "object" || Array.isArray(settings));
|
|
68
|
+
// A missing settings.json is not a fault: Pi boots on defaults and writes
|
|
69
|
+
// the file the first time a setting changes. A file that exists but did not
|
|
70
|
+
// parse is a fault, and Config already says so.
|
|
71
|
+
// Only when nothing was loaded: a running session can supply an empty
|
|
72
|
+
// `packages` list while no settings.json exists, and blaming the absent
|
|
73
|
+
// file for a conclusion that came from the session was wrong.
|
|
74
|
+
const absent = !settingsLoaded && Boolean(settingsFile) && isMissing(settingsFile);
|
|
75
|
+
const where = settingsSource === "runtime" ? "the running session" : "settings.json";
|
|
76
|
+
let detail = `no packages in ${where}`;
|
|
77
|
+
// "could not be inspected", not "could not be read": an oversized file was
|
|
78
|
+
// read fine, it just was not parsed.
|
|
79
|
+
if (absent) detail = "settings.json not found — no package list to check";
|
|
80
|
+
else if (unreadable) detail = "settings.json could not be inspected — no package list to check";
|
|
81
|
+
else if (notObject) detail = "settings.json is valid JSON but not an object — no package list to check";
|
|
82
|
+
else if (malformed) detail = `${where} \`packages\` is not a list`;
|
|
83
|
+
return [
|
|
84
|
+
{
|
|
85
|
+
group,
|
|
86
|
+
id: "installed",
|
|
87
|
+
title: "installed",
|
|
88
|
+
status: malformed || notObject || (unreadable && !absent) ? "warn" : "skip",
|
|
89
|
+
detail,
|
|
90
|
+
hint: unreadable && !absent ? "The Config group reports why settings.json could not be used." : undefined,
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const missingDir = [];
|
|
96
|
+
const unreadableDir = [];
|
|
97
|
+
const notDir = [];
|
|
98
|
+
const brokenManifest = [];
|
|
99
|
+
const unreadableManifest = [];
|
|
100
|
+
// A directory with no package.json at all is not a permissions problem and the
|
|
101
|
+
// hint for one ("check ownership") sent the reader looking in the wrong place.
|
|
102
|
+
const absentManifest = [];
|
|
103
|
+
const badSpecs = [];
|
|
104
|
+
const badExtensions = [];
|
|
105
|
+
const missingEntry = [];
|
|
106
|
+
const versionMismatch = [];
|
|
107
|
+
const unverifiable = [];
|
|
108
|
+
// Counted separately so a check that had nothing to look at reports skipped
|
|
109
|
+
// instead of printing a green tick over zero inspected packages.
|
|
110
|
+
let present = 0;
|
|
111
|
+
let manifestRead = 0;
|
|
112
|
+
let entryChecked = 0;
|
|
113
|
+
|
|
114
|
+
for (const spec of specs) {
|
|
115
|
+
const source = sourceOf(spec);
|
|
116
|
+
if (source === null) {
|
|
117
|
+
// A null, a number or an object without `source` is a malformed entry, not
|
|
118
|
+
// a source "resolved by Pi itself": the old path pushed `null` into the
|
|
119
|
+
// unverifiable list and printed "2 source(s) ... null, null".
|
|
120
|
+
// Shape, not content: an entry object can carry a token field, and
|
|
121
|
+
// redactSource only scrubs URLs, so serialising it echoed the secret.
|
|
122
|
+
badSpecs.push(describeSpec(spec));
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const dir = resolvePackageDir(source, root);
|
|
126
|
+
if (!dir) {
|
|
127
|
+
unverifiable.push(redactSource(source));
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
let stat;
|
|
131
|
+
try {
|
|
132
|
+
stat = fs.statSync(dir);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
// Only "it is not there" justifies offering to delete the settings entry.
|
|
135
|
+
// EACCES/EPERM/ELOOP mean the package may be perfectly fine and this
|
|
136
|
+
// process simply cannot see it; treating those as missing turned a
|
|
137
|
+
// permission problem into a destructive repair.
|
|
138
|
+
const code = e?.code;
|
|
139
|
+
if (code === "ENOENT" || code === "ENOTDIR") missingDir.push(redactSource(source));
|
|
140
|
+
// errno in the report is the least polished text in the whole tool;
|
|
141
|
+
// describeFsError has said "permission denied" for a while and this row
|
|
142
|
+
// was still printing `EACCES`.
|
|
143
|
+
else unreadableDir.push(`${redactSource(source)} (${describeFsError(e)})`);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (!stat.isDirectory()) {
|
|
147
|
+
// It exists. Calling a file "missing" reported a `fail` that the prune
|
|
148
|
+
// repair could never clear, because prune asks isMissing() and a file is
|
|
149
|
+
// not missing.
|
|
150
|
+
notDir.push(redactSource(source));
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
present++;
|
|
154
|
+
const manifest = readJson(path.join(dir, "package.json"));
|
|
155
|
+
if (!manifest.ok) {
|
|
156
|
+
// A manifest that does not parse is broken. One this process could not
|
|
157
|
+
// read — EACCES, a file past the size limit — is unproven, and telling
|
|
158
|
+
// someone to reinstall over a permissions problem is wrong.
|
|
159
|
+
if (manifest.invalid) brokenManifest.push(redactSource(source));
|
|
160
|
+
else if (manifest.missing) absentManifest.push(redactSource(source));
|
|
161
|
+
else unreadableManifest.push(`${redactSource(source)} (${manifest.error ?? "unreadable"})`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
manifestRead++;
|
|
165
|
+
// A pin and a different installed version is the gap between the code that
|
|
166
|
+
// was asked for and the code that runs. Only exact pins are comparable.
|
|
167
|
+
const pin = pinnedVersion(source);
|
|
168
|
+
const installed = manifest.data?.version;
|
|
169
|
+
if (pin && typeof installed === "string" && installed !== pin) {
|
|
170
|
+
// Scrub first, slice after. Cutting at 40 bytes first put the `@` of a
|
|
171
|
+
// `https://user:pass@host` version string past the cut, leaving nothing
|
|
172
|
+
// for scrubCredentials to recognise — and it printed the password prefix
|
|
173
|
+
// into the report, the TUI notification and the --json payload.
|
|
174
|
+
versionMismatch.push(`${redactSource(source)} (installed ${scrubCredentials(installed).slice(0, 40)})`);
|
|
175
|
+
}
|
|
176
|
+
const declared = manifest.data?.pi?.extensions;
|
|
177
|
+
if (!declared) continue; // convention-directory package, nothing to verify
|
|
178
|
+
const raw = Array.isArray(declared) ? declared : [declared];
|
|
179
|
+
// A hand-edited manifest can put a number, null or object here, and passing
|
|
180
|
+
// that to the glob matcher threw `pattern.includes is not a function` out of
|
|
181
|
+
// this module — which the runner turns into one "check crashed" row, losing
|
|
182
|
+
// every other package finding.
|
|
183
|
+
const list = raw.filter((entry) => typeof entry === "string");
|
|
184
|
+
if (list.length !== raw.length) badExtensions.push(redactSource(source));
|
|
185
|
+
if (!list.length) continue; // declared nothing verifiable
|
|
186
|
+
entryChecked++;
|
|
187
|
+
const missing = list.filter((entry) => !globExists(dir, entry));
|
|
188
|
+
// The patterns come from a third-party package.json, so they are untrusted
|
|
189
|
+
// text on their way to the terminal and `--json`, like everything else here.
|
|
190
|
+
if (missing.length) missingEntry.push(`${redactSource(source)} -> ${missing.map(redactSource).join(", ")}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Shared with the other modules so a cut list always says how much was cut.
|
|
194
|
+
const preview = (list, n) => truncateList(list, n);
|
|
195
|
+
const row = makeRow(group);
|
|
196
|
+
|
|
197
|
+
// --- package directories -----------------------------------------------
|
|
198
|
+
// The prune repair is only safe when settings came from the file the repair
|
|
199
|
+
// would rewrite. In-session the settings may come from the running process, so
|
|
200
|
+
// editing the on-disk copy could either miss the problem or hit a stale file.
|
|
201
|
+
// Only a file this check read itself may be rewritten: "runtime" means the
|
|
202
|
+
// session's copy is authoritative, and "runtime-unreadable" means the file is
|
|
203
|
+
// a guess.
|
|
204
|
+
// Only offer the repair when it would actually go through. Pruning refuses to
|
|
205
|
+
// empty the list, because a wrong agent directory makes every entry look
|
|
206
|
+
// missing — so a settings.json whose every entry is gone must not advertise a
|
|
207
|
+
// repair that answers "no". Anything prune keeps (a git: source, an entry with
|
|
208
|
+
// no path, an unreadable directory) counts as a survivor.
|
|
209
|
+
const survivors =
|
|
210
|
+
present + unreadableDir.length + notDir.length + unverifiable.length + badSpecs.length;
|
|
211
|
+
const canPrune = Boolean(settingsFile) && settingsSource === "file" && survivors > 0;
|
|
212
|
+
if (missingDir.length) {
|
|
213
|
+
results.push(
|
|
214
|
+
row("dirs", "package dirs", "fail", `${missingDir.length} missing from node_modules: ${preview(missingDir, 3)}`, {
|
|
215
|
+
hint: survivors
|
|
216
|
+
? "Run `pi install` again for the listed packages, or remove them from settings.json."
|
|
217
|
+
: // Every entry is gone, which is also what a mistyped PI_CODING_AGENT_DIR looks like. Deleting the whole list on that evidence would be worse than the finding, so the repair is not offered and the hint says so.
|
|
218
|
+
"Every entry in settings.json is gone from disk. Reinstall with `pi install`, or check that PI_CODING_AGENT_DIR points at the directory Pi uses — an empty list is not something this tool will write for you.",
|
|
219
|
+
// Dropping the entry is the only repair a static checker can offer;
|
|
220
|
+
// reinstalling needs the package manager and the network.
|
|
221
|
+
fix: canPrune ? "prune-missing-packages" : undefined,
|
|
222
|
+
fixTarget: canPrune ? settingsFile : undefined,
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
} else if (present) {
|
|
226
|
+
results.push(row("dirs", "package dirs", "ok", `${present} present`));
|
|
227
|
+
} else if (unreadableDir.length) {
|
|
228
|
+
results.push(row("dirs", "package dirs", "skip", "no package directory could be opened"));
|
|
229
|
+
} else {
|
|
230
|
+
// Every entry was resolved by Pi itself, so no directory was ever opened.
|
|
231
|
+
results.push(row("dirs", "package dirs", "skip", "no package directory could be inspected"));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Reported separately and deliberately without a `fix`: the package is
|
|
235
|
+
// probably fine, so deleting its settings entry would be the wrong repair.
|
|
236
|
+
if (unreadableDir.length) {
|
|
237
|
+
results.push(
|
|
238
|
+
row("unreadable", "unreadable dirs", "warn", `${unreadableDir.length} package dir(s) could not be opened: ${preview(unreadableDir, 3)}`, {
|
|
239
|
+
hint: "A permissions or filesystem problem, not a missing package. Check ownership of the agent directory.",
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// An entry that points at a file is not a package directory, and Pi cannot
|
|
245
|
+
// install from it. Reported without a `fix`: there is nothing to delete.
|
|
246
|
+
if (notDir.length) {
|
|
247
|
+
results.push(
|
|
248
|
+
row("not-dir", "not a package", "warn", `${notDir.length} entr${notDir.length === 1 ? "y points" : "ies point"} at a file, not a package directory: ${preview(notDir, 3)}`, {
|
|
249
|
+
hint: "A package entry has to be a directory containing package.json; declare a single-file extension inside a package's pi.extensions instead.",
|
|
250
|
+
}),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// --- manifests ---------------------------------------------------------
|
|
255
|
+
if (absentManifest.length) {
|
|
256
|
+
results.push(
|
|
257
|
+
row(
|
|
258
|
+
"manifests-missing",
|
|
259
|
+
"missing manifests",
|
|
260
|
+
"warn",
|
|
261
|
+
`${absentManifest.length} package dir(s) hold no package.json: ${preview(absentManifest, 3)}`,
|
|
262
|
+
{
|
|
263
|
+
hint: "The directory exists but is not a package — an interrupted install, or a leftover folder. Reinstall the package, or remove the entry from settings.json.",
|
|
264
|
+
},
|
|
265
|
+
),
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
if (unreadableManifest.length) {
|
|
269
|
+
results.push(
|
|
270
|
+
row("manifests-unreadable", "unreadable manifests", "warn", `${unreadableManifest.length} package.json could not be inspected: ${preview(unreadableManifest, 2)}`, {
|
|
271
|
+
hint: "A permissions or size problem, not proof of a broken package. The entry files below are only checked for manifests that parsed.",
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (brokenManifest.length) {
|
|
276
|
+
results.push(
|
|
277
|
+
row(
|
|
278
|
+
"manifests",
|
|
279
|
+
"manifests",
|
|
280
|
+
"fail",
|
|
281
|
+
`${brokenManifest.length} package.json not valid JSON: ${preview(brokenManifest, 3)}`,
|
|
282
|
+
{
|
|
283
|
+
// A run that overlaps an install sees a manifest mid-rename. Saying so
|
|
284
|
+
// is the difference between "reinstall everything" and "wait 30 seconds".
|
|
285
|
+
hint: "Reinstall the affected packages. If an install or update is running right now, re-run once it finishes.",
|
|
286
|
+
},
|
|
287
|
+
),
|
|
288
|
+
);
|
|
289
|
+
} else if (manifestRead) {
|
|
290
|
+
results.push(row("manifests", "manifests", "ok", `${manifestRead} readable`));
|
|
291
|
+
} else {
|
|
292
|
+
// Nothing was opened, so there is nothing to be green about. An unreadable
|
|
293
|
+
// manifest is not "nothing to inspect" — the row above says one existed.
|
|
294
|
+
results.push(
|
|
295
|
+
row("manifests", "manifests", "skip", unreadableManifest.length ? "no manifest could be read" : "nothing to inspect"),
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// --- version pins ------------------------------------------------------
|
|
300
|
+
if (versionMismatch.length) {
|
|
301
|
+
results.push(
|
|
302
|
+
row(
|
|
303
|
+
"version-pin",
|
|
304
|
+
"version pins",
|
|
305
|
+
"warn",
|
|
306
|
+
`${versionMismatch.length} pinned package(s) run a different version: ${preview(versionMismatch, 2)}`,
|
|
307
|
+
{
|
|
308
|
+
hint: "settings.json pins a version and the copy on disk is another one. Reinstall that package to make the disk match, or update the pin.",
|
|
309
|
+
},
|
|
310
|
+
),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// --- entry files -------------------------------------------------------
|
|
315
|
+
if (missingEntry.length) {
|
|
316
|
+
results.push(
|
|
317
|
+
row(
|
|
318
|
+
"entries",
|
|
319
|
+
"entry files",
|
|
320
|
+
"fail",
|
|
321
|
+
`${missingEntry.length} manifest(s) point at files that do not exist: ${preview(missingEntry, 2)}`,
|
|
322
|
+
{ hint: "The package is installed but cannot load. Reinstall it, or check whether it needs a build step." },
|
|
323
|
+
),
|
|
324
|
+
);
|
|
325
|
+
} else if (entryChecked) {
|
|
326
|
+
results.push(row("entries", "entry files", "ok", `${entryChecked} manifest(s) resolve`));
|
|
327
|
+
} else {
|
|
328
|
+
results.push(row("entries", "entry files", "skip", "no package declares an entry file"));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (badSpecs.length) {
|
|
332
|
+
results.push(
|
|
333
|
+
row(
|
|
334
|
+
"entries-shape",
|
|
335
|
+
"package entries",
|
|
336
|
+
"warn",
|
|
337
|
+
`${badSpecs.length} entr${badSpecs.length === 1 ? "y is" : "ies are"} not a package source: ${preview(badSpecs, 3)}`,
|
|
338
|
+
{ hint: "Each entry has to be a string like `npm:name` or `./path`, or an object with a `source` field." },
|
|
339
|
+
),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (badExtensions.length) {
|
|
344
|
+
results.push(
|
|
345
|
+
row(
|
|
346
|
+
"manifest-shape",
|
|
347
|
+
"manifest shape",
|
|
348
|
+
"warn",
|
|
349
|
+
`${badExtensions.length} manifest(s) declare a non-string pi.extensions entry: ${preview(badExtensions, 3)}`,
|
|
350
|
+
{ hint: "Entry files have to be strings. Reinstall the package, or fix its package.json." },
|
|
351
|
+
),
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (unverifiable.length) {
|
|
356
|
+
results.push(
|
|
357
|
+
row(
|
|
358
|
+
"unverifiable",
|
|
359
|
+
"unverifiable",
|
|
360
|
+
"skip",
|
|
361
|
+
`${unverifiable.length} source(s) resolved by Pi itself, not inspectable statically: ${preview(unverifiable, 3)}`,
|
|
362
|
+
),
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return results;
|
|
367
|
+
}
|