@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
@@ -2,7 +2,7 @@
2
2
  * autocreated commitinfo by @push.rocks/commitinfo
3
3
  */
4
4
  export const commitinfo = {
5
- name: '@modelprofile.com/authswitch',
6
- version: '3.2.0',
7
- description: 'Manage Codex, OpenCode and Claude Code accounts with guided switching and live usage status'
5
+ name: "@modelprofile.com/authswitch",
6
+ version: "4.0.0",
7
+ description: "Manage Codex, OpenCode and Claude Code accounts with guided switching and live usage status"
8
8
  }
package/ts/accounts.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { IAuthHarness, IHarnessAccount, IHarnessAccountStatus, IHarnessCredentialDrift, IHarnessState, IHarnessUsageWindow } from './interfaces.harness.js';
1
+ import type { IAccountType, IAuthHarness, IHarnessAccount, IHarnessAccountStatus, IHarnessCredentialDrift, IHarnessState, IHarnessStatusOptions, IHarnessUsageWindow } from './interfaces.harness.js';
2
2
  import { plainText } from './formatting.js';
3
3
 
4
4
  export interface IAccountRow {
@@ -6,7 +6,11 @@ export interface IAccountRow {
6
6
  account: IHarnessAccount;
7
7
  status?: IHarnessAccountStatus;
8
8
  }
9
+ /** A provider window length or a whole-minute bound: `5h`, `7d`, `2m`. */
9
10
  export const duration = (secondsArg: number): string => secondsArg % 86400 === 0 ? `${secondsArg / 86400}d` : secondsArg % 3600 === 0 ? `${secondsArg / 3600}h` : `${secondsArg / 60}m`;
11
+ /** The same for a chosen duration that need not be whole minutes: `90s` reads `1m 30s`, never `1.5m`. */
12
+ export const durationText = (secondsArg: number): string => secondsArg % 60 === 0 ? duration(secondsArg)
13
+ : secondsArg < 60 ? `${secondsArg}s` : `${Math.floor(secondsArg / 60)}m ${secondsArg % 60}s`;
10
14
  /** A countdown is a snapshot of the provider's deadline, not proof a quota has reset. */
11
15
  export const until = (timestampArg: string | null, nowArg: number): string => {
12
16
  if (timestampArg === null) return 'Not scheduled';
@@ -54,34 +58,110 @@ export const compactSince = (timestampArg: string | null, nowArg: number, unavai
54
58
  if (!Number.isFinite(elapsed)) return unavailableArg;
55
59
  return elapsed < 60000 ? '<1m' : twoUnits(elapsed);
56
60
  };
61
+ const twoDigits = (valueArg: number): string => String(valueArg).padStart(2, '0');
62
+ /**
63
+ * A timestamp in local calendar time: `2026-10-08`, or `2026-10-08 23:14` with the time. Human output never prints
64
+ * a raw ISO timestamp; an unknown or unparseable one reads as the caller's unavailable marker.
65
+ */
66
+ const localTimestamp = (timestampArg: string | null, withTimeArg: boolean, unavailableArg: string): string => {
67
+ const time = timestampArg === null ? Number.NaN : Date.parse(timestampArg);
68
+ if (!Number.isFinite(time)) return unavailableArg;
69
+ const date = new Date(time);
70
+ const day = `${date.getFullYear()}-${twoDigits(date.getMonth() + 1)}-${twoDigits(date.getDate())}`;
71
+ return withTimeArg ? `${day} ${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}` : day;
72
+ };
73
+ export const localDate = (timestampArg: string | null, unavailableArg = 'n/a'): string => localTimestamp(timestampArg, false, unavailableArg);
74
+ export const localDateTime = (timestampArg: string | null, unavailableArg = 'n/a'): string => localTimestamp(timestampArg, true, unavailableArg);
75
+ /** A moment's local time of day with seconds, `14:02:07`, for a log line whose date is implied. */
76
+ export const localTime = (timeArg: number): string => {
77
+ const date = new Date(timeArg);
78
+ return [date.getHours(), date.getMinutes(), date.getSeconds()].map(twoDigits).join(':');
79
+ };
80
+ const ISO_TIMESTAMP = /\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})/g;
81
+ /** Provider text with every ISO 8601 timestamp in it rewritten as local date and time. */
82
+ export const localiseTimestamps = (textArg: string): string => textArg.replace(ISO_TIMESTAMP, match => localDateTime(match, match));
83
+
57
84
  /**
58
85
  * One sentence for a credential file that no longer holds the account the last switch wrote.
59
86
  *
60
- * It names what the file holds now and how to put the intended account back, because the usual
61
- * cause -- a harness instance that kept running through the switch and then refreshed its
62
- * in-memory login -- leaves no other trace.
87
+ * It names what the file holds now and how to keep or undo that. A harness that picks up swaps live is only ever
88
+ * changed by a new login; one that does not can also have a running instance write its previous login back.
63
89
  */
