@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.
Files changed (87) hide show
  1. package/dist_ts/00_commitinfo_data.js +3 -3
  2. package/dist_ts/accounts.d.ts +42 -15
  3. package/dist_ts/accounts.js +98 -27
  4. package/dist_ts/classes.accountlist.d.ts +0 -25
  5. package/dist_ts/classes.accountlist.js +2 -216
  6. package/dist_ts/classes.claudecodeharness.d.ts +59 -1
  7. package/dist_ts/classes.claudecodeharness.js +130 -14
  8. package/dist_ts/classes.claudecodelocks.d.ts +35 -0
  9. package/dist_ts/classes.claudecodelocks.js +117 -0
  10. package/dist_ts/classes.claudestatus.d.ts +15 -2
  11. package/dist_ts/classes.claudestatus.js +88 -73
  12. package/dist_ts/classes.claudetokenrefresh.d.ts +32 -0
  13. package/dist_ts/classes.claudetokenrefresh.js +77 -0
  14. package/dist_ts/classes.cli.d.ts +14 -7
  15. package/dist_ts/classes.cli.js +125 -66
  16. package/dist_ts/classes.codexharness.d.ts +4 -0
  17. package/dist_ts/classes.codexharness.js +5 -1
  18. package/dist_ts/classes.codexstatus.d.ts +2 -1
  19. package/dist_ts/classes.codexstatus.js +25 -9
  20. package/dist_ts/classes.credentialstore.d.ts +28 -2
  21. package/dist_ts/classes.credentialstore.js +41 -12
  22. package/dist_ts/classes.fileharness.d.ts +37 -17
  23. package/dist_ts/classes.fileharness.js +58 -24
  24. package/dist_ts/classes.limits.d.ts +46 -7
  25. package/dist_ts/classes.limits.js +94 -36
  26. package/dist_ts/classes.listrenderer.d.ts +17 -0
  27. package/dist_ts/classes.listrenderer.js +313 -0
  28. package/dist_ts/classes.login.d.ts +1 -1
  29. package/dist_ts/classes.login.js +1 -1
  30. package/dist_ts/classes.opencodeharness.d.ts +4 -0
  31. package/dist_ts/classes.opencodeharness.js +7 -3
  32. package/dist_ts/classes.operations.js +3 -2
  33. package/dist_ts/classes.tui.js +4 -3
  34. package/dist_ts/classes.watch.d.ts +108 -0
  35. package/dist_ts/classes.watch.js +219 -0
  36. package/dist_ts/classes.watchlock.d.ts +33 -0
  37. package/dist_ts/classes.watchlock.js +118 -0
  38. package/dist_ts/claudehttp.d.ts +39 -0
  39. package/dist_ts/claudehttp.js +83 -0
  40. package/dist_ts/cliargs.d.ts +36 -0
  41. package/dist_ts/cliargs.js +60 -0
  42. package/dist_ts/consoletable.d.ts +21 -0
  43. package/dist_ts/consoletable.js +63 -0
  44. package/dist_ts/helpers.d.ts +7 -0
  45. package/dist_ts/helpers.js +16 -1
  46. package/dist_ts/index.d.ts +3 -0
  47. package/dist_ts/index.js +4 -1
  48. package/dist_ts/interfaces.harness.d.ts +60 -11
  49. package/dist_ts/interfaces.list.d.ts +3 -1
  50. package/dist_ts/plugins.d.ts +7 -0
  51. package/dist_ts/plugins.js +6 -1
  52. package/dist_ts/ratelimit.d.ts +8 -0
  53. package/dist_ts/ratelimit.js +13 -0
  54. package/dist_ts/watchpolicy.d.ts +44 -0
  55. package/dist_ts/watchpolicy.js +82 -0
  56. package/package.json +5 -3
  57. package/readme.md +315 -110
  58. package/ts/00_commitinfo_data.ts +3 -3
  59. package/ts/accounts.ts +109 -34
  60. package/ts/classes.accountlist.ts +2 -219
  61. package/ts/classes.claudecodeharness.ts +125 -12
  62. package/ts/classes.claudecodelocks.ts +132 -0
  63. package/ts/classes.claudestatus.ts +87 -54
  64. package/ts/classes.claudetokenrefresh.ts +85 -0
  65. package/ts/classes.cli.ts +112 -53
  66. package/ts/classes.codexharness.ts +4 -0
  67. package/ts/classes.codexstatus.ts +17 -7
  68. package/ts/classes.credentialstore.ts +53 -9
  69. package/ts/classes.fileharness.ts +67 -29
  70. package/ts/classes.limits.ts +110 -36
  71. package/ts/classes.listrenderer.ts +328 -0
  72. package/ts/classes.login.ts +1 -1
  73. package/ts/classes.opencodeharness.ts +6 -2
  74. package/ts/classes.operations.ts +2 -1
  75. package/ts/classes.tui.ts +3 -2
  76. package/ts/classes.watch.ts +263 -0
  77. package/ts/classes.watchlock.ts +100 -0
  78. package/ts/claudehttp.ts +92 -0
  79. package/ts/cliargs.ts +71 -0
  80. package/ts/consoletable.ts +62 -0
  81. package/ts/helpers.ts +14 -0
  82. package/ts/index.ts +3 -0
  83. package/ts/interfaces.harness.ts +60 -5
  84. package/ts/interfaces.list.ts +3 -1
  85. package/ts/plugins.ts +9 -0
  86. package/ts/ratelimit.ts +14 -0
  87. package/ts/watchpolicy.ts +121 -0
