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/src/util.js ADDED
@@ -0,0 +1,839 @@
1
+ // Shared helpers. Everything here is dependency-free and cross-platform
2
+ // (Node built-ins only), because a diagnostic tool that fails on the machine
3
+ // it is diagnosing is worse than useless.
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import dns from "node:dns/promises";
8
+ import { execFile } from "node:child_process";
9
+ import { promisify } from "node:util";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const exec = promisify(execFile);
13
+
14
+ export const TIMEOUT_MS = 8000;
15
+
16
+ /**
17
+ * The version of this package, read from its own manifest.
18
+ *
19
+ * A corrupt or partial install must still produce a report, so a manifest that
20
+ * does not parse degrades to "unknown" instead of an unhandled SyntaxError.
21
+ */
22
+ export function packageVersion() {
23
+ try {
24
+ const here = path.dirname(fileURLToPath(import.meta.url));
25
+ return JSON.parse(fs.readFileSync(path.join(here, "..", "package.json"), "utf8")).version ?? "unknown";
26
+ } catch {
27
+ return "unknown";
28
+ }
29
+ }
30
+ export const isWindows = process.platform === "win32";
31
+
32
+ // Windows and the default macOS filesystem are case-insensitive, so two paths
33
+ // that differ only in case are the same file there.
34
+ const CASE_INSENSITIVE = isWindows || process.platform === "darwin";
35
+
36
+ // Config files are small. A cap turns "read whatever this path points at" into
37
+ // a bounded read when the path came out of a settings file.
38
+ const MAX_JSON_BYTES = 4 << 20;
39
+
40
+ const SHIM_EXT = /\.(com|exe|bat|cmd|ps1)$/i;
41
+
42
+ /**
43
+ * Are two paths the same command?
44
+ *
45
+ * On Windows a package bin is linked as `foo`, `foo.cmd` and `foo.ps1`, and PATH
46
+ * resolution appends PATHEXT, so the hit is `foo.CMD` while the link is `foo`.
47
+ * Comparing the raw strings reported every correctly linked package command as
48
+ * shadowed. The directory still has to match: a `foo.cmd` in some other
49
+ * directory really is a different program.
50
+ */
51
+ export function sameCommand(a, b) {
52
+ if (typeof a !== "string" || typeof b !== "string") return false;
53
+ const stem = (p) => path.basename(p).replace(SHIM_EXT, "").toLowerCase();
54
+ return normalizePath(path.dirname(a)) === normalizePath(path.dirname(b)) && stem(a) === stem(b);
55
+ }
56
+
57
+ /** Root of the Pi agent directory (holds settings.json, models.json, npm/, bin/). */
58
+ export function agentDir() {
59
+ return process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
60
+ }
61
+
62
+ /**
63
+ * One finding row. Every module builds rows the same shape, and four of them used
64
+ * to carry their own copy of this one-liner.
65
+ */
66
+ export function makeRow(group) {
67
+ return (id, title, status, detail, extra = {}) => ({ group, id, title, status, detail, ...extra });
68
+ }
69
+
70
+ /** Run a command without blocking the event loop. Never throws. */
71
+ export async function sh(cmd, args, timeout = TIMEOUT_MS) {
72
+ try {
73
+ const { stdout, stderr } = await exec(cmd, args, {
74
+ timeout,
75
+ encoding: "utf8",
76
+ maxBuffer: 4 << 20,
77
+ windowsHide: true,
78
+ });
79
+ return { ok: true, out: stdout, err: stderr };
80
+ } catch (e) {
81
+ return {
82
+ ok: false,
83
+ // ENOENT means the binary is not installed, which is a different answer
84
+ // from "the command ran and failed". Callers that must tell the two
85
+ // apart need this; without it a dead proxy looked like a missing curl.
86
+ missing: e?.code === "ENOENT",
87
+ code: typeof e?.code === "number" ? e.code : null,
88
+ out: typeof e?.stdout === "string" ? e.stdout : "",
89
+ err: typeof e?.stderr === "string" ? e.stderr : String(e?.message ?? e),
90
+ };
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Every PATH hit for a command name, best match first.
96
+ *
97
+ * Resolved in-process rather than by spawning `which`/`where`: that removes one
98
+ * subprocess per binary inspected (a package with a dozen commands was costing a
99
+ * dozen spawns), and it still answers on images that ship no `which` at all.
100
+ */
101
+ export const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
102
+
103
+ export function whichAll(name, env = process.env, { windows = isWindows } = {}) {
104
+ // Windows exposes the same variable as `Path` when the environment block came
105
+ // from a case-preserving source (a spread copy of process.env, a child process
106
+ // launched by a shell). Reading only `PATH` there found zero commands and the
107
+ // report called every installed binary missing.
108
+ const extVar = windows ? (env.PATHEXT ?? env.Pathext ?? env.pathext ?? DEFAULT_PATHEXT) : "";
109
+ const exts = windows ? extVar.split(";").filter(Boolean) : [""];
110
+ const pathVar = env.PATH ?? env.Path ?? env.path ?? "";
111
+ const dirs = pathVar.split(path.delimiter).filter(Boolean);
112
+ const hits = [];
113
+ for (const dir of dirs) {
114
+ for (const ext of exts) {
115
+ const candidate = path.join(dir, name + ext);
116
+ if (!exists(candidate)) continue;
117
+ // `which` only reports executables on POSIX; matching that keeps the
118
+ // shadow and unreachable findings honest.
119
+ if (!windows) {
120
+ try {
121
+ fs.accessSync(candidate, fs.constants.X_OK);
122
+ } catch {
123
+ continue;
124
+ }
125
+ }
126
+ hits.push(candidate);
127
+ }
128
+ }
129
+ return hits;
130
+ }
131
+
132
+ /**
133
+ * A filesystem error in words a user can act on.
134
+ *
135
+ * Node's messages carry an errno, the syscall and the absolute path —
136
+ * `EISDIR: illegal operation on a directory, read` — which is the least polished
137
+ * text in the whole report, and it lands in the row that is supposed to explain
138
+ * what to do next.
139
+ */
140
+ export function describeFsError(e) {
141
+ const code = e?.code;
142
+ if (code === "EACCES" || code === "EPERM") return "permission denied";
143
+ if (code === "EISDIR") return "it is a directory, not a file";
144
+ if (code === "ELOOP") return "too many symbolic links";
145
+ if (code === "EMFILE" || code === "ENFILE") return "too many open files";
146
+ if (code === "ENOENT" || code === "ENOTDIR") return "not found";
147
+ if (code === "ENOSPC") return "no space left on the device";
148
+ if (code === "EIO") return "the device reported an I/O error";
149
+ return code ? `could not be read (${code})` : String(e?.message ?? e);
150
+ }
151
+
152
+ /**
153
+ * Read a JSON file. Returns a discriminated result instead of throwing so that
154
+ * callers can report "this file is broken" as a finding rather than a crash.
155
+ */
156
+ export function readJson(file) {
157
+ try {
158
+ const size = fs.statSync(file).size;
159
+ if (size > MAX_JSON_BYTES) {
160
+ return { ok: false, error: `${humanBytes(size)} is too large to inspect` };
161
+ }
162
+ } catch (e) {
163
+ return { ok: false, missing: e?.code === "ENOENT", error: describeFsError(e) };
164
+ }
165
+ let raw;
166
+ try {
167
+ raw = fs.readFileSync(file, "utf8");
168
+ } catch (e) {
169
+ return { ok: false, missing: e?.code === "ENOENT", error: describeFsError(e) };
170
+ }
171
+ try {
172
+ return { ok: true, data: JSON.parse(raw) };
173
+ } catch (e) {
174
+ // `invalid` marks a genuine parse failure, which is a different problem from
175
+ // a file that is missing or too large to read.
176
+ //
177
+ // V8 quotes a window of the input in some of its messages — `Unexpected
178
+ // token 's', ..."\"apiKey\": sk-live-d}" is not valid JSON` — and this file
179
+ // is often models.json, the one that holds keys. Only the position is
180
+ // carried through; the message itself never reaches a report.
181
+ const pos = /at position (\d+)/.exec(String(e?.message ?? ""));
182
+ // V8 reports a byte offset, and a multi-line models.json leaves the reader
183
+ // counting characters to find it. Line and column are what an editor jumps to.
184
+ if (pos) {
185
+ const idx = Number(pos[1]);
186
+ const before = raw.slice(0, idx);
187
+ return {
188
+ ok: false,
189
+ invalid: true,
190
+ error: `not valid JSON (line ${before.split("\n").length}, column ${idx - before.lastIndexOf("\n")})`,
191
+ };
192
+ }
193
+ return { ok: false, invalid: true, error: "not valid JSON" };
194
+ }
195
+ }
196
+
197
+ /**
198
+ * A failed HTTP request in words, never in Node's vocabulary.
199
+ *
200
+ * Every fault below the HTTP layer arrives as `fetch failed` with the reason on
201
+ * `cause`, and that reason is an errno plus a syscall — `getaddrinfo ENOTFOUND
202
+ * api.example.com` — or undici's `This operation was aborted` when the only
203
+ * thing that happened is that this process stopped waiting. The first is
204
+ * accurate but unreadable; the second asserts something about the endpoint that
205
+ * a local timeout never established.
206
+ *
207
+ * `aborted` is set by this process's own timer, so it is the one case where the
208
+ * failure says nothing at all about the server. `kind` lets a caller branch on
209
+ * the cause instead of matching on prose.
210
+ */
211
+ export function describeNetError(e, aborted = false, timeout = TIMEOUT_MS) {
212
+ if (aborted) return { kind: "timeout", text: `no answer within ${Math.round(timeout / 1000)}s` };
213
+ const cause = e?.cause ?? {};
214
+ const code = String(cause.code ?? "");
215
+ const raw = String(cause.message ?? e?.message ?? "");
216
+ // A request Node refuses to build never opens a socket, and undici's message
217
+ // quotes the offending value — which here is the apiKey. That is a fact about
218
+ // the config, not about the endpoint, so it gets its own kind.
219
+ if (/ByteString|Cannot convert argument|invalid header|Invalid value|Failed to parse URL/i.test(raw)) {
220
+ return { kind: "request", text: "the request could not be built" };
221
+ }
222
+ const known = [
223
+ [/^(ENOTFOUND|EAI_AGAIN|EAI_NODATA|ENODATA)$/, "the name does not resolve"],
224
+ [/^ECONNREFUSED$/, "the connection was refused"],
225
+ [/^ECONNRESET$/, "the connection was reset"],
226
+ [/^(EHOSTUNREACH|ENETUNREACH|EHOSTDOWN)$/, "the host is not reachable from here"],
227
+ [/^(ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT|UND_ERR_HEADERS_TIMEOUT)$/, "the connection timed out"],
228
+ [/^(CERT_|DEPTH_ZERO|SELF_SIGNED|UNABLE_TO_VERIFY|ERR_TLS|ERR_SSL)/, "the TLS certificate was rejected"],
229
+ ];
230
+ for (const [re, text] of known) if (re.test(code)) return { kind: code, text };
231
+ // An unrecognised errno is worth keeping: it is the string the reader will
232
+ // paste into a search engine.
233
+ if (code) return { kind: code, text: `the request failed (${code})` };
234
+ return { kind: "unknown", text: "no response" };
235
+ }
236
+
237
+ export function exists(p) {
238
+ try {
239
+ fs.accessSync(p);
240
+ return true;
241
+ } catch {
242
+ return false;
243
+ }
244
+ }
245
+
246
+ /** True only when the path is genuinely not there, not when it is unreadable. */
247
+ export function isMissing(p) {
248
+ try {
249
+ fs.statSync(p);
250
+ return false;
251
+ } catch (e) {
252
+ // EACCES/EPERM/ELOOP/EMFILE mean the path may well be fine and this process
253
+ // just cannot look at it. Treating those as "missing" is how a permission
254
+ // problem turns into a deleted settings entry.
255
+ return e?.code === "ENOENT" || e?.code === "ENOTDIR";
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Canonical form of a path for comparisons: symlinks resolved, separators
261
+ * unified, case-folded where the filesystem is case-insensitive.
262
+ *
263
+ * Resolving symlinks matters in practice: with pnpm, a package's directory is a
264
+ * link into a global store, so the path Pi reports for a loaded tool and the
265
+ * path resolved from settings are different strings for the same package.
266
+ */
267
+ export function normalizePath(p) {
268
+ if (typeof p !== "string" || !p) return "";
269
+ let absolute;
270
+ try {
271
+ absolute = fs.realpathSync.native(p);
272
+ } catch {
273
+ absolute = path.resolve(p);
274
+ }
275
+ const slashed = absolute.replace(/\\/g, "/");
276
+ return CASE_INSENSITIVE ? slashed.toLowerCase() : slashed;
277
+ }
278
+
279
+ /**
280
+ * Newest sibling backup of a file, matching either the `.checkup-bak-*` naming
281
+ * this package uses or the common `file.json.bak` convention other tools use.
282
+ *
283
+ * Ordered by mtime, not by name: a plain `.bak` written a minute ago is more
284
+ * useful to restore than a `.checkup-bak-2026-01-01` from last year, and the
285
+ * two naming schemes do not sort chronologically against each other.
286
+ */
287
+ export function newestBackup(file) {
288
+ const dir = path.dirname(file);
289
+ const base = path.basename(file);
290
+ let entries;
291
+ try {
292
+ entries = fs.readdirSync(dir);
293
+ } catch {
294
+ return null;
295
+ }
296
+ let best = null;
297
+ let bestTime = Number.NEGATIVE_INFINITY;
298
+ for (const entry of entries) {
299
+ if (entry === base || !entry.startsWith(base)) continue;
300
+ // Anchored: the marker has to start right after `settings.json`. Unanchored,
301
+ // `settings.json.local.bak` matched too, and restoring a corrupt settings.json
302
+ // wrote that unrelated file over it.
303
+ if (!/^\.(bak|checkup-bak)(-|$)/.test(entry.slice(base.length))) continue;
304
+ const full = path.join(dir, entry);
305
+ let mtimeMs;
306
+ try {
307
+ mtimeMs = fs.statSync(full).mtimeMs;
308
+ } catch {
309
+ continue; // dangling symlink or raced deletion
310
+ }
311
+ // A bulk copy can give several backups the same mtime; break the tie on
312
+ // the name so repeated runs always pick the same file.
313
+ if (mtimeMs > bestTime || (mtimeMs === bestTime && best !== null && full > best)) {
314
+ best = full;
315
+ bestTime = mtimeMs;
316
+ }
317
+ }
318
+ return best;
319
+ }
320
+
321
+ /**
322
+ * Attribute a source path to the package that owns it, so findings can say
323
+ * "pi-lens did this" instead of dumping an absolute path at the user.
324
+ */
325
+ export function ownerOf(sourcePath) {
326
+ // A registry entry is a third-party object: `sourceInfo.path` can be a number
327
+ // or an object, and calling `.startsWith` on it took the whole Registry group
328
+ // down as one crash row.
329
+ if (typeof sourcePath !== "string" || !sourcePath) return "unknown";
330
+ if (sourcePath.startsWith("builtin:")) return "builtin";
331
+ const normalized = sourcePath.replace(/\\/g, "/");
332
+ const marker = "/node_modules/";
333
+ const i = normalized.lastIndexOf(marker);
334
+ if (i === -1) {
335
+ // User-level or project-level extension file, not from a package.
336
+ const parts = normalized.split("/");
337
+ const parent = parts.at(-2) ?? "";
338
+ return parent === "extensions" ? `local:${parts.at(-1)}` : `local:${parent}`;
339
+ }
340
+ const rest = normalized.slice(i + marker.length).split("/");
341
+ const name = rest[0]?.startsWith("@") ? `${rest[0]}/${rest[1]}` : rest[0];
342
+ // `/x/node_modules/` has nothing after the marker; returning "" rendered the
343
+ // attribution row as " (3)" — a count with no owner.
344
+ return name || "unknown";
345
+ }
346
+
347
+ /**
348
+ * A list, cut to `max` items with a count of what was left out.
349
+ *
350
+ * Every row that prints a list had its own version of this, and three of them
351
+ * simply stopped — a reader could not tell a complete list from a truncated one.
352
+ */
353
+ export function truncateList(list, max, separator = ", ") {
354
+ const items = Array.from(list ?? []);
355
+ if (items.length <= max) return items.join(separator);
356
+ return `${items.slice(0, max).join(separator)}${separator}... ${items.length - max} more`;
357
+ }
358
+
359
+ /** Read the `source` out of a settings `packages` entry, whatever shape it has. */
360
+ export function sourceOf(spec) {
361
+ if (typeof spec === "string") return spec;
362
+ const source = spec?.source;
363
+ return typeof source === "string" ? source : null;
364
+ }
365
+
366
+ /** Map a settings `packages` entry to the directory Pi installed it into. */
367
+ export function resolvePackageDir(spec, root = agentDir()) {
368
+ const s = sourceOf(spec);
369
+ if (!s) return null;
370
+ if (s.startsWith("npm:")) {
371
+ // `npm:name@1.2.3`, `npm:@scope/name@^2` and `npm:name@latest` all install
372
+ // into the same directory: the version is a request, not part of the path.
373
+ // Taking the spec literally made every pinned package read as missing from
374
+ // node_modules — a `fail` that then offered to delete the entry.
375
+ const body = s.slice(4);
376
+ const at = body.indexOf("@", body.startsWith("@") ? 1 : 0);
377
+ return path.join(root, "npm", "node_modules", at === -1 ? body : body.slice(0, at));
378
+ }
379
+ if (s.startsWith("./") || s.startsWith("../") || path.isAbsolute(s)) return path.resolve(root, s);
380
+ // git:, https:, and bare names are resolved by Pi itself; static probing
381
+ // cannot follow them reliably, so callers treat null as "not inspectable".
382
+ return null;
383
+ }
384
+
385
+ /**
386
+ * The exact version an `npm:` spec pins, or null.
387
+ *
388
+ * Only an exact pin is comparable: `^2` or `latest` is a request the installer
389
+ * is free to satisfy with whatever is newest, and calling that a mismatch would
390
+ * be a warning nobody can clear.
391
+ */
392
+ export function pinnedVersion(spec) {
393
+ const s = sourceOf(spec);
394
+ if (!s || !s.startsWith("npm:")) return null;
395
+ const m = /@=?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)$/.exec(s.slice(4));
396
+ return m ? m[1] : null;
397
+ }
398
+
399
+ /**
400
+ * Match one path segment against a `*`-glob.
401
+ *
402
+ * Hand-rolled instead of compiled to a RegExp: a manifest glob like
403
+ * `*a*a*a*a*a*a*a*a*b` becomes `^.*a.*a…b$`, which backtracks catastrophically
404
+ * on a long name. Splitting on `*` and scanning with indexOf stays linear.
405
+ */
406
+ function segmentMatches(pattern, name) {
407
+ const parts = pattern.split("*");
408
+ if (parts.length === 1) return pattern === name;
409
+ const head = parts[0];
410
+ const tail = parts.at(-1);
411
+ if (!name.startsWith(head)) return false;
412
+ if (name.length - head.length < tail.length || !name.endsWith(tail)) return false;
413
+ let pos = head.length;
414
+ const limit = name.length - tail.length;
415
+ for (const part of parts.slice(1, -1)) {
416
+ const i = name.indexOf(part, pos);
417
+ if (i === -1 || i + part.length > limit) return false;
418
+ pos = i + part.length;
419
+ }
420
+ return true;
421
+ }
422
+
423
+ /** True when a manifest glob matches something on disk. Handles `*` segments only. */
424
+ export function globExists(dir, pattern) {
425
+ // A manifest may declare an absolute entry, or one that climbs out of the
426
+ // package with `..`. `path.join(dir, "/abs/file.js")` silently re-roots the
427
+ // pattern under `dir`, so a file that resolves fine at load time was reported
428
+ // as a missing entry — a `fail` with a reinstall hint.
429
+ const absolute = path.isAbsolute(pattern);
430
+ const root = absolute ? path.parse(pattern).root : "";
431
+ const base = absolute ? root : dir;
432
+ // An absolute pattern already names its root. Joining it back onto that root
433
+ // works on POSIX only by accident (`path.join("/", "/x")` collapses) and
434
+ // produced `D:\D:\a\b.js` on Windows, so the absolute pattern is used as-is.
435
+ if (!pattern.includes("*")) return exists(absolute ? pattern : path.join(base, pattern));
436
+ // The segments that spell the root must not be appended to it a second time:
437
+ // on Windows the leading `D:` would otherwise become a directory name.
438
+ const rest = absolute ? pattern.slice(root.length) : pattern;
439
+ let cur = base;
440
+ for (const seg of rest.replace(/\\/g, "/").split("/")) {
441
+ if (!seg) continue;
442
+ if (!seg.includes("*")) {
443
+ cur = path.join(cur, seg);
444
+ continue;
445
+ }
446
+ let entries;
447
+ try {
448
+ entries = fs.readdirSync(cur);
449
+ } catch {
450
+ return false;
451
+ }
452
+ const hit = entries.find((e) => segmentMatches(seg, e));
453
+ if (!hit) return false;
454
+ cur = path.join(cur, hit);
455
+ }
456
+ return exists(cur);
457
+ }
458
+
459
+ /**
460
+ * fetch() with a hard timeout. Returns `{ok, status, error, kind, aborted}` and
461
+ * never throws.
462
+ *
463
+ * `error` is prose and `kind` is the machine-readable cause (see
464
+ * `describeNetError`); the raw errno and undici message never leave this
465
+ * function, because they end up in the report, the TUI notification and the
466
+ * `--json` payload. `aborted` records that this process stopped waiting, which
467
+ * is the one failure that says nothing about the endpoint.
468
+ */
469
+ export async function timedFetch(url, init = {}, timeout = TIMEOUT_MS) {
470
+ const controller = new AbortController();
471
+ // Set by our own timer only, so it cannot be confused with a caller's abort.
472
+ let aborted = false;
473
+ const timer = setTimeout(() => {
474
+ aborted = true;
475
+ controller.abort();
476
+ }, timeout);
477
+ try {
478
+ const res = await fetch(url, { ...init, signal: controller.signal });
479
+ // We only want the status line. Dropping the body lets undici release the
480
+ // socket instead of parking one connection per probe until keep-alive ends.
481
+ await res.body?.cancel().catch(() => {});
482
+ return { ok: true, status: res.status, aborted: false };
483
+ } catch (e) {
484
+ const { kind, text } = describeNetError(e, aborted, timeout);
485
+ // `timeout` is carried out so a caller can phrase the deadline without
486
+ // parsing it back out of the sentence.
487
+ return { ok: false, status: 0, aborted, error: text, kind, timeoutMs: timeout };
488
+ } finally {
489
+ clearTimeout(timer);
490
+ }
491
+ }
492
+
493
+ /**
494
+ * Resolve a hostname to addresses. Never throws and never outlives `timeout`.
495
+ *
496
+ * `dns.lookup` takes no timeout of its own: it hands off to getaddrinfo on the
497
+ * libuv threadpool, which on a blackholed resolver blocks for tens of seconds.
498
+ * A report that promises a hard timeout cannot leave one check unbounded.
499
+ */
500
+ export async function resolveHost(host, timeout = 4000) {
501
+ let timer;
502
+ const deadline = new Promise((resolve) => {
503
+ timer = setTimeout(
504
+ () => resolve({ ok: false, timedOut: true, error: `no answer within ${Math.round(timeout / 1000)}s` }),
505
+ timeout,
506
+ );
507
+ });
508
+ const lookup = dns.lookup(host, { all: true }).then(
509
+ (addrs) => ({ ok: true, addresses: addrs.map((a) => a.address) }),
510
+ // Same translation as the HTTP path: `getaddrinfo ENOTFOUND` is an errno,
511
+ // and this row is read by someone deciding whether their DNS is broken.
512
+ (e) => ({ ok: false, timedOut: false, error: describeNetError({ cause: e }).text }),
513
+ );
514
+ try {
515
+ return await Promise.race([lookup, deadline]);
516
+ } finally {
517
+ clearTimeout(timer);
518
+ }
519
+ }
520
+
521
+ /**
522
+ * The proxy this process should use, from the environment, or undefined.
523
+ *
524
+ * Read here rather than in one module: the provider probe and the runtime proxy
525
+ * check have to agree on what "a proxy is configured" means, and they used to
526
+ * disagree — Runtime reported one while the probe silently ignored it.
527
+ */
528
+ export function proxyFromEnv(env = process.env) {
529
+ for (const key of ["https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY", "all_proxy", "ALL_PROXY"]) {
530
+ const value = env[key];
531
+ if (typeof value === "string" && value.trim()) return value.trim();
532
+ }
533
+ return undefined;
534
+ }
535
+
536
+ /**
537
+ * Make one request through `proxy` with curl, and return the HTTP status code.
538
+ *
539
+ * curl is used instead of fetch because Node's global fetch ignores the proxy
540
+ * environment variables entirely: a user behind a corporate proxy got
541
+ * "unreachable" for endpoints that work fine in their browser. curl honours
542
+ * `proxy` natively, and this package already depends on it for the opt-in
543
+ * forwarding test.
544
+ *
545
+ * The proxy URL and every header go in a config file created with mode 0600 —
546
+ * a no-op on Windows, where the per-user temp directory ACL is what protects it
547
+ * — and never in argv: `-x`/`-H` on the command line are readable by every other
548
+ * user on the box through `ps` and /proc, and both routinely carry a credential.
549
+ */
550
+ export function curlConfig({ url, proxy, headers = {} }) {
551
+ // curl config syntax: quoted values, backslash escapes. Line breaks are
552
+ // stripped first: a newline in the value would end the directive and let the
553
+ // rest of the string start a new one.
554
+ const quote = (value) =>
555
+ String(value)
556
+ .replace(/[\r\n]/g, "")
557
+ .replace(/\\/g, "\\\\")
558
+ .replace(/"/g, '\\"');
559
+ const lines = [];
560
+ // The URL goes in the config file too: it can carry a query string, and argv
561
+ // is world-readable through `ps`. curl accepts `url = "…"`.
562
+ lines.push(`url = "${quote(url)}"`);
563
+ if (proxy) lines.push(`proxy = "${quote(proxy)}"`);
564
+ for (const [name, value] of Object.entries(headers)) {
565
+ if (value === undefined || value === null) continue;
566
+ lines.push(`header = "${quote(`${name}: ${value}`)}"`);
567
+ }
568
+ return `${lines.join("\n")}\n`;
569
+ }
570
+
571
+ /** curl's argv for one probe. Exported so a test can prove what is not in it. */
572
+ export function curlArgs(configFile, maxTime = 6) {
573
+ return [
574
+ // -q has to be first: it stops curl reading a ~/.curlrc that could inject
575
+ // options — including a different proxy than the one being tested.
576
+ "-q",
577
+ // -sS: quiet progress, but still print the failure reason to stderr.
578
+ "-sS",
579
+ "-o",
580
+ isWindows ? "NUL" : "/dev/null",
581
+ "-w",
582
+ "%{http_code}",
583
+ "--max-time",
584
+ String(maxTime),
585
+ // An empty value disables NO_PROXY for this request. The check exists to
586
+ // answer "does this proxy work", and a NO_PROXY entry in the environment
587
+ // would send the probe around it while the row still said "through <proxy>".
588
+ "--noproxy",
589
+ "",
590
+ "--config",
591
+ configFile,
592
+ ];
593
+ }
594
+
595
+ export async function curlProbe(url, { proxy, headers = {}, maxTime = 6, timeout = 8000 } = {}) {
596
+ let dir;
597
+ try {
598
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-checkup-"));
599
+ const configFile = path.join(dir, "curl.conf");
600
+ fs.writeFileSync(configFile, curlConfig({ url, proxy, headers }), { mode: 0o600 });
601
+ const res = await sh("curl", curlArgs(configFile, maxTime), timeout);
602
+ // curl prints 000 for a refused or timed-out connection and exits 7 or 28.
603
+ // Reading only `ok` turned a dead proxy into "skipped, tool unavailable".
604
+ return { missing: res.missing === true, code: (res.out ?? "").trim(), error: res.ok ? "" : String(res.err ?? "").trim() };
605
+ } catch (e) {
606
+ // The request never left the machine. Consumers check this first, so a
607
+ // failure to create the config file is reported as "could not be set up"
608
+ // rather than as an endpoint that did not answer.
609
+ return { setupFailed: true, missing: false, code: "", error: String(e?.message ?? e) };
610
+ } finally {
611
+ if (dir) {
612
+ try {
613
+ fs.rmSync(dir, { recursive: true, force: true });
614
+ } catch {
615
+ // Best effort; the file is 0600 and inside the OS temp dir.
616
+ }
617
+ }
618
+ }
619
+ }
620
+
621
+ /** Is this HTTP status one that proves the endpoint answered? */
622
+ export function statusIsAlive(code) {
623
+ return code === 200 || code === 401 || code === 403;
624
+ }
625
+
626
+ /**
627
+ * Whether a bare string is a host at all, rather than a token that happens to
628
+ * contain dots.
629
+ *
630
+ * A pasted JWT — `eyJhbGciOiJIUzI1NiJ9.eyJzdWIi…` — passes the character-class
631
+ * test above: base64url is letters, digits, `-` and `_`, and the dots separate
632
+ * segments exactly like a hostname separates labels. Only the shape tells them
633
+ * apart: no DNS label is 25 characters long, and every token payload segment is.
634
+ */
635
+ function looksLikeHost(value) {
636
+ // IPv6 literal, with or without a port.
637
+ if (/^\[[0-9a-f:.]+\](:\d+)?(\/|$)/i.test(value)) return true;
638
+ const hostPart = value.split(/[\/:]/)[0];
639
+ const labels = hostPart.split(".").filter(Boolean);
640
+ if (!labels.length) return false;
641
+ if (labels.some((label) => label.length > 24)) return false;
642
+ const last = labels[labels.length - 1];
643
+ // A real host ends in a TLD-shaped label of at least two characters, or in an
644
+ // address octet. The length floor is what keeps `a.b.c` — or a truncated JWT —
645
+ // from being reprinted as if it were a hostname.
646
+ return /^[a-z][a-z0-9-]+$/i.test(last) || /^\d{1,3}$/.test(last);
647
+ }
648
+
649
+ /**
650
+ * A URL that is safe to put in a report: scheme, host, port and path, with the
651
+ * userinfo and the query string dropped.
652
+ *
653
+ * Both are routine places to paste a credential — a self-hosted gateway with
654
+ * `https://user:pass@host/v1`, or a provider URL copied with `?key=sk-…` still
655
+ * attached — and a report is the one place they must not reappear.
656
+ */
657
+ export function redactUrl(raw) {
658
+ const text = String(raw ?? "").trim();
659
+ if (!text) return "(empty)";
660
+ if (!text.includes("://") && !text.startsWith("//")) {
661
+ // Not a URL. Strip anything that looks like a credential anyway rather than
662
+ // echoing the raw paste back at the user — and only echo what is shaped
663
+ // like a host at all. `sk-ant-api03-…` pasted into baseUrl by mistake has
664
+ // no "." "/" or ":" in it, so the whole string was a secret the report
665
+ // reprinted into the terminal and the JSON.
666
+ const bare = scrubCredentials(text).split(/[?#]/)[0];
667
+ // The bracketed alternative is an IPv6 literal: its colons are part of the
668
+ // address, so the plain host character class cannot express it.
669
+ const hostShaped =
670
+ /[./:]/.test(bare) &&
671
+ /^(\[[0-9a-f:.]+\]|[a-z0-9._-]+)(:\d+)?(\/[\w.\-/]*)?$/i.test(bare) &&
672
+ looksLikeHost(bare);
673
+ return hostShaped ? bare : "(unparsable value)";
674
+ }
675
+ try {
676
+ const url = new URL(text);
677
+ // An empty hostname means the parse produced an opaque path, not an address:
678
+ // `git:https://…` and the routine `localhost:11434/v1` paste both land here.
679
+ // Printing `url.origin` would then print the string "null".
680
+ if (!url.hostname) return scrubCredentials(text);
681
+ const path = url.pathname === "/" ? "" : url.pathname;
682
+ return `${url.protocol}//${url.host}${path}`;
683
+ } catch {
684
+ return scrubCredentials(text);
685
+ }
686
+ }
687
+
688
+ /**
689
+ * A proxy URL safe to put in a report.
690
+ *
691
+ * Corporate proxies are routinely configured as http://user:password@host:port,
692
+ * so the raw env value must never reach the terminal, the JSON payload, or a CI
693
+ * log — which is exactly where --offline used to print it. Scheme, host and port
694
+ * are all the user needs to recognise their own proxy.
695
+ */
696
+ export function redactProxy(raw) {
697
+ const text = String(raw ?? "").trim();
698
+ if (!text) return "(empty)";
699
+
700
+ // Only trust the URL parser when the value actually looks like a URL. Without
701
+ // "//", `alice:pw@host:8080` parses as scheme "alice:" and the parser returns
702
+ // a meaningless origin instead of a scrubbed one.
703
+ if (text.includes("://")) {
704
+ try {
705
+ const url = new URL(text);
706
+ if (url.hostname) {
707
+ // Only the port the user actually wrote: printing a default :443 next to
708
+ // a URL that has no port makes the report disagree with the config the
709
+ // user is looking at, which is the one thing this string is for.
710
+ return `${url.protocol}//${url.hostname}${url.port ? `:${url.port}` : ""}`;
711
+ }
712
+ } catch {
713
+ // Fall through to the textual scrub below.
714
+ }
715
+ }
716
+
717
+ // Everything before the last "@" is a credential; everything after it is the
718
+ // proxy the user needs to recognise.
719
+ const at = text.lastIndexOf("@");
720
+ if (at !== -1) return text.slice(at + 1).replace(/^[a-z0-9+.-]+:\/\//i, "") || "(configured)";
721
+
722
+ // No "@" at all: `alice:s3cr3t` and `http://alice:s3cr3t` are a user and a
723
+ // password with no host, which the URL parser rejects. The shape itself is
724
+ // the credential, so nothing of it can be shown. A bare `host:port` is safe:
725
+ // the port is what makes it a proxy address rather than a secret.
726
+ if (/^[a-z0-9._\-[\]]+:\d+$/i.test(text)) return text;
727
+ // Nothing else is echoed. A bare word is exactly the shape of an API token —
728
+ // `https_proxy=sk-live-…` printed the whole secret here and again from curl's
729
+ // own stderr — and a value with a colon but no port is a user and password
730
+ // with no host. Neither can be told apart from a hostname, and the promise in
731
+ // the README and in --help is that a value which is nothing but a credential
732
+ // prints `(configured)`.
733
+ return "(configured)";
734
+ }
735
+
736
+ /**
737
+ * A package source, safe to print.
738
+ *
739
+ * `settings.json` legitimately holds sources that carry a token, because the
740
+ * only way to install a private repo over HTTPS is one in the URL:
741
+ * `git:https://ghp_xxx@github.com/me/private`. That string used to reach the
742
+ * report, the `--json` payload and therefore any CI log verbatim.
743
+ */
744
+ export function redactSource(spec) {
745
+ const text = String(spec ?? "");
746
+ if (text.includes("://")) return scrubCredentials(text);
747
+ // `git@github.com:me/x` is a git source in scp form: one "@", no password, and
748
+ // hiding it made a legitimate entry unreadable.
749
+ if (/^[a-z0-9._-]+@[a-z0-9._-]+:[^\s@]*$/i.test(text)) return scrubCredentials(text);
750
+ // `npm:pkg`, `git:…`, `file:./x`: a scheme is what makes a string a source.
751
+ // Only the schemes Pi understands, though — `alice:s3cr3t` also starts with
752
+ // `word:`, and treating it as a scheme printed a username and password as if
753
+ // they were a package name.
754
+ if (/^(npm|git|file|https?|ssh|link):/i.test(text)) return scrubCredentials(text);
755
+ // No scheme, but a path: `./ext/single.js` and `/opt/ext` are local sources
756
+ // with no credential in them, and hiding them made a real finding unreadable.
757
+ // Globs and scoped paths are sources too: `dist/*.js` and `./packages/@acme/ext`
758
+ // are what a manifest declares, and hiding them made an "entry files" finding
759
+ // unreadable. `@` is allowed only as a whole-segment prefix, so a
760
+ // `user:pass@host` (which has a colon) and a `user@host` stay out.
761
+ const segment = "@?[a-z0-9._*+!-]+";
762
+ const pathLike =
763
+ new RegExp(`^(${segment})(\\/(${segment}))+$`, "i").test(text) ||
764
+ /^(\.{0,2}\/|\/|~\/)/.test(text) ||
765
+ // Windows: `D:\ext\single.js`, `D:/ext/x.js`, `.\ext\x.js`, `..\x.js`,
766
+ // and backslash-separated relative paths. Without these every local source
767
+ // on Windows reached the report as "(unparsable value)", so the reader could
768
+ // not tell which file a package was missing. The drive form is one letter
769
+ // plus a separator, which no `user:pass@host` shape can satisfy.
770
+ /^[a-z]:[\\/]/i.test(text) ||
771
+ /^\.{1,2}[\\/]/.test(text) ||
772
+ new RegExp(`^(${segment})(\\\\(${segment}))+$`, "i").test(text);
773
+ if (pathLike) return scrubCredentials(text);
774
+ // Anything else is not a source. It may be a credential pasted into the wrong
775
+ // field — `sk-ant-…` in the packages array — and scrubCredentials only knows
776
+ // how to hide userinfo and query strings, so the value used to reach the
777
+ // report, the TUI notification and `--json` verbatim. `redactUrl` already
778
+ // keeps a host-shaped value and hides everything else.
779
+ return redactUrl(text);
780
+ }
781
+
782
+ /**
783
+ * Scrub credentials out of arbitrary text — an error message that quotes the URL
784
+ * it failed on, for instance. Node's fetch says
785
+ * `Request cannot be constructed from a URL that includes credentials:
786
+ * https://user:pass@host/v1/models`, and that message ends up in the report.
787
+ */
788
+ export function scrubCredentials(text) {
789
+ return (
790
+ String(text ?? "")
791
+ // The scheme is optional on purpose: `//user:pass@host/v1` is a
792
+ // protocol-relative paste, it fails `new URL` without a base, and it used
793
+ // to be echoed unchanged by the very branch that exists to hide it.
794
+ .replace(/([a-z][a-z0-9+.-]*:)?\/\/[^/\s@]+@/gi, "$1//***@")
795
+ // Userinfo without the slashes. `alice:pw@host:22` and `alice:pw@host/x`
796
+ // are the shapes a hand-pasted DSN has, and the rule above only matches
797
+ // the `//` form — so `user:pass@host` reached the report verbatim.
798
+ // A single `user@host` has no colon before the "@" and is left alone: it
799
+ // is a username, not a credential.
800
+ // The user part may not be a scheme: `npm:pi-lens@0.9.1` is a package
801
+ // spec, and masking it produced "***@0.9.1" in the version-pin row.
802
+ // A ":" or "/" before the user part is allowed, so the second colon in
803
+ // `npm:alice:pw@host` and the single slash in `https:/alice:pw@host`
804
+ // still start a match.
805
+ .replace(/(^|[\s"'=(,:/])(?!(?:npm|git|file|https?|ssh|link):)([^\s/@:'"]+):([^\s/@]*?)@/g, "$1***@")
806
+ // A query string is dropped whole rather than masked: it is where a pasted
807
+ // `?key=sk-…` lives, and nothing in a report needs it.
808
+ .replace(/\?[^\s]*/g, "")
809
+ );
810
+ }
811
+
812
+ /**
813
+ * Strip the brackets WHATWG URLs keep on IPv6 literals.
814
+ *
815
+ * `new URL("http://[::1]:11434/v1").hostname` is `"[::1]"`, and both
816
+ * `dns.lookup` and `net.connect` want the bare address. Passing the bracketed
817
+ * form made a loopback provider look remote and unresolvable.
818
+ */
819
+ export function bareHost(host) {
820
+ return String(host ?? "").replace(/^\[|\]$/g, "");
821
+ }
822
+
823
+ /** Loopback in any of its spellings, IPv6 literals included. */
824
+ export function isLoopbackHost(host) {
825
+ const bare = bareHost(host).toLowerCase();
826
+ return bare === "localhost" || bare === "::1" || bare === "0:0:0:0:0:0:0:1" || /^127\./.test(bare) || bare.endsWith(".localhost");
827
+ }
828
+
829
+ /** Human-readable byte size. */
830
+ export function humanBytes(n) {
831
+ const units = ["B", "KB", "MB", "GB", "TB"];
832
+ let v = Number(n);
833
+ let i = 0;
834
+ while (v >= 1024 && i < units.length - 1) {
835
+ v /= 1024;
836
+ i++;
837
+ }
838
+ return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
839
+ }