From 9b9cb30ae16e5802ba556b282b38fc41117ee5e1 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Wed, 4 Nov 2026 18:20:08 +0100 Subject: [PATCH] feat(coding-agent): add pi auth login and logout pi auth login logs in from the shell without starting a session, like pi mcp login. Account sign-in is the default and takes the provider's browser method; when the provider has another method, it prints a hint to rerun with --no-browser. --no-browser skips the browser method and uses the other one, such as device code, asking only when there are several. --api-key saves an API key instead and cannot be combined with --no-browser. After logging in, it fetches the provider's model catalog, and for Radius it points the Radius MCP server in the global mcp.json at the login without asking. pi auth logout removes the stored credentials like /logout and succeeds when nothing is stored. For MCP servers that use provider credentials, pi mcp login and logout now refer to these commands instead of /login and /logout. /login shares the check for the Radius MCP server entry and the web URL helpers with the new command. --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/cli.md | 15 +- packages/coding-agent/src/cli/args.ts | 2 +- packages/coding-agent/src/cli/auth-command.ts | 28 +- packages/coding-agent/src/cli/auth-login.ts | 277 ++++++++++ packages/coding-agent/src/cli/auth-logout.ts | 72 +++ .../coding-agent/src/cli/auth-terminal.ts | 78 +++ .../src/core/provider-matching.ts | 11 + packages/coding-agent/src/core/radius-mcp.ts | 44 ++ .../coding-agent/src/extensions/mcp/cli.ts | 12 +- packages/coding-agent/src/main.ts | 47 ++ .../interactive/components/login-dialog.ts | 20 +- .../src/modes/interactive/interactive-mode.ts | 45 +- .../src/utils/terminal-question.ts | 29 ++ packages/coding-agent/src/utils/web-url.ts | 20 + packages/coding-agent/test/auth-login.test.ts | 481 ++++++++++++++++++ .../coding-agent/test/auth-logout.test.ts | 99 ++++ .../coding-agent/test/auth-terminal.test.ts | 63 +++ .../coding-agent/test/mcp-command.test.ts | 2 +- .../test/provider-matching.test.ts | 35 ++ .../test/terminal-question.test.ts | 79 +++ 21 files changed, 1392 insertions(+), 68 deletions(-) create mode 100644 packages/coding-agent/src/cli/auth-login.ts create mode 100644 packages/coding-agent/src/cli/auth-logout.ts create mode 100644 packages/coding-agent/src/cli/auth-terminal.ts create mode 100644 packages/coding-agent/src/core/provider-matching.ts create mode 100644 packages/coding-agent/src/core/radius-mcp.ts create mode 100644 packages/coding-agent/src/utils/terminal-question.ts create mode 100644 packages/coding-agent/src/utils/web-url.ts create mode 100644 packages/coding-agent/test/auth-login.test.ts create mode 100644 packages/coding-agent/test/auth-logout.test.ts create mode 100644 packages/coding-agent/test/auth-terminal.test.ts create mode 100644 packages/coding-agent/test/provider-matching.test.ts create mode 100644 packages/coding-agent/test/terminal-question.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index de6bd9b0f..7a51caad1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -11,6 +11,7 @@ - `pi update` now lists each updated package with its npm version or git commit change, for example `npm:foo 1.2.0 -> 1.3.0`, and prints `Packages are already up to date` when nothing changed. When nothing changed but an update printed a warning, for example because npm left the old version installed after resolving stale registry metadata, it prints `No packages were updated, see the warnings above` instead; in offline mode it prints `Skipped package updates (offline mode)`. `PackageManager.update()` reports each changed package as a new `result` progress event whose `message` holds that line ([#11707](https://github.com/earendil-works/pi/pull/11707), [#11711](https://github.com/earendil-works/pi/pull/11711)) - Anthropic sessions now use message threads: each request sends only the new messages and the server continues from the previous reply, falling back to the full request when the conversation changed (for example after compaction or a tool list change). On by default for the `anthropic` provider on `api.anthropic.com`; set `compat.supportsMessageThreads` in `models.json` to enable it for gateways that forward the `thread` field, or to turn it off. Cache warming does not touch the session's thread ([#11545](https://github.com/earendil-works/pi/pull/11545)) - `pi.registerMarkdownTransformer()` takes an optional `{ messageTypes }` that selects the Markdown surfaces a transformer runs on. Besides user, assistant, and thinking Markdown, transformers can now opt into `custom` (custom messages and skill blocks), `summary` (branch and compaction summaries), and `changelog` (What's New, `/changelog`, the Extension Updates notice, and the Update Available note). Without options a transformer runs on user, assistant, and thinking Markdown as before. The built-in Mermaid renderer now also draws diagrams in expanded branch and compaction summaries +- `pi auth login ` logs in to a provider from the shell without starting a session, for example `pi auth login radius`. It signs in with an account in the browser and exits; `--api-key` saves an API key instead, and `--no-browser` uses device code or copy-code sign-in instead. Logging in to Radius also configures the Radius MCP server. `pi auth logout ` removes the stored credentials, like `/logout` ### Changed diff --git a/packages/coding-agent/docs/cli.md b/packages/coding-agent/docs/cli.md index 8861d22ad..216289ac9 100644 --- a/packages/coding-agent/docs/cli.md +++ b/packages/coding-agent/docs/cli.md @@ -12,6 +12,8 @@ pi uninstall [options] pi update [target] [options] pi list pi config [options] +pi auth login [--api-key] [--no-browser] +pi auth logout pi auth [options] pi mcp [options] ``` @@ -302,11 +304,22 @@ export PI_SELF_UPDATE_COMMAND="mise use -g npm:{packageName}@{version}" ## Credential commands +`pi auth login ` logs in to a provider in the terminal and exits, without starting a session. It signs in with an account and opens the browser, like `/login `. Providers without account sign-in ask for an API key. Logging in to Radius also adds the Radius MCP server to `~/.pi/agent/mcp.json` when no global server uses that login yet. It requires an interactive terminal. + +| Option | Description | +|---|---| +| `--api-key` | Save an API key instead of signing in with an account; the key is read without echoing it | +| `--no-browser` | Do not open a browser; use the provider's other sign-in method, such as a device code, for example over SSH. Asks when there are several. Not allowed with `--api-key` | + +`pi auth logout ` removes the credentials that `pi auth login` or `/login` stored, like `/logout`. Environment variables and `models.json` config are unchanged. It exits with status `0` when nothing is stored, so it is safe to repeat. + ```sh +pi auth login radius +pi auth logout radius pi auth check --provider openai --json ``` -Authentication commands require `--provider ` or `--model `. See [Providers](providers.md) for supported methods. +The other authentication commands require `--provider ` or `--model `. See [Providers](providers.md) for supported methods. | Command | Description | |---|---| diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 4bcd28dc2..8de8441c4 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -293,7 +293,7 @@ ${chalk.bold("Commands:")} ${APP_NAME} update [source|self|pi] Update pi, extensions, or model catalogs ${APP_NAME} list List installed extensions from settings ${APP_NAME} config [-l] Open TUI to enable/disable package resources (Tab switches scope) - ${APP_NAME} auth Print credentials or check provider readiness + ${APP_NAME} auth Log in or out, print credentials, or check provider readiness ${APP_NAME} mcp Manage MCP servers ${APP_NAME} --help Show help for install/remove/uninstall/update/list/config/auth/mcp diff --git a/packages/coding-agent/src/cli/auth-command.ts b/packages/coding-agent/src/cli/auth-command.ts index 9ee804602..687e5dbd5 100644 --- a/packages/coding-agent/src/cli/auth-command.ts +++ b/packages/coding-agent/src/cli/auth-command.ts @@ -38,11 +38,35 @@ export function isAuthCommandHelp(args: string[]): boolean { export function printAuthCommandHelp(): void { console.log(`Usage: + pi auth login [--api-key] [--no-browser] + pi auth logout pi auth print-api-key [--provider ] [--model ] pi auth print-bearer-token [--provider ] [--model ] [--min-expiry ] pi auth check [--provider ] [--model ] [--json] [--credentials] [--no-refresh] -Auth commands require at least one of --provider or --model. Checks refresh expired OAuth credentials by default; --no-refresh prevents this. --credentials emits the credential, or includes it in JSON output.`); +Log in to and out of model providers, print their credentials for external clients, or check that they are ready. +Credentials are stored in ~/.pi/agent/auth.json. + +Commands: + login Sign in with an account in the browser, or save an API key, then exit + logout Remove the stored credentials; environment variables and models.json stay + print-api-key Print the resolved API key + print-bearer-token Print a resolved OAuth bearer token (refreshes if expired) + check Print ready, not_ready, or invalid; exit with 0, 1, or 2 + +Options for print-api-key, print-bearer-token, and check (at least one is required): + --provider Resolve credentials for a provider + --model Resolve credentials from a model + +Options for login: + --api-key Save an API key instead of signing in with an account + --no-browser Do not open a browser; use another sign-in method, e.g. device code + +Other options: + --min-expiry Minimum remaining token lifetime for print-bearer-token, e.g. 30m or 1h + --json Print the check result as JSON + --credentials Emit the credential from check, or include it in JSON output + --no-refresh Do not refresh expired OAuth credentials during check`); } export function parseAuthCommand(args: string[]): AuthCommand | undefined { @@ -58,7 +82,7 @@ export function parseAuthCommand(args: string[]): AuthCommand | undefined { : undefined; if (!kind) { throw new AuthCommandError( - `Unknown auth command "${args[1] ?? ""}". Use "${APP_NAME} auth print-api-key", "${APP_NAME} auth print-bearer-token", or "${APP_NAME} auth check".`, + `Unknown auth command "${args[1] ?? ""}". Use "${APP_NAME} auth login", "${APP_NAME} auth logout", "${APP_NAME} auth print-api-key", "${APP_NAME} auth print-bearer-token", or "${APP_NAME} auth check".`, ); } diff --git a/packages/coding-agent/src/cli/auth-login.ts b/packages/coding-agent/src/cli/auth-login.ts new file mode 100644 index 000000000..d0729be5d --- /dev/null +++ b/packages/coding-agent/src/cli/auth-login.ts @@ -0,0 +1,277 @@ +import type { AuthInteraction, AuthPrompt, AuthType, Provider } from "@earendil-works/pi-ai"; +import { replaceControlCharacters } from "@earendil-works/pi-tui"; +import { APP_NAME, getAuthPath } from "../config.ts"; +import { CredentialSynchronizationError, type ModelRuntime } from "../core/model-runtime.ts"; +import { findMatchingProviders } from "../core/provider-matching.ts"; +import { RADIUS_PROVIDER_ID } from "../core/radius.ts"; +import { planRadiusMcpServer } from "../core/radius-mcp.ts"; +import { addMcpServerConfig } from "../extensions/mcp/config.ts"; +import { displayUrl, webUrl } from "../utils/web-url.ts"; + +import type { LoginTerminal } from "./auth-terminal.ts"; + +/** Dependencies of `runAuthLogin`, injected so tests can script the terminal. */ +export interface AuthLoginOptions { + modelRuntime: ModelRuntime; + terminal: LoginTerminal; + agentDir: string; + cwd: string; + getDeviceId: () => string; + /** Aborts the login, e.g. on Ctrl+C. */ + signal: AbortSignal; +} + +const AUTH_LOGIN_USAGE = `${APP_NAME} auth login [--api-key] [--no-browser]`; + +/** Parsed `pi auth login` arguments. */ +interface LoginRequest { + providerRef: string; + /** Log in with an API key instead of an account. */ + apiKey: boolean; + /** Open the browser and take the browser sign-in method without asking. */ + browser: boolean; +} + +/** Run `pi auth login ` in the terminal and return the process exit code. */ +export async function runAuthLogin(args: string[], options: AuthLoginOptions): Promise { + const { modelRuntime, terminal, signal } = options; + const parsed = parseLoginRequest(args); + if (parsed.type === "invalid") { + terminal.error(parsed.message); + return 1; + } + const { request } = parsed; + const { providerRef } = request; + + const provider = findMatchingProviders(modelRuntime.getProviders(), providerRef)[0]; + if (!provider) { + const loginProviders = modelRuntime + .getProviders() + .filter((candidate) => candidate.auth.oauth || candidate.auth.apiKey?.login) + .map((candidate) => candidate.id) + .sort(); + terminal.error(`Unknown provider "${replaceControlCharacters(providerRef)}".`); + terminal.error(`Providers: ${loginProviders.join(", ")}`); + return 1; + } + if (!terminal.interactive) { + terminal.error(`"${AUTH_LOGIN_USAGE}" requires an interactive terminal`); + return 1; + } + + const authType = selectAuthType(provider, request.apiKey); + if (authType.type === "unavailable") { + terminal.error(authType.message); + return 1; + } + try { + await modelRuntime.login( + provider.id, + authType.type, + createLoginInteraction(provider.id, authType.type, request, terminal, signal), + { getDeviceId: options.getDeviceId }, + ); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (error instanceof CredentialSynchronizationError) { + terminal.error(`Logged in to ${provider.name}, but local model state could not be synchronized: ${message}`); + } else if (signal.aborted || message === "Login cancelled") { + terminal.error("Login cancelled."); + } else { + terminal.error(`Failed to log in to ${provider.name}: ${message}`); + } + return 1; + } + + terminal.write( + `${authType.type === "oauth" ? `Logged in to ${provider.name}` : `Saved API key for ${provider.name}`}. Credentials saved to ${getAuthPath()}`, + ); + await refreshProviderCatalog(provider, modelRuntime, terminal, signal); + if (provider.id === RADIUS_PROVIDER_ID) configureRadiusMcpServer(provider, options); + return 0; +} + +function parseLoginRequest( + args: string[], +): { type: "valid"; request: LoginRequest } | { type: "invalid"; message: string } { + const usage = { type: "invalid", message: `Usage: ${AUTH_LOGIN_USAGE}` } as const; + const request: LoginRequest = { providerRef: "", apiKey: false, browser: true }; + for (const arg of args) { + if (arg === "--api-key") request.apiKey = true; + else if (arg === "--no-browser") request.browser = false; + else if (arg.startsWith("-") || request.providerRef) return usage; + else request.providerRef = arg; + } + if (!request.providerRef) return usage; + if (request.apiKey && !request.browser) { + return { type: "invalid", message: "--no-browser only applies to account sign-in; omit it with --api-key." }; + } + return { type: "valid", request }; +} + +/** + * Account login unless `apiKey` asks for an API key. Providers without account login use their API key login, since + * that is the only way in. + */ +function selectAuthType( + provider: Provider, + apiKey: boolean, +): { type: AuthType } | { type: "unavailable"; message: string } { + const hasApiKeyLogin = provider.auth.apiKey?.login !== undefined; + if (!apiKey && provider.auth.oauth) return { type: "oauth" }; + if (hasApiKeyLogin) return { type: "api_key" }; + if (apiKey && provider.auth.oauth) { + return { type: "unavailable", message: `${provider.name} has no API key login. Omit --api-key to sign in.` }; + } + const method = provider.auth.apiKey?.name ?? "ambient credentials"; + return { type: "unavailable", message: `${provider.name} is configured outside ${APP_NAME} (${method}).` }; +} + +/** Adapt provider prompts and events to the command's selected sign-in method. */ +function createLoginInteraction( + providerId: string, + authType: AuthType, + request: LoginRequest, + terminal: LoginTerminal, + signal: AbortSignal, +): AuthInteraction { + // Account logins list the browser sign-in first, as /login; API key setup asks every selection. + const methodChoice = authType === "api_key" ? "ask" : request.browser ? "browser" : "other"; + let skippedMethods: string[] = []; + + async function selectMethod(prompt: Extract, promptSignal: AbortSignal) { + const [first, ...others] = prompt.options; + if (methodChoice === "ask" || !first) { + return selectInTerminal(prompt.message, prompt.options, terminal, promptSignal); + } + if (methodChoice === "browser") { + skippedMethods = others.map((option) => option.label); + return first.id; + } + const [onlyOther, ...moreOthers] = others; + // A provider whose only method is the browser sign-in still gets it, without opening the browser. + if (!onlyOther) return first.id; + if (moreOthers.length > 0) return selectInTerminal(prompt.message, others, terminal, promptSignal); + terminal.write(`${prompt.message} ${onlyOther.label}`); + return onlyOther.id; + } + + return { + signal, + prompt(prompt) { + // The browser callback can finish a step before the pasted code, cancelling just that prompt. + const promptSignal = prompt.signal ? AbortSignal.any([signal, prompt.signal]) : signal; + if (prompt.type === "select") return selectMethod(prompt, promptSignal); + const placeholder = prompt.placeholder ? ` (${prompt.placeholder})` : ""; + const message = `${prompt.message}${placeholder}: `; + return prompt.type === "secret" + ? terminal.secret(message, promptSignal) + : terminal.question(message, promptSignal); + }, + notify(event) { + if (event.type === "auth_url") { + terminal.write(`Open this URL to sign in:\n ${displayUrl(event.url)}`); + if (event.instructions) terminal.write(event.instructions); + const target = webUrl(event.url); + if (target !== undefined && request.browser) terminal.openUrl(target); + // Providers without a method choice have nothing else to offer, so they get no hint. + const rerun = `"${APP_NAME} auth login ${providerId} --no-browser"`; + const [onlyMethod, ...moreMethods] = skippedMethods; + if (onlyMethod !== undefined && moreMethods.length === 0) { + terminal.write(`To use "${onlyMethod}" instead, run ${rerun}`); + } else if (onlyMethod !== undefined) { + const labels = skippedMethods.map((label) => `"${label}"`).join(", "); + terminal.write(`Other sign-in methods: ${labels}. Run ${rerun} to choose one.`); + } + } else if (event.type === "device_code") { + terminal.write(`Open ${displayUrl(event.verificationUri)} and enter code: ${event.userCode}`); + const target = webUrl(event.verificationUri); + if (target !== undefined && request.browser && event.openBrowserOnConfirm !== false) { + terminal.openUrl(target); + } + terminal.write("Waiting for authentication..."); + } else if (event.type === "info") { + terminal.write(event.message); + for (const link of event.links ?? []) { + terminal.write(` ${link.label ? `${link.label}: ` : ""}${displayUrl(link.url)}`); + } + } else { + terminal.write(event.message); + } + }, + }; +} + +async function selectInTerminal( + message: string, + options: readonly { id: string; label: string; description?: string }[], + terminal: LoginTerminal, + signal: AbortSignal, +): Promise { + terminal.write(message); + options.forEach((option, index) => { + terminal.write(` ${index + 1}. ${option.label}${option.description ? ` - ${option.description}` : ""}`); + }); + while (true) { + const answer = (await terminal.question(`Select 1-${options.length}: `, signal)).trim(); + const option = /^\d+$/u.test(answer) ? options[Number(answer) - 1] : undefined; + if (option) return option.id; + terminal.write(`Enter a number from 1 to ${options.length}.`); + } +} + +/** Fetch the provider's model catalog now, since dynamic catalogs stay empty until the first authenticated refresh. */ +async function refreshProviderCatalog( + provider: Provider, + modelRuntime: ModelRuntime, + terminal: LoginTerminal, + signal: AbortSignal, +) { + // The login is saved by now, so Ctrl+C only skips the refresh. + const cancelled = () => { + terminal.write(`Skipped the ${provider.name} model catalog refresh; ${APP_NAME} refreshes it on next start.`); + }; + try { + const result = await modelRuntime.refresh({ + providers: [provider.id], + signal: AbortSignal.any([signal, AbortSignal.timeout(15_000)]), + }); + if (signal.aborted) { + cancelled(); + } else if (result.aborted) { + terminal.write( + `The ${provider.name} model catalog refresh timed out; ${APP_NAME} refreshes it on next start.`, + ); + } else if (result.errors.size > 0) { + terminal.write( + `The ${provider.name} model catalog could not be refreshed; ${APP_NAME} retries on next start.`, + ); + } + } catch (error: unknown) { + if (signal.aborted) { + cancelled(); + return; + } + terminal.write( + `The ${provider.name} model catalog could not be refreshed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +/** + * Point the Radius MCP server in the global mcp.json at the Radius login without asking, since the login is what the + * server needs; /login asks instead. Nothing changes when a global server already uses this login. + */ +function configureRadiusMcpServer(provider: Provider, options: AuthLoginOptions): void { + const { terminal } = options; + const plan = planRadiusMcpServer(provider.id, options.agentDir, options.cwd); + if (!plan) return; + try { + const replaced = addMcpServerConfig(plan.mcpPath, plan.name, plan.config); + terminal.write( + `${replaced ? "Updated" : "Added"} MCP server "${plan.name}" in ${plan.mcpPath} to use the ${provider.name} login.`, + ); + } catch (error: unknown) { + terminal.error(`Could not update ${plan.mcpPath}: ${error instanceof Error ? error.message : String(error)}`); + } +} diff --git a/packages/coding-agent/src/cli/auth-logout.ts b/packages/coding-agent/src/cli/auth-logout.ts new file mode 100644 index 000000000..59a201440 --- /dev/null +++ b/packages/coding-agent/src/cli/auth-logout.ts @@ -0,0 +1,72 @@ +import type { CredentialInfo } from "@earendil-works/pi-ai"; +import { replaceControlCharacters } from "@earendil-works/pi-tui"; +import { APP_NAME } from "../config.ts"; +import { CredentialSynchronizationError, type ModelRuntime } from "../core/model-runtime.ts"; +import { findMatchingProviders } from "../core/provider-matching.ts"; +import type { AuthOutput } from "./auth-terminal.ts"; + +/** Dependencies of `runAuthLogout`, injected so tests can capture the output. */ +export interface AuthLogoutOptions { + modelRuntime: ModelRuntime; + terminal: AuthOutput; + signal: AbortSignal; +} + +const AUTH_LOGOUT_USAGE = `${APP_NAME} auth logout `; + +/** + * Run `pi auth logout `: remove the credential `pi auth login` or /login stored, and return the exit code. + * Environment variables and models.json config stay, as with /logout. + */ +export async function runAuthLogout(args: string[], options: AuthLogoutOptions): Promise { + const { modelRuntime, terminal, signal } = options; + const [providerRef, ...rest] = args; + if (providerRef === undefined || providerRef.startsWith("-") || rest.length > 0) { + terminal.error(`Usage: ${AUTH_LOGOUT_USAGE}`); + return 1; + } + + let stored: readonly CredentialInfo[]; + try { + stored = await modelRuntime.listCredentials({ signal }); + } catch (error: unknown) { + terminal.error(`Could not read stored credentials: ${error instanceof Error ? error.message : String(error)}`); + return 1; + } + const provider = findMatchingProviders(modelRuntime.getProviders(), providerRef)[0]; + // Providers registered by extensions are not loaded here, so their stored credentials match by id alone. + const credential = stored.find((candidate) => candidate.providerId === (provider?.id ?? providerRef)); + if (!provider && !credential) { + terminal.error(`Unknown provider "${replaceControlCharacters(providerRef)}".`); + const storedIds = stored.map((credential) => credential.providerId).sort(); + terminal.error(`Stored credentials: ${storedIds.join(", ") || "none"}`); + return 1; + } + const providerName = provider?.name ?? providerRef; + if (!credential) { + terminal.write( + `No stored credentials for ${providerName}. Environment variables and models.json config are unchanged.`, + ); + return 0; + } + + try { + await modelRuntime.logout(credential.providerId, { signal }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (error instanceof CredentialSynchronizationError) { + terminal.error( + `Credentials removed for ${providerName}, but local model state could not be synchronized: ${message}`, + ); + } else { + terminal.error(`Logout failed: ${message}`); + } + return 1; + } + terminal.write( + credential.type === "oauth" + ? `Logged out of ${providerName}.` + : `Removed stored API key for ${providerName}. Environment variables and models.json config are unchanged.`, + ); + return 0; +} diff --git a/packages/coding-agent/src/cli/auth-terminal.ts b/packages/coding-agent/src/cli/auth-terminal.ts new file mode 100644 index 000000000..a11a09e40 --- /dev/null +++ b/packages/coding-agent/src/cli/auth-terminal.ts @@ -0,0 +1,78 @@ +import { openBrowser } from "../utils/open-browser.ts"; +import { questionTerminal } from "../utils/terminal-question.ts"; + +/** Output shared by the terminal authentication commands. */ +export interface AuthOutput { + write(line: string): void; + error(line: string): void; +} + +/** Line-based terminal I/O for `pi auth login`. Prompts reject when `signal` aborts. */ +export interface LoginTerminal extends AuthOutput { + /** Whether stdin and stdout are a terminal; login prompts need one. */ + interactive: boolean; + question(message: string, signal: AbortSignal): Promise; + /** Read a line without echoing it. */ + secret(message: string, signal: AbortSignal): Promise; + openUrl(url: string): void; +} + +/** Terminal I/O on stdin and stdout. `onInterrupt` runs on Ctrl+C or EOF in a question, or Ctrl+C in a secret. */ +export function createProcessLoginTerminal(onInterrupt: () => void): LoginTerminal { + return { + interactive: process.stdin.isTTY === true && process.stdout.isTTY === true, + write: (line) => process.stdout.write(`${line}\n`), + error: (line) => process.stderr.write(`${line}\n`), + question: (message, signal) => questionTerminal(message, { signal, output: process.stdout, onInterrupt }), + secret: (message, signal) => readSecret(message, signal, onInterrupt), + openUrl: openBrowser, + }; +} + +function readSecret(message: string, signal: AbortSignal, onInterrupt: () => void): Promise { + const stdin = process.stdin; + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new Error("Login cancelled")); + return; + } + let value = ""; + const finish = () => { + stdin.off("data", onData); + signal.removeEventListener("abort", onAbort); + stdin.setRawMode(false); + stdin.pause(); + process.stdout.write("\n"); + }; + const onAbort = () => { + finish(); + reject(new Error("Login cancelled")); + }; + const onData = (data: Buffer) => { + const chunk = data.toString("utf8"); + // Ignore escape sequences such as arrow keys. + if (chunk.startsWith("\u001b")) return; + for (const char of chunk) { + if (char === "\r" || char === "\n") { + finish(); + resolve(value); + return; + } + if (char === "\u0003") { + onInterrupt(); + return; + } + if (char === "\u007f" || char === "\b") { + value = Array.from(value).slice(0, -1).join(""); + } else if (char >= " ") { + value += char; + } + } + }; + process.stdout.write(message); + stdin.setRawMode(true); + stdin.resume(); + stdin.on("data", onData); + signal.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/packages/coding-agent/src/core/provider-matching.ts b/packages/coding-agent/src/core/provider-matching.ts new file mode 100644 index 000000000..26b94d563 --- /dev/null +++ b/packages/coding-agent/src/core/provider-matching.ts @@ -0,0 +1,11 @@ +/** Match provider IDs and display names exactly, ignoring case and surrounding whitespace in the reference. */ +export function findMatchingProviders( + providers: readonly T[], + providerRef: string, +): T[] { + const normalized = providerRef.trim().toLowerCase(); + if (!normalized) return []; + return providers.filter( + (provider) => provider.id.toLowerCase() === normalized || provider.name.toLowerCase() === normalized, + ); +} diff --git a/packages/coding-agent/src/core/radius-mcp.ts b/packages/coding-agent/src/core/radius-mcp.ts new file mode 100644 index 000000000..13a9751e6 --- /dev/null +++ b/packages/coding-agent/src/core/radius-mcp.ts @@ -0,0 +1,44 @@ +import path from "node:path"; +import { mcpNamespace } from "@earendil-works/pi-mcp"; +import { loadMcpConfig } from "../extensions/mcp/config.ts"; +import type { McpHttpServerConfig } from "./mcp-servers.ts"; +import { RADIUS_MCP_URL } from "./radius.ts"; + +/** Change to the global mcp.json that points the Radius MCP server at a Radius login. */ +export interface RadiusMcpServerPlan { + mcpPath: string; + name: string; + config: McpHttpServerConfig; +} + +/** + * The global mcp.json entry that makes the Radius MCP server use the login of `providerId`, adding the server when + * missing, or undefined when a global server already uses that login. + */ +export function planRadiusMcpServer( + providerId: string, + agentDir: string, + cwd: string, +): RadiusMcpServerPlan | undefined { + const normalizeUrl = (url: string) => url.replace(/\/+$/u, ""); + const { servers } = loadMcpConfig({ agentDir, cwd, projectTrusted: false }); + const existing = servers.find( + (server) => "url" in server.config && normalizeUrl(server.config.url) === normalizeUrl(RADIUS_MCP_URL), + ); + if (existing && "url" in existing.config && existing.config.auth?.provider === providerId) return undefined; + + let name = existing?.name ?? "radius"; + if (!existing) { + const namespaces = new Set(servers.map((server) => mcpNamespace(server.name))); + for (let suffix = 1; namespaces.has(mcpNamespace(name)); suffix++) { + name = suffix === 1 ? "radius-mcp" : `radius-mcp-${suffix}`; + } + } + const config: McpHttpServerConfig = + existing && "url" in existing.config + ? { ...existing.config, auth: { provider: providerId } } + : { url: RADIUS_MCP_URL, auth: { provider: providerId } }; + // `auth` replaces the MCP OAuth sign-in. + delete config.oauth; + return { mcpPath: path.join(agentDir, "mcp.json"), name, config }; +} diff --git a/packages/coding-agent/src/extensions/mcp/cli.ts b/packages/coding-agent/src/extensions/mcp/cli.ts index 9dba82a84..c55bc8002 100644 --- a/packages/coding-agent/src/extensions/mcp/cli.ts +++ b/packages/coding-agent/src/extensions/mcp/cli.ts @@ -7,13 +7,13 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; -import { createInterface } from "node:readline/promises"; import chalk from "chalk"; import { APP_NAME, CONFIG_DIR_NAME } from "../../config.ts"; import { validateMcpServerConfig } from "../../core/mcp-servers.ts"; import { ModelRuntime } from "../../core/model-runtime.ts"; import { ProjectTrustStore } from "../../core/trust-manager.ts"; import { openBrowser } from "../../utils/open-browser.ts"; +import { questionTerminal } from "../../utils/terminal-question.ts"; import { addMcpServerConfig, getMcpToolExposure, @@ -285,7 +285,7 @@ export async function runMcpCommand(args: string[], options: McpCommandOptions): const authProvider = getAuthProvider(entry); if (authProvider) { error( - `MCP server "${name}" uses the credentials of provider "${authProvider}". Run /${command} ${authProvider} in ${APP_NAME}.`, + `MCP server "${name}" uses the credentials of provider "${authProvider}". Run "${APP_NAME} auth ${command} ${authProvider}".`, ); return 1; } @@ -659,21 +659,19 @@ async function login( /** * The pasted redirect URL in a terminal; otherwise only the browser callback can finish the sign-in. - * Resolves to undefined when `signal` aborts: the callback arrived, or the sign-in timed out. + * Resolves to undefined on cancellation or EOF, or when the callback arrives or the sign-in times out. */ async function waitForRedirectUrl(signal: AbortSignal, interactive: boolean): Promise { if (!interactive) { if (!signal.aborted) await new Promise((resolve) => signal.addEventListener("abort", resolve, { once: true })); return undefined; } - const readline = createInterface({ input: process.stdin, output: process.stderr }); try { - return await readline.question("If the browser cannot reach this machine, paste the URL it was redirected to: ", { + return await questionTerminal("If the browser cannot reach this machine, paste the URL it was redirected to: ", { signal, + output: process.stderr, }); } catch { return undefined; - } finally { - readline.close(); } } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 34111cca6..080ac0f0f 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -26,6 +26,9 @@ import { printAuthCommandHelp, validateAuthCommandArgs, } from "./cli/auth-command.ts"; +import { runAuthLogin } from "./cli/auth-login.ts"; +import { runAuthLogout } from "./cli/auth-logout.ts"; +import { createProcessLoginTerminal } from "./cli/auth-terminal.ts"; import { resolveCredentialForPrint } from "./cli/credential-print.ts"; import { processFileArguments } from "./cli/file-processor.ts"; import { buildInitialMessage } from "./cli/initial-message.ts"; @@ -130,11 +133,55 @@ function isPlainRuntimeMetadataCommand(parsed: Args): boolean { return !parsed.print && parsed.mode === undefined && (parsed.help === true || parsed.listModels !== undefined); } +async function runAuthLoginCommand(args: string[]): Promise { + const cwd = process.cwd(); + const agentDir = getAgentDir(); + const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false }); + applyHttpProxySettings(settingsManager.getGlobalSettings().httpProxy); + configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs()); + const modelRuntime = await ModelRuntime.create({ allowModelNetwork: false, signal: AbortSignal.timeout(15_000) }); + + const controller = new AbortController(); + const interrupt = () => controller.abort(); + process.on("SIGINT", interrupt); + try { + return await runAuthLogin(args, { + modelRuntime, + terminal: createProcessLoginTerminal(interrupt), + agentDir, + cwd, + getDeviceId: () => settingsManager.getOrCreateDeviceId(), + signal: controller.signal, + }); + } finally { + process.off("SIGINT", interrupt); + } +} + +async function runAuthLogoutCommand(args: string[]): Promise { + const signal = AbortSignal.timeout(15_000); + const modelRuntime = await ModelRuntime.create({ allowModelNetwork: false, signal }); + return runAuthLogout(args, { + modelRuntime, + terminal: { write: (line) => console.log(line), error: (line) => console.error(line) }, + signal, + }); +} + async function runAuthCommand(args: string[]): Promise { if (isAuthCommandHelp(args)) { printAuthCommandHelp(); return true; } + if (args[0] === "auth" && args[1] === "login") { + // main() already applied --offline. + process.exitCode = await runAuthLoginCommand(args.slice(2).filter((arg) => arg !== "--offline")); + return true; + } + if (args[0] === "auth" && args[1] === "logout") { + process.exitCode = await runAuthLogoutCommand(args.slice(2).filter((arg) => arg !== "--offline")); + return true; + } let command: AuthCommand | undefined; try { diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 95e676803..0172c0042 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -13,30 +13,12 @@ import { } from "@earendil-works/pi-tui"; import { copyToClipboard } from "../../../utils/clipboard.ts"; import { openBrowser } from "../../../utils/open-browser.ts"; +import { displayUrl, webUrl } from "../../../utils/web-url.ts"; import { theme } from "../theme/theme.ts"; import { AuthUrlComponent } from "./auth-url.ts"; import { DynamicBorder } from "./dynamic-border.ts"; import { keyHint } from "./keybinding-hints.ts"; -/** - * The `http:` or `https:` URL `value` names, normalized, or undefined. The shared launcher also opens files and custom - * protocols, so login only opens web URLs. Normalizing percent-encodes control characters, which would otherwise end a - * terminal hyperlink early. - */ -function webUrl(value: string): string | undefined { - if (!URL.canParse(value)) return undefined; - const url = new URL(value); - return url.protocol === "https:" || url.protocol === "http:" ? url.href : undefined; -} - -/** - * `value` normalized as a web URL, else with control characters replaced: an untrusted string printed as is could end a - * hyperlink early or send its own escape sequences to the terminal. - */ -function displayUrl(value: string): string { - return webUrl(value) ?? replaceControlCharacters(value); -} - /** `text` as a terminal hyperlink to `url` when that is a web URL, else plain `text`. */ function webLink(text: string, url: string): string { const target = webUrl(url); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 999e1640d..c65c40c2a 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -96,7 +96,6 @@ import type { import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts"; import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts"; import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.ts"; -import type { McpHttpServerConfig } from "../../core/mcp-servers.ts"; import { createCompactionSummaryMessage, createCustomMessage } from "../../core/messages.ts"; import { defaultModelPerProvider, @@ -105,7 +104,9 @@ import { } from "../../core/model-resolver.ts"; import { CredentialSynchronizationError } from "../../core/model-runtime.ts"; import { DefaultPackageManager } from "../../core/package-manager.ts"; -import { RADIUS_MCP_URL, RADIUS_PROVIDER_ID } from "../../core/radius.ts"; +import { findMatchingProviders } from "../../core/provider-matching.ts"; +import { RADIUS_PROVIDER_ID } from "../../core/radius.ts"; +import { planRadiusMcpServer } from "../../core/radius-mcp.ts"; import type { ResourceDiagnostic } from "../../core/resource-loader.ts"; import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts"; import { @@ -122,7 +123,7 @@ import { withBuiltInRenderers } from "../../core/tools/renderers/index.ts"; import type { TruncationResult } from "../../core/tools/truncate.ts"; import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts"; import { getUsageCostBreakdown } from "../../core/usage-totals.ts"; -import { addMcpServerConfig, loadMcpConfig } from "../../extensions/mcp/config.ts"; +import { addMcpServerConfig } from "../../extensions/mcp/config.ts"; import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts"; import { copyToClipboard, readClipboardFilePaths, readClipboardText } from "../../utils/clipboard.ts"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts"; @@ -5980,26 +5981,13 @@ export class InteractiveMode { .sort((a, b) => a.name.localeCompare(b.name)); } - private findLoginProviderOptions(providerRef: string): AuthSelectorProvider[] { - const normalizedProviderRef = providerRef.trim().toLowerCase(); - if (!normalizedProviderRef) { - return []; - } - - return this.getLoginProviderOptions().filter( - (provider) => - provider.id.toLowerCase() === normalizedProviderRef || - provider.name.toLowerCase() === normalizedProviderRef, - ); - } - private async handleLoginCommand(providerRef?: string): Promise { if (!providerRef) { this.showLoginAuthTypeSelector(); return; } - const providerOptions = this.findLoginProviderOptions(providerRef); + const providerOptions = findMatchingProviders(this.getLoginProviderOptions(), providerRef); if (providerOptions.length === 1) { await this.startProviderLogin(providerOptions[0]!); return; @@ -6520,26 +6508,9 @@ export class InteractiveMode { * when missing. Nothing is asked when a global server already uses this login. */ private offerRadiusMcpServer(providerId: string, providerName: string): void { - const mcpPath = path.join(getAgentDir(), "mcp.json"); - const normalizeUrl = (url: string) => url.replace(/\/+$/u, ""); - const { servers } = loadMcpConfig({ - agentDir: getAgentDir(), - cwd: this.sessionManager.getCwd(), - projectTrusted: false, - }); - const existing = servers.find( - (server) => "url" in server.config && normalizeUrl(server.config.url) === normalizeUrl(RADIUS_MCP_URL), - ); - if (existing && "url" in existing.config && existing.config.auth?.provider === providerId) return; - - let name = existing?.name ?? "radius"; - if (!existing && servers.some((server) => server.name === name)) name = "radius-mcp"; - const config: McpHttpServerConfig = - existing && "url" in existing.config - ? { ...existing.config, auth: { provider: providerId } } - : { url: RADIUS_MCP_URL, auth: { provider: providerId } }; - // `auth` replaces the MCP OAuth sign-in. - delete config.oauth; + const plan = planRadiusMcpServer(providerId, getAgentDir(), this.sessionManager.getCwd()); + if (!plan) return; + const { mcpPath, name, config } = plan; this.showSelector((done) => { const selector = new ExtensionSelectorComponent( diff --git a/packages/coding-agent/src/utils/terminal-question.ts b/packages/coding-agent/src/utils/terminal-question.ts new file mode 100644 index 000000000..138c06d9b --- /dev/null +++ b/packages/coding-agent/src/utils/terminal-question.ts @@ -0,0 +1,29 @@ +import { createInterface } from "node:readline/promises"; + +/** Read one line from stdin, rejecting on abort, Ctrl+C, or EOF and always closing readline. */ +export async function questionTerminal( + message: string, + options: { + output: NodeJS.WritableStream; + signal: AbortSignal; + /** Called on Ctrl+C or EOF, not when the caller aborts just this prompt. */ + onInterrupt?: () => void; + }, +): Promise { + const readline = createInterface({ input: process.stdin, output: options.output }); + const interrupted = new AbortController(); + // EOF closes readline without settling its pending question; Ctrl+C normally only pauses it. + const onInterrupt = () => interrupted.abort(); + readline.on("SIGINT", onInterrupt); + readline.on("close", onInterrupt); + try { + return await readline.question(message, { signal: AbortSignal.any([options.signal, interrupted.signal]) }); + } catch (error) { + if (interrupted.signal.aborted) options.onInterrupt?.(); + throw error; + } finally { + readline.off("close", onInterrupt); + readline.off("SIGINT", onInterrupt); + readline.close(); + } +} diff --git a/packages/coding-agent/src/utils/web-url.ts b/packages/coding-agent/src/utils/web-url.ts new file mode 100644 index 000000000..f4e75e607 --- /dev/null +++ b/packages/coding-agent/src/utils/web-url.ts @@ -0,0 +1,20 @@ +import { replaceControlCharacters } from "@earendil-works/pi-tui"; + +/** + * The `http:` or `https:` URL `value` names, normalized, or undefined. The shared launcher also opens files and custom + * protocols, so login only opens web URLs. Normalizing percent-encodes control characters, which would otherwise end a + * terminal hyperlink early. + */ +export function webUrl(value: string): string | undefined { + if (!URL.canParse(value)) return undefined; + const url = new URL(value); + return url.protocol === "https:" || url.protocol === "http:" ? url.href : undefined; +} + +/** + * `value` normalized as a web URL, else with control characters replaced: an untrusted string printed as is could end a + * hyperlink early or send its own escape sequences to the terminal. + */ +export function displayUrl(value: string): string { + return webUrl(value) ?? replaceControlCharacters(value); +} diff --git a/packages/coding-agent/test/auth-login.test.ts b/packages/coding-agent/test/auth-login.test.ts new file mode 100644 index 000000000..77d73f324 --- /dev/null +++ b/packages/coding-agent/test/auth-login.test.ts @@ -0,0 +1,481 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { InMemoryModelsStore, type Model, type Provider } from "@earendil-works/pi-ai"; +import { describe, expect, test, vi } from "vitest"; +import { runAuthLogin } from "../src/cli/auth-login.ts"; +import type { LoginTerminal } from "../src/cli/auth-terminal.ts"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { CredentialSynchronizationError, ModelRuntime } from "../src/core/model-runtime.ts"; + +const PROVIDER_ID = "login-test"; + +interface ScriptedTerminal extends LoginTerminal { + lines: string[]; + errors: string[]; + questions: string[]; + opened: string[]; +} + +/** Answers prompts in order; `secret` and `question` share one answer queue. */ +function scriptedTerminal(answers: string[], interactive = true): ScriptedTerminal { + const lines: string[] = []; + const errors: string[] = []; + const questions: string[] = []; + const opened: string[] = []; + const answer = async (message: string, signal: AbortSignal) => { + questions.push(message); + if (signal.aborted) throw new Error("Login cancelled"); + const next = answers.shift(); + if (next === undefined) throw new Error(`Unexpected prompt: ${message}`); + return next; + }; + return { + interactive, + lines, + errors, + questions, + opened, + write: (line) => lines.push(line), + error: (line) => errors.push(line), + question: answer, + secret: answer, + openUrl: (url) => opened.push(url), + }; +} + +function testModel(providerId: string): Model<"openai-completions"> { + return { + id: "test-model", + name: "test-model", + api: "openai-completions", + provider: providerId, + baseUrl: "https://example.test/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000, + maxTokens: 100, + }; +} + +function testProvider(auth: Provider["auth"], providerId: string): Provider { + const unused = () => { + throw new Error("unused"); + }; + return { + id: providerId, + name: "Login Test", + auth, + getModels: () => [testModel(providerId)], + stream: unused, + streamSimple: unused, + }; +} + +const bothMethods: Provider["auth"] = { + oauth: { + name: "Login Test account", + login: async (interaction) => { + const method = await interaction.prompt({ + type: "select", + message: "Sign in to Login Test:", + options: [ + { id: "browser", label: "Sign in with browser" }, + { id: "device", label: "Sign in with device code" }, + ], + }); + if (method === "device") { + interaction.notify({ + type: "device_code", + userCode: "ABCD-1234", + verificationUri: "https://example.test/device", + }); + return { type: "oauth", access: "access-device", refresh: "refresh", expires: Date.now() + 3_600_000 }; + } + interaction.notify({ + type: "auth_url", + url: "https://example.test/authorize", + instructions: "Finish in the browser", + }); + const code = await interaction.prompt({ type: "manual_code", message: "Paste the code" }); + return { type: "oauth", access: `access-${code}`, refresh: "refresh", expires: Date.now() + 3_600_000 }; + }, + refresh: async (credential) => credential, + toAuth: async (credential) => ({ apiKey: credential.access }), + }, + apiKey: { + name: "Login Test API key", + login: async (interaction) => ({ + type: "api_key", + key: await interaction.prompt({ type: "secret", message: "Enter Login Test API key" }), + }), + resolve: async () => undefined, + }, +}; + +async function setup(auth: Provider["auth"], providerId = PROVIDER_ID) { + const credentials = AuthStorage.inMemory(); + const modelRuntime = await ModelRuntime.create({ + credentials, + modelsPath: null, + modelsStore: new InMemoryModelsStore(), + allowModelNetwork: false, + }); + modelRuntime.registerNativeProvider(testProvider(auth, providerId)); + return { credentials, modelRuntime }; +} + +function run( + args: string[], + modelRuntime: ModelRuntime, + terminal: LoginTerminal, + signal = new AbortController().signal, + agentDir = "/nonexistent-agent-dir", +) { + return runAuthLogin(args, { + modelRuntime, + terminal, + agentDir, + cwd: "/nonexistent-cwd", + getDeviceId: () => "device", + signal, + }); +} + +describe("pi auth login", () => { + const usage = "Usage: pi auth login [--api-key] [--no-browser]"; + + test("requires exactly one provider and known options", async () => { + const { modelRuntime } = await setup(bothMethods); + for (const args of [[], ["--api-key"], ["--provider", PROVIDER_ID], [PROVIDER_ID, "extra"]]) { + const terminal = scriptedTerminal([]); + expect(await run(args, modelRuntime, terminal)).toBe(1); + expect(terminal.errors).toEqual([usage]); + } + }); + + test("rejects --no-browser with --api-key", async () => { + const { modelRuntime } = await setup(bothMethods); + const terminal = scriptedTerminal([]); + expect(await run([PROVIDER_ID, "--api-key", "--no-browser"], modelRuntime, terminal)).toBe(1); + expect(terminal.errors).toEqual(["--no-browser only applies to account sign-in; omit it with --api-key."]); + }); + + test("rejects an unknown provider and lists the providers with a login flow", async () => { + const { modelRuntime } = await setup(bothMethods); + const terminal = scriptedTerminal([]); + expect(await run(["nope"], modelRuntime, terminal)).toBe(1); + expect(terminal.errors[0]).toBe('Unknown provider "nope".'); + expect(terminal.errors[1]).toContain(PROVIDER_ID); + }); + + test("requires an interactive terminal after checking the arguments", async () => { + const { modelRuntime } = await setup(bothMethods); + const terminal = scriptedTerminal([], false); + expect(await run([PROVIDER_ID], modelRuntime, terminal)).toBe(1); + expect(terminal.errors).toEqual([ + '"pi auth login [--api-key] [--no-browser]" requires an interactive terminal', + ]); + }); + + test("signs in with an account in the browser without asking for a method", async () => { + const { credentials, modelRuntime } = await setup(bothMethods); + const terminal = scriptedTerminal(["the-code"]); + // Matches the provider name as well as the id. + expect(await run(["login test"], modelRuntime, terminal)).toBe(0); + expect(terminal.questions).toEqual(["Paste the code: "]); + expect(terminal.lines).toContain("Open this URL to sign in:\n https://example.test/authorize"); + expect(terminal.lines).toContain("Finish in the browser"); + expect(terminal.opened).toEqual(["https://example.test/authorize"]); + expect(terminal.lines).toContain( + 'To use "Sign in with device code" instead, run "pi auth login login-test --no-browser"', + ); + expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "oauth", access: "access-the-code" }); + expect(terminal.lines.some((line) => line.startsWith("Logged in to Login Test."))).toBe(true); + }); + + test("lists several other sign-in methods", async () => { + const { modelRuntime } = await setup({ + oauth: { + ...bothMethods.oauth!, + login: async (interaction) => { + await interaction.prompt({ + type: "select", + message: "Sign in to Login Test:", + options: [ + { id: "browser", label: "Browser" }, + { id: "device", label: "Device code" }, + { id: "copy", label: "Copy code" }, + ], + }); + interaction.notify({ type: "auth_url", url: "https://example.test/authorize" }); + return { type: "oauth", access: "access", refresh: "refresh", expires: Date.now() + 3_600_000 }; + }, + }, + }); + const terminal = scriptedTerminal([]); + expect(await run([PROVIDER_ID], modelRuntime, terminal)).toBe(0); + expect(terminal.lines).toContain( + 'Other sign-in methods: "Device code", "Copy code". Run "pi auth login login-test --no-browser" to choose one.', + ); + }); + + test("offers no other sign-in methods when the provider has no method choice", async () => { + const { credentials, modelRuntime } = await setup({ + oauth: { + ...bothMethods.oauth!, + login: async (interaction) => { + interaction.notify({ type: "auth_url", url: "https://example.test/authorize" }); + const code = await interaction.prompt({ type: "manual_code", message: "Paste the code" }); + return { type: "oauth", access: `access-${code}`, refresh: "refresh", expires: Date.now() + 3_600_000 }; + }, + }, + }); + const terminal = scriptedTerminal(["the-code"]); + expect(await run([PROVIDER_ID], modelRuntime, terminal)).toBe(0); + expect(terminal.opened).toEqual(["https://example.test/authorize"]); + expect(terminal.lines.some((line) => line.includes("--no-browser"))).toBe(false); + expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "oauth", access: "access-the-code" }); + }); + + test("--no-browser takes the only other sign-in method and opens nothing", async () => { + const { credentials, modelRuntime } = await setup(bothMethods); + const terminal = scriptedTerminal([]); + expect(await run([PROVIDER_ID, "--no-browser"], modelRuntime, terminal)).toBe(0); + expect(terminal.questions).toEqual([]); + expect(terminal.lines[0]).toBe("Sign in to Login Test: Sign in with device code"); + expect(terminal.lines).toContain("Open https://example.test/device and enter code: ABCD-1234"); + expect(terminal.opened).toEqual([]); + expect(terminal.lines.some((line) => line.includes("--no-browser"))).toBe(false); + expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "oauth", access: "access-device" }); + }); + + test("--no-browser asks among several other sign-in methods and re-asks on an invalid selection", async () => { + const { credentials, modelRuntime } = await setup({ + oauth: { + ...bothMethods.oauth!, + login: async (interaction) => { + const method = await interaction.prompt({ + type: "select", + message: "Sign in to Login Test:", + options: [ + { id: "browser", label: "Browser" }, + { id: "device", label: "Device code" }, + { id: "copy", label: "Copy code" }, + ], + }); + return { + type: "oauth", + access: `access-${method}`, + refresh: "refresh", + expires: Date.now() + 3_600_000, + }; + }, + }, + }); + const terminal = scriptedTerminal(["7", "2"]); + expect(await run(["--no-browser", PROVIDER_ID], modelRuntime, terminal)).toBe(0); + expect(terminal.lines.slice(0, 3)).toEqual(["Sign in to Login Test:", " 1. Device code", " 2. Copy code"]); + expect(terminal.lines).toContain("Enter a number from 1 to 2."); + expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "oauth", access: "access-copy" }); + }); + + test("--no-browser keeps a provider's only sign-in method without opening the browser", async () => { + const { credentials, modelRuntime } = await setup({ + oauth: { + ...bothMethods.oauth!, + login: async (interaction) => { + const method = await interaction.prompt({ + type: "select", + message: "Sign in to Login Test:", + options: [{ id: "browser", label: "Browser" }], + }); + interaction.notify({ type: "auth_url", url: "https://example.test/authorize" }); + return { + type: "oauth", + access: `access-${method}`, + refresh: "refresh", + expires: Date.now() + 3_600_000, + }; + }, + }, + }); + const terminal = scriptedTerminal([]); + expect(await run([PROVIDER_ID, "--no-browser"], modelRuntime, terminal)).toBe(0); + expect(terminal.questions).toEqual([]); + expect(terminal.opened).toEqual([]); + expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "oauth", access: "access-browser" }); + }); + + test("asks for selections during API key setup", async () => { + const { credentials, modelRuntime } = await setup({ + apiKey: { + ...bothMethods.apiKey!, + login: async (interaction) => ({ + type: "api_key", + key: await interaction.prompt({ + type: "select", + message: "Choose credentials:", + options: [ + { id: "first", label: "First" }, + { id: "second", label: "Second", description: "Other credentials" }, + ], + }), + }), + }, + }); + const terminal = scriptedTerminal(["2"]); + expect(await run([PROVIDER_ID, "--api-key"], modelRuntime, terminal)).toBe(0); + expect(terminal.questions).toEqual(["Select 1-2: "]); + expect(terminal.lines).toContain(" 2. Second - Other credentials"); + expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "api_key", key: "second" }); + }); + + test("cancels a pending prompt without cancelling the login when the browser callback wins", async () => { + const promptController = new AbortController(); + const { credentials, modelRuntime } = await setup({ + oauth: { + ...bothMethods.oauth!, + login: async (interaction) => { + const pending = interaction.prompt({ + type: "manual_code", + message: "Paste the code", + placeholder: "redirect URL", + signal: promptController.signal, + }); + promptController.abort(); + await expect(pending).rejects.toBe(promptController.signal.reason); + expect(interaction.signal.aborted).toBe(false); + return { type: "oauth", access: "callback", refresh: "refresh", expires: Date.now() + 3_600_000 }; + }, + }, + }); + const terminal = scriptedTerminal([]); + terminal.question = vi.fn( + (_message: string, signal: AbortSignal) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }), + ); + expect(await run([PROVIDER_ID], modelRuntime, terminal)).toBe(0); + expect(terminal.question).toHaveBeenCalledWith("Paste the code (redirect URL): ", expect.any(AbortSignal)); + expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "oauth", access: "callback" }); + }); + + test("reports a committed login separately from a failed login", async () => { + const { modelRuntime } = await setup(bothMethods); + vi.spyOn(modelRuntime, "login").mockRejectedValue( + new CredentialSynchronizationError( + PROVIDER_ID, + "login", + { type: "api_key", key: "saved" }, + { cause: new Error("snapshot failed") }, + ), + ); + const terminal = scriptedTerminal([]); + expect(await run([PROVIDER_ID], modelRuntime, terminal)).toBe(1); + expect(terminal.errors[0]).toContain("Logged in to Login Test, but local model state could not be synchronized:"); + }); + + test("--api-key saves an API key", async () => { + const { credentials, modelRuntime } = await setup(bothMethods); + const terminal = scriptedTerminal(["sk-test"]); + expect(await run([PROVIDER_ID, "--api-key"], modelRuntime, terminal)).toBe(0); + expect(terminal.questions).toEqual(["Enter Login Test API key: "]); + expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "api_key", key: "sk-test" }); + expect(terminal.lines.some((line) => line.startsWith("Saved API key for Login Test."))).toBe(true); + }); + + test("uses the API key login of providers without account sign-in", async () => { + const { modelRuntime } = await setup({ apiKey: bothMethods.apiKey }); + const terminal = scriptedTerminal(["sk-test"]); + expect(await run([PROVIDER_ID], modelRuntime, terminal)).toBe(0); + expect(terminal.questions).toEqual(["Enter Login Test API key: "]); + }); + + test("--api-key fails for providers without API key login", async () => { + const { modelRuntime } = await setup({ oauth: bothMethods.oauth }); + const terminal = scriptedTerminal([]); + expect(await run([PROVIDER_ID, "--api-key"], modelRuntime, terminal)).toBe(1); + expect(terminal.errors).toEqual(["Login Test has no API key login. Omit --api-key to sign in."]); + }); + + test("reports providers configured outside pi", async () => { + const { modelRuntime } = await setup({ apiKey: { name: "AWS profile", resolve: async () => undefined } }); + const terminal = scriptedTerminal([]); + expect(await run([PROVIDER_ID], modelRuntime, terminal)).toBe(1); + expect(terminal.errors).toEqual(["Login Test is configured outside pi (AWS profile)."]); + }); + + test("Ctrl+C during the catalog refresh skips it and keeps the login", async () => { + const { credentials, modelRuntime } = await setup({ apiKey: bothMethods.apiKey }); + const controller = new AbortController(); + // A refresh that only ends when aborted, interrupted as soon as it starts. + vi.spyOn(modelRuntime, "refresh").mockImplementation( + ({ signal } = {}) => + new Promise((resolve) => { + signal?.addEventListener("abort", () => resolve({ aborted: true, errors: new Map() })); + controller.abort(); + }), + ); + const terminal = scriptedTerminal(["sk-test"]); + expect(await run([PROVIDER_ID], modelRuntime, terminal, controller.signal)).toBe(0); + expect(terminal.lines).toContain("Skipped the Login Test model catalog refresh; pi refreshes it on next start."); + expect(await credentials.read(PROVIDER_ID)).toMatchObject({ type: "api_key", key: "sk-test" }); + }); + + test("reports cancellation", async () => { + const { credentials, modelRuntime } = await setup({ apiKey: bothMethods.apiKey }); + const controller = new AbortController(); + controller.abort(); + const terminal = scriptedTerminal(["sk-test"]); + expect(await run([PROVIDER_ID], modelRuntime, terminal, controller.signal)).toBe(1); + expect(terminal.errors).toEqual(["Login cancelled."]); + expect(await credentials.read(PROVIDER_ID)).toBeUndefined(); + }); + + test.each(["radius-mcp", "radius_mcp"])("keeps unrelated MCP servers when %s is occupied", async (fallback) => { + const agentDir = mkdtempSync(join(tmpdir(), "pi-auth-login-")); + try { + const { modelRuntime } = await setup({ apiKey: bothMethods.apiKey }, "radius"); + const mcpPath = join(agentDir, "mcp.json"); + const servers = { + radius: { command: "original-one" }, + [fallback]: { command: "original-two" }, + "radius-mcp-2": { command: "original-three" }, + }; + writeFileSync(mcpPath, JSON.stringify({ mcpServers: servers })); + const terminal = scriptedTerminal(["sk-test"]); + expect(await run(["radius"], modelRuntime, terminal, undefined, agentDir)).toBe(0); + expect(JSON.parse(readFileSync(mcpPath, "utf8")).mcpServers).toEqual({ + ...servers, + "radius-mcp-3": { url: "https://radius.pi.dev/mcp", auth: { provider: "radius" } }, + }); + expect(terminal.lines).toContain(`Added MCP server "radius-mcp-3" in ${mcpPath} to use the Login Test login.`); + } finally { + rmSync(agentDir, { recursive: true, force: true }); + } + }); + + test("configures the Radius MCP server without asking, once", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "pi-auth-login-")); + try { + const { modelRuntime } = await setup({ apiKey: bothMethods.apiKey }, "radius"); + const mcpPath = join(agentDir, "mcp.json"); + const first = scriptedTerminal(["sk-test"]); + expect(await run(["radius"], modelRuntime, first, undefined, agentDir)).toBe(0); + expect(first.questions).toEqual(["Enter Login Test API key: "]); + expect(first.lines).toContain(`Added MCP server "radius" in ${mcpPath} to use the Login Test login.`); + expect(JSON.parse(readFileSync(mcpPath, "utf8")).mcpServers.radius).toMatchObject({ + auth: { provider: "radius" }, + }); + + const second = scriptedTerminal(["sk-test"]); + expect(await run(["radius"], modelRuntime, second, undefined, agentDir)).toBe(0); + expect(second.lines.some((line) => line.includes("MCP server"))).toBe(false); + } finally { + rmSync(agentDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/coding-agent/test/auth-logout.test.ts b/packages/coding-agent/test/auth-logout.test.ts new file mode 100644 index 000000000..5f050e07e --- /dev/null +++ b/packages/coding-agent/test/auth-logout.test.ts @@ -0,0 +1,99 @@ +import { type Credential, InMemoryModelsStore } from "@earendil-works/pi-ai"; +import { describe, expect, test, vi } from "vitest"; +import { runAuthLogout } from "../src/cli/auth-logout.ts"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { CredentialSynchronizationError, ModelRuntime } from "../src/core/model-runtime.ts"; + +async function setup(data: Record) { + const credentials = AuthStorage.inMemory(data); + const modelRuntime = await ModelRuntime.create({ + credentials, + modelsPath: null, + modelsStore: new InMemoryModelsStore(), + allowModelNetwork: false, + }); + const lines: string[] = []; + const errors: string[] = []; + const run = (args: string[]) => + runAuthLogout(args, { + modelRuntime, + terminal: { write: (line) => lines.push(line), error: (line) => errors.push(line) }, + signal: new AbortController().signal, + }); + return { credentials, modelRuntime, lines, errors, run }; +} + +const oauthCredential = { + type: "oauth", + access: "access", + refresh: "refresh", + expires: Date.now() + 3_600_000, +} as const; + +describe("pi auth logout", () => { + test("requires exactly one provider", async () => { + const { errors, run } = await setup({}); + for (const args of [[], ["--api-key"], ["radius", "extra"]]) { + errors.length = 0; + expect(await run(args)).toBe(1); + expect(errors).toEqual(["Usage: pi auth logout "]); + } + }); + + test("removes a stored account login", async () => { + const { credentials, lines, run } = await setup({ radius: oauthCredential }); + expect(await run(["radius"])).toBe(0); + expect(lines).toEqual(["Logged out of Radius."]); + expect(await credentials.read("radius")).toBeUndefined(); + }); + + test("removes a stored API key by provider name and keeps other credentials", async () => { + const { credentials, lines, run } = await setup({ + openai: { type: "api_key", key: "sk-test" }, + radius: oauthCredential, + }); + expect(await run(["OpenAI"])).toBe(0); + expect(lines).toEqual([ + "Removed stored API key for OpenAI. Environment variables and models.json config are unchanged.", + ]); + expect(await credentials.read("openai")).toBeUndefined(); + expect(await credentials.read("radius")).toMatchObject({ type: "oauth" }); + }); + + test("succeeds when nothing is stored, so it can be repeated", async () => { + const { lines, run } = await setup({}); + expect(await run(["radius"])).toBe(0); + expect(lines).toEqual([ + "No stored credentials for Radius. Environment variables and models.json config are unchanged.", + ]); + }); + + test("removes credentials of providers that are not loaded, matched by id", async () => { + const { credentials, lines, run } = await setup({ "extension-provider": oauthCredential }); + expect(await run(["extension-provider"])).toBe(0); + expect(lines).toEqual(["Logged out of extension-provider."]); + expect(await credentials.read("extension-provider")).toBeUndefined(); + }); + + test("reports credential removal failures", async () => { + const { modelRuntime, errors, run } = await setup({ radius: oauthCredential }); + vi.spyOn(modelRuntime, "logout").mockRejectedValue(new Error("storage failed")); + expect(await run(["radius"])).toBe(1); + expect(errors).toEqual(["Logout failed: storage failed"]); + }); + + test("reports committed credential removal separately from a failed logout", async () => { + const { modelRuntime, errors, run } = await setup({ radius: oauthCredential }); + vi.spyOn(modelRuntime, "logout").mockRejectedValue( + new CredentialSynchronizationError("radius", "logout", undefined, { cause: new Error("snapshot failed") }), + ); + expect(await run(["radius"])).toBe(1); + expect(errors[0]).toContain("Credentials removed for Radius, but local model state could not be synchronized:"); + }); + + test("rejects an unknown provider and lists the stored credentials", async () => { + const { errors, run } = await setup({ radius: oauthCredential }); + expect(await run(["nope"])).toBe(1); + expect(errors).toEqual(['Unknown provider "nope".', "Stored credentials: radius"]); + }); +}); diff --git a/packages/coding-agent/test/auth-terminal.test.ts b/packages/coding-agent/test/auth-terminal.test.ts new file mode 100644 index 000000000..b433234d7 --- /dev/null +++ b/packages/coding-agent/test/auth-terminal.test.ts @@ -0,0 +1,63 @@ +import { PassThrough } from "node:stream"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createProcessLoginTerminal } from "../src/cli/auth-terminal.ts"; + +const inputs: PassThrough[] = []; + +afterEach(() => { + vi.restoreAllMocks(); + for (const input of inputs) input.destroy(); + inputs.length = 0; +}); + +function setup() { + const input = new PassThrough(); + inputs.push(input); + // Readline only needs the readable stream; the test does not exercise TTY detection or raw mode. + vi.spyOn(process, "stdin", "get").mockReturnValue(input as unknown as typeof process.stdin); + vi.spyOn(process.stdout, "write").mockReturnValue(true); + const controller = new AbortController(); + const interrupt = vi.fn(() => controller.abort()); + const terminal = createProcessLoginTerminal(interrupt); + return { input, controller, interrupt, terminal }; +} + +describe("auth terminal questions", () => { + test("rejects and cancels the login when stdin closes before an answer", async () => { + const { input, controller, interrupt, terminal } = setup(); + const answer = terminal.question("Enter domain: ", controller.signal); + const rejected = expect(answer).rejects.toMatchObject({ name: "AbortError" }); + input.end(); + await rejected; + expect(interrupt).toHaveBeenCalledOnce(); + expect(controller.signal.aborted).toBe(true); + }); + + test("normal cleanup does not cancel the login after an answer", async () => { + const { input, controller, interrupt, terminal } = setup(); + const answer = terminal.question("Enter domain: ", controller.signal); + input.write("example.com\n"); + await expect(answer).resolves.toBe("example.com"); + expect(interrupt).not.toHaveBeenCalled(); + expect(controller.signal.aborted).toBe(false); + }); + + test("keeps an answer delivered immediately before EOF", async () => { + const { input, controller, interrupt, terminal } = setup(); + const answer = terminal.question("Enter domain: ", controller.signal); + input.end("example.com\n"); + await expect(answer).resolves.toBe("example.com"); + expect(interrupt).not.toHaveBeenCalled(); + }); + + test("prompt cancellation does not cancel the whole login", async () => { + const { controller, interrupt, terminal } = setup(); + const prompt = new AbortController(); + const answer = terminal.question("Paste code: ", AbortSignal.any([controller.signal, prompt.signal])); + const rejected = expect(answer).rejects.toMatchObject({ name: "AbortError" }); + prompt.abort(); + await rejected; + expect(interrupt).not.toHaveBeenCalled(); + expect(controller.signal.aborted).toBe(false); + }); +}); diff --git a/packages/coding-agent/test/mcp-command.test.ts b/packages/coding-agent/test/mcp-command.test.ts index 5f09b37eb..c3d55471f 100644 --- a/packages/coding-agent/test/mcp-command.test.ts +++ b/packages/coding-agent/test/mcp-command.test.ts @@ -110,7 +110,7 @@ describe("pi mcp", () => { for (const command of ["login", "logout"]) { expect(await run([command, "radius"], radius)).toMatchObject({ exitCode: 1, - output: `MCP server "radius" uses the credentials of provider "radius". Run /${command} radius in pi.`, + output: `MCP server "radius" uses the credentials of provider "radius". Run "pi auth ${command} radius".`, }); } } finally { diff --git a/packages/coding-agent/test/provider-matching.test.ts b/packages/coding-agent/test/provider-matching.test.ts new file mode 100644 index 000000000..5ab3b1b15 --- /dev/null +++ b/packages/coding-agent/test/provider-matching.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "vitest"; +import { findMatchingProviders } from "../src/core/provider-matching.ts"; + +const providers = [ + { id: "first", name: "Shared Name", authType: "oauth" }, + { id: "first", name: "Shared Name", authType: "api_key" }, + { id: "other", name: "FIRST", authType: "api_key" }, + { id: "second", name: "Second Provider", authType: "oauth" }, +]; + +describe("provider reference matching", () => { + test("matches IDs and display names case-insensitively with surrounding whitespace", () => { + expect(findMatchingProviders(providers, " sEcOnD ")).toEqual([providers[3]]); + expect(findMatchingProviders(providers, "\tSECOND PROVIDER\n")).toEqual([providers[3]]); + }); + + test("keeps all matching providers and auth methods in input order", () => { + const matched = findMatchingProviders(providers, "first"); + expect(matched).toEqual(providers.slice(0, 3)); + expect(matched[0]).toBe(providers[0]); + expect(matched[1]?.authType).toBe("api_key"); + expect(findMatchingProviders(providers, "shared name")).toEqual(providers.slice(0, 2)); + }); + + test("does not fuzzy-match unknown or partial references", () => { + expect(findMatchingProviders(providers, "unknown")).toEqual([]); + expect(findMatchingProviders(providers, "sec")).toEqual([]); + expect(findMatchingProviders([], "first")).toEqual([]); + }); + + test("never matches an empty reference, even against empty display names", () => { + expect(findMatchingProviders([{ id: "unnamed", name: "" }], "")).toEqual([]); + expect(findMatchingProviders(providers, " \t ")).toEqual([]); + }); +}); diff --git a/packages/coding-agent/test/terminal-question.test.ts b/packages/coding-agent/test/terminal-question.test.ts new file mode 100644 index 000000000..67dc26e5f --- /dev/null +++ b/packages/coding-agent/test/terminal-question.test.ts @@ -0,0 +1,79 @@ +import { PassThrough } from "node:stream"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { questionTerminal } from "../src/utils/terminal-question.ts"; + +const streams: PassThrough[] = []; + +afterEach(() => { + vi.restoreAllMocks(); + for (const stream of streams) stream.destroy(); + streams.length = 0; +}); + +function setup() { + const input = new PassThrough(); + const output = new PassThrough(); + streams.push(input, output); + // Readline only needs the stream; real terminal capabilities are added by the Ctrl+C test below. + vi.spyOn(process, "stdin", "get").mockReturnValue(input as unknown as typeof process.stdin); + const controller = new AbortController(); + const onInterrupt = vi.fn(); + return { input, output, controller, onInterrupt }; +} + +describe("terminal questions", () => { + test("writes to the selected output and cleans up after an answer", async () => { + const { input, output, controller, onInterrupt } = setup(); + const answer = questionTerminal("Paste URL: ", { output, signal: controller.signal, onInterrupt }); + expect(output.read()?.toString()).toBe("Paste URL: "); + input.write("https://example.test/callback\n"); + await expect(answer).resolves.toBe("https://example.test/callback"); + expect(onInterrupt).not.toHaveBeenCalled(); + expect(input.listenerCount("data")).toBe(0); + expect(input.isPaused()).toBe(true); + }); + + test("rejects on EOF without requiring an interrupt callback", async () => { + const { input, output, controller } = setup(); + const answer = questionTerminal("Paste URL: ", { output, signal: controller.signal }); + const rejected = expect(answer).rejects.toMatchObject({ name: "AbortError" }); + input.end(); + await rejected; + expect(input.listenerCount("data")).toBe(0); + }); + + test("Ctrl+C rejects the question and reports an interrupt", async () => { + const { input, output, controller, onInterrupt } = setup(); + const setRawMode = vi.fn(); + Object.assign(input, { setRawMode }); + Object.assign(output, { isTTY: true }); + const answer = questionTerminal("Paste URL: ", { output, signal: controller.signal, onInterrupt }); + const rejected = expect(answer).rejects.toMatchObject({ name: "AbortError" }); + input.write("\u0003"); + await rejected; + expect(onInterrupt).toHaveBeenCalledOnce(); + expect(input.listenerCount("keypress")).toBe(0); + expect(output.listenerCount("resize")).toBe(0); + expect(setRawMode).toHaveBeenLastCalledWith(false); + }); + + test("caller cancellation does not report an interrupt", async () => { + const { input, output, controller, onInterrupt } = setup(); + const answer = questionTerminal("Paste URL: ", { output, signal: controller.signal, onInterrupt }); + const rejected = expect(answer).rejects.toMatchObject({ name: "AbortError" }); + controller.abort(); + await rejected; + expect(onInterrupt).not.toHaveBeenCalled(); + expect(input.listenerCount("data")).toBe(0); + }); + + test("rejects an already-aborted prompt and cleans up", async () => { + const { input, output, controller, onInterrupt } = setup(); + controller.abort(); + await expect( + questionTerminal("Paste URL: ", { output, signal: controller.signal, onInterrupt }), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(onInterrupt).not.toHaveBeenCalled(); + expect(input.listenerCount("data")).toBe(0); + }); +});