@modelprofile.com/authswitch 3.3.0 → 5.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 +52 -15
  3. package/dist_ts/accounts.js +110 -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 +81 -2
  7. package/dist_ts/classes.claudecodeharness.js +208 -14
  8. package/dist_ts/classes.claudecodelocks.d.ts +38 -0
  9. package/dist_ts/classes.claudecodelocks.js +118 -0
  10. package/dist_ts/classes.claudestatus.d.ts +15 -2
  11. package/dist_ts/classes.claudestatus.js +100 -83
  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 +126 -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 +7 -2
  19. package/dist_ts/classes.codexstatus.js +49 -21
  20. package/dist_ts/classes.credentialstore.d.ts +43 -2
  21. package/dist_ts/classes.credentialstore.js +60 -13
  22. package/dist_ts/classes.fileharness.d.ts +45 -21
  23. package/dist_ts/classes.fileharness.js +64 -26
  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 +8 -4
  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 +349 -115
  58. package/ts/00_commitinfo_data.ts +3 -3
  59. package/ts/accounts.ts +122 -35
  60. package/ts/classes.accountlist.ts +2 -219
  61. package/ts/classes.claudecodeharness.ts +199 -13
  62. package/ts/classes.claudecodelocks.ts +133 -0
  63. package/ts/classes.claudestatus.ts +100 -65
  64. package/ts/classes.claudetokenrefresh.ts +85 -0
  65. package/ts/classes.cli.ts +114 -54
  66. package/ts/classes.codexharness.ts +4 -0
  67. package/ts/classes.codexstatus.ts +40 -18
  68. package/ts/classes.credentialstore.ts +78 -10
  69. package/ts/classes.fileharness.ts +76 -34
  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 +7 -3
  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
@@ -1,4 +1,5 @@
1
1
  import * as plugins from './plugins.js';
2
+ import { authSwitchHome } from './classes.credentialstore.js';
2
3
  import type { IAuthHarness, IHarnessOutcome } from './interfaces.harness.js';
3
4
 