@@ -0,0 +1,100 @@
1
+ import * as plugins from './plugins.js';
2
+ import { errorCode } from './helpers.js';
3
+
4
+ /** Another watch holds the lock. */
5
+ export class WatchBusyError extends Error {
6
+ constructor(public readonly pid: number, fileArg: string) {
7
+ super(`Another authswitch watch is running (pid ${pid}). If it is not, remove ${fileArg}.`);
8
+ }
9
+ }
10
+
11
+ export interface IWatchLockOptions {
12
+ /** The pid the lock names; this process by default. */
13
+ pid?: number;
14
+ /** Whether a process with this pid exists; a pid owned by another user counts as existing. */
15
+ isAlive?: (pidArg: number) => boolean;
16
+ }
17
+
18
+ const processExists = (pidArg: number): boolean => {
19
+ try { process.kill(pidArg, 0); return true; }
20
+ catch (error) { return errorCode(error) === 'EPERM'; }
21
+ };
22
+
23
+ /**
24
+ * The lock files this process holds.
25
+ *
26
+ * A lock naming this process's own pid is otherwise taken over, because a process that died can have its pid reused
27
+ * by the one that replaces it. While a watch in this process still holds the lock, that reasoning does not apply,
28
+ * and a second watch must be refused like any other.
29
+ */
30
+ const heldFiles = new Set<string>();
31
+
32
+ /**
33
+ * The single watch of one authswitch home: `<home>/watch.lock`, naming the pid that holds it.
34
+ *
35
+ * The lock file is created by hard-linking a complete file into place, so it never exists half-written and a second
36
+ * watch always reads a pid. A lock whose process no longer exists is left over from a watch that died and is taken
37
+ * over; the stale file is removed only while it still holds exactly what was judged stale. A lock another watch in
38
+ * this same process holds is never taken over.
39
+ */
40
+ export class WatchLock {
41
+ public readonly file: string;
42
+ private readonly pid: number;
43
+ private readonly isAlive: (pidArg: number) => boolean;
44
+ private content: string | null = null;
45
+
46
+ constructor(private readonly home: string, optionsArg: IWatchLockOptions = {}) {
47
+ this.file = plugins.path.join(home, 'watch.lock');
48
+ this.pid = optionsArg.pid ?? process.pid;
49
+ this.isAlive = optionsArg.isAlive ?? processExists;
50
+ }
51
+
52
+ private read(): string | null {
53
+ try { return plugins.fs.readFileSync(this.file, 'utf8'); }
54
+ catch (error) { if (errorCode(error) === 'ENOENT') return null; throw error; }
55
+ }
56
+
57
+ private static holder(contentArg: string): number | null {
58
+ try {
59
+ const pid: unknown = JSON.parse(contentArg).pid;
60
+ return typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0 ? pid : null;
61
+ } catch { return null; }
62
+ }
63
+
64
+ /** Takes the lock, or throws `WatchBusyError` naming the running watch. */
65
+ public acquire(): void {
66
+ if (this.content !== null) throw new Error('This watch already holds its lock.');
67
+ if (heldFiles.has(this.file)) throw new WatchBusyError(this.pid, this.file);
68
+ plugins.fs.mkdirSync(this.home, { recursive: true, mode: 0o700 });
69
+ const content = `${JSON.stringify({ pid: this.pid, startedAt: new Date().toISOString() })}\n`;
70
+ const candidate = `${this.file}.${this.pid}`;
71
+ plugins.fs.writeFileSync(candidate, content, { mode: 0o600 });
72
+ try {
73
+ // One attempt, and one more after removing a stale lock; a watch that took it over in between wins.
74
+ for (let attempt = 0; attempt < 2; attempt++) {
75
+ try {
76
+ plugins.fs.linkSync(candidate, this.file);
77
+ this.content = content;
78
+ heldFiles.add(this.file);
79
+ return;
80
+ } catch (error) { if (errorCode(error) !== 'EEXIST') throw error; }
81
+ const existing = this.read();
82
+ if (existing === null) continue;
83
+ const holder = WatchLock.holder(existing);
84
+ if (holder !== null && holder !== this.pid && this.isAlive(holder)) throw new WatchBusyError(holder, this.file);
85
+ if (this.read() === existing) plugins.fs.rmSync(this.file, { force: true });
86
+ }
87
+ const holder = WatchLock.holder(this.read() ?? '');
88
+ if (holder !== null) throw new WatchBusyError(holder, this.file);
89
+ throw new Error(`The watch lock ${this.file} could not be taken. Try again.`);
90
+ } finally { plugins.fs.rmSync(candidate, { force: true }); }
91
+ }
92
+
93
+ /** Releases the lock if this watch still holds it; a lock another watch took over is left alone. */
94
+ public release(): void {
95
+ if (this.content === null) return;
96
+ heldFiles.delete(this.file);
97
+ if (this.read() === this.content) plugins.fs.rmSync(this.file, { force: true });
98
+ this.content = null;
99
+ }
100
+ }
@@ -0,0 +1,92 @@
1
+ import { commitinfo } from './00_commitinfo_data.js';
2
+ import { credentialRecord } from './classes.credentialstore.js';
3
+ import { retryAtFrom } from './ratelimit.js';
4
+
5
+ /** A fixed, local diagnostic. Nothing a service sent (a body, a header) and no request header ever becomes one. */
6
+ export class ClaudeRequestError extends Error {}
7
+
8
+ /** How every Claude lookup reports a login the service does not accept (HTTP 401, or a refresh grant it rejected). */
9
+ export const CLAUDE_LOGIN_REJECTED = 'Login expired or was rejected. Log in again with Claude Code and save it.';
10
+
11
+ /** The service did not accept the login. */
12
+ export class ClaudeLoginRejectedError extends ClaudeRequestError {
13
+ constructor() { super(CLAUDE_LOGIN_REJECTED); }
14
+ }
15
+
16
+ /** The service refused a request for too many requests. That says nothing about the account's usage or login. */
17
+ export class ClaudeRateLimitError extends ClaudeRequestError {
18
+ constructor(subjectArg: string, public readonly retryAt: string | null) {
19
+ super(`The ${subjectArg} service is rate limiting requests (HTTP 429); try again later.`);
20
+ }
21
+ }
22
+
23
+ export interface IClaudeRequest {
24
+ url: string;
25
+ method: 'GET' | 'POST';
26
+ headers: Record<string, string>;
27
+ body?: string;
28
+ /** What diagnostics call the service: `Claude account` reads `The Claude account service could not be reached.` */
29
+ subject: string;
30
+ timeoutMs: number;
31
+ signal?: AbortSignal;
32
+ /** Whether an error status's JSON body is read for the caller, such as an OAuth error code; otherwise it is discarded. */
33
+ readErrorBody?: boolean;
34
+ /** The clock a `Retry-After` delay is counted from. */
35
+ now: () => number;
36
+ }
37
+
38
+ export interface IClaudeResponse {
39
+ status: number;
40
+ /** The JSON object the service answered with; null for an empty body, a body that is not a JSON object, or an unread one. */
41
+ body: Record<string, unknown> | null;
42
+ }
43
+
44
+ const MAX_RESPONSE_BYTES = 1024 * 1024;
45
+
46
+ /** The body as a JSON object, read to at most 1 MiB; null when it is empty or not a JSON object. */
47
+ const jsonBody = async (responseArg: Response, subjectArg: string): Promise<Record<string, unknown> | null> => {
48
+ const reader = responseArg.body?.getReader();
49
+ if (!reader) return null;
50
+ const chunks: Uint8Array[] = [];
51
+ let size = 0;
52
+ try {
53
+ while (true) {
54
+ const part = await reader.read();
55
+ if (part.done) break;
56
+ size += part.value.byteLength;
57
+ if (size > MAX_RESPONSE_BYTES) { await reader.cancel(); throw new ClaudeRequestError(`The ${subjectArg} response is too large.`); }
58
+ chunks.push(part.value);
59
+ }
60
+ } finally { reader.releaseLock(); }
61
+ try { return credentialRecord(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
62
+ catch { return null; }
63
+ };
64
+
65
+ /**
66
+ * One request to a Claude account service, shaped like Claude Code's own but naming authswitch in its User-Agent:
67
+ * redirects refused, bounded in time and in size, and cancelled with the caller's signal. A rate limit is reported as
68
+ * `ClaudeRateLimitError`; every other status is the caller's to interpret.
69
+ */
70
+ export const claudeRequest = async (fetcherArg: typeof fetch, requestArg: IClaudeRequest): Promise<IClaudeResponse> => {
71
+ const timeout = AbortSignal.timeout(requestArg.timeoutMs);
72
+ const signal = requestArg.signal ? AbortSignal.any([requestArg.signal, timeout]) : timeout;
73
+ try {
74
+ const response = await fetcherArg(requestArg.url, {
75
+ method: requestArg.method, redirect: 'error', signal, body: requestArg.body,
76
+ headers: { ...requestArg.headers, 'User-Agent': `authswitch/${commitinfo.version}` },
77
+ });
78
+ if (response.status === 429) {
79
+ await response.body?.cancel();
80
+ throw new ClaudeRateLimitError(requestArg.subject, retryAtFrom(response.headers.get('retry-after'), requestArg.now()));
81
+ }
82
+ if (!response.ok && !requestArg.readErrorBody) {
83
+ await response.body?.cancel();
84
+ return { status: response.status, body: null };
85
+ }
86
+ return { status: response.status, body: await jsonBody(response, requestArg.subject) };
87
+ } catch (error) {
88
+ if (signal.aborted) throw new ClaudeRequestError(`The ${requestArg.subject} request ${timeout.aborted ? 'timed out' : 'was cancelled'}.`);
89
+ if (error instanceof ClaudeRequestError) throw error;
90
+ throw new ClaudeRequestError(`The ${requestArg.subject} service could not be reached.`);
91
+ }
92
+ };
package/ts/cliargs.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { duration } from './accounts.js';
2
+
3
+ /** A command line a command cannot run with. The command prints the message and exits with status 2. */
4
+ export class UsageError extends Error {}
5
+
6
+ export interface ICommandArgsSpec {
7
+ /** Options that take a value: `--name value` or `--name=value`. */
8
+ values: readonly string[];
9
+ /** Options that take none. */
10
+ flags: readonly string[];
11
+ maxPositionals: number;
12
+ /** The message for an unknown option or one positional too many. */
13
+ usage: string;
14
+ }
15
+
16
+ export interface ICommandArgs {
17
+ /** Each given value option's value, by option name. */
18
+ values: ReadonlyMap<string, string>;
19
+ flags: ReadonlySet<string>;
20
+ positionals: readonly string[];
21
+ }
22
+
23
+ /**
24
+ * A command's own arguments: value options, flags and positionals. Every option is accepted at most once, and a value
25
+ * is taken as it is, even when it starts with `-`, so a prompt can itself contain an option.
26
+ */
27
+ export const parseCommandArgs = (argsArg: readonly string[], specArg: ICommandArgsSpec): ICommandArgs => {
28
+ const values = new Map<string, string>();
29
+ const flags = new Set<string>();
30
+ const positionals: string[] = [];
31
+ for (let index = 0; index < argsArg.length; index++) {
32
+ const argument = argsArg[index];
33
+ const separator = argument.startsWith('--') ? argument.indexOf('=') : -1;
34
+ const name = separator < 0 ? argument : argument.slice(0, separator);
35
+ if (specArg.values.includes(name)) {
36
+ if (values.has(name)) throw new UsageError(`Use ${name} only once.`);
37
+ const value = separator < 0 ? argsArg[++index] : argument.slice(separator + 1);
38
+ if (value === undefined) throw new UsageError(`${name} requires a value.`);
39
+ values.set(name, value);
40
+ } else if (separator < 0 && specArg.flags.includes(argument)) {
41
+ if (flags.has(argument)) throw new UsageError(`Use ${argument} only once.`);
42
+ flags.add(argument);
43
+ } else if (argument.startsWith('-') || positionals.length >= specArg.maxPositionals) {
44
+ throw new UsageError(specArg.usage);
45
+ } else positionals.push(argument);
46
+ }
47
+ return { values, flags, positionals };
48
+ };
49
+
50
+ const DURATION_UNITS: Readonly<Record<string, number>> = { '': 1000, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
51
+
52
+ /**
53
+ * A duration option in milliseconds: whole seconds (`120`), or a number with a unit (`90s`, `2m`, `1h`, `1d`),
54
+ * within the bounds. Every unit the bounds message can name is accepted, so a value the message suggests parses.
55
+ */
56
+ export const parseDurationOption = (nameArg: string, valueArg: string, boundsArg: { minMs: number; maxMs: number }): number => {
57
+ const match = /^(\d{1,7})([smhd]?)$/.exec(valueArg);
58
+ if (!match) throw new UsageError(`${nameArg} takes a duration such as 120, 90s or 2m.`);
59
+ const milliseconds = Number(match[1]) * DURATION_UNITS[match[2]];
60
+ if (milliseconds < boundsArg.minMs || milliseconds > boundsArg.maxMs) {
61
+ throw new UsageError(`${nameArg} must be from ${duration(boundsArg.minMs / 1000)} to ${duration(boundsArg.maxMs / 1000)}.`);
62
+ }
63
+ return milliseconds;
64
+ };
65
+
66
+ /** A whole-number option within the bounds. */
67
+ export const parseIntegerOption = (nameArg: string, valueArg: string, boundsArg: { min: number; max: number }): number => {
68
+ const value = /^\d{1,6}$/.test(valueArg) ? Number(valueArg) : Number.NaN;
69
+ if (!(value >= boundsArg.min && value <= boundsArg.max)) throw new UsageError(`${nameArg} takes a whole number from ${boundsArg.min} to ${boundsArg.max}.`);
70
+ return value;
71
+ };
@@ -0,0 +1,62 @@
1
+ import * as plugins from './plugins.js';
2
+ import { bold, dim } from './formatting.js';
3
+
4
+ export const consoleWidth = (): number => process.stdout.columns ?? 100;
5
+ export const consoleHeading = (textArg: string): void => { process.stdout.write(`\n${bold(textArg)}\n`); };
6
+
7
+ /**
8
+ * One note line beneath a table: dim, wrapped to the terminal, continuation lines under the prefix.
9
+ *
10
+ * Table cells wrap themselves (`overflow: 'wrap'` below), so a note is the only text a view writes that could run
11
+ * past the terminal. Words stay whole: one longer than the remaining width keeps its own line rather than being cut,
12
+ * so an account id or a URL stays selectable.
13
+ */
14
+ export const consoleNote = (textArg: string, prefixArg = ''): void => {
15
+ const words = textArg.split(/\s+/).filter(Boolean);
16
+ if (!words.length) return;
17
+ const width = consoleWidth();
18
+ const indent = ' '.repeat(prefixArg.length);
19
+ const lines: string[] = [];
20
+ let line = `${prefixArg}${words[0]}`;
21
+ for (const word of words.slice(1)) {
22
+ if (line.length + 1 + word.length > width) { lines.push(line); line = `${indent}${word}`; }
23
+ else line += ` ${word}`;
24
+ }
25
+ lines.push(line);
26
+ for (const written of lines) process.stdout.write(`${dim(written)}\n`);
27
+ };
28
+
29
+ /**
30
+ * One table implementation for every command, including the label-per-line fallback on narrow terminals.
31
+ *
32
+ * `groupsArg` is smartconsole's optional visual grouping: a divider between runs of consecutive rows with
33
+ * different keys, and a colour line along the left edge per run. Keys are identities, never display labels,
34
+ * and the caller orders the rows so that every group is one run. Below 40 columns the fallback separates
35
+ * divider runs with one dim rule line; it has no left edge, so it draws no colour line, and it repeats a
36
+ * spanning cell on every row it covers. A column's `render` content is what both layouts show.
37
+ */
38
+ export const consoleTable = async <TRow>(
39
+ outArg: plugins.smartconsole.SmartConsole,
40
+ rowsArg: TRow[],
41
+ columnsArg: plugins.smartconsole.IBackendTableColumn<TRow>[],
42
+ groupsArg?: NoInfer<plugins.smartconsole.IBackendTableGroups<TRow>>,
43
+ ): Promise<void> => {
44
+ if (!rowsArg.length) return;
45
+ const width = consoleWidth();
46
+ if (width < 40) {
47
+ // smartconsole's run semantics: null and undefined are the same key.
48
+ const dividerKeys = rowsArg.map(row => groupsArg?.divider?.(row) ?? null);
49
+ const rule = ` ${dim('─'.repeat(Math.max(1, width - 4)))}\n\n`;
50
+ const content = (column: plugins.smartconsole.IBackendTableColumn<TRow>, row: TRow): string => column.render
51
+ ? plugins.smartconsole.color.plain(column.render(row)) : String(column.value(row) ?? 'Unavailable');
52
+ rowsArg.forEach((row, index) => {
53
+ if (index > 0 && dividerKeys[index] !== dividerKeys[index - 1]) process.stdout.write(rule);
54
+ process.stdout.write(columnsArg.map(column => ` ${column.title}: ${content(column, row).replace(/\n/g, '\n ')}`).join('\n') + '\n\n');
55
+ });
56
+ return;
57
+ }
58
+ await outArg.table(rowsArg, {
59
+ columns: columnsArg, overflow: 'wrap', groups: groupsArg,
60
+ theme: { header: { bold: true, foreground: 'cyan' }, border: { dim: true } },
61
+ });
62
+ };
package/ts/helpers.ts CHANGED
@@ -53,6 +53,9 @@ export const writeSecretFileAtomically = (
53
53
  plugins.fs.chmodSync(destinationPathArg, 0o600);
54
54
  };
55
55
 
56
+ /** A Node system error's code, for the distinctions the callers make (`EEXIST`, `ENOENT`, `ELOCKED`, `ERELEASED`). */
57
+ export const errorCode = (errorArg: unknown): string | undefined => (errorArg as NodeJS.ErrnoException | null)?.code;
58
+
56
59
  /**
57
60
  * Blocks the calling thread for `millisecondsArg`.
58
61
  *
@@ -65,6 +68,17 @@ export const pauseSynchronously = (millisecondsArg: number): void => {
65
68
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, millisecondsArg);
66
69
  };
67
70
 
71
+ /**
72
+ * Waits `millisecondsArg` without blocking the thread. An aborted signal rejects the wait with its reason and clears
73
+ * the timer at once, so an abandoned wait never keeps the process alive.
74
+ */
75
+ export const pause = (millisecondsArg: number, signalArg?: AbortSignal): Promise<void> => new Promise<void>((resolve, reject) => {
76
+ if (signalArg?.aborted) { reject(signalArg.reason); return; }
77
+ const abort = (): void => { clearTimeout(timer); reject(signalArg!.reason); };
78
+ const timer = setTimeout(() => { signalArg?.removeEventListener('abort', abort); resolve(); }, Math.max(0, millisecondsArg));
79
+ signalArg?.addEventListener('abort', abort, { once: true });
80
+ });
81
+
68
82
  /**
69
83
  * Trims third-party process output down to something safe to put in an error
70
84
  * message: one line, length-capped, and never empty.
package/ts/index.ts CHANGED
@@ -20,6 +20,9 @@ export * from './classes.cli.js';
20
20
  export * from './classes.login.js';
21
21
  export * from './classes.operations.js';
22
22
  export * from './classes.service.js';
23
+ export * from './classes.watch.js';
24
+ export * from './classes.watchlock.js';
25
+ export * from './watchpolicy.js';
23
26
 
24
27
  import { AuthSwitchCli } from './classes.cli.js';
25
28
 
@@ -53,8 +53,9 @@ export interface IHarnessProcessControl {
53
53
  * A live credential that no longer holds the account the last switch activated.
54
54
  *
55
55
  * Authswitch records the hash of what it wrote, in its own store. A different hash with the same
56
- * account is that account's own token refresh; a different account is what a running instance
57
- * writing its in-memory login back looks like.
56
+ * account is that account's own token refresh; a different account means the login changed outside
57
+ * authswitch -- a new native login, or, for a harness that does not pick up swaps live, a running
58
+ * instance writing its in-memory login back.
58
59
  */
59
60
  export interface IHarnessCredentialDrift {
60
61
  slotId?: string;
@@ -97,6 +98,12 @@ export interface IHarnessAccountStatus {
97
98
  problems: string[];
98
99
  /** Optional structured data for shared presentation; never parse the display facts. */
99
100
  summary?: IHarnessStatusSummary;
101
+ /**
102
+ * Set when the provider refused a lookup for too many requests (HTTP 429). It says nothing about the account's
103
+ * usage: callers back off, never read it as an exhausted or expired login. `retryAt` is when the provider asked to
104
+ * be tried again (ISO 8601 UTC), or null when it did not say.
105
+ */
106
+ rateLimit?: { retryAt: string | null };
100
107
  }
101
108
 
102
109
  export interface IHarnessStatusFact {
@@ -138,11 +145,19 @@ export interface IAccountType {
138
145
  source: 'live' | 'stored';
139
146
  }
140
147
 
148
+ /** Live billing information only. Never infer these fields from a plan or entitlement expiry. Dates are ISO 8601 UTC. */
149
+ export interface IHarnessBilling {
150
+ hasActiveSubscription?: boolean;
151
+ autoRenew?: boolean;
152
+ renewsAt?: string;
153
+ cancelsAt?: string;
154
+ expiresAt?: string;
155
+ }
156
+
141
157
  export interface IHarnessStatusSummary {
142
158
  /** A plan name does not imply an active billing status. */
143
159
  subscription?: IAccountType;
144
- /** Live billing information only. Never infer these fields from a plan or entitlement expiry. */
145
- billing?: { hasActiveSubscription?: boolean; autoRenew?: boolean; renewsAt?: string; cancelsAt?: string; expiresAt?: string };
160
+ billing?: IHarnessBilling;
146
161
  /** Missing windows are unknown, not unused. Times are ISO 8601 UTC. */
147
162
  usageWindows?: IHarnessUsageWindow[];
148
163
  resets?: {
@@ -157,10 +172,25 @@ export interface IHarnessOutcome {
157
172
  problems: string[];
158
173
  }
159
174
 
160
- /** A read-only status lookup. */
175
+ /**
176
+ * A caller's status-reading session, such as one `authswitch watch`. Within it an adapter may reuse a lookup whose
177
+ * answer rarely changes (a Claude profile) instead of repeating it on every read; outside a session every lookup is live.
178
+ */
179
+ export interface IHarnessStatusSession {
180
+ /**
181
+ * The value `lookupArg` resolved to under `keyArg` less than `maxAgeMsArg` ago, otherwise the lookup's fresh result,
182
+ * which is then remembered. A rejected lookup is never remembered. Keys belong to the adapter that uses them, which
183
+ * prefixes them with its own id, so a key always names values of one type.
184
+ */
185
+ reuse<T>(keyArg: string, maxAgeMsArg: number, lookupArg: () => Promise<T>): Promise<T>;
186
+ }
187
+
188
+ /** One status lookup; see `IAuthHarness.readAccountStatus` for what it may and may not change. */
161
189
  export interface IHarnessStatusOptions {
162
190
  /** Cancels the lookup's requests; an adapter still bounds each request with its own timeout. */
163
191
  signal?: AbortSignal;
192
+ /** The session the read belongs to, when the caller reads the same accounts repeatedly. */
193
+ session?: IHarnessStatusSession;
164
194
  }
165
195
 
166
196
  /** A single text-only inference request. Credentials remain inside the adapter. */
@@ -188,6 +218,31 @@ export interface IAuthHarness {
188
218
  readState(): THarnessResult<IHarnessState>;
189
219
  /** Running instances of this harness, when the adapter owns their lifecycle. */
190
220
  readonly processes?: IHarnessProcessControl;
221
+ /**
222
+ * Whether running instances pick up a credential swap by themselves, on their next request.
223
+ *
224
+ * Such a harness is never offered a stop before a switch, and its outcomes carry no restart advice;
225
+ * an explicit stop request is still honoured through `processes`. Absent means running instances keep
226
+ * the login they loaded until they are restarted.
227
+ */
228
+ readonly liveSwap?: boolean;
229
+ /**
230
+ * Set when this harness's provider never reports renewal or cancellation dates, as a lower-case clause
231
+ * that completes "<label>: ...". Views then explain the missing dates once instead of per account.
232
+ */
233
+ readonly renewalUnavailableReason?: string;
234
+ /**
235
+ * Whether `authswitch watch` may switch this harness's login by itself when the active account runs out: every
236
+ * saved account's usage can be read, and a switch needs no session of the user's stopped. A helper process the
237
+ * adapter manages itself may still be restarted by its own switch -- Codex' app-server is, because its
238
+ * credential file cannot be rewritten underneath it. Absent means the watch never switches this harness.
239
+ */
240
+ readonly autoSwitch?: boolean;
241
+ /**
242
+ * The account's live status. It must never activate, switch or clear a login. It may renew the tokens of the
243
+ * saved copy it reads when the provider leaves it no other way to answer -- the Claude Code adapter does, for
244
+ * saved and inactive logins only, under its store's lock -- and must leave the active login's credential alone.
245
+ */
191
246
  readAccountStatus(accountIdArg: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus>;
192
247
  readonly loginProviders?: IHarnessLoginProvider[];
193
248
  beginLogin?(options: IHarnessLoginOptions): Promise<IHarnessLoginHandle>;
@@ -1,4 +1,4 @@
1
- import type { IAccountType, IHarnessAccount, IHarnessAccountStatus, IHarnessCredentialDrift, TUsageSeverity } from './interfaces.harness.js';
1
+ import type { IAccountType, IHarnessAccount, IHarnessAccountStatus, IHarnessBilling, IHarnessCredentialDrift, TUsageSeverity } from './interfaces.harness.js';
2
2
 
3
3
  /** Credential-free, versioned output of list --json. Missing status fields stay omitted. */
4
4
  export interface IAccountList {
@@ -41,6 +41,8 @@ export interface IAccountLimitRow {
41
41
  isActive: boolean;
42
42
  /** The adapter's plan name (`max`, `pro`), sanitised but not display-formatted; null when none is known or the row names no account. */
43
43
  accountType: IAccountType | null;
44
+ /** The account's live billing fields as its provider reported them (ISO UTC dates); null when none were reported or the row names no account. */
45
+ billing: IHarnessBilling | null;
44
46
  /** The provider's own window name, or null when no limit data is available. */
45
47
  limitType: string | null;
46
48
  scope: 'account' | 'feature' | null;
package/ts/plugins.ts CHANGED
@@ -13,6 +13,15 @@ const nativeRequire = createRequire(import.meta.url);
13
13
  /** SQLite is needed only when accessing a Codex state database, not when hosting account APIs. */
14
14
  export const loadSqlite = (): typeof import('node:sqlite') => nativeRequire('node:sqlite');
15
15
 
16
+ // third party modules
17
+ import type * as properLockfile from 'proper-lockfile';
18
+ export type TProperLockfile = typeof properLockfile;
19
+ /**
20
+ * Claude Code's own lock library, loaded on first use: it installs process exit hooks when it loads, which a
21
+ * process that never changes a Claude Code login (such as a host embedding the account service) does not need.
22
+ */
23
+ export const loadProperLockfile = (): Promise<TProperLockfile> => import('proper-lockfile');
24
+
16
25
  // @push.rocks modules
17
26
  import * as smartconsole from '@push.rocks/smartconsole';
18
27
  export { smartconsole };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * When a provider that answered HTTP 429 asked to be tried again, read from its `Retry-After` header (RFC 9110: a
3
+ * number of seconds, or an HTTP date) and returned as ISO 8601 UTC. An absent or unreadable header is null: the
4
+ * provider did not say, and the caller picks its own backoff.
5
+ */
6
+ export const retryAtFrom = (headerArg: string | null, nowArg: number): string | null => {
7
+ const value = headerArg?.trim() ?? '';
8
+ const time = /^\d{1,9}$/.test(value) ? nowArg + Number(value) * 1000 : /^[A-Za-z]{3}, .+ GMT$/.test(value) ? Date.parse(value) : Number.NaN;
9
+ return Number.isFinite(time) ? new Date(time).toISOString() : null;
10
+ };
11
+
12
+ /** The latest retry time several refused requests asked for; null when none of them said. */
13
+ export const latestRetryAt = (retryAtsArg: readonly (string | null)[]): string | null =>
14
+ retryAtsArg.filter((value): value is string => value !== null).sort().at(-1) ?? null;
@@ -0,0 +1,121 @@
1
+ import { compactUntil, usagePercentText } from './accounts.js';
2
+ import { plainText } from './formatting.js';
3
+ import type { IHarnessUsageWindow } from './interfaces.harness.js';
4
+
5
+ /** What a watch knows about one account of a harness when it decides. */
6
+ export interface IWatchReading {
7
+ id: string;
8
+ label: string;
9
+ /** Only a saved account can be switched to. */
10
+ isSaved: boolean;
11
+ /** The provider's usage windows from a reading recent enough to act on; null while the usage is unknown. */
12
+ windows: readonly IHarnessUsageWindow[] | null;
13
+ /** Why the usage is unknown, for a decision that depends on it. */
14
+ unknownReason?: string;
15
+ }
16
+
17
+ export interface IWatchPolicyInput {
18
+ activeId: string | null;
19
+ readings: readonly IWatchReading[];
20
+ /** The used percentage at which a window counts as used up. */
21
+ threshold: number;
22
+ /** A used-up account is traded for another used-up one only when that one is usable again more than this much sooner. */
23
+ intervalMs: number;
24
+ now: number;
25
+ }
26
+
27
+ /** `ok` says whether the active account is fine; a stay that is not ok still has nothing better to switch to. */
28
+ export type TWatchDecision =
29
+ | { action: 'stay'; ok: boolean; reason: string }
30
+ | { action: 'switch'; target: string; reason: string };
31
+
32
+ /**
33
+ * How far below the threshold a switch target must be on every limit, so that a switch never lands on an account that
34
+ * is about to reach it and the watch never flips back and forth.
35
+ */
36
+ const WATCH_HYSTERESIS = 10;
37
+
38
+ const accountWindows = (readingArg: IWatchReading): IHarnessUsageWindow[] => (readingArg.windows ?? []).filter(window => window.scope !== 'feature');
39
+ const featureWindows = (readingArg: IWatchReading): IHarnessUsageWindow[] => (readingArg.windows ?? []).filter(window => window.scope === 'feature');
40
+
41
+ /** The fullest of the windows at or above the threshold (the longer one on a tie), which names why an account is limited. */
42
+ const fullest = (windowsArg: readonly IHarnessUsageWindow[], thresholdArg: number): IHarnessUsageWindow | undefined =>
43
+ windowsArg.filter(window => window.usedPercent >= thresholdArg)
44
+ .sort((left, right) => right.usedPercent - left.usedPercent || right.durationSeconds - left.durationSeconds)[0];
45
+
46
+ const limitText = (windowArg: IHarnessUsageWindow, thresholdArg: number): string =>
47
+ `${plainText(windowArg.label)} ${usagePercentText(windowArg.usedPercent)} ≥ ${thresholdArg}%`;
48
+
49
+ /** When a window resets; one without a known reset never does. */
50
+ const resetTime = (windowArg: IHarnessUsageWindow): number => {
51
+ const time = windowArg.resetAt === null ? Number.NaN : Date.parse(windowArg.resetAt);
52
+ return Number.isFinite(time) ? time : Number.POSITIVE_INFINITY;
53
+ };
54
+
55
+ /** When every one of the account's used-up windows has reset. */
56
+ const usableAt = (readingArg: IWatchReading, thresholdArg: number): number =>
57
+ Math.max(...accountWindows(readingArg).filter(window => window.usedPercent >= thresholdArg).map(resetTime));
58
+
59
+ /** The reset of the account's longest window: capacity that expires first is used first. */
60
+ const longestWindowReset = (readingArg: IWatchReading): number => {
61
+ const windows = accountWindows(readingArg);
62
+ const longest = Math.max(...windows.map(window => window.durationSeconds));
63
+ return Math.min(...windows.filter(window => window.durationSeconds === longest).map(resetTime));
64
+ };
65
+
66
+ const peakUsage = (readingArg: IWatchReading): number => Math.max(...accountWindows(readingArg).map(window => window.usedPercent));
67
+
68
+ /** Numbers that are both unbounded compare as equal, so the next key decides. */
69
+ const ascending = (leftArg: number, rightArg: number): number => leftArg === rightArg ? 0 : leftArg < rightArg ? -1 : 1;
70
+ const byLabel = (leftArg: IWatchReading, rightArg: IWatchReading): number =>
71
+ leftArg.label.localeCompare(rightArg.label) || leftArg.id.localeCompare(rightArg.id);
72
+ const byPreference = (leftArg: IWatchReading, rightArg: IWatchReading): number =>
73
+ ascending(longestWindowReset(leftArg), longestWindowReset(rightArg)) || ascending(peakUsage(leftArg), peakUsage(rightArg)) || byLabel(leftArg, rightArg);
74
+
75
+ /**
76
+ * Which account a harness should use, from one set of readings. Pure: the same input always decides the same.
77
+ *
78
+ * - The active account is used up when any of its account windows is at or above the threshold. The watch then
79
+ * switches to the saved account whose account windows are all at least `WATCH_HYSTERESIS` points below it,
80
+ * preferring the one whose longest window resets soonest, then the least used, then by label.
81
+ * - When no account qualifies and every other saved account with a known reading is used up too, it switches to the
82
+ * one that is usable again first, and only when that is more than one interval sooner than the active account.
83
+ * - An active account limited only on a feature window (one model) switches to an account that is below the margin on
84
+ * every window; without one it stays, because the active account still serves everything else.
85
+ * - A reading that is unknown, whatever the cause, never counts as used up and never makes an account a target.
86
+ */
87
+ export const decideWatchAction = (inputArg: IWatchPolicyInput): TWatchDecision => {
88
+ const { threshold } = inputArg;
89
+ const stay = (okArg: boolean, reasonArg: string): TWatchDecision => ({ action: 'stay', ok: okArg, reason: reasonArg });
90
+ const active = inputArg.readings.find(reading => reading.id === inputArg.activeId);
91
+ if (!active) return stay(false, 'no active login');
92
+ if (active.windows === null) return stay(false, `usage unknown${active.unknownReason ? ` (${active.unknownReason})` : ''}`);
93
+ const margin = threshold - WATCH_HYSTERESIS;
94
+ const known = inputArg.readings.filter(reading => reading.id !== active.id && reading.isSaved && reading.windows !== null);
95
+ const below = (windowsArg: readonly IHarnessUsageWindow[]): boolean => windowsArg.every(window => window.usedPercent < margin);
96
+ const best = (candidatesArg: IWatchReading[]): IWatchReading | undefined => candidatesArg.sort(byPreference)[0];
97
+
98
+ const usedUp = fullest(accountWindows(active), threshold);
99
+ if (usedUp) {
100
+ const limit = limitText(usedUp, threshold);
101
+ const target = best(known.filter(reading => accountWindows(reading).length > 0 && below(accountWindows(reading))));
102
+ if (target) return { action: 'switch', target: target.id, reason: limit };
103
+ const exhausted = known.filter(reading => fullest(accountWindows(reading), threshold) !== undefined);
104
+ if (!exhausted.length || exhausted.length < known.length) return stay(false, `${limit}; no other saved account is known to be below ${margin}%`);
105
+ const soonest = exhausted.map(reading => ({ reading, at: usableAt(reading, threshold) }))
106
+ .sort((left, right) => ascending(left.at, right.at) || byLabel(left.reading, right.reading))[0];
107
+ if (soonest.at < usableAt(active, threshold) - inputArg.intervalMs) {
108
+ return { action: 'switch', target: soonest.reading.id, reason: `${limit}; every saved account is used up, and ${plainText(soonest.reading.label)} is usable again first (in ${compactUntil(new Date(soonest.at).toISOString(), inputArg.now)})` };
109
+ }
110
+ return stay(false, `${limit}; every saved account is used up, and none is usable again sooner`);
111
+ }
112
+
113
+ const featureLimit = fullest(featureWindows(active), threshold);
114
+ if (featureLimit) {
115
+ const limit = limitText(featureLimit, threshold);
116
+ const target = best(known.filter(reading => accountWindows(reading).length > 0 && below(reading.windows!)));
117
+ if (target) return { action: 'switch', target: target.id, reason: limit };
118
+ return stay(false, `${limit}; no other saved account is known to be below ${margin}% on every limit`);
119
+ }
120
+ return stay(true, `every limit below ${threshold}%`);
121
+ };