64
- export const credentialDriftNote = (harnessIdArg: string, driftArg: IHarnessCredentialDrift): string =>
65
- 'The credential file changed since the last switch (likely a running instance refreshed the previous login). '
66
- + (driftArg.currentLabel === null ? 'No login is active in that slot now. '
67
- : `It now holds ${plainText(driftArg.currentLabel)}${driftArg.currentIsSaved ? ', which is saved' : ', which is not saved'}. `)
68
- + (driftArg.expectedLabel === null ? 'Switch to the intended account again.'
69
- : `Run authswitch ${plainText(harnessIdArg)} use ${plainText(driftArg.expectedLabel)} again.`);
90
+ export const credentialDriftNote = (harnessArg: Pick<IAuthHarness, 'id' | 'liveSwap'>, driftArg: IHarnessCredentialDrift): string => {
91
+ const id = plainText(harnessArg.id);
92
+ const cause = harnessArg.liveSwap === true ? 'for example a new login' : 'for example a new login, or a running instance that wrote its previous login back';
93
+ const now = driftArg.currentLabel === null ? 'No login is active in that slot now'
94
+ : `It now holds ${plainText(driftArg.currentLabel)}, which is ${driftArg.currentIsSaved ? '' : 'not '}saved`;
95
+ const keep = driftArg.currentLabel !== null && !driftArg.currentIsSaved ? `authswitch ${id} stash --keep saves it, and ` : '';
96
+ const back = driftArg.expectedLabel === null ? 'switching to the intended account restores it' : `authswitch ${id} use ${plainText(driftArg.expectedLabel)} switches back`;
97
+ return `The active login changed outside authswitch since the last switch (${cause}). ${now}; ${keep}${back}.`;
98
+ };
99
+ /**
100
+ * The same finding as one table cell: which account was expected, what the slot holds now, and then every action
101
+ * that resolves it, one per line -- the same actions the sentence names, so the two views advise the same thing in a
102
+ * cell that has to stay narrow.
103
+ */
104
+ export const credentialDriftProblem = (harnessIdArg: string, driftArg: IHarnessCredentialDrift): string => {
105
+ const id = plainText(harnessIdArg);
106
+ const expected = driftArg.expectedLabel === null ? 'a login that is no longer saved' : plainText(driftArg.expectedLabel);
107
+ const now = driftArg.currentLabel === null ? 'none' : `${plainText(driftArg.currentLabel)} (${driftArg.currentIsSaved ? 'saved' : 'not saved'})`;
108
+ const actions = [
109
+ ...(driftArg.currentLabel !== null && !driftArg.currentIsSaved ? [`authswitch ${id} stash --keep saves it`] : []),
110
+ driftArg.expectedLabel === null ? 'switching to the intended account restores it' : `authswitch ${id} use ${plainText(driftArg.expectedLabel)} switches back`,
111
+ ];
112
+ return [`Active login changed outside authswitch: expected ${expected},`, `now ${now}`, ...actions.map(action => `— ${action}`)].join('\n');
113
+ };
114
+
115
+ /** An account as prompts and reports name it: its label, qualified by its credential slot when it has one. */
116
+ export const accountName = (accountArg: Pick<IHarnessAccount, 'label' | 'slotId'>): string =>
117
+ `${plainText(accountArg.label)}${accountArg.slotId ? ` (${plainText(accountArg.slotId)})` : ''}`;
70
118
 
