@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.
- package/LICENSE +21 -0
- package/README.md +509 -2
- package/bin/piquota.js +199 -0
- package/extensions/quota-panel.ts +963 -0
- package/package.json +59 -4
- package/src/auth/claude-code-auth.js +490 -0
- package/src/auth/jwt.js +84 -0
- package/src/auth/pi-auth.js +424 -0
- package/src/browser/cookies.js +557 -0
- package/src/browser/history.js +173 -0
- package/src/cache.js +224 -0
- package/src/cli/args.js +154 -0
- package/src/cli/commands/auth.js +216 -0
- package/src/cli/commands/gentle-pi.js +44 -0
- package/src/cli/commands/omarchy.js +75 -0
- package/src/cli/explain.js +99 -0
- package/src/cli/output.js +34 -0
- package/src/engine.js +404 -0
- package/src/exec.js +31 -0
- package/src/gentle-pi/rail-patch.js +335 -0
- package/src/http.js +182 -0
- package/src/model.js +332 -0
- package/src/omarchy/assets/antigravity.svg +3 -0
- package/src/omarchy/assets/claude.svg +1 -0
- package/src/omarchy/assets/codex.svg +1 -0
- package/src/omarchy/assets/github-actions.svg +3 -0
- package/src/omarchy/assets/opencode-go.svg +3 -0
- package/src/omarchy/publish.js +152 -0
- package/src/omarchy/record.js +462 -0
- package/src/opencode/dashboard.js +337 -0
- package/src/opencode/session.js +485 -0
- package/src/providers/antigravity-oauth.js +173 -0
- package/src/providers/antigravity.js +225 -0
- package/src/providers/backoff.js +146 -0
- package/src/providers/claude.js +194 -0
- package/src/providers/codex.js +151 -0
- package/src/providers/error-kind.js +128 -0
- package/src/providers/github-actions.js +379 -0
- package/src/providers/opencode-go.js +274 -0
- package/src/refresh.js +292 -0
- package/src/render/panel.js +411 -0
- package/src/render/theme.js +102 -0
- 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
|
+
}
|