pi-checkup 0.0.0-stage → 0.1.0
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 +343 -2
- package/bin/checkup.js +233 -0
- package/package.json +47 -4
- package/scripts/lint.js +43 -0
- package/src/checks/binaries.js +355 -0
- package/src/checks/config.js +312 -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
package/scripts/lint.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Syntax-check every source file.
|
|
2
|
+
//
|
|
3
|
+
// `node --check src/index.js` only ever checked one file, so a syntax error in
|
|
4
|
+
// any of the other modules survived `npm run lint`. This walks the same three
|
|
5
|
+
// directories CI does, so there is one definition of "every source file".
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
// `import.meta.dirname` needs Node 20.11; `engines` promises 20.0.
|
|
12
|
+
const ROOT = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
13
|
+
const DIRS = ["src", "bin", "test", "scripts"];
|
|
14
|
+
|
|
15
|
+
function walk(dir) {
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
18
|
+
const full = path.join(dir, entry.name);
|
|
19
|
+
if (entry.isDirectory()) out.push(...walk(full));
|
|
20
|
+
else if (entry.name.endsWith(".js")) out.push(full);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const files = DIRS.map((d) => path.join(ROOT, d))
|
|
26
|
+
.filter((d) => fs.existsSync(d))
|
|
27
|
+
.flatMap(walk);
|
|
28
|
+
|
|
29
|
+
let failed = 0;
|
|
30
|
+
for (const file of files) {
|
|
31
|
+
try {
|
|
32
|
+
execFileSync(process.execPath, ["--check", file], { stdio: "pipe" });
|
|
33
|
+
} catch (e) {
|
|
34
|
+
failed += 1;
|
|
35
|
+
process.stderr.write(`${path.relative(ROOT, file)}: ${e.stderr ?? e.message}\n`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (failed) {
|
|
40
|
+
process.stderr.write(`\n${failed} of ${files.length} file(s) failed the syntax check\n`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
console.log(`syntax ok: ${files.length} file(s)`);
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// Binary checks — can you actually run what your packages ship?
|
|
2
|
+
//
|
|
3
|
+
// Two failures this catches, both invisible to `pi list`:
|
|
4
|
+
// 1. A dangling symlink in node_modules/.bin (postinstall blocked by npm,
|
|
5
|
+
// optional native dependency skipped for this platform).
|
|
6
|
+
// 2. A package's command name is shadowed by an unrelated program earlier on
|
|
7
|
+
// PATH, so the name resolves to something else entirely.
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import {
|
|
11
|
+
describeFsError,
|
|
12
|
+
exists,
|
|
13
|
+
isMissing,
|
|
14
|
+
isWindows,
|
|
15
|
+
readJson,
|
|
16
|
+
resolvePackageDir,
|
|
17
|
+
sameCommand,
|
|
18
|
+
sourceOf,
|
|
19
|
+
truncateList,
|
|
20
|
+
whichAll,
|
|
21
|
+
} from "../util.js";
|
|
22
|
+
|
|
23
|
+
export const group = "Binaries";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* npm links package commands without a leading dot, so a dot-prefixed entry in
|
|
27
|
+
* .bin is a leftover of npm's atomic install: it writes `.<name>-<random>` and
|
|
28
|
+
* renames it into place. Seeing one means an install is running right now, or was
|
|
29
|
+
* interrupted — not that a package is broken. Reporting those as broken commands
|
|
30
|
+
* made a checkup run during `pi install` look like a damaged installation.
|
|
31
|
+
*/
|
|
32
|
+
function isStagingArtifact(name) {
|
|
33
|
+
return name.startsWith(".") && /-[A-Za-z0-9]{6,12}$/.test(name);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stagingRow(staging, danglingStaging, unreadableStaging = []) {
|
|
37
|
+
if (!staging.length) return null;
|
|
38
|
+
// `!exists()` is not `isMissing()`: a link this process cannot follow is not
|
|
39
|
+
// proof that it is gone, and it is not proof that it resolves either.
|
|
40
|
+
const broken = danglingStaging.length;
|
|
41
|
+
const unclear = unreadableStaging.length;
|
|
42
|
+
return {
|
|
43
|
+
group,
|
|
44
|
+
id: "staging",
|
|
45
|
+
title: "install leftovers",
|
|
46
|
+
status: broken || unclear ? "warn" : "ok",
|
|
47
|
+
detail: broken
|
|
48
|
+
? `${broken} of ${staging.length} leftover staging link(s) do not resolve: ${names(staging, 6)}`
|
|
49
|
+
: unclear
|
|
50
|
+
? `${staging.length} leftover staging link(s), ${unclear} of them could not be followed`
|
|
51
|
+
: `${staging.length} leftover staging link(s), all resolve`,
|
|
52
|
+
hint: broken ? "An install was interrupted. Re-run it for the affected packages, or delete these entries from node_modules/.bin." : undefined,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function danglingRow(dangling, linkCount, entryCount, unreadable = 0) {
|
|
57
|
+
if (dangling.length) {
|
|
58
|
+
return {
|
|
59
|
+
group,
|
|
60
|
+
id: "dangling",
|
|
61
|
+
title: "dangling links",
|
|
62
|
+
status: "fail",
|
|
63
|
+
detail: `${dangling.length} broken: ${names(dangling, 6)}`,
|
|
64
|
+
// Deliberately-declined install scripts are a legitimate state, but the
|
|
65
|
+
// row stays a failure — the command it names is genuinely broken — so the
|
|
66
|
+
// way out is named instead of a status downgrade.
|
|
67
|
+
hint: "Usually a blocked postinstall or a skipped optional native dependency. Reinstall the owning package with install scripts approved, or, if you declined them on purpose, silence the row with PI_CHECKUP_IGNORE=dangling.",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// Nothing to inspect is not a pass: an empty .bin used to print a green tick
|
|
71
|
+
// over zero entries.
|
|
72
|
+
if (!entryCount) {
|
|
73
|
+
return { group, id: "dangling", title: "dangling links", status: "skip", detail: "no command links in .bin to check" };
|
|
74
|
+
}
|
|
75
|
+
// npm writes shims as plain files on Windows, so a populated .bin can contain
|
|
76
|
+
// zero symlinks. Counting entries instead of links printed "19 entries all
|
|
77
|
+
// resolve" having inspected nothing.
|
|
78
|
+
if (!linkCount) {
|
|
79
|
+
return {
|
|
80
|
+
group,
|
|
81
|
+
id: "dangling",
|
|
82
|
+
title: "dangling links",
|
|
83
|
+
status: "skip",
|
|
84
|
+
detail: `${entryCount} entries in .bin, none of them links${isWindows ? " (Windows shims are plain files)" : ""}`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// A link this process could not follow is not a link that resolved. Saying
|
|
88
|
+
// "N links all resolve" here while the row below warns about the same links
|
|
89
|
+
// was a green tick contradicting a warning in the same report.
|
|
90
|
+
const resolved = linkCount - unreadable;
|
|
91
|
+
if (resolved !== linkCount) {
|
|
92
|
+
return {
|
|
93
|
+
group,
|
|
94
|
+
id: "dangling",
|
|
95
|
+
title: "dangling links",
|
|
96
|
+
status: "warn",
|
|
97
|
+
detail: `${resolved} of ${linkCount} links resolve — ${unreadable} could not be followed`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const noun = linkCount === 1 ? "link" : "links";
|
|
101
|
+
return { group, id: "dangling", title: "dangling links", status: "ok", detail: `${linkCount} ${noun} all resolve` };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** bin names that installed packages declare in their own manifests. */
|
|
105
|
+
function declaredBins(settings, root) {
|
|
106
|
+
const names = new Set();
|
|
107
|
+
// Guard the type: `"packages": {"a": "npm:x"}` is not iterable, and the throw
|
|
108
|
+
// replaced the whole Binaries group with a crash row that blamed pi-checkup
|
|
109
|
+
// for a hand-edited settings file.
|
|
110
|
+
const specs = Array.isArray(settings?.packages) ? settings.packages : [];
|
|
111
|
+
for (const spec of specs) {
|
|
112
|
+
const source = sourceOf(spec);
|
|
113
|
+
if (!source) continue;
|
|
114
|
+
const dir = resolvePackageDir(source, root);
|
|
115
|
+
if (!dir) continue;
|
|
116
|
+
const manifest = readJson(path.join(dir, "package.json"));
|
|
117
|
+
if (!manifest.ok) continue;
|
|
118
|
+
const bin = manifest.data?.bin;
|
|
119
|
+
if (!bin) continue;
|
|
120
|
+
if (typeof bin === "string") {
|
|
121
|
+
const base = String(manifest.data?.name ?? "").split("/").pop();
|
|
122
|
+
if (base) names.add(base);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
// An array is not a shape npm accepts, and Object.keys(["a","b"]) yields
|
|
126
|
+
// "0" and "1" — command names that cannot exist, later reported as missing
|
|
127
|
+
// shims or unreachable bins. Skip the entry instead of inventing rows.
|
|
128
|
+
if (Array.isArray(bin) || typeof bin !== "object") continue;
|
|
129
|
+
for (const key of Object.keys(bin)) names.add(key);
|
|
130
|
+
}
|
|
131
|
+
return names;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A list cut to six names without a marker reads as the whole list, and the count
|
|
136
|
+
* in front of it then contradicts what the reader can see.
|
|
137
|
+
*/
|
|
138
|
+
function names(list, max) {
|
|
139
|
+
// One truncation helper for the whole package: the marker has to look the same
|
|
140
|
+
// in every row, and it has to say how much was left out.
|
|
141
|
+
return truncateList(list, max);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function run({ settings, settingsLoaded = true, agentDir: root, binDir = path.join(root, "npm", "node_modules", ".bin"), env = process.env }) {
|
|
145
|
+
// Computed before the early returns: a package whose bins were never linked is
|
|
146
|
+
// exactly the state where .bin is missing or thin, and the old early return
|
|
147
|
+
// reported "no npm packages installed" without ever reading settings.
|
|
148
|
+
const ownBins = declaredBins(settings, root);
|
|
149
|
+
|
|
150
|
+
if (!exists(binDir)) {
|
|
151
|
+
// An unreadable .bin is not an empty one. Saying "no npm packages
|
|
152
|
+
// installed" for an EACCES sends the user looking in the wrong place.
|
|
153
|
+
const missing = isMissing(binDir);
|
|
154
|
+
const declared = ownBins.size;
|
|
155
|
+
const rows = [
|
|
156
|
+
{
|
|
157
|
+
group,
|
|
158
|
+
id: "bin-dir",
|
|
159
|
+
title: "bin dir",
|
|
160
|
+
status: missing ? "skip" : "warn",
|
|
161
|
+
detail: missing
|
|
162
|
+
? `${binDir} does not exist${declared ? ` (${declared} declared command(s) therefore have no shim)` : ""}`
|
|
163
|
+
: `cannot read ${binDir}`,
|
|
164
|
+
hint: missing
|
|
165
|
+
? declared
|
|
166
|
+
? "npm creates these links during install. Reinstall the owning package without --ignore-scripts."
|
|
167
|
+
: "Nothing is missing yet: no package in settings.json declares a command, and the directory appears with the first `pi install`."
|
|
168
|
+
: "A permissions or filesystem problem, not a missing install. Check ownership of the agent directory.",
|
|
169
|
+
},
|
|
170
|
+
];
|
|
171
|
+
if (declared) {
|
|
172
|
+
rows.push({
|
|
173
|
+
group,
|
|
174
|
+
id: "missing-shims",
|
|
175
|
+
title: "missing shims",
|
|
176
|
+
status: "warn",
|
|
177
|
+
detail: `${declared} declared command(s) never linked into .bin: ${names([...ownBins].sort(), 6)}`,
|
|
178
|
+
hint: "npm creates these links during install. Reinstall the owning package without --ignore-scripts.",
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return rows;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let entries;
|
|
185
|
+
try {
|
|
186
|
+
entries = fs.readdirSync(binDir);
|
|
187
|
+
} catch (e) {
|
|
188
|
+
return [
|
|
189
|
+
{
|
|
190
|
+
group,
|
|
191
|
+
id: "bin-dir",
|
|
192
|
+
title: "bin dir",
|
|
193
|
+
status: "warn",
|
|
194
|
+
// Not Node's message: it carries the errno and the raw syscall.
|
|
195
|
+
detail: `cannot read ${binDir}: ${describeFsError(e)}`,
|
|
196
|
+
hint: "Nothing below this point was checked. Fix the permissions on that directory and re-run.",
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// --- dangling symlinks -------------------------------------------------
|
|
202
|
+
const dangling = [];
|
|
203
|
+
const unreadableLinks = [];
|
|
204
|
+
let linkCount = 0;
|
|
205
|
+
const staging = [];
|
|
206
|
+
const danglingStaging = [];
|
|
207
|
+
const unreadableStaging = [];
|
|
208
|
+
for (const entry of entries) {
|
|
209
|
+
const full = path.join(binDir, entry);
|
|
210
|
+
let isLink = false;
|
|
211
|
+
try {
|
|
212
|
+
isLink = fs.lstatSync(full).isSymbolicLink();
|
|
213
|
+
} catch {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (!isLink) continue;
|
|
217
|
+
// ENOENT proves the target is gone. EACCES or ELOOP proves only that this
|
|
218
|
+
// process could not follow it, and reporting those as "broken: reinstall the
|
|
219
|
+
// package" blamed the package for a permissions problem.
|
|
220
|
+
const broken = isMissing(full);
|
|
221
|
+
const unreadable = !broken && !exists(full);
|
|
222
|
+
if (isStagingArtifact(entry)) {
|
|
223
|
+
staging.push(entry);
|
|
224
|
+
if (broken) danglingStaging.push(entry);
|
|
225
|
+
else if (unreadable) unreadableStaging.push(entry);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
// Counted after the staging branch: a half-written npm temp link is not a
|
|
229
|
+
// command anyone can run, so it cannot be part of "N links all resolve".
|
|
230
|
+
linkCount++;
|
|
231
|
+
if (broken) dangling.push(entry);
|
|
232
|
+
else if (unreadable) unreadableLinks.push(entry);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Real breakage stays a failure; a half-finished install is reported as its
|
|
236
|
+
// own thing, because the fix is "let the install finish", not "reinstall".
|
|
237
|
+
const stagingFindings = stagingRow(staging, danglingStaging, unreadableStaging);
|
|
238
|
+
const results = [
|
|
239
|
+
danglingRow(dangling, linkCount, entries.length - staging.length, unreadableLinks.length),
|
|
240
|
+
...(stagingFindings ? [stagingFindings] : []),
|
|
241
|
+
];
|
|
242
|
+
if (unreadableLinks.length) {
|
|
243
|
+
results.push({
|
|
244
|
+
group,
|
|
245
|
+
id: "unreadable-links",
|
|
246
|
+
title: "unreadable links",
|
|
247
|
+
status: "warn",
|
|
248
|
+
detail: `${unreadableLinks.length} link(s) could not be resolved: ${names(unreadableLinks, 6)}`,
|
|
249
|
+
hint: "Not proven broken — the target may exist but be unreadable for this user. Check ownership of the package directory.",
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// --- declared but never linked -----------------------------------------
|
|
254
|
+
// A package can declare a `bin` and still have no shim, because npm skips
|
|
255
|
+
// linking when install scripts are blocked. Driven off the manifests, not off
|
|
256
|
+
// .bin, so a command that was never installed is not silently ignored.
|
|
257
|
+
const entryNames = new Set(entries.map((e) => e.replace(/\.(cmd|ps1|exe|bat)$/i, "")));
|
|
258
|
+
const missingShims = [...ownBins].filter((name) => !entryNames.has(name)).sort();
|
|
259
|
+
if (missingShims.length) {
|
|
260
|
+
results.push({
|
|
261
|
+
group,
|
|
262
|
+
id: "missing-shims",
|
|
263
|
+
title: "missing shims",
|
|
264
|
+
status: "warn",
|
|
265
|
+
detail: `${missingShims.length} declared command(s) never linked into .bin: ${names(missingShims, 6)}`,
|
|
266
|
+
hint: "npm creates these links during install. Reinstall the owning package without --ignore-scripts.",
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// --- shadowing and reachability ----------------------------------------
|
|
271
|
+
const shadowed = [];
|
|
272
|
+
const unreachable = [];
|
|
273
|
+
|
|
274
|
+
// How many of the declared commands actually resolved on PATH. Shadowing is a
|
|
275
|
+
// question that can only be answered for a command PATH found, and `ownBins`
|
|
276
|
+
// being non-empty is not the same thing: a package whose shim is missing or
|
|
277
|
+
// whose directory never made it into PATH leaves zero commands to compare.
|
|
278
|
+
let resolved = 0;
|
|
279
|
+
for (const entry of ownBins) {
|
|
280
|
+
// Both findings below are about commands a Pi package advertises. A
|
|
281
|
+
// transitive dev dependency that happens to be shadowed is not the user's
|
|
282
|
+
// problem and reporting it made a healthy install look broken forever.
|
|
283
|
+
const hits = whichAll(entry, env);
|
|
284
|
+
if (!hits.length) {
|
|
285
|
+
// A missing shim is already reported above with a better hint; listing it
|
|
286
|
+
// here too would double-count the same root cause.
|
|
287
|
+
if (!missingShims.includes(entry)) unreachable.push(entry);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
// realpath on both sides: a symlinked ~/.pi (or /var on macOS) is the same
|
|
291
|
+
// file, and comparing raw strings called every package bin shadowed.
|
|
292
|
+
// sameCommand also folds the Windows shim extension, where PATH resolution
|
|
293
|
+
// answers `foo.CMD` for a link named `foo`.
|
|
294
|
+
resolved++;
|
|
295
|
+
if (!sameCommand(hits[0], path.join(binDir, entry))) shadowed.push({ name: entry, actual: hits[0] });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const shownShadow = shadowed
|
|
299
|
+
.slice(0, 4)
|
|
300
|
+
.map((s) => `${s.name} resolves to ${s.actual}`)
|
|
301
|
+
.join("; ");
|
|
302
|
+
const moreShadow = shadowed.length > 4 ? `; and ${shadowed.length - 4} more` : "";
|
|
303
|
+
|
|
304
|
+
// A green tick over zero inspected commands is the same hole the dangling row
|
|
305
|
+
// closes: an extension-only install declares no bin, so nothing was resolved.
|
|
306
|
+
results.push(
|
|
307
|
+
shadowed.length
|
|
308
|
+
? {
|
|
309
|
+
group,
|
|
310
|
+
id: "shadowed",
|
|
311
|
+
title: "shadowed names",
|
|
312
|
+
status: "warn",
|
|
313
|
+
detail: shownShadow + moreShadow,
|
|
314
|
+
hint: "Running the bare name in a shell starts a different program. Call the package binary by full path, or reorder PATH.",
|
|
315
|
+
}
|
|
316
|
+
: ownBins.size === 0
|
|
317
|
+
? {
|
|
318
|
+
group,
|
|
319
|
+
id: "shadowed",
|
|
320
|
+
title: "shadowed names",
|
|
321
|
+
status: "skip",
|
|
322
|
+
// settings.json that does not parse also arrives here as an empty set.
|
|
323
|
+
// Saying "no package command declared" asserted a fact about the
|
|
324
|
+
// config two groups below a row saying the file could not be read.
|
|
325
|
+
detail: settingsLoaded ? "no package command declared" : "settings.json could not be inspected — no package command list to check",
|
|
326
|
+
}
|
|
327
|
+
: resolved === 0
|
|
328
|
+
? // Nothing resolved, so nothing was compared. "none" here was a green
|
|
329
|
+
// tick over zero inspected commands, next to a row saying the
|
|
330
|
+
// commands could not be found — the same vacuous pass the
|
|
331
|
+
// `dangling` row closes.
|
|
332
|
+
{ group, id: "shadowed", title: "shadowed names", status: "skip", detail: "no package command resolved on PATH" }
|
|
333
|
+
: { group, id: "shadowed", title: "shadowed names", status: "ok", detail: "none" },
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
if (unreachable.length) {
|
|
337
|
+
results.push({
|
|
338
|
+
group,
|
|
339
|
+
id: "unreachable",
|
|
340
|
+
title: "unreachable bins",
|
|
341
|
+
status: "warn",
|
|
342
|
+
detail: `${unreachable.length} package command(s) not on PATH: ${names(unreachable, 6)}`,
|
|
343
|
+
// The file exists — it is just not on PATH — so the fix is the directory,
|
|
344
|
+
// not a reinstall. Without this the row reads as "something is broken"
|
|
345
|
+
// with no way to tell what to do about it.
|
|
346
|
+
// The file exists — it is just not on PATH — so the fix is the directory,
|
|
347
|
+
// not a reinstall. Without this the row reads as "something is broken"
|
|
348
|
+
// with no way to tell what to do about it. The suppression is named here
|
|
349
|
+
// because this is the one row a healthy install carries for good.
|
|
350
|
+
hint: `Pi installs package commands into ${binDir} without adding it to PATH. Extensions that call them by full path still work; to use them by name, add that directory to PATH or run them with npx. If this is the state you want, silence the row with PI_CHECKUP_IGNORE=unreachable.`,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return results;
|
|
355
|
+
}
|