4
5
  export type TAuthSwitchMutation = { harnessId: string } & (
@@ -30,7 +31,7 @@ export type TAuthSwitchLocalPreparation = () => Promise<void>;
30
31
 
31
32
  export const authSwitchEnvironmentId = (env: NodeJS.ProcessEnv = process.env): string => plugins.crypto.createHash('sha256').update(JSON.stringify({
32
33
  home: plugins.os.homedir(),
33
- stash: plugins.path.resolve(env.AUTHSWITCH_HOME || plugins.path.join(plugins.os.homedir(), '.authswitch')),
34
+ stash: plugins.path.resolve(authSwitchHome(env)),
34
35
  codex: plugins.path.resolve(env.CODEX_HOME || plugins.path.join(plugins.os.homedir(), '.codex')),
35
36
  data: plugins.path.resolve(env.XDG_DATA_HOME || plugins.path.join(plugins.os.homedir(), '.local/share')),
36
37
  claude: plugins.path.resolve(env.CLAUDE_CONFIG_DIR || plugins.path.join(plugins.os.homedir(), '.claude')),
package/ts/classes.tui.ts CHANGED
@@ -65,11 +65,12 @@ export class AuthSwitchTui {
65
65
  *
66
66
  * The dashboard makes the same offer the command line does, with the same rule: only pids
67
67
  * enumerated for this user are signalled, the session running this dashboard is never one of
68
- * them, and declining still performs the switch.
68
+ * them, and declining still performs the switch. A harness that picks up swaps live is never
69
+ * offered a stop here; the dashboard has no explicit stop request.
69
70
  */
70
71
  const settleInstances = (screen: plugins.smartconsole.ITuiContext) => async (): Promise<void> => {
71
72
  const control = harness.processes;
72
- if (!control) return;
73
+ if (!control || harness.liveSwap === true) return;
73
74
  let running: IHarnessProcess[];
74
75
  try { running = control.list(); }
75
76
  catch { ui.logs.append(`Could not check for running ${plainText(harness.label)} processes.`); return; }
@@ -0,0 +1,263 @@
1
+ import { accountBadge, accountName, durationText, localTime, readAccountRows, type IAccountRow } from './accounts.js';
2
+ import { pause } from './helpers.js';
3
+ import { bold, dim, green, orange, plainText, red } from './formatting.js';
4
+ import { decideWatchAction, type IWatchReading } from './watchpolicy.js';
5
+ import type { AuthSwitchOperations } from './classes.operations.js';
6
+ import type { WatchLock } from './classes.watchlock.js';
7
+ import type { IAccountType, IAuthHarness, IHarnessStatusSession, IHarnessUsageWindow } from './interfaces.harness.js';
8
+
9
+ /** The watch's time source; tests replace it so that no test waits for an interval. */
10
+ export interface IWatchClock {
11
+ now(): number;
12
+ /** Resolves after the delay; rejects when the signal aborts first, leaving no timer behind. */
13
+ sleep(millisecondsArg: number, signalArg: AbortSignal): Promise<void>;
14
+ }
15
+
16
+ const systemClock: IWatchClock = { now: () => Date.now(), sleep: pause };
17
+
18
+ /** An account as a watch event names it. Carries no credential. */
19
+ export interface IWatchAccount {
20
+ accountId: string;
21
+ label: string;
22
+ slotId?: string;
23
+ accountType: IAccountType | null;
24
+ /** The usage windows the decision used; null while the usage is unknown. */
25
+ usageWindows: IHarnessUsageWindow[] | null;
26
+ }
27
+
28
+ /** One line of `authswitch watch --json`. Times are ISO 8601 UTC. */
29
+ export type TWatchEvent =
30
+ | { type: 'start'; at: string; harnesses: { harnessId: string; label: string; note: string | null }[]; intervalSeconds: number; threshold: number; dryRun: boolean }
31
+ | { type: 'tick'; at: string; harnessId: string; label: string; active: IWatchAccount | null; ok: boolean; reason: string }
32
+ | { type: 'switch'; at: string; harnessId: string; label: string; dryRun: boolean; from: IWatchAccount; to: IWatchAccount; reason: string }
33
+ | { type: 'error'; at: string; harnessId: string; label: string; message: string };
34
+
35
+ export interface IAuthSwitchWatchOptions {
36
+ harnesses: readonly IAuthHarness[];
37
+ /** The one mutation path every switch goes through. */
38
+ operations: AuthSwitchOperations;
39
+ lock: WatchLock;
40
+ intervalMs: number;
41
+ threshold: number;
42
+ dryRun: boolean;
43
+ onEvent: (eventArg: TWatchEvent) => void;
44
+ clock?: IWatchClock;
45
+ }
46
+
47
+ /** How long a reading stands in for an account that is backing off. */
48
+ const READING_REUSE_MS = 10 * 60_000;
49
+ /** The longest backoff after repeated failures that named no retry time. */
50
+ const MAX_BACKOFF_MS = 30 * 60_000;
51
+ /** How a rate limited account's deferral reads; a lookup that failed otherwise is named by its own problem. */
52
+ const RATE_LIMITED = 'rate limited';
53
+
54
+ /** A caller-owned memo of slow-changing lookups, aged by the watch's clock. */
55
+ class WatchStatusSession implements IHarnessStatusSession {
56
+ private readonly entries = new Map<string, { at: number; maxAgeMs: number; value: unknown }>();
57
+ constructor(private readonly now: () => number) {}
58
+ public async reuse<T>(keyArg: string, maxAgeMsArg: number, lookupArg: () => Promise<T>): Promise<T> {
59
+ const now = this.now();
60
+ for (const [key, entry] of this.entries) if (now - entry.at >= entry.maxAgeMs) this.entries.delete(key);
61
+ const entry = this.entries.get(keyArg);
62
+ // Keys are adapter-owned and always name values of one type (IHarnessStatusSession).
63
+ if (entry) return entry.value as T;
64
+ const value = await lookupArg();
65
+ this.entries.set(keyArg, { at: this.now(), maxAgeMs: maxAgeMsArg, value });
66
+ return value;
67
+ }
68
+ }
69
+
70
+ /** What the watch remembers about one account between ticks. */
71
+ interface IAccountMemory {
72
+ usage: { accountType: IAccountType | null; windows: IHarnessUsageWindow[]; at: number } | null;
73
+ /** No lookup before this time, after the provider rate limited one or a lookup failed. */
74
+ retryAt: number;
75
+ /** Consecutive failures without a named retry time, for the exponential backoff. */
76
+ strikes: number;
77
+ /** Why the account is backing off, so a tick that skips its lookup reads like the one that failed; null while none did. */
78
+ deferralReason: string | null;
79
+ }
80
+
81
+ interface IHarnessTick {
82
+ harness: IAuthHarness;
83
+ rows: IAccountRow[];
84
+ readings: IWatchReading[];
85
+ accounts: Map<string, IWatchAccount>;
86
+ }
87
+
88
+ /**
89
+ * `authswitch watch`: reads every account's usage on an interval and switches a harness to a better saved account when
90
+ * its active one runs out, through the same mutation path as `use`.
91
+ *
92
+ * Ticks never overlap: the next one starts an interval after the previous one started, or at once when that one took
93
+ * longer. Lookups never change which account is in use and are bounded; an account whose lookup a provider refused or
94
+ * whose lookup failed is not asked about again until the time the provider named, or after an exponentially growing
95
+ * delay, and its last reading stands in meanwhile while it is recent. A failed or refused switch is reported and
96
+ * decided again on the next tick.
97
+ */
98
+ export class AuthSwitchWatch {
99
+ private readonly clock: IWatchClock;
100
+ private readonly session: WatchStatusSession;
101
+ /** Per harness id, per account id. */
102
+ private readonly memory = new Map<string, Map<string, IAccountMemory>>();
103
+
104
+ constructor(private readonly options: IAuthSwitchWatchOptions) {
105
+ this.clock = options.clock ?? systemClock;
106
+ this.session = new WatchStatusSession(() => this.clock.now());
107
+ }
108
+
109
+ private at(): string { return new Date(this.clock.now()).toISOString(); }
110
+
111
+ /**
112
+ * Holds the lock and ticks until the signal aborts, or once. Resolves whether every switch it attempted completed;
113
+ * throws `WatchBusyError` when another watch is running.
114
+ */
115
+ public async run(signalArg: AbortSignal, optionsArg: { once?: boolean } = {}): Promise<boolean> {
116
+ this.options.lock.acquire();
117
+ try {
118
+ this.options.onEvent({
119
+ type: 'start', at: this.at(), intervalSeconds: this.options.intervalMs / 1000, threshold: this.options.threshold, dryRun: this.options.dryRun,
120
+ harnesses: this.options.harnesses.map(harness => ({ harnessId: harness.id, label: plainText(harness.label),
121
+ note: harness.liveSwap === true ? null : `Running ${plainText(harness.label)} sessions keep the login they loaded; new sessions use the switched one.` })),
122
+ });
123
+ let completed = true;
124
+ while (!signalArg.aborted) {
125
+ const startedAt = this.clock.now();
126
+ completed = await this.tick(signalArg) && completed;
127
+ if (optionsArg.once) break;
128
+ try { await this.clock.sleep(Math.max(0, startedAt + this.options.intervalMs - this.clock.now()), signalArg); }
129
+ catch (error) { if (!signalArg.aborted) throw error; }
130
+ }
131
+ return completed;
132
+ } finally { this.options.lock.release(); }
133
+ }
134
+
135
+ /** One round over every harness. Resolves false when a switch was attempted and did not complete. */
136
+ private async tick(signalArg: AbortSignal): Promise<boolean> {
137
+ const now = this.clock.now();
138
+ const reads = await Promise.allSettled(this.options.harnesses.map(harness => this.read(harness, now, signalArg)));
139
+ let completed = true;
140
+ for (const [index, read] of reads.entries()) {
141
+ if (signalArg.aborted) break;
142
+ const harness = this.options.harnesses[index];
143
+ if (read.status === 'rejected') {
144
+ this.options.onEvent({ type: 'error', at: this.at(), harnessId: harness.id, label: plainText(harness.label),
145
+ message: `Could not read the accounts. Run authswitch ${harness.id} doctor.` });
146
+ continue;
147
+ }
148
+ completed = await this.decide(read.value, now) && completed;
149
+ }
150
+ return completed;
151
+ }
152
+
153
+ private async read(harnessArg: IAuthHarness, nowArg: number, signalArg: AbortSignal): Promise<IHarnessTick> {
154
+ const previous = this.memory.get(harnessArg.id) ?? new Map<string, IAccountMemory>();
155
+ const { rows } = await readAccountRows(harnessArg, {
156
+ signal: signalArg, session: this.session, lookup: account => nowArg >= (previous.get(account.id)?.retryAt ?? 0),
157
+ });
158
+ // Only listed accounts are remembered, so a removed account leaves nothing behind.
159
+ const current = new Map<string, IAccountMemory>();
160
+ const readings: IWatchReading[] = [];
161
+ const accounts = new Map<string, IWatchAccount>();
162
+ for (const row of rows) {
163
+ const memory = previous.get(row.account.id) ?? { usage: null, retryAt: 0, strikes: 0, deferralReason: null };
164
+ current.set(row.account.id, memory);
165
+ const { reading, account } = this.remember(memory, row, nowArg);
166
+ readings.push(reading);
167
+ accounts.set(row.account.id, account);
168
+ }
169
+ this.memory.set(harnessArg.id, current);
170
+ return { harness: harnessArg, rows, readings, accounts };
171
+ }
172
+
173
+ /**
174
+ * Folds one lookup into the account's memory and returns what the decision may use. A row without a status was not
175
+ * looked up because the account is backing off.
176
+ *
177
+ * A lookup that a provider refused (a rate limit) or that failed (a rejected login, a timeout, an unreachable
178
+ * service) puts the account on hold for the time the provider named, else for a delay that doubles from one
179
+ * interval, and its last reading stands in meanwhile while it is recent: the accounts failing hardest must not be
180
+ * the ones asked most often, and one flaky request must not discard a reading taken moments ago. A lookup that
181
+ * answered without usage and without a problem is the provider reporting none, which is an answer: it clears both.
182
+ */
183
+ private remember(memoryArg: IAccountMemory, rowArg: IAccountRow, nowArg: number): { reading: IWatchReading; account: IWatchAccount } {
184
+ const status = rowArg.status;
185
+ const windows = status?.summary?.usageWindows;
186
+ const limited = status?.rateLimit !== undefined;
187
+ const failed = status !== undefined && !limited && windows === undefined && status.problems.length > 0;
188
+ if (status !== undefined && (limited || failed)) {
189
+ const named = status.rateLimit?.retryAt == null ? Number.NaN : Date.parse(status.rateLimit.retryAt);
190
+ memoryArg.strikes = Number.isFinite(named) ? 0 : memoryArg.strikes + 1;
191
+ memoryArg.retryAt = Number.isFinite(named) ? named : nowArg + Math.min(this.options.intervalMs * 2 ** (memoryArg.strikes - 1), MAX_BACKOFF_MS);
192
+ memoryArg.deferralReason = limited ? RATE_LIMITED : plainText(status.problems[0]);
193
+ } else if (status) {
194
+ memoryArg.strikes = 0;
195
+ memoryArg.retryAt = 0;
196
+ memoryArg.deferralReason = null;
197
+ }
198
+ if (windows !== undefined) memoryArg.usage = { accountType: status!.summary!.subscription ?? null, windows, at: nowArg };
199
+ else if (status && !limited && !failed) memoryArg.usage = null;
200
+ const deferred = status === undefined || limited || failed;
201
+ const usage = deferred && memoryArg.usage !== null && nowArg - memoryArg.usage.at >= READING_REUSE_MS ? null : memoryArg.usage;
202
+ const unknownReason = usage !== null ? undefined
203
+ : `${memoryArg.deferralReason ?? 'usage not reported'}${nowArg < memoryArg.retryAt ? `, next lookup at ${localTime(memoryArg.retryAt)}` : ''}`;
204
+ const accountType = usage?.accountType ?? status?.summary?.subscription ?? null;
205
+ return {
206
+ reading: { id: rowArg.account.id, label: plainText(rowArg.account.label), isSaved: rowArg.account.isStashed, windows: usage?.windows ?? null,
207
+ ...(unknownReason === undefined ? {} : { unknownReason }) },
208
+ account: { accountId: rowArg.account.id, label: plainText(rowArg.account.label), ...(rowArg.account.slotId === undefined ? {} : { slotId: plainText(rowArg.account.slotId) }),
209
+ accountType: accountType === null ? null : { plan: plainText(accountType.plan), source: accountType.source }, usageWindows: usage === null ? null : [...usage.windows] },
210
+ };
211
+ }
212
+
213
+ private async decide(tickArg: IHarnessTick, nowArg: number): Promise<boolean> {
214
+ const { harness } = tickArg;
215
+ const label = plainText(harness.label);
216
+ const activeId = tickArg.rows.find(row => row.account.isActive)?.account.id ?? null;
217
+ const decision = decideWatchAction({ activeId, readings: tickArg.readings, threshold: this.options.threshold, intervalMs: this.options.intervalMs, now: nowArg });
218
+ const active = activeId === null ? null : tickArg.accounts.get(activeId)!;
219
+ if (decision.action === 'stay') {
220
+ this.options.onEvent({ type: 'tick', at: this.at(), harnessId: harness.id, label, active, ok: decision.ok, reason: decision.reason });
221
+ return true;
222
+ }
223
+ const target = tickArg.accounts.get(decision.target)!;
224
+ const switched = { type: 'switch', harnessId: harness.id, label, from: active!, to: target, reason: decision.reason } as const;
225
+ if (this.options.dryRun) {
226
+ this.options.onEvent({ ...switched, at: this.at(), dryRun: true });
227
+ return true;
228
+ }
229
+ let problems: string[];
230
+ try { problems = (await this.options.operations.run(harness, { harnessId: harness.id, action: 'switch', accountId: target.accountId })).problems; }
231
+ catch (error) { problems = [error instanceof Error ? error.message : 'The switch failed.']; }
232
+ if (problems.length) {
233
+ this.options.onEvent({ type: 'error', at: this.at(), harnessId: harness.id, label,
234
+ message: `Switching to ${accountName(target)} did not complete: ${problems.map(plainText).join(' ')} Deciding again next tick.` });
235
+ return false;
236
+ }
237
+ this.options.onEvent({ ...switched, at: this.at(), dryRun: false });
238
+ return true;
239
+ }
240
+ }
241
+
242
+ /** An account and its switch badge: `alice@example.com (type:Max) (5h:17%) (w:4%)`. */
243
+ const badged = (accountArg: IWatchAccount): string => `${bold(accountName(accountArg))}${accountBadge({ facts: [], problems: [], summary: {
244
+ ...(accountArg.accountType === null ? {} : { subscription: accountArg.accountType }),
245
+ ...(accountArg.usageWindows === null ? {} : { usageWindows: accountArg.usageWindows }),
246
+ } })}`;
247
+
248
+ /** How `authswitch watch` prints an event: one line each, with the notes of a start on their own lines. */
249
+ export const watchEventText = (eventArg: TWatchEvent): string => {
250
+ const time = dim(localTime(Date.parse(eventArg.at)));
251
+ switch (eventArg.type) {
252
+ case 'start':
253
+ return [`${time} Watching ${eventArg.harnesses.map(harness => harness.label).join(', ')} every ${durationText(eventArg.intervalSeconds)}; switching at ${eventArg.threshold}%${eventArg.dryRun ? ' (dry run: nothing is switched)' : ''}.`,
254
+ ...eventArg.harnesses.flatMap(harness => harness.note === null ? [] : [`${time} ${dim(harness.note)}`])].join('\n');
255
+ case 'tick':
256
+ return eventArg.active === null ? `${time} ${eventArg.label} ${orange(eventArg.reason)}`
257
+ : `${time} ${eventArg.label} ${badged(eventArg.active)} ${eventArg.ok ? green('ok') : orange(eventArg.reason)}`;
258
+ case 'switch':
259
+ return `${time} ${eventArg.label} ${eventArg.dryRun ? 'would switch' : 'switched'} ${accountName(eventArg.from)} → ${badged(eventArg.to)} — ${eventArg.reason}`;
260
+ case 'error':
261
+ return `${time} ${eventArg.label} ${red(eventArg.message)}`;
262
+ }
263
+ };
@@ -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