@modelprofile.com/authswitch 3.1.0 → 3.3.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 +1 -1
- package/dist_ts/accounts.d.ts +25 -2
- package/dist_ts/accounts.js +112 -15
- package/dist_ts/classes.accountlist.d.ts +10 -3
- package/dist_ts/classes.accountlist.js +71 -34
- package/dist_ts/classes.claudecodeharness.d.ts +2 -2
- package/dist_ts/classes.claudecodeharness.js +3 -3
- package/dist_ts/classes.claudestatus.d.ts +2 -2
- package/dist_ts/classes.claudestatus.js +7 -6
- package/dist_ts/classes.cli.d.ts +15 -0
- package/dist_ts/classes.cli.js +51 -16
- package/dist_ts/classes.codexharness.d.ts +2 -2
- package/dist_ts/classes.codexharness.js +3 -3
- package/dist_ts/classes.codexstatus.d.ts +3 -3
- package/dist_ts/classes.codexstatus.js +12 -11
- package/dist_ts/classes.fileharness.d.ts +2 -2
- package/dist_ts/classes.limits.js +56 -18
- package/dist_ts/classes.opencodeharness.d.ts +2 -2
- package/dist_ts/classes.opencodeharness.js +3 -3
- package/dist_ts/classes.tui.js +7 -4
- package/dist_ts/interfaces.harness.d.ts +17 -5
- package/dist_ts/interfaces.list.d.ts +5 -1
- package/package.json +2 -2
- package/readme.md +102 -22
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/accounts.ts +105 -12
- package/ts/classes.accountlist.ts +74 -35
- package/ts/classes.claudecodeharness.ts +3 -3
- package/ts/classes.claudestatus.ts +7 -6
- package/ts/classes.cli.ts +56 -15
- package/ts/classes.codexharness.ts +3 -3
- package/ts/classes.codexstatus.ts +12 -11
- package/ts/classes.fileharness.ts +2 -2
- package/ts/classes.limits.ts +63 -19
- package/ts/classes.opencodeharness.ts +3 -3
- package/ts/classes.tui.ts +6 -3
- package/ts/interfaces.harness.ts +19 -2
- package/ts/interfaces.list.ts +5 -1
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
|
-
import { accountQuotaSummary, orderedUsageWindows, readAccountRows, duration, until } from './accounts.js';
|
|
2
|
+
import { accountQuotaSummary, accountTypeText, orderedUsageWindows, readAccountRows, duration, until } from './accounts.js';
|
|
3
3
|
import type { IAuthHarness, IHarnessAccountStatus, IHarnessStatusFact } from './interfaces.harness.js';
|
|
4
4
|
import type { IAccountList, IHarnessAccountList } from './interfaces.list.js';
|
|
5
5
|
import { bold, dim, orange, plainText } from './formatting.js';
|
|
6
6
|
|
|
7
7
|
type TListedAccount = IHarnessAccountList['accounts'][number];
|
|
8
|
+
type TNamedAccount = TListedAccount & { name: string };
|
|
8
9
|
interface IDisplayFact { account: number; label: string; value: string; }
|
|
9
10
|
|
|
10
11
|
/** Allowlist the public data contract: never serialize a harness or its credential-bearing internals. */
|
|
@@ -53,20 +54,60 @@ export const readAccountList = async (harnessesArg: IAuthHarness[]): Promise<IAc
|
|
|
53
54
|
export const consoleWidth = (): number => process.stdout.columns ?? 100;
|
|
54
55
|
export const consoleHeading = (textArg: string): void => { process.stdout.write(`\n${bold(textArg)}\n`); };
|
|
55
56
|
|
|
56
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* One table implementation for every command, including the label-per-line fallback on narrow terminals.
|
|
59
|
+
*
|
|
60
|
+
* `groupsArg` is smartconsole's optional visual grouping: a divider between runs of consecutive rows with
|
|
61
|
+
* different keys, and a colour line along the left edge per run. Keys are identities, never display labels,
|
|
62
|
+
* and the caller orders the rows so that every group is one run. Below 40 columns the fallback separates
|
|
63
|
+
* divider runs with one dim rule line; it has no left edge, so it draws no colour line, and it repeats a
|
|
64
|
+
* spanning cell on every row it covers. A column's `render` content is what both layouts show.
|
|
65
|
+
*/
|
|
57
66
|
export const consoleTable = async <TRow>(
|
|
58
67
|
outArg: plugins.smartconsole.SmartConsole,
|
|
59
68
|
rowsArg: TRow[],
|
|
60
69
|
columnsArg: plugins.smartconsole.IBackendTableColumn<TRow>[],
|
|
70
|
+
groupsArg?: NoInfer<plugins.smartconsole.IBackendTableGroups<TRow>>,
|
|
61
71
|
): Promise<void> => {
|
|
62
72
|
if (!rowsArg.length) return;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
73
|
+
const width = consoleWidth();
|
|
74
|
+
if (width < 40) {
|
|
75
|
+
// smartconsole's run semantics: null and undefined are the same key.
|
|
76
|
+
const dividerKeys = rowsArg.map(row => groupsArg?.divider?.(row) ?? null);
|
|
77
|
+
const rule = ` ${dim('─'.repeat(Math.max(1, width - 4)))}\n\n`;
|
|
78
|
+
const content = (column: plugins.smartconsole.IBackendTableColumn<TRow>, row: TRow): string => column.render
|
|
79
|
+
? plugins.smartconsole.color.plain(column.render(row)) : String(column.value(row) ?? 'Unavailable');
|
|
80
|
+
rowsArg.forEach((row, index) => {
|
|
81
|
+
if (index > 0 && dividerKeys[index] !== dividerKeys[index - 1]) process.stdout.write(rule);
|
|
82
|
+
process.stdout.write(columnsArg.map(column => ` ${column.title}: ${content(column, row).replace(/\n/g, '\n ')}`).join('\n') + '\n\n');
|
|
83
|
+
});
|
|
67
84
|
return;
|
|
68
85
|
}
|
|
69
|
-
await outArg.table(rowsArg, {
|
|
86
|
+
await outArg.table(rowsArg, {
|
|
87
|
+
columns: columnsArg, overflow: 'wrap', groups: groupsArg,
|
|
88
|
+
theme: { header: { bold: true, foreground: 'cyan' }, border: { dim: true } },
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** List tables that repeat one account across rows divide it from the next account by its id. */
|
|
93
|
+
const accountDividers: plugins.smartconsole.IBackendTableGroups<{ accountId: string | null }> = { divider: row => row.accountId };
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Names every account of one harness by its label, numbering each later account that repeats a label
|
|
97
|
+
* (`alice@example.com (2)`), so all tables name an account alike and the metric columns stay distinct.
|
|
98
|
+
*/
|
|
99
|
+
const nameAccounts = (accountsArg: TListedAccount[]): TNamedAccount[] => {
|
|
100
|
+
const labels = accountsArg.map(account => plainText(account.label));
|
|
101
|
+
const taken = new Set(labels);
|
|
102
|
+
return accountsArg.map((account, index) => {
|
|
103
|
+
const label = labels[index];
|
|
104
|
+
let name = label;
|
|
105
|
+
if (labels.indexOf(label) < index) {
|
|
106
|
+
for (let ordinal = 2; taken.has(name); ordinal++) name = `${label} (${ordinal})`;
|
|
107
|
+
taken.add(name);
|
|
108
|
+
}
|
|
109
|
+
return { ...account, name };
|
|
110
|
+
});
|
|
70
111
|
};
|
|
71
112
|
|
|
72
113
|
/** Shared, provider-independent list presentation. Adapters supply grouping metadata. */
|
|
@@ -75,10 +116,6 @@ export class AccountListRenderer {
|
|
|
75
116
|
private get width(): number { return consoleWidth(); }
|
|
76
117
|
private heading(textArg: string): void { consoleHeading(textArg); }
|
|
77
118
|
|
|
78
|
-
private async table<TRow>(rowsArg: TRow[], columnsArg: plugins.smartconsole.IBackendTableColumn<TRow>[]): Promise<void> {
|
|
79
|
-
await consoleTable(this.out, rowsArg, columnsArg);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
119
|
public async render(listArg: IAccountList): Promise<void> {
|
|
83
120
|
const now = Date.parse(listArg.generatedAt);
|
|
84
121
|
if (!listArg.harnesses.length) process.stdout.write('No harnesses registered.\n');
|
|
@@ -92,58 +129,60 @@ export class AccountListRenderer {
|
|
|
92
129
|
process.stdout.write(`${dim(plainText(harness.saveUnavailableReason ?? 'No known accounts. ' + harness.loginHint))}\n`);
|
|
93
130
|
continue;
|
|
94
131
|
}
|
|
95
|
-
|
|
96
|
-
|
|
132
|
+
const accounts = nameAccounts(harness.accounts);
|
|
133
|
+
await this.overview(accounts, now);
|
|
134
|
+
const schedules = accounts.flatMap(account => orderedUsageWindows(account.status.summary?.usageWindows).map(window => ({ accountId: account.id, account: account.name, window })));
|
|
97
135
|
for (const feature of [false, true]) {
|
|
98
136
|
const scopedSchedules = schedules.filter(row => (row.window.scope === 'feature') === feature);
|
|
99
137
|
if (!scopedSchedules.length) continue;
|
|
100
138
|
this.heading(feature ? 'Feature-specific limits' : 'Usage & reset schedule');
|
|
101
|
-
await this.
|
|
139
|
+
await consoleTable(this.out, scopedSchedules, [
|
|
102
140
|
{ key: 'account', title: 'Account', value: row => row.account },
|
|
103
141
|
{ key: 'window', title: 'Window', value: row => `${plainText(row.window.label)} (${duration(row.window.durationSeconds)})` },
|
|
104
142
|
{ key: 'usage', title: 'Usage', value: row => `${row.window.usedPercent}% used\n${Math.max(0, 100 - row.window.usedPercent)}% left` },
|
|
105
143
|
{ key: 'reset', title: 'Reset in', value: row => until(row.window.resetAt, now) },
|
|
106
|
-
]);
|
|
144
|
+
], accountDividers);
|
|
107
145
|
}
|
|
108
|
-
const resets =
|
|
146
|
+
const resets = accounts.flatMap(account => (account.status.summary?.resets?.details ?? []).map(reset => ({ accountId: account.id, account: account.name, reset })));
|
|
109
147
|
if (resets.length) {
|
|
110
148
|
this.heading('Earned reset details');
|
|
111
|
-
await this.
|
|
149
|
+
await consoleTable(this.out, resets, [
|
|
112
150
|
{ key: 'account', title: 'Account', value: row => row.account },
|
|
113
151
|
{ key: 'kind', title: 'Type', value: row => plainText(row.reset.kind) },
|
|
114
152
|
{ key: 'status', title: 'Status', value: row => plainText(row.reset.status) },
|
|
115
153
|
{ key: 'expiry', title: 'Expires (UTC)', value: row => row.reset.expiresAt === null ? 'Not reported' : plainText(row.reset.expiresAt).replace('T', '\n').replace('.000Z', '').replace(/Z$/, '') },
|
|
116
|
-
]);
|
|
154
|
+
], accountDividers);
|
|
117
155
|
}
|
|
118
156
|
const groups = new Map<string, IDisplayFact[]>();
|
|
119
157
|
const add = (section: string, fact: IDisplayFact) => { const group = groups.get(section) ?? []; group.push(fact); groups.set(section, group); };
|
|
120
|
-
|
|
158
|
+
accounts.forEach((account, index) => {
|
|
121
159
|
for (const fact of account.status.facts) {
|
|
122
160
|
if (!this.covered(fact, account.status)) add(plainText(fact.section ?? 'Additional information'), { account: index, label: plainText(fact.label), value: plainText(fact.value) });
|
|
123
161
|
}
|
|
124
162
|
});
|
|
125
|
-
for (const [section, facts] of groups) await this.facts(section, facts,
|
|
126
|
-
const saved =
|
|
163
|
+
for (const [section, facts] of groups) await this.facts(section, facts, accounts);
|
|
164
|
+
const saved = accounts.flatMap((account, index) => [
|
|
127
165
|
...(account.savedAt ? [{ account: index, label: 'Saved (UTC)', value: plainText(account.savedAt) }] : []),
|
|
128
166
|
...account.details.map((detail, detailIndex) => ({ account: index, label: `Note ${detailIndex + 1}`, value: plainText(detail) })),
|
|
129
167
|
]);
|
|
130
|
-
await this.facts('Saved logins', saved,
|
|
131
|
-
const issues
|
|
132
|
-
|
|
168
|
+
await this.facts('Saved logins', saved, accounts);
|
|
169
|
+
const issues: { accountId: string | null; account: string; issue: string }[] = accounts.flatMap(account =>
|
|
170
|
+
account.status.problems.map(problem => ({ accountId: account.id, account: account.name, issue: plainText(problem) })));
|
|
171
|
+
if (harness.saveUnavailableReason) issues.push({ accountId: null, account: 'Harness', issue: plainText(harness.saveUnavailableReason) });
|
|
133
172
|
if (issues.length) {
|
|
134
173
|
this.heading('Availability & actions');
|
|
135
|
-
await this.
|
|
174
|
+
await consoleTable(this.out, issues, [
|
|
136
175
|
{ key: 'account', title: 'Account', value: row => row.account },
|
|
137
176
|
{ key: 'issue', title: 'Unavailable / action needed', value: row => row.issue },
|
|
138
|
-
]);
|
|
177
|
+
], accountDividers);
|
|
139
178
|
}
|
|
140
179
|
}
|
|
141
180
|
}
|
|
142
181
|
|
|
143
|
-
private async overview(accountsArg:
|
|
182
|
+
private async overview(accountsArg: TNamedAccount[], nowArg: number): Promise<void> {
|
|
144
183
|
const rows = accountsArg;
|
|
145
184
|
const state = (account: TListedAccount) => `${account.isActive ? '* active\n' : ''}${account.isStashed ? 'saved' : 'not saved'}${account.status.problems.length ? '\n! partial status' : ''}`;
|
|
146
|
-
const plan = (account: TListedAccount) => account.status.summary?.subscription
|
|
185
|
+
const plan = (account: TListedAccount) => accountTypeText(account.status.summary?.subscription) ?? 'Unavailable';
|
|
147
186
|
const billing = (account: TListedAccount) => {
|
|
148
187
|
const info = account.status.summary?.billing;
|
|
149
188
|
if (!info) return 'Unavailable';
|
|
@@ -159,7 +198,7 @@ export class AccountListRenderer {
|
|
|
159
198
|
const resets = (account: TListedAccount) => account.status.summary?.resets === undefined ? 'Unavailable' : String(account.status.summary.resets.available);
|
|
160
199
|
const usage = (account: TListedAccount) => accountQuotaSummary(account, nowArg);
|
|
161
200
|
const columns: plugins.smartconsole.IBackendTableColumn<typeof rows[number]>[] = [
|
|
162
|
-
{ key: 'account', title: 'Account', value: row =>
|
|
201
|
+
{ key: 'account', title: 'Account', value: row => row.name, style: row => ({ bold: row.isActive }) },
|
|
163
202
|
];
|
|
164
203
|
if (this.width >= 100) columns.push(
|
|
165
204
|
{ key: 'state', title: 'Login', value: state, style: row => ({ foreground: row.isStashed ? 'green' : 'orange' }) },
|
|
@@ -174,7 +213,7 @@ export class AccountListRenderer {
|
|
|
174
213
|
{ key: 'usage', title: 'Usage / resets', value: row => `${usage(row)}\nEarned resets: ${resets(row)}` },
|
|
175
214
|
);
|
|
176
215
|
else columns.push({ key: 'status', title: 'Status', value: row => `${state(row)}\nPlan: ${plan(row)}\nBilling: ${billing(row)}\nUsage: ${usage(row)}\nEarned resets: ${resets(row)}` });
|
|
177
|
-
await this.
|
|
216
|
+
await consoleTable(this.out, rows, columns);
|
|
178
217
|
}
|
|
179
218
|
|
|
180
219
|
private covered(factArg: IHarnessStatusFact, statusArg: IHarnessAccountStatus): boolean {
|
|
@@ -191,10 +230,10 @@ export class AccountListRenderer {
|
|
|
191
230
|
}
|
|
192
231
|
|
|
193
232
|
/** Compare like metrics across accounts, keeping duplicate labels as separate rows. */
|
|
194
|
-
private async facts(titleArg: string, factsArg: IDisplayFact[], accountsArg:
|
|
233
|
+
private async facts(titleArg: string, factsArg: IDisplayFact[], accountsArg: TNamedAccount[]): Promise<void> {
|
|
195
234
|
if (!factsArg.length) return;
|
|
196
235
|
this.heading(titleArg);
|
|
197
|
-
const valueBudget = Math.max(10, Math.min(40, Math.max(...factsArg.map(fact => Math.max(fact.value.length,
|
|
236
|
+
const valueBudget = Math.max(10, Math.min(40, Math.max(...factsArg.map(fact => Math.max(fact.value.length, accountsArg[fact.account].name.length))) + 3));
|
|
198
237
|
const perPage = Math.max(1, Math.min(6, Math.floor((this.width - 30) / valueBudget)));
|
|
199
238
|
const accountIndices = accountsArg.map((_, index) => index).filter(index => factsArg.some(fact => fact.account === index));
|
|
200
239
|
for (let start = 0; start < accountIndices.length; start += perPage) {
|
|
@@ -212,12 +251,12 @@ export class AccountListRenderer {
|
|
|
212
251
|
}
|
|
213
252
|
if (this.width < 40) {
|
|
214
253
|
for (const index of indices) {
|
|
215
|
-
process.stdout.write(` Account: ${
|
|
254
|
+
process.stdout.write(` Account: ${accountsArg[index].name}\n`);
|
|
216
255
|
for (const metric of metrics.values()) if (metric.values.has(index)) process.stdout.write(` - ${metric.label}: ${metric.values.get(index)}\n`);
|
|
217
256
|
}
|
|
218
|
-
} else await this.
|
|
257
|
+
} else await consoleTable(this.out, [...metrics.values()], [
|
|
219
258
|
{ key: 'metric', title: 'Metric', value: row => row.label },
|
|
220
|
-
...indices.map(index => ({ key: String(index), title:
|
|
259
|
+
...indices.map(index => ({ key: String(index), title: accountsArg[index].name, value: (row: { values: Map<number, string> }) => row.values.get(index) ?? 'Not reported' })),
|
|
221
260
|
]);
|
|
222
261
|
}
|
|
223
262
|
}
|
|
@@ -4,7 +4,7 @@ import { CredentialStore, credentialRecord, credentialText, readCredentialDocume
|
|
|
4
4
|
import { HarnessProcesses } from './classes.harnessprocesses.js';
|
|
5
5
|
import { ClaudeAccountStatus } from './classes.claudestatus.js';
|
|
6
6
|
import { writeSecretFileAtomically } from './helpers.js';
|
|
7
|
-
import type { IHarnessAccountStatus, IHarnessProcessControl } from './interfaces.harness.js';
|
|
7
|
+
import type { IHarnessAccountStatus, IHarnessProcessControl, IHarnessStatusOptions } from './interfaces.harness.js';
|
|
8
8
|
|
|
9
9
|
export interface IClaudeCodeHarnessOptions {
|
|
10
10
|
configDir?: string;
|
|
@@ -107,8 +107,8 @@ export class ClaudeCodeHarness extends FileHarness {
|
|
|
107
107
|
throw new Error('Claude Code switch failed while updating account metadata. The outgoing login remains saved; check doctor before restarting.');
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
|
-
public async readAccountStatus(id: string): Promise<IHarnessAccountStatus> {
|
|
110
|
+
public async readAccountStatus(id: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus> {
|
|
111
111
|
const account = this.readCredential(id);
|
|
112
|
-
return this.status.read(credentialRecord(account.credential.claudeAiOauth), credentialRecord(account.credential.oauthAccount));
|
|
112
|
+
return this.status.read(credentialRecord(account.credential.claudeAiOauth), credentialRecord(account.credential.oauthAccount), optionsArg);
|
|
113
113
|
}
|
|
114
114
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { credentialRecord, credentialText } from './classes.credentialstore.js';
|
|
2
2
|
import { plainText } from './formatting.js';
|
|
3
|
-
import type { IHarnessAccountStatus, IHarnessUsageWindow, TUsageSeverity } from './interfaces.harness.js';
|
|
3
|
+
import type { IHarnessAccountStatus, IHarnessStatusOptions, IHarnessUsageWindow, TUsageSeverity } from './interfaces.harness.js';
|
|
4
4
|
|
|
5
5
|
class ClaudeStatusError extends Error {}
|
|
6
6
|
|
|
@@ -105,8 +105,9 @@ const claudeUsageWindows = (usage: Record<string, unknown>): IClaudeUsageWindows
|
|
|
105
105
|
/** Read-only Claude Code OAuth account endpoints. No refresh, inference, or browser session is used. */
|
|
106
106
|
export class ClaudeAccountStatus {
|
|
107
107
|
constructor(private readonly fetcher: typeof fetch = globalThis.fetch) {}
|
|
108
|
-
private async get(route: 'usage' | 'profile', accessToken: string): Promise<Record<string, unknown>> {
|
|
109
|
-
const
|
|
108
|
+
private async get(route: 'usage' | 'profile', accessToken: string, cancelArg?: AbortSignal): Promise<Record<string, unknown>> {
|
|
109
|
+
const timeout = AbortSignal.timeout(10_000);
|
|
110
|
+
const signal = cancelArg ? AbortSignal.any([cancelArg, timeout]) : timeout;
|
|
110
111
|
try {
|
|
111
112
|
const response = await this.fetcher(`https://api.anthropic.com/api/oauth/${route}`, {
|
|
112
113
|
method: 'GET', redirect: 'error', signal,
|
|
@@ -133,12 +134,12 @@ export class ClaudeAccountStatus {
|
|
|
133
134
|
try { return credentialRecord(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
|
|
134
135
|
catch { throw new ClaudeStatusError('The Claude account service returned invalid data.'); }
|
|
135
136
|
} catch (error) {
|
|
136
|
-
if (signal.aborted) throw new ClaudeStatusError('The Claude account request timed out.');
|
|
137
|
+
if (signal.aborted) throw new ClaudeStatusError(timeout.aborted ? 'The Claude account request timed out.' : 'The Claude account request was cancelled.');
|
|
137
138
|
if (error instanceof ClaudeStatusError) throw error;
|
|
138
139
|
throw new ClaudeStatusError('The Claude account service could not be reached.');
|
|
139
140
|
}
|
|
140
141
|
}
|
|
141
|
-
public async read(oauth: Record<string, unknown>, account: Record<string, unknown
|
|
142
|
+
public async read(oauth: Record<string, unknown>, account: Record<string, unknown>, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
|
|
142
143
|
const result: IHarnessAccountStatus = { facts: [], problems: [] };
|
|
143
144
|
const storedPlan = credentialText(oauth.subscriptionType);
|
|
144
145
|
if (storedPlan) result.summary = { subscription: { plan: storedPlan, source: 'stored' } };
|
|
@@ -148,7 +149,7 @@ export class ClaudeAccountStatus {
|
|
|
148
149
|
}
|
|
149
150
|
const token = credentialText(oauth.accessToken);
|
|
150
151
|
if (!token) return { facts: [], problems: ['No usable Claude Code access token is available.'] };
|
|
151
|
-
const [profile, usage] = await Promise.allSettled([this.get('profile', token), this.get('usage', token)]);
|
|
152
|
+
const [profile, usage] = await Promise.allSettled([this.get('profile', token, optionsArg.signal), this.get('usage', token, optionsArg.signal)]);
|
|
152
153
|
if (profile.status === 'fulfilled') {
|
|
153
154
|
try {
|
|
154
155
|
const user = credentialRecord(profile.value.account);
|
package/ts/classes.cli.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { AuthSwitchTui } from './classes.tui.js';
|
|
|
7
7
|
import { AglAuthSwitchCoordinator, AuthSwitchOperations, type TAuthSwitchCoordinator, type TAuthSwitchMutation } from './classes.operations.js';
|
|
8
8
|
import { AccountListRenderer, readAccountList } from './classes.accountlist.js';
|
|
9
9
|
import { accountLimits, activeAccounts, CondensedRenderer } from './classes.limits.js';
|
|
10
|
-
import { credentialDriftNote, duration, orderedUsageWindows, until } from './accounts.js';
|
|
10
|
+
import { credentialDriftNote, duration, orderedUsageWindows, readAccountBadges, until } from './accounts.js';
|
|
11
11
|
import { describeHarnessProcesses, describeStopOutcome } from './classes.harnessprocesses.js';
|
|
12
12
|
import { defaultPreusePrompt, PreuseError, validatePreuseOptions } from './preuse.js';
|
|
13
13
|
import type { CodexSwitcher } from './classes.codexswitcher.js';
|
|
@@ -25,6 +25,18 @@ const STOP_COMMANDS = ['use', 'stash'];
|
|
|
25
25
|
/** Read-only overviews that work across every registered harness and support --json. */
|
|
26
26
|
const OVERVIEW_COMMANDS = ['list', 'ls', 'limits', 'active'];
|
|
27
27
|
|
|
28
|
+
/** An account as prompts and reports name it: its label, qualified by its credential slot when it has one. */
|
|
29
|
+
const accountName = (accountArg: IHarnessAccount): string =>
|
|
30
|
+
`${plainText(accountArg.label)}${accountArg.slotId ? ` (${plainText(accountArg.slotId)})` : ''}`;
|
|
31
|
+
|
|
32
|
+
/** The account a command acts on, named as it was offered, with the badge it was offered with. */
|
|
33
|
+
interface IChosenAccount {
|
|
34
|
+
id: string;
|
|
35
|
+
name: string;
|
|
36
|
+
/** ` (type:Max) (5h:63%) (w:88%)`, empty when no part is known, or null when it was not read. */
|
|
37
|
+
badge: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
/** Harness-independent command routing and interactive account management. */
|
|
29
41
|
export class AuthSwitchCli {
|
|
30
42
|
private readonly harnesses = new Map<string, IAuthHarness>();
|
|
@@ -406,7 +418,7 @@ ${bold('Environment')}
|
|
|
406
418
|
for (const current of state.accounts.filter(account => account.isActive && !account.isStashed)) {
|
|
407
419
|
const answer = await this.out.prompts.ask({
|
|
408
420
|
name: 'saveCurrent', type: 'confirm',
|
|
409
|
-
message: `${
|
|
421
|
+
message: `${accountName(current)} is not saved in ${harnessArg.label}. Save this login before switching?`, default: true,
|
|
410
422
|
});
|
|
411
423
|
if (answer !== true) return 'declined';
|
|
412
424
|
if (this.reportOutcome(await this.mutate(harnessArg, { harnessId: harnessArg.id, action: 'save', keepActive: true, accountId: current.id })) !== 0) return 'failed';
|
|
@@ -457,12 +469,12 @@ ${bold('Environment')}
|
|
|
457
469
|
case 'list': code = await this.commandList([harnessArg]); break;
|
|
458
470
|
case 'doctor': code = this.reportOutcome(await harnessArg.diagnose()); break;
|
|
459
471
|
case 'drop': {
|
|
460
|
-
const
|
|
461
|
-
if (!
|
|
472
|
+
const target = await this.promptForAccount(harnessArg, `Which saved ${harnessArg.label} account should be removed?`, { allowBack: true, includeIncomplete: true });
|
|
473
|
+
if (!target) break;
|
|
462
474
|
const confirmation = await this.out.prompts.ask({
|
|
463
|
-
name: 'confirmDrop', type: 'confirm', message: `Remove the saved login for ${plainText(id)}? The active login stays as it is.`, default: false,
|
|
475
|
+
name: 'confirmDrop', type: 'confirm', message: `Remove the saved login for ${plainText(target.id)}? The active login stays as it is.`, default: false,
|
|
464
476
|
});
|
|
465
|
-
if (confirmation === true) code = this.reportOutcome(await harnessArg.removeAccount(id));
|
|
477
|
+
if (confirmation === true) code = this.reportOutcome(await harnessArg.removeAccount(target.id));
|
|
466
478
|
break;
|
|
467
479
|
}
|
|
468
480
|
case 'harness': return null;
|
|
@@ -492,7 +504,7 @@ ${bold('Environment')}
|
|
|
492
504
|
if (accounts.length === 1) return accounts[0];
|
|
493
505
|
if (!accounts.length) { process.stderr.write('No matching active login to save.\n'); return undefined; }
|
|
494
506
|
if (!canPrompt()) { process.stderr.write(`Several logins are active. Use authswitch ${harness.id} stash <account/provider> [--keep].\n`); return undefined; }
|
|
495
|
-
const id = await this.out.prompts.ask({ name: 'activeAccount', type: 'list', message: 'Which active login should be saved?', choices: accounts.map(account => ({ name:
|
|
507
|
+
const id = await this.out.prompts.ask({ name: 'activeAccount', type: 'list', message: 'Which active login should be saved?', choices: accounts.map(account => ({ name: accountName(account), value: account.id })) });
|
|
496
508
|
return accounts.find(account => account.id === id);
|
|
497
509
|
}
|
|
498
510
|
|
|
@@ -538,6 +550,14 @@ ${bold('Environment')}
|
|
|
538
550
|
return null;
|
|
539
551
|
}
|
|
540
552
|
|
|
553
|
+
/**
|
|
554
|
+
* Activate a saved account and report what is now active with its badge.
|
|
555
|
+
*
|
|
556
|
+
* The badge is read once per command through the bounded status read: the chooser reads it before it
|
|
557
|
+
* offers the accounts and the report repeats it, while a target named on the command line is read only
|
|
558
|
+
* after its switch, so nothing waits on it. A read that fails or does not answer in time leaves the name
|
|
559
|
+
* alone and never decides the outcome.
|
|
560
|
+
*/
|
|
541
561
|
private async commandUse(harnessArg: IAuthHarness, referenceArg?: string, allowBackArg = false, stopModeArg?: TStopMode): Promise<number> {
|
|
542
562
|
let id: string | null = null;
|
|
543
563
|
if (referenceArg) {
|
|
@@ -548,21 +568,42 @@ ${bold('Environment')}
|
|
|
548
568
|
const offered = await this.offerToSave(harnessArg);
|
|
549
569
|
if (offered !== 'ready') return offered === 'failed' ? 1 : 0;
|
|
550
570
|
}
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
571
|
+
const target = id === null
|
|
572
|
+
? await this.promptForAccount(harnessArg, `Which ${harnessArg.label} account should be active?`, { allowBack: allowBackArg, withBadges: true })
|
|
573
|
+
: await this.namedAccount(harnessArg, id);
|
|
574
|
+
if (target === null) return allowBackArg ? 0 : 1;
|
|
575
|
+
const code = this.reportOutcome(await this.mutate(harnessArg, { harnessId: harnessArg.id, action: 'switch', accountId: target.id }, stopModeArg));
|
|
576
|
+
if (code !== 0) return code;
|
|
577
|
+
const badge = target.badge ?? (await readAccountBadges(harnessArg, [target.id])).get(target.id) ?? '';
|
|
578
|
+
process.stdout.write(`Now active: ${bold(target.name)}${badge}\n`);
|
|
579
|
+
return code;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* A switch target named on the command line; its badge is not read here, so the switch never waits on it.
|
|
584
|
+
*
|
|
585
|
+
* The reference already resolved to this id, so the name is presentation only: a state read that fails
|
|
586
|
+
* names the account by its id and leaves the switch, which reports any real credential problem, to run.
|
|
587
|
+
*/
|
|
588
|
+
private async namedAccount(harnessArg: IAuthHarness, idArg: string): Promise<IChosenAccount> {
|
|
589
|
+
let account: IHarnessAccount | undefined;
|
|
590
|
+
try { account = (await harnessArg.readState()).accounts.find(item => item.id === idArg); }
|
|
591
|
+
catch { account = undefined; }
|
|
592
|
+
return { id: idArg, name: account ? accountName(account) : plainText(idArg), badge: null };
|
|
554
593
|
}
|
|
555
594
|
|
|
556
|
-
private async promptForAccount(harnessArg: IAuthHarness, messageArg: string,
|
|
557
|
-
const accounts = (await harnessArg.readState()).accounts.filter((accountArg) => accountArg.isStashed || (
|
|
595
|
+
private async promptForAccount(harnessArg: IAuthHarness, messageArg: string, optionsArg: { allowBack?: boolean; includeIncomplete?: boolean; withBadges?: boolean } = {}): Promise<IChosenAccount | null> {
|
|
596
|
+
const accounts = (await harnessArg.readState()).accounts.filter((accountArg) => accountArg.isStashed || (optionsArg.includeIncomplete === true && accountArg.savedAt !== null));
|
|
558
597
|
if (accounts.length === 0) { process.stderr.write(`${red('no stashed accounts')} - run \`authswitch ${harnessArg.id} stash\` first\n`); return null; }
|
|
559
598
|
if (!canPrompt()) { process.stderr.write(`${red('an account is required')} without an interactive terminal: authswitch ${harnessArg.id} use <account>\n`); return null; }
|
|
599
|
+
const badges = optionsArg.withBadges ? await readAccountBadges(harnessArg, accounts.map((accountArg) => accountArg.id)) : null;
|
|
600
|
+
const offered = accounts.map((accountArg): IChosenAccount => ({ id: accountArg.id, name: accountName(accountArg), badge: badges === null ? null : badges.get(accountArg.id) ?? '' }));
|
|
560
601
|
const answer = await this.out.prompts.ask({
|
|
561
602
|
name: 'account', type: 'list', message: messageArg,
|
|
562
603
|
default: accounts.find((accountArg) => accountArg.isActive)?.id ?? accounts[0].id,
|
|
563
|
-
choices: [...accounts.map((accountArg) => ({ name: `${
|
|
564
|
-
...(
|
|
604
|
+
choices: [...accounts.map((accountArg, index) => ({ name: `${offered[index].name}${accountArg.isActive ? ' (active)' : ''}${offered[index].badge ?? ''}`, value: accountArg.id })),
|
|
605
|
+
...(optionsArg.allowBack ? [{ name: 'Back', value: null }] : [])],
|
|
565
606
|
});
|
|
566
|
-
return
|
|
607
|
+
return offered.find((accountArg) => accountArg.id === answer) ?? null;
|
|
567
608
|
}
|
|
568
609
|
}
|
|
@@ -2,7 +2,7 @@ import { CodexSwitcher } from './classes.codexswitcher.js';
|
|
|
2
2
|
import { CodexAccountStatus } from './classes.codexstatus.js';
|
|
3
3
|
import { CodexPreuse } from './classes.codexpreuse.js';
|
|
4
4
|
import { PreuseError } from './preuse.js';
|
|
5
|
-
import type { IAuthHarness, IHarnessAccount, IHarnessAccountStatus, IHarnessOutcome, IHarnessState, IHarnessPreuseOptions, IHarnessPreuseResult } from './interfaces.harness.js';
|
|
5
|
+
import type { IAuthHarness, IHarnessAccount, IHarnessAccountStatus, IHarnessOutcome, IHarnessState, IHarnessPreuseOptions, IHarnessPreuseResult, IHarnessStatusOptions } from './interfaces.harness.js';
|
|
6
6
|
import type { IStashListing } from './interfaces.js';
|
|
7
7
|
import { countPaired, enrollmentState } from './helpers.js';
|
|
8
8
|
import { beginSavedOpenAiLogin } from './classes.login.js';
|
|
@@ -70,10 +70,10 @@ export class CodexHarness implements IAuthHarness {
|
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
public async readAccountStatus(accountIdArg: string): Promise<IHarnessAccountStatus> {
|
|
73
|
+
public async readAccountStatus(accountIdArg: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus> {
|
|
74
74
|
const credential = this.readAccountCredential(accountIdArg);
|
|
75
75
|
if (!credential) return { facts: [], problems: ['No matching credential is available for status lookup.'] };
|
|
76
|
-
return this.accountStatus.read(credential.raw, credential.identity);
|
|
76
|
+
return this.accountStatus.read(credential.raw, credential.identity, optionsArg);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
public async preuseAccount(accountIdArg: string, optionsArg: IHarnessPreuseOptions): Promise<IHarnessPreuseResult> {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ICodexIdentity } from './interfaces.js';
|
|
2
|
-
import type { IHarnessAccountStatus, IHarnessStatusSummary } from './interfaces.harness.js';
|
|
2
|
+
import type { IHarnessAccountStatus, IHarnessStatusOptions, IHarnessStatusSummary } from './interfaces.harness.js';
|
|
3
3
|
import { commitinfo } from './00_commitinfo_data.js';
|
|
4
4
|
|
|
5
5
|
// Verified Chromium-compatible billing request profile, independent of any locally installed browser.
|
|
@@ -39,7 +39,7 @@ class StatusRequestError extends Error {}
|
|
|
39
39
|
export class CodexAccountStatus {
|
|
40
40
|
constructor(private readonly fetcher: typeof fetch = globalThis.fetch) {}
|
|
41
41
|
|
|
42
|
-
public async read(rawArg: string, identityArg: ICodexIdentity): Promise<IHarnessAccountStatus> {
|
|
42
|
+
public async read(rawArg: string, identityArg: ICodexIdentity, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
|
|
43
43
|
if (identityArg.kind !== 'chatgpt') return {
|
|
44
44
|
facts: [{ label: 'Authentication', value: 'API key' }, { label: 'Subscription and usage', value: 'Not exposed by Codex for API-key logins' }], problems: [],
|
|
45
45
|
};
|
|
@@ -47,18 +47,18 @@ export class CodexAccountStatus {
|
|
|
47
47
|
try { accessToken = text(record(record(JSON.parse(rawArg)).tokens).access_token); }
|
|
48
48
|
catch { return { facts: [], problems: ['The login has no usable access token. Log in again with Codex, then save the account.'] }; }
|
|
49
49
|
if (!identityArg.accountId) return { facts: [], problems: ['The login has no account ID for a status lookup.'] };
|
|
50
|
-
return this.readCredential(accessToken, identityArg.accountId, identityArg.planType);
|
|
50
|
+
return this.readCredential(accessToken, identityArg.accountId, identityArg.planType, optionsArg);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/** Read the ChatGPT account directly, independent of a harness's on-disk format. */
|
|
54
|
-
public async readCredential(accessToken: string, accountId: string, planType?: string | null): Promise<IHarnessAccountStatus> {
|
|
54
|
+
public async readCredential(accessToken: string, accountId: string, planType?: string | null, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
|
|
55
55
|
const result: IHarnessAccountStatus = { facts: [], problems: [] };
|
|
56
56
|
const identityArg = { accountId, planType };
|
|
57
57
|
const sections = await Promise.allSettled([
|
|
58
|
-
this.get('wham/usage', accessToken, identityArg.accountId),
|
|
59
|
-
this.get('wham/rate-limit-reset-credits', accessToken, identityArg.accountId),
|
|
60
|
-
this.get('wham/profiles/me', accessToken, identityArg.accountId),
|
|
61
|
-
this.get('accounts/check/v4-2023-04-27', accessToken, identityArg.accountId),
|
|
58
|
+
this.get('wham/usage', accessToken, identityArg.accountId, optionsArg.signal),
|
|
59
|
+
this.get('wham/rate-limit-reset-credits', accessToken, identityArg.accountId, optionsArg.signal),
|
|
60
|
+
this.get('wham/profiles/me', accessToken, identityArg.accountId, optionsArg.signal),
|
|
61
|
+
this.get('accounts/check/v4-2023-04-27', accessToken, identityArg.accountId, optionsArg.signal),
|
|
62
62
|
]);
|
|
63
63
|
const usage = sections[0];
|
|
64
64
|
if (usage.status === 'fulfilled' && usage.value.account_id != null && usage.value.account_id !== identityArg.accountId) {
|
|
@@ -92,8 +92,9 @@ export class CodexAccountStatus {
|
|
|
92
92
|
return result;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
private async get(pathArg: string, tokenArg: string, accountIdArg: string): Promise<Record<string, unknown>> {
|
|
96
|
-
const
|
|
95
|
+
private async get(pathArg: string, tokenArg: string, accountIdArg: string, cancelArg?: AbortSignal): Promise<Record<string, unknown>> {
|
|
96
|
+
const timeout = AbortSignal.timeout(10_000);
|
|
97
|
+
const signal = cancelArg ? AbortSignal.any([cancelArg, timeout]) : timeout;
|
|
97
98
|
try {
|
|
98
99
|
const response = await this.fetcher(`https://chatgpt.com/backend-api/${pathArg}`, {
|
|
99
100
|
method: 'GET', redirect: 'error', signal,
|
|
@@ -125,7 +126,7 @@ export class CodexAccountStatus {
|
|
|
125
126
|
catch { throw new StatusRequestError('The service returned invalid status data.'); }
|
|
126
127
|
} catch (errorArg) {
|
|
127
128
|
// Never surface remote bodies, request headers, token strings or arbitrary fetch errors.
|
|
128
|
-
if (signal.aborted) throw new Error('The status request timed out.');
|
|
129
|
+
if (signal.aborted) throw new Error(timeout.aborted ? 'The status request timed out.' : 'The status request was cancelled.');
|
|
129
130
|
if (errorArg instanceof StatusRequestError) throw errorArg;
|
|
130
131
|
throw new Error('The status service could not be reached.');
|
|
131
132
|
}
|
|
@@ -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';
|
|
@@ -28,7 +28,7 @@ export abstract class FileHarness implements IAuthHarness {
|
|
|
28
28
|
/** Raw bytes of the native files a snapshot's credentials are taken from, in a fixed order. */
|
|
29
29
|
protected abstract nativeSources(): (string | null)[];
|
|
30
30
|
protected abstract inspect(credential: Record<string, unknown>, slotId: string): IFileAccount;
|
|
31
|
-
public abstract readAccountStatus(id: string): Promise<IHarnessAccountStatus>;
|
|
31
|
+
public abstract readAccountStatus(id: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus>;
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* A snapshot proven not to have been taken across a native write.
|