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.
@@ -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
+ }