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.
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Dependency resolution — finds the three data-source CLIs inside this
3
+ * package's own dependency tree.
4
+ *
5
+ * token-quota declares `quota-axi`, `bailian-cli` and `@volcengine/ark-cli` as
6
+ * dependencies, so after `npm i -g token-quota` (or `npx token-quota`) they sit
7
+ * on disk next to this package. These helpers locate the package directory with
8
+ * `createRequire(import.meta.url)` (never by guessing a global layout) and then
9
+ * read **that package's own `bin` map** to find its entry point, so a dependency
10
+ * that moves its entry file does not silently break us.
11
+ *
12
+ * Resolution order per package, first hit wins:
13
+ * 1. `<pkg>/package.json` through the Node resolver
14
+ * 2. the package's resolved main entry, then walk up to the package root
15
+ * 3. the plain `node_modules/<pkg>` directory in the resolver's search paths
16
+ * Every step is read-only and side-effect free; a failure returns null so the
17
+ * caller can fall back to its own global-install-layout probes.
18
+ *
19
+ * `@volcengine/ark-cli` is special: its platform binary is downloaded by its own
20
+ * postinstall script, so the binary is preferred and the bin script
21
+ * (`scripts/run.js`, a node program that execs the binary) is the fallback.
22
+ */
23
+ import { createRequire } from 'node:module';
24
+ import { existsSync, readFileSync, statSync } from 'node:fs';
25
+ import { dirname, join, resolve } from 'node:path';
26
+
27
+ const defaultRequire = createRequire(import.meta.url);
28
+
29
+ /** npm package names of the three data sources. */
30
+ export const PACKAGES = {
31
+ quotaAxi: 'quota-axi',
32
+ bailianCli: 'bailian-cli',
33
+ arkCli: '@volcengine/ark-cli',
34
+ };
35
+
36
+ /** How deep to walk up looking for the package root behind a resolved entry. */
37
+ const MAX_WALK_UP = 12;
38
+
39
+ /** Reads and parses a package.json inside `dir`; null when absent or unreadable. */
40
+ export function readManifest(dir) {
41
+ try {
42
+ return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ function isDirectory(path) {
49
+ try {
50
+ return statSync(path).isDirectory();
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Absolute directory of an installed package, or null.
58
+ * @param {string} name package name (may be scoped)
59
+ * @param {{ require?: { resolve: Function } }} [options]
60
+ */
61
+ export function resolvePackageDir(name, { require: req = defaultRequire } = {}) {
62
+ // 1. Direct package.json lookup (works unless the package restricts "exports").
63
+ try {
64
+ const manifest = req.resolve(`${name}/package.json`);
65
+ if (manifest) {
66
+ const dir = dirname(manifest);
67
+ if (isDirectory(dir)) return dir;
68
+ }
69
+ } catch {
70
+ // fall through: quota-axi ships an "exports" map without ./package.json
71
+ }
72
+
73
+ // 2. Resolve the main entry, then walk up to the package root.
74
+ try {
75
+ const entry = req.resolve(name);
76
+ if (entry) {
77
+ const found = walkUpToPackage(dirname(entry), name);
78
+ if (found) return found;
79
+ }
80
+ } catch {
81
+ // fall through: no main entry resolvable either
82
+ }
83
+
84
+ // 3. Bare node_modules directory in the resolver's search paths.
85
+ const roots = typeof req.resolve?.paths === 'function' ? req.resolve.paths(name) ?? [] : [];
86
+ for (const root of roots) {
87
+ const dir = join(root, ...name.split('/'));
88
+ if (isDirectory(dir)) return dir;
89
+ }
90
+ return null;
91
+ }
92
+
93
+ function walkUpToPackage(from, name) {
94
+ let dir = from;
95
+ for (let i = 0; i < MAX_WALK_UP; i += 1) {
96
+ const manifest = readManifest(dir);
97
+ if (manifest?.name === name) return dir;
98
+ const parent = dirname(dir);
99
+ if (parent === dir) return null;
100
+ dir = parent;
101
+ }
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * Absolute path of a bin entry declared by the package in `dir`, or null.
107
+ * Accepts both `bin` shapes npm allows: a string (one bin, named like the
108
+ * package) and an object keyed by command name — falling back to its only
109
+ * entry when the requested key is absent.
110
+ * @param {string} dir package root
111
+ * @param {string} binName command name, e.g. "quota-axi" or "bl"
112
+ */
113
+ export function binEntry(dir, binName) {
114
+ const bin = readManifest(dir)?.bin;
115
+ let relative = null;
116
+ if (typeof bin === 'string') relative = bin;
117
+ else if (bin && typeof bin === 'object') {
118
+ relative = typeof bin[binName] === 'string' ? bin[binName] : null;
119
+ if (relative === null) {
120
+ const values = Object.values(bin).filter((value) => typeof value === 'string');
121
+ if (values.length === 1) [relative] = values;
122
+ }
123
+ }
124
+ if (relative === null) return null;
125
+ const entry = resolve(dir, relative);
126
+ return existsSync(entry) ? entry : null;
127
+ }
128
+
129
+ /**
130
+ * Launch descriptor for one package's first matching bin name, or null.
131
+ * Exported for callers that need a bare command from PATH instead of the tree.
132
+ */
133
+ export function resolveCliEntry(name, binNames, options = {}) {
134
+ const dir = resolvePackageDir(name, options);
135
+ if (!dir) return null;
136
+ for (const binName of binNames) {
137
+ const entry = binEntry(dir, binName);
138
+ if (entry) return entry;
139
+ }
140
+ return null;
141
+ }
142
+
143
+ /** quota-axi's entry script inside our dependency tree, or null. */
144
+ export function resolveQuotaAxiEntry(options = {}) {
145
+ return resolveCliEntry(PACKAGES.quotaAxi, ['quota-axi'], options);
146
+ }
147
+
148
+ /** The official Bailian CLI (`bl`) entry inside our dependency tree, or null. */
149
+ export function resolveBailianCliEntry(options = {}) {
150
+ return resolveCliEntry(PACKAGES.bailianCli, ['bl', 'bailian'], options);
151
+ }
152
+
153
+ /**
154
+ * File name of the ark-cli platform binary for a platform/arch pair, mirroring
155
+ * the mapping in @volcengine/ark-cli's own `scripts/run.js`. null when the
156
+ * platform has no published binary.
157
+ */
158
+ export function arkCliBinaryName({ platform = process.platform, arch = process.arch } = {}) {
159
+ const platforms = { darwin: 'darwin', linux: 'linux', win32: 'windows' };
160
+ const arches = { x64: 'amd64', arm64: 'arm64' };
161
+ const os = platforms[platform];
162
+ const machine = arches[arch];
163
+ if (!os || !machine) return null;
164
+ return `arkcli-${os}-${machine}${os === 'windows' ? '.exe' : ''}`;
165
+ }
166
+
167
+ /**
168
+ * How to launch the dependency-tree arkcli:
169
+ * - `{ command: <platform binary> }` when the postinstall download succeeded
170
+ * - `{ command: <scripts/run.js>, nodeEntry: true }` — the package's own bin
171
+ * script, which needs the node binary in front of it
172
+ * - null when the package is not in the dependency tree
173
+ */
174
+ export function resolveArkCliLaunch(options = {}) {
175
+ const { platform = process.platform, arch = process.arch } = options;
176
+ const dir = resolvePackageDir(PACKAGES.arkCli, options);
177
+ if (!dir) return null;
178
+ const binaryName = arkCliBinaryName({ platform, arch });
179
+ if (binaryName) {
180
+ const binary = join(dir, 'bin', binaryName);
181
+ if (existsSync(binary)) return { command: binary };
182
+ }
183
+ const script = binEntry(dir, 'arkcli');
184
+ if (script) return { command: script, nodeEntry: true };
185
+ return null;
186
+ }
187
+
188
+ /**
189
+ * Uniform launch descriptor for one data-source CLI from the dependency tree.
190
+ * JS entries are run with the current node binary, so callers get an executable
191
+ * plus the arguments that must precede the CLI's own arguments.
192
+ * @returns {{ command: string, prefixArgs: string[] } | null}
193
+ */
194
+ export function resolveDependencyCli(name, { execPath = process.execPath, ...options } = {}) {
195
+ if (name === PACKAGES.arkCli) {
196
+ const launch = resolveArkCliLaunch(options);
197
+ if (!launch) return null;
198
+ return launch.nodeEntry
199
+ ? { command: execPath, prefixArgs: [launch.command] }
200
+ : { command: launch.command, prefixArgs: [] };
201
+ }
202
+ const binNames = name === PACKAGES.bailianCli ? ['bl', 'bailian'] : ['quota-axi'];
203
+ const entry = resolveCliEntry(name, binNames, options);
204
+ return entry ? { command: execPath, prefixArgs: [entry] } : null;
205
+ }
206
+
207
+ /**
208
+ * npm's `.bin` shim directory for each dependency that resolved, deduplicated
209
+ * and filtered to what exists on disk.
210
+ *
211
+ * quota-axi looks up the Bailian CLI (`bl`) on PATH itself, and npm does not put
212
+ * a nested dependency's bin shims on the user's PATH — so the bailian row only
213
+ * works from a packaged install if those shims are prepended to the subprocess
214
+ * PATH (the same trick the vendored sqlite3 shim uses).
215
+ */
216
+ export function dependencyBinDirs(options = {}) {
217
+ const dirs = [];
218
+ for (const name of Object.values(PACKAGES)) {
219
+ const dir = resolvePackageDir(name, options);
220
+ if (!dir) continue;
221
+ const binDir = join(dirname(dir), '.bin');
222
+ if (dirs.includes(binDir) || !isDirectory(binDir)) continue;
223
+ dirs.push(binDir);
224
+ }
225
+ return dirs;
226
+ }
227
+
228
+ /**
229
+ * Launch descriptor for a bare command name resolved through PATH, used as the
230
+ * last resort when nothing is found in the dependency tree.
231
+ *
232
+ * On Windows the npm shims are `.cmd` wrappers, which a spawned process cannot
233
+ * execute directly (and libuv's PATH search only appends `.exe`), so the `.cmd`
234
+ * form plus a shell is required there. On POSIX the plain name works.
235
+ * @returns {{ command: string, prefixArgs: string[], shell?: true }}
236
+ */
237
+ export function pathFallback(command, { platform = process.platform } = {}) {
238
+ if (platform !== 'win32') return { command, prefixArgs: [] };
239
+ return { command: `${command}.cmd`, prefixArgs: [], shell: true };
240
+ }
package/src/setup.js ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * `token-quota setup` — first-run sign-in wizard.
3
+ *
4
+ * For each selected platform it checks the current state (a real, read-only
5
+ * query) and, when a sign-in is missing, explains what is needed and runs the
6
+ * platform's own login command with inherited stdio so the browser flow is the
7
+ * official one:
8
+ *
9
+ * doubao `arkcli auth login volc-sso` (one browser SSO sign-in)
10
+ * bailian `bl auth login --console` (one browser sign-in)
11
+ * cursor sign in inside the Cursor editor, then this wizard re-checks
12
+ *
13
+ * The wizard writes no file and stores no credential: every session stays owned
14
+ * by the vendor CLI or editor that created it. Non-interactive runs (no TTY)
15
+ * only print the manual steps and exit 0.
16
+ */
17
+ import { spawn } from 'node:child_process';
18
+ import { createInterface } from 'node:readline';
19
+
20
+ import { commandLine, selectPlatforms } from './cli.js';
21
+ import { classifyFailure } from './doctor.js';
22
+ import { runPanel } from './panel.js';
23
+ import { platforms as allPlatforms } from './platforms/index.js';
24
+ import { PACKAGES, pathFallback, resolveDependencyCli } from './resolve-deps.js';
25
+
26
+ /** How many sign-in attempts one platform gets before the wizard moves on. */
27
+ const MAX_ATTEMPTS = 3;
28
+
29
+ /**
30
+ * Per-platform sign-in facts. `cli` is the npm package that owns the login
31
+ * command, `loginArgs` its arguments (null = the credential is created inside
32
+ * an editor), `manual` the copy-pasteable command for non-interactive runs.
33
+ */
34
+ export const PLATFORM_GUIDE = {
35
+ doubao: {
36
+ cli: PACKAGES.arkCli,
37
+ fallback: 'arkcli',
38
+ loginArgs: ['auth', 'login', 'volc-sso'],
39
+ manual: 'arkcli auth login volc-sso',
40
+ summary: 'Volcengine Ark CLI SSO session — one browser SSO sign-in',
41
+ editor: false,
42
+ },
43
+ bailian: {
44
+ cli: PACKAGES.bailianCli,
45
+ fallback: 'bl',
46
+ loginArgs: ['auth', 'login', '--console'],
47
+ manual: 'bl auth login --console',
48
+ summary: 'Bailian console session — one browser sign-in',
49
+ editor: false,
50
+ },
51
+ cursor: {
52
+ cli: PACKAGES.quotaAxi,
53
+ fallback: 'quota-axi',
54
+ loginArgs: null,
55
+ manual: 'open Cursor and sign in',
56
+ summary: 'Cursor sign-in inside the editor — quota-axi reads the local credential store Cursor itself keeps',
57
+ editor: true,
58
+ },
59
+ };
60
+
61
+ const HOW_TO_INSTALL = {
62
+ [PACKAGES.arkCli]: 'npm i -g @volcengine/ark-cli',
63
+ [PACKAGES.bailianCli]: 'npm i -g bailian-cli',
64
+ [PACKAGES.quotaAxi]: 'npm i -g quota-axi',
65
+ };
66
+
67
+ /**
68
+ * Runs the wizard (or prints the manual steps) and finishes with the panel.
69
+ * @param {{ only?: string[]|null }} options parsed CLI options
70
+ * @param {object} [io] injectable environment
71
+ * @returns {Promise<number>} exit code
72
+ */
73
+ export async function runSetup(options = {}, io = {}) {
74
+ const {
75
+ stdout = process.stdout,
76
+ stderr = process.stderr,
77
+ stdin = process.stdin,
78
+ isTTY = Boolean(stdin.isTTY && stdout.isTTY),
79
+ platforms = allPlatforms,
80
+ execPath = process.execPath,
81
+ platform = process.platform,
82
+ arch = process.arch,
83
+ ask = null,
84
+ runLogin = defaultRunLogin,
85
+ } = io;
86
+
87
+ let selected;
88
+ try {
89
+ selected = selectPlatforms(options.only, platforms);
90
+ } catch (error) {
91
+ stderr.write(`token-quota: ${error.message}\n`);
92
+ return 2;
93
+ }
94
+
95
+ if (!isTTY) {
96
+ printManualGuide(selected, stdout);
97
+ return 0;
98
+ }
99
+
100
+ const ownPrompt = ask === null;
101
+ const prompt = ask ?? createPrompt({ input: stdin, output: stdout });
102
+ try {
103
+ stdout.write('token-quota setup — signing in to the platforms that are not configured yet\n\n');
104
+ for (const platformCheck of selected) {
105
+ await setupOne(platformCheck, { stdout, prompt, execPath, platform, arch, runLogin });
106
+ stdout.write('\n');
107
+ }
108
+ } finally {
109
+ if (ownPrompt) prompt.close?.();
110
+ }
111
+
112
+ stdout.write('Final panel:\n\n');
113
+ return runPanel({ only: selected.map((entry) => entry.id), json: false }, { stdout, stderr, isTTY: true, platforms });
114
+ }
115
+
116
+ /**
117
+ * Sign-in loop for one platform: check → explain → run the official login →
118
+ * re-check. `cli-missing` and plain query failures end the loop with guidance.
119
+ */
120
+ async function setupOne(platformCheck, { stdout, prompt, execPath, platform, arch, runLogin }) {
121
+ const guide = PLATFORM_GUIDE[platformCheck.id] ?? { editor: false, loginArgs: null };
122
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
123
+ const state = await probe(platformCheck);
124
+ if (state.ok) {
125
+ stdout.write(` [ok] ${platformCheck.id} — already configured (${state.rows} row(s) read)\n`);
126
+ return true;
127
+ }
128
+ if (state.kind === 'cli-missing') {
129
+ stdout.write(` [fail] ${platformCheck.id} — ${state.detail}\n`);
130
+ stdout.write(` fix: ${state.fix ?? HOW_TO_INSTALL[guide.cli] ?? 'install the CLI'}\n`);
131
+ return false;
132
+ }
133
+ if (state.kind === 'failed') {
134
+ stdout.write(` [fail] ${platformCheck.id} — ${state.detail}\n`);
135
+ return false;
136
+ }
137
+
138
+ stdout.write(` [--] ${platformCheck.id} — not signed in: ${state.detail}\n`);
139
+ if (guide.editor) {
140
+ const answer = await prompt(` ${guide.manual}, then press Enter to check again (or "s" to skip): `);
141
+ if (isSkip(answer)) return false;
142
+ continue;
143
+ }
144
+
145
+ const launch = resolveDependencyCli(guide.cli, { execPath, platform, arch });
146
+ const command = launch ?? pathFallback(guide.fallback, { platform });
147
+ const rendered = [command.command, ...command.prefixArgs, ...guide.loginArgs].join(' ');
148
+ const answer = await prompt(` Press Enter to run \`${guide.manual}\` now (or "s" to skip): `);
149
+ if (isSkip(answer)) return false;
150
+
151
+ stdout.write(` running: ${rendered}\n`);
152
+ const code = await runLogin(command, guide.loginArgs);
153
+ if (code !== 0) {
154
+ stdout.write(` that command exited with code ${code} — you can re-run it later and start setup again\n`);
155
+ }
156
+ }
157
+ stdout.write(` [--] ${platformCheck.id} — still not signed in; re-run \`token-quota setup\` when you are ready\n`);
158
+ return false;
159
+ }
160
+
161
+ /** Read-only platform probe reusing the doctor classification. */
162
+ async function probe(platformCheck) {
163
+ try {
164
+ const { rows } = await platformCheck.query();
165
+ return { ok: true, rows: Array.isArray(rows) ? rows.length : 0 };
166
+ } catch (error) {
167
+ return { ok: false, ...classifyFailure(error) };
168
+ }
169
+ }
170
+
171
+ function isSkip(answer) {
172
+ return /^(s|skip|n|no|q|quit)$/i.test(String(answer ?? '').trim());
173
+ }
174
+
175
+ function createPrompt({ input, output }) {
176
+ const rl = createInterface({ input, output });
177
+ const prompt = (question) => new Promise((resolve) => rl.question(question, resolve));
178
+ prompt.close = () => rl.close();
179
+ return prompt;
180
+ }
181
+
182
+ /** Spawns the vendor login command with inherited stdio; resolves with its exit code. */
183
+ export function defaultRunLogin(launch, args) {
184
+ const argv = [...launch.prefixArgs, ...args];
185
+ return new Promise((resolve) => {
186
+ let child;
187
+ try {
188
+ // Windows `.cmd` npm shims need a shell and therefore one composed line.
189
+ child =
190
+ launch.shell === true
191
+ ? spawn(commandLine(launch.command, argv), { stdio: 'inherit', shell: true, windowsHide: false })
192
+ : spawn(launch.command, argv, { stdio: 'inherit', windowsHide: false });
193
+ } catch (error) {
194
+ resolve(1);
195
+ return;
196
+ }
197
+ child.on('error', () => resolve(1));
198
+ child.on('close', (code) => resolve(code ?? 0));
199
+ });
200
+ }
201
+
202
+ /** Non-interactive output: what to run by hand, per platform. Exit code is always 0. */
203
+ function printManualGuide(selected, stdout) {
204
+ const lines = [
205
+ 'token-quota setup — sign-in helper',
206
+ '',
207
+ 'Not running in an interactive terminal, so no login command is launched here.',
208
+ 'Sign in once per platform, then run `token-quota` again:',
209
+ '',
210
+ ];
211
+ for (const platformCheck of selected) {
212
+ const guide = PLATFORM_GUIDE[platformCheck.id];
213
+ if (!guide) continue;
214
+ lines.push(` ${platformCheck.id}`);
215
+ lines.push(` ${guide.manual}`);
216
+ lines.push(` ${guide.summary}`);
217
+ lines.push('');
218
+ }
219
+ lines.push(' Every session belongs to the vendor CLI or editor that created it:');
220
+ lines.push(' this wizard writes no file and stores no credential.');
221
+ lines.push('');
222
+ lines.push('Next: token-quota setup (interactive wizard)');
223
+ lines.push(' token-quota doctor (read-only check of what is missing)');
224
+ lines.push('');
225
+ stdout.write(`${lines.join('\n')}`);
226
+ }
package/src/table.js ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Minimal fixed-width table renderer for the compact one-table output.
3
+ * Every cell is a plain string; rows carry no colour so output stays pipeable.
4
+ */
5
+
6
+ const COLUMNS = ['PLATFORM', 'PLAN/PACKAGE', 'TOTAL', 'USED', 'REMAINING', 'RESET'];
7
+
8
+ /** @param {Array<{platform: string, plan: string, total: string, used: string, remaining: string, reset: string}>} rows */
9
+ export function renderTable(rows) {
10
+ const cells = rows.map((row) => [
11
+ row.platform ?? '',
12
+ row.plan ?? '',
13
+ row.total ?? '',
14
+ row.used ?? '',
15
+ row.remaining ?? '',
16
+ row.reset ?? '',
17
+ ]);
18
+
19
+ const widths = COLUMNS.map((header, i) =>
20
+ cells.reduce((max, row) => Math.max(max, row[i].length), header.length),
21
+ );
22
+
23
+ const line = (row) =>
24
+ row
25
+ .map((cell, i) => (i === row.length - 1 ? cell : pad(cell, widths[i])))
26
+ .join(' ')
27
+ .trimEnd();
28
+
29
+ const rule = widths.map((w) => '-'.repeat(w)).join(' ');
30
+ return [line(COLUMNS), rule, ...cells.map(line)].join('\n');
31
+ }
32
+
33
+ function pad(text, width) {
34
+ return text.length >= width ? text : text + ' '.repeat(width - text.length);
35
+ }
36
+
37
+ export { COLUMNS };
Binary file