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.
@@ -0,0 +1,543 @@
1
+ // Network checks — are the endpoints your config points at actually alive?
2
+ //
3
+ // Deliberately provider-agnostic: every OpenAI-compatible provider exposes
4
+ // /models, and a 401/403 still proves the host answered. No vendor SDK, no
5
+ // API key validation (this package never spends your tokens).
6
+ import {
7
+ bareHost,
8
+ curlProbe,
9
+ isLoopbackHost,
10
+ proxyFromEnv,
11
+ readJson,
12
+ redactProxy,
13
+ redactUrl,
14
+ resolveHost,
15
+ scrubCredentials,
16
+ statusIsAlive,
17
+ timedFetch,
18
+ truncateList,
19
+ } from "../util.js";
20
+
21
+ export const group = "Network";
22
+
23
+ /**
24
+ * Does this provider declare a header whose value is a credential?
25
+ *
26
+ * Matched by name, never by value: the values are the secrets. Pi interpolates
27
+ * `$NAME` in header values and sends them, so a header carrying a token is the
28
+ * same exposure as an `apiKey` and has to count the same way.
29
+ */
30
+ function hasCredentialHeader(headers) {
31
+ if (!headers || typeof headers !== "object" || Array.isArray(headers)) return false;
32
+ return Object.keys(headers).some((name) =>
33
+ /(^|[-_])(authorization|auth|api[-_]?key|access[-_]?key|token|secret|password|bearer)([-_]|$)/i.test(name),
34
+ );
35
+ }
36
+
37
+ export async function run({ offline = false, agentDir: root, modelsFile, env = process.env, hasRuntime = false }) {
38
+ const results = [];
39
+ const models = readJson(modelsFile);
40
+ if (!models.ok) {
41
+ // Same distinction as Config: a parse failure is corruption, an oversized or
42
+ // unreadable file is not, and saying "not valid JSON" about a valid file is
43
+ // a claim this check never established.
44
+ let reason = "could not be inspected";
45
+ if (models.missing) reason = "is missing";
46
+ else if (models.invalid) reason = "is not valid JSON";
47
+ // "cannot read" covered a missing file and a corrupt one with the same
48
+ // jargon, and those are two different problems with two different fixes.
49
+ return [
50
+ {
51
+ group,
52
+ id: "providers",
53
+ title: "providers",
54
+ status: "skip",
55
+ detail: `models.json ${reason} — no endpoint to probe`,
56
+ hint: models.missing
57
+ ? "Add providers to models.json, or configure them through Pi."
58
+ : models.invalid
59
+ ? "The Config group reports the parse error."
60
+ : undefined,
61
+ },
62
+ ];
63
+ }
64
+
65
+ // `providers` must be a non-null object. The `??` fallthrough to the whole
66
+ // file made two wrong shapes read as content: `"providers": null` iterated the
67
+ // top-level keys as if they were providers, and `"providers": 5` silently
68
+ // became "no provider with a baseUrl" — a claim about a config whose
69
+ // providers key is a number. Feeding the bad value back through the normal
70
+ // entry loop makes it land in the named shape row instead.
71
+ const fileData = models.data && typeof models.data === "object" && !Array.isArray(models.data) ? models.data : {};
72
+ const rawProviders = fileData.providers;
73
+ let providers;
74
+ if (rawProviders === undefined) {
75
+ providers = fileData;
76
+ } else if (rawProviders && typeof rawProviders === "object" && !Array.isArray(rawProviders)) {
77
+ providers = rawProviders;
78
+ } else {
79
+ providers = { providers: rawProviders };
80
+ }
81
+
82
+ // Worked out before the offline branch: whether a key would cross the network
83
+ // in the clear is a property of the config, not of the probe. Skipping it in
84
+ // offline mode hid a real misconfiguration from the one mode a locked-down
85
+ // user is most likely to run.
86
+ const cleartext = [];
87
+ const planned = [];
88
+ // Anything dropped for its shape is named here. Dropping silently made the
89
+ // report claim "no provider with a baseUrl" about a config it had just
90
+ // disproved — and a provider whose value is not even an object was the same
91
+ // hole one level up.
92
+ const shape = [];
93
+ const queryWithheld = [];
94
+ const proxy = proxyFromEnv(env);
95
+ for (const [name, provider] of Object.entries(providers)) {
96
+ if (!provider || typeof provider !== "object" || Array.isArray(provider)) {
97
+ shape.push(`${name} (${provider === null ? "null" : Array.isArray(provider) ? "array" : typeof provider}) is not a provider object`);
98
+ continue;
99
+ }
100
+ const baseUrl = provider.baseUrl ?? provider.base_url;
101
+ if (baseUrl === undefined || baseUrl === null || baseUrl === "") continue;
102
+ if (typeof baseUrl !== "string") {
103
+ shape.push(`${name} (${Array.isArray(baseUrl) ? "array" : typeof baseUrl}) — baseUrl is not a string`);
104
+ continue;
105
+ }
106
+ let url;
107
+ try {
108
+ url = new URL(baseUrl);
109
+ } catch {
110
+ // Redacted: the usual cause is a hand-pasted baseUrl, and a pasted URL
111
+ // is the one that still has `?key=…` on it.
112
+ planned.push({ name, invalid: redactUrl(baseUrl) });
113
+ continue;
114
+ }
115
+ // `localhost:11434/v1` — the routine hand-paste with no scheme — parses as
116
+ // scheme `localhost:` with an opaque path and an empty hostname. The probe
117
+ // then printed "null unreachable" and the DNS check invented a failure for
118
+ // the empty string, which is a finding the check never established.
119
+ if (!url.hostname || !/^https?:$/.test(url.protocol)) {
120
+ planned.push({ name, invalid: redactUrl(baseUrl) });
121
+ continue;
122
+ }
123
+ // Appended to the pathname, not to the raw string: `…/v1?api-version=2024`
124
+ // used to become `…/v1?api-version=2024/models`, so the probe asked a
125
+ // different question than the config posed and a 404 was reported as a
126
+ // broken provider.
127
+ url.pathname = url.pathname.replace(/\/+$/, "") + "/models";
128
+ // A gateway configured as https://user:pass@host/v1 cannot be fetched at
129
+ // all: fetch() refuses a URL that carries credentials, so the probe failed
130
+ // with an error that quoted the password back. Moving them into a header
131
+ // makes the probe work and keeps them out of the URL and the message.
132
+ const key = provider.apiKey ?? provider.api_key;
133
+ const hasKey = typeof key === "string" && key.length > 0;
134
+ let userinfo = null;
135
+ let userinfoError = null;
136
+ if (url.username || url.password) {
137
+ // A literal `%` survives the URL parser but throws in decodeURIComponent.
138
+ // The throw used to escape run(), replacing the whole Network group with
139
+ // a crash row that blamed pi-checkup for a typo in the user's config.
140
+ try {
141
+ const pair = `${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`;
142
+ userinfo = `Basic ${Buffer.from(pair).toString("base64")}`;
143
+ } catch {
144
+ // Only a blocker when the userinfo was the credential that would have
145
+ // been sent: with an apiKey configured, refusing to probe reported a
146
+ // credentials typo as an unreachable provider.
147
+ if (!hasKey) userinfoError = `${name}: credentials in baseUrl are not valid percent-encoding, so they were not sent`;
148
+ }
149
+ url.username = "";
150
+ url.password = "";
151
+ }
152
+ const loopback = isLoopbackHost(url.hostname);
153
+ // One rule for every credential shape: over plain http to a host that is
154
+ // not this machine, nothing is sent. Testing reachability is not worth
155
+ // putting a live secret on the wire in the clear. The apiKey branch had
156
+ // this guard from the start; userinfo went out as a Basic header, and
157
+ // `?key=…` gateways went out as part of the URL.
158
+ const hasQuery = url.search.length > 1;
159
+ // A custom header is a credential Pi interpolates and Pi sends, so a remote
160
+ // http:// endpoint whose only secret lives in `headers.Authorization` was
161
+ // reported clean by the one row that exists to catch exactly that. Names
162
+ // only — the values are never read here, let alone printed.
163
+ const headerAuth = hasCredentialHeader(provider.headers);
164
+ const cleartextAuth = (hasKey || userinfo || hasQuery || headerAuth) && url.protocol === "http:" && !loopback;
165
+ if (cleartextAuth) cleartext.push(name);
166
+ // A query string cannot be inspected for secrets, so it is not sent at all
167
+ // on a cleartext endpoint: dropping only the parts that look like keys
168
+ // would be guessing. The endpoint is left unprobed rather than probed with
169
+ // a different URL, because a 404 for the stripped request is not a fact
170
+ // about the configured endpoint.
171
+ // Not probed at all (see the probe loop): dropping the query string and
172
+ // sending the rest would answer a question the config did not ask.
173
+ const withheld = cleartextAuth && hasQuery;
174
+ if (withheld) queryWithheld.push(name);
175
+ planned.push({
176
+ name,
177
+ url,
178
+ withheld,
179
+ key: hasKey && !cleartextAuth ? key : null,
180
+ // An explicit apiKey wins over userinfo; a URL carrying both is a user
181
+ // mid-migration, not a configuration to guess about.
182
+ userinfo: hasKey || cleartextAuth ? null : userinfo,
183
+ userinfoError,
184
+ });
185
+ }
186
+
187
+ const cleartextRow = cleartext.length
188
+ ? {
189
+ group,
190
+ id: "cleartext",
191
+ title: "cleartext auth",
192
+ status: "warn",
193
+ detail: `${cleartext.join(", ")} use http:// with credentials configured${queryWithheld.length ? ` (query string on ${queryWithheld.join(", ")} withheld)` : ""}`,
194
+ hint: `Switch the baseUrl to https://, or use a host on localhost. ${
195
+ queryWithheld.length
196
+ ? "Endpoints carrying a query string were left unprobed; the rest were probed without the credential, so a 401 there is not a broken endpoint."
197
+ : "The probe was sent without the credential, which is also why a 401 here is not a broken endpoint."
198
+ }`,
199
+ }
200
+ : null;
201
+
202
+ // A baseUrl that cannot be parsed is a config fault, not a network one, so it
203
+ // is named here as well. Offline mode used to report only the endpoints it did
204
+ // not probe, which left `localhost:11434/v1` — the routine hand-paste — with
205
+ // no finding at all on a machine that runs with --offline.
206
+ const notUrl = planned.filter((item) => item.invalid !== undefined).map((item) => `${item.name}: baseUrl is not a URL`);
207
+ const shapeNotes = [...shape, ...notUrl];
208
+
209
+ // Built once so offline mode can emit it too: whether a baseUrl is even a
210
+ // string is a property of the config, not of the probe.
211
+ const shapeRow = shapeNotes.length
212
+ ? {
213
+ group,
214
+ id: "baseUrl-shape",
215
+ title: "baseUrl shape",
216
+ status: "warn",
217
+ detail: `${shapeNotes.join("; ")} — not probed`,
218
+ hint: "baseUrl has to be an absolute http(s) URL as a quoted string, like \"https://api.example.com/v1\".",
219
+ }
220
+ : null;
221
+
222
+ if (offline) {
223
+ const rows = [
224
+ {
225
+ group,
226
+ id: "providers",
227
+ title: "providers",
228
+ status: "skip",
229
+ detail: shapeNotes.length
230
+ ? `offline mode — ${planned.length} endpoint(s) not probed; ${shapeNotes.join("; ")}`
231
+ : `offline mode — ${planned.length} configured endpoint(s) not probed`,
232
+ hint: "Re-run without --offline to test reachability.",
233
+ },
234
+ // Emitted as a skip rather than omitted: a row that vanishes in offline
235
+ // mode makes the check count and the summary change between two runs of
236
+ // the same tool, which is exactly the kind of drift a health report
237
+ // should not have.
238
+ { group, id: "dns", title: "dns", status: "skip", detail: "offline mode — no host resolved" },
239
+ ];
240
+ if (cleartextRow) rows.push(cleartextRow);
241
+ if (shapeRow) rows.push(shapeRow);
242
+ return rows;
243
+ }
244
+
245
+ const hosts = new Map();
246
+ const probes = [];
247
+ for (const item of planned) {
248
+ // Drop the name: the hint already prefixes every sentence with the provider,
249
+ // so re-echoing the detail's own prefix printed "openai: openai: invalid…".
250
+ const outcome = (status, detail) => ({ name: item.name, status, detail: detail.replace(`${item.name}: `, "") });
251
+ if (item.invalid !== undefined) {
252
+ probes.push(Promise.resolve(outcome("fail", `${item.name}: invalid baseUrl ${item.invalid}`)));
253
+ continue;
254
+ }
255
+ const { name, url, key, userinfo, userinfoError, withheld } = item;
256
+ const host = bareHost(url.hostname);
257
+ hosts.set(host, name);
258
+ if (withheld) {
259
+ probes.push(
260
+ Promise.resolve({
261
+ name,
262
+ status: "skip",
263
+ detail: `${name}: not probed — a query string would have crossed the network in the clear`,
264
+ }),
265
+ );
266
+ continue;
267
+ }
268
+ if (userinfoError) {
269
+ probes.push(Promise.resolve({ name, status: "warn", detail: userinfoError }));
270
+ continue;
271
+ }
272
+ const auth = key ? `Bearer ${key}` : userinfo;
273
+ const headers = auth ? { Authorization: auth } : {};
274
+ probes.push(probeEndpoint(url, { headers, proxy }).then((outcome) => ({ ...outcome, name })));
275
+ }
276
+
277
+ // One line regardless of how many providers are configured. A user with ten
278
+ // endpoints should not get ten lines of green ticks; the detail is still
279
+ // there under `verbose`.
280
+ const outcomes = await Promise.all(probes);
281
+ const unhealthy = outcomes.filter((o) => o.status === "warn" || o.status === "fail");
282
+ const unprobed = outcomes.filter((o) => o.status === "skip");
283
+ // The denominator is the endpoints the probe actually judged. Probes that were
284
+ // withheld (a query string that would cross the network in the clear, a missing
285
+ // curl, a request that could not be built) appear in the detail as unprobed —
286
+ // counting them as judged said "1 of 3 unhealthy" where only two were asked.
287
+ const probed = outcomes.length - unprobed.length;
288
+ if (!outcomes.length) {
289
+ results.push({
290
+ group,
291
+ id: "providers",
292
+ title: "providers",
293
+ status: "skip",
294
+ detail: shapeNotes.length
295
+ ? `no provider with a usable baseUrl — ${shapeNotes.join("; ")}`
296
+ : "no provider with a baseUrl in models.json",
297
+ });
298
+ } else if (!unhealthy.length && unprobed.length) {
299
+ // Nothing was sent, so there is nothing to call healthy — but when some
300
+ // endpoints did answer, saying only "skipped" hid the ones that were proven.
301
+ const answered = outcomes.length - unprobed.length;
302
+ results.push({
303
+ group,
304
+ id: "providers",
305
+ title: "providers",
306
+ status: "skip",
307
+ detail: `${answered ? `${answered} endpoint(s) reachable; ` : ""}${unprobed.map((o) => o.detail).join("; ")}`,
308
+ });
309
+ } else if (!unhealthy.length) {
310
+ results.push({
311
+ group,
312
+ id: "providers",
313
+ title: "providers",
314
+ status: "ok",
315
+ detail: `${probed} endpoint(s) reachable`,
316
+ });
317
+ } else {
318
+ results.push({
319
+ group,
320
+ id: "providers",
321
+ title: "providers",
322
+ status: unhealthy.some((o) => o.status === "fail") ? "fail" : "warn",
323
+ detail: `${unhealthy.length} of ${probed} unhealthy: ${truncateList(unhealthy.map((o) => o.name), 6)}`,
324
+ // An outcome that knows why it failed brings its own advice; the generic
325
+ // sentence is only for the ones that do not (a refused connection, a
326
+ // timeout) and for the ones that sit next to them.
327
+ hint: [
328
+ `${unhealthy.map((o) => `${o.name}: ${o.detail}`).join("; ")}.`,
329
+ // Each hint is attributed. Unattributed, two endpoints failing for
330
+ // different reasons produced one paragraph whose sentences contradicted
331
+ // each other — "the request was cut off locally" followed by "the
332
+ // hostname does not resolve" — with nothing saying which was which.
333
+ ...new Set(unhealthy.filter((o) => o.hint).map((o) => `${o.name}: ${o.hint}`)),
334
+ unhealthy.some((o) => !o.hint) ? "Check the baseUrl, whether the server is running, and whether a proxy is required." : "",
335
+ ]
336
+ .filter(Boolean)
337
+ .join(" "),
338
+ });
339
+ }
340
+
341
+ if (cleartextRow) results.push(cleartextRow);
342
+ if (shapeRow) results.push(shapeRow);
343
+
344
+ // --- DNS ---------------------------------------------------------------
345
+ // A host that resolves to loopback while the baseUrl is remote, or does not
346
+ // resolve at all, explains a whole class of "it worked yesterday" failures.
347
+ // Loopback hosts are excluded from the count as well as from the lookup: a
348
+ // host this check deliberately skips is not a host that "resolved normally".
349
+ const dnsTargets = [...hosts].filter(([host]) => !isLoopbackHost(host));
350
+ const dnsChecks = await Promise.all(
351
+ dnsTargets.map(async ([host, provider]) => {
352
+ const res = await resolveHost(host);
353
+ if (!res.ok) {
354
+ // A deadline is this process giving up, not the resolver answering.
355
+ // Reporting it as "does not resolve" failed a host that resolves fine
356
+ // on a slow corporate resolver.
357
+ return {
358
+ host,
359
+ provider,
360
+ status: res.timedOut ? "warn" : "fail",
361
+ detail: res.timedOut
362
+ ? `${host} resolution timed out (${res.error}) — not proven broken`
363
+ : `${host} does not resolve — ${res.error}`,
364
+ };
365
+ }
366
+ const loopback = res.addresses.every((a) => a === "::1" || a.startsWith("127."));
367
+ if (loopback) {
368
+ return {
369
+ host,
370
+ provider,
371
+ status: "warn",
372
+ detail: `${host} resolves only to loopback (${res.addresses.join(", ")}) for provider "${provider}"`,
373
+ };
374
+ }
375
+ return null;
376
+ }),
377
+ );
378
+ const dnsProblems = dnsChecks.filter(Boolean);
379
+ const anyUnresolved = dnsProblems.some((p) => p.status === "fail");
380
+ if (dnsProblems.length) {
381
+ results.push({
382
+ group,
383
+ id: "dns",
384
+ title: "dns",
385
+ status: anyUnresolved ? "fail" : "warn",
386
+ detail: dnsProblems.map((p) => p.detail).join("; "),
387
+ // The in-session command has no --offline flag — naming a flag the reader
388
+ // cannot pass made the hint a dead end exactly when Pi would not boot.
389
+ hint: hasRuntime
390
+ ? "Fix DNS or use a proxy. To skip every network check, run `npx pi-checkup --offline` outside the session. A hosts-file override is usually the fastest repair."
391
+ : "Fix DNS or use a proxy, or pass --offline to skip every network check. A hosts-file override is usually the fastest repair.",
392
+ });
393
+ } else if (!dnsTargets.length) {
394
+ results.push({
395
+ group,
396
+ id: "dns",
397
+ title: "dns",
398
+ status: "skip",
399
+ detail: hosts.size ? "every provider host is loopback" : "no provider host to resolve",
400
+ });
401
+ } else {
402
+ results.push({
403
+ group,
404
+ id: "dns",
405
+ title: "dns",
406
+ status: "ok",
407
+ detail: `${dnsTargets.length} provider host(s) resolve normally`,
408
+ });
409
+ }
410
+
411
+ return results;
412
+ }
413
+
414
+ /** One-line verdict for a provider probe, kept out of the ternary chain. */
415
+ /**
416
+ * curl's own stderr, with the proxy it failed on replaced by its redacted form.
417
+ *
418
+ * curl quotes the configured proxy verbatim in `Could not resolve proxy: …`, so
419
+ * a proxy value that is nothing but a token reached the report through this
420
+ * path even though the row itself printed `(configured)`.
421
+ */
422
+ function scrubCurlError(text, proxy) {
423
+ const scrubbed = scrubCredentials(String(text ?? ""));
424
+ if (!proxy) return scrubbed;
425
+ return scrubbed.split(String(proxy)).join(redactProxy(proxy));
426
+ }
427
+
428
+ /**
429
+ * Probe one endpoint, through the proxy when one is configured.
430
+ *
431
+ * Node's global fetch ignores `https_proxy` and friends, so a user behind a
432
+ * corporate proxy saw "unreachable" for endpoints that work in their browser —
433
+ * a false finding in a tool whose only job is to avoid them. curl honours the
434
+ * proxy natively; it is already a dependency of the opt-in forwarding test.
435
+ */
436
+ async function probeEndpoint(url, { headers, proxy }) {
437
+ // A header value with a newline or a character outside Latin-1 is not an HTTP
438
+ // header value. fetch refuses to build the request; curl would silently send a
439
+ // different key (line breaks stripped) and a 401 from that request says nothing
440
+ // about the configured one. Neither path is allowed to make the attempt, and
441
+ // neither message may quote the value.
442
+ const badHeader = Object.values(headers ?? {}).some(
443
+ (value) => value !== undefined && value !== null && /[\r\n]|[^\u0000-\u00ff]/.test(String(value)),
444
+ );
445
+ if (badHeader) {
446
+ // A warning, not a skip: this is a fault in the config, not a probe that
447
+ // could not run. Nothing was sent, so the endpoint is still unjudged — the
448
+ // row must say that in the same breath, or it reads as "the server is down".
449
+ return {
450
+ status: "warn",
451
+ detail: "not probed: a configured header value cannot be sent as an HTTP header (a newline or a non-Latin-1 character)",
452
+ hint: "Fix the apiKey or headers for this provider in models.json — the value is rejected before the request leaves the machine.",
453
+ };
454
+ }
455
+ if (!proxy) {
456
+ const res = await timedFetch(url.toString(), { headers });
457
+ // A request Node refuses to construct never opens a socket. `describeNetError`
458
+ // already classifies that as `kind: "request"` without quoting the value —
459
+ // undici's own message embeds it, and the value here is the apiKey.
460
+ if (res.kind === "request") {
461
+ return { status: "skip", detail: "not probed: the request could not be built — a header value or baseUrl the fetch API rejects" };
462
+ }
463
+ // 401/403 means the endpoint is up and merely disagrees about auth, which
464
+ // is a configuration problem, not a reachability problem.
465
+ const alive = res.ok && (res.status === 200 || res.status === 401 || res.status === 403);
466
+ // A refused connection, a rejected certificate, an HTTP 5xx: the endpoint was
467
+ // asked and answered with something other than life. That is the finding this
468
+ // check exists to report, and reporting it as a warning exited 0 — CI and
469
+ // `npx pi-checkup && start-pi` sailed past the one row that said unhealthy.
470
+ // Only a local timeout stays a warning: the deadline is this process's, and
471
+ // the detail already refuses to call the endpoint unreachable. A response
472
+ // status — 503 and 404 included — is a definitive answer from the endpoint,
473
+ // so the comment above and the status have to agree.
474
+ const failed = !alive && !res.aborted;
475
+ return { status: alive ? "ok" : failed ? "fail" : "warn", detail: describeProbe(url, res, alive), hint: probeHint(res) };
476
+ }
477
+ const res = await curlProbe(url.toString(), { proxy, headers });
478
+ if (res.setupFailed) {
479
+ // The probe never left the machine; blaming the endpoint would be a finding
480
+ // this check did not establish.
481
+ return { status: "skip", detail: `not probed: ${scrubCurlError(res.error, proxy)}` };
482
+ }
483
+ if (res.missing) {
484
+ return { status: "skip", detail: "not probed: a proxy is configured but curl is not installed" };
485
+ }
486
+ const code = Number(res.code);
487
+ const alive = statusIsAlive(code);
488
+ if (alive) {
489
+ return { status: "ok", detail: `${redactUrl(url.toString())} answered ${code} through ${redactProxy(proxy)}` };
490
+ }
491
+ // The proxy path failed. Node's fetch ignores `https_proxy`, so Pi's own
492
+ // requests take the direct path — and a run inside Pi would then call the same
493
+ // endpoint reachable while the CLI called it dead. Ask the other path before
494
+ // blaming the endpoint.
495
+ const direct = await timedFetch(url.toString(), { headers });
496
+ const directAlive = direct.ok && statusIsAlive(direct.status);
497
+ const through = `through ${redactProxy(proxy)}`;
498
+ if (directAlive) {
499
+ return {
500
+ // A config problem either way (the proxy path is the one Pi's own requests
501
+ // do not take), and the detail says so — not a confirmed-dead endpoint.
502
+ status: "warn",
503
+ detail: `${redactUrl(url.toString())} answered HTTP ${direct.status} directly but not ${through} (${scrubCurlError(res.error, proxy) || `HTTP ${code}`}) — Pi's own requests ignore https_proxy`,
504
+ };
505
+ }
506
+ // Both paths failed. curl got a definitive answer (a refused connection, a
507
+ // rejected certificate, an HTTP error status) — the endpoint did not answer,
508
+ // which is the finding this check exists to report.
509
+ const failed = Boolean(res.code) || !/no response/.test(String(res.error ?? ""));
510
+ return {
511
+ status: failed ? "fail" : "warn",
512
+ detail: `${redactUrl(url.toString())} did not answer ${through}${code ? ` (HTTP ${code})` : ""}; ${scrubCurlError(res.error, proxy) || "no response"}${direct.status ? ` — and HTTP ${direct.status} directly` : ""}`,
513
+ };
514
+ }
515
+
516
+ function describeProbe(url, res, alive) {
517
+ // `url.origin` never carries userinfo, and `res.error` is prose from
518
+ // `describeNetError`, so neither can quote a credential or an errno.
519
+ if (alive) return `${url.origin} reachable (HTTP ${res.status})`;
520
+ if (res.status) return `${url.origin} answered HTTP ${res.status}`;
521
+ // A local timeout is this process giving up. Saying "unreachable" asserted a
522
+ // fact about the endpoint that an 8s deadline did not establish, and the same
523
+ // file already refuses to make that claim on the DNS path.
524
+ if (res.aborted) {
525
+ // The deadline is already named by `error`; repeating it in parentheses made
526
+ // the line read "did not answer in time (no answer within 8s)".
527
+ return `${url.origin} did not answer within ${Math.round((res.timeoutMs ?? 8000) / 1000)}s — not proven unreachable`;
528
+ }
529
+ return `${url.origin} unreachable (${res.error})`;
530
+ }
531
+
532
+ /** Advice that matches the failure, rather than one sentence for all of them. */
533
+ function probeHint(res) {
534
+ if (res.aborted) return "The request was cut off locally, which says nothing about the endpoint. Re-run, or raise the timeout on a slow link.";
535
+ if (res.kind === "ENOTFOUND" || res.kind === "EAI_AGAIN" || res.kind === "EAI_NODATA") {
536
+ return "The hostname does not resolve. Check the spelling of baseUrl, or the proxy or DNS settings of this machine.";
537
+ }
538
+ if (res.kind === "ECONNREFUSED") return "Nothing is listening on that host and port. Check that the server is running, and whether baseUrl has the right port.";
539
+ if (/^(CERT_|DEPTH_ZERO|SELF_SIGNED|UNABLE_TO_VERIFY|ERR_TLS|ERR_SSL)/.test(String(res.kind))) {
540
+ return "The TLS certificate was rejected. A corporate proxy that terminates TLS needs its CA added to Node's trust store.";
541
+ }
542
+ return "Check the baseUrl, whether the server is running, and whether a proxy is required.";
543
+ }