@modelprofile.com/authswitch 3.3.0 → 4.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/dist_ts/00_commitinfo_data.js +3 -3
- package/dist_ts/accounts.d.ts +42 -15
- package/dist_ts/accounts.js +98 -27
- package/dist_ts/classes.accountlist.d.ts +0 -25
- package/dist_ts/classes.accountlist.js +2 -216
- package/dist_ts/classes.claudecodeharness.d.ts +59 -1
- package/dist_ts/classes.claudecodeharness.js +130 -14
- package/dist_ts/classes.claudecodelocks.d.ts +35 -0
- package/dist_ts/classes.claudecodelocks.js +117 -0
- package/dist_ts/classes.claudestatus.d.ts +15 -2
- package/dist_ts/classes.claudestatus.js +88 -73
- package/dist_ts/classes.claudetokenrefresh.d.ts +32 -0
- package/dist_ts/classes.claudetokenrefresh.js +77 -0
- package/dist_ts/classes.cli.d.ts +14 -7
- package/dist_ts/classes.cli.js +125 -66
- package/dist_ts/classes.codexharness.d.ts +4 -0
- package/dist_ts/classes.codexharness.js +5 -1
- package/dist_ts/classes.codexstatus.d.ts +2 -1
- package/dist_ts/classes.codexstatus.js +25 -9
- package/dist_ts/classes.credentialstore.d.ts +28 -2
- package/dist_ts/classes.credentialstore.js +41 -12
- package/dist_ts/classes.fileharness.d.ts +37 -17
- package/dist_ts/classes.fileharness.js +58 -24
- package/dist_ts/classes.limits.d.ts +46 -7
- package/dist_ts/classes.limits.js +94 -36
- package/dist_ts/classes.listrenderer.d.ts +17 -0
- package/dist_ts/classes.listrenderer.js +313 -0
- package/dist_ts/classes.login.d.ts +1 -1
- package/dist_ts/classes.login.js +1 -1
- package/dist_ts/classes.opencodeharness.d.ts +4 -0
- package/dist_ts/classes.opencodeharness.js +7 -3
- package/dist_ts/classes.operations.js +3 -2
- package/dist_ts/classes.tui.js +4 -3
- package/dist_ts/classes.watch.d.ts +108 -0
- package/dist_ts/classes.watch.js +219 -0
- package/dist_ts/classes.watchlock.d.ts +33 -0
- package/dist_ts/classes.watchlock.js +118 -0
- package/dist_ts/claudehttp.d.ts +39 -0
- package/dist_ts/claudehttp.js +83 -0
- package/dist_ts/cliargs.d.ts +36 -0
- package/dist_ts/cliargs.js +60 -0
- package/dist_ts/consoletable.d.ts +21 -0
- package/dist_ts/consoletable.js +63 -0
- package/dist_ts/helpers.d.ts +7 -0
- package/dist_ts/helpers.js +16 -1
- package/dist_ts/index.d.ts +3 -0
- package/dist_ts/index.js +4 -1
- package/dist_ts/interfaces.harness.d.ts +60 -11
- package/dist_ts/interfaces.list.d.ts +3 -1
- package/dist_ts/plugins.d.ts +7 -0
- package/dist_ts/plugins.js +6 -1
- package/dist_ts/ratelimit.d.ts +8 -0
- package/dist_ts/ratelimit.js +13 -0
- package/dist_ts/watchpolicy.d.ts +44 -0
- package/dist_ts/watchpolicy.js +82 -0
- package/package.json +5 -3
- package/readme.md +315 -110
- package/ts/00_commitinfo_data.ts +3 -3
- package/ts/accounts.ts +109 -34
- package/ts/classes.accountlist.ts +2 -219
- package/ts/classes.claudecodeharness.ts +125 -12
- package/ts/classes.claudecodelocks.ts +132 -0
- package/ts/classes.claudestatus.ts +87 -54
- package/ts/classes.claudetokenrefresh.ts +85 -0
- package/ts/classes.cli.ts +112 -53
- package/ts/classes.codexharness.ts +4 -0
- package/ts/classes.codexstatus.ts +17 -7
- package/ts/classes.credentialstore.ts +53 -9
- package/ts/classes.fileharness.ts +67 -29
- package/ts/classes.limits.ts +110 -36
- package/ts/classes.listrenderer.ts +328 -0
- package/ts/classes.login.ts +1 -1
- package/ts/classes.opencodeharness.ts +6 -2
- package/ts/classes.operations.ts +2 -1
- package/ts/classes.tui.ts +3 -2
- package/ts/classes.watch.ts +263 -0
- package/ts/classes.watchlock.ts +100 -0
- package/ts/claudehttp.ts +92 -0
- package/ts/cliargs.ts +71 -0
- package/ts/consoletable.ts +62 -0
- package/ts/helpers.ts +14 -0
- package/ts/index.ts +3 -0
- package/ts/interfaces.harness.ts +60 -5
- package/ts/interfaces.list.ts +3 -1
- package/ts/plugins.ts +9 -0
- package/ts/ratelimit.ts +14 -0
- package/ts/watchpolicy.ts +121 -0
package/ts/classes.cli.ts
CHANGED
|
@@ -5,29 +5,34 @@ import { OpenCodeHarness } from './classes.opencodeharness.js';
|
|
|
5
5
|
import { ClaudeCodeHarness } from './classes.claudecodeharness.js';
|
|
6
6
|
import { AuthSwitchTui } from './classes.tui.js';
|
|
7
7
|
import { AglAuthSwitchCoordinator, AuthSwitchOperations, type TAuthSwitchCoordinator, type TAuthSwitchMutation } from './classes.operations.js';
|
|
8
|
-
import {
|
|
8
|
+
import { readAccountList } from './classes.accountlist.js';
|
|
9
|
+
import { AccountListRenderer } from './classes.listrenderer.js';
|
|
9
10
|
import { accountLimits, activeAccounts, CondensedRenderer } from './classes.limits.js';
|
|
10
|
-
import {
|
|
11
|
+
import { consoleTable } from './consoletable.js';
|
|
12
|
+
import { accountName, credentialDriftNote, duration, orderedUsageWindows, readAccountBadges, until, usagePercentText } from './accounts.js';
|
|
11
13
|
import { describeHarnessProcesses, describeStopOutcome } from './classes.harnessprocesses.js';
|
|
12
14
|
import { defaultPreusePrompt, PreuseError, validatePreuseOptions } from './preuse.js';
|
|
15
|
+
import { parseCommandArgs, parseDurationOption, parseIntegerOption, UsageError } from './cliargs.js';
|
|
16
|
+
import { AuthSwitchWatch, watchEventText } from './classes.watch.js';
|
|
17
|
+
import { WatchBusyError, WatchLock } from './classes.watchlock.js';
|
|
18
|
+
import { authSwitchHome } from './classes.credentialstore.js';
|
|
13
19
|
import type { CodexSwitcher } from './classes.codexswitcher.js';
|
|
14
20
|
import type { IAuthHarness, IHarnessAccount, IHarnessOutcome, IHarnessProcess, IHarnessState, IHarnessStopOutcome, IHarnessPreuseResult } from './interfaces.harness.js';
|
|
15
21
|
import { bold, dim, green, orange, plainText, red } from './formatting.js';
|
|
16
22
|
|
|
17
23
|
const canPrompt = (): boolean =>
|
|
18
24
|
process.stdin.isTTY === true && process.stdout.isTTY === true && !process.env.CI;
|
|
19
|
-
const COMMANDS = ['login', 'stash', 'list', 'ls', 'limits', 'active', 'use', 'preuse', 'current', 'drop', 'rm', 'doctor'];
|
|
25
|
+
const COMMANDS = ['login', 'stash', 'list', 'ls', 'limits', 'active', 'use', 'preuse', 'watch', 'current', 'drop', 'rm', 'doctor'];
|
|
20
26
|
/** What to do about the harness's own running instances before a credential file is rewritten. */
|
|
21
27
|
export type TStopMode = 'stop' | 'force-stop' | 'keep-running';
|
|
22
28
|
const STOP_FLAGS = ['--stop', '--force-stop', '--keep-running'];
|
|
23
29
|
/** Commands that replace or clear a native login, and therefore offer to stop its instances. */
|
|
24
30
|
const STOP_COMMANDS = ['use', 'stash'];
|
|
25
|
-
/**
|
|
31
|
+
/** Overviews across every registered harness; none of them changes which account is in use, and all support --json. */
|
|
26
32
|
const OVERVIEW_COMMANDS = ['list', 'ls', 'limits', 'active'];
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
`${plainText(accountArg.label)}${accountArg.slotId ? ` (${plainText(accountArg.slotId)})` : ''}`;
|
|
33
|
+
const WATCH_USAGE = 'Usage: authswitch [harness] watch [harness] [--interval <duration>] [--threshold <percent>] [--dry-run] [--once] [--json]';
|
|
34
|
+
const WATCH_INTERVAL = { defaultMs: 120_000, minMs: 60_000, maxMs: 86_400_000 };
|
|
35
|
+
const WATCH_THRESHOLD = { default: 95, min: 50, max: 100 };
|
|
31
36
|
|
|
32
37
|
/** The account a command acts on, named as it was offered, with the badge it was offered with. */
|
|
33
38
|
interface IChosenAccount {
|
|
@@ -73,6 +78,9 @@ ${bold('Usage')}
|
|
|
73
78
|
authswitch active one row per provider: which account is in use, and since when
|
|
74
79
|
authswitch <harness> preuse <account> [--prompt <text>] [--model <id>]
|
|
75
80
|
send one prompt through that account without switching
|
|
81
|
+
authswitch watch [harness] [--interval <duration>] [--threshold <percent>] [--dry-run] [--once] [--json]
|
|
82
|
+
check usage every 2m and switch to a better saved account
|
|
83
|
+
when the active one reaches the threshold (95%)
|
|
76
84
|
authswitch <harness> stash [account/provider] [--keep]
|
|
77
85
|
save one active login; --keep leaves it active
|
|
78
86
|
authswitch <harness> use [account] [--stop|--force-stop|--keep-running]
|
|
@@ -92,14 +100,24 @@ ${bold('Options')}
|
|
|
92
100
|
-v, --version show the version
|
|
93
101
|
-i, --interactive open the guided account manager
|
|
94
102
|
--tui open the terminal management dashboard
|
|
95
|
-
--json JSON output for list / ls / limits / active
|
|
103
|
+
--json JSON output for list / ls / limits / active; one JSON event per line for
|
|
104
|
+
watch, written after the command (authswitch watch --json)
|
|
96
105
|
--stop stop this harness's running instances before the switch
|
|
97
106
|
--force-stop the same, then kill instances that ignore SIGTERM
|
|
98
107
|
--keep-running switch without stopping anything
|
|
99
108
|
|
|
100
|
-
A switch works while OpenCode or Claude Code is running.
|
|
101
|
-
|
|
102
|
-
|
|
109
|
+
A switch works while OpenCode or Claude Code is running. Claude Code picks the new login
|
|
110
|
+
up on its next request, so its sessions are stopped only on --stop or --force-stop.
|
|
111
|
+
OpenCode keeps the previous login in memory and can write it back at its next token
|
|
112
|
+
refresh, so an OpenCode switch offers to stop it first; without a terminal it leaves
|
|
113
|
+
it running and says so.
|
|
114
|
+
|
|
115
|
+
Watch covers the harnesses that support automatic switching (${[...this.harnesses.values()].filter(harness => harness.autoSwitch === true).map(harness => harness.id).join(', ') || 'none registered'}).
|
|
116
|
+
--interval takes seconds or a unit (120, 90s, 2m; from 1m to 1d); --threshold takes 50 to 100.
|
|
117
|
+
A switch goes to a saved account at least 10 points below the threshold, or, when every
|
|
118
|
+
account is used up, to the one usable again first. No session of yours is stopped, though
|
|
119
|
+
a Codex switch restarts Codex' own app-server; --dry-run only reports, --once checks once.
|
|
120
|
+
One watch runs per AUTHSWITCH_HOME.
|
|
103
121
|
|
|
104
122
|
Preuse consumes the selected account's quota. Its default prompt is:
|
|
105
123
|
"${defaultPreusePrompt}"
|
|
@@ -121,6 +139,11 @@ ${bold('Environment')}
|
|
|
121
139
|
const qualified = args[0] !== 'preuse';
|
|
122
140
|
return await this.commandPreuse(qualified ? this.harnesses.get(args[0]) : undefined, args.slice(qualified ? 2 : 1));
|
|
123
141
|
}
|
|
142
|
+
// Watch owns its options, --json among them.
|
|
143
|
+
if (args[0] === 'watch' || (this.harnesses.has(args[0]) && args[1] === 'watch')) {
|
|
144
|
+
const qualified = args[0] !== 'watch';
|
|
145
|
+
return await this.commandWatch(qualified ? this.harnesses.get(args[0]) : undefined, args.slice(qualified ? 2 : 1));
|
|
146
|
+
}
|
|
124
147
|
const jsonFlags = args.filter(value => value === '--json');
|
|
125
148
|
const json = jsonFlags.length === 1;
|
|
126
149
|
if (jsonFlags.length > 1) { process.stderr.write('Use --json only once.\n'); return 2; }
|
|
@@ -234,14 +257,17 @@ ${bold('Environment')}
|
|
|
234
257
|
* Decide what happens to the harness's own running instances before its credential file changes.
|
|
235
258
|
*
|
|
236
259
|
* The switch itself no longer depends on this: it writes atomically and re-saves the outgoing
|
|
237
|
-
* login either way.
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
260
|
+
* login either way. A harness that picks up swaps live needs nothing here, so its instances are
|
|
261
|
+
* only stopped on an explicit --stop or --force-stop. Any other running instance still costs
|
|
262
|
+
* durability -- it holds the previous login in memory and can write it back at its next refresh --
|
|
263
|
+
* so the instances are named and stopping them is offered, never assumed. Without a terminal and
|
|
264
|
+
* without a flag nothing is signalled, and only pids enumerated here for this user are ever
|
|
265
|
+
* signalled at all.
|
|
241
266
|
*/
|
|
242
267
|
private async settleRunningInstances(harnessArg: IAuthHarness, modeArg?: TStopMode): Promise<void> {
|
|
243
268
|
const control = harnessArg.processes;
|
|
244
|
-
|
|
269
|
+
const stopRequested = modeArg === 'stop' || modeArg === 'force-stop';
|
|
270
|
+
if (!control || (harnessArg.liveSwap === true && !stopRequested)) return;
|
|
245
271
|
let running: IHarnessProcess[];
|
|
246
272
|
try { running = control.list(); }
|
|
247
273
|
catch { process.stdout.write(`${orange(`Could not check for running ${plainText(harnessArg.label)} processes; continuing with the switch.`)}\n`); return; }
|
|
@@ -250,7 +276,7 @@ ${bold('Environment')}
|
|
|
250
276
|
if (control.stopUnavailableReason !== null) { process.stdout.write(`${dim(control.stopUnavailableReason)}\n`); return; }
|
|
251
277
|
const stoppable = running.filter(item => !item.isAncestor);
|
|
252
278
|
if (!stoppable.length || modeArg === 'keep-running') return;
|
|
253
|
-
let stop =
|
|
279
|
+
let stop = stopRequested;
|
|
254
280
|
if (modeArg === undefined) {
|
|
255
281
|
if (!canPrompt()) { process.stdout.write(`${dim('Leaving them running. Use --stop, --force-stop or --keep-running to decide this without a terminal.')}\n`); return; }
|
|
256
282
|
stop = await this.out.prompts.ask({
|
|
@@ -298,28 +324,18 @@ ${bold('Environment')}
|
|
|
298
324
|
|
|
299
325
|
private async commandPreuse(harnessArg: IAuthHarness | undefined, argsArg: string[]): Promise<number> {
|
|
300
326
|
let reference: string | undefined;
|
|
301
|
-
let prompt
|
|
327
|
+
let prompt: string;
|
|
302
328
|
let model: string | undefined;
|
|
303
|
-
const flags = new Set<string>();
|
|
304
329
|
try {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
if (flags.has(flag)) throw new PreuseError(`Use ${flag} only once.`);
|
|
311
|
-
flags.add(flag);
|
|
312
|
-
const value = separator < 0 ? argsArg[++index] : argument.slice(separator + 1);
|
|
313
|
-
if (value === undefined) throw new PreuseError(`${flag} requires a value.`);
|
|
314
|
-
if (flag === '--prompt') prompt = value; else model = value;
|
|
315
|
-
} else if (argument.startsWith('-') || reference !== undefined) {
|
|
316
|
-
throw new PreuseError('Usage: authswitch [harness] preuse <account> [--prompt <text>] [--model <id>]');
|
|
317
|
-
} else reference = argument;
|
|
318
|
-
}
|
|
330
|
+
const parsed = parseCommandArgs(argsArg, { values: ['--prompt', '--model'], flags: [], maxPositionals: 1,
|
|
331
|
+
usage: 'Usage: authswitch [harness] preuse <account> [--prompt <text>] [--model <id>]' });
|
|
332
|
+
[reference] = parsed.positionals;
|
|
333
|
+
prompt = parsed.values.get('--prompt') ?? defaultPreusePrompt;
|
|
334
|
+
model = parsed.values.get('--model');
|
|
319
335
|
validatePreuseOptions({ prompt, model });
|
|
320
|
-
if (!reference && !canPrompt()) throw new
|
|
336
|
+
if (!reference && !canPrompt()) throw new UsageError('preuse requires an account outside an interactive terminal.');
|
|
321
337
|
} catch (error) {
|
|
322
|
-
process.stderr.write(`${error instanceof PreuseError ? error.message : 'Invalid preuse arguments.'}\n`);
|
|
338
|
+
process.stderr.write(`${error instanceof UsageError || error instanceof PreuseError ? error.message : 'Invalid preuse arguments.'}\n`);
|
|
323
339
|
return 2;
|
|
324
340
|
}
|
|
325
341
|
const harness = harnessArg ?? await this.selectHarness();
|
|
@@ -366,13 +382,11 @@ ${bold('Environment')}
|
|
|
366
382
|
if (windows?.length) {
|
|
367
383
|
const now = Date.now();
|
|
368
384
|
process.stdout.write('Reset schedule reported after the prompt:\n');
|
|
369
|
-
|
|
370
|
-
for (const window of windows) process.stdout.write(`${plainText(window.label)} (${duration(window.durationSeconds)})\n Used: ${window.usedPercent}%\n Reset in: ${until(window.resetAt, now)}\n`);
|
|
371
|
-
} else await this.out.table(windows, { columns: [
|
|
385
|
+
await consoleTable(this.out, windows, [
|
|
372
386
|
{ key: 'window', title: 'Window', value: row => `${plainText(row.label)} (${duration(row.durationSeconds)})` },
|
|
373
|
-
{ key: 'usage', title: 'Used', value: row =>
|
|
387
|
+
{ key: 'usage', title: 'Used', value: row => usagePercentText(row.usedPercent) },
|
|
374
388
|
{ key: 'reset', title: 'Reset in', value: row => until(row.resetAt, now) },
|
|
375
|
-
]
|
|
389
|
+
]);
|
|
376
390
|
} else process.stdout.write('Reset schedule could not be verified; the completed prompt will not be repeated.\n');
|
|
377
391
|
} catch {
|
|
378
392
|
process.stderr.write('Prompt completed, but its reset schedule could not be verified or displayed. The prompt will not be repeated.\n');
|
|
@@ -384,6 +398,57 @@ ${bold('Environment')}
|
|
|
384
398
|
}
|
|
385
399
|
}
|
|
386
400
|
|
|
401
|
+
/**
|
|
402
|
+
* Watch usage and switch automatically; see `AuthSwitchWatch`. SIGINT and SIGTERM end it cleanly with status 0.
|
|
403
|
+
* `--once` runs one check and exits 1 when a switch it attempted did not complete.
|
|
404
|
+
*/
|
|
405
|
+
private async commandWatch(harnessArg: IAuthHarness | undefined, argsArg: string[]): Promise<number> {
|
|
406
|
+
let options: { harnesses: IAuthHarness[]; intervalMs: number; threshold: number; dryRun: boolean; once: boolean; json: boolean };
|
|
407
|
+
try {
|
|
408
|
+
const parsed = parseCommandArgs(argsArg, { values: ['--interval', '--threshold'], flags: ['--dry-run', '--once', '--json', '--help', '-h'],
|
|
409
|
+
maxPositionals: harnessArg ? 0 : 1, usage: WATCH_USAGE });
|
|
410
|
+
if (parsed.flags.has('--help') || parsed.flags.has('-h')) { process.stdout.write(`${this.usage()}\n`); return 0; }
|
|
411
|
+
const named = parsed.positionals[0];
|
|
412
|
+
const harness = harnessArg ?? (named === undefined ? undefined : this.harnesses.get(named));
|
|
413
|
+
if (named !== undefined && !harness) throw new UsageError(`Unknown harness "${plainText(named)}". ${WATCH_USAGE}`);
|
|
414
|
+
if (harness && harness.autoSwitch !== true) throw new UsageError(`${plainText(harness.label)} does not support automatic switching.`);
|
|
415
|
+
const harnesses = (harness ? [harness] : [...this.harnesses.values()].filter(item => item.autoSwitch === true))
|
|
416
|
+
.sort((left, right) => left.label.localeCompare(right.label) || left.id.localeCompare(right.id));
|
|
417
|
+
if (!harnesses.length) throw new UsageError('No registered harness supports automatic switching.');
|
|
418
|
+
const interval = parsed.values.get('--interval');
|
|
419
|
+
const threshold = parsed.values.get('--threshold');
|
|
420
|
+
options = {
|
|
421
|
+
harnesses, dryRun: parsed.flags.has('--dry-run'), once: parsed.flags.has('--once'), json: parsed.flags.has('--json'),
|
|
422
|
+
intervalMs: interval === undefined ? WATCH_INTERVAL.defaultMs : parseDurationOption('--interval', interval, WATCH_INTERVAL),
|
|
423
|
+
threshold: threshold === undefined ? WATCH_THRESHOLD.default : parseIntegerOption('--threshold', threshold, WATCH_THRESHOLD),
|
|
424
|
+
};
|
|
425
|
+
} catch (error) {
|
|
426
|
+
if (!(error instanceof UsageError)) throw error;
|
|
427
|
+
process.stderr.write(`${error.message}\n`);
|
|
428
|
+
return 2;
|
|
429
|
+
}
|
|
430
|
+
const watch = new AuthSwitchWatch({
|
|
431
|
+
harnesses: options.harnesses, operations: this.operations, lock: new WatchLock(authSwitchHome()),
|
|
432
|
+
intervalMs: options.intervalMs, threshold: options.threshold, dryRun: options.dryRun,
|
|
433
|
+
onEvent: event => { process.stdout.write(`${options.json ? JSON.stringify(event) : watchEventText(event)}\n`); },
|
|
434
|
+
});
|
|
435
|
+
const controller = new AbortController();
|
|
436
|
+
const stop = () => controller.abort();
|
|
437
|
+
process.once('SIGINT', stop);
|
|
438
|
+
process.once('SIGTERM', stop);
|
|
439
|
+
try {
|
|
440
|
+
const completed = await watch.run(controller.signal, { once: options.once });
|
|
441
|
+
return options.once && !completed ? 1 : 0;
|
|
442
|
+
} catch (error) {
|
|
443
|
+
if (!(error instanceof WatchBusyError)) throw error;
|
|
444
|
+
process.stderr.write(`${error.message}\n`);
|
|
445
|
+
return 1;
|
|
446
|
+
} finally {
|
|
447
|
+
process.removeListener('SIGINT', stop);
|
|
448
|
+
process.removeListener('SIGTERM', stop);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
387
452
|
private async selectHarness(): Promise<IAuthHarness | undefined> {
|
|
388
453
|
if (this.harnesses.size === 1) return this.harnesses.values().next().value;
|
|
389
454
|
if (this.harnesses.size === 0 || !canPrompt()) {
|
|
@@ -494,7 +559,7 @@ ${bold('Environment')}
|
|
|
494
559
|
process.stdout.write(`${dim('no account is currently active')}\n`);
|
|
495
560
|
}
|
|
496
561
|
if (stateArg.saveUnavailableReason) process.stdout.write(`${dim(stateArg.saveUnavailableReason)}\n`);
|
|
497
|
-
for (const drift of stateArg.credentialDrift ?? []) process.stdout.write(`${orange(credentialDriftNote(harnessArg
|
|
562
|
+
for (const drift of stateArg.credentialDrift ?? []) process.stdout.write(`${orange(credentialDriftNote(harnessArg, drift))}\n`);
|
|
498
563
|
}
|
|
499
564
|
|
|
500
565
|
private async selectActive(harness: IAuthHarness, reference?: string): Promise<IHarnessAccount | undefined> {
|
|
@@ -515,12 +580,13 @@ ${bold('Environment')}
|
|
|
515
580
|
}
|
|
516
581
|
|
|
517
582
|
/**
|
|
518
|
-
* The condensed views are projections of the same
|
|
519
|
-
* once, per account, with the same bounded timeouts and per-account failure
|
|
583
|
+
* The condensed views are projections of the same account list, which never changes which account is in
|
|
584
|
+
* use, so a provider is queried once, per account, with the same bounded timeouts and per-account failure
|
|
585
|
+
* isolation as `list`.
|
|
520
586
|
*/
|
|
521
587
|
private async commandCondensed(commandArg: 'limits' | 'active', harnessesArg: IAuthHarness[], jsonArg: boolean): Promise<number> {
|
|
522
588
|
const list = await readAccountList(harnessesArg);
|
|
523
|
-
const document = commandArg === 'limits' ? accountLimits(list) : activeAccounts(list);
|
|
589
|
+
const document = commandArg === 'limits' ? accountLimits(list, harnessesArg) : activeAccounts(list, harnessesArg);
|
|
524
590
|
if (jsonArg) process.stdout.write(`${JSON.stringify(document, null, 2)}\n`);
|
|
525
591
|
else if ('limits' in document) await new CondensedRenderer(this.out).renderLimits(document);
|
|
526
592
|
else await new CondensedRenderer(this.out).renderActive(document);
|
|
@@ -530,17 +596,10 @@ ${bold('Environment')}
|
|
|
530
596
|
private async commandList(harnessesArg: IAuthHarness[], jsonArg = false): Promise<number> {
|
|
531
597
|
const list = await readAccountList(harnessesArg);
|
|
532
598
|
if (jsonArg) process.stdout.write(`${JSON.stringify(list, null, 2)}\n`);
|
|
533
|
-
else await new AccountListRenderer(this.out).render(list);
|
|
599
|
+
else await new AccountListRenderer(this.out).render(list, harnessesArg);
|
|
534
600
|
return list.complete ? 0 : 1;
|
|
535
601
|
}
|
|
536
602
|
|
|
537
|
-
private printAccount(accountArg: IHarnessAccount): void {
|
|
538
|
-
const saved = accountArg.isStashed ? 'saved' : 'not saved';
|
|
539
|
-
process.stdout.write(`${accountArg.isActive ? green('*') : ' '} ${bold(plainText(accountArg.label))} ${dim(`(${saved})`)}\n`);
|
|
540
|
-
if (accountArg.savedAt) process.stdout.write(` ${dim('Saved: ' + plainText(accountArg.savedAt))}\n`);
|
|
541
|
-
for (const detail of accountArg.details) process.stdout.write(` ${detail}\n`);
|
|
542
|
-
}
|
|
543
|
-
|
|
544
603
|
private async resolveAccount(harnessArg: IAuthHarness, referenceArg: string): Promise<string | null> {
|
|
545
604
|
const resolved = await harnessArg.resolveAccount(referenceArg);
|
|
546
605
|
if ('id' in resolved) return resolved.id;
|
|
@@ -16,6 +16,10 @@ export class CodexHarness implements IAuthHarness {
|
|
|
16
16
|
public readonly loginHint = 'Use authswitch codex login to sign in and save another account.';
|
|
17
17
|
public readonly loginProviders: IHarnessLoginProvider[] = [{ providerId: 'openai', label: 'OpenAI', flows: ['device'] }];
|
|
18
18
|
public readonly diagnosticsLabel = 'Check credential storage and remote control';
|
|
19
|
+
/** Running Codex sessions keep the login they loaded; the switch itself restarts the managed app-server. */
|
|
20
|
+
public readonly liveSwap = false;
|
|
21
|
+
/** Every saved ChatGPT login reports its usage, and a switch restarts only the managed app-server. */
|
|
22
|
+
public readonly autoSwitch = true;
|
|
19
23
|
|
|
20
24
|
constructor(
|
|
21
25
|
private readonly switcher = new CodexSwitcher(),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ICodexIdentity } from './interfaces.js';
|
|
2
2
|
import type { IHarnessAccountStatus, IHarnessStatusOptions, IHarnessStatusSummary } from './interfaces.harness.js';
|
|
3
3
|
import { commitinfo } from './00_commitinfo_data.js';
|
|
4
|
+
import { latestRetryAt, retryAtFrom } from './ratelimit.js';
|
|
4
5
|
|
|
5
6
|
// Verified Chromium-compatible billing request profile, independent of any locally installed browser.
|
|
6
7
|
const billingUserAgent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36';
|
|
@@ -31,13 +32,18 @@ const count = (valueArg: unknown): number => {
|
|
|
31
32
|
/** Only fixed, local diagnostics may cross the HTTP error boundary. */
|
|
32
33
|
class StatusRequestError extends Error {}
|
|
33
34
|
|
|
35
|
+
/** The service refused a section for too many requests; that says nothing about the account's usage. */
|
|
36
|
+
class StatusRateLimitError extends StatusRequestError {
|
|
37
|
+
constructor(public readonly retryAt: string | null) { super('The service is rate limiting requests (HTTP 429); try again later.'); }
|
|
38
|
+
}
|
|
39
|
+
|
|
34
40
|
/**
|
|
35
41
|
* Read-only ChatGPT status contract verified against Codex 0.154.0.
|
|
36
42
|
* These source-defined backend routes are isolated here. No token refresh,
|
|
37
43
|
* credential writes, app-server lifecycle changes or reset consumption occur.
|
|
38
44
|
*/
|
|
39
45
|
export class CodexAccountStatus {
|
|
40
|
-
constructor(private readonly fetcher: typeof fetch = globalThis.fetch) {}
|
|
46
|
+
constructor(private readonly fetcher: typeof fetch = globalThis.fetch, private readonly now: () => number = Date.now) {}
|
|
41
47
|
|
|
42
48
|
public async read(rawArg: string, identityArg: ICodexIdentity, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
|
|
43
49
|
if (identityArg.kind !== 'chatgpt') return {
|
|
@@ -85,6 +91,8 @@ export class CodexAccountStatus {
|
|
|
85
91
|
}
|
|
86
92
|
catch { result.problems.push(`${labels[index]}: the service returned an unsupported response; no values were inferred.`); }
|
|
87
93
|
}
|
|
94
|
+
const limited = sections.flatMap(section => section.status === 'rejected' && section.reason instanceof StatusRateLimitError ? [section.reason] : []);
|
|
95
|
+
if (limited.length) result.rateLimit = { retryAt: latestRetryAt(limited.map(error => error.retryAt)) };
|
|
88
96
|
if (!result.summary?.subscription && identityArg.planType) {
|
|
89
97
|
result.facts.unshift({ section: 'Subscription', summaryKey: 'subscription', label: 'Last known plan', value: `${identityArg.planType} (from stored login; not verified live)` });
|
|
90
98
|
result.summary = { ...result.summary, subscription: { plan: identityArg.planType, source: 'stored' } };
|
|
@@ -105,6 +113,7 @@ export class CodexAccountStatus {
|
|
|
105
113
|
if (!response.ok) {
|
|
106
114
|
await response.body?.cancel();
|
|
107
115
|
if (response.headers.get('cf-mitigated') === 'challenge') throw new StatusRequestError('Blocked by a service verification challenge (Cloudflare); this lookup is unavailable to the current HTTP client.');
|
|
116
|
+
if (response.status === 429) throw new StatusRateLimitError(retryAtFrom(response.headers.get('retry-after'), this.now()));
|
|
108
117
|
if (response.status === 401) throw new StatusRequestError('Login expired or was rejected. Log in again with the account’s harness, then save the account.');
|
|
109
118
|
if (response.status === 403) throw new StatusRequestError('Access denied by the service (HTTP 403).');
|
|
110
119
|
throw new StatusRequestError(`Service returned HTTP ${response.status}.`);
|
|
@@ -157,9 +166,9 @@ export class CodexAccountStatus {
|
|
|
157
166
|
const append = (label: string, value: string) => facts.push({ section: 'Billing', summaryKey: 'billing', label, value });
|
|
158
167
|
if (billing.hasActiveSubscription !== undefined) append('Active subscription', billing.hasActiveSubscription ? 'yes' : 'no');
|
|
159
168
|
if (billing.autoRenew !== undefined) append('Automatic renewal', billing.autoRenew ? 'on' : 'off');
|
|
160
|
-
if (billing.renewsAt) append('Renewal date
|
|
161
|
-
if (billing.cancelsAt) append('Cancellation date
|
|
162
|
-
if (billing.expiresAt) append('Subscription expiry
|
|
169
|
+
if (billing.renewsAt) append('Renewal date', billing.renewsAt);
|
|
170
|
+
if (billing.cancelsAt) append('Cancellation date', billing.cancelsAt);
|
|
171
|
+
if (billing.expiresAt) append('Subscription expiry', billing.expiresAt);
|
|
163
172
|
return { facts, summary: { billing } };
|
|
164
173
|
}
|
|
165
174
|
|
|
@@ -172,8 +181,9 @@ export class CodexAccountStatus {
|
|
|
172
181
|
const appendLimit = (nameArg: string, valueArg: unknown, scopeArg: 'account' | 'feature') => {
|
|
173
182
|
if (valueArg == null) return;
|
|
174
183
|
const limit = record(valueArg);
|
|
175
|
-
|
|
176
|
-
facts.push({ section: 'Limits & credits', label: `${nameArg}
|
|
184
|
+
// The service's own reading of the same windows, so a view that shows the windows need not repeat it.
|
|
185
|
+
facts.push({ section: 'Limits & credits', summaryKey: 'usageWindows', label: `${nameArg} usage allowed`, value: boolean(limit.allowed) ? 'yes' : 'no' });
|
|
186
|
+
facts.push({ section: 'Limits & credits', summaryKey: 'usageWindows', label: `${nameArg} limit reached`, value: boolean(limit.limit_reached) ? 'yes' : 'no' });
|
|
177
187
|
for (const [key, name] of [['primary_window', 'primary'], ['secondary_window', 'secondary']]) {
|
|
178
188
|
if (limit[key] == null) continue;
|
|
179
189
|
const window = record(limit[key]);
|
|
@@ -251,7 +261,7 @@ export class CodexAccountStatus {
|
|
|
251
261
|
return { date, tokens: numeric(day.tokens) };
|
|
252
262
|
}).sort((leftArg, rightArg) => rightArg.date.localeCompare(leftArg.date));
|
|
253
263
|
for (const day of days.slice(0, 7)) facts.push({ section: 'Daily tokens', label: `Tokens ${day.date}`, value: day.tokens.toLocaleString('en-US') });
|
|
254
|
-
|
|
264
|
+
facts.push({ section: 'Daily tokens', label: 'Reported days', value: String(days.length) });
|
|
255
265
|
}
|
|
256
266
|
return facts;
|
|
257
267
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
|
-
import { writeSecretFileAtomically } from './helpers.js';
|
|
2
|
+
import { errorCode, pause, writeSecretFileAtomically } from './helpers.js';
|
|
3
3
|
|
|
4
4
|
export const credentialRecord = (value: unknown): Record<string, unknown> => {
|
|
5
5
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Unsupported credential document.');
|
|
@@ -64,11 +64,29 @@ export interface IStoredCredential {
|
|
|
64
64
|
credential: Record<string, unknown>;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/** The root of authswitch's own state: `AUTHSWITCH_HOME`, or `~/.authswitch`. */
|
|
68
|
+
export const authSwitchHome = (envArg: NodeJS.ProcessEnv = process.env): string =>
|
|
69
|
+
envArg.AUTHSWITCH_HOME || plugins.path.join(plugins.os.homedir(), '.authswitch');
|
|
70
|
+
|
|
71
|
+
export interface ICredentialStoreOptions {
|
|
72
|
+
/**
|
|
73
|
+
* How long an operation waits for another authswitch operation on the same store, 45 seconds by default: longer
|
|
74
|
+
* than the longest operation that holds the lock -- a Claude Code login refresh (30 s) plus the 15 s a native
|
|
75
|
+
* write waits for Claude Code's own locks -- so such operations queue instead of being told the store is locked.
|
|
76
|
+
*/
|
|
77
|
+
lockTimeoutMs?: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const DEFAULT_LOCK_TIMEOUT_MS = 45_000;
|
|
81
|
+
const LOCK_RETRY_MS = 50;
|
|
82
|
+
|
|
67
83
|
/** A single atomic, owner-only record keeps the saved credential and its identity together. */
|
|
68
84
|
export class CredentialStore {
|
|
69
85
|
public readonly dir: string;
|
|
70
|
-
|
|
86
|
+
private readonly lockTimeoutMs: number;
|
|
87
|
+
constructor(harness: string, root = authSwitchHome(), optionsArg: ICredentialStoreOptions = {}) {
|
|
71
88
|
this.dir = plugins.path.join(root, harness);
|
|
89
|
+
this.lockTimeoutMs = optionsArg.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
|
|
72
90
|
}
|
|
73
91
|
public id(slot: string, identity: string): string { return credentialHash(JSON.stringify([slot, identity])); }
|
|
74
92
|
private file(id: string): string {
|
|
@@ -89,12 +107,22 @@ export class CredentialStore {
|
|
|
89
107
|
.map(name => this.read(name.slice(0, -5))).sort((a, b) => b.savedAt.localeCompare(a.savedAt));
|
|
90
108
|
}
|
|
91
109
|
public save(entry: Omit<IStoredCredential, 'schemaVersion' | 'id' | 'savedAt'>): IStoredCredential {
|
|
92
|
-
|
|
110
|
+
return this.write({ ...entry, schemaVersion: 1, id: this.id(entry.slotId, entry.identity), savedAt: new Date().toISOString() },
|
|
111
|
+
'Saved credential verification failed; the active login was preserved.');
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Replaces a saved login's credential in place, such as with refreshed tokens: the account, its identity and when
|
|
115
|
+
* it was saved stay as they are. The caller holds the lock and has checked the record it replaces.
|
|
116
|
+
*/
|
|
117
|
+
public replaceCredential(id: string, credential: Record<string, unknown>): IStoredCredential {
|
|
118
|
+
return this.write({ ...this.read(id), credential }, 'The refreshed login could not be verified after saving it.');
|
|
119
|
+
}
|
|
120
|
+
private write(record: IStoredCredential, failureArg: string): IStoredCredential {
|
|
93
121
|
plugins.fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
94
122
|
plugins.fs.chmodSync(this.dir, 0o700);
|
|
95
123
|
const raw = JSON.stringify(record, null, 2) + '\n';
|
|
96
124
|
writeSecretFileAtomically(this.file(record.id), raw);
|
|
97
|
-
if (readCredentialDocument(this.file(record.id)).raw !== raw) throw new Error(
|
|
125
|
+
if (readCredentialDocument(this.file(record.id)).raw !== raw) throw new Error(failureArg);
|
|
98
126
|
return this.read(record.id);
|
|
99
127
|
}
|
|
100
128
|
public remove(id: string): void { this.read(id); plugins.fs.unlinkSync(this.file(id)); }
|
|
@@ -124,13 +152,29 @@ export class CredentialStore {
|
|
|
124
152
|
plugins.fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
125
153
|
writeSecretFileAtomically(this.switchFile, JSON.stringify({ schemaVersion: 1, switches: records }, null, 2) + '\n');
|
|
126
154
|
}
|
|
127
|
-
|
|
155
|
+
/**
|
|
156
|
+
* Runs `action` as the only authswitch operation on this store; the lock is held until an asynchronous action settles.
|
|
157
|
+
*
|
|
158
|
+
* An operation that finds the store locked waits for it, up to the store's lock timeout, so concurrent operations
|
|
159
|
+
* (a manual switch and a watch refreshing a saved login) run one after the other instead of failing. The signal
|
|
160
|
+
* ends the wait early; an action that has started is never interrupted by it.
|
|
161
|
+
*/
|
|
162
|
+
public async locked<T>(action: () => T | Promise<T>, optionsArg: { signal?: AbortSignal } = {}): Promise<T> {
|
|
128
163
|
plugins.fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
129
164
|
const file = plugins.path.join(this.dir, '.lock');
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
165
|
+
const deadline = Date.now() + this.lockTimeoutMs;
|
|
166
|
+
let fd: number | undefined;
|
|
167
|
+
while (fd === undefined) {
|
|
168
|
+
optionsArg.signal?.throwIfAborted();
|
|
169
|
+
try { fd = plugins.fs.openSync(file, 'wx', 0o600); }
|
|
170
|
+
catch (error) {
|
|
171
|
+
if (errorCode(error) !== 'EEXIST' || Date.now() >= deadline) {
|
|
172
|
+
throw new Error(`Account storage is locked. Close other authswitch operations; if none remain, remove ${file}.`);
|
|
173
|
+
}
|
|
174
|
+
await pause(Math.min(LOCK_RETRY_MS, deadline - Date.now()), optionsArg.signal);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
try { return await action(); }
|
|
134
178
|
finally { plugins.fs.closeSync(fd); plugins.fs.unlinkSync(file); }
|
|
135
179
|
}
|
|
136
180
|
}
|