@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,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
|
+
}
|
|
@@ -1 +1,17 @@
|
|
|
1
|
-
export {};
|
|
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';
|
package/dist/auth-menu/index.js
CHANGED
|
@@ -1,2 +1,12 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
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
|
+
}
|