@modelprofile.com/authswitch 3.3.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +3 -3
- package/dist_ts/accounts.d.ts +42 -15
- package/dist_ts/accounts.js +98 -27
- package/dist_ts/classes.accountlist.d.ts +0 -25
- package/dist_ts/classes.accountlist.js +2 -216
- package/dist_ts/classes.claudecodeharness.d.ts +59 -1
- package/dist_ts/classes.claudecodeharness.js +130 -14
- package/dist_ts/classes.claudecodelocks.d.ts +35 -0
- package/dist_ts/classes.claudecodelocks.js +117 -0
- package/dist_ts/classes.claudestatus.d.ts +15 -2
- package/dist_ts/classes.claudestatus.js +88 -73
- package/dist_ts/classes.claudetokenrefresh.d.ts +32 -0
- package/dist_ts/classes.claudetokenrefresh.js +77 -0
- package/dist_ts/classes.cli.d.ts +14 -7
- package/dist_ts/classes.cli.js +125 -66
- package/dist_ts/classes.codexharness.d.ts +4 -0
- package/dist_ts/classes.codexharness.js +5 -1
- package/dist_ts/classes.codexstatus.d.ts +2 -1
- package/dist_ts/classes.codexstatus.js +25 -9
- package/dist_ts/classes.credentialstore.d.ts +28 -2
- package/dist_ts/classes.credentialstore.js +41 -12
- package/dist_ts/classes.fileharness.d.ts +37 -17
- package/dist_ts/classes.fileharness.js +58 -24
- package/dist_ts/classes.limits.d.ts +46 -7
- package/dist_ts/classes.limits.js +94 -36
- package/dist_ts/classes.listrenderer.d.ts +17 -0
- package/dist_ts/classes.listrenderer.js +313 -0
- package/dist_ts/classes.login.d.ts +1 -1
- package/dist_ts/classes.login.js +1 -1
- package/dist_ts/classes.opencodeharness.d.ts +4 -0
- package/dist_ts/classes.opencodeharness.js +7 -3
- package/dist_ts/classes.operations.js +3 -2
- package/dist_ts/classes.tui.js +4 -3
- package/dist_ts/classes.watch.d.ts +108 -0
- package/dist_ts/classes.watch.js +219 -0
- package/dist_ts/classes.watchlock.d.ts +33 -0
- package/dist_ts/classes.watchlock.js +118 -0
- package/dist_ts/claudehttp.d.ts +39 -0
- package/dist_ts/claudehttp.js +83 -0
- package/dist_ts/cliargs.d.ts +36 -0
- package/dist_ts/cliargs.js +60 -0
- package/dist_ts/consoletable.d.ts +21 -0
- package/dist_ts/consoletable.js +63 -0
- package/dist_ts/helpers.d.ts +7 -0
- package/dist_ts/helpers.js +16 -1
- package/dist_ts/index.d.ts +3 -0
- package/dist_ts/index.js +4 -1
- package/dist_ts/interfaces.harness.d.ts +60 -11
- package/dist_ts/interfaces.list.d.ts +3 -1
- package/dist_ts/plugins.d.ts +7 -0
- package/dist_ts/plugins.js +6 -1
- package/dist_ts/ratelimit.d.ts +8 -0
- package/dist_ts/ratelimit.js +13 -0
- package/dist_ts/watchpolicy.d.ts +44 -0
- package/dist_ts/watchpolicy.js +82 -0
- package/package.json +5 -3
- package/readme.md +315 -110
- package/ts/00_commitinfo_data.ts +3 -3
- package/ts/accounts.ts +109 -34
- package/ts/classes.accountlist.ts +2 -219
- package/ts/classes.claudecodeharness.ts +125 -12
- package/ts/classes.claudecodelocks.ts +132 -0
- package/ts/classes.claudestatus.ts +87 -54
- package/ts/classes.claudetokenrefresh.ts +85 -0
- package/ts/classes.cli.ts +112 -53
- package/ts/classes.codexharness.ts +4 -0
- package/ts/classes.codexstatus.ts +17 -7
- package/ts/classes.credentialstore.ts +53 -9
- package/ts/classes.fileharness.ts +67 -29
- package/ts/classes.limits.ts +110 -36
- package/ts/classes.listrenderer.ts +328 -0
- package/ts/classes.login.ts +1 -1
- package/ts/classes.opencodeharness.ts +6 -2
- package/ts/classes.operations.ts +2 -1
- package/ts/classes.tui.ts +3 -2
- package/ts/classes.watch.ts +263 -0
- package/ts/classes.watchlock.ts +100 -0
- package/ts/claudehttp.ts +92 -0
- package/ts/cliargs.ts +71 -0
- package/ts/consoletable.ts +62 -0
- package/ts/helpers.ts +14 -0
- package/ts/index.ts +3 -0
- package/ts/interfaces.harness.ts +60 -5
- package/ts/interfaces.list.ts +3 -1
- package/ts/plugins.ts +9 -0
- package/ts/ratelimit.ts +14 -0
- package/ts/watchpolicy.ts +121 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import { accountUsageWindows, compactUsageLine, credentialDriftProblem, localDate, localDateTime, localiseTimestamps } from './accounts.js';
|
|
3
|
+
import { accountBilling, accountCell, accountLimits, accountType, CondensedRenderer, nextRenewal, renewalCell, UNAVAILABLE, usageColor, type THarnessTraits } from './classes.limits.js';
|
|
4
|
+
import { consoleHeading, consoleNote, consoleTable, consoleWidth } from './consoletable.js';
|
|
5
|
+
import type { IHarnessStatusFact, IHarnessStatusSummary } from './interfaces.harness.js';
|
|
6
|
+
import type { IAccountList, IAccountLimitRow, IHarnessAccountList } from './interfaces.list.js';
|
|
7
|
+
import { orange, plainText } from './formatting.js';
|
|
8
|
+
|
|
9
|
+
type TListedAccount = IHarnessAccountList['accounts'][number];
|
|
10
|
+
type TNamedAccount = TListedAccount & { name: string };
|
|
11
|
+
type TText = plugins.smartconsole.TText;
|
|
12
|
+
|
|
13
|
+
const { color } = plugins.smartconsole;
|
|
14
|
+
const dimText = (textArg: string): TText => color.text(textArg, { dim: true });
|
|
15
|
+
/** Cell lines, one per part; parts that are null are left out. */
|
|
16
|
+
const stack = (...partsArg: (TText | null)[]): TText =>
|
|
17
|
+
color.concat(...partsArg.filter((part): part is TText => part !== null).flatMap((part, index) => index ? ['\n', part] : [part]));
|
|
18
|
+
|
|
19
|
+
/** How many dated rows a date series keeps: the newest week, as the providers report daily totals. */
|
|
20
|
+
const DATED_ROWS = 7;
|
|
21
|
+
const DATED_LABEL = /\d{4}-\d{2}-\d{2}$/;
|
|
22
|
+
/** A problem that names what failed before its message: `Usage: Claude account service returned HTTP 429.` */
|
|
23
|
+
const TOPIC_PROBLEM = /^([^:.]{1,40}): (.+)$/s;
|
|
24
|
+
/** A saved-login note of the form `name: value` or `name:value`. */
|
|
25
|
+
const NOTE_PAIR = /^([^:]{1,40}):\s*(.+)$/s;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Names every account of one harness by its label, numbering each later account that repeats a label
|
|
29
|
+
* (`alice@example.com (2)`), so all tables name an account alike and the metric columns stay distinct.
|
|
30
|
+
*/
|
|
31
|
+
const nameAccounts = (accountsArg: TListedAccount[]): TNamedAccount[] => {
|
|
32
|
+
const labels = accountsArg.map(account => plainText(account.label));
|
|
33
|
+
const taken = new Set(labels);
|
|
34
|
+
return accountsArg.map((account, index) => {
|
|
35
|
+
const label = labels[index];
|
|
36
|
+
let name = label;
|
|
37
|
+
if (labels.indexOf(label) < index) {
|
|
38
|
+
for (let ordinal = 2; taken.has(name); ordinal++) name = `${label} (${ordinal})`;
|
|
39
|
+
taken.add(name);
|
|
40
|
+
}
|
|
41
|
+
return { ...account, name };
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Whether a fact restates a structured field that the Accounts or Limits table shows. */
|
|
46
|
+
const shownSummaryField = (factArg: IHarnessStatusFact, summaryArg: IHarnessStatusSummary): boolean => {
|
|
47
|
+
switch (factArg.summaryKey) {
|
|
48
|
+
case 'subscription': return summaryArg.subscription !== undefined;
|
|
49
|
+
case 'billing': return summaryArg.billing !== undefined;
|
|
50
|
+
case 'usageWindows': return summaryArg.usageWindows !== undefined;
|
|
51
|
+
case 'resets': return summaryArg.resets !== undefined;
|
|
52
|
+
case 'resetDetails': return summaryArg.resets?.details !== undefined;
|
|
53
|
+
default: return false;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
/** Facts the other tables already show: a displayed structured field, or the account's own label (a profile's email). */
|
|
57
|
+
const covered = (factArg: IHarnessStatusFact, accountArg: TNamedAccount): boolean =>
|
|
58
|
+
(accountArg.status.summary !== undefined && shownSummaryField(factArg, accountArg.status.summary))
|
|
59
|
+
|| plainText(factArg.value).toLowerCase() === plainText(accountArg.label).toLowerCase();
|
|
60
|
+
|
|
61
|
+
const isSentence = (valueArg: string): boolean => /\s/.test(valueArg) && /[.!?]$/.test(valueArg);
|
|
62
|
+
/** A topic continuing a list starts in lower case, unless its first word is a name (`OpenAI`). */
|
|
63
|
+
const lowerFirst = (textArg: string): string => /^[A-Z][a-z]*(\s|$)/.test(textArg) ? `${textArg.charAt(0).toLowerCase()}${textArg.slice(1)}` : textArg;
|
|
64
|
+
|
|
65
|
+
interface IDetailRow {
|
|
66
|
+
section: string;
|
|
67
|
+
label: string;
|
|
68
|
+
/** Value per account index. */
|
|
69
|
+
values: Map<number, string>;
|
|
70
|
+
}
|
|
71
|
+
type TDetailLine = IDetailRow | { section: string };
|
|
72
|
+
|
|
73
|
+
interface IDetails {
|
|
74
|
+
/** Rows grouped by section, sections in first-seen order. */
|
|
75
|
+
sections: Map<string, IDetailRow[]>;
|
|
76
|
+
/** Sentences stated once instead of per account. */
|
|
77
|
+
notes: string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One comparison of everything the Accounts and Limits tables do not show, by section.
|
|
82
|
+
*
|
|
83
|
+
* A metric keeps one row across accounts, and a label an account repeats keeps a row per occurrence. A section of
|
|
84
|
+
* dated rows (daily totals) is merged across accounts and keeps the newest week. A sentence that every account in its
|
|
85
|
+
* section states identically is said once, as a note. Saved-login notes form their own section; a note that only
|
|
86
|
+
* restates the account's credential slot is left out, since the Accounts table names the slot.
|
|
87
|
+
*/
|
|
88
|
+
const collectDetails = (accountsArg: TNamedAccount[]): IDetails => {
|
|
89
|
+
const sections = new Map<string, Map<string, IDetailRow>>();
|
|
90
|
+
accountsArg.forEach((account, index) => {
|
|
91
|
+
const occurrences = new Map<string, number>();
|
|
92
|
+
const put = (sectionArg: string, labelArg: string, valueArg: string): void => {
|
|
93
|
+
const occurrenceKey = JSON.stringify([sectionArg, labelArg]);
|
|
94
|
+
const occurrence = occurrences.get(occurrenceKey) ?? 0;
|
|
95
|
+
occurrences.set(occurrenceKey, occurrence + 1);
|
|
96
|
+
const rows = sections.get(sectionArg) ?? new Map<string, IDetailRow>();
|
|
97
|
+
sections.set(sectionArg, rows);
|
|
98
|
+
const key = JSON.stringify([labelArg, occurrence]);
|
|
99
|
+
const row = rows.get(key) ?? { section: sectionArg, label: labelArg, values: new Map<number, string>() };
|
|
100
|
+
row.values.set(index, valueArg);
|
|
101
|
+
rows.set(key, row);
|
|
102
|
+
};
|
|
103
|
+
for (const fact of account.status.facts) {
|
|
104
|
+
if (!covered(fact, account)) put(plainText(fact.section ?? 'Additional information'), plainText(fact.label), localiseTimestamps(plainText(fact.value)));
|
|
105
|
+
}
|
|
106
|
+
for (const detail of account.details.map(plainText)) {
|
|
107
|
+
if (account.slotId !== undefined && detail === `Provider: ${plainText(account.slotId)}`) continue;
|
|
108
|
+
const pair = NOTE_PAIR.exec(detail);
|
|
109
|
+
put('Saved login', pair ? pair[1].trim() : 'Note', pair ? pair[2] : detail);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
const notes: string[] = [];
|
|
113
|
+
const result = new Map<string, IDetailRow[]>();
|
|
114
|
+
for (const [section, keyed] of sections) {
|
|
115
|
+
const rows = [...keyed.values()];
|
|
116
|
+
const inSection = new Set(rows.flatMap(row => [...row.values.keys()]));
|
|
117
|
+
const dated = rows.filter(row => DATED_LABEL.test(row.label)).sort((left, right) => right.label.localeCompare(left.label)).slice(0, DATED_ROWS);
|
|
118
|
+
const kept: IDetailRow[] = [];
|
|
119
|
+
for (const row of [...dated, ...rows.filter(row => !DATED_LABEL.test(row.label))]) {
|
|
120
|
+
const values = [...new Set(row.values.values())];
|
|
121
|
+
if (values.length === 1 && isSentence(values[0]) && row.values.size === inSection.size) {
|
|
122
|
+
const names = row.values.size === accountsArg.length ? '' : `${[...row.values.keys()].map(index => accountsArg[index].name).join(', ')} — `;
|
|
123
|
+
notes.push(`${names}${row.label}: ${values[0]}`);
|
|
124
|
+
} else kept.push(row);
|
|
125
|
+
}
|
|
126
|
+
if (kept.length) result.set(section, kept);
|
|
127
|
+
}
|
|
128
|
+
return { sections: result, notes };
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Problems with one message are merged, their topics joined: `Profile, usage: Login expired …`. Several topics take a
|
|
133
|
+
* line of their own and the message follows one sentence per line, so its words stay together in a narrow cell.
|
|
134
|
+
*/
|
|
135
|
+
const mergeProblems = (problemsArg: string[]): string[] => {
|
|
136
|
+
const topics = new Map<string, string[]>();
|
|
137
|
+
for (const problem of problemsArg.map(plainText)) {
|
|
138
|
+
const match = TOPIC_PROBLEM.exec(problem);
|
|
139
|
+
const message = match ? match[2] : problem;
|
|
140
|
+
const known = topics.get(message) ?? [];
|
|
141
|
+
if (match && !known.includes(match[1])) known.push(match[1]);
|
|
142
|
+
topics.set(message, known);
|
|
143
|
+
}
|
|
144
|
+
return [...topics].map(([message, names]) => !names.length ? message
|
|
145
|
+
: names.length === 1 ? `${names[0]}: ${message}`
|
|
146
|
+
: `${names.map((name, index) => index ? lowerFirst(name) : name).join(', ')}:\n${message.replace(/(?<=[.!?]) +(?=\S)/g, '\n')}`);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
interface IProblemRow {
|
|
150
|
+
group: string;
|
|
151
|
+
accounts: string;
|
|
152
|
+
problem: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Shared, provider-independent list presentation: per harness an Accounts, a Limits, a Details and a Problems table. */
|
|
156
|
+
export class AccountListRenderer {
|
|
157
|
+
constructor(private readonly out: plugins.smartconsole.SmartConsole) {}
|
|
158
|
+
private get width(): number { return consoleWidth(); }
|
|
159
|
+
|
|
160
|
+
public async render(listArg: IAccountList, harnessesArg: readonly THarnessTraits[]): Promise<void> {
|
|
161
|
+
const now = Date.parse(listArg.generatedAt);
|
|
162
|
+
if (!listArg.harnesses.length) process.stdout.write('No harnesses registered.\n');
|
|
163
|
+
const limits = accountLimits(listArg, harnessesArg).limits;
|
|
164
|
+
// The same harness order as `limits`: by provider name.
|
|
165
|
+
const order = [...new Set(limits.map(row => row.harnessId))];
|
|
166
|
+
const harnesses = [...listArg.harnesses].sort((left, right) => order.indexOf(left.id) - order.indexOf(right.id));
|
|
167
|
+
for (const harness of harnesses) {
|
|
168
|
+
consoleHeading(plainText(harness.label));
|
|
169
|
+
if (harness.problems.length) {
|
|
170
|
+
for (const problem of harness.problems) process.stdout.write(`${orange('Unavailable')}: ${plainText(problem)}\n`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (!harness.accounts.length) {
|
|
174
|
+
consoleNote(plainText(harness.saveUnavailableReason ?? 'No known accounts. ' + harness.loginHint));
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const accounts = nameAccounts(harness.accounts);
|
|
178
|
+
const slots = new Set(harness.accounts.map(account => account.slotId)).size > 1;
|
|
179
|
+
await this.accounts(accounts, slots, now);
|
|
180
|
+
// The Renewal cell can only read `n/a` for such a provider, so the declaration explains it here as it does in `limits`.
|
|
181
|
+
const renewalUnavailable = harnessesArg.find(traits => traits.id === harness.id)?.renewalUnavailableReason;
|
|
182
|
+
if (renewalUnavailable !== undefined) consoleNote(`${plainText(harness.label)}: ${plainText(renewalUnavailable)}`, `Renewal ${UNAVAILABLE} — `);
|
|
183
|
+
const names = new Map(accounts.map(account => [account.id, account.name]));
|
|
184
|
+
const limitRows: IAccountLimitRow[] = limits
|
|
185
|
+
.filter(row => row.harnessId === harness.id && row.limitType !== null)
|
|
186
|
+
.map(row => ({ ...row, account: row.accountId === null ? row.account : names.get(row.accountId) ?? row.account }));
|
|
187
|
+
if (limitRows.length) {
|
|
188
|
+
process.stdout.write('\n');
|
|
189
|
+
await new CondensedRenderer(this.out).limitsTable(limitRows, { provider: false, renewal: false, slots });
|
|
190
|
+
}
|
|
191
|
+
await this.details(accounts);
|
|
192
|
+
await this.problems(harness, accounts);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private async accounts(accountsArg: TNamedAccount[], slotsArg: boolean, nowArg: number): Promise<void> {
|
|
197
|
+
/** Whether the account is active and saved, then when it was saved; an active login without a save says so instead. */
|
|
198
|
+
const status = (account: TNamedAccount): TText => stack(
|
|
199
|
+
account.isActive ? color.text(account.isStashed ? 'active, saved' : 'active', { foreground: 'green', bold: false })
|
|
200
|
+
: color.text(account.isStashed ? 'saved' : 'not saved', { foreground: account.isStashed ? undefined : 'orange', bold: false }),
|
|
201
|
+
account.savedAt !== null ? dimText(localDateTime(account.savedAt))
|
|
202
|
+
: account.isActive && !account.isStashed ? color.text('not saved', { foreground: 'orange', bold: false }) : null,
|
|
203
|
+
);
|
|
204
|
+
const usage = (account: TNamedAccount): TText => {
|
|
205
|
+
const windows = accountUsageWindows(account);
|
|
206
|
+
if (windows === undefined) return dimText(`usage ${UNAVAILABLE}`);
|
|
207
|
+
if (!windows.length) return dimText('no usage reported');
|
|
208
|
+
return stack(...windows.map(window => color.text(compactUsageLine(window, windows, nowArg), { foreground: usageColor(window.severity ?? null, window.usedPercent) })));
|
|
209
|
+
};
|
|
210
|
+
const reportsResets = accountsArg.some(account => account.status.summary?.resets !== undefined);
|
|
211
|
+
const resetsCell = (account: TNamedAccount): TText => {
|
|
212
|
+
const summary = account.status.summary?.resets;
|
|
213
|
+
if (summary === undefined) return dimText(UNAVAILABLE);
|
|
214
|
+
return stack(String(summary.available), ...(summary.details ?? []).map(detail =>
|
|
215
|
+
dimText(`${plainText(detail.kind)} ${plainText(detail.status)}${detail.expiresAt === null ? '' : ` · until ${localDate(detail.expiresAt)}`}`)));
|
|
216
|
+
};
|
|
217
|
+
/** Earned resets as extra cell lines, for the layouts without their own column; only where the harness reports any. */
|
|
218
|
+
const resets = (account: TNamedAccount): TText | null => reportsResets ? color.concat(dimText('earned resets: '), resetsCell(account)) : null;
|
|
219
|
+
const renewalLine = (account: TNamedAccount): TText => {
|
|
220
|
+
const renewal = nextRenewal(accountBilling(account));
|
|
221
|
+
return renewal === null ? dimText(`renewal ${UNAVAILABLE}`) : `renewal ${localDate(renewal.at)} · ${renewal.kind}`;
|
|
222
|
+
};
|
|
223
|
+
const account = (row: TNamedAccount): TText => accountCell({ account: row.name, accountType: accountType(row), slotId: row.slotId }, slotsArg);
|
|
224
|
+
type TColumn = plugins.smartconsole.IBackendTableColumn<TNamedAccount>;
|
|
225
|
+
const accountColumn = (render: (row: TNamedAccount) => TText): TColumn =>
|
|
226
|
+
({ key: 'account', title: 'Account', value: row => row.name, render, style: row => ({ bold: row.isActive }) });
|
|
227
|
+
const statusColumn: TColumn = { key: 'status', title: 'Status', value: row => color.plain(status(row)), render: status };
|
|
228
|
+
const renewalColumn: TColumn = { key: 'renewal', title: 'Renewal', value: row => nextRenewal(accountBilling(row))?.at ?? UNAVAILABLE, render: row => renewalCell(accountBilling(row)) };
|
|
229
|
+
const usageValue = (row: TNamedAccount): string => color.plain(usage(row));
|
|
230
|
+
let columns: TColumn[];
|
|
231
|
+
if (this.width >= 120) {
|
|
232
|
+
columns = [accountColumn(account), statusColumn, { key: 'usage', title: 'Usage', value: usageValue, render: usage }, renewalColumn,
|
|
233
|
+
...(reportsResets ? [{ key: 'resets', title: 'Earned resets', value: (row: TNamedAccount) => row.status.summary?.resets?.available, render: resetsCell }] : [])];
|
|
234
|
+
} else if (this.width >= 100) {
|
|
235
|
+
columns = [accountColumn(account), statusColumn, { key: 'usage', title: 'Usage', value: usageValue, render: row => stack(usage(row), resets(row)) }, renewalColumn];
|
|
236
|
+
} else if (this.width >= 60) {
|
|
237
|
+
columns = [accountColumn(row => stack(account(row), status(row))), { key: 'usage', title: 'Usage', value: usageValue, render: row => stack(usage(row), resets(row)) }, renewalColumn];
|
|
238
|
+
} else {
|
|
239
|
+
columns = [accountColumn(account), { ...statusColumn, render: row => stack(status(row), usage(row), renewalLine(row), resets(row)) }];
|
|
240
|
+
}
|
|
241
|
+
await consoleTable(this.out, accountsArg, columns, { divider: row => row.id });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The Details table, paged across as many account columns as the width holds, with its notes beneath. */
|
|
245
|
+
private async details(accountsArg: TNamedAccount[]): Promise<void> {
|
|
246
|
+
const { sections, notes } = collectDetails(accountsArg);
|
|
247
|
+
const rows = [...sections.values()].flat();
|
|
248
|
+
const withValues = accountsArg.map((_, index) => index).filter(index => rows.some(row => row.values.has(index)));
|
|
249
|
+
if (withValues.length) {
|
|
250
|
+
process.stdout.write('\n');
|
|
251
|
+
if (this.width < 40) this.narrowDetails(accountsArg, withValues, sections);
|
|
252
|
+
else {
|
|
253
|
+
// Section rows span the whole table, so only metric labels size the first column. A table is one edge plus, per
|
|
254
|
+
// column, its content, two spaces of padding and a rule; pages take accounts in order while they fit.
|
|
255
|
+
const labelWidth = Math.max('Metric'.length, ...rows.map(row => row.label.length));
|
|
256
|
+
const columnWidth = (indexArg: number): number => 3 + Math.min(40, Math.max(accountsArg[indexArg].name.length, ...rows.map(row => row.values.get(indexArg)?.length ?? 0)));
|
|
257
|
+
const pages: number[][] = [];
|
|
258
|
+
let used = 0;
|
|
259
|
+
for (const index of withValues) {
|
|
260
|
+
if (!pages.length || used + columnWidth(index) > this.width - 1) { pages.push([]); used = 1 + labelWidth + 3; }
|
|
261
|
+
pages[pages.length - 1].push(index);
|
|
262
|
+
used += columnWidth(index);
|
|
263
|
+
}
|
|
264
|
+
for (const page of pages) await this.detailsPage(accountsArg, page, sections);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (notes.length) {
|
|
268
|
+
if (!withValues.length) process.stdout.write('\n');
|
|
269
|
+
for (const note of notes) consoleNote(note);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private async detailsPage(accountsArg: TNamedAccount[], indicesArg: number[], sectionsArg: Map<string, IDetailRow[]>): Promise<void> {
|
|
274
|
+
const lines: TDetailLine[] = [];
|
|
275
|
+
for (const [section, rows] of sectionsArg) {
|
|
276
|
+
const shown = rows.filter(row => indicesArg.some(index => row.values.has(index)));
|
|
277
|
+
if (shown.length) lines.push({ section }, ...shown);
|
|
278
|
+
}
|
|
279
|
+
const isRow = (lineArg: TDetailLine): lineArg is IDetailRow => 'values' in lineArg;
|
|
280
|
+
await consoleTable(this.out, lines, [
|
|
281
|
+
{ key: 'metric', title: 'Metric', value: line => isRow(line) ? line.label : line.section,
|
|
282
|
+
colSpan: line => isRow(line) ? 1 : 1 + indicesArg.length, style: line => isRow(line) ? {} : { bold: true, foreground: 'cyan' } },
|
|
283
|
+
...indicesArg.map((index): plugins.smartconsole.IBackendTableColumn<TDetailLine> => ({
|
|
284
|
+
key: `account-${index}`, title: accountsArg[index].name,
|
|
285
|
+
value: line => isRow(line) ? line.values.get(index) ?? UNAVAILABLE : line.section,
|
|
286
|
+
render: line => (isRow(line) ? line.values.get(index) : undefined) ?? dimText(UNAVAILABLE),
|
|
287
|
+
})),
|
|
288
|
+
]);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private narrowDetails(accountsArg: TNamedAccount[], indicesArg: number[], sectionsArg: Map<string, IDetailRow[]>): void {
|
|
292
|
+
for (const index of indicesArg) {
|
|
293
|
+
process.stdout.write(` Account: ${accountsArg[index].name}\n`);
|
|
294
|
+
for (const [section, rows] of sectionsArg) {
|
|
295
|
+
for (const row of rows) if (row.values.has(index)) process.stdout.write(` - ${section} · ${row.label}: ${row.values.get(index)}\n`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** One Problems table: a changed active login first, then each account's merged problems, then the harness's own. */
|
|
301
|
+
private async problems(harnessArg: IHarnessAccountList, accountsArg: TNamedAccount[]): Promise<void> {
|
|
302
|
+
const rows: IProblemRow[] = harnessArg.credentialDrift.map((drift, index) => ({
|
|
303
|
+
group: `drift:${index}`,
|
|
304
|
+
accounts: accountsArg.find(account => account.id === drift.currentAccountId)?.name ?? (drift.currentLabel === null ? plainText(harnessArg.label) : plainText(drift.currentLabel)),
|
|
305
|
+
problem: credentialDriftProblem(harnessArg.id, drift),
|
|
306
|
+
}));
|
|
307
|
+
// Accounts with the same problems share one block.
|
|
308
|
+
const blocks = new Map<string, { names: string[]; problems: string[] }>();
|
|
309
|
+
for (const account of accountsArg) {
|
|
310
|
+
const problems = mergeProblems(account.status.problems);
|
|
311
|
+
if (!problems.length) continue;
|
|
312
|
+
const key = JSON.stringify(problems);
|
|
313
|
+
const block = blocks.get(key) ?? { names: [], problems };
|
|
314
|
+
block.names.push(account.name);
|
|
315
|
+
blocks.set(key, block);
|
|
316
|
+
}
|
|
317
|
+
// One line, not one name per line: a row-spanned cell lays its lines onto the rows it covers, which would read
|
|
318
|
+
// as "this account has this problem" for a block whose accounts all have all of them.
|
|
319
|
+
for (const [key, block] of blocks) rows.push(...block.problems.map(problem => ({ group: key, accounts: block.names.join(', '), problem })));
|
|
320
|
+
if (harnessArg.saveUnavailableReason !== null) rows.push({ group: 'harness', accounts: plainText(harnessArg.label), problem: plainText(harnessArg.saveUnavailableReason) });
|
|
321
|
+
if (!rows.length) return;
|
|
322
|
+
process.stdout.write('\n');
|
|
323
|
+
await consoleTable(this.out, rows, [
|
|
324
|
+
{ key: 'account', title: 'Account', value: row => row.accounts, rowSpan: plugins.smartconsole.rowSpansBy(rows, row => row.group) },
|
|
325
|
+
{ key: 'problem', title: 'Problem', value: row => row.problem, style: row => ({ foreground: row.group.startsWith('drift:') ? 'orange' : undefined }) },
|
|
326
|
+
], { divider: row => row.group });
|
|
327
|
+
}
|
|
328
|
+
}
|
package/ts/classes.login.ts
CHANGED
|
@@ -65,7 +65,7 @@ export class AuthSwitchLogin {
|
|
|
65
65
|
|
|
66
66
|
/** A hosted or terminal UI sees only the prompt and saved-account outcome. */
|
|
67
67
|
export const beginSavedOpenAiLogin = async (
|
|
68
|
-
save: (result: IAuthLoginResult) => IHarnessLoginResult
|
|
68
|
+
save: (result: IAuthLoginResult) => IHarnessLoginResult | Promise<IHarnessLoginResult>,
|
|
69
69
|
options: IHarnessLoginOptions,
|
|
70
70
|
fetcher?: typeof fetch,
|
|
71
71
|
): Promise<IHarnessLoginHandle> => {
|
|
@@ -22,6 +22,10 @@ export class OpenCodeHarness extends FileHarness {
|
|
|
22
22
|
public readonly id = 'opencode';
|
|
23
23
|
public readonly label = 'OpenCode';
|
|
24
24
|
public readonly loginHint = 'Use authswitch opencode login for OpenAI; use opencode auth login for other providers.';
|
|
25
|
+
/** OpenCode loads provider credentials at startup (1.18.28), so a running instance keeps its login until restarted. */
|
|
26
|
+
public readonly liveSwap = false;
|
|
27
|
+
/** Only its OpenAI login reports usage, and a supervisor (AGL) restarts it around a switch, so a watch leaves it alone. */
|
|
28
|
+
public readonly autoSwitch = false;
|
|
25
29
|
public readonly loginProviders: IHarnessLoginProvider[] = [{ providerId: 'openai', label: 'OpenAI', flows: ['device'] }];
|
|
26
30
|
protected readonly store: CredentialStore;
|
|
27
31
|
private readonly file: string;
|
|
@@ -47,8 +51,8 @@ export class OpenCodeHarness extends FileHarness {
|
|
|
47
51
|
return account ? this.openAiInfo(account.credential).accountId : undefined;
|
|
48
52
|
}
|
|
49
53
|
public beginLogin(options: IHarnessLoginOptions = { providerId: 'openai', flow: 'device' }) {
|
|
50
|
-
return beginSavedOpenAiLogin(result => {
|
|
51
|
-
const saved = this.importCredential('openai', openCodeCredentialFromLogin(result.credential));
|
|
54
|
+
return beginSavedOpenAiLogin(async result => {
|
|
55
|
+
const saved = await this.importCredential('openai', openCodeCredentialFromLogin(result.credential));
|
|
52
56
|
const account = this.readState().accounts.find(account => account.id === saved.accountId)!;
|
|
53
57
|
return { outcome: { lines: [`Saved OpenAI login for ${plainText(account.label)}. Select it to switch OpenCode.`], problems: [] }, account: { id: account.id, label: account.label, slotId: account.slotId } };
|
|
54
58
|
}, options, this.fetcher);
|
package/ts/classes.operations.ts
CHANGED
|
@@ -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
|
|
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
|
+
};
|