@modelprofile.com/authswitch 3.2.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +3 -3
- package/dist_ts/accounts.d.ts +63 -13
- package/dist_ts/accounts.js +199 -31
- package/dist_ts/classes.accountlist.d.ts +0 -24
- package/dist_ts/classes.accountlist.js +2 -213
- package/dist_ts/classes.claudecodeharness.d.ts +61 -3
- 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 +17 -4
- package/dist_ts/classes.claudestatus.js +89 -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 +29 -7
- package/dist_ts/classes.cli.js +172 -78
- package/dist_ts/classes.codexharness.d.ts +6 -2
- package/dist_ts/classes.codexharness.js +7 -3
- package/dist_ts/classes.codexstatus.d.ts +5 -4
- package/dist_ts/classes.codexstatus.js +36 -19
- package/dist_ts/classes.credentialstore.d.ts +28 -2
- package/dist_ts/classes.credentialstore.js +41 -12
- package/dist_ts/classes.fileharness.d.ts +39 -19
- package/dist_ts/classes.fileharness.js +58 -24
- package/dist_ts/classes.limits.d.ts +46 -7
- package/dist_ts/classes.limits.js +106 -32
- 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 +6 -2
- package/dist_ts/classes.opencodeharness.js +9 -5
- package/dist_ts/classes.operations.js +3 -2
- package/dist_ts/classes.tui.js +10 -6
- 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 +76 -15
- package/dist_ts/interfaces.list.d.ts +7 -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 +360 -106
- package/ts/00_commitinfo_data.ts +3 -3
- package/ts/accounts.ts +202 -34
- package/ts/classes.accountlist.ts +2 -216
- package/ts/classes.claudecodeharness.ts +126 -13
- package/ts/classes.claudecodelocks.ts +132 -0
- package/ts/classes.claudestatus.ts +88 -54
- package/ts/classes.claudetokenrefresh.ts +85 -0
- package/ts/classes.cli.ts +163 -63
- package/ts/classes.codexharness.ts +7 -3
- package/ts/classes.codexstatus.ts +29 -18
- package/ts/classes.credentialstore.ts +53 -9
- package/ts/classes.fileharness.ts +69 -31
- package/ts/classes.limits.ts +126 -33
- package/ts/classes.listrenderer.ts +328 -0
- package/ts/classes.login.ts +1 -1
- package/ts/classes.opencodeharness.ts +9 -5
- package/ts/classes.operations.ts +2 -1
- package/ts/classes.tui.ts +9 -5
- 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 +78 -6
- package/ts/interfaces.list.ts +7 -1
- package/ts/plugins.ts +9 -0
- package/ts/ratelimit.ts +14 -0
- package/ts/watchpolicy.ts +121 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
|
-
import { writeSecretFileAtomically } from './helpers.js';
|
|
2
|
+
import { errorCode, pause, writeSecretFileAtomically } from './helpers.js';
|
|
3
3
|
|
|
4
4
|
export const credentialRecord = (value: unknown): Record<string, unknown> => {
|
|
5
5
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Unsupported credential document.');
|
|
@@ -64,11 +64,29 @@ export interface IStoredCredential {
|
|
|
64
64
|
credential: Record<string, unknown>;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/** The root of authswitch's own state: `AUTHSWITCH_HOME`, or `~/.authswitch`. */
|
|
68
|
+
export const authSwitchHome = (envArg: NodeJS.ProcessEnv = process.env): string =>
|
|
69
|
+
envArg.AUTHSWITCH_HOME || plugins.path.join(plugins.os.homedir(), '.authswitch');
|
|
70
|
+
|
|
71
|
+
export interface ICredentialStoreOptions {
|
|
72
|
+
/**
|
|
73
|
+
* How long an operation waits for another authswitch operation on the same store, 45 seconds by default: longer
|
|
74
|
+
* than the longest operation that holds the lock -- a Claude Code login refresh (30 s) plus the 15 s a native
|
|
75
|
+
* write waits for Claude Code's own locks -- so such operations queue instead of being told the store is locked.
|
|
76
|
+
*/
|
|
77
|
+
lockTimeoutMs?: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const DEFAULT_LOCK_TIMEOUT_MS = 45_000;
|
|
81
|
+
const LOCK_RETRY_MS = 50;
|
|
82
|
+
|
|
67
83
|
/** A single atomic, owner-only record keeps the saved credential and its identity together. */
|
|
68
84
|
export class CredentialStore {
|
|
69
85
|
public readonly dir: string;
|
|
70
|
-
|
|
86
|
+
private readonly lockTimeoutMs: number;
|
|
87
|
+
constructor(harness: string, root = authSwitchHome(), optionsArg: ICredentialStoreOptions = {}) {
|
|
71
88
|
this.dir = plugins.path.join(root, harness);
|
|
89
|
+
this.lockTimeoutMs = optionsArg.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
|
|
72
90
|
}
|
|
73
91
|
public id(slot: string, identity: string): string { return credentialHash(JSON.stringify([slot, identity])); }
|
|
74
92
|
private file(id: string): string {
|
|
@@ -89,12 +107,22 @@ export class CredentialStore {
|
|
|
89
107
|
.map(name => this.read(name.slice(0, -5))).sort((a, b) => b.savedAt.localeCompare(a.savedAt));
|
|
90
108
|
}
|
|
91
109
|
public save(entry: Omit<IStoredCredential, 'schemaVersion' | 'id' | 'savedAt'>): IStoredCredential {
|
|
92
|
-
|
|
110
|
+
return this.write({ ...entry, schemaVersion: 1, id: this.id(entry.slotId, entry.identity), savedAt: new Date().toISOString() },
|
|
111
|
+
'Saved credential verification failed; the active login was preserved.');
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Replaces a saved login's credential in place, such as with refreshed tokens: the account, its identity and when
|
|
115
|
+
* it was saved stay as they are. The caller holds the lock and has checked the record it replaces.
|
|
116
|
+
*/
|
|
117
|
+
public replaceCredential(id: string, credential: Record<string, unknown>): IStoredCredential {
|
|
118
|
+
return this.write({ ...this.read(id), credential }, 'The refreshed login could not be verified after saving it.');
|
|
119
|
+
}
|
|
120
|
+
private write(record: IStoredCredential, failureArg: string): IStoredCredential {
|
|
93
121
|
plugins.fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
94
122
|
plugins.fs.chmodSync(this.dir, 0o700);
|
|
95
123
|
const raw = JSON.stringify(record, null, 2) + '\n';
|
|
96
124
|
writeSecretFileAtomically(this.file(record.id), raw);
|
|
97
|
-
if (readCredentialDocument(this.file(record.id)).raw !== raw) throw new Error(
|
|
125
|
+
if (readCredentialDocument(this.file(record.id)).raw !== raw) throw new Error(failureArg);
|
|
98
126
|
return this.read(record.id);
|
|
99
127
|
}
|
|
100
128
|
public remove(id: string): void { this.read(id); plugins.fs.unlinkSync(this.file(id)); }
|
|
@@ -124,13 +152,29 @@ export class CredentialStore {
|
|
|
124
152
|
plugins.fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
125
153
|
writeSecretFileAtomically(this.switchFile, JSON.stringify({ schemaVersion: 1, switches: records }, null, 2) + '\n');
|
|
126
154
|
}
|
|
127
|
-
|
|
155
|
+
/**
|
|
156
|
+
* Runs `action` as the only authswitch operation on this store; the lock is held until an asynchronous action settles.
|
|
157
|
+
*
|
|
158
|
+
* An operation that finds the store locked waits for it, up to the store's lock timeout, so concurrent operations
|
|
159
|
+
* (a manual switch and a watch refreshing a saved login) run one after the other instead of failing. The signal
|
|
160
|
+
* ends the wait early; an action that has started is never interrupted by it.
|
|
161
|
+
*/
|
|
162
|
+
public async locked<T>(action: () => T | Promise<T>, optionsArg: { signal?: AbortSignal } = {}): Promise<T> {
|
|
128
163
|
plugins.fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
129
164
|
const file = plugins.path.join(this.dir, '.lock');
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
165
|
+
const deadline = Date.now() + this.lockTimeoutMs;
|
|
166
|
+
let fd: number | undefined;
|
|
167
|
+
while (fd === undefined) {
|
|
168
|
+
optionsArg.signal?.throwIfAborted();
|
|
169
|
+
try { fd = plugins.fs.openSync(file, 'wx', 0o600); }
|
|
170
|
+
catch (error) {
|
|
171
|
+
if (errorCode(error) !== 'EEXIST' || Date.now() >= deadline) {
|
|
172
|
+
throw new Error(`Account storage is locked. Close other authswitch operations; if none remain, remove ${file}.`);
|
|
173
|
+
}
|
|
174
|
+
await pause(Math.min(LOCK_RETRY_MS, deadline - Date.now()), optionsArg.signal);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
try { return await action(); }
|
|
134
178
|
finally { plugins.fs.closeSync(fd); plugins.fs.unlinkSync(file); }
|
|
135
179
|
}
|
|
136
180
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IAuthHarness, IHarnessAccountStatus, IHarnessCredentialDrift, IHarnessOutcome, IHarnessProcessControl, IHarnessState } from './interfaces.harness.js';
|
|
1
|
+
import type { IAuthHarness, IHarnessAccountStatus, IHarnessCredentialDrift, IHarnessOutcome, IHarnessProcessControl, IHarnessState, IHarnessStatusOptions } from './interfaces.harness.js';
|
|
2
2
|
import { credentialHash, CredentialStore, type IStoredCredential, type ISwitchRecord } from './classes.credentialstore.js';
|
|
3
3
|
import { pauseSynchronously } from './helpers.js';
|
|
4
4
|
import { plainText } from './formatting.js';
|
|
@@ -25,20 +25,32 @@ export abstract class FileHarness implements IAuthHarness {
|
|
|
25
25
|
protected abstract apply(slotId: string, previous: IFileAccount | undefined, target: IFileAccount | undefined): void;
|
|
26
26
|
/** The harness's own running instances. Detection informs the caller; it never blocks a write. */
|
|
27
27
|
public abstract readonly processes: IHarnessProcessControl;
|
|
28
|
+
/** Whether running instances pick up a swapped login by themselves; see `IAuthHarness.liveSwap`. */
|
|
29
|
+
public abstract readonly liveSwap: boolean;
|
|
30
|
+
/** Whether `authswitch watch` may switch this harness by itself; see `IAuthHarness.autoSwitch`. */
|
|
31
|
+
public abstract readonly autoSwitch: boolean;
|
|
28
32
|
/** Raw bytes of the native files a snapshot's credentials are taken from, in a fixed order. */
|
|
29
33
|
protected abstract nativeSources(): (string | null)[];
|
|
30
34
|
protected abstract inspect(credential: Record<string, unknown>, slotId: string): IFileAccount;
|
|
31
|
-
public abstract readAccountStatus(id: string): Promise<IHarnessAccountStatus>;
|
|
35
|
+
public abstract readAccountStatus(id: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus>;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Runs a read and write of the native login under the harness's own write locks, so it never interleaves
|
|
39
|
+
* with a running instance's token refresh or save. A harness without such a protocol runs the action as it is.
|
|
40
|
+
*/
|
|
41
|
+
protected async nativeTransaction(actionArg: () => string[]): Promise<string[]> {
|
|
42
|
+
return actionArg();
|
|
43
|
+
}
|
|
32
44
|
|
|
33
45
|
/**
|
|
34
46
|
* A snapshot proven not to have been taken across a native write.
|
|
35
47
|
*
|
|
36
48
|
* Saving a login without replacing it writes no native file, so it must not require the harness
|
|
37
|
-
* to be stopped. A running harness does rotate its tokens on its own schedule, and
|
|
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 { 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 { IHarnessCredentialDrift, TUsageSeverity } from './interfaces.harness.js';
|
|
5
|
+
import type { IAccountType, IAuthHarness, IHarnessBilling, IHarnessCredentialDrift, TUsageSeverity } from './interfaces.harness.js';
|
|
6
6
|
import { dim, plainText } from './formatting.js';
|
|
7
7
|
|
|
8
8
|
type TListedAccount = IHarnessAccountList['accounts'][number];
|
|
9
9
|
|
|
10
|
-
const UNAVAILABLE = 'n/a';
|
|
10
|
+
export const UNAVAILABLE = 'n/a';
|
|
11
11
|
const NONE = 'none';
|
|
12
12
|
|
|
13
|
+
/** What the projections need to know about a harness beyond its account list. */
|
|
14
|
+
export type THarnessTraits = Pick<IAuthHarness, 'id' | 'liveSwap' | 'renewalUnavailableReason'>;
|
|
15
|
+
|
|
16
|
+
/** The declared traits of a listed harness; a list is always projected with the harnesses it was read from. */
|
|
17
|
+
const traitsOf = (harnessesArg: readonly THarnessTraits[], harnessArg: IHarnessAccountList): THarnessTraits => {
|
|
18
|
+
const traits = harnessesArg.find(harness => harness.id === harnessArg.id);
|
|
19
|
+
if (traits === undefined) throw new Error(`The account list names harness ${plainText(harnessArg.id)}, which was not passed with it.`);
|
|
20
|
+
return traits;
|
|
21
|
+
};
|
|
22
|
+
|
|
13
23
|
/**
|
|
14
24
|
* Provider naming stays data-driven.
|
|
15
25
|
*
|
|
@@ -46,6 +56,47 @@ const accountKeys = (harnessArg: IHarnessAccountList, accountArg: TListedAccount
|
|
|
46
56
|
...(accountArg.slotId === undefined ? {} : { slotId: plainText(accountArg.slotId) }),
|
|
47
57
|
});
|
|
48
58
|
|
|
59
|
+
/** The account's plan, sanitised like every other label and copied so that no two rows share one object. */
|
|
60
|
+
export const accountType = (accountArg: TListedAccount): IAccountType | null => {
|
|
61
|
+
const subscription = accountArg.status.summary?.subscription;
|
|
62
|
+
return subscription === undefined ? null : { plan: plainText(subscription.plan), source: subscription.source };
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** The account's reported billing fields, sanitised and copied per row; null when the provider reported none. */
|
|
66
|
+
export const accountBilling = (accountArg: TListedAccount): IHarnessBilling | null => {
|
|
67
|
+
const billing = accountArg.status.summary?.billing;
|
|
68
|
+
if (billing === undefined) return null;
|
|
69
|
+
const date = (valueArg: string | undefined) => valueArg === undefined ? undefined : plainText(valueArg);
|
|
70
|
+
return Object.fromEntries(Object.entries({
|
|
71
|
+
hasActiveSubscription: billing.hasActiveSubscription, autoRenew: billing.autoRenew,
|
|
72
|
+
renewsAt: date(billing.renewsAt), cancelsAt: date(billing.cancelsAt), expiresAt: date(billing.expiresAt),
|
|
73
|
+
}).filter(([, value]) => value !== undefined)) as IHarnessBilling;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** The next billing event a provider reported: a renewal it will charge, or the end of a subscription it will not renew. */
|
|
77
|
+
export interface IRenewal {
|
|
78
|
+
at: string;
|
|
79
|
+
kind: 'auto-renews' | 'cancels';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Read from the reported fields only; a missing or unparseable date is no event, never an inferred one. */
|
|
83
|
+
export const nextRenewal = (billingArg: IHarnessBilling | null): IRenewal | null => {
|
|
84
|
+
if (billingArg === null) return null;
|
|
85
|
+
const known = (valueArg: string | undefined): valueArg is string => valueArg !== undefined && Number.isFinite(Date.parse(valueArg));
|
|
86
|
+
if (billingArg.autoRenew === true && known(billingArg.renewsAt)) return { at: billingArg.renewsAt, kind: 'auto-renews' };
|
|
87
|
+
if (known(billingArg.cancelsAt)) return { at: billingArg.cancelsAt, kind: 'cancels' };
|
|
88
|
+
if (billingArg.autoRenew === false && known(billingArg.expiresAt)) return { at: billingArg.expiresAt, kind: 'cancels' };
|
|
89
|
+
return null;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** The Renewal cell: the local date, and beneath it whether the subscription renews or ends then; a dim `n/a` otherwise. */
|
|
93
|
+
export const renewalCell = (billingArg: IHarnessBilling | null): plugins.smartconsole.TText => {
|
|
94
|
+
const renewal = nextRenewal(billingArg);
|
|
95
|
+
return renewal === null ? plugins.smartconsole.color.text(UNAVAILABLE, { dim: true })
|
|
96
|
+
: plugins.smartconsole.color.concat(`${localDate(renewal.at)}\n`,
|
|
97
|
+
plugins.smartconsole.color.text(renewal.kind, renewal.kind === 'cancels' ? { foreground: 'orange' } : { dim: true }));
|
|
98
|
+
};
|
|
99
|
+
|
|
49
100
|
const harnessProblem = (harnessArg: IHarnessAccountList): string | null =>
|
|
50
101
|
harnessArg.problems.length ? harnessArg.problems.map(plainText).join(' ') : null;
|
|
51
102
|
|
|
@@ -76,6 +127,9 @@ const byProviderThenAccount = <TRow extends TCondensedRow>(rowsArg: TRow[]): TRo
|
|
|
76
127
|
/** One colour line per harness: an OpenCode installation's slots share it. */
|
|
77
128
|
const harnessRuns = (rowArg: TCondensedRow): string => rowArg.harnessId;
|
|
78
129
|
|
|
130
|
+
/** One Provider cell per provider name within a harness; two harnesses that share a label stay apart. */
|
|
131
|
+
const providerRuns = (rowArg: TCondensedRow): string => JSON.stringify([rowArg.harnessId, rowArg.provider]);
|
|
132
|
+
|
|
79
133
|
/** One divider run per account identity; a provider-level row without an account is its own run. */
|
|
80
134
|
const accountRuns = (rowArg: TCondensedRow): string => JSON.stringify([rowArg.harnessId, rowArg.slotId ?? null, rowArg.accountId]);
|
|
81
135
|
|
|
@@ -87,44 +141,49 @@ const collectNotes = (rowsArg: { provider: string; account: string | null; unava
|
|
|
87
141
|
[...new Set(rowsArg.map(noteFor).filter((note): note is string => note !== null))];
|
|
88
142
|
|
|
89
143
|
/** The drift a switch record proves, carried into the row document with both accounts named. */
|
|
90
|
-
const activeDrift = (
|
|
144
|
+
const activeDrift = (traitsArg: THarnessTraits, driftArg: IHarnessCredentialDrift): IActiveAccountDrift => ({
|
|
91
145
|
expectedAccountId: driftArg.expectedAccountId, expectedAccount: driftArg.expectedLabel === null ? null : plainText(driftArg.expectedLabel),
|
|
92
146
|
currentAccountId: driftArg.currentAccountId, currentAccount: driftArg.currentLabel === null ? null : plainText(driftArg.currentLabel),
|
|
93
|
-
currentIsSaved: driftArg.currentIsSaved, switchedAt: driftArg.switchedAt, reason: credentialDriftNote(
|
|
147
|
+
currentIsSaved: driftArg.currentIsSaved, switchedAt: driftArg.switchedAt, reason: credentialDriftNote(traitsArg, driftArg),
|
|
94
148
|
});
|
|
95
149
|
|
|
96
150
|
/**
|
|
97
|
-
* One row per account and limit type, projected from the account list.
|
|
151
|
+
* One row per account and limit type, projected from the account list and the harnesses it was read from.
|
|
98
152
|
*
|
|
99
|
-
* Nothing here talks to a provider: the list is already the
|
|
100
|
-
*
|
|
153
|
+
* Nothing here talks to a provider: the list is already the per-account-isolated lookup, so a single
|
|
154
|
+
* failing account costs its own rows a number and nothing else. A harness that declares its provider
|
|
155
|
+
* never reports renewal dates gets one note instead of an unexplained `n/a` per account.
|
|
101
156
|
*/
|
|
102
|
-
export const accountLimits = (listArg: IAccountList): IAccountLimits => {
|
|
157
|
+
export const accountLimits = (listArg: IAccountList, harnessesArg: readonly THarnessTraits[]): IAccountLimits => {
|
|
103
158
|
const now = Date.parse(listArg.generatedAt);
|
|
104
159
|
const rows: IAccountLimitRow[] = [];
|
|
160
|
+
const renewalNotes: string[] = [];
|
|
105
161
|
for (const harness of listArg.harnesses) {
|
|
106
162
|
const problem = harnessProblem(harness);
|
|
107
163
|
const empty = {
|
|
108
164
|
limitType: null, scope: null, windowSeconds: null, usedPercent: null, resetAt: null, resetsIn: UNAVAILABLE, severity: null, headline: false,
|
|
109
165
|
} as const;
|
|
110
166
|
if (problem !== null) {
|
|
111
|
-
rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, ...empty, unavailableReason: problem });
|
|
167
|
+
rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, accountType: null, billing: null, ...empty, unavailableReason: problem });
|
|
112
168
|
continue;
|
|
113
169
|
}
|
|
114
170
|
if (!harness.accounts.length) {
|
|
115
|
-
rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, ...empty,
|
|
171
|
+
rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, accountType: null, billing: null, ...empty,
|
|
116
172
|
unavailableReason: harness.saveUnavailableReason === null ? 'No accounts are known for this harness.' : plainText(harness.saveUnavailableReason) });
|
|
117
173
|
continue;
|
|
118
174
|
}
|
|
175
|
+
const renewalUnavailable = traitsOf(harnessesArg, harness).renewalUnavailableReason;
|
|
176
|
+
if (renewalUnavailable !== undefined) renewalNotes.push(`${plainText(harness.label)}: ${plainText(renewalUnavailable)}`);
|
|
119
177
|
for (const account of harness.accounts) {
|
|
178
|
+
const identity = () => ({ ...accountKeys(harness, account), isActive: account.isActive, accountType: accountType(account), billing: accountBilling(account) });
|
|
120
179
|
const windows = orderedUsageWindows(account.status.summary?.usageWindows);
|
|
121
180
|
if (!windows.length) {
|
|
122
|
-
rows.push({ ...
|
|
181
|
+
rows.push({ ...identity(), ...empty, unavailableReason: missingLimitReason(account) });
|
|
123
182
|
continue;
|
|
124
183
|
}
|
|
125
184
|
for (const window of windows) {
|
|
126
185
|
rows.push({
|
|
127
|
-
...
|
|
186
|
+
...identity(),
|
|
128
187
|
limitType: plainText(window.label), scope: window.scope ?? 'account', windowSeconds: window.durationSeconds,
|
|
129
188
|
usedPercent: window.usedPercent, resetAt: window.resetAt, resetsIn: compactUntil(window.resetAt, now, UNAVAILABLE),
|
|
130
189
|
severity: window.severity ?? null, headline: window.headline === true, unavailableReason: null,
|
|
@@ -133,7 +192,7 @@ export const accountLimits = (listArg: IAccountList): IAccountLimits => {
|
|
|
133
192
|
}
|
|
134
193
|
}
|
|
135
194
|
const limits = byProviderThenAccount(rows);
|
|
136
|
-
return { schemaVersion: 1, generatedAt: listArg.generatedAt, complete: listArg.complete, limits, notes: collectNotes(limits) };
|
|
195
|
+
return { schemaVersion: 1, generatedAt: listArg.generatedAt, complete: listArg.complete, limits, notes: [...collectNotes(limits), ...renewalNotes] };
|
|
137
196
|
};
|
|
138
197
|
|
|
139
198
|
/**
|
|
@@ -142,36 +201,37 @@ export const accountLimits = (listArg: IAccountList): IAccountLimits => {
|
|
|
142
201
|
* A harness with no active login still gets a row: "nothing is active here" is an answer, and
|
|
143
202
|
* silently dropping the provider would read as "this provider does not exist".
|
|
144
203
|
*/
|
|
145
|
-
export const activeAccounts = (listArg: IAccountList): IActiveAccounts => {
|
|
204
|
+
export const activeAccounts = (listArg: IAccountList, harnessesArg: readonly THarnessTraits[]): IActiveAccounts => {
|
|
146
205
|
const now = Date.parse(listArg.generatedAt);
|
|
147
206
|
const rows: IActiveAccountRow[] = [];
|
|
148
207
|
const unattached: string[] = [];
|
|
149
208
|
for (const harness of listArg.harnesses) {
|
|
209
|
+
const traits = traitsOf(harnessesArg, harness);
|
|
150
210
|
const problem = harnessProblem(harness);
|
|
151
211
|
const drifts = [...harness.credentialDrift];
|
|
152
212
|
const driftFor = (slotIdArg?: string): IActiveAccountDrift | null => {
|
|
153
213
|
const index = drifts.findIndex(item => item.slotId === slotIdArg);
|
|
154
|
-
return index < 0 ? null : activeDrift(
|
|
214
|
+
return index < 0 ? null : activeDrift(traits, drifts.splice(index, 1)[0]);
|
|
155
215
|
};
|
|
156
216
|
if (problem !== null) {
|
|
157
|
-
rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, savedAt: null, savedAgo: UNAVAILABLE, source: 'none', unavailableReason: problem, drift: null });
|
|
217
|
+
rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, accountType: null, savedAt: null, savedAgo: UNAVAILABLE, source: 'none', unavailableReason: problem, drift: null });
|
|
158
218
|
continue;
|
|
159
219
|
}
|
|
160
220
|
const active = harness.accounts.filter(account => account.isActive);
|
|
161
221
|
if (!active.length) {
|
|
162
222
|
const stashed = harness.accounts.filter(account => account.isStashed);
|
|
163
223
|
rows.push({
|
|
164
|
-
harnessId: harness.id, provider: providerName(harness), account: null, accountId: null,
|
|
224
|
+
harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, accountType: null,
|
|
165
225
|
savedAt: null, savedAgo: UNAVAILABLE, source: stashed.length ? 'stash only' : 'none',
|
|
166
226
|
unavailableReason: harness.saveUnavailableReason !== null ? plainText(harness.saveUnavailableReason)
|
|
167
227
|
: stashed.length ? `No login is active; ${stashed.length} saved account(s) can be activated with authswitch ${plainText(harness.id)} use.`
|
|
168
228
|
: `No login is active and none is saved. ${plainText(harness.loginHint)}`,
|
|
169
|
-
drift: drifts.length ? activeDrift(
|
|
229
|
+
drift: drifts.length ? activeDrift(traits, drifts.shift()!) : null,
|
|
170
230
|
});
|
|
171
231
|
} else {
|
|
172
232
|
for (const account of active) {
|
|
173
233
|
rows.push({
|
|
174
|
-
...accountKeys(harness, account),
|
|
234
|
+
...accountKeys(harness, account), accountType: accountType(account),
|
|
175
235
|
savedAt: account.savedAt, savedAgo: compactSince(account.savedAt, now, UNAVAILABLE),
|
|
176
236
|
source: 'credential file',
|
|
177
237
|
unavailableReason: account.isStashed ? null : 'This active login is not saved yet; authswitch cannot restore it after a switch.',
|
|
@@ -180,7 +240,7 @@ export const activeAccounts = (listArg: IAccountList): IActiveAccounts => {
|
|
|
180
240
|
}
|
|
181
241
|
}
|
|
182
242
|
// A drifted slot with no row of its own still has to be reported, not dropped.
|
|
183
|
-
for (const drift of drifts) unattached.push(`${providerName(harness)}${drift.slotId === undefined ? '' : ` / ${plainText(drift.slotId)}`}: ${credentialDriftNote(
|
|
243
|
+
for (const drift of drifts) unattached.push(`${providerName(harness)}${drift.slotId === undefined ? '' : ` / ${plainText(drift.slotId)}`}: ${credentialDriftNote(traits, drift)}`);
|
|
184
244
|
}
|
|
185
245
|
const active = byProviderThenAccount(rows);
|
|
186
246
|
const driftNotes = active.filter(row => row.drift !== null)
|
|
@@ -188,37 +248,70 @@ export const activeAccounts = (listArg: IAccountList): IActiveAccounts => {
|
|
|
188
248
|
return { schemaVersion: 1, generatedAt: listArg.generatedAt, complete: listArg.complete, active, notes: [...collectNotes(active), ...driftNotes, ...unattached] };
|
|
189
249
|
};
|
|
190
250
|
|
|
191
|
-
|
|
251
|
+
/**
|
|
252
|
+
* The Account cell: the label, and the account type beneath it, dim. An unknown type keeps its line as
|
|
253
|
+
* `n/a`, so every account cell has the same shape; a provider row without an account has no type to name.
|
|
254
|
+
* A view without a Provider column names the credential slot in the type line (`openai · Pro`) when the
|
|
255
|
+
* harness owns several.
|
|
256
|
+
*/
|
|
257
|
+
export const accountCell = (rowArg: Pick<IAccountLimitRow, 'account' | 'accountType' | 'slotId'>, slotsArg: boolean): plugins.smartconsole.TText => {
|
|
258
|
+
if (rowArg.account === null) return UNAVAILABLE;
|
|
259
|
+
const type = [slotsArg ? rowArg.slotId : undefined, accountTypeText(rowArg.accountType) ?? undefined].filter(part => part !== undefined).join(' · ');
|
|
260
|
+
return plugins.smartconsole.color.concat(`${rowArg.account}\n`, plugins.smartconsole.color.text(type || UNAVAILABLE, { dim: true, bold: false }));
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const percent = (rowArg: IAccountLimitRow): string => rowArg.usedPercent === null ? UNAVAILABLE : usagePercentText(rowArg.usedPercent);
|
|
192
264
|
|
|
193
265
|
const SEVERITY_COLORS: Record<TUsageSeverity, plugins.smartconsole.TColorName | undefined> = { normal: undefined, warning: 'orange', critical: 'red' };
|
|
194
266
|
|
|
195
267
|
/** The provider's own reading colours a window; without one, n/a and exhausted windows stand out. */
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
:
|
|
268
|
+
export const usageColor = (severityArg: TUsageSeverity | null, usedPercentArg: number | null): plugins.smartconsole.TColorName | undefined =>
|
|
269
|
+
severityArg !== null ? SEVERITY_COLORS[severityArg]
|
|
270
|
+
: usedPercentArg === null ? 'orange' : usedPercentArg >= 100 ? 'red' : undefined;
|
|
271
|
+
const percentColor = (rowArg: IAccountLimitRow): plugins.smartconsole.TColorName | undefined => usageColor(rowArg.severity, rowArg.usedPercent);
|
|
272
|
+
|
|
273
|
+
/** Which parts of the limits table a view shows. */
|
|
274
|
+
export interface ILimitsTableOptions {
|
|
275
|
+
/** The Provider column and the per-harness colour line, for a view across harnesses. */
|
|
276
|
+
provider: boolean;
|
|
277
|
+
renewal: boolean;
|
|
278
|
+
/** Whether the harness owns several credential slots, named in the account cell when there is no Provider column. */
|
|
279
|
+
slots: boolean;
|
|
280
|
+
}
|
|
199
281
|
|
|
200
|
-
/** Condensed presentation for `authswitch limits` and `authswitch active
|
|
282
|
+
/** Condensed presentation for `authswitch limits` and `authswitch active`, and the limits table other views embed. */
|
|
201
283
|
export class CondensedRenderer {
|
|
202
284
|
constructor(private readonly out: plugins.smartconsole.SmartConsole) {}
|
|
203
285
|
|
|
204
286
|
private notes(notesArg: string[], prefixArg: string): void {
|
|
205
287
|
if (!notesArg.length) return;
|
|
206
288
|
process.stdout.write('\n');
|
|
207
|
-
for (const note of notesArg)
|
|
289
|
+
for (const note of notesArg) consoleNote(note, prefixArg);
|
|
208
290
|
}
|
|
209
291
|
|
|
210
292
|
public async renderLimits(limitsArg: IAccountLimits): Promise<void> {
|
|
211
293
|
consoleHeading('Account limits');
|
|
212
294
|
if (!limitsArg.limits.length) { process.stdout.write(`${dim('No harnesses registered.')}\n`); return; }
|
|
213
|
-
await
|
|
214
|
-
|
|
215
|
-
|
|
295
|
+
await this.limitsTable(limitsArg.limits, { provider: true, renewal: true, slots: false });
|
|
296
|
+
this.notes(limitsArg.notes, 'n/a — ');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** One row per account and limit type; `rowsArg` must be ordered by `byProviderThenAccount` and is rendered as it is. */
|
|
300
|
+
public async limitsTable(rowsArg: IAccountLimitRow[], optionsArg: ILimitsTableOptions): Promise<void> {
|
|
301
|
+
// The span callbacks record these exact rows, so the table renders this array as it is.
|
|
302
|
+
const accountSpan = plugins.smartconsole.rowSpansBy(rowsArg, accountRuns);
|
|
303
|
+
const columns: plugins.smartconsole.IBackendTableColumn<IAccountLimitRow>[] = [
|
|
304
|
+
...(optionsArg.provider ? [{ key: 'provider', title: 'Provider', value: (row: IAccountLimitRow) => row.provider, rowSpan: plugins.smartconsole.rowSpansBy(rowsArg, providerRuns) }] : []),
|
|
305
|
+
{ key: 'account', title: 'Account', value: row => row.account ?? UNAVAILABLE, render: row => accountCell(row, optionsArg.slots),
|
|
306
|
+
style: row => ({ bold: row.isActive }), rowSpan: accountSpan },
|
|
216
307
|
{ key: 'limit', title: 'Limit type', value: row => row.limitType ?? UNAVAILABLE },
|
|
217
308
|
{ key: 'used', title: 'Used %', value: percent, align: 'right',
|
|
218
309
|
style: row => ({ foreground: percentColor(row) }) },
|
|
219
310
|
{ key: 'reset', title: 'Resets in', value: row => row.resetsIn, align: 'right' },
|
|
220
|
-
|
|
221
|
-
|
|
311
|
+
...(optionsArg.renewal ? [{ key: 'renewal', title: 'Renewal', value: (row: IAccountLimitRow) => nextRenewal(row.billing)?.at ?? UNAVAILABLE,
|
|
312
|
+
render: (row: IAccountLimitRow) => renewalCell(row.billing), rowSpan: accountSpan }] : []),
|
|
313
|
+
];
|
|
314
|
+
await consoleTable(this.out, rowsArg, columns, optionsArg.provider ? { divider: accountRuns, colorLine: harnessRuns } : { divider: accountRuns });
|
|
222
315
|
}
|
|
223
316
|
|
|
224
317
|
public async renderActive(activeArg: IActiveAccounts): Promise<void> {
|