71
- export const accountPlan = (rowArg: IAccountRow): string => {
72
- const subscription = rowArg.status?.summary?.subscription;
73
- return subscription ? `${plainText(subscription.plan)}${subscription.source === 'stored' ? ' (stored; unverified)' : ' (live)'}` : rowArg.status ? 'Unavailable' : 'Loading';
119
+ /**
120
+ * How every view marks a plan that comes from the saved login because the provider did not confirm it: the
121
+ * word both earlier plan cells used, kept short so the type line of a limits cell does not wrap.
122
+ */
123
+ const STORED_PLAN_MARK = 'unverified';
124
+ /**
125
+ * How an account type reads in every view: the provider's plan name with its first letter capitalised
126
+ * (`max` reads `Max`), and a plan taken from the stored login marked `Plus, unverified`. The text holds no
127
+ * parentheses, so it reads the same inside a badge part. Nothing is invented: an unknown type is null, and
128
+ * each view decides how to show that.
129
+ */
130
+ export const accountTypeText = (typeArg: IAccountType | null | undefined): string | null => {
131
+ if (!typeArg) return null;
132
+ const plan = plainText(typeArg.plan);
133
+ return `${plan.charAt(0).toUpperCase()}${plan.slice(1)}${typeArg.source === 'stored' ? `, ${STORED_PLAN_MARK}` : ''}`;
74
134
  };
135
+ export const accountPlan = (rowArg: IAccountRow): string =>
136
+ accountTypeText(rowArg.status?.summary?.subscription) ?? (rowArg.status ? 'Unavailable' : 'Loading');
75
137
  type TAccountStatusRow = Pick<IAccountRow, 'status'>;
76
138
  /**
77
- * How a single-value summary names a window among the windows it shows. An account window is named by
78
- * its period while it is the only shown account window of that period; a feature window, or an account
79
- * window sharing its period with another, is named by its own label, so no meter reads as another's.
139
+ * Whether a window among the shown ones is named by its label. An account window is named by its period while it is
140
+ * the only shown account window of that period; a feature window, or an account window sharing its period with
141
+ * another, is named by its own label, so no meter reads as another's.
142
+ */
143
+ const namedByLabel = (windowArg: IHarnessUsageWindow, shownArg: readonly IHarnessUsageWindow[]): boolean =>
144
+ windowArg.scope === 'feature' || shownArg.some(other => other !== windowArg && other.scope !== 'feature' && other.durationSeconds === windowArg.durationSeconds);
145
+ const periodName = (secondsArg: number): string => secondsArg === 604800 ? 'weekly' : duration(secondsArg);
146
+ /** How a single-value summary names a window among the windows it shows: `Weekly`, `5h`, or its label. */
147
+ export const usageWindowName = (windowArg: IHarnessUsageWindow, shownArg: readonly IHarnessUsageWindow[]): string => {
148
+ if (namedByLabel(windowArg, shownArg)) return plainText(windowArg.label);
149
+ const period = periodName(windowArg.durationSeconds);
150
+ return `${period.charAt(0).toUpperCase()}${period.slice(1)}`;
151
+ };
152
+ /**
153
+ * How every view prints a provider's usage reading: the nearest whole percent, except that a window the provider
154
+ * reports below 100% never reads as `100%` -- that is the one reading acted on as exhausted, and a window with
155
+ * capacity left must not claim it. The tables, the switch badge and the watch share this text, so none of them
156
+ * shows a different number for the same window.
80
157
  */
81
- export const usageWindowName = (windowArg: IHarnessUsageWindow, shownArg: readonly IHarnessUsageWindow[]): string =>
82
- windowArg.scope === 'feature' || shownArg.some(other => other !== windowArg && other.scope !== 'feature' && other.durationSeconds === windowArg.durationSeconds)
83
- ? plainText(windowArg.label)
84
- : windowArg.durationSeconds === 604800 ? 'Weekly' : duration(windowArg.durationSeconds);
158
+ export const usagePercentText = (usedPercentArg: number): string => {
159
+ const whole = Math.round(usedPercentArg);
160
+ return `${whole >= 100 && usedPercentArg < 100 ? 99 : whole}%`;
161
+ };
162
+ /** A shown window in one compact line: `weekly 4% · 5d 1h`, `Fable weekly 100% · 2d`. */
163
+ export const compactUsageLine = (windowArg: IHarnessUsageWindow, shownArg: readonly IHarnessUsageWindow[], nowArg: number): string =>
164
+ `${namedByLabel(windowArg, shownArg) ? plainText(windowArg.label) : periodName(windowArg.durationSeconds)} ${usagePercentText(windowArg.usedPercent)} · ${compactUntil(windowArg.resetAt, nowArg)}`;
85
165
  /** Order by scope and actual duration: a provider's primary slot need not be five hours. */
86
166
  export const orderedUsageWindows = (windowsArg: readonly IHarnessUsageWindow[] = []): IHarnessUsageWindow[] => [...windowsArg].sort((left, right) =>
87
167
  Number(left.scope === 'feature') - Number(right.scope === 'feature') ||
@@ -113,29 +193,117 @@ export const accountQuotaSummary = (rowArg: TAccountStatusRow, nowArg: number):
113
193
  ...windows.slice(1).filter(window => window.usedPercent >= 100).map(window => `${usageWindowName(window, windows)} exhausted\nResets in: ${until(window.resetAt, nowArg)}`),
114
194
  ] : [])].join('\n');
115
195
  };
