@cad0p/pi-usage 0.0.0-stage → 0.11.2

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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +509 -2
  3. package/bin/piquota.js +199 -0
  4. package/extensions/quota-panel.ts +963 -0
  5. package/package.json +59 -4
  6. package/src/auth/claude-code-auth.js +490 -0
  7. package/src/auth/jwt.js +84 -0
  8. package/src/auth/pi-auth.js +424 -0
  9. package/src/browser/cookies.js +557 -0
  10. package/src/browser/history.js +173 -0
  11. package/src/cache.js +224 -0
  12. package/src/cli/args.js +154 -0
  13. package/src/cli/commands/auth.js +216 -0
  14. package/src/cli/commands/gentle-pi.js +44 -0
  15. package/src/cli/commands/omarchy.js +75 -0
  16. package/src/cli/explain.js +99 -0
  17. package/src/cli/output.js +34 -0
  18. package/src/engine.js +404 -0
  19. package/src/exec.js +31 -0
  20. package/src/gentle-pi/rail-patch.js +335 -0
  21. package/src/http.js +182 -0
  22. package/src/model.js +332 -0
  23. package/src/omarchy/assets/antigravity.svg +3 -0
  24. package/src/omarchy/assets/claude.svg +1 -0
  25. package/src/omarchy/assets/codex.svg +1 -0
  26. package/src/omarchy/assets/github-actions.svg +3 -0
  27. package/src/omarchy/assets/opencode-go.svg +3 -0
  28. package/src/omarchy/publish.js +152 -0
  29. package/src/omarchy/record.js +462 -0
  30. package/src/opencode/dashboard.js +337 -0
  31. package/src/opencode/session.js +485 -0
  32. package/src/providers/antigravity-oauth.js +173 -0
  33. package/src/providers/antigravity.js +225 -0
  34. package/src/providers/backoff.js +146 -0
  35. package/src/providers/claude.js +194 -0
  36. package/src/providers/codex.js +151 -0
  37. package/src/providers/error-kind.js +128 -0
  38. package/src/providers/github-actions.js +379 -0
  39. package/src/providers/opencode-go.js +274 -0
  40. package/src/refresh.js +292 -0
  41. package/src/render/panel.js +411 -0
  42. package/src/render/theme.js +102 -0
  43. package/src/sticky.js +319 -0
