@cortexkit/common-auth 0.2.4 → 0.2.5
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/auth-menu/accounts.d.ts +88 -0
- package/dist/auth-menu/accounts.js +401 -0
- package/dist/auth-menu/ansi.d.ts +24 -0
- package/dist/auth-menu/ansi.js +67 -0
- package/dist/auth-menu/confirm.d.ts +7 -0
- package/dist/auth-menu/confirm.js +21 -0
- package/dist/auth-menu/doctor.d.ts +52 -0
- package/dist/auth-menu/doctor.js +81 -0
- package/dist/auth-menu/index.d.ts +17 -1
- package/dist/auth-menu/index.js +12 -2
- package/dist/auth-menu/login.d.ts +47 -0
- package/dist/auth-menu/login.js +63 -0
- package/dist/auth-menu/menu.d.ts +59 -0
- package/dist/auth-menu/menu.js +73 -0
- package/dist/auth-menu/opencode-v1.d.ts +49 -0
- package/dist/auth-menu/opencode-v1.js +47 -0
- package/dist/auth-menu/select.d.ts +21 -0
- package/dist/auth-menu/select.js +153 -0
- package/dist/auth-menu/terminal.d.ts +43 -0
- package/dist/auth-menu/terminal.js +19 -0
- package/dist/cachekeep/index.d.ts +4 -1
- package/dist/cachekeep/index.js +2 -2
- package/dist/cachekeep/manager.d.ts +214 -0
- package/dist/cachekeep/manager.js +424 -0
- package/dist/cachekeep/window.d.ts +13 -0
- package/dist/cachekeep/window.js +33 -0
- package/dist/dump/index.d.ts +90 -1
- package/dist/dump/index.js +292 -2
- package/package.json +1 -1
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { type PoolLockSpec, type PoolRow, type PoolStore, type RemoveOptions } from '../store/index.js';
|
|
2
|
+
import { type DoctorCheck } from './doctor.js';
|
|
3
|
+
import { type LoginAccount, type MenuLogin } from './login.js';
|
|
4
|
+
import { type MenuAction, type MenuOutcome } from './menu.js';
|
|
5
|
+
import type { MenuTerminal } from './terminal.js';
|
|
6
|
+
/** The reason recorded on a row the menu disables. */
|
|
7
|
+
export declare const MENU_DISABLE_REASON = "disabled from the auth menu";
|
|
8
|
+
export interface AccountMenuOptions {
|
|
9
|
+
/** The menu heading, naming the provider's accounts. */
|
|
10
|
+
title: string;
|
|
11
|
+
store: PoolStore;
|
|
12
|
+
/** Defaults to the current process's terminal. */
|
|
13
|
+
terminal?: MenuTerminal;
|
|
14
|
+
/** The plugin's login. Without it, add and re-authenticate are not offered. */
|
|
15
|
+
login?: MenuLogin;
|
|
16
|
+
/** The id of a new account; defaults to the login's id, else `account-N`. */
|
|
17
|
+
newAccountId?(account: LoginAccount, rows: readonly PoolRow[]): string;
|
|
18
|
+
/**
|
|
19
|
+
* Passed to every removal the menu makes, including delete-all: an id it
|
|
20
|
+
* names a reason for is kept, with both store files untouched for it.
|
|
21
|
+
*/
|
|
22
|
+
protect?: RemoveOptions['protect'];
|
|
23
|
+
/** Locks the plugin takes around every row write, passed to the store. */
|
|
24
|
+
extraLocks?: readonly PoolLockSpec[];
|
|
25
|
+
/**
|
|
26
|
+
* Fetches one row's quota as an observation for the store's quota codec
|
|
27
|
+
* (a `/quota` observation when the store was opened with `quotaCodec`).
|
|
28
|
+
* Without it, check quotas prints only what is stored.
|
|
29
|
+
*/
|
|
30
|
+
pollQuota?(row: PoolRow): Promise<unknown>;
|
|
31
|
+
/** Checks the doctor runs; without any, the doctor is not offered. */
|
|
32
|
+
doctor?: readonly DoctorCheck[];
|
|
33
|
+
/**
|
|
34
|
+
* True when accounts come from a vault rather than from this machine:
|
|
35
|
+
* the account actions become a read-only listing plus enable/disable,
|
|
36
|
+
* because adding, signing in and removing happen in the vault.
|
|
37
|
+
*/
|
|
38
|
+
custody?(): boolean | Promise<boolean>;
|
|
39
|
+
/** Plugin lines shown above the accounts, such as the routing mode. */
|
|
40
|
+
status?(): readonly string[] | Promise<readonly string[]>;
|
|
41
|
+
/** The plugin's own actions, listed before delete-all. */
|
|
42
|
+
extraActions?: readonly MenuAction[];
|
|
43
|
+
/** Recorded on a row the menu disables; defaults to `MENU_DISABLE_REASON`. */
|
|
44
|
+
disableReason?: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Whether the store holds any usable credential. Meant as one half of an
|
|
48
|
+
* `hasCredential` predicate (the other being the host's own slot); a row with
|
|
49
|
+
* no credential does not count, since it gives the menu nothing to manage.
|
|
50
|
+
*/
|
|
51
|
+
export declare function poolHasCredential(store: PoolStore): Promise<boolean>;
|
|
52
|
+
/** Formats a row's stored quota map as one line per window. */
|
|
53
|
+
export declare function quotaLines(row: PoolRow): string[];
|
|
54
|
+
/** Adds an account through the plugin's login and reports what the store did. */
|
|
55
|
+
export declare function addAccountAction(options: AccountMenuOptions): MenuAction;
|
|
56
|
+
/**
|
|
57
|
+
* Signs an existing account in again and replaces its credential. A login
|
|
58
|
+
* that comes back as a different provider account is refused: replacing
|
|
59
|
+
* would silently turn the row into another account.
|
|
60
|
+
*/
|
|
61
|
+
export declare function reauthenticateAction(options: AccountMenuOptions): MenuAction;
|
|
62
|
+
/** Removes one account after the operator confirms it by name. */
|
|
63
|
+
export declare function removeAccountAction(options: AccountMenuOptions): MenuAction;
|
|
64
|
+
/** Disables an enabled account, or enables a disabled one. */
|
|
65
|
+
export declare function toggleAccountAction(options: AccountMenuOptions): MenuAction;
|
|
66
|
+
/** Prints every account and its state, changing nothing. */
|
|
67
|
+
export declare function listAccountsAction(options: AccountMenuOptions): MenuAction;
|
|
68
|
+
/**
|
|
69
|
+
* Polls each account's quota once, in order, records each reading through
|
|
70
|
+
* the store (so it lands only on the credential it was taken with), then
|
|
71
|
+
* prints every account's windows from what is stored.
|
|
72
|
+
*/
|
|
73
|
+
export declare function checkQuotasAction(options: AccountMenuOptions): MenuAction;
|
|
74
|
+
/**
|
|
75
|
+
* Removes every account the plugin does not protect. Each removal goes
|
|
76
|
+
* through `store.remove` with the plugin's `protect`, so a protected id is
|
|
77
|
+
* refused under the store's locks and stays, with its files untouched.
|
|
78
|
+
*/
|
|
79
|
+
export declare function deleteAllAction(options: AccountMenuOptions): MenuAction;
|
|
80
|
+
/**
|
|
81
|
+
* The menu's actions for this plugin and mode. Local mode: add,
|
|
82
|
+
* re-authenticate, remove, enable/disable, check quotas, doctor, the
|
|
83
|
+
* plugin's extras, delete all. Custody mode: list, enable/disable, check
|
|
84
|
+
* quotas, doctor and the extras; nothing that adds, signs in or removes.
|
|
85
|
+
*/
|
|
86
|
+
export declare function accountMenuActions(options: AccountMenuOptions): Promise<MenuAction[]>;
|
|
87
|
+
/** Shows the account menu once and runs the chosen action. */
|
|
88
|
+
export declare function runAccountMenu(options: AccountMenuOptions): Promise<MenuOutcome>;
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { isQuotaMap, projectQuota } from '../quota/index.js';
|
|
2
|
+
import { PoolOperationError, } from '../store/index.js';
|
|
3
|
+
import { doctorAction } from './doctor.js';
|
|
4
|
+
import { runMenuLogin } from './login.js';
|
|
5
|
+
import { runMenu, } from './menu.js';
|
|
6
|
+
/** The reason recorded on a row the menu disables. */
|
|
7
|
+
export const MENU_DISABLE_REASON = 'disabled from the auth menu';
|
|
8
|
+
/** At most this many account lines are shown above the actions. */
|
|
9
|
+
const STATUS_ACCOUNT_LINES = 8;
|
|
10
|
+
async function readRows(store) {
|
|
11
|
+
const load = await store.read();
|
|
12
|
+
if (load.status === 'ready')
|
|
13
|
+
return { rows: load.rows };
|
|
14
|
+
if (load.status === 'pending-migration')
|
|
15
|
+
return { rows: [], problem: 'The account store has not been migrated yet.' };
|
|
16
|
+
return {
|
|
17
|
+
rows: [],
|
|
18
|
+
problem: `The account store could not be read (${load.file}: ${load.reason}).`,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Whether the store holds any usable credential. Meant as one half of an
|
|
23
|
+
* `hasCredential` predicate (the other being the host's own slot); a row with
|
|
24
|
+
* no credential does not count, since it gives the menu nothing to manage.
|
|
25
|
+
*/
|
|
26
|
+
export async function poolHasCredential(store) {
|
|
27
|
+
const { rows } = await readRows(store);
|
|
28
|
+
return rows.some((row) => row.credential !== undefined);
|
|
29
|
+
}
|
|
30
|
+
function describeRow(row) {
|
|
31
|
+
const parts = [];
|
|
32
|
+
if (row.label)
|
|
33
|
+
parts.push(row.label);
|
|
34
|
+
if (row.invalid)
|
|
35
|
+
parts.push(`invalid ${row.invalid}`);
|
|
36
|
+
else if (!row.enabled)
|
|
37
|
+
parts.push(row.disabledReason ? `disabled: ${row.disabledReason}` : 'disabled');
|
|
38
|
+
else
|
|
39
|
+
parts.push('enabled');
|
|
40
|
+
if (!row.credential && !row.invalid)
|
|
41
|
+
parts.push('no credential');
|
|
42
|
+
return parts.join(', ');
|
|
43
|
+
}
|
|
44
|
+
function accountLines(read) {
|
|
45
|
+
if (read.problem)
|
|
46
|
+
return [read.problem];
|
|
47
|
+
if (read.rows.length === 0)
|
|
48
|
+
return ['No accounts yet.'];
|
|
49
|
+
const lines = read.rows
|
|
50
|
+
.slice(0, STATUS_ACCOUNT_LINES)
|
|
51
|
+
.map((row) => `${row.id}: ${describeRow(row)}`);
|
|
52
|
+
const more = read.rows.length - STATUS_ACCOUNT_LINES;
|
|
53
|
+
if (more > 0)
|
|
54
|
+
lines.push(`... and ${more} more`);
|
|
55
|
+
return lines;
|
|
56
|
+
}
|
|
57
|
+
function errorText(error) {
|
|
58
|
+
return error instanceof Error ? error.message : String(error);
|
|
59
|
+
}
|
|
60
|
+
async function pickAccount(context, rows, message) {
|
|
61
|
+
if (rows.length === 0) {
|
|
62
|
+
context.print('There are no accounts.');
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
return context.select(rows.map((row) => ({ label: row.id, value: row, hint: describeRow(row) })), { message });
|
|
66
|
+
}
|
|
67
|
+
function defaultAccountId(account, rows) {
|
|
68
|
+
const taken = new Set(rows.map((row) => row.id));
|
|
69
|
+
if (account.id && !taken.has(account.id))
|
|
70
|
+
return account.id;
|
|
71
|
+
for (let n = 1;; n++) {
|
|
72
|
+
const id = `account-${n}`;
|
|
73
|
+
if (!taken.has(id))
|
|
74
|
+
return id;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Formats a row's stored quota map as one line per window. */
|
|
78
|
+
export function quotaLines(row) {
|
|
79
|
+
if (!isQuotaMap(row.quota))
|
|
80
|
+
return [' no quota reading'];
|
|
81
|
+
const projected = projectQuota(row.quota);
|
|
82
|
+
const lines = [];
|
|
83
|
+
for (const limit of projected.limits) {
|
|
84
|
+
const name = limit.scope === 'all' ? limit.label : `${limit.scope}/${limit.label}`;
|
|
85
|
+
if (limit.kind !== 'reading') {
|
|
86
|
+
lines.push(` ${name}: no limit reported`);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const resets = limit.resetsAt ? `, resets ${limit.resetsAt}` : '';
|
|
90
|
+
lines.push(` ${name}: ${limit.remainingPercent}% left${resets}`);
|
|
91
|
+
}
|
|
92
|
+
if (projected.budget) {
|
|
93
|
+
const budget = projected.budget;
|
|
94
|
+
lines.push(budget.reached
|
|
95
|
+
? ' budget: reached'
|
|
96
|
+
: budget.remainingPercent === undefined
|
|
97
|
+
? ' budget: available'
|
|
98
|
+
: ` budget: ${budget.remainingPercent}% left`);
|
|
99
|
+
}
|
|
100
|
+
return lines.length > 0 ? lines : [' no quota reading'];
|
|
101
|
+
}
|
|
102
|
+
/** Adds an account through the plugin's login and reports what the store did. */
|
|
103
|
+
export function addAccountAction(options) {
|
|
104
|
+
return {
|
|
105
|
+
id: 'add-account',
|
|
106
|
+
label: 'Add account',
|
|
107
|
+
hint: 'sign in to another account',
|
|
108
|
+
async run(context) {
|
|
109
|
+
const login = options.login;
|
|
110
|
+
if (!login)
|
|
111
|
+
throw new Error('No login is configured');
|
|
112
|
+
const account = await runMenuLogin(login, context);
|
|
113
|
+
const { rows } = await readRows(options.store);
|
|
114
|
+
const id = (options.newAccountId ?? defaultAccountId)(account, rows);
|
|
115
|
+
const added = await options.store.add({
|
|
116
|
+
id,
|
|
117
|
+
credential: account.credential,
|
|
118
|
+
...(account.identity !== undefined
|
|
119
|
+
? { identity: account.identity }
|
|
120
|
+
: {}),
|
|
121
|
+
...(account.label !== undefined ? { label: account.label } : {}),
|
|
122
|
+
}, options.extraLocks ? { extraLocks: options.extraLocks } : {});
|
|
123
|
+
if (added.outcome === 'rotated')
|
|
124
|
+
context.print(`That sign-in is already account ${added.id}; its credential was updated.`);
|
|
125
|
+
else if (added.outcome === 'added-disabled')
|
|
126
|
+
context.print(`Added account ${added.id}, disabled: another enabled account is the same provider account.`);
|
|
127
|
+
else if (added.outcome === 'completed')
|
|
128
|
+
context.print(`Finished adding account ${added.id}.`);
|
|
129
|
+
else
|
|
130
|
+
context.print(`Added account ${added.id}.`);
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Signs an existing account in again and replaces its credential. A login
|
|
136
|
+
* that comes back as a different provider account is refused: replacing
|
|
137
|
+
* would silently turn the row into another account.
|
|
138
|
+
*/
|
|
139
|
+
export function reauthenticateAction(options) {
|
|
140
|
+
return {
|
|
141
|
+
id: 'reauthenticate',
|
|
142
|
+
label: 'Re-authenticate account',
|
|
143
|
+
hint: 'sign an account in again',
|
|
144
|
+
async run(context) {
|
|
145
|
+
const login = options.login;
|
|
146
|
+
if (!login)
|
|
147
|
+
throw new Error('No login is configured');
|
|
148
|
+
const { rows } = await readRows(options.store);
|
|
149
|
+
const row = await pickAccount(context, rows, 'Re-authenticate which account?');
|
|
150
|
+
if (!row)
|
|
151
|
+
return;
|
|
152
|
+
const account = await runMenuLogin(login, context);
|
|
153
|
+
if (account.identity !== undefined &&
|
|
154
|
+
row.identity !== undefined &&
|
|
155
|
+
account.identity !== row.identity) {
|
|
156
|
+
context.print(`That sign-in is a different account from ${row.id}; nothing was changed. Use Add account to add it.`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const identity = account.identity ?? row.identity;
|
|
160
|
+
await options.store.replace(row.id, account.credential, identity !== undefined ? { identity } : {}, options.extraLocks ? { extraLocks: options.extraLocks } : {});
|
|
161
|
+
context.print(`Updated the sign-in of account ${row.id}.`);
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function removeOptions(options) {
|
|
166
|
+
return {
|
|
167
|
+
...(options.protect ? { protect: options.protect } : {}),
|
|
168
|
+
...(options.extraLocks ? { extraLocks: options.extraLocks } : {}),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function protectedReason(error) {
|
|
172
|
+
return error instanceof PoolOperationError && error.kind === 'row-protected'
|
|
173
|
+
? error.message
|
|
174
|
+
: undefined;
|
|
175
|
+
}
|
|
176
|
+
/** Removes one account after the operator confirms it by name. */
|
|
177
|
+
export function removeAccountAction(options) {
|
|
178
|
+
return {
|
|
179
|
+
id: 'remove-account',
|
|
180
|
+
label: 'Remove account',
|
|
181
|
+
hint: 'delete one account',
|
|
182
|
+
async run(context) {
|
|
183
|
+
const { rows } = await readRows(options.store);
|
|
184
|
+
const row = await pickAccount(context, rows, 'Remove which account?');
|
|
185
|
+
if (!row)
|
|
186
|
+
return;
|
|
187
|
+
if (!(await context.confirm(`Remove account ${row.id}?`))) {
|
|
188
|
+
context.print('Cancelled; nothing was changed.');
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
await options.store.remove(row.id, removeOptions(options));
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
const reason = protectedReason(error);
|
|
196
|
+
if (reason === undefined)
|
|
197
|
+
throw error;
|
|
198
|
+
context.print(`Account ${row.id} was kept: ${reason}`);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
context.print(`Removed account ${row.id}.`);
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/** Disables an enabled account, or enables a disabled one. */
|
|
206
|
+
export function toggleAccountAction(options) {
|
|
207
|
+
return {
|
|
208
|
+
id: 'toggle-account',
|
|
209
|
+
label: 'Enable or disable account',
|
|
210
|
+
hint: 'stop or resume using an account',
|
|
211
|
+
async run(context) {
|
|
212
|
+
const { rows } = await readRows(options.store);
|
|
213
|
+
const row = await pickAccount(context, rows, 'Enable or disable which account?');
|
|
214
|
+
if (!row)
|
|
215
|
+
return;
|
|
216
|
+
const toggle = options.extraLocks
|
|
217
|
+
? { extraLocks: options.extraLocks }
|
|
218
|
+
: {};
|
|
219
|
+
if (row.enabled) {
|
|
220
|
+
await options.store.disable(row.id, options.disableReason ?? MENU_DISABLE_REASON, toggle);
|
|
221
|
+
context.print(`Disabled account ${row.id}.`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
await options.store.enable(row.id, toggle);
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
if (error instanceof PoolOperationError &&
|
|
229
|
+
error.kind === 'duplicate-identity') {
|
|
230
|
+
context.print(`Account ${row.id} stays disabled: another enabled account is the same provider account.`);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
context.print(`Enabled account ${row.id}.`);
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/** Prints every account and its state, changing nothing. */
|
|
240
|
+
export function listAccountsAction(options) {
|
|
241
|
+
return {
|
|
242
|
+
id: 'list-accounts',
|
|
243
|
+
label: 'List accounts',
|
|
244
|
+
hint: 'show every account',
|
|
245
|
+
async run(context) {
|
|
246
|
+
const read = await readRows(options.store);
|
|
247
|
+
if (read.problem) {
|
|
248
|
+
context.print(read.problem);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (read.rows.length === 0)
|
|
252
|
+
context.print('No accounts yet.');
|
|
253
|
+
for (const row of read.rows)
|
|
254
|
+
context.print(`${row.id}: ${describeRow(row)}`);
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Polls each account's quota once, in order, records each reading through
|
|
260
|
+
* the store (so it lands only on the credential it was taken with), then
|
|
261
|
+
* prints every account's windows from what is stored.
|
|
262
|
+
*/
|
|
263
|
+
export function checkQuotasAction(options) {
|
|
264
|
+
return {
|
|
265
|
+
id: 'check-quotas',
|
|
266
|
+
label: 'Check quotas',
|
|
267
|
+
hint: 'poll and show the windows of each account',
|
|
268
|
+
async run(context) {
|
|
269
|
+
const before = await readRows(options.store);
|
|
270
|
+
if (before.problem) {
|
|
271
|
+
context.print(before.problem);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const errors = new Map();
|
|
275
|
+
const poll = options.pollQuota;
|
|
276
|
+
if (poll) {
|
|
277
|
+
for (const row of before.rows) {
|
|
278
|
+
if (row.invalid ||
|
|
279
|
+
!row.credential ||
|
|
280
|
+
row.credentialEpoch === undefined) {
|
|
281
|
+
errors.set(row.id, 'no usable credential');
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
const observation = await poll(row);
|
|
286
|
+
await options.store.recordQuota(row.id, {
|
|
287
|
+
credentialEpoch: row.credentialEpoch,
|
|
288
|
+
...(row.identity !== undefined
|
|
289
|
+
? { identity: row.identity }
|
|
290
|
+
: {}),
|
|
291
|
+
}, observation);
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
errors.set(row.id, errorText(error));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const after = await readRows(options.store);
|
|
299
|
+
if (after.rows.length === 0)
|
|
300
|
+
context.print('No accounts yet.');
|
|
301
|
+
for (const row of after.rows) {
|
|
302
|
+
context.print(`${row.id}:`);
|
|
303
|
+
const error = errors.get(row.id);
|
|
304
|
+
if (error)
|
|
305
|
+
context.print(` quota check failed: ${error}`);
|
|
306
|
+
for (const line of quotaLines(row))
|
|
307
|
+
context.print(line);
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Removes every account the plugin does not protect. Each removal goes
|
|
314
|
+
* through `store.remove` with the plugin's `protect`, so a protected id is
|
|
315
|
+
* refused under the store's locks and stays, with its files untouched.
|
|
316
|
+
*/
|
|
317
|
+
export function deleteAllAction(options) {
|
|
318
|
+
return {
|
|
319
|
+
id: 'delete-all',
|
|
320
|
+
label: 'Delete all accounts',
|
|
321
|
+
destructive: true,
|
|
322
|
+
confirm: 'Delete every account? Accounts the plugin keeps are not deleted.',
|
|
323
|
+
async run(context) {
|
|
324
|
+
const read = await readRows(options.store);
|
|
325
|
+
if (read.problem) {
|
|
326
|
+
context.print(read.problem);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const removed = [];
|
|
330
|
+
const kept = [];
|
|
331
|
+
for (const row of read.rows) {
|
|
332
|
+
try {
|
|
333
|
+
await options.store.remove(row.id, removeOptions(options));
|
|
334
|
+
removed.push(row.id);
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
const reason = protectedReason(error);
|
|
338
|
+
if (reason === undefined)
|
|
339
|
+
throw error;
|
|
340
|
+
kept.push(`${row.id} (${reason})`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
context.print(`Deleted ${removed.length} account(s).`);
|
|
344
|
+
if (kept.length > 0)
|
|
345
|
+
context.print(`Kept: ${kept.join(', ')}.`);
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* The menu's actions for this plugin and mode. Local mode: add,
|
|
351
|
+
* re-authenticate, remove, enable/disable, check quotas, doctor, the
|
|
352
|
+
* plugin's extras, delete all. Custody mode: list, enable/disable, check
|
|
353
|
+
* quotas, doctor and the extras; nothing that adds, signs in or removes.
|
|
354
|
+
*/
|
|
355
|
+
export async function accountMenuActions(options) {
|
|
356
|
+
return actionsFor(options, (await options.custody?.()) === true);
|
|
357
|
+
}
|
|
358
|
+
function actionsFor(options, custody) {
|
|
359
|
+
const doctor = options.doctor?.length
|
|
360
|
+
? [doctorAction({ checks: options.doctor })]
|
|
361
|
+
: [];
|
|
362
|
+
const extras = [...(options.extraActions ?? [])];
|
|
363
|
+
if (custody) {
|
|
364
|
+
return [
|
|
365
|
+
listAccountsAction(options),
|
|
366
|
+
toggleAccountAction(options),
|
|
367
|
+
checkQuotasAction(options),
|
|
368
|
+
...doctor,
|
|
369
|
+
...extras,
|
|
370
|
+
];
|
|
371
|
+
}
|
|
372
|
+
return [
|
|
373
|
+
...(options.login
|
|
374
|
+
? [addAccountAction(options), reauthenticateAction(options)]
|
|
375
|
+
: []),
|
|
376
|
+
removeAccountAction(options),
|
|
377
|
+
toggleAccountAction(options),
|
|
378
|
+
checkQuotasAction(options),
|
|
379
|
+
...doctor,
|
|
380
|
+
...extras,
|
|
381
|
+
deleteAllAction(options),
|
|
382
|
+
];
|
|
383
|
+
}
|
|
384
|
+
/** Shows the account menu once and runs the chosen action. */
|
|
385
|
+
export async function runAccountMenu(options) {
|
|
386
|
+
const custody = (await options.custody?.()) === true;
|
|
387
|
+
const actions = actionsFor(options, custody);
|
|
388
|
+
const [read, status] = await Promise.all([
|
|
389
|
+
readRows(options.store),
|
|
390
|
+
options.status?.() ?? [],
|
|
391
|
+
]);
|
|
392
|
+
return runMenu({
|
|
393
|
+
title: options.title,
|
|
394
|
+
subtitle: custody
|
|
395
|
+
? 'Accounts come from the vault; select an action'
|
|
396
|
+
: 'Select an account action',
|
|
397
|
+
status: [...status, ...accountLines(read)],
|
|
398
|
+
actions,
|
|
399
|
+
...(options.terminal ? { terminal: options.terminal } : {}),
|
|
400
|
+
});
|
|
401
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** ANSI controls used by the first-party auth menu. */
|
|
2
|
+
export declare const ANSI: {
|
|
3
|
+
readonly hide: '\u001B[?25l';
|
|
4
|
+
readonly show: '\u001B[?25h';
|
|
5
|
+
readonly up: (n?: number) => string;
|
|
6
|
+
readonly clearLine: '\u001B[2K';
|
|
7
|
+
readonly clearScreen: '\u001B[2J';
|
|
8
|
+
readonly moveTo: (row: number, col: number) => string;
|
|
9
|
+
readonly cyan: '\u001B[36m';
|
|
10
|
+
readonly green: '\u001B[32m';
|
|
11
|
+
readonly red: '\u001B[31m';
|
|
12
|
+
readonly dim: '\u001B[2m';
|
|
13
|
+
readonly reset: '\u001B[0m';
|
|
14
|
+
};
|
|
15
|
+
export type KeyAction = 'up' | 'down' | 'enter' | 'escape' | 'escape-start' | null;
|
|
16
|
+
/** Convert terminal key bytes into the small action set the menu accepts. */
|
|
17
|
+
export declare function parseKey(data: Buffer | string): KeyAction;
|
|
18
|
+
/** Remove colour codes, leaving the text an operator sees. */
|
|
19
|
+
export declare function stripAnsi(input: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Shorten coloured text to a visible width without cutting an escape code in
|
|
22
|
+
* half, resetting the colour before the ellipsis so it cannot bleed.
|
|
23
|
+
*/
|
|
24
|
+
export declare function truncateAnsi(input: string, maxVisibleChars: number): string;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** ANSI controls used by the first-party auth menu. */
|
|
2
|
+
export const ANSI = {
|
|
3
|
+
hide: '\x1b[?25l',
|
|
4
|
+
show: '\x1b[?25h',
|
|
5
|
+
up: (n = 1) => `\x1b[${n}A`,
|
|
6
|
+
clearLine: '\x1b[2K',
|
|
7
|
+
clearScreen: '\x1b[2J',
|
|
8
|
+
moveTo: (row, col) => `\x1b[${row};${col}H`,
|
|
9
|
+
cyan: '\x1b[36m',
|
|
10
|
+
green: '\x1b[32m',
|
|
11
|
+
red: '\x1b[31m',
|
|
12
|
+
dim: '\x1b[2m',
|
|
13
|
+
reset: '\x1b[0m',
|
|
14
|
+
};
|
|
15
|
+
/** Convert terminal key bytes into the small action set the menu accepts. */
|
|
16
|
+
export function parseKey(data) {
|
|
17
|
+
const value = data.toString();
|
|
18
|
+
if (value === '\x1b[A' || value === '\x1bOA')
|
|
19
|
+
return 'up';
|
|
20
|
+
if (value === '\x1b[B' || value === '\x1bOB')
|
|
21
|
+
return 'down';
|
|
22
|
+
if (value === '\r' || value === '\n')
|
|
23
|
+
return 'enter';
|
|
24
|
+
if (value === '\x03')
|
|
25
|
+
return 'escape';
|
|
26
|
+
if (value === '\x1b')
|
|
27
|
+
return 'escape-start';
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const ANSI_PATTERN = `${String.fromCharCode(27)}\\[[0-9;]*m`;
|
|
31
|
+
const ANSI_REGEX = new RegExp(ANSI_PATTERN, 'g');
|
|
32
|
+
const ANSI_LEADING_REGEX = new RegExp(`^${ANSI_PATTERN}`);
|
|
33
|
+
/** Remove colour codes, leaving the text an operator sees. */
|
|
34
|
+
export function stripAnsi(input) {
|
|
35
|
+
return input.replace(ANSI_REGEX, '');
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Shorten coloured text to a visible width without cutting an escape code in
|
|
39
|
+
* half, resetting the colour before the ellipsis so it cannot bleed.
|
|
40
|
+
*/
|
|
41
|
+
export function truncateAnsi(input, maxVisibleChars) {
|
|
42
|
+
if (maxVisibleChars <= 0)
|
|
43
|
+
return '';
|
|
44
|
+
if (stripAnsi(input).length <= maxVisibleChars)
|
|
45
|
+
return input;
|
|
46
|
+
const suffix = maxVisibleChars >= 3 ? '...' : '.'.repeat(maxVisibleChars);
|
|
47
|
+
const keep = Math.max(0, maxVisibleChars - suffix.length);
|
|
48
|
+
let output = '';
|
|
49
|
+
let offset = 0;
|
|
50
|
+
let visible = 0;
|
|
51
|
+
while (offset < input.length && visible < keep) {
|
|
52
|
+
if (input[offset] === '\x1b') {
|
|
53
|
+
const match = input.slice(offset).match(ANSI_LEADING_REGEX);
|
|
54
|
+
if (match) {
|
|
55
|
+
output += match[0];
|
|
56
|
+
offset += match[0].length;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
output += input[offset];
|
|
61
|
+
offset += 1;
|
|
62
|
+
visible += 1;
|
|
63
|
+
}
|
|
64
|
+
return output.includes('\x1b[')
|
|
65
|
+
? `${output}${ANSI.reset}${suffix}`
|
|
66
|
+
: output + suffix;
|
|
67
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type MenuTerminal } from './terminal.js';
|
|
2
|
+
/**
|
|
3
|
+
* Ask a yes/no question. "No" is listed first unless `defaultYes`, so a
|
|
4
|
+
* stray Enter declines. Escape, and a terminal that cannot take keys, count
|
|
5
|
+
* as "No": nothing destructive runs without an explicit yes.
|
|
6
|
+
*/
|
|
7
|
+
export declare function confirm(terminal: MenuTerminal, message: string, defaultYes?: boolean): Promise<boolean>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { select } from './select.js';
|
|
2
|
+
import { isInteractive } from './terminal.js';
|
|
3
|
+
/**
|
|
4
|
+
* Ask a yes/no question. "No" is listed first unless `defaultYes`, so a
|
|
5
|
+
* stray Enter declines. Escape, and a terminal that cannot take keys, count
|
|
6
|
+
* as "No": nothing destructive runs without an explicit yes.
|
|
7
|
+
*/
|
|
8
|
+
export async function confirm(terminal, message, defaultYes = false) {
|
|
9
|
+
if (!isInteractive(terminal))
|
|
10
|
+
return false;
|
|
11
|
+
const items = defaultYes
|
|
12
|
+
? [
|
|
13
|
+
{ label: 'Yes', value: true },
|
|
14
|
+
{ label: 'No', value: false },
|
|
15
|
+
]
|
|
16
|
+
: [
|
|
17
|
+
{ label: 'No', value: false },
|
|
18
|
+
{ label: 'Yes', value: true },
|
|
19
|
+
];
|
|
20
|
+
return (await select(terminal, items, { message })) ?? false;
|
|
21
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { MenuAction, MenuContext } from './menu.js';
|
|
2
|
+
/** A fix the doctor can offer for one finding; applied only when chosen. */
|
|
3
|
+
export interface DoctorRepair {
|
|
4
|
+
/** What the repair does, as the operator is asked about it. */
|
|
5
|
+
label: string;
|
|
6
|
+
apply(): void | Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
export interface DoctorFinding {
|
|
9
|
+
/** A stable plugin-chosen code, for tests and logs. */
|
|
10
|
+
code: string;
|
|
11
|
+
message: string;
|
|
12
|
+
accountId?: string;
|
|
13
|
+
repair?: DoctorRepair;
|
|
14
|
+
}
|
|
15
|
+
/** One plugin-registered check. A check only reads; repairs do the writing. */
|
|
16
|
+
export interface DoctorCheck {
|
|
17
|
+
id: string;
|
|
18
|
+
run(): readonly DoctorFinding[] | Promise<readonly DoctorFinding[]>;
|
|
19
|
+
}
|
|
20
|
+
export interface DoctorReport {
|
|
21
|
+
findings: DoctorFinding[];
|
|
22
|
+
}
|
|
23
|
+
/** The code of the finding recorded for a check that threw. */
|
|
24
|
+
export declare const DOCTOR_CHECK_FAILED = "doctor-check-failed";
|
|
25
|
+
/**
|
|
26
|
+
* Runs every check in order. A check that throws becomes a finding of its
|
|
27
|
+
* own instead of hiding what the other checks found.
|
|
28
|
+
*/
|
|
29
|
+
export declare function runDoctorChecks(checks: readonly DoctorCheck[]): Promise<DoctorReport>;
|
|
30
|
+
export declare function formatDoctorReport(title: string, report: DoctorReport): string[];
|
|
31
|
+
export interface RepairOutcome {
|
|
32
|
+
applied: DoctorFinding[];
|
|
33
|
+
declined: DoctorFinding[];
|
|
34
|
+
failed: {
|
|
35
|
+
finding: DoctorFinding;
|
|
36
|
+
error: unknown;
|
|
37
|
+
}[];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Asks about each available repair in turn and applies only the ones the
|
|
41
|
+
* operator answers yes to. Without an interactive terminal every question
|
|
42
|
+
* is answered no, so nothing is written.
|
|
43
|
+
*/
|
|
44
|
+
export declare function applyChosenRepairs(context: MenuContext, report: DoctorReport): Promise<RepairOutcome>;
|
|
45
|
+
export interface DoctorActionOptions {
|
|
46
|
+
checks: readonly DoctorCheck[];
|
|
47
|
+
/** The report's heading; defaults to "Auth doctor". */
|
|
48
|
+
title?: string;
|
|
49
|
+
label?: string;
|
|
50
|
+
}
|
|
51
|
+
/** The menu's doctor: lists every finding, then offers each repair. */
|
|
52
|
+
export declare function doctorAction(options: DoctorActionOptions): MenuAction;
|