196
+ /** The badge's usage meters: the general account windows a switch decision weighs, never a feature quota. */
197
+ const BADGE_WINDOWS = [['5h', 18000], ['w', 604800]] as const;
198
+ /**
199
+ * The account window a badge part reads: the only account window of that period, or the provider's headline
200
+ * pick among several. Any other choice would be a guess, so the part is left out and no meter reads as another's.
201
+ */
202
+ const badgeWindow = (windowsArg: readonly IHarnessUsageWindow[], secondsArg: number): IHarnessUsageWindow | undefined => {
203
+ const matches = windowsArg.filter(window => window.scope !== 'feature' && window.durationSeconds === secondsArg);
204
+ const candidates = matches.length > 1 ? matches.filter(window => window.headline === true) : matches;
205
+ return candidates.length === 1 ? candidates[0] : undefined;
206
+ };
207
+ /**
208
+ * What a switch activates, in one line: ` (type:Max) (5h:63%) (w:88%)`, appended to the account label.
209
+ *
210
+ * Each part is the provider's own reading as a whole percent, and is omitted when it was not
211
+ * reported; with no part known the badge is empty, so a missing reading never reads as zero usage.
212
+ */
213
+ export const accountBadge = (statusArg: IHarnessAccountStatus | undefined): string => {
214
+ const type = accountTypeText(statusArg?.summary?.subscription);
215
+ const windows = statusArg?.summary?.usageWindows ?? [];
216
+ return [
217
+ ...(type === null ? [] : [`type:${type}`]),
218
+ ...BADGE_WINDOWS.flatMap(([name, seconds]) => {
219
+ const window = badgeWindow(windows, seconds);
220
+ return window === undefined ? [] : [`${name}:${usagePercentText(window.usedPercent)}`];
221
+ }),
222
+ ].map(part => ` (${part})`).join('');
223
+ };
224
+ /** How many accounts' statuses are read at once, so a long account list never floods a provider. */
225
+ const STATUS_LOOKUP_CONCURRENCY = 4;
226
+ /**
227
+ * Bounded status lookups: at most four accounts at a time, started in order, each handed the caller's signal.
228
+ * A lookup that fails reports undefined; once the signal aborts, no further lookup starts. A callback that throws
229
+ * rejects the call, and no other lookup starts after it. No lookup activates, switches or clears a login, though an
230
+ * adapter may renew the tokens of the saved copy it reads (`IAuthHarness.readAccountStatus`).
231
+ */
232
+ const lookupStatuses = async (
233
+ harnessArg: IAuthHarness,
234
+ accountIdsArg: readonly string[],
235
+ optionsArg: IHarnessStatusOptions & { onStatus: (indexArg: number, statusArg: IHarnessAccountStatus | undefined) => void | Promise<void> },
236
+ ): Promise<void> => {
237
+ let next = 0;
238
+ let failed = false;
239
+ const worker = async (): Promise<void> => {
240
+ while (!failed && next < accountIdsArg.length && !optionsArg.signal?.aborted) {
241
+ const index = next++;
242
+ let status: IHarnessAccountStatus | undefined;
243
+ try { status = await harnessArg.readAccountStatus(accountIdsArg[index], { signal: optionsArg.signal, session: optionsArg.session }); }
244
+ catch { status = undefined; }
245
+ try { await optionsArg.onStatus(index, status); }
246
+ catch (error) { failed = true; throw error; }
247
+ }
248
+ };
249
+ await Promise.all(Array.from({ length: Math.min(STATUS_LOOKUP_CONCURRENCY, accountIdsArg.length) }, worker));
250
+ };
251
+ /** How long a switch waits for the badges of the accounts it offers or activates. */
252
+ const ACCOUNT_BADGE_TIMEOUT_MS = 2500;
253
+ /**
254
+ * The badge of every named account whose status answered before one shared deadline.
255
+ *
256
+ * Never fatal, and it never changes which account is in use: a switch must not wait on a provider past this deadline, nor fail because
257
+ * usage could not be read, so a lookup that fails or misses the deadline contributes no badge and the
258
+ * caller shows the label alone. At the deadline every lookup still running is cancelled, so no request
259
+ * outlives the call and keeps the command from exiting.
260
+ */
261
+ export const readAccountBadges = async (
262
+ harnessArg: IAuthHarness,
263
+ accountIdsArg: readonly string[],
264
+ timeoutMsArg = ACCOUNT_BADGE_TIMEOUT_MS,
265
+ ): Promise<Map<string, string>> => {
266
+ const badges = new Map<string, string>();
267
+ if (!accountIdsArg.length) return badges;
268
+ const cancel = new AbortController();
269
+ const lookups = lookupStatuses(harnessArg, accountIdsArg, { signal: cancel.signal, onStatus: (index, status) => {
270
+ if (status !== undefined && !cancel.signal.aborted) badges.set(accountIdsArg[index], accountBadge(status));
271
+ } });
272
+ // A referenced timer: the command must stay alive until the deadline even when a stalled lookup holds nothing open.
273
+ let timer: ReturnType<typeof setTimeout> | undefined;
274
+ const deadline = new Promise<void>(resolve => { timer = setTimeout(resolve, timeoutMsArg); });
275
+ try { await Promise.race([lookups, deadline]); }
276
+ finally { clearTimeout(timer); cancel.abort(); }
277
+ return badges;
278
+ };
116
279
  export const accountState = (rowArg: IAccountRow): string => `${rowArg.account.isActive ? '* active, ' : ''}${rowArg.account.isStashed ? 'saved' : 'not saved'}`;
