@modelprofile.com/authswitch 3.2.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 +63 -13
  3. package/dist_ts/accounts.js +199 -31
  4. package/dist_ts/classes.accountlist.d.ts +0 -24
  5. package/dist_ts/classes.accountlist.js +2 -213
  6. package/dist_ts/classes.claudecodeharness.d.ts +61 -3
  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 +17 -4
  11. package/dist_ts/classes.claudestatus.js +89 -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 +29 -7
  15. package/dist_ts/classes.cli.js +172 -78
  16. package/dist_ts/classes.codexharness.d.ts +6 -2
  17. package/dist_ts/classes.codexharness.js +7 -3
  18. package/dist_ts/classes.codexstatus.d.ts +5 -4
  19. package/dist_ts/classes.codexstatus.js +36 -19
  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 +39 -19
  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 +106 -32
  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 +6 -2
  31. package/dist_ts/classes.opencodeharness.js +9 -5
  32. package/dist_ts/classes.operations.js +3 -2
  33. package/dist_ts/classes.tui.js +10 -6
  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 +76 -15
  49. package/dist_ts/interfaces.list.d.ts +7 -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 +360 -106
  58. package/ts/00_commitinfo_data.ts +3 -3
  59. package/ts/accounts.ts +202 -34
  60. package/ts/classes.accountlist.ts +2 -216
  61. package/ts/classes.claudecodeharness.ts +126 -13
  62. package/ts/classes.claudecodelocks.ts +132 -0
  63. package/ts/classes.claudestatus.ts +88 -54
  64. package/ts/classes.claudetokenrefresh.ts +85 -0
  65. package/ts/classes.cli.ts +163 -63
  66. package/ts/classes.codexharness.ts +7 -3
  67. package/ts/classes.codexstatus.ts +29 -18
  68. package/ts/classes.credentialstore.ts +53 -9
  69. package/ts/classes.fileharness.ts +69 -31
  70. package/ts/classes.limits.ts +126 -33
  71. package/ts/classes.listrenderer.ts +328 -0
  72. package/ts/classes.login.ts +1 -1
  73. package/ts/classes.opencodeharness.ts +9 -5
  74. package/ts/classes.operations.ts +2 -1
  75. package/ts/classes.tui.ts +9 -5
  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 +78 -6
  84. package/ts/interfaces.list.ts +7 -1
  85. package/ts/plugins.ts +9 -0
  86. package/ts/ratelimit.ts +14 -0
  87. package/ts/watchpolicy.ts +121 -0
