token-quota 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/bin/token-quota.js +105 -0
- package/package.json +49 -0
- package/src/cli.js +92 -0
- package/src/doctor.js +292 -0
- package/src/errors.js +23 -0
- package/src/format.js +54 -0
- package/src/panel.js +113 -0
- package/src/platforms/bailian.js +96 -0
- package/src/platforms/cursor.js +86 -0
- package/src/platforms/doubao.js +259 -0
- package/src/platforms/index.js +10 -0
- package/src/quota-axi.js +255 -0
- package/src/resolve-deps.js +240 -0
- package/src/setup.js +226 -0
- package/src/table.js +37 -0
- package/vendor/sqlite3-shim/sqlite3.exe +0 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doubao / Volcengine Ark quota fetcher — ark-cli-backed.
|
|
3
|
+
*
|
|
4
|
+
* All Doubao quota data comes from the official `arkcli` CLI (v1.0.33+),
|
|
5
|
+
* subprocess-launched with the same pattern as the quota-axi wrapper
|
|
6
|
+
* (src/quota-axi.js): fixed argument list, bounded timeout, JSON stdout parsed
|
|
7
|
+
* even on non-zero exit, failures classified per platform. The credential is
|
|
8
|
+
* the CLI's own SSO session (`arkcli auth login volc-sso`, one browser
|
|
9
|
+
* sign-in); AK/SK pairs and DOUBAO_* model keys are never used or printed.
|
|
10
|
+
*
|
|
11
|
+
* `arkcli usage plan` emits:
|
|
12
|
+
* { viewer: {...}, items: [ { product, edition, tier, subscribed,
|
|
13
|
+
* periods: [ { label, used, total, percent, reset_at }, ... ] }, ... ] }
|
|
14
|
+
*
|
|
15
|
+
* Items with subscribed:false are skipped by default (one row per period of
|
|
16
|
+
* each subscribed item). A failure whose text looks like an auth problem
|
|
17
|
+
* becomes a missing-credential row naming `arkcli auth login volc-sso`.
|
|
18
|
+
*/
|
|
19
|
+
import { spawn } from 'node:child_process';
|
|
20
|
+
import { existsSync } from 'node:fs';
|
|
21
|
+
import { dirname, join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
import { MissingCredential } from '../errors.js';
|
|
24
|
+
import { NA, formatDateTime } from '../format.js';
|
|
25
|
+
import { PACKAGES, resolveArkCliLaunch } from '../resolve-deps.js';
|
|
26
|
+
|
|
27
|
+
export const id = 'doubao';
|
|
28
|
+
export const label = 'doubao';
|
|
29
|
+
|
|
30
|
+
const SPAWN_TIMEOUT_MS = 60_000;
|
|
31
|
+
const LOGIN_COMMAND = 'arkcli auth login volc-sso';
|
|
32
|
+
|
|
33
|
+
const HOW_TO_FIX = `run \`${LOGIN_COMMAND}\` (one browser SSO sign-in) to establish the Ark CLI session`;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* ark-cli's platform binary is fetched by that package's own postinstall script.
|
|
37
|
+
* When the binary is missing, the package's bin script exits with this message,
|
|
38
|
+
* and a plain reinstall of the CLI (or a rerun of its postinstall) is the fix.
|
|
39
|
+
*/
|
|
40
|
+
const INSTALL_FIX =
|
|
41
|
+
'install the Ark CLI with its platform binary: `npm i -g @volcengine/ark-cli` ' +
|
|
42
|
+
'(if only the bin script is present, rerun that package\'s postinstall: ' +
|
|
43
|
+
'`node node_modules/@volcengine/ark-cli/scripts/postinstall.js`)';
|
|
44
|
+
|
|
45
|
+
/** Raised when the arkcli binary itself cannot be used at all. */
|
|
46
|
+
class ArkCliUnavailable extends Error {
|
|
47
|
+
constructor(message) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = 'ArkCliUnavailable';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolves how to launch arkcli:
|
|
55
|
+
* 1. `ARKCLI_BIN` env var — path to a launchable binary
|
|
56
|
+
* 2. this package's own dependency tree (@volcengine/ark-cli): the platform
|
|
57
|
+
* binary when its postinstall download succeeded, else its bin script
|
|
58
|
+
* (`scripts/run.js`, which needs node in front of it)
|
|
59
|
+
* 3. the platform executable installed next to the running node binary
|
|
60
|
+
* (`<node dir>/node_modules/@volcengine/ark-cli/bin/arkcli-windows-amd64.exe`)
|
|
61
|
+
* 4. plain `arkcli` from PATH (works via execvp on POSIX)
|
|
62
|
+
* `resolveDependency` is injectable so the ordering above can be tested.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveLaunch({
|
|
65
|
+
env = process.env,
|
|
66
|
+
execPath = process.execPath,
|
|
67
|
+
platform = process.platform,
|
|
68
|
+
arch = process.arch,
|
|
69
|
+
resolveDependency = resolveArkCliLaunch,
|
|
70
|
+
} = {}) {
|
|
71
|
+
const args = ['usage', 'plan'];
|
|
72
|
+
|
|
73
|
+
const bin = (env.ARKCLI_BIN ?? '').trim();
|
|
74
|
+
if (bin !== '') return { command: bin, args };
|
|
75
|
+
|
|
76
|
+
const dependency = resolveDependency({ platform, arch });
|
|
77
|
+
if (dependency) {
|
|
78
|
+
return dependency.nodeEntry
|
|
79
|
+
? { command: execPath, args: [dependency.command, ...args] }
|
|
80
|
+
: { command: dependency.command, args };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (platform === 'win32') {
|
|
84
|
+
const machine = arch === 'arm64' ? 'arm64' : 'amd64';
|
|
85
|
+
const exe = join(
|
|
86
|
+
dirname(execPath),
|
|
87
|
+
'node_modules',
|
|
88
|
+
'@volcengine',
|
|
89
|
+
'ark-cli',
|
|
90
|
+
'bin',
|
|
91
|
+
`arkcli-windows-${machine}.exe`,
|
|
92
|
+
);
|
|
93
|
+
if (existsSync(exe)) return { command: exe, args };
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return { command: 'arkcli', args };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function query() {
|
|
100
|
+
const launch = resolveLaunch();
|
|
101
|
+
if (!launch) throw missingArkCliError();
|
|
102
|
+
const report = await runArkCli(launch);
|
|
103
|
+
return buildRows(report);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The error raised when no arkcli executable can be resolved. Exported for tests. */
|
|
107
|
+
export function missingArkCliError() {
|
|
108
|
+
return new MissingCredential(
|
|
109
|
+
'Ark CLI (arkcli) executable',
|
|
110
|
+
`${INSTALL_FIX}, or point ARKCLI_BIN at its executable`,
|
|
111
|
+
'cli-missing',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function runArkCli(launch) {
|
|
116
|
+
return new Promise((resolvePromise, reject) => {
|
|
117
|
+
const child = spawn(launch.command, launch.args, {
|
|
118
|
+
timeout: SPAWN_TIMEOUT_MS,
|
|
119
|
+
windowsHide: true,
|
|
120
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
121
|
+
});
|
|
122
|
+
let stdout = '';
|
|
123
|
+
let stderr = '';
|
|
124
|
+
child.stdout.on('data', (chunk) => {
|
|
125
|
+
stdout += chunk;
|
|
126
|
+
});
|
|
127
|
+
child.stderr.on('data', (chunk) => {
|
|
128
|
+
stderr += chunk;
|
|
129
|
+
});
|
|
130
|
+
child.on('error', (error) => {
|
|
131
|
+
reject(new ArkCliUnavailable(`arkcli could not be launched: ${error.message}`));
|
|
132
|
+
});
|
|
133
|
+
child.on('close', () => {
|
|
134
|
+
// Prefer a parseable stdout over the exit code: arkcli may exit non-zero
|
|
135
|
+
// for per-command complaints while still emitting a JSON envelope.
|
|
136
|
+
try {
|
|
137
|
+
resolvePromise(parseReport(stdout, stderr));
|
|
138
|
+
} catch (error) {
|
|
139
|
+
reject(error);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Parses `arkcli usage plan` stdout; exported for tests. */
|
|
146
|
+
export function parseReport(stdout, stderr = '') {
|
|
147
|
+
let report;
|
|
148
|
+
try {
|
|
149
|
+
report = JSON.parse(stdout);
|
|
150
|
+
} catch {
|
|
151
|
+
const detail = firstMeaningfulLine(stderr) || firstMeaningfulLine(stdout) || 'no output';
|
|
152
|
+
if (/binary not found/i.test(`${detail}\n${stderr}`)) {
|
|
153
|
+
throw new MissingCredential('Ark CLI (arkcli) platform binary', INSTALL_FIX, 'cli-missing');
|
|
154
|
+
}
|
|
155
|
+
if (/not logged in|login required|unauthorized|auth/i.test(detail)) {
|
|
156
|
+
throw new MissingCredential('Ark CLI SSO session (absent or expired)', HOW_TO_FIX);
|
|
157
|
+
}
|
|
158
|
+
throw new Error(`arkcli usage plan did not produce JSON (${detail})`);
|
|
159
|
+
}
|
|
160
|
+
if (!report || !Array.isArray(report.items)) {
|
|
161
|
+
throw new Error('arkcli usage plan report has no items array');
|
|
162
|
+
}
|
|
163
|
+
return report;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Turns the usage-plan payload into table rows.
|
|
168
|
+
* Exported for tests: no subprocess needed.
|
|
169
|
+
*/
|
|
170
|
+
export function buildRows(report) {
|
|
171
|
+
const rows = [];
|
|
172
|
+
const notes = [];
|
|
173
|
+
let skipped = 0;
|
|
174
|
+
|
|
175
|
+
for (const item of Array.isArray(report?.items) ? report.items : []) {
|
|
176
|
+
if (!item || typeof item !== 'object') continue;
|
|
177
|
+
if (item.subscribed === false) {
|
|
178
|
+
skipped += 1;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const planLabel = describeItem(item);
|
|
182
|
+
const periods = Array.isArray(item.periods) ? item.periods : [];
|
|
183
|
+
if (periods.length === 0) {
|
|
184
|
+
rows.push(row(`${planLabel} — no usage windows reported`));
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
for (const period of periods) {
|
|
188
|
+
if (!period || typeof period !== 'object') continue;
|
|
189
|
+
const windowName = text(period.label) || 'window';
|
|
190
|
+
const usedPct = usedPercentValue(period);
|
|
191
|
+
rows.push(
|
|
192
|
+
row(`${planLabel} — ${windowName}`, {
|
|
193
|
+
total: usedPct === null ? NA : pct(100),
|
|
194
|
+
used: usedPct === null ? NA : pct(usedPct),
|
|
195
|
+
remaining: usedPct === null ? NA : pct(Math.max(0, 100 - usedPct)),
|
|
196
|
+
reset: formatDateTime(period.reset_at),
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (rows.length === 0) {
|
|
203
|
+
rows.push(row('no active Ark plan subscription'));
|
|
204
|
+
notes.push(
|
|
205
|
+
`${label}: arkcli reported no subscribed plan items — check \`arkcli usage plan\` and \`${LOGIN_COMMAND}\``,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (skipped > 0) {
|
|
209
|
+
notes.push(`${label}: ${skipped} unsubscribed plan item(s) hidden (subscribed:false)`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return { rows, notes };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function describeItem(item) {
|
|
216
|
+
const product = text(item.product) || 'unknown-plan';
|
|
217
|
+
const edition = text(item.edition);
|
|
218
|
+
const tier = text(item.tier);
|
|
219
|
+
const parts = [product, edition].filter(Boolean).join(' ');
|
|
220
|
+
return tier ? `${parts} (${tier})` : parts || 'Ark plan';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function row(plan, values = {}) {
|
|
224
|
+
return { platform: label, plan, total: NA, used: NA, remaining: NA, reset: NA, ...values };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* All platforms display percentages, so doubao windows do too: arkcli reports
|
|
229
|
+
* `percent` directly, else it is derived from used/total. `remaining` is
|
|
230
|
+
* 100 minus the ROUNDED used value so used + remaining always reads as 100%.
|
|
231
|
+
*/
|
|
232
|
+
function pct(value) {
|
|
233
|
+
return `${Math.round(value * 100) / 100}%`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function usedPercentValue(period) {
|
|
237
|
+
const percent = Number(period?.percent);
|
|
238
|
+
if (Number.isFinite(percent)) return Math.max(0, percent);
|
|
239
|
+
const total = Number(period?.total);
|
|
240
|
+
const used = Number(period?.used);
|
|
241
|
+
if (Number.isFinite(total) && Number.isFinite(used) && total > 0) {
|
|
242
|
+
return Math.max(0, (used / total) * 100);
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function text(value) {
|
|
248
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : '';
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function firstMeaningfulLine(raw) {
|
|
252
|
+
return (
|
|
253
|
+
String(raw ?? '')
|
|
254
|
+
.split('\n')
|
|
255
|
+
.map((line) => line.trim())
|
|
256
|
+
.filter((line) => line !== '')
|
|
257
|
+
.find((line) => !line.startsWith('(node:')) ?? ''
|
|
258
|
+
).slice(0, 220);
|
|
259
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** The three platforms this panel knows about, in display order (doubao, bailian, cursor). */
|
|
2
|
+
import * as cursor from './cursor.js';
|
|
3
|
+
import * as doubao from './doubao.js';
|
|
4
|
+
import * as bailian from './bailian.js';
|
|
5
|
+
|
|
6
|
+
export const platforms = [doubao, bailian, cursor];
|
|
7
|
+
|
|
8
|
+
export function findPlatform(id) {
|
|
9
|
+
return platforms.find((platform) => platform.id === id);
|
|
10
|
+
}
|
package/src/quota-axi.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quota-axi integration — the single data source for the Cursor and Alibaba
|
|
3
|
+
* (Bailian) platform rows.
|
|
4
|
+
*
|
|
5
|
+
* Per the project contract, quota-axi owns all credential reading, API calls
|
|
6
|
+
* and normalization for those two providers; this module only runs it as a
|
|
7
|
+
* subprocess and maps its JSON windows into table rows. Doubao/Volcengine is
|
|
8
|
+
* the only self-built fetcher and does not go through here.
|
|
9
|
+
*
|
|
10
|
+
* Invocation details that were verified against quota-axi 0.1.47:
|
|
11
|
+
* - `quota-axi --provider cursor,alibaba --json` prints a JSON report
|
|
12
|
+
* `{ generatedAt, schemaVersion, providers: [...] }`; a provider-level
|
|
13
|
+
* failure is reported inside `providers[].state` while the process still
|
|
14
|
+
* prints valid JSON (it may exit non-zero when any provider fails), so a
|
|
15
|
+
* parseable stdout is always preferred over the exit code.
|
|
16
|
+
* - `--no-credential-refresh` keeps the read strictly read-only: no expired
|
|
17
|
+
* session is ever renewed by an interactive vendor CLI flow.
|
|
18
|
+
* - The cursor provider reads the signed-in editor's `state.vscdb` through
|
|
19
|
+
* the `sqlite3` CLI. On Windows only a real `sqlite3.exe` is executable
|
|
20
|
+
* from a spawned process, so this repo vendors one under
|
|
21
|
+
* `vendor/sqlite3-shim/` and prepends that directory to PATH for the
|
|
22
|
+
* subprocess only. Nothing is installed globally and the user's own
|
|
23
|
+
* environment is never modified.
|
|
24
|
+
*
|
|
25
|
+
* The subprocess PATH also gets the dependency tree's `.bin` shims: quota-axi
|
|
26
|
+
* resolves the Bailian CLI (`bl`) through PATH on its own, and npm does not put
|
|
27
|
+
* a nested dependency's shims on the user's PATH.
|
|
28
|
+
*/
|
|
29
|
+
import { spawn } from 'node:child_process';
|
|
30
|
+
import { existsSync } from 'node:fs';
|
|
31
|
+
import { dirname, join, resolve } from 'node:path';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
|
|
34
|
+
import { commandLine } from './cli.js';
|
|
35
|
+
import { formatDateTime, formatPercent, formatUsd, NA } from './format.js';
|
|
36
|
+
import { dependencyBinDirs, PACKAGES, resolveDependencyCli } from './resolve-deps.js';
|
|
37
|
+
|
|
38
|
+
/** The providers this panel consumes through quota-axi, in CLI flag form. */
|
|
39
|
+
export const QUOTA_AXI_PROVIDERS = 'cursor,alibaba';
|
|
40
|
+
|
|
41
|
+
const SPAWN_TIMEOUT_MS = 90_000;
|
|
42
|
+
|
|
43
|
+
/** Raised when quota-axi itself cannot be used at all (missing/undecodable). */
|
|
44
|
+
export class QuotaAxiUnavailable extends Error {
|
|
45
|
+
constructor(message) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = 'QuotaAxiUnavailable';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Absolute path of the vendored sqlite3 directory (Windows: contains sqlite3.exe). */
|
|
52
|
+
export function shimDir(importMetaUrl = import.meta.url) {
|
|
53
|
+
return resolve(dirname(fileURLToPath(importMetaUrl)), '..', 'vendor', 'sqlite3-shim');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** PATH entries prepended for the quota-axi subprocess only. */
|
|
57
|
+
function subprocessPathEntries() {
|
|
58
|
+
return [shimDir(), ...dependencyBinDirs()];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolves how to launch quota-axi:
|
|
63
|
+
* 1. `QUOTA_AXI_BIN` env var — absolute path to quota-axi.js or a launchable binary
|
|
64
|
+
* 2. this package's own dependency tree (`npm i -g token-quota`, `npx token-quota`),
|
|
65
|
+
* using quota-axi's declared bin entry
|
|
66
|
+
* 3. the copy installed next to the running node binary (`<node dir>/node_modules/quota-axi`)
|
|
67
|
+
* 4. plain `quota-axi` from PATH (works via execvp on POSIX)
|
|
68
|
+
* Returns { command, args } or null when nothing usable is found.
|
|
69
|
+
* `resolveDependency` is injectable so the ordering above can be tested.
|
|
70
|
+
*/
|
|
71
|
+
export function resolveLaunch({
|
|
72
|
+
env = process.env,
|
|
73
|
+
execPath = process.execPath,
|
|
74
|
+
platform = process.platform,
|
|
75
|
+
arch = process.arch,
|
|
76
|
+
resolveDependency = resolveDependencyCli,
|
|
77
|
+
} = {}) {
|
|
78
|
+
const entryArgs = ['--json', '--no-credential-refresh', '--provider', QUOTA_AXI_PROVIDERS];
|
|
79
|
+
|
|
80
|
+
const bin = (env.QUOTA_AXI_BIN ?? '').trim();
|
|
81
|
+
if (bin !== '') return { command: bin, args: entryArgs };
|
|
82
|
+
|
|
83
|
+
const dependency = resolveDependency(PACKAGES.quotaAxi, { execPath, platform, arch });
|
|
84
|
+
if (dependency) {
|
|
85
|
+
return { command: dependency.command, args: [...dependency.prefixArgs, ...entryArgs] };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const siblingEntry = join(
|
|
89
|
+
dirname(execPath),
|
|
90
|
+
'node_modules',
|
|
91
|
+
'quota-axi',
|
|
92
|
+
'dist',
|
|
93
|
+
'bin',
|
|
94
|
+
'quota-axi.js',
|
|
95
|
+
);
|
|
96
|
+
if (existsSync(siblingEntry)) return { command: execPath, args: [siblingEntry, ...entryArgs] };
|
|
97
|
+
|
|
98
|
+
if (platform === 'win32') {
|
|
99
|
+
// Spawn cannot resolve quota-axi.cmd without a shell; the npm shim next to
|
|
100
|
+
// node.exe launches it with the same node binary, so try that path shape.
|
|
101
|
+
const cmdShim = join(dirname(execPath), 'quota-axi.cmd');
|
|
102
|
+
if (existsSync(cmdShim)) return { command: cmdShim, args: entryArgs, shell: true };
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
return { command: 'quota-axi', args: entryArgs };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Parses quota-axi stdout; returns the report object or throws QuotaAxiUnavailable. */
|
|
109
|
+
export function parseReport(stdout, stderr = '') {
|
|
110
|
+
let report;
|
|
111
|
+
try {
|
|
112
|
+
report = JSON.parse(stdout);
|
|
113
|
+
} catch {
|
|
114
|
+
const detail = firstLine(stderr) || firstLine(stdout) || 'no output';
|
|
115
|
+
throw new QuotaAxiUnavailable(`quota-axi did not produce a JSON report (${detail})`);
|
|
116
|
+
}
|
|
117
|
+
if (!report || !Array.isArray(report.providers)) {
|
|
118
|
+
throw new QuotaAxiUnavailable('quota-axi report has no providers array');
|
|
119
|
+
}
|
|
120
|
+
return report;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function findProvider(report, id) {
|
|
124
|
+
return report.providers.find((provider) => provider?.provider === id) ?? null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let reportPromise = null;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Single-flight report fetch shared by both quota-axi-backed platforms, so one
|
|
131
|
+
* panel run spawns quota-axi exactly once. Memoized per process.
|
|
132
|
+
*/
|
|
133
|
+
export function fetchReport(launch = resolveLaunch()) {
|
|
134
|
+
if (reportPromise) return reportPromise;
|
|
135
|
+
reportPromise = spawnReport(launch).catch((error) => {
|
|
136
|
+
reportPromise = null; // allow a later call in the same process to retry
|
|
137
|
+
throw error;
|
|
138
|
+
});
|
|
139
|
+
return reportPromise;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function spawnReport(launch) {
|
|
143
|
+
if (!launch) {
|
|
144
|
+
return Promise.reject(
|
|
145
|
+
new QuotaAxiUnavailable(
|
|
146
|
+
'quota-axi was not found (install it globally, or point QUOTA_AXI_BIN at its entry script)',
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
return new Promise((resolvePromise, reject) => {
|
|
151
|
+
const spawnOptions = {
|
|
152
|
+
timeout: SPAWN_TIMEOUT_MS,
|
|
153
|
+
env: {
|
|
154
|
+
...process.env,
|
|
155
|
+
// Give the subprocess the vendored sqlite3.exe (cursor state-vscdb
|
|
156
|
+
// source) and the dependency tree's `.bin` shims (the `bl` CLI lookup),
|
|
157
|
+
// without touching the user's own environment.
|
|
158
|
+
PATH: [...subprocessPathEntries(), process.env.PATH ?? ''].join(
|
|
159
|
+
process.platform === 'win32' ? ';' : ':',
|
|
160
|
+
),
|
|
161
|
+
},
|
|
162
|
+
windowsHide: true,
|
|
163
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
164
|
+
};
|
|
165
|
+
// A `.cmd` npm shim can only be launched through a shell, so that path uses
|
|
166
|
+
// one composed line (passing args with `shell` is deprecated).
|
|
167
|
+
const child = launch.shell
|
|
168
|
+
? spawn(commandLine(launch.command, launch.args), { ...spawnOptions, shell: true })
|
|
169
|
+
: spawn(launch.command, launch.args, spawnOptions);
|
|
170
|
+
let stdout = '';
|
|
171
|
+
let stderr = '';
|
|
172
|
+
child.stdout.on('data', (chunk) => {
|
|
173
|
+
stdout += chunk;
|
|
174
|
+
});
|
|
175
|
+
child.stderr.on('data', (chunk) => {
|
|
176
|
+
stderr += chunk;
|
|
177
|
+
});
|
|
178
|
+
child.on('error', (error) => {
|
|
179
|
+
reject(new QuotaAxiUnavailable(`quota-axi could not be launched: ${error.message}`));
|
|
180
|
+
});
|
|
181
|
+
child.on('close', () => {
|
|
182
|
+
// Exit code is intentionally ignored: quota-axi exits non-zero when any
|
|
183
|
+
// provider fails while still printing the full per-provider JSON report.
|
|
184
|
+
try {
|
|
185
|
+
resolvePromise(parseReport(stdout, stderr));
|
|
186
|
+
} catch (error) {
|
|
187
|
+
reject(error);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Maps one quota-axi provider's windows into table rows.
|
|
195
|
+
* Two window shapes exist (schemaVersion 5):
|
|
196
|
+
* - percent windows: percentRemaining (preferred), percentUsed, resetsAt
|
|
197
|
+
* - credit/USD windows: spentUsd + limitUsd (kind "credits")
|
|
198
|
+
* `platformLabel` goes into the PLATFORM column; the plan name prefixes the PLAN column.
|
|
199
|
+
*/
|
|
200
|
+
export function providerWindowsToRows(platformLabel, provider) {
|
|
201
|
+
const plan = planName(provider);
|
|
202
|
+
return (provider?.windows ?? []).map((window) => {
|
|
203
|
+
if (window?.kind === 'credits' || window?.limitUsd !== undefined) {
|
|
204
|
+
const limit = finiteOrNull(window?.limitUsd);
|
|
205
|
+
const spent = finiteOrNull(window?.spentUsd);
|
|
206
|
+
const remaining = limit !== null && spent !== null ? Math.max(0, limit - spent) : null;
|
|
207
|
+
return {
|
|
208
|
+
platform: platformLabel,
|
|
209
|
+
plan: `${plan} — ${windowLabel(window)}`,
|
|
210
|
+
total: centsOrNa(limit),
|
|
211
|
+
used: centsOrNa(spent),
|
|
212
|
+
remaining: centsOrNa(remaining),
|
|
213
|
+
reset: formatDateTime(window?.resetsAt),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
const used = finiteOrNull(window?.percentUsed);
|
|
217
|
+
const remaining = finiteOrNull(window?.percentRemaining);
|
|
218
|
+
return {
|
|
219
|
+
platform: platformLabel,
|
|
220
|
+
plan: `${plan} — ${windowLabel(window)}`,
|
|
221
|
+
total: '100%',
|
|
222
|
+
used: formatPercent(used ?? (remaining === null ? null : Math.round((100 - remaining) * 10) / 10)),
|
|
223
|
+
remaining: formatPercent(remaining ?? (used === null ? null : Math.max(0, Math.round((100 - used) * 10) / 10))),
|
|
224
|
+
reset: formatDateTime(window?.resetsAt),
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function planName(provider) {
|
|
230
|
+
return provider?.plan ?? provider?.label ?? 'unknown plan';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function windowLabel(window) {
|
|
234
|
+
return String(window?.label ?? window?.id ?? 'usage');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function finiteOrNull(value) {
|
|
238
|
+
const n = Number(value);
|
|
239
|
+
return Number.isFinite(n) ? n : null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** formatUsd takes cents; quota-axi credit windows carry whole dollars. */
|
|
243
|
+
function centsOrNa(usd) {
|
|
244
|
+
return usd === null ? NA : formatUsd(Math.round(usd * 100));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function firstLine(text) {
|
|
248
|
+
return (
|
|
249
|
+
String(text ?? '')
|
|
250
|
+
.split('\n')
|
|
251
|
+
.map((line) => line.trim())
|
|
252
|
+
.filter((line) => line !== '')
|
|
253
|
+
.find((line) => !line.startsWith('(node:')) ?? ''
|
|
254
|
+
);
|
|
255
|
+
}
|