@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
package/src/engine.js
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quota collection engine.
|
|
3
|
+
*
|
|
4
|
+
* Loads Pi credentials (read-only), asks each provider for its windows and
|
|
5
|
+
* returns one normalized report. Providers never throw: a failure becomes a
|
|
6
|
+
* degraded result with an actionable message.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { checkFreshness, LABEL_BY_FAMILY, loadPiCredentials, resolveAuthPaths } from "./auth/pi-auth.js";
|
|
10
|
+
import { loadClaudeCodeCredential } from "./auth/claude-code-auth.js";
|
|
11
|
+
import { degradedResult, displayIdentity, selectPrimaryWindow } from "./model.js";
|
|
12
|
+
import { backoffState, clearBackoff, isAuthFailure, isThrottled, recordBackoff, retryAfterFromError } from "./providers/backoff.js";
|
|
13
|
+
import { fetchQuota as fetchClaude } from "./providers/claude.js";
|
|
14
|
+
import { fetchQuota as fetchCodex } from "./providers/codex.js";
|
|
15
|
+
import { fetchQuota as fetchAntigravity } from "./providers/antigravity.js";
|
|
16
|
+
import { fetchQuota as fetchOpenCodeGo } from "./providers/opencode-go.js";
|
|
17
|
+
import { fetchQuota as fetchGithubActions } from "./providers/github-actions.js";
|
|
18
|
+
|
|
19
|
+
/** Canonical family order used by every surface. */
|
|
20
|
+
export const FAMILIES = ["claude", "codex", "antigravity", "opencode-go", "github-actions"];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Return the ordered families enabled for a run.
|
|
24
|
+
*
|
|
25
|
+
* GitHub Actions joins a default family list only when the env flag is truthy.
|
|
26
|
+
* A positional family selection sets `explicit` to record user consent directly,
|
|
27
|
+
* independent of whether the requested array has been copied or transformed.
|
|
28
|
+
*
|
|
29
|
+
* @param {{ env?: Record<string, string | undefined>, requested?: string[], explicit?: boolean }} [input]
|
|
30
|
+
* @returns {string[]}
|
|
31
|
+
*/
|
|
32
|
+
export function enabledFamilies(input = {}) {
|
|
33
|
+
const env = input.env ?? process.env;
|
|
34
|
+
const requested = input.requested;
|
|
35
|
+
const families = requested ?? FAMILIES;
|
|
36
|
+
const explicit = input.explicit ?? false;
|
|
37
|
+
const includeGithubActions = isTruthyFlag(env.PI_QUOTA_GITHUB_ACTIONS) || explicit;
|
|
38
|
+
return families.filter((family) => family !== "github-actions" || includeGithubActions);
|
|
39
|
+
}
|
|
40
|
+
const GH_CLI_CREDENTIAL = {
|
|
41
|
+
family: "github-actions",
|
|
42
|
+
label: "GitHub Actions",
|
|
43
|
+
source: "gh CLI",
|
|
44
|
+
identity: "GitHub CLI",
|
|
45
|
+
expiresAtMs: null,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** @param {string | undefined} value */
|
|
49
|
+
function isTruthyFlag(value) {
|
|
50
|
+
return typeof value === "string" && /^(1|true|yes|on)$/i.test(value.trim());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Claude has two possible sources, and a user may have either or both.
|
|
55
|
+
*
|
|
56
|
+
* `auto` prefers the Claude Code CLI, because the plugin that drives it is what a
|
|
57
|
+
* user reaches for when Pi's own `anthropic` token cannot serve requests. The
|
|
58
|
+
* choice is a preference, never a fallback: only the selected source is tried, so
|
|
59
|
+
* a broken preferred source is reported instead of silently masked by the other.
|
|
60
|
+
*/
|
|
61
|
+
export const CLAUDE_SOURCE_MODES = ["auto", "claude-code", "pi"];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {unknown} value
|
|
65
|
+
* @returns {"auto" | "claude-code" | "pi"}
|
|
66
|
+
*/
|
|
67
|
+
export function normalizeClaudeSourceMode(value) {
|
|
68
|
+
const text = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
69
|
+
if (text === "claude-code" || text === "claude" || text === "cli") return "claude-code";
|
|
70
|
+
if (text === "pi" || text === "anthropic" || text === "oauth") return "pi";
|
|
71
|
+
return "auto";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Pick the Claude credential set for this run.
|
|
76
|
+
*
|
|
77
|
+
* @param {{
|
|
78
|
+
* credentials: import("./auth/pi-auth.js").PiCredential[],
|
|
79
|
+
* env: Record<string, string | undefined>,
|
|
80
|
+
* home?: string,
|
|
81
|
+
* platform?: string,
|
|
82
|
+
* usersRoot?: string,
|
|
83
|
+
* claudeCodePaths?: string[] | null,
|
|
84
|
+
* }} input
|
|
85
|
+
* @returns {{ credentials: import("./auth/pi-auth.js").PiCredential[], mode: string, unavailable: string | null, claudeCodePaths: string[] }}
|
|
86
|
+
*/
|
|
87
|
+
export function resolveClaudeCredentials(input) {
|
|
88
|
+
const mode = normalizeClaudeSourceMode(input.env.PI_QUOTA_CLAUDE_SOURCE);
|
|
89
|
+
const fromPi = input.credentials.filter((credential) => credential.family === "claude");
|
|
90
|
+
const claudeCodePaths = input.claudeCodePaths === undefined ? undefined : input.claudeCodePaths;
|
|
91
|
+
|
|
92
|
+
if (mode === "pi") return { credentials: fromPi, mode, unavailable: null, claudeCodePaths: claudeCodePaths ?? [], recoveredLock: null };
|
|
93
|
+
|
|
94
|
+
const claudeCode = loadClaudeCodeCredential({
|
|
95
|
+
env: input.env,
|
|
96
|
+
home: input.home,
|
|
97
|
+
platform: input.platform,
|
|
98
|
+
usersRoot: input.usersRoot,
|
|
99
|
+
paths: claudeCodePaths ?? undefined,
|
|
100
|
+
now: input.now,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (claudeCode.ok) {
|
|
104
|
+
return {
|
|
105
|
+
credentials: [claudeCode.credential],
|
|
106
|
+
mode: "claude-code",
|
|
107
|
+
unavailable: null,
|
|
108
|
+
claudeCodePaths: claudeCode.paths,
|
|
109
|
+
recoveredLock: claudeCode.recoveredLock ?? null,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (mode === "claude-code") {
|
|
113
|
+
return {
|
|
114
|
+
credentials: [],
|
|
115
|
+
mode,
|
|
116
|
+
unavailable: claudeCode.error,
|
|
117
|
+
claudeCodePaths: claudeCode.paths,
|
|
118
|
+
recoveredLock: claudeCode.recoveredLock ?? null,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
credentials: fromPi,
|
|
123
|
+
mode: "pi",
|
|
124
|
+
unavailable: null,
|
|
125
|
+
claudeCodePaths: claudeCode.paths,
|
|
126
|
+
recoveredLock: claudeCode.recoveredLock ?? null,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @type {Record<string, (credential: any, options?: any) => Promise<import("./model.js").QuotaResult>>} */
|
|
131
|
+
const PROVIDERS = {
|
|
132
|
+
claude: fetchClaude,
|
|
133
|
+
codex: fetchCodex,
|
|
134
|
+
antigravity: fetchAntigravity,
|
|
135
|
+
"opencode-go": fetchOpenCodeGo,
|
|
136
|
+
"github-actions": fetchGithubActions,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @typedef {Object} PiQuotaReport
|
|
141
|
+
* @property {string} engine
|
|
142
|
+
* @property {number} schemaVersion
|
|
143
|
+
* @property {true} readOnly
|
|
144
|
+
* @property {string} generatedAt
|
|
145
|
+
* @property {string[]} sources Pi auth stores that were read.
|
|
146
|
+
* @property {string[]} warnings
|
|
147
|
+
* @property {import("./model.js").QuotaResult[]} providers Flattened, family-ordered.
|
|
148
|
+
* @property {Record<string, import("./model.js").QuotaResult[]>} byFamily
|
|
149
|
+
*/
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {{
|
|
153
|
+
* families?: string[],
|
|
154
|
+
* explicit?: boolean,
|
|
155
|
+
* env?: Record<string, string | undefined>,
|
|
156
|
+
* home?: string,
|
|
157
|
+
* platform?: string,
|
|
158
|
+
* usersRoot?: string,
|
|
159
|
+
* paths?: string[],
|
|
160
|
+
* now?: number,
|
|
161
|
+
* fetchFn?: typeof fetch,
|
|
162
|
+
* runCommand?: typeof import("./exec.js").runCommand,
|
|
163
|
+
* timeoutMs?: number,
|
|
164
|
+
* refresh?: boolean,
|
|
165
|
+
* force?: boolean,
|
|
166
|
+
* stores?: import("./browser/cookies.js").CookieStore[],
|
|
167
|
+
* allowBrowser?: boolean,
|
|
168
|
+
* claudeCodePaths?: string[] | null,
|
|
169
|
+
* }} [options]
|
|
170
|
+
* @returns {Promise<PiQuotaReport>}
|
|
171
|
+
*/
|
|
172
|
+
export async function collectQuota(options = {}) {
|
|
173
|
+
const env = options.env ?? process.env;
|
|
174
|
+
const now = options.now ?? Date.now();
|
|
175
|
+
const families = enabledFamilies({ env, requested: options.families, explicit: options.explicit });
|
|
176
|
+
|
|
177
|
+
const loaded = loadPiCredentials({
|
|
178
|
+
env,
|
|
179
|
+
home: options.home,
|
|
180
|
+
platform: options.platform,
|
|
181
|
+
usersRoot: options.usersRoot,
|
|
182
|
+
paths: options.paths,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Claude is the one family with a second, non-Pi source, so it is resolved
|
|
186
|
+
// before the per-family loop and then treated exactly like any other credential.
|
|
187
|
+
const claude = resolveClaudeCredentials({
|
|
188
|
+
credentials: loaded.credentials,
|
|
189
|
+
env,
|
|
190
|
+
home: options.home,
|
|
191
|
+
platform: options.platform,
|
|
192
|
+
usersRoot: options.usersRoot,
|
|
193
|
+
claudeCodePaths: options.claudeCodePaths,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* @param {string} family
|
|
198
|
+
* @returns {import("./auth/pi-auth.js").PiCredential[]}
|
|
199
|
+
*/
|
|
200
|
+
const credentialsFor = (family) => {
|
|
201
|
+
if (family === "claude") return claude.credentials;
|
|
202
|
+
if (family === "github-actions") return [GH_CLI_CREDENTIAL];
|
|
203
|
+
return loaded.credentials.filter((credential) => credential.family === family);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/** @type {Record<string, import("./model.js").QuotaResult[]>} */
|
|
207
|
+
const byFamily = {};
|
|
208
|
+
/** @type {string[]} */
|
|
209
|
+
const warnings = [...loaded.warnings];
|
|
210
|
+
if (claude.recoveredLock) {
|
|
211
|
+
warnings.push(
|
|
212
|
+
`claude: removed abandoned OAuth refresh lock (${Math.round((claude.recoveredLock.ageMs ?? 0) / 1000)}s old) at ${claude.recoveredLock.lockPath}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
/** @type {Record<string, string[]>} */
|
|
216
|
+
const familyWarnings = {};
|
|
217
|
+
|
|
218
|
+
const hasOpenAi = loaded.rawProviders?.includes("openai") || Boolean(env.OPENAI_API_KEY);
|
|
219
|
+
|
|
220
|
+
await Promise.all(
|
|
221
|
+
families.map(async (family) => {
|
|
222
|
+
const credentials = credentialsFor(family);
|
|
223
|
+
if (credentials.length === 0) {
|
|
224
|
+
// `notConfigured` is stated rather than inferred: the message now names a
|
|
225
|
+
// second source, and every renderer keys off this flag to avoid painting a
|
|
226
|
+
// failure icon for a provider the user simply does not use.
|
|
227
|
+
byFamily[family] = [
|
|
228
|
+
degradedResult({
|
|
229
|
+
family,
|
|
230
|
+
label: LABEL_BY_FAMILY[family] ?? family,
|
|
231
|
+
error: claude.unavailable && family === "claude" ? claude.unavailable : missingCredentialMessage(family, { hasOpenAi }),
|
|
232
|
+
notConfigured: true,
|
|
233
|
+
source: loaded.paths[0] ?? resolveAuthPaths({ env, home: options.home })[0] ?? "~/.pi/agent/auth.json",
|
|
234
|
+
now,
|
|
235
|
+
}),
|
|
236
|
+
];
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// A family that is currently throttled is skipped entirely, so the
|
|
241
|
+
// caller's sticky layer can republish the previous good snapshot instead
|
|
242
|
+
// of adding more pressure to a rate-limited endpoint.
|
|
243
|
+
//
|
|
244
|
+
// Authentication failures supersede throttling: if any credential for this
|
|
245
|
+
// family is already known to be expired, waiting out a rate limit is useless
|
|
246
|
+
// and misleading. We clear backoff immediately so the auth failure surfaces.
|
|
247
|
+
const hasExpiredToken = credentials.some((credential) => {
|
|
248
|
+
const freshness = checkFreshness(credential, now);
|
|
249
|
+
return !freshness.fresh;
|
|
250
|
+
});
|
|
251
|
+
if (hasExpiredToken || options.force) {
|
|
252
|
+
clearBackoff(family, { env, home: options.home });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const throttle = backoffState(family, { now, env, home: options.home });
|
|
256
|
+
if (throttle.active && !options.force) {
|
|
257
|
+
byFamily[family] = [
|
|
258
|
+
degradedResult({
|
|
259
|
+
family,
|
|
260
|
+
label: LABEL_BY_FAMILY[family] ?? family,
|
|
261
|
+
// Keep the identity visible while paused: "unknown" on screen is
|
|
262
|
+
// worse than a stale-but-labelled account.
|
|
263
|
+
account: displayIdentity(credentials[0]),
|
|
264
|
+
error: `backing off after a throttle: next attempt in ${throttle.secondsLeft}s`,
|
|
265
|
+
source: credentials[0].source,
|
|
266
|
+
now,
|
|
267
|
+
}),
|
|
268
|
+
];
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** @type {import("./model.js").QuotaResult[]} */
|
|
273
|
+
const results = [];
|
|
274
|
+
const familyWarnList = [];
|
|
275
|
+
for (const credential of credentials) {
|
|
276
|
+
const freshness = checkFreshness(credential, now);
|
|
277
|
+
if (!freshness.fresh) {
|
|
278
|
+
familyWarnList.push(
|
|
279
|
+
`${family}: the ${expiredTokenName(credential)} expired ${Math.abs(freshness.expiresInMin ?? 0)}m ago; ${refreshHint(credential)}`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
const fetchQuota = PROVIDERS[family];
|
|
283
|
+
try {
|
|
284
|
+
const result = await fetchQuota(credential, {
|
|
285
|
+
now,
|
|
286
|
+
fetchFn: options.fetchFn,
|
|
287
|
+
runCommand: options.runCommand,
|
|
288
|
+
timeoutMs: options.timeoutMs,
|
|
289
|
+
expiresInMin: freshness.expiresInMin,
|
|
290
|
+
env,
|
|
291
|
+
home: options.home,
|
|
292
|
+
refresh: options.refresh,
|
|
293
|
+
stores: options.stores,
|
|
294
|
+
allowBrowser: options.allowBrowser,
|
|
295
|
+
});
|
|
296
|
+
// The renderers group siblings by family; the record builder needs the
|
|
297
|
+
// slot to label each account without re-parsing its display string.
|
|
298
|
+
result.slot = credential.slot ?? null;
|
|
299
|
+
if (isThrottled(result.error)) {
|
|
300
|
+
const seconds = recordBackoff(family, {
|
|
301
|
+
retryAfterSec: retryAfterFromError(result.error),
|
|
302
|
+
now,
|
|
303
|
+
env,
|
|
304
|
+
home: options.home,
|
|
305
|
+
}).seconds;
|
|
306
|
+
familyWarnList.push(`${family}: throttled upstream; pausing that family for ${seconds}s`);
|
|
307
|
+
} else if (result.ok || isAuthFailure(result.error)) {
|
|
308
|
+
clearBackoff(family, { env, home: options.home });
|
|
309
|
+
}
|
|
310
|
+
results.push(result);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
// A provider must never take the whole report down.
|
|
313
|
+
const crashed = degradedResult({
|
|
314
|
+
family,
|
|
315
|
+
label: credential.label,
|
|
316
|
+
account: credential.email ?? credential.accountId ?? "unknown",
|
|
317
|
+
error: `provider crashed: ${/** @type {{ message?: string }} */ (error)?.message ?? String(error)}`,
|
|
318
|
+
source: credential.source,
|
|
319
|
+
now,
|
|
320
|
+
expiresInMin: freshness.expiresInMin,
|
|
321
|
+
});
|
|
322
|
+
crashed.slot = credential.slot ?? null;
|
|
323
|
+
results.push(crashed);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
byFamily[family] = results;
|
|
327
|
+
familyWarnings[family] = familyWarnList;
|
|
328
|
+
}),
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
for (const family of families) {
|
|
332
|
+
if (familyWarnings[family]) {
|
|
333
|
+
warnings.push(...familyWarnings[family]);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const providers = families.flatMap((family) => byFamily[family] ?? []);
|
|
338
|
+
|
|
339
|
+
// Stamp the primary window so the terminal, the Pi TUI and the Moshi adapter
|
|
340
|
+
// all agree on which window represents a provider, with no duplicated rules.
|
|
341
|
+
for (const provider of providers) {
|
|
342
|
+
provider.primaryWindowId = selectPrimaryWindow(provider.windows ?? [])?.id ?? null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
engine: "pi-quota",
|
|
347
|
+
schemaVersion: 1,
|
|
348
|
+
readOnly: true,
|
|
349
|
+
generatedAt: new Date(now).toISOString(),
|
|
350
|
+
sources: loaded.paths,
|
|
351
|
+
// Named so `piquota --explain` can state which Claude source was chosen and
|
|
352
|
+
// every surface agrees, instead of each one guessing.
|
|
353
|
+
claudeSource: { mode: claude.mode, paths: claude.claudeCodePaths, unavailable: claude.unavailable },
|
|
354
|
+
warnings,
|
|
355
|
+
providers,
|
|
356
|
+
byFamily,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* @param {string} family
|
|
362
|
+
* @param {{ hasOpenAi?: boolean }} [context]
|
|
363
|
+
* @returns {string}
|
|
364
|
+
*/
|
|
365
|
+
function missingCredentialMessage(family, context = {}) {
|
|
366
|
+
if (family === "claude") {
|
|
367
|
+
return "no claude credential in the Pi store or from the Claude Code CLI";
|
|
368
|
+
}
|
|
369
|
+
if (family === "github-actions") {
|
|
370
|
+
return "GitHub Actions uses the authenticated gh CLI; no Pi-store credential is required";
|
|
371
|
+
}
|
|
372
|
+
if (family === "codex" && context.hasOpenAi) {
|
|
373
|
+
return "no codex credential in the Pi store (found 'openai' API; run /login openai-codex in Pi for subscription quota)";
|
|
374
|
+
}
|
|
375
|
+
return `no ${family} credential in the Pi store`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* @param {{ sourceKind?: string }} credential
|
|
380
|
+
* @returns {string}
|
|
381
|
+
*/
|
|
382
|
+
function expiredTokenName(credential) {
|
|
383
|
+
return credential.sourceKind === "claude-code" ? "Claude Code token" : "Pi token";
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* @param {{ sourceKind?: string }} credential
|
|
388
|
+
* @returns {string}
|
|
389
|
+
*/
|
|
390
|
+
function refreshHint(credential) {
|
|
391
|
+
return credential.sourceKind === "claude-code"
|
|
392
|
+
? "run `claude` once to refresh it"
|
|
393
|
+
: "use that provider in Pi to refresh it";
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Convenience accessor used by the renderers.
|
|
398
|
+
*
|
|
399
|
+
* @param {PiQuotaReport} report
|
|
400
|
+
* @returns {import("./model.js").QuotaResult | null}
|
|
401
|
+
*/
|
|
402
|
+
export function firstForFamily(report, family) {
|
|
403
|
+
return report.byFamily[family]?.[0] ?? null;
|
|
404
|
+
}
|
package/src/exec.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place that spawns foreign binaries.
|
|
3
|
+
*
|
|
4
|
+
* piQuota delegates GitHub API requests to the user's authenticated `gh` CLI.
|
|
5
|
+
* Callers must degrade on missing binaries, non-zero exits and timeouts;
|
|
6
|
+
* external commands must never make quota collection throw.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_TIMEOUT_MS = 15_000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} command
|
|
15
|
+
* @param {string[]} [args]
|
|
16
|
+
* @param {{ timeoutMs?: number, env?: Record<string, string | undefined> }} [options]
|
|
17
|
+
* @returns {{ status: number | null, stdout: string, stderr: string, error: string | null }}
|
|
18
|
+
*/
|
|
19
|
+
export function runCommand(command, args = [], options = {}) {
|
|
20
|
+
const result = spawnSync(command, args, {
|
|
21
|
+
encoding: "utf-8",
|
|
22
|
+
timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
23
|
+
env: options.env ?? process.env,
|
|
24
|
+
});
|
|
25
|
+
return {
|
|
26
|
+
status: typeof result.status === "number" ? result.status : null,
|
|
27
|
+
stdout: typeof result.stdout === "string" ? result.stdout : "",
|
|
28
|
+
stderr: typeof result.stderr === "string" ? result.stderr : "",
|
|
29
|
+
error: result.error ? result.error.message : null,
|
|
30
|
+
};
|
|
31
|
+
}
|