@@ -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
- constructor(harness: string, root = process.env.AUTHSWITCH_HOME || plugins.path.join(plugins.os.homedir(), '.authswitch')) {
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
- const record: IStoredCredential = { ...entry, schemaVersion: 1, id: this.id(entry.slotId, entry.identity), savedAt: new Date().toISOString() };
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('Saved credential verification failed; the active login was preserved.');
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
- public locked<T>(action: () => T): T {
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
- let fd: number;
131
- try { fd = plugins.fs.openSync(file, 'wx', 0o600); }
132
- catch { throw new Error(`Account storage is locked. Close other authswitch operations; if none remain, remove ${file}.`); }
133
- try { return action(); }
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
  }
@@ -1,4 +1,4 @@
1
- import type { IAuthHarness, IHarnessAccountStatus, IHarnessCredentialDrift, IHarnessOutcome, IHarnessProcessControl, IHarnessState } from './interfaces.harness.js';
1
+ import type { IAuthHarness, IHarnessAccountStatus, IHarnessCredentialDrift, IHarnessOutcome, IHarnessProcessControl, IHarnessState, IHarnessStatusOptions } from './interfaces.harness.js';
2
2
  import { credentialHash, CredentialStore, type IStoredCredential, type ISwitchRecord } from './classes.credentialstore.js';
3
3
  import { pauseSynchronously } from './helpers.js';
4
4
  import { plainText } from './formatting.js';
@@ -25,20 +25,32 @@ export abstract class FileHarness implements IAuthHarness {
25
25
  protected abstract apply(slotId: string, previous: IFileAccount | undefined, target: IFileAccount | undefined): void;
26
26
  /** The harness's own running instances. Detection informs the caller; it never blocks a write. */
27
27
  public abstract readonly processes: IHarnessProcessControl;
28
+ /** Whether running instances pick up a swapped login by themselves; see `IAuthHarness.liveSwap`. */
29
+ public abstract readonly liveSwap: boolean;
30
+ /** Whether `authswitch watch` may switch this harness by itself; see `IAuthHarness.autoSwitch`. */
31
+ public abstract readonly autoSwitch: boolean;
28
32
  /** Raw bytes of the native files a snapshot's credentials are taken from, in a fixed order. */
29
33
  protected abstract nativeSources(): (string | null)[];
30
34
  protected abstract inspect(credential: Record<string, unknown>, slotId: string): IFileAccount;
31
- public abstract readAccountStatus(id: string): Promise<IHarnessAccountStatus>;
35
+ public abstract readAccountStatus(id: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus>;
36
+
37
+ /**
38
+ * Runs a read and write of the native login under the harness's own write locks, so it never interleaves
39
+ * with a running instance's token refresh or save. A harness without such a protocol runs the action as it is.
40
+ */
41
+ protected async nativeTransaction(actionArg: () => string[]): Promise<string[]> {
42
+ return actionArg();
43
+ }
32
44
 
33
45
  /**
34
46
  * A snapshot proven not to have been taken across a native write.
35
47
  *
36
48
  * Saving a login without replacing it writes no native file, so it must not require the harness
37
- * to be stopped. A running harness does rotate its tokens on its own schedule, and its fallback
38
- * writer truncates the file in place, so reading the same sources before and after the snapshot
39
- * and requiring identical bytes is what keeps a torn or half-rotated credential out of the stash.
40
- * Both the retry count and the pause between attempts are bounded; an unstable file is reported,
41
- * never guessed at.
49
+ * to be stopped. A running harness does rotate its tokens on its own schedule, and a native writer
50
+ * that is not transactional can leave a partial file for a moment, so reading the same sources
51
+ * before and after the snapshot and requiring identical bytes is what keeps a torn or half-rotated
52
+ * credential out of the stash. Both the retry count and the pause between attempts are bounded; an
53
+ * unstable file is reported, never guessed at.
42
54
  */
43
55
  private stableSnapshot(): IFileHarnessSnapshot {
44
56
  let failure: unknown;
@@ -82,11 +94,11 @@ export abstract class FileHarness implements IAuthHarness {
82
94
  /**
83
95
  * Credential slots that no longer hold the account the last switch wrote.
84
96
  *
85
- * A running harness refreshes its login on its own schedule and writes the result to the same
86
- * file, so a switch performed underneath one can be undone minutes later without any error. The
87
- * recorded hash detects that the file changed at all; comparing the account distinguishes the
88
- * benign case -- the switched-in account rotating its own token -- from the previous login coming
89
- * back. Detection is advisory and never turns a read into a failure.
97
+ * The login can change outside authswitch -- a new native login, or, for a harness that does not pick
98
+ * up swaps live, a running instance writing its previous login back at its next refresh. The recorded
99
+ * hash detects that the file changed at all; comparing the account distinguishes the benign case -- the
100
+ * switched-in account rotating its own token -- from a different account. Detection is advisory and
101
+ * never turns a read into a failure.
90
102
  */
91
103
  private credentialDrift(snapshotArg: IFileHarnessSnapshot): IHarnessCredentialDrift[] {
92
104
  let records: ISwitchRecord[];
@@ -111,8 +123,9 @@ export abstract class FileHarness implements IAuthHarness {
111
123
  private savedLabel(accountIdArg: string): string | null {
112
124
  try { return this.store.read(accountIdArg).label; } catch { return null; }
113
125
  }
114
- /** Instances that keep a replaced or cleared login in memory until they are restarted. */
126
+ /** Instances that keep a replaced or cleared login in memory until they are restarted; none for a live-swap harness. */
115
127
  private runningCaveat(kindArg: 'replaced' | 'cleared'): string[] {
128
+ if (this.liveSwap) return [];
116
129
  let count: number;
117
130
  try { count = this.processes.list().length; }
118
131
  catch { return [`Could not check for running ${this.label} processes. If any are running, restart them: an unrestarted instance can write its previous login back.`]; }
@@ -137,16 +150,24 @@ export abstract class FileHarness implements IAuthHarness {
137
150
  return [];
138
151
  } catch { return [`The login was written, but this switch could not be recorded for later credential checks.`]; }
139
152
  }
153
+ /** The active login with this account id, read from the native files; undefined when the account is not active. */
154
+ protected activeCredential(id: string): IFileAccount | undefined {
155
+ return this.snapshot().accounts.find(account => this.store.id(account.slotId, account.identity) === id);
156
+ }
157
+ /** The saved copy of this account, verified against its identity. */
158
+ protected savedCredential(id: string): IFileAccount {
159
+ return this.saved(this.store.read(id));
160
+ }
161
+ /** The account's current credential: the active login when it is active, which may be newer than its saved copy. */
140
162
  protected readCredential(id: string): IFileAccount {
141
- const active = this.snapshot().accounts.find(account => this.store.id(account.slotId, account.identity) === id);
142
- return active ?? this.saved(this.store.read(id));
163
+ return this.activeCredential(id) ?? this.savedCredential(id);
143
164
  }
144
165
  /** Save a newly authenticated credential without changing a native harness's login. */
145
- public importCredential(slotId: string, credential: Record<string, unknown>): { accountId: string } {
166
+ public importCredential(slotId: string, credential: Record<string, unknown>): Promise<{ accountId: string }> {
146
167
  return this.store.locked(() => ({ accountId: this.store.save(this.inspect(credential, slotId)).id }));
147
168
  }
148
169
  /** Backend-only login handoff. Preserve the outgoing credential before replacing its slot. */
149
- public activateCredential(slotId: string, credential: Record<string, unknown>): IHarnessOutcome {
170
+ public activateCredential(slotId: string, credential: Record<string, unknown>): Promise<IHarnessOutcome> {
150
171
  return this.outcome(() => {
151
172
  const target = this.inspect(credential, slotId);
152
173
  const snapshot = this.snapshot();
@@ -154,9 +175,15 @@ export abstract class FileHarness implements IAuthHarness {
154
175
  const previous = snapshot.accounts.find(account => account.slotId === slotId);
155
176
  if (previous) this.store.save(previous);
156
177
  this.apply(slotId, previous, target);
157
- return [`Activated ${plainText(target.label)} (${plainText(slotId)}).`,
178
+ return [`Activated ${plainText(target.label)} (${plainText(slotId)}).`, ...this.liveSwapNote('replaced'),
158
179
  ...this.recordActivation(slotId, this.store.id(slotId, target.identity)), ...this.runningCaveat('replaced')];
159
- });
180
+ }, true);
181
+ }
182
+ /** What a live-swap harness's running sessions do with the change; they need no restart either way. */
183
+ private liveSwapNote(kindArg: 'replaced' | 'cleared'): string[] {
184
+ if (!this.liveSwap) return [];
185
+ return [kindArg === 'replaced' ? `Running ${this.label} sessions use it from their next request.`
186
+ : `Running ${this.label} sessions have no login from their next request.`];
160
187
  }
161
188
  public resolveAccount(reference: string): { id: string } | { candidates: string[] } {
162
189
  const accounts = this.store.list();
@@ -165,11 +192,18 @@ export abstract class FileHarness implements IAuthHarness {
165
192
  const matches = exact.length ? exact : accounts.filter(account => account.label.toLowerCase().startsWith(reference.toLowerCase()));
166
193
  return matches.length === 1 ? { id: matches[0].id } : { candidates: matches.map(account => `${account.slotId}:${account.label} (${account.id})`) };
167
194
  }
168
- private outcome(action: () => string | string[]): IHarnessOutcome {
169
- try { return this.store.locked(() => { const lines = action(); return { lines: Array.isArray(lines) ? lines : [lines], problems: [] }; }); }
170
- catch (error) { return { lines: [], problems: [error instanceof Error ? error.message : 'Credential operation failed.'] }; }
195
+ /**
196
+ * Runs one credential operation as the only authswitch operation on this harness's store. An operation that reads
197
+ * or writes the native login (`nativeArg`) also runs inside the harness's native transaction.
198
+ */
199
+ private async outcome(actionArg: () => string | string[], nativeArg: boolean): Promise<IHarnessOutcome> {
200
+ const run = (): string[] => [actionArg()].flat();
201
+ try {
202
+ const lines = await this.store.locked(() => nativeArg ? this.nativeTransaction(run) : run());
203
+ return { lines, problems: [] };
204
+ } catch (error) { return { lines: [], problems: [error instanceof Error ? error.message : 'Credential operation failed.'] }; }
171
205
  }
172
- public saveCurrent(options: { keepActive: boolean; accountId?: string }): IHarnessOutcome {
206
+ public saveCurrent(options: { keepActive: boolean; accountId?: string }): Promise<IHarnessOutcome> {
173
207
  return this.outcome(() => {
174
208
  const snapshot = this.stableSnapshot();
175
209
  if (snapshot.unavailable) throw new Error(snapshot.unavailable);
@@ -182,10 +216,13 @@ export abstract class FileHarness implements IAuthHarness {
182
216
  const cleared: string[] = [];
183
217
  try { this.store.clearSwitch(active.slotId); }
184
218
  catch { cleared.push('The login was cleared, but its switch record could not be removed.'); }
185
- return [`Saved ${plainText(active.label)} (${plainText(active.slotId)}). Start ${this.label} to log in again.`, ...cleared, ...this.runningCaveat('cleared')];
186
- });
219
+ return [this.liveSwap
220
+ ? `Saved ${plainText(active.label)} (${plainText(active.slotId)}) and cleared the active login. ${this.loginHint}`
221
+ : `Saved ${plainText(active.label)} (${plainText(active.slotId)}). Start ${this.label} to log in again.`,
222
+ ...this.liveSwapNote('cleared'), ...cleared, ...this.runningCaveat('cleared')];
223
+ }, true);
187
224
  }
188
- public switchAccount(id: string): IHarnessOutcome {
225
+ public switchAccount(id: string): Promise<IHarnessOutcome> {
189
226
  return this.outcome(() => {
190
227
  const target = this.saved(this.store.read(id));
191
228
  const snapshot = this.snapshot();
@@ -198,12 +235,13 @@ export abstract class FileHarness implements IAuthHarness {
198
235
  return [`${plainText(target.label)} is already active; its latest credential is saved.`, ...this.recordActivation(target.slotId, id)];
199
236
  }
200
237
  this.apply(target.slotId, previous, target);
201
- return [`Activated ${plainText(target.label)} (${plainText(target.slotId)}). Start ${this.label} to use it.`,
202
- ...this.recordActivation(target.slotId, id), ...this.runningCaveat('replaced')];
203
- });
238
+ const activated = `Activated ${plainText(target.label)} (${plainText(target.slotId)}).`;
239
+ return [this.liveSwap ? activated : `${activated} Start ${this.label} to use it.`,
240
+ ...this.liveSwapNote('replaced'), ...this.recordActivation(target.slotId, id), ...this.runningCaveat('replaced')];
241
+ }, true);
204
242
  }
205
- public removeAccount(id: string): IHarnessOutcome {
206
- return this.outcome(() => { const account = this.store.read(id); this.store.remove(id); return `Removed saved login for ${plainText(account.label)}; the active login is preserved.`; });
243
+ public removeAccount(id: string): Promise<IHarnessOutcome> {
244
+ return this.outcome(() => { const account = this.store.read(id); this.store.remove(id); return `Removed saved login for ${plainText(account.label)}; the active login is preserved.`; }, false);
207
245
  }
208
246
  public diagnose(): IHarnessOutcome {
209
247
  try {
@@ -1,15 +1,25 @@
1
1
  import * as plugins from './plugins.js';
2
- import { compactSince, compactUntil, credentialDriftNote, orderedUsageWindows } from './accounts.js';
3
- import { consoleHeading, consoleTable } from './classes.accountlist.js';
2
+ import { accountTypeText, compactSince, compactUntil, credentialDriftNote, localDate, orderedUsageWindows, usagePercentText } from './accounts.js';
3
+ import { consoleHeading, consoleNote, consoleTable } from './consoletable.js';
4
4
  import type { IAccountList, IAccountLimitRow, IAccountLimits, IActiveAccountDrift, IActiveAccountRow, IActiveAccounts, IHarnessAccountList } from './interfaces.list.js';
5
- import type { IHarnessCredentialDrift, TUsageSeverity } from './interfaces.harness.js';
5
+ import type { IAccountType, IAuthHarness, IHarnessBilling, IHarnessCredentialDrift, TUsageSeverity } from './interfaces.harness.js';
6
6
  import { dim, plainText } from './formatting.js';
7
7
 
8
8
  type TListedAccount = IHarnessAccountList['accounts'][number];
9
9
 
10
- const UNAVAILABLE = 'n/a';
10
+ export const UNAVAILABLE = 'n/a';
11
11
  const NONE = 'none';
12
12
 
13
+ /** What the projections need to know about a harness beyond its account list. */
14
+ export type THarnessTraits = Pick<IAuthHarness, 'id' | 'liveSwap' | 'renewalUnavailableReason'>;
15
+
16
+ /** The declared traits of a listed harness; a list is always projected with the harnesses it was read from. */
17
+ const traitsOf = (harnessesArg: readonly THarnessTraits[], harnessArg: IHarnessAccountList): THarnessTraits => {
18
+ const traits = harnessesArg.find(harness => harness.id === harnessArg.id);
19
+ if (traits === undefined) throw new Error(`The account list names harness ${plainText(harnessArg.id)}, which was not passed with it.`);
20
+ return traits;
21
+ };
22
+
13
23
  /**
14
24
  * Provider naming stays data-driven.
15
25
  *
@@ -46,6 +56,47 @@ const accountKeys = (harnessArg: IHarnessAccountList, accountArg: TListedAccount
46
56
  ...(accountArg.slotId === undefined ? {} : { slotId: plainText(accountArg.slotId) }),
47
57
  });
48
58
 
59
+ /** The account's plan, sanitised like every other label and copied so that no two rows share one object. */
60
+ export const accountType = (accountArg: TListedAccount): IAccountType | null => {
61
+ const subscription = accountArg.status.summary?.subscription;
62
+ return subscription === undefined ? null : { plan: plainText(subscription.plan), source: subscription.source };
63
+ };
64
+
65
+ /** The account's reported billing fields, sanitised and copied per row; null when the provider reported none. */
66
+ export const accountBilling = (accountArg: TListedAccount): IHarnessBilling | null => {
67
+ const billing = accountArg.status.summary?.billing;
68
+ if (billing === undefined) return null;
69
+ const date = (valueArg: string | undefined) => valueArg === undefined ? undefined : plainText(valueArg);
70
+ return Object.fromEntries(Object.entries({
71
+ hasActiveSubscription: billing.hasActiveSubscription, autoRenew: billing.autoRenew,
72
+ renewsAt: date(billing.renewsAt), cancelsAt: date(billing.cancelsAt), expiresAt: date(billing.expiresAt),
73
+ }).filter(([, value]) => value !== undefined)) as IHarnessBilling;
74
+ };
75
+
76
+ /** The next billing event a provider reported: a renewal it will charge, or the end of a subscription it will not renew. */
77
+ export interface IRenewal {
78
+ at: string;
79
+ kind: 'auto-renews' | 'cancels';
80
+ }
81
+
82
+ /** Read from the reported fields only; a missing or unparseable date is no event, never an inferred one. */
83
+ export const nextRenewal = (billingArg: IHarnessBilling | null): IRenewal | null => {
84
+ if (billingArg === null) return null;
85
+ const known = (valueArg: string | undefined): valueArg is string => valueArg !== undefined && Number.isFinite(Date.parse(valueArg));
86
+ if (billingArg.autoRenew === true && known(billingArg.renewsAt)) return { at: billingArg.renewsAt, kind: 'auto-renews' };
87
+ if (known(billingArg.cancelsAt)) return { at: billingArg.cancelsAt, kind: 'cancels' };
88
+ if (billingArg.autoRenew === false && known(billingArg.expiresAt)) return { at: billingArg.expiresAt, kind: 'cancels' };
89
+ return null;
90
+ };
91
+
92
+ /** The Renewal cell: the local date, and beneath it whether the subscription renews or ends then; a dim `n/a` otherwise. */
93
+ export const renewalCell = (billingArg: IHarnessBilling | null): plugins.smartconsole.TText => {
94
+ const renewal = nextRenewal(billingArg);
95
+ return renewal === null ? plugins.smartconsole.color.text(UNAVAILABLE, { dim: true })
96
+ : plugins.smartconsole.color.concat(`${localDate(renewal.at)}\n`,
97
+ plugins.smartconsole.color.text(renewal.kind, renewal.kind === 'cancels' ? { foreground: 'orange' } : { dim: true }));
98
+ };
99
+
49
100
  const harnessProblem = (harnessArg: IHarnessAccountList): string | null =>
50
101
  harnessArg.problems.length ? harnessArg.problems.map(plainText).join(' ') : null;
51
102
 
@@ -76,6 +127,9 @@ const byProviderThenAccount = <TRow extends TCondensedRow>(rowsArg: TRow[]): TRo
76
127
  /** One colour line per harness: an OpenCode installation's slots share it. */
77
128
  const harnessRuns = (rowArg: TCondensedRow): string => rowArg.harnessId;
78
129
 
130
+ /** One Provider cell per provider name within a harness; two harnesses that share a label stay apart. */
131
+ const providerRuns = (rowArg: TCondensedRow): string => JSON.stringify([rowArg.harnessId, rowArg.provider]);
132
+
79
133
  /** One divider run per account identity; a provider-level row without an account is its own run. */
80
134
  const accountRuns = (rowArg: TCondensedRow): string => JSON.stringify([rowArg.harnessId, rowArg.slotId ?? null, rowArg.accountId]);
81
135
 
@@ -87,44 +141,49 @@ const collectNotes = (rowsArg: { provider: string; account: string | null; unava
87
141
  [...new Set(rowsArg.map(noteFor).filter((note): note is string => note !== null))];
88
142
 
89
143
  /** The drift a switch record proves, carried into the row document with both accounts named. */
90
- const activeDrift = (harnessArg: IHarnessAccountList, driftArg: IHarnessCredentialDrift): IActiveAccountDrift => ({
144
+ const activeDrift = (traitsArg: THarnessTraits, driftArg: IHarnessCredentialDrift): IActiveAccountDrift => ({
91
145
  expectedAccountId: driftArg.expectedAccountId, expectedAccount: driftArg.expectedLabel === null ? null : plainText(driftArg.expectedLabel),
92
146
  currentAccountId: driftArg.currentAccountId, currentAccount: driftArg.currentLabel === null ? null : plainText(driftArg.currentLabel),
93
- currentIsSaved: driftArg.currentIsSaved, switchedAt: driftArg.switchedAt, reason: credentialDriftNote(harnessArg.id, driftArg),
147
+ currentIsSaved: driftArg.currentIsSaved, switchedAt: driftArg.switchedAt, reason: credentialDriftNote(traitsArg, driftArg),
94
148
  });
95
149
 
96
150
  /**
97
- * One row per account and limit type, projected from the account list.
151
+ * One row per account and limit type, projected from the account list and the harnesses it was read from.
98
152
  *
99
- * Nothing here talks to a provider: the list is already the read-only, per-account-isolated lookup,
100
- * so a single failing account costs its own rows a number and nothing else.
153
+ * Nothing here talks to a provider: the list is already the per-account-isolated lookup, so a single
154
+ * failing account costs its own rows a number and nothing else. A harness that declares its provider
155
+ * never reports renewal dates gets one note instead of an unexplained `n/a` per account.
101
156
  */
102
- export const accountLimits = (listArg: IAccountList): IAccountLimits => {
157
+ export const accountLimits = (listArg: IAccountList, harnessesArg: readonly THarnessTraits[]): IAccountLimits => {
103
158
  const now = Date.parse(listArg.generatedAt);
104
159
  const rows: IAccountLimitRow[] = [];
160
+ const renewalNotes: string[] = [];
105
161
  for (const harness of listArg.harnesses) {
106
162
  const problem = harnessProblem(harness);
107
163
  const empty = {
108
164
  limitType: null, scope: null, windowSeconds: null, usedPercent: null, resetAt: null, resetsIn: UNAVAILABLE, severity: null, headline: false,
109
165
  } as const;
110
166
  if (problem !== null) {
111
- rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, ...empty, unavailableReason: problem });
167
+ rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, accountType: null, billing: null, ...empty, unavailableReason: problem });
112
168
  continue;
113
169
  }
114
170
  if (!harness.accounts.length) {
115
- rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, ...empty,
171
+ rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, accountType: null, billing: null, ...empty,
116
172
  unavailableReason: harness.saveUnavailableReason === null ? 'No accounts are known for this harness.' : plainText(harness.saveUnavailableReason) });
117
173
  continue;
118
174
  }
175
+ const renewalUnavailable = traitsOf(harnessesArg, harness).renewalUnavailableReason;
176
+ if (renewalUnavailable !== undefined) renewalNotes.push(`${plainText(harness.label)}: ${plainText(renewalUnavailable)}`);
119
177
  for (const account of harness.accounts) {
178
+ const identity = () => ({ ...accountKeys(harness, account), isActive: account.isActive, accountType: accountType(account), billing: accountBilling(account) });
120
179
  const windows = orderedUsageWindows(account.status.summary?.usageWindows);
121
180
  if (!windows.length) {
122
- rows.push({ ...accountKeys(harness, account), isActive: account.isActive, ...empty, unavailableReason: missingLimitReason(account) });
181
+ rows.push({ ...identity(), ...empty, unavailableReason: missingLimitReason(account) });
123
182
  continue;
124
183
  }
125
184
  for (const window of windows) {
126
185
  rows.push({
127
- ...accountKeys(harness, account), isActive: account.isActive,
186
+ ...identity(),
128
187
  limitType: plainText(window.label), scope: window.scope ?? 'account', windowSeconds: window.durationSeconds,
129
188
  usedPercent: window.usedPercent, resetAt: window.resetAt, resetsIn: compactUntil(window.resetAt, now, UNAVAILABLE),
130
189
  severity: window.severity ?? null, headline: window.headline === true, unavailableReason: null,
@@ -133,7 +192,7 @@ export const accountLimits = (listArg: IAccountList): IAccountLimits => {
133
192
  }
134
193
  }
135
194
  const limits = byProviderThenAccount(rows);
136
- return { schemaVersion: 1, generatedAt: listArg.generatedAt, complete: listArg.complete, limits, notes: collectNotes(limits) };
195
+ return { schemaVersion: 1, generatedAt: listArg.generatedAt, complete: listArg.complete, limits, notes: [...collectNotes(limits), ...renewalNotes] };
137
196
  };
138
197
 
139
198
  /**
@@ -142,36 +201,37 @@ export const accountLimits = (listArg: IAccountList): IAccountLimits => {
142
201
  * A harness with no active login still gets a row: "nothing is active here" is an answer, and
143
202
  * silently dropping the provider would read as "this provider does not exist".
144
203
  */
145
- export const activeAccounts = (listArg: IAccountList): IActiveAccounts => {
204
+ export const activeAccounts = (listArg: IAccountList, harnessesArg: readonly THarnessTraits[]): IActiveAccounts => {
146
205
  const now = Date.parse(listArg.generatedAt);
147
206
  const rows: IActiveAccountRow[] = [];
148
207
  const unattached: string[] = [];
149
208
  for (const harness of listArg.harnesses) {
209
+ const traits = traitsOf(harnessesArg, harness);
150
210
  const problem = harnessProblem(harness);
151
211
  const drifts = [...harness.credentialDrift];
152
212
  const driftFor = (slotIdArg?: string): IActiveAccountDrift | null => {
153
213
  const index = drifts.findIndex(item => item.slotId === slotIdArg);
154
- return index < 0 ? null : activeDrift(harness, drifts.splice(index, 1)[0]);
214
+ return index < 0 ? null : activeDrift(traits, drifts.splice(index, 1)[0]);
155
215
  };
156
216
  if (problem !== null) {
157
- rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, savedAt: null, savedAgo: UNAVAILABLE, source: 'none', unavailableReason: problem, drift: null });
217
+ rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, accountType: null, savedAt: null, savedAgo: UNAVAILABLE, source: 'none', unavailableReason: problem, drift: null });
158
218
  continue;
159
219
  }
160
220
  const active = harness.accounts.filter(account => account.isActive);
161
221
  if (!active.length) {
162
222
  const stashed = harness.accounts.filter(account => account.isStashed);
163
223
  rows.push({
164
- harnessId: harness.id, provider: providerName(harness), account: null, accountId: null,
224
+ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, accountType: null,
165
225
  savedAt: null, savedAgo: UNAVAILABLE, source: stashed.length ? 'stash only' : 'none',
166
226
  unavailableReason: harness.saveUnavailableReason !== null ? plainText(harness.saveUnavailableReason)
167
227
  : stashed.length ? `No login is active; ${stashed.length} saved account(s) can be activated with authswitch ${plainText(harness.id)} use.`
168
228
  : `No login is active and none is saved. ${plainText(harness.loginHint)}`,
169
- drift: drifts.length ? activeDrift(harness, drifts.shift()!) : null,
229
+ drift: drifts.length ? activeDrift(traits, drifts.shift()!) : null,
170
230
  });
171
231
  } else {
172
232
  for (const account of active) {
173
233
  rows.push({
174
- ...accountKeys(harness, account),
234
+ ...accountKeys(harness, account), accountType: accountType(account),
175
235
  savedAt: account.savedAt, savedAgo: compactSince(account.savedAt, now, UNAVAILABLE),
176
236
  source: 'credential file',
177
237
  unavailableReason: account.isStashed ? null : 'This active login is not saved yet; authswitch cannot restore it after a switch.',
@@ -180,7 +240,7 @@ export const activeAccounts = (listArg: IAccountList): IActiveAccounts => {
180
240
  }
181
241
  }
182
242
  // A drifted slot with no row of its own still has to be reported, not dropped.
183
- for (const drift of drifts) unattached.push(`${providerName(harness)}${drift.slotId === undefined ? '' : ` / ${plainText(drift.slotId)}`}: ${credentialDriftNote(harness.id, drift)}`);
243
+ for (const drift of drifts) unattached.push(`${providerName(harness)}${drift.slotId === undefined ? '' : ` / ${plainText(drift.slotId)}`}: ${credentialDriftNote(traits, drift)}`);
184
244
  }
185
245
  const active = byProviderThenAccount(rows);
186
246
  const driftNotes = active.filter(row => row.drift !== null)
@@ -188,37 +248,70 @@ export const activeAccounts = (listArg: IAccountList): IActiveAccounts => {
188
248
  return { schemaVersion: 1, generatedAt: listArg.generatedAt, complete: listArg.complete, active, notes: [...collectNotes(active), ...driftNotes, ...unattached] };
189
249
  };
190
250
 
191
- const percent = (rowArg: IAccountLimitRow): string => rowArg.usedPercent === null ? UNAVAILABLE : `${rowArg.usedPercent}%`;
251
+ /**
252
+ * The Account cell: the label, and the account type beneath it, dim. An unknown type keeps its line as
253
+ * `n/a`, so every account cell has the same shape; a provider row without an account has no type to name.
254
+ * A view without a Provider column names the credential slot in the type line (`openai · Pro`) when the
255
+ * harness owns several.
256
+ */
257
+ export const accountCell = (rowArg: Pick<IAccountLimitRow, 'account' | 'accountType' | 'slotId'>, slotsArg: boolean): plugins.smartconsole.TText => {
258
+ if (rowArg.account === null) return UNAVAILABLE;
259
+ const type = [slotsArg ? rowArg.slotId : undefined, accountTypeText(rowArg.accountType) ?? undefined].filter(part => part !== undefined).join(' · ');
260
+ return plugins.smartconsole.color.concat(`${rowArg.account}\n`, plugins.smartconsole.color.text(type || UNAVAILABLE, { dim: true, bold: false }));
261
+ };
262
+
263
+ const percent = (rowArg: IAccountLimitRow): string => rowArg.usedPercent === null ? UNAVAILABLE : usagePercentText(rowArg.usedPercent);
192
264
 
193
265
  const SEVERITY_COLORS: Record<TUsageSeverity, plugins.smartconsole.TColorName | undefined> = { normal: undefined, warning: 'orange', critical: 'red' };
194
266
 
195
267
  /** The provider's own reading colours a window; without one, n/a and exhausted windows stand out. */
196
- const percentColor = (rowArg: IAccountLimitRow): plugins.smartconsole.TColorName | undefined =>
197
- rowArg.severity !== null ? SEVERITY_COLORS[rowArg.severity]
198
- : rowArg.usedPercent === null ? 'orange' : rowArg.usedPercent >= 100 ? 'red' : undefined;
268
+ export const usageColor = (severityArg: TUsageSeverity | null, usedPercentArg: number | null): plugins.smartconsole.TColorName | undefined =>
269
+ severityArg !== null ? SEVERITY_COLORS[severityArg]
270
+ : usedPercentArg === null ? 'orange' : usedPercentArg >= 100 ? 'red' : undefined;
271
+ const percentColor = (rowArg: IAccountLimitRow): plugins.smartconsole.TColorName | undefined => usageColor(rowArg.severity, rowArg.usedPercent);
272
+
273
+ /** Which parts of the limits table a view shows. */
274
+ export interface ILimitsTableOptions {
275
+ /** The Provider column and the per-harness colour line, for a view across harnesses. */
276
+ provider: boolean;
277
+ renewal: boolean;
278
+ /** Whether the harness owns several credential slots, named in the account cell when there is no Provider column. */
279
+ slots: boolean;
280
+ }
199
281
 
200
- /** Condensed presentation for `authswitch limits` and `authswitch active`. */
282
+ /** Condensed presentation for `authswitch limits` and `authswitch active`, and the limits table other views embed. */
201
283
  export class CondensedRenderer {
202
284
  constructor(private readonly out: plugins.smartconsole.SmartConsole) {}
203
285
 
204
286
  private notes(notesArg: string[], prefixArg: string): void {
205
287
  if (!notesArg.length) return;
206
288
  process.stdout.write('\n');
207
- for (const note of notesArg) process.stdout.write(`${dim(`${prefixArg}${note}`)}\n`);
289
+ for (const note of notesArg) consoleNote(note, prefixArg);
208
290
  }
209
291
 
210
292
  public async renderLimits(limitsArg: IAccountLimits): Promise<void> {
211
293
  consoleHeading('Account limits');
212
294
  if (!limitsArg.limits.length) { process.stdout.write(`${dim('No harnesses registered.')}\n`); return; }
213
- await consoleTable(this.out, limitsArg.limits, [
214
- { key: 'provider', title: 'Provider', value: row => row.provider },
215
- { key: 'account', title: 'Account', value: row => row.account ?? UNAVAILABLE, style: row => ({ bold: row.isActive }) },
295
+ await this.limitsTable(limitsArg.limits, { provider: true, renewal: true, slots: false });
296
+ this.notes(limitsArg.notes, 'n/a — ');
297
+ }
298
+
299
+ /** One row per account and limit type; `rowsArg` must be ordered by `byProviderThenAccount` and is rendered as it is. */
300
+ public async limitsTable(rowsArg: IAccountLimitRow[], optionsArg: ILimitsTableOptions): Promise<void> {
301
+ // The span callbacks record these exact rows, so the table renders this array as it is.
302
+ const accountSpan = plugins.smartconsole.rowSpansBy(rowsArg, accountRuns);
303
+ const columns: plugins.smartconsole.IBackendTableColumn<IAccountLimitRow>[] = [
304
+ ...(optionsArg.provider ? [{ key: 'provider', title: 'Provider', value: (row: IAccountLimitRow) => row.provider, rowSpan: plugins.smartconsole.rowSpansBy(rowsArg, providerRuns) }] : []),
305
+ { key: 'account', title: 'Account', value: row => row.account ?? UNAVAILABLE, render: row => accountCell(row, optionsArg.slots),
306
+ style: row => ({ bold: row.isActive }), rowSpan: accountSpan },
216
307
  { key: 'limit', title: 'Limit type', value: row => row.limitType ?? UNAVAILABLE },
217
308
  { key: 'used', title: 'Used %', value: percent, align: 'right',
218
309
  style: row => ({ foreground: percentColor(row) }) },
219
310
  { key: 'reset', title: 'Resets in', value: row => row.resetsIn, align: 'right' },
220
- ], { divider: accountRuns, colorLine: harnessRuns });
221
- this.notes(limitsArg.notes, 'n/a — ');
311
+ ...(optionsArg.renewal ? [{ key: 'renewal', title: 'Renewal', value: (row: IAccountLimitRow) => nextRenewal(row.billing)?.at ?? UNAVAILABLE,
312
+ render: (row: IAccountLimitRow) => renewalCell(row.billing), rowSpan: accountSpan }] : []),
313
+ ];
314
+ await consoleTable(this.out, rowsArg, columns, optionsArg.provider ? { divider: accountRuns, colorLine: harnessRuns } : { divider: accountRuns });
222
315
  }
223
316
 
224
317
  public async renderActive(activeArg: IActiveAccounts): Promise<void> {