@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,81 @@
1
+ /** The code of the finding recorded for a check that threw. */
2
+ export const DOCTOR_CHECK_FAILED = 'doctor-check-failed';
3
+ /**
4
+ * Runs every check in order. A check that throws becomes a finding of its
5
+ * own instead of hiding what the other checks found.
6
+ */
7
+ export async function runDoctorChecks(checks) {
8
+ const findings = [];
9
+ for (const check of checks) {
10
+ try {
11
+ findings.push(...(await check.run()));
12
+ }
13
+ catch (error) {
14
+ findings.push({
15
+ code: DOCTOR_CHECK_FAILED,
16
+ message: `Check ${check.id} failed: ${error instanceof Error ? error.message : String(error)}`,
17
+ });
18
+ }
19
+ }
20
+ return { findings };
21
+ }
22
+ export function formatDoctorReport(title, report) {
23
+ const lines = [title];
24
+ if (report.findings.length === 0) {
25
+ lines.push('No problems found.');
26
+ return lines;
27
+ }
28
+ for (const finding of report.findings) {
29
+ const repair = finding.repair ? ' (repair available)' : '';
30
+ lines.push(`- ${finding.message}${repair}`);
31
+ }
32
+ return lines;
33
+ }
34
+ /**
35
+ * Asks about each available repair in turn and applies only the ones the
36
+ * operator answers yes to. Without an interactive terminal every question
37
+ * is answered no, so nothing is written.
38
+ */
39
+ export async function applyChosenRepairs(context, report) {
40
+ const outcome = { applied: [], declined: [], failed: [] };
41
+ for (const finding of report.findings) {
42
+ const repair = finding.repair;
43
+ if (!repair)
44
+ continue;
45
+ if (!(await context.confirm(`Apply repair: ${repair.label}?`))) {
46
+ outcome.declined.push(finding);
47
+ continue;
48
+ }
49
+ try {
50
+ await repair.apply();
51
+ outcome.applied.push(finding);
52
+ }
53
+ catch (error) {
54
+ outcome.failed.push({ finding, error });
55
+ context.print(`Repair failed: ${repair.label}: ${error instanceof Error ? error.message : String(error)}`);
56
+ }
57
+ }
58
+ return outcome;
59
+ }
60
+ /** The menu's doctor: lists every finding, then offers each repair. */
61
+ export function doctorAction(options) {
62
+ const title = options.title ?? 'Auth doctor';
63
+ return {
64
+ id: 'doctor',
65
+ label: options.label ?? 'Auth doctor',
66
+ hint: 'check accounts and offer repairs',
67
+ async run(context) {
68
+ const report = await runDoctorChecks(options.checks);
69
+ for (const line of formatDoctorReport(title, report))
70
+ context.print(line);
71
+ const repairable = report.findings.filter((finding) => finding.repair);
72
+ if (repairable.length === 0) {
73
+ if (report.findings.length > 0)
74
+ context.print('No repairs are available.');
75
+ return;
76
+ }
77
+ const outcome = await applyChosenRepairs(context, report);
78
+ context.print(`Applied ${outcome.applied.length} of ${repairable.length} repair(s).`);
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,17 @@
1
+ export type { AccountMenuOptions } from './accounts.js';
2
+ export { accountMenuActions, addAccountAction, checkQuotasAction, deleteAllAction, listAccountsAction, MENU_DISABLE_REASON, poolHasCredential, quotaLines, reauthenticateAction, removeAccountAction, runAccountMenu, toggleAccountAction, } from './accounts.js';
3
+ export type { KeyAction } from './ansi.js';
4
+ export { ANSI, parseKey, stripAnsi, truncateAnsi } from './ansi.js';
5
+ export { confirm } from './confirm.js';
6
+ export type { DoctorActionOptions, DoctorCheck, DoctorFinding, DoctorRepair, DoctorReport, RepairOutcome, } from './doctor.js';
7
+ export { applyChosenRepairs, DOCTOR_CHECK_FAILED, doctorAction, formatDoctorReport, runDoctorChecks, } from './doctor.js';
8
+ export type { LoginAccount, LoginFlow, MenuLogin } from './login.js';
9
+ export { openBrowserForMenu, runMenuLogin } from './login.js';
10
+ export type { MenuAction, MenuContext, MenuOutcome, RunMenuOptions, } from './menu.js';
11
+ export { menuContext, runMenu } from './menu.js';
12
+ export type { AuthorizeInputs, MenuAuthorizeOptions, MenuCompletedResult, } from './opencode-v1.js';
13
+ export { isCliAuthorize, menuAuthorize, menuCompletedResult, } from './opencode-v1.js';
14
+ export type { MenuItem, SelectOptions } from './select.js';
15
+ export { select } from './select.js';
16
+ export type { MenuInput, MenuOutput, MenuSignals, MenuTerminal, } from './terminal.js';
17
+ export { isInteractive, printLine, processTerminal } from './terminal.js';
@@ -0,0 +1,12 @@
1
+ // The `opencode auth login` account menu: a first-party full-screen menu
2
+ // runtime, the OpenCode v1 `authorize` contract around it, and the account
3
+ // actions built over the `/store` pool.
4
+ export { accountMenuActions, addAccountAction, checkQuotasAction, deleteAllAction, listAccountsAction, MENU_DISABLE_REASON, poolHasCredential, quotaLines, reauthenticateAction, removeAccountAction, runAccountMenu, toggleAccountAction, } from './accounts.js';
5
+ export { ANSI, parseKey, stripAnsi, truncateAnsi } from './ansi.js';
6
+ export { confirm } from './confirm.js';
7
+ export { applyChosenRepairs, DOCTOR_CHECK_FAILED, doctorAction, formatDoctorReport, runDoctorChecks, } from './doctor.js';
8
+ export { openBrowserForMenu, runMenuLogin } from './login.js';
9
+ export { menuContext, runMenu } from './menu.js';
10
+ export { isCliAuthorize, menuAuthorize, menuCompletedResult, } from './opencode-v1.js';
11
+ export { select } from './select.js';
12
+ export { isInteractive, printLine, processTerminal } from './terminal.js';
@@ -0,0 +1,47 @@
1
+ import type { PoolCredential } from '../store/index.js';
2
+ import type { MenuContext } from './menu.js';
3
+ /** An account a login produced, ready to become a pool row. */
4
+ export interface LoginAccount {
5
+ credential: PoolCredential;
6
+ /** The provider's account identity, recorded on the row when known. */
7
+ identity?: string;
8
+ label?: string;
9
+ /** The row id to use for a new account; the menu makes one otherwise. */
10
+ id?: string;
11
+ }
12
+ /** A started login: where the operator signs in, and its eventual account. */
13
+ export interface LoginFlow {
14
+ url: string;
15
+ instructions: string;
16
+ completion: Promise<LoginAccount>;
17
+ }
18
+ /** The plugin's login, supplied to the menu's add and re-authenticate actions. */
19
+ export interface MenuLogin {
20
+ /**
21
+ * Starts a browser login (`headless: false`, abortable through `signal`) or
22
+ * a device-code login (`headless: true`) for a machine without a browser.
23
+ */
24
+ begin(options: {
25
+ headless: boolean;
26
+ signal?: AbortSignal;
27
+ }): Promise<LoginFlow>;
28
+ /**
29
+ * Opens the URL in a browser; false or a throw means no browser could be
30
+ * opened. Defaults to `openBrowserForMenu`.
31
+ */
32
+ openBrowser?(url: string): boolean | undefined | Promise<boolean | undefined>;
33
+ }
34
+ type BrowserExec = (file: string, args: string[], options: {
35
+ stdio: 'ignore';
36
+ timeout: number;
37
+ }) => unknown;
38
+ /** Opens a URL with the platform's opener; false when that fails. */
39
+ export declare function openBrowserForMenu(url: string, platform?: NodeJS.Platform, execFileSync?: BrowserExec): boolean;
40
+ /**
41
+ * Runs a login for the menu: a browser login first and, when no browser can
42
+ * be opened, a device-code login instead, so a headless machine can still
43
+ * add an account. The URL is always printed, so an operator can open it by
44
+ * hand when the opener reports success but nothing appears.
45
+ */
46
+ export declare function runMenuLogin(login: MenuLogin, context: MenuContext): Promise<LoginAccount>;
47
+ export {};
@@ -0,0 +1,63 @@
1
+ import { execFileSync as defaultExecFileSync } from 'node:child_process';
2
+ /** Opens a URL with the platform's opener; false when that fails. */
3
+ export function openBrowserForMenu(url, platform = process.platform, execFileSync = defaultExecFileSync) {
4
+ try {
5
+ if (platform === 'win32') {
6
+ execFileSync('cmd', ['/c', 'start', '', url], {
7
+ stdio: 'ignore',
8
+ timeout: 3000,
9
+ });
10
+ }
11
+ else {
12
+ execFileSync(platform === 'darwin' ? 'open' : 'xdg-open', [url], {
13
+ stdio: 'ignore',
14
+ timeout: 3000,
15
+ });
16
+ }
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ function printFlow(context, flow) {
24
+ context.print('');
25
+ context.print('Open this URL in your browser and complete sign-in:');
26
+ context.print('');
27
+ context.print(flow.url);
28
+ context.print('');
29
+ if (flow.instructions) {
30
+ context.print(flow.instructions);
31
+ context.print('');
32
+ }
33
+ }
34
+ /**
35
+ * Runs a login for the menu: a browser login first and, when no browser can
36
+ * be opened, a device-code login instead, so a headless machine can still
37
+ * add an account. The URL is always printed, so an operator can open it by
38
+ * hand when the opener reports success but nothing appears.
39
+ */
40
+ export async function runMenuLogin(login, context) {
41
+ const abort = new AbortController();
42
+ let flow = await login.begin({ headless: false, signal: abort.signal });
43
+ // Attached before the opener runs: an opener failure aborts this flow in
44
+ // the same turn, and its rejection must not surface as unhandled.
45
+ void flow.completion.catch(() => { });
46
+ printFlow(context, flow);
47
+ let opened = false;
48
+ try {
49
+ opened =
50
+ (await (login.openBrowser ?? openBrowserForMenu)(flow.url)) !== false;
51
+ }
52
+ catch {
53
+ opened = false;
54
+ }
55
+ if (!opened) {
56
+ abort.abort();
57
+ context.print('Could not open a browser. Switching to device authorization.');
58
+ context.print('');
59
+ flow = await login.begin({ headless: true });
60
+ printFlow(context, flow);
61
+ }
62
+ return flow.completion;
63
+ }
@@ -0,0 +1,59 @@
1
+ import { type MenuItem, type SelectOptions } from './select.js';
2
+ import { type MenuTerminal } from './terminal.js';
3
+ /** What an action is given to talk to the operator. */
4
+ export interface MenuContext {
5
+ terminal: MenuTerminal;
6
+ /** Whether keys can be read; false when the menu printed a plain list. */
7
+ interactive: boolean;
8
+ print(line?: string): void;
9
+ /** A yes/no question; false on a terminal that cannot take keys. */
10
+ confirm(message: string, defaultYes?: boolean): Promise<boolean>;
11
+ /** A list choice; null on cancel or on a terminal that cannot take keys. */
12
+ select<T>(items: readonly MenuItem<T>[], options: SelectOptions): Promise<T | null>;
13
+ }
14
+ export interface MenuAction {
15
+ id: string;
16
+ label: string;
17
+ hint?: string;
18
+ /**
19
+ * Shown in red and run only after the operator answers yes to `confirm`
20
+ * (or "<label>?"). An action that first asks which account to act on
21
+ * leaves this unset and confirms through its context once it knows.
22
+ */
23
+ destructive?: boolean;
24
+ confirm?: string;
25
+ run(context: MenuContext): void | Promise<void>;
26
+ }
27
+ export interface RunMenuOptions {
28
+ title: string;
29
+ subtitle?: string;
30
+ /** Lines shown above the actions, such as the accounts and their state. */
31
+ status?: readonly string[];
32
+ actions: readonly MenuAction[];
33
+ /** Defaults to the current process's terminal. */
34
+ terminal?: MenuTerminal;
35
+ }
36
+ export type MenuOutcome = {
37
+ status: 'ran';
38
+ action: string;
39
+ } | {
40
+ status: 'declined';
41
+ action: string;
42
+ } | {
43
+ status: 'failed';
44
+ action: string;
45
+ error: unknown;
46
+ } | {
47
+ status: 'cancelled';
48
+ } | {
49
+ status: 'not-interactive';
50
+ };
51
+ export declare function menuContext(terminal: MenuTerminal): MenuContext;
52
+ /**
53
+ * Show the full-screen menu once and run the chosen action. Without an
54
+ * interactive terminal it prints the same content as a plain list and runs
55
+ * nothing, so a piped or scripted login exits cleanly instead of hanging on
56
+ * a key that never comes. An action's failure is printed and reported rather
57
+ * than thrown, because the caller still owes the host its result.
58
+ */
59
+ export declare function runMenu(options: RunMenuOptions): Promise<MenuOutcome>;
@@ -0,0 +1,73 @@
1
+ import { confirm } from './confirm.js';
2
+ import { select } from './select.js';
3
+ import { isInteractive, printLine, processTerminal, } from './terminal.js';
4
+ export function menuContext(terminal) {
5
+ const interactive = isInteractive(terminal);
6
+ return {
7
+ terminal,
8
+ interactive,
9
+ print: (line) => printLine(terminal, line),
10
+ confirm: (message, defaultYes) => confirm(terminal, message, defaultYes),
11
+ select: async (items, options) => interactive && items.length > 0 ? select(terminal, items, options) : null,
12
+ };
13
+ }
14
+ function printPlainMenu(context, options) {
15
+ context.print(options.title);
16
+ if (options.subtitle)
17
+ context.print(options.subtitle);
18
+ for (const line of options.status ?? [])
19
+ context.print(` ${line}`);
20
+ context.print('');
21
+ context.print('Actions:');
22
+ for (const action of options.actions) {
23
+ context.print(action.hint
24
+ ? ` - ${action.label} (${action.hint})`
25
+ : ` - ${action.label}`);
26
+ }
27
+ context.print('');
28
+ context.print('This menu needs an interactive terminal to choose an action; nothing was changed.');
29
+ return { status: 'not-interactive' };
30
+ }
31
+ /**
32
+ * Show the full-screen menu once and run the chosen action. Without an
33
+ * interactive terminal it prints the same content as a plain list and runs
34
+ * nothing, so a piped or scripted login exits cleanly instead of hanging on
35
+ * a key that never comes. An action's failure is printed and reported rather
36
+ * than thrown, because the caller still owes the host its result.
37
+ */
38
+ export async function runMenu(options) {
39
+ const terminal = options.terminal ?? processTerminal();
40
+ const context = menuContext(terminal);
41
+ if (!context.interactive)
42
+ return printPlainMenu(context, options);
43
+ if (options.actions.length === 0) {
44
+ context.print('No actions are available.');
45
+ return { status: 'cancelled' };
46
+ }
47
+ const chosen = await select(terminal, options.actions.map((action) => ({
48
+ label: action.label,
49
+ value: action,
50
+ color: action.destructive ? 'red' : 'cyan',
51
+ ...(action.hint ? { hint: action.hint } : {}),
52
+ })), {
53
+ message: options.title,
54
+ subtitle: options.subtitle ?? 'Select an account action',
55
+ ...(options.status ? { lines: options.status } : {}),
56
+ clearScreen: true,
57
+ });
58
+ if (!chosen)
59
+ return { status: 'cancelled' };
60
+ if (chosen.destructive &&
61
+ !(await context.confirm(chosen.confirm ?? `${chosen.label}?`))) {
62
+ context.print('Cancelled; nothing was changed.');
63
+ return { status: 'declined', action: chosen.id };
64
+ }
65
+ try {
66
+ await chosen.run(context);
67
+ return { status: 'ran', action: chosen.id };
68
+ }
69
+ catch (error) {
70
+ context.print(`${chosen.label} failed: ${error instanceof Error ? error.message : String(error)}`);
71
+ return { status: 'failed', action: chosen.id, error };
72
+ }
73
+ }
@@ -0,0 +1,49 @@
1
+ /** The inputs the CLI passes to `authorize`; the TUI passes none. */
2
+ export type AuthorizeInputs = Record<string, string>;
3
+ /** The automatic-method result OpenCode v1 awaits after `authorize`. */
4
+ export interface MenuCompletedResult {
5
+ url: '';
6
+ instructions: '';
7
+ method: 'auto';
8
+ callback(): Promise<{
9
+ type: 'failed';
10
+ }>;
11
+ }
12
+ /**
13
+ * The result to hand back after the menu has run, whatever the action did.
14
+ *
15
+ * The host's result type has no top-level "done, store nothing": every
16
+ * result ends in a callback whose success the host files as the provider's
17
+ * own credential. An account added, re-authenticated or repaired from the
18
+ * menu is already stored by the plugin, and storing it again in the host's
19
+ * slot would make an extra account the provider's main credential. A failed
20
+ * callback is the only result that stores nothing, so the operator sees
21
+ * "Failed to authorize" after every menu action; that line is expected.
22
+ */
23
+ export declare function menuCompletedResult(): MenuCompletedResult;
24
+ /** Whether `authorize` was called by `opencode auth login` rather than the TUI. */
25
+ export declare function isCliAuthorize(inputs: AuthorizeInputs | undefined): inputs is AuthorizeInputs;
26
+ export interface MenuAuthorizeOptions<R> {
27
+ /**
28
+ * Whether this machine is past its first sign-in: a real credential exists,
29
+ * in the host's slot or in the plugin's store.
30
+ *
31
+ * Do not answer "the account roster is not empty". The signed-in account
32
+ * is usually the only one there is and may not be a roster row, so a
33
+ * roster check hides the menu from exactly the operator who came to add
34
+ * their first extra account, and on a headless machine leaves them no way
35
+ * to do it.
36
+ */
37
+ hasCredential(): boolean | Promise<boolean>;
38
+ /** Shows the menu and runs the chosen action. */
39
+ openMenu(inputs: AuthorizeInputs): Promise<unknown>;
40
+ /** The plugin's normal login, used by the TUI and by a first CLI login. */
41
+ login(inputs?: AuthorizeInputs): Promise<R>;
42
+ }
43
+ /**
44
+ * Builds an OAuth method's `authorize`: the TUI and a first CLI login get
45
+ * the plugin's normal login; a CLI login on a machine with a credential gets
46
+ * the menu, then the failed result described at `menuCompletedResult`. The
47
+ * TUI path never runs `hasCredential`, so it reads nothing it did not before.
48
+ */
49
+ export declare function menuAuthorize<R>(options: MenuAuthorizeOptions<R>): (inputs?: AuthorizeInputs) => Promise<R | MenuCompletedResult>;
@@ -0,0 +1,47 @@
1
+ // The OpenCode v1 side of the account menu.
2
+ //
3
+ // `opencode auth login` calls an OAuth method's `authorize(inputs?)` and then
4
+ // awaits the returned result's `callback()`; a success there is stored as the
5
+ // provider's credential. `inputs` is passed only by the CLI, never by the TUI,
6
+ // which is what lets the CLI path open a menu while the TUI keeps signing in
7
+ // as before. The types here are structural copies of the host's
8
+ // `AuthOAuthResult` so the library does not depend on the host's package.
9
+ /**
10
+ * The result to hand back after the menu has run, whatever the action did.
11
+ *
12
+ * The host's result type has no top-level "done, store nothing": every
13
+ * result ends in a callback whose success the host files as the provider's
14
+ * own credential. An account added, re-authenticated or repaired from the
15
+ * menu is already stored by the plugin, and storing it again in the host's
16
+ * slot would make an extra account the provider's main credential. A failed
17
+ * callback is the only result that stores nothing, so the operator sees
18
+ * "Failed to authorize" after every menu action; that line is expected.
19
+ */
20
+ export function menuCompletedResult() {
21
+ return {
22
+ url: '',
23
+ instructions: '',
24
+ method: 'auto',
25
+ callback: async () => ({ type: 'failed' }),
26
+ };
27
+ }
28
+ /** Whether `authorize` was called by `opencode auth login` rather than the TUI. */
29
+ export function isCliAuthorize(inputs) {
30
+ return inputs !== undefined;
31
+ }
32
+ /**
33
+ * Builds an OAuth method's `authorize`: the TUI and a first CLI login get
34
+ * the plugin's normal login; a CLI login on a machine with a credential gets
35
+ * the menu, then the failed result described at `menuCompletedResult`. The
36
+ * TUI path never runs `hasCredential`, so it reads nothing it did not before.
37
+ */
38
+ export function menuAuthorize(options) {
39
+ return async (inputs) => {
40
+ if (!isCliAuthorize(inputs))
41
+ return options.login();
42
+ if (!(await options.hasCredential()))
43
+ return options.login(inputs);
44
+ await options.openMenu(inputs);
45
+ return menuCompletedResult();
46
+ };
47
+ }
@@ -0,0 +1,21 @@
1
+ import { type MenuTerminal } from './terminal.js';
2
+ export interface MenuItem<T = string> {
3
+ label: string;
4
+ value: T;
5
+ color?: 'red' | 'cyan';
6
+ /** Dim text shown after the label, such as an account's state. */
7
+ hint?: string;
8
+ }
9
+ export interface SelectOptions {
10
+ message: string;
11
+ subtitle?: string;
12
+ /** Lines shown between the subtitle and the items, such as status lines. */
13
+ lines?: readonly string[];
14
+ clearScreen?: boolean;
15
+ }
16
+ /**
17
+ * Render a bounded, keyboard-only terminal selector without a prompt
18
+ * dependency. Resolves the chosen value, or null on Escape, Ctrl-C, a signal,
19
+ * or a terminal that refuses raw mode.
20
+ */
21
+ export declare function select<T>(terminal: MenuTerminal, items: readonly MenuItem<T>[], options: SelectOptions): Promise<T | null>;
@@ -0,0 +1,153 @@
1
+ import { ANSI, parseKey, truncateAnsi } from './ansi.js';
2
+ import { isInteractive } from './terminal.js';
3
+ /** How long a lone Escape byte waits for the rest of an arrow-key sequence. */
4
+ const ESCAPE_TIMEOUT_MS = 50;
5
+ function colorCode(color) {
6
+ if (color === 'red')
7
+ return ANSI.red;
8
+ if (color === 'cyan')
9
+ return ANSI.cyan;
10
+ return '';
11
+ }
12
+ /**
13
+ * Render a bounded, keyboard-only terminal selector without a prompt
14
+ * dependency. Resolves the chosen value, or null on Escape, Ctrl-C, a signal,
15
+ * or a terminal that refuses raw mode.
16
+ */
17
+ export async function select(terminal, items, options) {
18
+ if (!isInteractive(terminal))
19
+ throw new Error('Interactive select requires a TTY terminal');
20
+ if (items.length === 0)
21
+ throw new Error('No menu items provided');
22
+ const { input: stdin, output: stdout, signals } = terminal;
23
+ let cursor = 0;
24
+ let escapeTimeout = null;
25
+ let cleaned = false;
26
+ let renderedLines = 0;
27
+ const render = () => {
28
+ const columns = stdout.columns ?? 80;
29
+ const rows = stdout.rows ?? 24;
30
+ const previousLines = renderedLines;
31
+ if (options.clearScreen) {
32
+ stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1));
33
+ }
34
+ else if (previousLines > 0) {
35
+ stdout.write(ANSI.up(previousLines));
36
+ }
37
+ let lines = 0;
38
+ const writeLine = (line) => {
39
+ stdout.write(`${ANSI.clearLine}${line}\n`);
40
+ lines += 1;
41
+ };
42
+ const extraLines = options.lines ?? [];
43
+ const headerLines = 1 +
44
+ (options.subtitle ? 3 : 0) +
45
+ extraLines.length +
46
+ (extraLines.length ? 1 : 0);
47
+ const maxVisible = Math.max(1, Math.min(items.length, rows - headerLines - 2 - 1));
48
+ const windowStart = Math.max(0, Math.min(cursor - Math.floor(maxVisible / 2), Math.max(0, items.length - maxVisible)));
49
+ const visibleItems = items.slice(windowStart, windowStart + maxVisible);
50
+ writeLine(`${ANSI.dim}┌ ${ANSI.reset}${truncateAnsi(options.message, Math.max(1, columns - 4))}`);
51
+ if (options.subtitle) {
52
+ writeLine(`${ANSI.dim}│${ANSI.reset}`);
53
+ writeLine(`${ANSI.cyan}◆${ANSI.reset} ${truncateAnsi(options.subtitle, Math.max(1, columns - 4))}`);
54
+ writeLine('');
55
+ }
56
+ for (const line of extraLines) {
57
+ writeLine(`${ANSI.cyan}│${ANSI.reset} ${truncateAnsi(line, Math.max(1, columns - 4))}`);
58
+ }
59
+ if (extraLines.length)
60
+ writeLine(`${ANSI.cyan}│${ANSI.reset}`);
61
+ for (let offset = 0; offset < visibleItems.length; offset++) {
62
+ const item = visibleItems[offset];
63
+ if (!item)
64
+ continue;
65
+ const selected = windowStart + offset === cursor;
66
+ const color = colorCode(item.color);
67
+ let label = color
68
+ ? `${selected ? '' : ANSI.dim}${color}${item.label}${ANSI.reset}`
69
+ : selected
70
+ ? item.label
71
+ : `${ANSI.dim}${item.label}${ANSI.reset}`;
72
+ if (item.hint)
73
+ label += ` ${ANSI.dim}${item.hint}${ANSI.reset}`;
74
+ label = truncateAnsi(label, Math.max(1, columns - 8));
75
+ writeLine(selected
76
+ ? `${ANSI.cyan}│${ANSI.reset} ${ANSI.green}●${ANSI.reset} ${label}`
77
+ : `${ANSI.cyan}│${ANSI.reset} ${ANSI.dim}○${ANSI.reset} ${label}`);
78
+ }
79
+ const windowHint = visibleItems.length < items.length
80
+ ? ` (${windowStart + 1}-${windowStart + visibleItems.length}/${items.length})`
81
+ : '';
82
+ writeLine(`${ANSI.cyan}│${ANSI.reset} ${ANSI.dim}${truncateAnsi(`Up/Down to select | Enter: confirm | Esc: back${windowHint}`, Math.max(1, columns - 6))}${ANSI.reset}`);
83
+ writeLine(`${ANSI.cyan}└${ANSI.reset}`);
84
+ for (let extra = lines; extra < previousLines; extra++)
85
+ writeLine('');
86
+ renderedLines = lines;
87
+ };
88
+ return new Promise((resolve) => {
89
+ const wasRaw = stdin.isRaw ?? false;
90
+ const cleanup = () => {
91
+ if (cleaned)
92
+ return;
93
+ cleaned = true;
94
+ if (escapeTimeout)
95
+ clearTimeout(escapeTimeout);
96
+ stdin.removeListener('data', onKey);
97
+ try {
98
+ stdin.setRawMode(wasRaw);
99
+ stdin.pause();
100
+ stdout.write(ANSI.show);
101
+ }
102
+ catch { }
103
+ signals?.removeListener('SIGINT', onSignal);
104
+ signals?.removeListener('SIGTERM', onSignal);
105
+ };
106
+ const finish = (value) => {
107
+ cleanup();
108
+ resolve(value);
109
+ };
110
+ const onSignal = () => finish(null);
111
+ const onKey = (data) => {
112
+ if (escapeTimeout) {
113
+ clearTimeout(escapeTimeout);
114
+ escapeTimeout = null;
115
+ }
116
+ switch (parseKey(data)) {
117
+ case 'up':
118
+ cursor = (cursor - 1 + items.length) % items.length;
119
+ render();
120
+ break;
121
+ case 'down':
122
+ cursor = (cursor + 1) % items.length;
123
+ render();
124
+ break;
125
+ case 'enter':
126
+ finish(items[cursor]?.value ?? null);
127
+ break;
128
+ case 'escape':
129
+ finish(null);
130
+ break;
131
+ case 'escape-start':
132
+ // A bare Escape byte is also the start of an arrow-key sequence
133
+ // that may arrive split across reads; only a lone one cancels.
134
+ escapeTimeout = setTimeout(() => finish(null), ESCAPE_TIMEOUT_MS);
135
+ break;
136
+ }
137
+ };
138
+ signals?.once('SIGINT', onSignal);
139
+ signals?.once('SIGTERM', onSignal);
140
+ try {
141
+ stdin.setRawMode(true);
142
+ }
143
+ catch {
144
+ cleanup();
145
+ resolve(null);
146
+ return;
147
+ }
148
+ stdin.resume();
149
+ stdout.write(ANSI.hide);
150
+ render();
151
+ stdin.on('data', onKey);
152
+ });
153
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The terminal the menu draws on and reads keys from. The process's own
3
+ * stdin and stdout satisfy it; tests pass a scripted stand-in, so the full
4
+ * key-in, frame-out path runs without a real TTY.
5
+ */
6
+ export interface MenuTerminal {
7
+ input: MenuInput;
8
+ output: MenuOutput;
9
+ /**
10
+ * Where SIGINT and SIGTERM are heard while a selector holds raw mode, so a
11
+ * kill restores the terminal. Defaults to nothing: raw mode turns Ctrl-C
12
+ * into a key the selector already handles.
13
+ */
14
+ signals?: MenuSignals;
15
+ }
16
+ export interface MenuInput {
17
+ /** True only on an interactive terminal; anything else gets a plain list. */
18
+ readonly isTTY?: boolean;
19
+ readonly isRaw?: boolean;
20
+ setRawMode(mode: boolean): unknown;
21
+ resume(): unknown;
22
+ pause(): unknown;
23
+ on(event: 'data', listener: (data: Buffer | string) => void): unknown;
24
+ removeListener(event: 'data', listener: (data: Buffer | string) => void): unknown;
25
+ }
26
+ export interface MenuOutput {
27
+ write(text: string): unknown;
28
+ readonly columns?: number;
29
+ readonly rows?: number;
30
+ }
31
+ export interface MenuSignals {
32
+ once(signal: 'SIGINT' | 'SIGTERM', listener: () => void): unknown;
33
+ removeListener(signal: 'SIGINT' | 'SIGTERM', listener: () => void): unknown;
34
+ }
35
+ /** The current process's terminal, with its signals. */
36
+ export declare function processTerminal(): MenuTerminal;
37
+ /**
38
+ * Whether keys can be read one at a time. Only the input is checked, as the
39
+ * plugins' menus always did: output piped to a file still gets the menu.
40
+ */
41
+ export declare function isInteractive(terminal: MenuTerminal): boolean;
42
+ /** Writes one line of plain output below the menu. */
43
+ export declare function printLine(terminal: MenuTerminal, line?: string): void;