@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
|
@@ -25,20 +25,32 @@ export abstract class FileHarness implements IAuthHarness {
|
|
|
25
25
|
protected abstract apply(slotId: string, previous: IFileAccount | undefined, target: IFileAccount | undefined): void;
|
|
26
26
|
/** The harness's own running instances. Detection informs the caller; it never blocks a write. */
|
|
27
27
|
public abstract readonly processes: IHarnessProcessControl;
|
|
28
|
+
/** Whether running instances pick up a swapped login by themselves; see `IAuthHarness.liveSwap`. */
|
|
29
|
+
public abstract readonly liveSwap: boolean;
|
|
30
|
+
/** Whether `authswitch watch` may switch this harness by itself; see `IAuthHarness.autoSwitch`. */
|
|
31
|
+
public abstract readonly autoSwitch: boolean;
|
|
28
32
|
/** Raw bytes of the native files a snapshot's credentials are taken from, in a fixed order. */
|
|
29
33
|
protected abstract nativeSources(): (string | null)[];
|
|
30
34
|
protected abstract inspect(credential: Record<string, unknown>, slotId: string): IFileAccount;
|
|
31
35
|
public abstract readAccountStatus(id: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus>;
|
|
32
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Runs a read and write of the native login under the harness's own write locks, so it never interleaves
|
|
39
|
+
* with a running instance's token refresh or save. A harness without such a protocol runs the action as it is.
|
|
40
|
+
*/
|
|
41
|
+
protected async nativeTransaction(actionArg: () => string[]): Promise<string[]> {
|
|
42
|
+
return actionArg();
|
|
43
|
+
}
|
|
44
|
+
|
|
33
45
|
/**
|
|
34
46
|
* A snapshot proven not to have been taken across a native write.
|
|
35
47
|
*
|
|
36
48
|
* Saving a login without replacing it writes no native file, so it must not require the harness
|
|
37
|
-
* to be stopped. A running harness does rotate its tokens on its own schedule, and
|
|
38
|
-
*
|
|
39
|
-
* and requiring identical bytes is what keeps a torn or half-rotated
|
|
40
|
-
* Both the retry count and the pause between attempts are bounded; an
|
|
41
|
-
* never guessed at.
|
|
49
|
+
* to be stopped. A running harness does rotate its tokens on its own schedule, and a native writer
|
|
50
|
+
* that is not transactional can leave a partial file for a moment, so reading the same sources
|
|
51
|
+
* before and after the snapshot and requiring identical bytes is what keeps a torn or half-rotated
|
|
52
|
+
* credential out of the stash. Both the retry count and the pause between attempts are bounded; an
|
|
53
|
+
* unstable file is reported, never guessed at.
|
|
42
54
|
*/
|
|
43
55
|
private stableSnapshot(): IFileHarnessSnapshot {
|
|
44
56
|
let failure: unknown;
|
|
@@ -82,11 +94,11 @@ export abstract class FileHarness implements IAuthHarness {
|
|
|
82
94
|
/**
|
|
83
95
|
* Credential slots that no longer hold the account the last switch wrote.
|
|
84
96
|
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
97
|
+
* The login can change outside authswitch -- a new native login, or, for a harness that does not pick
|
|
98
|
+
* up swaps live, a running instance writing its previous login back at its next refresh. The recorded
|
|
99
|
+
* hash detects that the file changed at all; comparing the account distinguishes the benign case -- the
|
|
100
|
+
* switched-in account rotating its own token -- from a different account. Detection is advisory and
|
|
101
|
+
* never turns a read into a failure.
|
|
90
102
|
*/
|
|
91
103
|
private credentialDrift(snapshotArg: IFileHarnessSnapshot): IHarnessCredentialDrift[] {
|
|
92
104
|
let records: ISwitchRecord[];
|
|
@@ -111,8 +123,9 @@ export abstract class FileHarness implements IAuthHarness {
|
|
|
111
123
|
private savedLabel(accountIdArg: string): string | null {
|
|
112
124
|
try { return this.store.read(accountIdArg).label; } catch { return null; }
|
|
113
125
|
}
|
|
114
|
-
/** Instances that keep a replaced or cleared login in memory until they are restarted. */
|
|
126
|
+
/** Instances that keep a replaced or cleared login in memory until they are restarted; none for a live-swap harness. */
|
|
115
127
|
private runningCaveat(kindArg: 'replaced' | 'cleared'): string[] {
|
|
128
|
+
if (this.liveSwap) return [];
|
|
116
129
|
let count: number;
|
|
117
130
|
try { count = this.processes.list().length; }
|
|
118
131
|
catch { return [`Could not check for running ${this.label} processes. If any are running, restart them: an unrestarted instance can write its previous login back.`]; }
|
|
@@ -137,16 +150,24 @@ export abstract class FileHarness implements IAuthHarness {
|
|
|
137
150
|
return [];
|
|
138
151
|
} catch { return [`The login was written, but this switch could not be recorded for later credential checks.`]; }
|
|
139
152
|
}
|
|
153
|
+
/** The active login with this account id, read from the native files; undefined when the account is not active. */
|
|
154
|
+
protected activeCredential(id: string): IFileAccount | undefined {
|
|
155
|
+
return this.snapshot().accounts.find(account => this.store.id(account.slotId, account.identity) === id);
|
|
156
|
+
}
|
|
157
|
+
/** The saved copy of this account, verified against its identity. */
|
|
158
|
+
protected savedCredential(id: string): IFileAccount {
|
|
159
|
+
return this.saved(this.store.read(id));
|
|
160
|
+
}
|
|
161
|
+
/** The account's current credential: the active login when it is active, which may be newer than its saved copy. */
|
|
140
162
|
protected readCredential(id: string): IFileAccount {
|
|
141
|
-
|
|
142
|
-
return active ?? this.saved(this.store.read(id));
|
|
163
|
+
return this.activeCredential(id) ?? this.savedCredential(id);
|
|
143
164
|
}
|
|
144
165
|
/** Save a newly authenticated credential without changing a native harness's login. */
|
|
145
|
-
public importCredential(slotId: string, credential: Record<string, unknown>): { accountId: string } {
|
|
166
|
+
public importCredential(slotId: string, credential: Record<string, unknown>): Promise<{ accountId: string }> {
|
|
146
167
|
return this.store.locked(() => ({ accountId: this.store.save(this.inspect(credential, slotId)).id }));
|
|
147
168
|
}
|
|
148
169
|
/** Backend-only login handoff. Preserve the outgoing credential before replacing its slot. */
|
|
149
|
-
public activateCredential(slotId: string, credential: Record<string, unknown>): IHarnessOutcome {
|
|
170
|
+
public activateCredential(slotId: string, credential: Record<string, unknown>): Promise<IHarnessOutcome> {
|
|
150
171
|
return this.outcome(() => {
|
|
151
172
|
const target = this.inspect(credential, slotId);
|
|
152
173
|
const snapshot = this.snapshot();
|
|
@@ -154,9 +175,15 @@ export abstract class FileHarness implements IAuthHarness {
|
|
|
154
175
|
const previous = snapshot.accounts.find(account => account.slotId === slotId);
|
|
155
176
|
if (previous) this.store.save(previous);
|
|
156
177
|
this.apply(slotId, previous, target);
|
|
157
|
-
return [`Activated ${plainText(target.label)} (${plainText(slotId)}).`,
|
|
178
|
+
return [`Activated ${plainText(target.label)} (${plainText(slotId)}).`, ...this.liveSwapNote('replaced'),
|
|
158
179
|
...this.recordActivation(slotId, this.store.id(slotId, target.identity)), ...this.runningCaveat('replaced')];
|
|
159
|
-
});
|
|
180
|
+
}, true);
|
|
181
|
+
}
|
|
182
|
+
/** What a live-swap harness's running sessions do with the change; they need no restart either way. */
|
|
183
|
+
private liveSwapNote(kindArg: 'replaced' | 'cleared'): string[] {
|
|
184
|
+
if (!this.liveSwap) return [];
|
|
185
|
+
return [kindArg === 'replaced' ? `Running ${this.label} sessions use it from their next request.`
|
|
186
|
+
: `Running ${this.label} sessions have no login from their next request.`];
|
|
160
187
|
}
|
|
161
188
|
public resolveAccount(reference: string): { id: string } | { candidates: string[] } {
|
|
162
189
|
const accounts = this.store.list();
|
|
@@ -165,11 +192,18 @@ export abstract class FileHarness implements IAuthHarness {
|
|
|
165
192
|
const matches = exact.length ? exact : accounts.filter(account => account.label.toLowerCase().startsWith(reference.toLowerCase()));
|
|
166
193
|
return matches.length === 1 ? { id: matches[0].id } : { candidates: matches.map(account => `${account.slotId}:${account.label} (${account.id})`) };
|
|
167
194
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
195
|
+
/**
|
|
196
|
+
* Runs one credential operation as the only authswitch operation on this harness's store. An operation that reads
|
|
197
|
+
* or writes the native login (`nativeArg`) also runs inside the harness's native transaction.
|
|
198
|
+
*/
|
|
199
|
+
private async outcome(actionArg: () => string | string[], nativeArg: boolean): Promise<IHarnessOutcome> {
|
|
200
|
+
const run = (): string[] => [actionArg()].flat();
|
|
201
|
+
try {
|
|
202
|
+
const lines = await this.store.locked(() => nativeArg ? this.nativeTransaction(run) : run());
|
|
203
|
+
return { lines, problems: [] };
|
|
204
|
+
} catch (error) { return { lines: [], problems: [error instanceof Error ? error.message : 'Credential operation failed.'] }; }
|
|
171
205
|
}
|
|
172
|
-
public saveCurrent(options: { keepActive: boolean; accountId?: string }): IHarnessOutcome {
|
|
206
|
+
public saveCurrent(options: { keepActive: boolean; accountId?: string }): Promise<IHarnessOutcome> {
|
|
173
207
|
return this.outcome(() => {
|
|
174
208
|
const snapshot = this.stableSnapshot();
|
|
175
209
|
if (snapshot.unavailable) throw new Error(snapshot.unavailable);
|
|
@@ -182,10 +216,13 @@ export abstract class FileHarness implements IAuthHarness {
|
|
|
182
216
|
const cleared: string[] = [];
|
|
183
217
|
try { this.store.clearSwitch(active.slotId); }
|
|
184
218
|
catch { cleared.push('The login was cleared, but its switch record could not be removed.'); }
|
|
185
|
-
return [
|
|
186
|
-
|
|
219
|
+
return [this.liveSwap
|
|
220
|
+
? `Saved ${plainText(active.label)} (${plainText(active.slotId)}) and cleared the active login. ${this.loginHint}`
|
|
221
|
+
: `Saved ${plainText(active.label)} (${plainText(active.slotId)}). Start ${this.label} to log in again.`,
|
|
222
|
+
...this.liveSwapNote('cleared'), ...cleared, ...this.runningCaveat('cleared')];
|
|
223
|
+
}, true);
|
|
187
224
|
}
|
|
188
|
-
public switchAccount(id: string): IHarnessOutcome {
|
|
225
|
+
public switchAccount(id: string): Promise<IHarnessOutcome> {
|
|
189
226
|
return this.outcome(() => {
|
|
190
227
|
const target = this.saved(this.store.read(id));
|
|
191
228
|
const snapshot = this.snapshot();
|
|
@@ -198,12 +235,13 @@ export abstract class FileHarness implements IAuthHarness {
|
|
|
198
235
|
return [`${plainText(target.label)} is already active; its latest credential is saved.`, ...this.recordActivation(target.slotId, id)];
|
|
199
236
|
}
|
|
200
237
|
this.apply(target.slotId, previous, target);
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
238
|
+
const activated = `Activated ${plainText(target.label)} (${plainText(target.slotId)}).`;
|
|
239
|
+
return [this.liveSwap ? activated : `${activated} Start ${this.label} to use it.`,
|
|
240
|
+
...this.liveSwapNote('replaced'), ...this.recordActivation(target.slotId, id), ...this.runningCaveat('replaced')];
|
|
241
|
+
}, true);
|
|
204
242
|
}
|
|
205
|
-
public removeAccount(id: string): IHarnessOutcome {
|
|
206
|
-
return this.outcome(() => { const account = this.store.read(id); this.store.remove(id); return `Removed saved login for ${plainText(account.label)}; the active login is preserved.`; });
|
|
243
|
+
public removeAccount(id: string): Promise<IHarnessOutcome> {
|
|
244
|
+
return this.outcome(() => { const account = this.store.read(id); this.store.remove(id); return `Removed saved login for ${plainText(account.label)}; the active login is preserved.`; }, false);
|
|
207
245
|
}
|
|
208
246
|
public diagnose(): IHarnessOutcome {
|
|
209
247
|
try {
|
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> {
|