@modelprofile.com/authswitch 3.3.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +3 -3
- package/dist_ts/accounts.d.ts +52 -15
- package/dist_ts/accounts.js +110 -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 +81 -2
- package/dist_ts/classes.claudecodeharness.js +208 -14
- package/dist_ts/classes.claudecodelocks.d.ts +38 -0
- package/dist_ts/classes.claudecodelocks.js +118 -0
- package/dist_ts/classes.claudestatus.d.ts +15 -2
- package/dist_ts/classes.claudestatus.js +100 -83
- 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 +126 -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 +7 -2
- package/dist_ts/classes.codexstatus.js +49 -21
- package/dist_ts/classes.credentialstore.d.ts +43 -2
- package/dist_ts/classes.credentialstore.js +60 -13
- package/dist_ts/classes.fileharness.d.ts +45 -21
- package/dist_ts/classes.fileharness.js +64 -26
- 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 +8 -4
- 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 +349 -115
- package/ts/00_commitinfo_data.ts +3 -3
- package/ts/accounts.ts +122 -35
- package/ts/classes.accountlist.ts +2 -219
- package/ts/classes.claudecodeharness.ts +199 -13
- package/ts/classes.claudecodelocks.ts +133 -0
- package/ts/classes.claudestatus.ts +100 -65
- package/ts/classes.claudetokenrefresh.ts +85 -0
- package/ts/classes.cli.ts +114 -54
- package/ts/classes.codexharness.ts +4 -0
- package/ts/classes.codexstatus.ts +40 -18
- package/ts/classes.credentialstore.ts +78 -10
- package/ts/classes.fileharness.ts +76 -34
- 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 +7 -3
- 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
package/ts/classes.limits.ts
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
|
-
import { accountTypeText, compactSince, compactUntil, credentialDriftNote, orderedUsageWindows } from './accounts.js';
|
|
3
|
-
import { consoleHeading, consoleTable } from './
|
|
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 { IAccountType, 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
|
*
|
|
@@ -47,11 +57,46 @@ const accountKeys = (harnessArg: IHarnessAccountList, accountArg: TListedAccount
|
|
|
47
57
|
});
|
|
48
58
|
|
|
49
59
|
/** The account's plan, sanitised like every other label and copied so that no two rows share one object. */
|
|
50
|
-
const accountType = (accountArg: TListedAccount): IAccountType | null => {
|
|
60
|
+
export const accountType = (accountArg: TListedAccount): IAccountType | null => {
|
|
51
61
|
const subscription = accountArg.status.summary?.subscription;
|
|
52
62
|
return subscription === undefined ? null : { plan: plainText(subscription.plan), source: subscription.source };
|
|
53
63
|
};
|
|
54
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
|
+
|
|
55
100
|
const harnessProblem = (harnessArg: IHarnessAccountList): string | null =>
|
|
56
101
|
harnessArg.problems.length ? harnessArg.problems.map(plainText).join(' ') : null;
|
|
57
102
|
|
|
@@ -96,44 +141,49 @@ const collectNotes = (rowsArg: { provider: string; account: string | null; unava
|
|
|
96
141
|
[...new Set(rowsArg.map(noteFor).filter((note): note is string => note !== null))];
|
|
97
142
|
|
|
98
143
|
/** The drift a switch record proves, carried into the row document with both accounts named. */
|
|
99
|
-
const activeDrift = (
|
|
144
|
+
const activeDrift = (traitsArg: THarnessTraits, driftArg: IHarnessCredentialDrift): IActiveAccountDrift => ({
|
|
100
145
|
expectedAccountId: driftArg.expectedAccountId, expectedAccount: driftArg.expectedLabel === null ? null : plainText(driftArg.expectedLabel),
|
|
101
146
|
currentAccountId: driftArg.currentAccountId, currentAccount: driftArg.currentLabel === null ? null : plainText(driftArg.currentLabel),
|
|
102
|
-
currentIsSaved: driftArg.currentIsSaved, switchedAt: driftArg.switchedAt, reason: credentialDriftNote(
|
|
147
|
+
currentIsSaved: driftArg.currentIsSaved, switchedAt: driftArg.switchedAt, reason: credentialDriftNote(traitsArg, driftArg),
|
|
103
148
|
});
|
|
104
149
|
|
|
105
150
|
/**
|
|
106
|
-
* 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.
|
|
107
152
|
*
|
|
108
|
-
* Nothing here talks to a provider: the list is already the
|
|
109
|
-
*
|
|
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.
|
|
110
156
|
*/
|
|
111
|
-
export const accountLimits = (listArg: IAccountList): IAccountLimits => {
|
|
157
|
+
export const accountLimits = (listArg: IAccountList, harnessesArg: readonly THarnessTraits[]): IAccountLimits => {
|
|
112
158
|
const now = Date.parse(listArg.generatedAt);
|
|
113
159
|
const rows: IAccountLimitRow[] = [];
|
|
160
|
+
const renewalNotes: string[] = [];
|
|
114
161
|
for (const harness of listArg.harnesses) {
|
|
115
162
|
const problem = harnessProblem(harness);
|
|
116
163
|
const empty = {
|
|
117
164
|
limitType: null, scope: null, windowSeconds: null, usedPercent: null, resetAt: null, resetsIn: UNAVAILABLE, severity: null, headline: false,
|
|
118
165
|
} as const;
|
|
119
166
|
if (problem !== null) {
|
|
120
|
-
rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, accountType: null, ...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 });
|
|
121
168
|
continue;
|
|
122
169
|
}
|
|
123
170
|
if (!harness.accounts.length) {
|
|
124
|
-
rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, accountType: null, ...empty,
|
|
171
|
+
rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, accountType: null, billing: null, ...empty,
|
|
125
172
|
unavailableReason: harness.saveUnavailableReason === null ? 'No accounts are known for this harness.' : plainText(harness.saveUnavailableReason) });
|
|
126
173
|
continue;
|
|
127
174
|
}
|
|
175
|
+
const renewalUnavailable = traitsOf(harnessesArg, harness).renewalUnavailableReason;
|
|
176
|
+
if (renewalUnavailable !== undefined) renewalNotes.push(`${plainText(harness.label)}: ${plainText(renewalUnavailable)}`);
|
|
128
177
|
for (const account of harness.accounts) {
|
|
178
|
+
const identity = () => ({ ...accountKeys(harness, account), isActive: account.isActive, accountType: accountType(account), billing: accountBilling(account) });
|
|
129
179
|
const windows = orderedUsageWindows(account.status.summary?.usageWindows);
|
|
130
180
|
if (!windows.length) {
|
|
131
|
-
rows.push({ ...
|
|
181
|
+
rows.push({ ...identity(), ...empty, unavailableReason: missingLimitReason(account) });
|
|
132
182
|
continue;
|
|
133
183
|
}
|
|
134
184
|
for (const window of windows) {
|
|
135
185
|
rows.push({
|
|
136
|
-
...
|
|
186
|
+
...identity(),
|
|
137
187
|
limitType: plainText(window.label), scope: window.scope ?? 'account', windowSeconds: window.durationSeconds,
|
|
138
188
|
usedPercent: window.usedPercent, resetAt: window.resetAt, resetsIn: compactUntil(window.resetAt, now, UNAVAILABLE),
|
|
139
189
|
severity: window.severity ?? null, headline: window.headline === true, unavailableReason: null,
|
|
@@ -142,7 +192,7 @@ export const accountLimits = (listArg: IAccountList): IAccountLimits => {
|
|
|
142
192
|
}
|
|
143
193
|
}
|
|
144
194
|
const limits = byProviderThenAccount(rows);
|
|
145
|
-
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] };
|
|
146
196
|
};
|
|
147
197
|
|
|
148
198
|
/**
|
|
@@ -151,16 +201,17 @@ export const accountLimits = (listArg: IAccountList): IAccountLimits => {
|
|
|
151
201
|
* A harness with no active login still gets a row: "nothing is active here" is an answer, and
|
|
152
202
|
* silently dropping the provider would read as "this provider does not exist".
|
|
153
203
|
*/
|
|
154
|
-
export const activeAccounts = (listArg: IAccountList): IActiveAccounts => {
|
|
204
|
+
export const activeAccounts = (listArg: IAccountList, harnessesArg: readonly THarnessTraits[]): IActiveAccounts => {
|
|
155
205
|
const now = Date.parse(listArg.generatedAt);
|
|
156
206
|
const rows: IActiveAccountRow[] = [];
|
|
157
207
|
const unattached: string[] = [];
|
|
158
208
|
for (const harness of listArg.harnesses) {
|
|
209
|
+
const traits = traitsOf(harnessesArg, harness);
|
|
159
210
|
const problem = harnessProblem(harness);
|
|
160
211
|
const drifts = [...harness.credentialDrift];
|
|
161
212
|
const driftFor = (slotIdArg?: string): IActiveAccountDrift | null => {
|
|
162
213
|
const index = drifts.findIndex(item => item.slotId === slotIdArg);
|
|
163
|
-
return index < 0 ? null : activeDrift(
|
|
214
|
+
return index < 0 ? null : activeDrift(traits, drifts.splice(index, 1)[0]);
|
|
164
215
|
};
|
|
165
216
|
if (problem !== null) {
|
|
166
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 });
|
|
@@ -175,7 +226,7 @@ export const activeAccounts = (listArg: IAccountList): IActiveAccounts => {
|
|
|
175
226
|
unavailableReason: harness.saveUnavailableReason !== null ? plainText(harness.saveUnavailableReason)
|
|
176
227
|
: stashed.length ? `No login is active; ${stashed.length} saved account(s) can be activated with authswitch ${plainText(harness.id)} use.`
|
|
177
228
|
: `No login is active and none is saved. ${plainText(harness.loginHint)}`,
|
|
178
|
-
drift: drifts.length ? activeDrift(
|
|
229
|
+
drift: drifts.length ? activeDrift(traits, drifts.shift()!) : null,
|
|
179
230
|
});
|
|
180
231
|
} else {
|
|
181
232
|
for (const account of active) {
|
|
@@ -189,7 +240,7 @@ export const activeAccounts = (listArg: IAccountList): IActiveAccounts => {
|
|
|
189
240
|
}
|
|
190
241
|
}
|
|
191
242
|
// A drifted slot with no row of its own still has to be reported, not dropped.
|
|
192
|
-
for (const drift of drifts) unattached.push(`${providerName(harness)}${drift.slotId === undefined ? '' : ` / ${plainText(drift.slotId)}`}: ${credentialDriftNote(
|
|
243
|
+
for (const drift of drifts) unattached.push(`${providerName(harness)}${drift.slotId === undefined ? '' : ` / ${plainText(drift.slotId)}`}: ${credentialDriftNote(traits, drift)}`);
|
|
193
244
|
}
|
|
194
245
|
const active = byProviderThenAccount(rows);
|
|
195
246
|
const driftNotes = active.filter(row => row.drift !== null)
|
|
@@ -200,44 +251,67 @@ export const activeAccounts = (listArg: IAccountList): IActiveAccounts => {
|
|
|
200
251
|
/**
|
|
201
252
|
* The Account cell: the label, and the account type beneath it, dim. An unknown type keeps its line as
|
|
202
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.
|
|
203
256
|
*/
|
|
204
|
-
const accountCell = (rowArg: IAccountLimitRow): plugins.smartconsole.TText =>
|
|
205
|
-
|
|
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
|
+
};
|
|
206
262
|
|
|
207
|
-
const percent = (rowArg: IAccountLimitRow): string => rowArg.usedPercent === null ? UNAVAILABLE :
|
|
263
|
+
const percent = (rowArg: IAccountLimitRow): string => rowArg.usedPercent === null ? UNAVAILABLE : usagePercentText(rowArg.usedPercent);
|
|
208
264
|
|
|
209
265
|
const SEVERITY_COLORS: Record<TUsageSeverity, plugins.smartconsole.TColorName | undefined> = { normal: undefined, warning: 'orange', critical: 'red' };
|
|
210
266
|
|
|
211
267
|
/** The provider's own reading colours a window; without one, n/a and exhausted windows stand out. */
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
:
|
|
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
|
+
}
|
|
215
281
|
|
|
216
|
-
/** Condensed presentation for `authswitch limits` and `authswitch active
|
|
282
|
+
/** Condensed presentation for `authswitch limits` and `authswitch active`, and the limits table other views embed. */
|
|
217
283
|
export class CondensedRenderer {
|
|
218
284
|
constructor(private readonly out: plugins.smartconsole.SmartConsole) {}
|
|
219
285
|
|
|
220
286
|
private notes(notesArg: string[], prefixArg: string): void {
|
|
221
287
|
if (!notesArg.length) return;
|
|
222
288
|
process.stdout.write('\n');
|
|
223
|
-
for (const note of notesArg)
|
|
289
|
+
for (const note of notesArg) consoleNote(note, prefixArg);
|
|
224
290
|
}
|
|
225
291
|
|
|
226
292
|
public async renderLimits(limitsArg: IAccountLimits): Promise<void> {
|
|
227
293
|
consoleHeading('Account limits');
|
|
294
|
+
if (!limitsArg.limits.length) { process.stdout.write(`${dim('No harnesses registered.')}\n`); return; }
|
|
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> {
|
|
228
301
|
// The span callbacks record these exact rows, so the table renders this array as it is.
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
{ key: '
|
|
233
|
-
|
|
234
|
-
style: row => ({ bold: row.isActive }), rowSpan: plugins.smartconsole.rowSpansBy(rows, accountRuns) },
|
|
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 },
|
|
235
307
|
{ key: 'limit', title: 'Limit type', value: row => row.limitType ?? UNAVAILABLE },
|
|
236
308
|
{ key: 'used', title: 'Used %', value: percent, align: 'right',
|
|
237
309
|
style: row => ({ foreground: percentColor(row) }) },
|
|
238
310
|
{ key: 'reset', title: 'Resets in', value: row => row.resetsIn, align: 'right' },
|
|
239
|
-
|
|
240
|
-
|
|
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 });
|
|
241
315
|
}
|
|
242
316
|
|
|
243
317
|
public async renderActive(activeArg: IActiveAccounts): Promise<void> {
|
|
@@ -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,9 +51,9 @@ 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));
|
|
52
|
-
const account = this.readState().accounts.find(account => account.id === saved.accountId)!;
|
|
54
|
+
return beginSavedOpenAiLogin(async result => {
|
|
55
|
+
const saved = await this.importCredential('openai', openCodeCredentialFromLogin(result.credential));
|
|
56
|
+
const account = (await 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);
|
|
55
59
|
}
|