117
280
  export const accountDetails = (rowArg: IAccountRow): string => [
118
281
  plainText(rowArg.account.label), accountState(rowArg),
119
- ...(rowArg.account.savedAt ? ['Saved: ' + plainText(rowArg.account.savedAt)] : []),
282
+ ...(rowArg.account.savedAt ? ['Saved: ' + localDateTime(rowArg.account.savedAt)] : []),
120
283
  ...rowArg.account.details.map(plainText),
121
- ...(rowArg.status?.facts.map(factArg => `${plainText(factArg.label)}: ${plainText(factArg.value)}`) ?? ['Loading account status…']),
284
+ ...(rowArg.status?.facts.map(factArg => `${plainText(factArg.label)}: ${localiseTimestamps(plainText(factArg.value))}`) ?? ['Loading account status…']),
122
285
  ...(rowArg.status?.problems.map(problemArg => `Unavailable: ${plainText(problemArg)}`) ?? []),
123
286
  ].join('\n');
124
287
 
125
- /** Read-only, bounded parallel status lookup. No account is switched to inspect it. */
126
- export const readAccountRows = async (
127
- harnessArg: IAuthHarness,
128
- optionsArg: { signal?: AbortSignal; onUpdate?: (rowsArg: IAccountRow[], stateArg: IHarnessState) => void | Promise<void> } = {},
129
- ): Promise<{ rows: IAccountRow[]; state: IHarnessState }> => {
288
+ export interface IAccountRowsOptions extends IHarnessStatusOptions {
289
+ onUpdate?: (rowsArg: IAccountRow[], stateArg: IHarnessState) => void | Promise<void>;
290
+ /** Which accounts' statuses to read; every account by default. A row that is not read keeps no status. */
291
+ lookup?: (accountArg: IHarnessAccount) => boolean;
292
+ }
293
+
294
+ /**
295
+ * Bounded parallel status lookup. No account is activated, switched or cleared to inspect it -- an adapter may renew
296
+ * the tokens of the saved copy it reads, and nothing else; the signal cancels lookups in flight, and a session lets
297
+ * the adapters reuse slow-changing lookups across repeated reads.
298
+ */
299
+ export const readAccountRows = async (harnessArg: IAuthHarness, optionsArg: IAccountRowsOptions = {}): Promise<{ rows: IAccountRow[]; state: IHarnessState }> => {
130
300
  const state = await harnessArg.readState();
131
301
  const rows = state.accounts.map(account => ({ harness: harnessArg, account } as IAccountRow));
302
+ const read = rows.filter(row => optionsArg.lookup?.(row.account) ?? true);
132
303
  await optionsArg.onUpdate?.(rows, state);
133
- for (let index = 0; index < rows.length && !optionsArg.signal?.aborted; index += 4) {
134
- await Promise.all(rows.slice(index, index + 4).map(async row => {
135
- try { row.status = await harnessArg.readAccountStatus(row.account.id); }
136
- catch { row.status = { facts: [], problems: ['Could not read account status.'] }; }
137
- if (!optionsArg.signal?.aborted) await optionsArg.onUpdate?.(rows, state);
138
- }));
139
- }
304
+ await lookupStatuses(harnessArg, read.map(row => row.account.id), { signal: optionsArg.signal, session: optionsArg.session, onStatus: async (index, status) => {
305
+ read[index].status = status ?? { facts: [], problems: ['Could not read account status.'] };
306
+ if (!optionsArg.signal?.aborted) await optionsArg.onUpdate?.(rows, state);
307
+ } });
140
308
  return { rows, state };
141
309
  };
@@ -1,12 +1,6 @@
1
- import * as plugins from './plugins.js';
2
- import { accountQuotaSummary, orderedUsageWindows, readAccountRows, duration, until } from './accounts.js';
3
- import type { IAuthHarness, IHarnessAccountStatus, IHarnessStatusFact } from './interfaces.harness.js';
1
+ import { readAccountRows } from './accounts.js';
2
+ import type { IAuthHarness, IHarnessAccountStatus } from './interfaces.harness.js';
4
3
  import type { IAccountList, IHarnessAccountList } from './interfaces.list.js';
5
- import { bold, dim, orange, plainText } from './formatting.js';
6
-
7
- type TListedAccount = IHarnessAccountList['accounts'][number];
8
- type TNamedAccount = TListedAccount & { name: string };
9
- interface IDisplayFact { account: number; label: string; value: string; }
10
4
 
11
5
  /** Allowlist the public data contract: never serialize a harness or its credential-bearing internals. */
12
6
  const publicStatus = (statusArg: IHarnessAccountStatus): IHarnessAccountStatus => {
@@ -50,211 +44,3 @@ export const readAccountList = async (harnessesArg: IAuthHarness[]): Promise<IAc
50
44
  harnesses,
51
45
  };
52
46
  };
