From f0c217a1bc6c21c45502bca607135cf71d3fa084 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 23 Oct 2026 01:20:40 -0400 Subject: [PATCH] fix(cli): resolve prompts by policy when stdin or stdout is not a TTY `hack env unset KEY` with a piped stdin and no HACK_NO_INTERACTIVE never returned and burned a full CPU: `confirmSafe` only consulted the explicit opt-out, so a TTY-less run entered the clack confirm, whose readline loop cannot settle on a non-TTY stdin (HACK-1385). - `confirmSafe` now gates on `canPrompt()` (TTY on both streams plus the opt-outs) and resolves every TTY-less run by its declared policy within milliseconds; `doctorConfirm` notes skipped destructive steps on the same condition. - Raw clack prompts without a guard are routed through the convention: `secrets` (name, value, delete), the `session` picker and attached-workspace choice, `mcp` target selection, `update` (stdin as well as stdout), and the `agent` client picker. - Regression tests: unit cases for each policy without a TTY, and an e2e spawn of `env unset` and `secrets delete` with piped stdin under a 5 s kill bound asserting exit, bounded CPU and wall time, and no value echo. - docs/cli.md documents the non-TTY behavior. Refs HACK-1385 Co-Authored-By: Claude Opus 5.5 --- docs/cli.md | 8 ++ src/commands/agent.ts | 5 +- src/commands/doctor.ts | 7 +- src/commands/mcp.ts | 8 ++ src/commands/secrets.ts | 28 ++++- src/commands/session.ts | 14 +++ src/commands/update.ts | 5 +- src/lib/interactivity.ts | 17 ++- tests/env-unset-no-tty-cli.test.ts | 193 +++++++++++++++++++++++++++++ tests/global-command.macos.test.ts | 44 +++++++ tests/interactivity.test.ts | 106 ++++++++++++++++ 11 files changed, 417 insertions(+), 18 deletions(-) create mode 100644 tests/env-unset-no-tty-cli.test.ts diff --git a/docs/cli.md b/docs/cli.md index 0eeb6f78f..5f50bc25d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -150,6 +150,14 @@ Historical remote guides are excluded from the supported documentation. trace; set `HACK_DEBUG=1` to add the stack. Unexpected exceptions always print their stack. - `--no-interactive` (or `HACK_NO_INTERACTIVE=1`) is a global flag: commands never prompt — they apply documented defaults or fail fast with `E_INTERACTIVE_REQUIRED`. +- A run whose stdin or stdout is not a TTY (piped input, an agent harness, CI) is treated the same + way, with or without the flag: the command resolves every prompt by its documented + non-interactive policy within milliseconds and never waits on a prompt. Confirmations that have + a safe default apply it (`hack env unset KEY` removes the key; `hack doctor --fix` applies safe + remediations and skips destructive ones with a note); prompts that need a real answer fail + closed with `E_INTERACTIVE_REQUIRED`, naming the flag or argument that makes the invocation + scriptable (for example `hack update --yes`, `hack mcp --all`, `hack session attach `). + Set `HACK_NO_INTERACTIVE=1` explicitly in scripts anyway so the intent is visible. - `NO_COLOR` (or `HACK_NO_COLOR`) disables colored/decorated output. Generated agent docs, Cursor rules, Codex skills, and the shared `~/.ai/skills/hack-cli` skill carry diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 780a6a938..23badd970 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -17,6 +17,7 @@ import { renderAgentIntegrationFreshnessNotice, } from "../cli/integration-sync.ts"; import { optPath } from "../cli/options.ts"; +import { canPrompt } from "../lib/interactivity.ts"; import { openUrl } from "../lib/os.ts"; import { findProjectContext, findRepoRootForInit } from "../lib/project.ts"; import { findExecutableInPath, run } from "../lib/shell.ts"; @@ -215,7 +216,9 @@ async function resolveInitClient(opts: { ); } - if (!(process.stdin.isTTY && process.stdout.isTTY)) { + // No TTY, HACK_NO_INTERACTIVE, or --no-interactive: print the prompt + // instead of opening a picker that cannot settle on a piped stdin. + if (!canPrompt()) { return null; } diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 035742972..b580dd007 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -118,10 +118,7 @@ import { resolveInstalledGrafanaHost } from "../lib/global-service-host.ts"; import { getString, isRecord } from "../lib/guards.ts"; import { resolveHackInvocation } from "../lib/hack-cli.ts"; import { inspectHackEnvOverlayWarnings } from "../lib/hack-env.ts"; -import { - confirmSafe, - isExplicitlyNonInteractive, -} from "../lib/interactivity.ts"; +import { canPrompt, confirmSafe } from "../lib/interactivity.ts"; import { findHackLocalCaCertPath } from "../lib/local-ca.ts"; import { noteBrowserTargetsForLocalNetworkHint } from "../lib/local-network-hint.ts"; import { @@ -4344,7 +4341,7 @@ async function doctorConfirm(opts: { initialValue: opts.initialValue, nonInteractive: opts.destructive ? "decline" : "accept-default", }); - if (!ok && opts.destructive && isExplicitlyNonInteractive()) { + if (!ok && opts.destructive && !canPrompt()) { noteSkippedDoctorFixStep({ what: firstLine(opts.message) }); } return ok; diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index b07e564aa..31543134e 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -8,6 +8,7 @@ import { withHandler, } from "../cli/command.ts"; import { optPath } from "../cli/options.ts"; +import { canPrompt, requireInteractive } from "../lib/interactivity.ts"; import type { AgentDocTarget } from "../mcp/agent-docs.ts"; import { upsertAgentDocs } from "../mcp/agent-docs.ts"; import { @@ -401,6 +402,13 @@ async function resolveTargets(opts: { return dedupeTargets({ targets }); } + if (!canPrompt()) { + requireInteractive({ + what: "hack mcp needs at least one target", + hint: "Pass --all, or any of --cursor, --claude, --codex.", + }); + } + const selected = await autocompleteMultiselect({ message: "Select MCP clients to configure:", required: true, diff --git a/src/commands/secrets.ts b/src/commands/secrets.ts index f9d6e2dc1..e84bdebcd 100644 --- a/src/commands/secrets.ts +++ b/src/commands/secrets.ts @@ -1,8 +1,13 @@ -import { confirm, isCancel, password, text } from "@clack/prompts"; +import { isCancel, password, text } from "@clack/prompts"; import { secrets } from "bun"; import type { CliContext, CommandArgs } from "../cli/command.ts"; import { defineCommand, defineOption, withHandler } from "../cli/command.ts"; +import { + canPrompt, + confirmSafe, + requireInteractive, +} from "../lib/interactivity.ts"; import { logger } from "../ui/logger.ts"; const DEFAULT_SECRETS_SERVICE = "hack-cli" as const; @@ -75,6 +80,13 @@ async function resolveName(positionals: readonly string[]): Promise { return fromPos; } + if (!canPrompt()) { + requireInteractive({ + what: "a secret name is required", + hint: "Pass the name as an argument (e.g. `hack secrets get `).", + }); + } + const name = await text({ message: "Secret name:", validate: (value) => { @@ -102,6 +114,13 @@ async function handleSecretsSet({ [args.positionals.name].filter((v) => typeof v === "string") ); + if (!canPrompt()) { + requireInteractive({ + what: `a value for secret "${name}" is required`, + hint: "Run `hack secrets set ` in a terminal; the value is read from a hidden prompt and never from an argument.", + }); + } + const value = await password({ message: `Value for "${name}" (${service}):`, validate: (v) => (!v || v.length === 0 ? "Required" : undefined), @@ -150,13 +169,12 @@ async function handleSecretsDelete({ [args.positionals.name].filter((v) => typeof v === "string") ); - const ok = await confirm({ + const ok = await confirmSafe({ message: `Delete secret "${name}" (${service})?`, initialValue: false, + nonInteractive: "fail", + hint: "Run `hack secrets delete ` in a terminal to confirm the deletion.", }); - if (isCancel(ok)) { - return 1; - } if (!ok) { return 0; } diff --git a/src/commands/session.ts b/src/commands/session.ts index 4a743c4c5..d7f5a403a 100644 --- a/src/commands/session.ts +++ b/src/commands/session.ts @@ -18,6 +18,7 @@ import { resolveHackEnv, selectHackEnvValues, } from "../lib/hack-env.ts"; +import { canPrompt, requireInteractive } from "../lib/interactivity.ts"; import { appendHackHostTrustEnvironment } from "../lib/local-ca.ts"; import { defaultProjectSlugFromPath, @@ -347,6 +348,12 @@ async function promptAttachedWorkspaceAction(opts: { readonly attachedWorkspaceName: string; readonly nextWorkspaceName: string; }): Promise<"attach" | "new" | null> { + if (!canPrompt()) { + requireInteractive({ + what: `workspace '${opts.attachedWorkspaceName}' is attached elsewhere`, + hint: "Pass --new (optionally with --name ) to create an isolated workspace instead of attaching, or run this command in a terminal.", + }); + } const action = await p.select({ message: `Workspace '${opts.attachedWorkspaceName}' is attached elsewhere`, options: [ @@ -816,6 +823,13 @@ async function handleSessionPicker(): Promise { return 1; } + if (!canPrompt()) { + requireInteractive({ + what: "the workspace picker needs a selection", + hint: "Run `hack session list` to see workspaces and `hack session attach ` to attach to one.", + }); + } + const selection = await p.select({ message: "Select workspace or project", options, diff --git a/src/commands/update.ts b/src/commands/update.ts index 6b5ce8669..86d640883 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -7,6 +7,7 @@ import { defineCommand, defineOption, withHandler } from "../cli/command.ts"; import { optJson } from "../cli/options.ts"; import { resolveGlobalHackDir } from "../lib/config-paths.ts"; import { ensureDir } from "../lib/fs.ts"; +import { canPrompt } from "../lib/interactivity.ts"; import { ensureBundledMutagenInstalled, getMutagenPath, @@ -185,7 +186,9 @@ const handleUpdate: CommandHandlerFor = async ({ const wantsInstall = await confirmInstall({ yes: args.options.yes === true, - isTty: process.stdout.isTTY, + // canPrompt() covers stdin and stdout TTYs plus the explicit opt-outs; a + // piped stdin alone would otherwise spin in the clack prompt (HACK-1385). + isTty: canPrompt(), prompt: `${announce}. Install now?`, }); if (!wantsInstall.ok) { diff --git a/src/lib/interactivity.ts b/src/lib/interactivity.ts index f8a0f00d7..44449c206 100644 --- a/src/lib/interactivity.ts +++ b/src/lib/interactivity.ts @@ -91,16 +91,21 @@ const NON_INTERACTIVE_ANSWERS: Readonly< /** * Confirm prompt that honors the no-interactive convention. * - * When the run is explicitly non-interactive (`--no-interactive` or - * `HACK_NO_INTERACTIVE`), the declared policy resolves the answer without - * prompting: + * Whenever prompting is disallowed ({@link canPrompt} is false: stdin or + * stdout is not a TTY, `HACK_NO_INTERACTIVE`, or `--no-interactive`), the + * declared policy resolves the answer without prompting: * - `accept-default`: answer with `initialValue` (the documented default), * - `decline`: answer `false` (safe skip for destructive/sudo actions), * - `fail`: throw `E_INTERACTIVE_REQUIRED` with the provided hint. * * Otherwise a normal clack confirm runs (cancel throws, matching existing - * prompt-site behavior). TTY-less runs without the explicit opt-out keep the - * legacy clack behavior so wrapper environments stay unchanged. + * prompt-site behavior). + * + * Invariant: the clack prompt is never entered without a TTY on both + * streams. Its readline loop cannot settle on a piped stdin and spins at + * full CPU forever (HACK-1385), so a TTY-less run must resolve through the + * policy within milliseconds. Never call `@clack/prompts` directly from a + * command without a `canPrompt()` guard in front of it. */ export async function confirmSafe(opts: { readonly message: string; @@ -108,7 +113,7 @@ export async function confirmSafe(opts: { readonly nonInteractive: NonInteractivePolicy; readonly hint?: string; }): Promise { - if (!isExplicitlyNonInteractive()) { + if (canPrompt()) { const ok = await confirm({ message: opts.message, initialValue: opts.initialValue, diff --git a/tests/env-unset-no-tty-cli.test.ts b/tests/env-unset-no-tty-cli.test.ts new file mode 100644 index 000000000..6967c8c4d --- /dev/null +++ b/tests/env-unset-no-tty-cli.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +/** + * HACK-1385: `hack env unset KEY` with a piped (non-TTY) stdin and without + * `HACK_NO_INTERACTIVE` used to enter the clack confirm prompt, which cannot + * settle on a non-TTY stdin and spins at full CPU forever. + * + * Each case runs the real CLI in a fresh subprocess with piped stdin, no + * `HACK_NO_INTERACTIVE`, and a hard 5 s kill. The run must settle through + * the documented non-interactive policy: `env unset` applies its default + * (the key is removed), and a `fail`-policy prompt refuses with + * `E_INTERACTIVE_REQUIRED`. Wall time and CPU time are both bounded, so a + * regressed prompt path fails by timeout rather than hanging the suite. + * + * Isolation: private HOME/HACK_HOME/DOCKER_CONFIG, a temp project with its env + * layer file written first, the project-key YAML store, and the keychain + * fallback disabled. No keychain, Docker socket, or launchd is reached. + */ + +const REPO_ROOT = resolve(import.meta.dir, ".."); +const ENTRYPOINT = resolve(REPO_ROOT, "index.ts"); +/** Hard bound for the whole subprocess; the old prompt loop never exits. */ +const KILL_AFTER_MS = 5000; +/** Bun startup plus the command measured ~0.3 s CPU; the spin burns ~1 s/s. */ +const MAX_CPU_MS = 3000; +/** Never printed by the command: proves the value stays out of both streams. */ +const VALUE_CANARY = "unset-canary-value-7f3a9c"; + +type RunResult = { + readonly exitCode: number; + readonly signal: string | null; + readonly stdout: string; + readonly stderr: string; + readonly wallMs: number; + readonly cpuMs: number | null; +}; + +let root: string | null = null; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "hack-env-unset-no-tty-")); + await mkdir(join(root, "home"), { recursive: true }); +}); + +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }); + root = null; + } +}); + +function requireRoot(): string { + if (!root) { + throw new Error("Missing temp root"); + } + return root; +} + +/** + * Spawns the CLI with a piped stdin that carries one newline and then closes, + * the shape an agent harness or `echo | hack ...` produces. The environment + * deliberately omits `HACK_NO_INTERACTIVE` and `--no-interactive`. + */ +async function runHackPipedStdin(input: { + readonly args: readonly string[]; + readonly cwd: string; +}): Promise { + const home = join(requireRoot(), "home"); + const started = performance.now(); + const proc = Bun.spawn([process.execPath, ENTRYPOINT, ...input.args], { + cwd: input.cwd, + env: { + PATH: process.env.PATH ?? "/usr/bin:/bin", + HOME: home, + TMPDIR: home, + HACK_HOME: join(home, ".hack"), + DOCKER_CONFIG: join(home, "docker"), + HACK_GLOBAL_CONFIG_PATH: join(home, "hack-config.json"), + HACK_LOGGER: "console", + HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK: "1", + HACK_SECRETS_FILE_KEY: "env-unset-no-tty-test-key", + NO_COLOR: "1", + }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + timeout: KILL_AFTER_MS, + killSignal: "SIGKILL", + }); + proc.stdin.write("\n"); + await proc.stdin.end(); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + const wallMs = performance.now() - started; + const usage = proc.resourceUsage(); + const cpuMs = usage + ? Number(usage.cpuTime.user + usage.cpuTime.system) / 1000 + : null; + return { + exitCode, + signal: proc.signalCode, + stdout, + stderr, + wallMs, + cpuMs, + }; +} + +function expectSettledWithinBounds(result: RunResult): void { + // A regressed prompt path is killed by the spawn timeout: non-zero exit via + // SIGKILL, wall time at the bound, and CPU time close to wall time. + expect(result.signal).toBeNull(); + expect(result.wallMs).toBeLessThan(KILL_AFTER_MS); + if (result.cpuMs !== null) { + expect(result.cpuMs).toBeLessThan(MAX_CPU_MS); + } + // The clack confirm never rendered its "● Yes / ○ No" toggle. + expect(result.stdout).not.toContain("Yes / "); +} + +async function createModernProject(): Promise<{ + readonly projectRoot: string; + readonly layerFile: string; +}> { + const projectRoot = join(requireRoot(), "modern"); + const projectDir = join(projectRoot, ".hack"); + await mkdir(projectDir, { recursive: true }); + await writeFile( + join(projectDir, "docker-compose.yml"), + "services:\n api:\n image: alpine:3.20\n" + ); + await writeFile( + join(projectDir, "hack.config.json"), + `${JSON.stringify({ name: "unset-no-tty-modern", dev_host: "unset-no-tty-modern.hack" }, null, 2)}\n` + ); + // The env layer file is written before any command runs. + const layerFile = join(projectDir, "hack.env.default.yaml"); + await writeFile( + layerFile, + `version: 1\nenvironment: default\nsecretsprovider: project_key\nvalues:\n global:\n KEEP_FLAG: "1"\n DOOMED_KEY: "${VALUE_CANARY}"\n` + ); + return { projectRoot, layerFile }; +} + +test( + "env unset with piped stdin applies the documented default and exits promptly", + async () => { + const { projectRoot, layerFile } = await createModernProject(); + const result = await runHackPipedStdin({ + args: ["env", "unset", "DOOMED_KEY", "--path", projectRoot], + cwd: projectRoot, + }); + + expectSettledWithinBounds(result); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain(`Updated ${layerFile}`); + + const after = await readFile(layerFile, "utf8"); + expect(after).not.toContain("DOOMED_KEY"); + expect(after).toContain("KEEP_FLAG"); + for (const stream of [result.stdout, result.stderr]) { + expect(stream).not.toContain(VALUE_CANARY); + expect(stream).not.toContain("E_INTERACTIVE_REQUIRED"); + } + }, + { timeout: 30_000 } +); + +test( + "a fail-policy confirmation with piped stdin refuses closed instead of prompting", + async () => { + // `hack secrets delete` confirms with the `fail` policy; the refusal must + // land before the keychain is touched and must name the way out. + const result = await runHackPipedStdin({ + args: ["secrets", "delete", "unset-no-tty-never-stored"], + cwd: requireRoot(), + }); + + expectSettledWithinBounds(result); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("E_INTERACTIVE_REQUIRED"); + expect(result.stderr).toContain("no TTY"); + expect(result.stderr).toContain("in a terminal"); + expect(result.stdout).toBe(""); + }, + { timeout: 30_000 } +); diff --git a/tests/global-command.macos.test.ts b/tests/global-command.macos.test.ts index e34a34715..d483aa827 100644 --- a/tests/global-command.macos.test.ts +++ b/tests/global-command.macos.test.ts @@ -256,9 +256,15 @@ beforeEach(async () => { reachabilityByHost = {}; idUser = "mock-user"; confirmResponder = null; + // The clack confirm mock stands in for an interactive user, so the run must + // look interactive: `confirmSafe` resolves TTY-less runs by policy without + // entering the prompt (HACK-1385), and `bun test` has no TTY. + restoreInteractiveTty = stubInteractiveTty(); }); afterEach(async () => { + restoreInteractiveTty?.(); + restoreInteractiveTty = null; if (originalMcpCommand === undefined) { Reflect.deleteProperty(process.env, "HACK_MCP_COMMAND"); } else { @@ -799,6 +805,13 @@ test("global up uses the dns recovery helper when it is installed", async () => test("global up falls back to brew when the helper restart fails", async () => { pathExistsOverrides.set("/usr/local/libexec/hack-dns-recovery", true); + // Non-interactive sudo path: with a TTY on stdin the failed helper restart + // retries with interactive sudo instead of falling back to brew. afterEach + // restores the original descriptor. + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: false, + }); const caddyCompose = join( tempDir!, GLOBAL_HACK_DIR_NAME, @@ -908,6 +921,37 @@ test("global up falls back to interactive sudo when stdin is tty but stdout is r } }); +let restoreInteractiveTty: (() => void) | null = null; + +/** + * Makes stdin and stdout report a TTY for one test so `canPrompt()` is true and + * the mocked clack confirm is reached. Returns the restore function; a test + * that stubs its own values on top restores to these and `afterEach` restores + * the originals. + */ +function stubInteractiveTty(): () => void { + const stdinDescriptor = Object.getOwnPropertyDescriptor( + process.stdin, + "isTTY" + ); + const stdoutDescriptor = Object.getOwnPropertyDescriptor( + process.stdout, + "isTTY" + ); + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: true, + }); + return () => { + restoreIsTty({ stream: process.stdin, descriptor: stdinDescriptor }); + restoreIsTty({ stream: process.stdout, descriptor: stdoutDescriptor }); + }; +} + /** * Restores a stubbed isTTY property. When the stream had no own isTTY * descriptor before the stub, the stubbed own property must be deleted diff --git a/tests/interactivity.test.ts b/tests/interactivity.test.ts index ed9af6294..85584984d 100644 --- a/tests/interactivity.test.ts +++ b/tests/interactivity.test.ts @@ -86,6 +86,112 @@ test("confirmSafe decline answers false regardless of the default", async () => ).toBe(false); }); +/** + * Stubs `isTTY` on a stream for one test and returns the restore function. + * When the stream had no own descriptor, the stub is deleted on restore so the + * value never leaks into other test files sharing this process. + */ +function stubIsTty(opts: { + readonly stream: NodeJS.ReadStream | NodeJS.WriteStream; + readonly value: boolean; +}): () => void { + const descriptor = Object.getOwnPropertyDescriptor(opts.stream, "isTTY"); + Object.defineProperty(opts.stream, "isTTY", { + configurable: true, + value: opts.value, + }); + return () => { + if (descriptor) { + Object.defineProperty(opts.stream, "isTTY", descriptor); + return; + } + Reflect.deleteProperty(opts.stream, "isTTY"); + }; +} + +/** + * HACK-1385: without a TTY and without the explicit opt-out, `confirmSafe` + * used to enter the clack prompt, whose readline loop never settles on a + * piped stdin and spins at full CPU. The policy must resolve the answer + * without touching the prompt, so each case is bounded by a short timeout + * (the old code path fails these tests by timing out). + */ +async function withNoTty(run: () => Promise): Promise { + Reflect.deleteProperty(process.env, "HACK_NO_INTERACTIVE"); + setNoInteractiveFlag({ enabled: false }); + const restoreStdin = stubIsTty({ stream: process.stdin, value: false }); + const restoreStdout = stubIsTty({ stream: process.stdout, value: true }); + try { + expect(canPrompt()).toBe(false); + return await run(); + } finally { + restoreStdin(); + restoreStdout(); + } +} + +test( + "confirmSafe without a TTY applies accept-default instead of prompting", + async () => { + const started = performance.now(); + const answers = await withNoTty(async () => [ + await confirmSafe({ + message: 'Unset "KEY" from global in default?', + initialValue: true, + nonInteractive: "accept-default", + }), + await confirmSafe({ + message: "Destroy everything?", + initialValue: false, + nonInteractive: "accept-default", + }), + ]); + expect(answers).toEqual([true, false]); + expect(performance.now() - started).toBeLessThan(1000); + }, + { timeout: 2000 } +); + +test( + "confirmSafe without a TTY declines instead of prompting", + async () => { + const answer = await withNoTty(() => + confirmSafe({ + message: "Trust CA in the System keychain? (requires sudo)", + initialValue: true, + nonInteractive: "decline", + }) + ); + expect(answer).toBe(false); + }, + { timeout: 2000 } +); + +test( + "confirmSafe without a TTY fails closed with the scriptable hint", + async () => { + let caught: unknown = null; + await withNoTty(async () => { + try { + await confirmSafe({ + message: "Delete it?", + initialValue: false, + nonInteractive: "fail", + hint: "Pass --yes to script this.", + }); + } catch (error: unknown) { + caught = error; + } + }); + expect(caught).toBeInstanceOf(HackCliError); + const err = caught as HackCliError; + expect(err.code).toBe("E_INTERACTIVE_REQUIRED"); + expect(err.message).toContain("no TTY"); + expect(err.message).toContain("--yes"); + }, + { timeout: 2000 } +); + test("confirmSafe fail policy raises E_INTERACTIVE_REQUIRED", async () => { process.env.HACK_NO_INTERACTIVE = "1"; let caught: unknown = null;