@cortexkit/common-auth 0.2.3 → 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.
Files changed (44) hide show
  1. package/dist/auth-menu/accounts.d.ts +88 -0
  2. package/dist/auth-menu/accounts.js +401 -0
  3. package/dist/auth-menu/ansi.d.ts +24 -0
  4. package/dist/auth-menu/ansi.js +67 -0
  5. package/dist/auth-menu/confirm.d.ts +7 -0
  6. package/dist/auth-menu/confirm.js +21 -0
  7. package/dist/auth-menu/doctor.d.ts +52 -0
  8. package/dist/auth-menu/doctor.js +81 -0
  9. package/dist/auth-menu/index.d.ts +17 -0
  10. package/dist/auth-menu/index.js +12 -0
  11. package/dist/auth-menu/login.d.ts +47 -0
  12. package/dist/auth-menu/login.js +63 -0
  13. package/dist/auth-menu/menu.d.ts +59 -0
  14. package/dist/auth-menu/menu.js +73 -0
  15. package/dist/auth-menu/opencode-v1.d.ts +49 -0
  16. package/dist/auth-menu/opencode-v1.js +47 -0
  17. package/dist/auth-menu/select.d.ts +21 -0
  18. package/dist/auth-menu/select.js +153 -0
  19. package/dist/auth-menu/terminal.d.ts +43 -0
  20. package/dist/auth-menu/terminal.js +19 -0
  21. package/dist/cachekeep/index.d.ts +4 -0
  22. package/dist/cachekeep/index.js +2 -0
  23. package/dist/cachekeep/manager.d.ts +214 -0
  24. package/dist/cachekeep/manager.js +424 -0
  25. package/dist/cachekeep/window.d.ts +13 -0
  26. package/dist/cachekeep/window.js +33 -0
  27. package/dist/claustrum/index.d.ts +1 -0
  28. package/dist/claustrum/index.js +2 -0
  29. package/dist/commands/index.d.ts +1 -0
  30. package/dist/commands/index.js +2 -0
  31. package/dist/dump/index.d.ts +90 -0
  32. package/dist/dump/index.js +292 -0
  33. package/dist/opencode2/index.d.ts +1 -0
  34. package/dist/opencode2/index.js +2 -0
  35. package/dist/store/errors.d.ts +2 -2
  36. package/dist/store/hooks.d.ts +1 -1
  37. package/dist/store/index.d.ts +1 -1
  38. package/dist/store/mutate.d.ts +3 -2
  39. package/dist/store/mutate.js +2 -1
  40. package/dist/store/pool.d.ts +8 -1
  41. package/dist/store/pool.js +2 -1
  42. package/dist/store/rows.d.ts +28 -1
  43. package/dist/store/rows.js +93 -0
  44. package/package.json +37 -3
@@ -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;