53
-
54
- export const consoleWidth = (): number => process.stdout.columns ?? 100;
55
- export const consoleHeading = (textArg: string): void => { process.stdout.write(`\n${bold(textArg)}\n`); };
56
-
57
- /**
58
- * One table implementation for every command, including the label-per-line fallback on narrow terminals.
59
- *
60
- * `groupsArg` is smartconsole's optional visual grouping: a divider between runs of consecutive rows with
61
- * different keys, and a colour line along the left edge per run. Keys are identities, never display labels,
62
- * and the caller orders the rows so that every group is one run. Below 40 columns the fallback separates
63
- * divider runs with one dim rule line; it has no left edge, so it draws no colour line.
64
- */
65
- export const consoleTable = async <TRow>(
66
- outArg: plugins.smartconsole.SmartConsole,
67
- rowsArg: TRow[],
68
- columnsArg: plugins.smartconsole.IBackendTableColumn<TRow>[],
69
- groupsArg?: NoInfer<plugins.smartconsole.IBackendTableGroups<TRow>>,
70
- ): Promise<void> => {
71
- if (!rowsArg.length) return;
72
- const width = consoleWidth();
73
- if (width < 40) {
74
- // smartconsole's run semantics: null and undefined are the same key.
75
- const dividerKeys = rowsArg.map(row => groupsArg?.divider?.(row) ?? null);
76
- const rule = ` ${dim('─'.repeat(Math.max(1, width - 4)))}\n\n`;
77
- rowsArg.forEach((row, index) => {
78
- if (index > 0 && dividerKeys[index] !== dividerKeys[index - 1]) process.stdout.write(rule);
79
- process.stdout.write(columnsArg.map(column => ` ${column.title}: ${String(column.value(row) ?? 'Unavailable').replace(/\n/g, '\n ')}`).join('\n') + '\n\n');
80
- });
81
- return;
82
- }
83
- await outArg.table(rowsArg, {
84
- columns: columnsArg, overflow: 'wrap', groups: groupsArg,
85
- theme: { header: { bold: true, foreground: 'cyan' }, border: { dim: true } },
86
- });
87
- };
88
-
89
- /** List tables that repeat one account across rows divide it from the next account by its id. */
90
- const accountDividers: plugins.smartconsole.IBackendTableGroups<{ accountId: string | null }> = { divider: row => row.accountId };
91
-
92
- /**
93
- * Names every account of one harness by its label, numbering each later account that repeats a label
94
- * (`alice@example.com (2)`), so all tables name an account alike and the metric columns stay distinct.
95
- */
96
- const nameAccounts = (accountsArg: TListedAccount[]): TNamedAccount[] => {
97
- const labels = accountsArg.map(account => plainText(account.label));
98
- const taken = new Set(labels);
99
- return accountsArg.map((account, index) => {
100
- const label = labels[index];
101
- let name = label;
102
- if (labels.indexOf(label) < index) {
103
- for (let ordinal = 2; taken.has(name); ordinal++) name = `${label} (${ordinal})`;
104
- taken.add(name);
105
- }
106
- return { ...account, name };
107
- });
108
- };
109
-
110
- /** Shared, provider-independent list presentation. Adapters supply grouping metadata. */
111
- export class AccountListRenderer {
112
- constructor(private readonly out: plugins.smartconsole.SmartConsole) {}
113
- private get width(): number { return consoleWidth(); }
114
- private heading(textArg: string): void { consoleHeading(textArg); }
115
-
116
- public async render(listArg: IAccountList): Promise<void> {
117
- const now = Date.parse(listArg.generatedAt);
118
- if (!listArg.harnesses.length) process.stdout.write('No harnesses registered.\n');
119
- for (const harness of listArg.harnesses) {
120
- this.heading(plainText(harness.label));
121
- if (harness.problems.length) {
122
- for (const problem of harness.problems) process.stdout.write(`${orange('Unavailable')}: ${plainText(problem)}\n`);
123
- continue;
124
- }
125
- if (!harness.accounts.length) {
126
- process.stdout.write(`${dim(plainText(harness.saveUnavailableReason ?? 'No known accounts. ' + harness.loginHint))}\n`);
127
- continue;
128
- }
129
- const accounts = nameAccounts(harness.accounts);
130
- await this.overview(accounts, now);
131
- const schedules = accounts.flatMap(account => orderedUsageWindows(account.status.summary?.usageWindows).map(window => ({ accountId: account.id, account: account.name, window })));
132
- for (const feature of [false, true]) {
133
- const scopedSchedules = schedules.filter(row => (row.window.scope === 'feature') === feature);
134
- if (!scopedSchedules.length) continue;
135
- this.heading(feature ? 'Feature-specific limits' : 'Usage & reset schedule');
136
- await consoleTable(this.out, scopedSchedules, [
137
- { key: 'account', title: 'Account', value: row => row.account },
138
- { key: 'window', title: 'Window', value: row => `${plainText(row.window.label)} (${duration(row.window.durationSeconds)})` },
139
- { key: 'usage', title: 'Usage', value: row => `${row.window.usedPercent}% used\n${Math.max(0, 100 - row.window.usedPercent)}% left` },
140
- { key: 'reset', title: 'Reset in', value: row => until(row.window.resetAt, now) },
141
- ], accountDividers);
142
- }
143
- const resets = accounts.flatMap(account => (account.status.summary?.resets?.details ?? []).map(reset => ({ accountId: account.id, account: account.name, reset })));
144
- if (resets.length) {
145
- this.heading('Earned reset details');
146
- await consoleTable(this.out, resets, [
147
- { key: 'account', title: 'Account', value: row => row.account },
148
- { key: 'kind', title: 'Type', value: row => plainText(row.reset.kind) },
149
- { key: 'status', title: 'Status', value: row => plainText(row.reset.status) },
150
- { key: 'expiry', title: 'Expires (UTC)', value: row => row.reset.expiresAt === null ? 'Not reported' : plainText(row.reset.expiresAt).replace('T', '\n').replace('.000Z', '').replace(/Z$/, '') },
151
- ], accountDividers);
152
- }
153
- const groups = new Map<string, IDisplayFact[]>();
154
- const add = (section: string, fact: IDisplayFact) => { const group = groups.get(section) ?? []; group.push(fact); groups.set(section, group); };
155
- accounts.forEach((account, index) => {
156
- for (const fact of account.status.facts) {
157
- if (!this.covered(fact, account.status)) add(plainText(fact.section ?? 'Additional information'), { account: index, label: plainText(fact.label), value: plainText(fact.value) });
158
- }
159
- });
160
- for (const [section, facts] of groups) await this.facts(section, facts, accounts);
161
- const saved = accounts.flatMap((account, index) => [
162
- ...(account.savedAt ? [{ account: index, label: 'Saved (UTC)', value: plainText(account.savedAt) }] : []),
163
- ...account.details.map((detail, detailIndex) => ({ account: index, label: `Note ${detailIndex + 1}`, value: plainText(detail) })),
164
- ]);
165
- await this.facts('Saved logins', saved, accounts);
166
- const issues: { accountId: string | null; account: string; issue: string }[] = accounts.flatMap(account =>
167
- account.status.problems.map(problem => ({ accountId: account.id, account: account.name, issue: plainText(problem) })));
168
- if (harness.saveUnavailableReason) issues.push({ accountId: null, account: 'Harness', issue: plainText(harness.saveUnavailableReason) });
169
- if (issues.length) {
170
- this.heading('Availability & actions');
171
- await consoleTable(this.out, issues, [
172
- { key: 'account', title: 'Account', value: row => row.account },
173
- { key: 'issue', title: 'Unavailable / action needed', value: row => row.issue },
174
- ], accountDividers);
175
- }
176
- }
177
- }
178
-
179
- private async overview(accountsArg: TNamedAccount[], nowArg: number): Promise<void> {
180
- const rows = accountsArg;
181
- const state = (account: TListedAccount) => `${account.isActive ? '* active\n' : ''}${account.isStashed ? 'saved' : 'not saved'}${account.status.problems.length ? '\n! partial status' : ''}`;
182
- const plan = (account: TListedAccount) => account.status.summary?.subscription ? `${plainText(account.status.summary.subscription.plan)} (${account.status.summary.subscription.source})${account.status.summary.subscription.source === 'stored' ? '\nunverified' : ''}` : 'Unavailable';
183
- const billing = (account: TListedAccount) => {
184
- const info = account.status.summary?.billing;
185
- if (!info) return 'Unavailable';
186
- const dates = [
187
- ...(info.renewsAt ? [`${info.autoRenew === true ? 'Renews' : 'Renewal boundary'}: ${plainText(info.renewsAt)}`] : []),
188
- ...(info.cancelsAt ? [`Cancels: ${plainText(info.cancelsAt)}`] : []),
189
- ...(info.expiresAt ? [`Expires: ${plainText(info.expiresAt)}`] : []),
190
- ];
191
- return [...(info.hasActiveSubscription === undefined ? [] : [info.hasActiveSubscription ? 'Subscription active' : 'No active subscription']),
192
- info.autoRenew === true ? 'Auto-renew on' : info.autoRenew === false ? 'Auto-renew off' : 'Auto-renew unknown', ...dates,
193
- ...(!dates.length ? ['Date unavailable'] : [])].join('\n');
194
- };
195
- const resets = (account: TListedAccount) => account.status.summary?.resets === undefined ? 'Unavailable' : String(account.status.summary.resets.available);
196
- const usage = (account: TListedAccount) => accountQuotaSummary(account, nowArg);
197
- const columns: plugins.smartconsole.IBackendTableColumn<typeof rows[number]>[] = [
198
- { key: 'account', title: 'Account', value: row => row.name, style: row => ({ bold: row.isActive }) },
199
- ];
200
- if (this.width >= 100) columns.push(
201
- { key: 'state', title: 'Login', value: state, style: row => ({ foreground: row.isStashed ? 'green' : 'orange' }) },
202
- { key: 'plan', title: 'Plan', value: plan },
203
- { key: 'usage', title: 'Usage / reset', value: usage },
204
- { key: 'resets', title: 'Resets', value: resets, align: 'right' },
205
- );
206
- if (this.width >= 120) columns.splice(3, 0, { key: 'billing', title: 'Renewal / cancellation', value: billing });
207
- else if (this.width >= 100) columns[2] = { key: 'plan', title: 'Plan / billing', value: row => `${plan(row)}\n${row.status.summary?.billing ? billing(row) : 'Billing unavailable'}` };
208
- else if (this.width >= 60) columns.push(
209
- { key: 'login', title: 'Login / plan', value: row => `${state(row)}\n${plan(row)}\n${row.status.summary?.billing ? billing(row) : 'Billing unavailable'}` },
210
- { key: 'usage', title: 'Usage / resets', value: row => `${usage(row)}\nEarned resets: ${resets(row)}` },
211
- );
212
- else columns.push({ key: 'status', title: 'Status', value: row => `${state(row)}\nPlan: ${plan(row)}\nBilling: ${billing(row)}\nUsage: ${usage(row)}\nEarned resets: ${resets(row)}` });
213
- await consoleTable(this.out, rows, columns);
214
- }
215
-
216
- private covered(factArg: IHarnessStatusFact, statusArg: IHarnessAccountStatus): boolean {
217
- const summary = statusArg.summary;
218
- if (!summary) return false;
219
- switch (factArg.summaryKey) {
220
- case 'subscription': return summary.subscription !== undefined;
221
- case 'billing': return summary.billing !== undefined;
222
- case 'usageWindows': return summary.usageWindows !== undefined;
223
- case 'resets': return summary.resets !== undefined;
224
- case 'resetDetails': return summary.resets?.details !== undefined;
225
- default: return false;
226
- }
227
- }
228
-
229
- /** Compare like metrics across accounts, keeping duplicate labels as separate rows. */
230
- private async facts(titleArg: string, factsArg: IDisplayFact[], accountsArg: TNamedAccount[]): Promise<void> {
231
- if (!factsArg.length) return;
232
- this.heading(titleArg);
233
- const valueBudget = Math.max(10, Math.min(40, Math.max(...factsArg.map(fact => Math.max(fact.value.length, accountsArg[fact.account].name.length))) + 3));
234
- const perPage = Math.max(1, Math.min(6, Math.floor((this.width - 30) / valueBudget)));
235
- const accountIndices = accountsArg.map((_, index) => index).filter(index => factsArg.some(fact => fact.account === index));
236
- for (let start = 0; start < accountIndices.length; start += perPage) {
237
- const indices = accountIndices.slice(start, start + perPage);
238
- const metrics = new Map<string, { label: string; values: Map<number, string> }>();
239
- const occurrences = new Map<string, number>();
240
- for (const fact of factsArg.filter(fact => indices.includes(fact.account))) {
241
- const occurrenceKey = JSON.stringify([fact.account, fact.label]);
242
- const occurrence = occurrences.get(occurrenceKey) ?? 0;
243
- occurrences.set(occurrenceKey, occurrence + 1);
244
- const key = JSON.stringify([fact.label, occurrence]);
245
- const metric = metrics.get(key) ?? { label: fact.label, values: new Map<number, string>() };
246
- metric.values.set(fact.account, fact.value);
247
- metrics.set(key, metric);
248
- }
249
- if (this.width < 40) {
250
- for (const index of indices) {
251
- process.stdout.write(` Account: ${accountsArg[index].name}\n`);
252
- for (const metric of metrics.values()) if (metric.values.has(index)) process.stdout.write(` - ${metric.label}: ${metric.values.get(index)}\n`);
253
- }
254
- } else await consoleTable(this.out, [...metrics.values()], [
255
- { key: 'metric', title: 'Metric', value: row => row.label },
256
- ...indices.map(index => ({ key: String(index), title: accountsArg[index].name, value: (row: { values: Map<number, string> }) => row.values.get(index) ?? 'Not reported' })),
257
- ]);
258
- }
259
- }
260
- }