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/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
|
+
}
|