@@ -0,0 +1,335 @@
1
+ /**
2
+ * gentle-pi paints its right rail from a hardcoded allowlist, so a part that
3
+ * another extension registers under any other key is never rendered. This module
4
+ * owns the single anchored edit that appends piQuota's part to that allowlist,
5
+ * plus the detection that tells a patch wiped by a package update apart from an
6
+ * unrecognized gentle-pi revision.
7
+ *
8
+ * The edit is deliberately narrow: it rewrites one array literal and leaves every
9
+ * other byte of the file untouched. An unrecognized shape is reported instead of
10
+ * guessed at, because a wrong rewrite of another extension's layout would break
11
+ * the whole rail.
12
+ *
13
+ * CLI (used by install.sh and available for manual repair):
14
+ * node src/gentle-pi/rail-patch.js --status | --apply | --revert [--dir <path>]
15
+ */
16
+
17
+ import { copyFileSync, existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { pathToFileURL } from "node:url";
21
+
22
+ /** The rail part piQuota registers, and the key this module adds to the allowlist. */
23
+ export const GENTLE_PI_PART = "quota";
24
+
25
+ const LAYOUT_RELATIVE = join("lib", "shell-sidebar-layout.ts");
26
+ const BACKUP_SUFFIX = ".pi-quota-backup";
27
+ const TEMP_SUFFIX = ".pi-quota-tmp";
28
+
29
+ /** Default package roots, in the order a Pi installation is likely to have them. */
30
+ const PACKAGE_ROOTS = [
31
+ join(homedir(), ".pi", "agent", "npm", "node_modules", "gentle-pi"),
32
+ join(homedir(), ".pi", "agent", "gentle-ai", "node_modules", "gentle-pi"),
33
+ ];
34
+
35
+ /**
36
+ * @typedef {"patched" | "unpatched" | "unknown" | "missing"} RailPatchState
37
+ * @typedef {{ state: RailPatchState, sections: string[], detail: string }} RailPatchInspection
38
+ * @typedef {{ ok: boolean, changed: boolean, state: RailPatchState, detail: string, backupPath?: string }} RailPatchResult
39
+ */
40
+
41
+ /**
42
+ * @param {unknown} error
43
+ * @returns {string}
44
+ */
45
+ function reason(error) {
46
+ const message = /** @type {{ message?: string }} */ (error)?.message;
47
+ return message ?? String(error);
48
+ }
49
+
50
+ /**
51
+ * Every `const sections = [...]` allowlist in the file, each with its own offsets
52
+ * so a rewrite can replace exactly one of them. Matches both simple `.map()` chains
53
+ * (gentle-pi 3.x) and filtered/chained pipelines like `.filter(...).map()` (gentle-pi 4.x+).
54
+ *
55
+ * @param {string} text
56
+ * @returns {Array<{ text: string, inner: string, index: number }>}
57
+ */
58
+ function findSectionAllowlists(text) {
59
+ const pattern = /(?:const|let|var)\s+sections\s*=\s*\[([^\]]*)\]/g;
60
+ /** @type {Array<{ text: string, inner: string, index: number }>} */
61
+ const found = [];
62
+ let match;
63
+ while ((match = pattern.exec(text)) !== null) {
64
+ found.push({ text: match[0], inner: match[1], index: match.index });
65
+ }
66
+ return found;
67
+ }
68
+
69
+ /**
70
+ * @param {string} inner
71
+ * @returns {string[]}
72
+ */
73
+ function keysOf(inner) {
74
+ const pattern = /["']([^"']+)["']/g;
75
+ /** @type {string[]} */
76
+ const keys = [];
77
+ let match;
78
+ while ((match = pattern.exec(inner)) !== null) keys.push(match[1]);
79
+ return keys;
80
+ }
81
+
82
+ /**
83
+ * Append a key while preserving the spacing and quote style of the entries already present.
84
+ *
85
+ * @param {string} inner
86
+ * @param {string} part
87
+ * @returns {string}
88
+ */
89
+ function appendPart(inner, part) {
90
+ const quote = /'/.test(inner) && !/"/.test(inner) ? "'" : '"';
91
+ const entry = `${quote}${part}${quote}`;
92
+ const trimmed = inner.replace(/\s+$/, "");
93
+ return trimmed === "" ? entry : `${trimmed}, ${entry}`;
94
+ }
95
+
96
+ /**
97
+ * Drop a key together with the comma that joined it, so the remaining entries keep
98
+ * their original text and `["a", "b", "quota"]` becomes `["a", "b"]` rather than a
99
+ * list with a dangling separator.
100
+ *
101
+ * @param {string} inner
102
+ * @param {string} part
103
+ * @returns {string}
104
+ */
105
+ function dropPart(inner, part) {
106
+ const escaped = part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
107
+ const trailing = new RegExp(`,\\s*["']${escaped}["']\\s*$`);
108
+ if (trailing.test(inner)) return inner.replace(trailing, "");
109
+ const leading = new RegExp(`["']${escaped}["']\\s*,\\s*`);
110
+ if (leading.test(inner)) return inner.replace(leading, "");
111
+ // The part can be the only entry, and then there is no comma to remove with it.
112
+ const only = new RegExp(`^\\s*["']${escaped}["']\\s*$`);
113
+ if (only.test(inner)) return "";
114
+ return inner;
115
+ }
116
+
117
+ /**
118
+ * @param {string} layoutPath
119
+ * @param {string} text
120
+ */
121
+ function writeAtomically(layoutPath, text) {
122
+ const temp = `${layoutPath}.${process.pid}.${Date.now()}.tmp`;
123
+ try {
124
+ writeFileSync(temp, text, "utf-8");
125
+ renameSync(temp, layoutPath);
126
+ } catch (error) {
127
+ try {
128
+ unlinkSync(temp);
129
+ } catch {
130
+ // The temp file is either gone or unreachable; the caller reports the real failure.
131
+ }
132
+ throw error;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * @param {string} dir
138
+ * @returns {{ dir: string, layoutPath: string, present: boolean }}
139
+ */
140
+ function layoutFor(dir) {
141
+ const layoutPath = join(dir, LAYOUT_RELATIVE);
142
+ return { dir, layoutPath, present: existsSync(layoutPath) };
143
+ }
144
+
145
+ /**
146
+ * Locate the installed gentle-pi package and the layout file this module edits.
147
+ * An explicit `dir` or `PI_QUOTA_GENTLE_PI_DIR` is authoritative: when it holds no
148
+ * layout file the answer is "not present" rather than a different installation.
149
+ *
150
+ * @param {{ dir?: string }} [options]
151
+ * @returns {{ dir: string, layoutPath: string, present: boolean }}
152
+ */
153
+ export function resolveGentlePiLayout(options = {}) {
154
+ if (options.dir) return layoutFor(options.dir);
155
+ const override = process.env.PI_QUOTA_GENTLE_PI_DIR;
156
+ if (override) return layoutFor(override);
157
+ for (const root of PACKAGE_ROOTS) {
158
+ const layout = layoutFor(root);
159
+ if (layout.present) return layout;
160
+ }
161
+ return layoutFor(PACKAGE_ROOTS[0]);
162
+ }
163
+
164
+ /**
165
+ * @param {{ layoutPath: string }} options
166
+ * @returns {RailPatchInspection}
167
+ */
168
+ export function inspectRailPatch({ layoutPath }) {
169
+ let text;
170
+ try {
171
+ text = readFileSync(layoutPath, "utf-8");
172
+ } catch (error) {
173
+ return { state: "missing", sections: [], detail: `cannot read ${layoutPath}: ${reason(error)}` };
174
+ }
175
+
176
+ const allowlists = findSectionAllowlists(text);
177
+ if (allowlists.length === 0) {
178
+ return { state: "unknown", sections: [], detail: "no rail section allowlist found; refusing to guess" };
179
+ }
180
+ if (allowlists.length > 1) {
181
+ return { state: "unknown", sections: [], detail: `expected one rail section allowlist, found ${allowlists.length}` };
182
+ }
183
+
184
+ const sections = keysOf(allowlists[0].inner);
185
+ if (sections.includes(GENTLE_PI_PART)) {
186
+ return { state: "patched", sections, detail: `the rail already renders "${GENTLE_PI_PART}"` };
187
+ }
188
+ return { state: "unpatched", sections, detail: `the rail renders: ${sections.join(", ") || "(nothing)"}` };
189
+ }
190
+
191
+ /**
192
+ * Append the part to the allowlist. Idempotent, atomic, and verified: the file is
193
+ * re-read after the write and restored from the backup when the result does not
194
+ * read back as patched.
195
+ *
196
+ * @param {{ layoutPath: string }} options
197
+ * @returns {RailPatchResult}
198
+ */
199
+ export function applyRailPatch({ layoutPath }) {
200
+ const inspected = inspectRailPatch({ layoutPath });
201
+ if (inspected.state === "patched") {
202
+ return { ok: true, changed: false, state: "patched", detail: inspected.detail };
203
+ }
204
+ if (inspected.state !== "unpatched") {
205
+ return { ok: false, changed: false, state: inspected.state, detail: inspected.detail };
206
+ }
207
+
208
+ let original;
209
+ try {
210
+ original = readFileSync(layoutPath, "utf-8");
211
+ } catch (error) {
212
+ return { ok: false, changed: false, state: "missing", detail: `cannot read ${layoutPath}: ${reason(error)}` };
213
+ }
214
+
215
+ const allowlist = findSectionAllowlists(original)[0];
216
+ const replacement = `const sections = [${appendPart(allowlist.inner, GENTLE_PI_PART)}]`;
217
+ const patched = original.slice(0, allowlist.index) + replacement + original.slice(allowlist.index + allowlist.text.length);
218
+
219
+ const backupPath = `${layoutPath}${BACKUP_SUFFIX}`;
220
+ try {
221
+ if (!existsSync(backupPath)) copyFileSync(layoutPath, backupPath);
222
+ writeAtomically(layoutPath, patched);
223
+ } catch (error) {
224
+ return { ok: false, changed: false, state: "unpatched", detail: `could not write ${layoutPath}: ${reason(error)}`, backupPath };
225
+ }
226
+
227
+ const verified = inspectRailPatch({ layoutPath });
228
+ if (verified.state !== "patched") {
229
+ try {
230
+ writeAtomically(layoutPath, readFileSync(backupPath, "utf-8"));
231
+ } catch {
232
+ // The restore is best effort; the report below is the authoritative outcome.
233
+ }
234
+ return { ok: false, changed: true, state: verified.state, detail: `the edit did not read back as patched (${verified.detail}); restored from backup`, backupPath };
235
+ }
236
+ return { ok: true, changed: true, state: "patched", detail: `added "${GENTLE_PI_PART}" to the rail allowlist`, backupPath };
237
+ }
238
+
239
+ /**
240
+ * Remove the part from the allowlist, restoring the original bytes for a file this
241
+ * module patched.
242
+ *
243
+ * @param {{ layoutPath: string }} options
244
+ * @returns {RailPatchResult}
245
+ */
246
+ export function revertRailPatch({ layoutPath }) {
247
+ const inspected = inspectRailPatch({ layoutPath });
248
+ if (inspected.state === "missing" || inspected.state === "unknown") {
249
+ return { ok: false, changed: false, state: inspected.state, detail: inspected.detail };
250
+ }
251
+ if (inspected.state === "unpatched") {
252
+ return { ok: true, changed: false, state: "unpatched", detail: `the rail already excludes "${GENTLE_PI_PART}"` };
253
+ }
254
+
255
+ let original;
256
+ try {
257
+ original = readFileSync(layoutPath, "utf-8");
258
+ } catch (error) {
259
+ return { ok: false, changed: false, state: "missing", detail: `cannot read ${layoutPath}: ${reason(error)}` };
260
+ }
261
+
262
+ const allowlist = findSectionAllowlists(original)[0];
263
+ const replacement = `const sections = [${dropPart(allowlist.inner, GENTLE_PI_PART)}]`;
264
+ const reverted = original.slice(0, allowlist.index) + replacement + original.slice(allowlist.index + allowlist.text.length);
265
+
266
+ const backupPath = `${layoutPath}${BACKUP_SUFFIX}`;
267
+ try {
268
+ writeAtomically(layoutPath, reverted);
269
+ } catch (error) {
270
+ return { ok: false, changed: false, state: "patched", detail: `could not write ${layoutPath}: ${reason(error)}`, backupPath };
271
+ }
272
+
273
+ const verified = inspectRailPatch({ layoutPath });
274
+ if (verified.state !== "unpatched") {
275
+ try {
276
+ copyFileSync(backupPath, layoutPath);
277
+ } catch {
278
+ // The restore is best effort; the report below is the authoritative outcome.
279
+ }
280
+ return { ok: false, changed: true, state: verified.state, detail: `the revert did not read back as unpatched (${verified.detail}); restored from backup`, backupPath };
281
+ }
282
+ return { ok: true, changed: true, state: "unpatched", detail: `removed "${GENTLE_PI_PART}" from the rail allowlist`, backupPath };
283
+ }
284
+
285
+ /**
286
+ * The validator the extension and the installer both call: report whether the rail
287
+ * renders the part, and repair it when a gentle-pi update wiped the patch.
288
+ *
289
+ * @param {{ layoutPath: string }} options
290
+ * @returns {RailPatchResult & { repaired: boolean }}
291
+ */
292
+ export function ensureRailPatch({ layoutPath }) {
293
+ const inspected = inspectRailPatch({ layoutPath });
294
+ if (inspected.state === "patched") {
295
+ return { repaired: false, ok: true, changed: false, state: "patched", detail: inspected.detail };
296
+ }
297
+ const applied = applyRailPatch({ layoutPath });
298
+ return { ...applied, repaired: applied.changed === true };
299
+ }
300
+
301
+ /**
302
+ * @param {string[]} argv
303
+ * @returns {number}
304
+ */
305
+ function runCli(argv) {
306
+ const action = argv.includes("--revert") ? "revert" : argv.includes("--apply") ? "apply" : "status";
307
+ const dirIndex = argv.indexOf("--dir");
308
+ const dir = dirIndex >= 0 ? argv[dirIndex + 1] : undefined;
309
+
310
+ const layout = resolveGentlePiLayout(dir ? { dir } : {});
311
+ if (!layout.present) {
312
+ process.stdout.write(`gentle-pi not found at ${layout.layoutPath}\n`);
313
+ process.stdout.write("nothing to do: piQuota renders its own widget without the rail\n");
314
+ return 0;
315
+ }
316
+
317
+ if (action === "status") {
318
+ const inspected = inspectRailPatch({ layoutPath: layout.layoutPath });
319
+ process.stdout.write(`gentle-pi: ${layout.layoutPath}\n`);
320
+ process.stdout.write(`rail patch: ${inspected.state} (${inspected.detail})\n`);
321
+ if (inspected.state !== "patched") {
322
+ process.stdout.write("run `piquota gentle-pi apply`, or let the extension repair it at session start\n");
323
+ }
324
+ return inspected.state === "unknown" ? 1 : 0;
325
+ }
326
+
327
+ const result = action === "revert" ? revertRailPatch({ layoutPath: layout.layoutPath }) : applyRailPatch({ layoutPath: layout.layoutPath });
328
+ process.stdout.write(`gentle-pi: ${layout.layoutPath}\n`);
329
+ process.stdout.write(`rail patch: ${result.state} — ${result.detail}\n`);
330
+ if (result.backupPath) process.stdout.write(`backup: ${result.backupPath}\n`);
331
+ return result.ok ? 0 : 1;
332
+ }
333
+
334
+ const invokedDirectly = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
335
+ if (invokedDirectly) process.exit(runCli(process.argv.slice(2)));
package/src/http.js ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Minimal timed JSON HTTP helpers.
3
+ *
4
+ * Every outbound request in this project goes through here so that timeouts,
5
+ * redaction of secrets and error normalization stay in one place.
6
+ */
7
+
8
+ const DEFAULT_TIMEOUT_MS = 15000;
9
+
10
+ /**
11
+ * Redact credential-looking substrings from arbitrary text before it can reach
12
+ * a log, a terminal or an error message.
13
+ *
14
+ * @param {unknown} value
15
+ * @returns {string}
16
+ */
17
+ export function redact(value) {
18
+ let text = typeof value === "string" ? value : String(value ?? "");
19
+ text = text.replace(/[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}/g, "<email>");
20
+ text = text.replace(/\beyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{4,}\b/g, "<jwt>");
21
+ text = text.replace(/\b(sk-[A-Za-z0-9_-]{6})[A-Za-z0-9_-]+/g, "$1<redacted>");
22
+ text = text.replace(/\b(ya29\.)[A-Za-z0-9_-]+/g, "$1<redacted>");
23
+ text = text.replace(/\b(1\/\/)[A-Za-z0-9_-]+/g, "$1<redacted>");
24
+ text = text.replace(/\b(rt\.[\w.]{4})[A-Za-z0-9_-]+/g, "$1<redacted>");
25
+ text = text.replace(/\b(Bearer\s+)[A-Za-z0-9._~+/-]+=*/gi, "$1<redacted>");
26
+ text = text.replace(/\b((?:__Host-console_session|auth)=)[^;\s]+/gi, "$1<redacted>");
27
+ return text;
28
+ }
29
+
30
+ /**
31
+ * @typedef {Object} HttpResult
32
+ * @property {boolean} ok
33
+ * @property {number} status
34
+ * @property {unknown} [body] Parsed JSON body when parsing succeeded.
35
+ * @property {string} [text] Raw body when JSON parsing failed or on error.
36
+ * @property {string} [error] Redacted, human-readable failure reason.
37
+ * @property {boolean} [authError] True for 401/403, which callers surface as a
38
+ * "sign in again" degradation.
39
+ * @property {string | null} [authMessage] The provider's own error message for a
40
+ * 401/403, redacted and length-capped. Callers
41
+ * that need to distinguish "revoked key" from
42
+ * "plan not active" use it; the generic error
43
+ * text stays the same for everyone else.
44
+ */
45
+
46
+ /**
47
+ * Read a provider's JSON error message from a 401/403 body.
48
+ *
49
+ * The status alone cannot tell a revoked credential from a valid credential
50
+ * whose workspace is not entitled to the product, and both Pi and the vendor
51
+ * return that distinction in the body. Never throws: an unreadable body is
52
+ * simply no detail.
53
+ *
54
+ * @param {{ text?: () => Promise<string> }} response
55
+ * @returns {Promise<string | null>}
56
+ */
57
+ async function readAuthMessage(response) {
58
+ let raw = "";
59
+ try {
60
+ raw = await response.text();
61
+ } catch {
62
+ return null;
63
+ }
64
+ if (!raw || raw.trim() === "") return null;
65
+ let message = null;
66
+ try {
67
+ const body = JSON.parse(raw);
68
+ const value = body?.error?.message ?? body?.message ?? body?.error;
69
+ message = typeof value === "string" ? value : null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ if (!message) return null;
74
+ return redact(message).replace(/\s+/g, " ").trim().slice(0, 120) || null;
75
+ }
76
+
77
+ /**
78
+ * Perform one request and parse a JSON body.
79
+ *
80
+ * @param {string} url
81
+ * @param {{
82
+ * method?: string,
83
+ * headers?: Record<string, string>,
84
+ * body?: string,
85
+ * timeoutMs?: number,
86
+ * fetchFn?: typeof fetch,
87
+ * }} [options]
88
+ * @returns {Promise<HttpResult>}
89
+ */
90
+ export async function requestJson(url, options = {}) {
91
+ const fetchFn = options.fetchFn ?? fetch;
92
+ const controller = new AbortController();
93
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
94
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
95
+
96
+ try {
97
+ const response = await fetchFn(url, {
98
+ method: options.method ?? "GET",
99
+ headers: options.headers,
100
+ body: options.body,
101
+ signal: controller.signal,
102
+ });
103
+
104
+ if (response.status === 401 || response.status === 403) {
105
+ return {
106
+ ok: false,
107
+ status: response.status,
108
+ authError: true,
109
+ authMessage: await readAuthMessage(response),
110
+ error: `sign-in expired (HTTP ${response.status})`,
111
+ };
112
+ }
113
+
114
+ if (response.status === 429) {
115
+ const rawHeader = response.headers?.get?.("retry-after")?.trim();
116
+ let retrySec = null;
117
+ if (rawHeader) {
118
+ if (/^\d+$/.test(rawHeader)) {
119
+ retrySec = Number(rawHeader);
120
+ } else {
121
+ const parsed = Date.parse(rawHeader);
122
+ if (!Number.isNaN(parsed)) {
123
+ retrySec = Math.max(0, Math.round((parsed - Date.now()) / 1000));
124
+ }
125
+ }
126
+ }
127
+ return {
128
+ ok: false,
129
+ status: 429,
130
+ error: retrySec !== null
131
+ ? `rate limited (HTTP 429); retry in ${retrySec}s`
132
+ : "rate limited (HTTP 429); retry in a minute",
133
+ };
134
+ }
135
+
136
+ if (!response.ok) {
137
+ let detail = "";
138
+ try {
139
+ detail = (await response.text()).slice(0, 200);
140
+ } catch {
141
+ // Body is optional: the status alone is enough to report the failure.
142
+ }
143
+ return {
144
+ ok: false,
145
+ status: response.status,
146
+ error: detail
147
+ ? `HTTP ${response.status}: ${redact(detail).replace(/\s+/g, " ")}`
148
+ : `HTTP ${response.status}`,
149
+ };
150
+ }
151
+
152
+ const raw = await response.text();
153
+ if (raw.trim() === "") {
154
+ return { ok: true, status: response.status, body: null };
155
+ }
156
+ try {
157
+ return { ok: true, status: response.status, body: JSON.parse(raw) };
158
+ } catch {
159
+ return {
160
+ ok: false,
161
+ status: response.status,
162
+ text: raw,
163
+ error: "response was not valid JSON",
164
+ };
165
+ }
166
+ } catch (error) {
167
+ const name = /** @type {{ name?: string, message?: string }} */ (error)?.name;
168
+ const message = /** @type {{ message?: string }} */ (error)?.message ?? String(error);
169
+ const cause = /** @type {{ cause?: { code?: string, message?: string } }} */ (error)?.cause;
170
+ const causeCode = cause?.code ?? cause?.message;
171
+ const detail = causeCode && typeof causeCode === "string" && !message.includes(causeCode)
172
+ ? `${message}: ${causeCode}`
173
+ : message;
174
+ return {
175
+ ok: false,
176
+ status: 0,
177
+ error: name === "AbortError" ? `timed out after ${timeoutMs}ms` : redact(detail),
178
+ };
179
+ } finally {
180
+ clearTimeout(timeout);
181
+ }
182
+ }