From 0bdc128cb589c99147c76a2e9a5eb4a66f4b1be2 Mon Sep 17 00:00:00 2001 From: James Russo Date: Wed, 28 Oct 2026 07:56:20 +0000 Subject: [PATCH] fix(cli): never prompt or auto-install when nobody is at the terminal (#6617) * fix(cli): never prompt or auto-install when nobody is at the terminal publish and upgrade called the confirmation prompt with no terminal check. With stdin from /dev/null, publish exited 13; with an open pipe it hung. Unattended and without --yes, both now exit 2 before doing any work, and stderr names --yes (upgrade also names --check). All prompts and spinners now go through ui/prompts.ts, the only module allowed to import @clack/prompts (a lint rule and a contract test enforce it). Its confirm, select, text and password refuse to prompt in an unattended run, and its spinner prints only the final line, on stderr. init, feedback, catalog and cloud delete decide whether to ask with isUnattended() instead of a TTY check of their own. The background auto-install no longer starts in an unattended run, and the update notice ("Run: npm install -g ...") is not shown while an install for that version is pending. CI now counts as set for any value but "false" or "0". Co-Authored-By: Claude Opus 5.5 (1M context) * test(cli): neutralize agent detection in the catalog offer tests through isAgentDriven Attendance now reads isAgentDriven(), so that is what the catalog tests stub to stay independent of the agent running the suite. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .oxlintrc.json | 10 ++- docs/packages/cli.mdx | 17 ++++- packages/cli/src/cli.agentMode.test.ts | 24 +++++- packages/cli/src/commands/auth/login.ts | 2 +- packages/cli/src/commands/benchmark.ts | 2 +- packages/cli/src/commands/browser.test.ts | 5 ++ packages/cli/src/commands/browser.ts | 2 +- packages/cli/src/commands/catalog.test.ts | 5 ++ packages/cli/src/commands/catalog.ts | 10 +-- packages/cli/src/commands/cloud/delete.ts | 7 +- packages/cli/src/commands/feedback.ts | 5 +- .../cli/src/commands/init.interactive.test.ts | 5 ++ packages/cli/src/commands/init.ts | 5 +- packages/cli/src/commands/models.ts | 2 +- packages/cli/src/commands/play.ts | 2 +- packages/cli/src/commands/present.ts | 2 +- .../src/commands/preview.startFailure.test.ts | 5 ++ packages/cli/src/commands/preview.ts | 2 +- packages/cli/src/commands/publish.ts | 6 +- .../cli/src/commands/remove-background.ts | 2 +- packages/cli/src/commands/render/execute.ts | 2 +- packages/cli/src/commands/skills.ts | 2 +- packages/cli/src/commands/transcribe.ts | 2 +- packages/cli/src/commands/tts.ts | 2 +- packages/cli/src/commands/upgrade.ts | 9 ++- packages/cli/src/ui/prompts.test.ts | 67 +++++++++++++++++ packages/cli/src/ui/prompts.ts | 74 +++++++++++++++++++ packages/cli/src/utils/autoUpdate.test.ts | 17 +++++ packages/cli/src/utils/autoUpdate.ts | 7 +- packages/cli/src/utils/updateCheck.test.ts | 29 +++++++- packages/cli/src/utils/updateCheck.ts | 6 +- 31 files changed, 300 insertions(+), 37 deletions(-) create mode 100644 packages/cli/src/ui/prompts.test.ts create mode 100644 packages/cli/src/ui/prompts.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 196dc68fee..ef54d6835b 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -90,7 +90,11 @@ }, { "files": ["packages/cli/src/**/*.ts"], - "excludeFiles": ["**/*.test.ts", "packages/cli/src/utils/defineCommand.ts"], + "excludeFiles": [ + "**/*.test.ts", + "packages/cli/src/utils/defineCommand.ts", + "packages/cli/src/ui/prompts.ts" + ], "rules": { "no-restricted-imports": [ "error", @@ -100,6 +104,10 @@ "name": "citty", "importNames": ["defineCommand"], "message": "Define commands with defineCommand from packages/cli/src/utils/defineCommand.ts so the shared guards and the agent-mode contract test cover them." + }, + { + "name": "@clack/prompts", + "message": "Import prompts and spinners from packages/cli/src/ui/prompts.ts: it refuses to prompt when nobody can answer." } ] } diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index fcd2376970..29145bf4c1 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -71,6 +71,12 @@ example rather than waiting for input. Two commands are interactive on a TTY: `--non-interactive`, and `catalog --human-friendly` opens a picker. Nothing else prompts, apart from confirmations you can skip with `--yes` or `--no-confirm`. +**Unattended runs never prompt.** A run is unattended when stdin or stdout is not +a terminal, under CI, inside a coding agent, or with `HYPERFRAMES_AGENT=1`. +There, a command that would ask for confirmation, such as `publish` or `upgrade`, +exits 2 instead and names the flag to pass (`--yes`), and spinners print only +their final line, on stderr. + **Exit codes.** `0` means the command succeeded, `1` means it ran and failed, and `2` means the command line itself is wrong: an unknown command or flag, or a missing or invalid value. @@ -85,8 +91,10 @@ it cannot install — `npx`, an unknown installer — you get a notice instead: Run: npx hyperframes@latest ``` -Both stay silent in CI and non-TTY shells. `HYPERFRAMES_NO_UPDATE_CHECK=1` turns -off the check, the install, and the notice. +Both stay silent in unattended runs, and the background install never starts +there. While an install is underway, the notice is not shown. +`HYPERFRAMES_NO_UPDATE_CHECK=1` turns off the check, the install, and the +notice; `HYPERFRAMES_NO_AUTO_INSTALL=1` turns off only the install. ## Create a project @@ -1027,7 +1035,7 @@ npx hyperframes publish --space | Flag | Description | | ------------------------ | ------------------------------------------------------------------ | -| `--yes, -y` | Skip the confirmation prompt | +| `--yes, -y` | Skip the confirmation prompt. Required in an unattended run | | `--public` | Make the claimed project visible to anyone | | `--update ` | Update an existing project in place. Requires sign-in. | | `--space ` | Publish into a shared team space. Requires sign-in. | @@ -1887,7 +1895,8 @@ npx hyperframes upgrade --project # bump pinned CLI scripts in package.jso `upgrade` compares your installed version against npm. `--check` exits without prompting, `--yes, -y` upgrades a detected global install (otherwise it prints -the right `npx` command), and `--project [dir]` rewrites +the right `npx` command; an unattended run without `--yes` or `--check` exits 2), +and `--project [dir]` rewrites `hyperframes@` script pins in a project's `package.json`. `--check --json` returns: diff --git a/packages/cli/src/cli.agentMode.test.ts b/packages/cli/src/cli.agentMode.test.ts index ade63c7533..5b9a9023b1 100644 --- a/packages/cli/src/cli.agentMode.test.ts +++ b/packages/cli/src/cli.agentMode.test.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -152,4 +152,26 @@ describe("agent-mode contract", () => { expectQuiet(run); expect(run.code, run.stderr).toBe(2); }); + + it.each([["publish"], ["upgrade"]])( + "%s without --yes refuses to prompt, names --yes, and exits 2", + async (name) => { + const run = await runAsAgent([name]); + expectQuiet(run); + expect(run.code, run.stderr).toBe(2); + expect(run.stdout).toBe(""); + expect(run.stderr).toContain("--yes"); + }, + ); + + it("only the prompt wrapper loads @clack/prompts, statically or dynamically", () => { + const offenders = (readdirSync(srcDir, { recursive: true }) as string[]).filter( + (file) => + file.endsWith(".ts") && + !file.endsWith(".test.ts") && + file !== join("ui", "prompts.ts") && + readFileSync(join(srcDir, file), "utf8").includes('"@clack/prompts"'), + ); + expect(offenders).toEqual([]); + }); }); diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 23613d77ea..89bed70997 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -468,7 +468,7 @@ async function readAllWithTimeout( } async function promptForKey(): Promise { - const clack = await import("@clack/prompts"); + const clack = await import("../../ui/prompts.js"); const value = await clack.password({ message: "Enter HeyGen API key", validate: (v) => { diff --git a/packages/cli/src/commands/benchmark.ts b/packages/cli/src/commands/benchmark.ts index 8e5576a516..3cf2047f9f 100644 --- a/packages/cli/src/commands/benchmark.ts +++ b/packages/cli/src/commands/benchmark.ts @@ -14,7 +14,7 @@ import { resolveProject } from "../utils/project.js"; import { loadProducer } from "../utils/producer.js"; import { c } from "../ui/colors.js"; import { formatBytes, formatDuration, errorBox } from "../ui/format.js"; -import * as clack from "@clack/prompts"; +import * as clack from "../ui/prompts.js"; import { withMeta } from "../utils/updateCheck.js"; import { fpsToFfmpegArg } from "@hyperframes/core"; diff --git a/packages/cli/src/commands/browser.test.ts b/packages/cli/src/commands/browser.test.ts index 0733fb1f5a..fe246515d7 100644 --- a/packages/cli/src/commands/browser.test.ts +++ b/packages/cli/src/commands/browser.test.ts @@ -2,6 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const spinners: Array<{ running: boolean }> = []; const logError = vi.fn(); +// A person at a terminal: the prompts and spinners these tests drive are refused otherwise. +vi.mock("../utils/attendedTerminal.js", async (importOriginal) => ({ + ...(await importOriginal()), + isUnattended: () => false, +})); vi.mock("@clack/prompts", () => ({ intro: vi.fn(), outro: vi.fn(), diff --git a/packages/cli/src/commands/browser.ts b/packages/cli/src/commands/browser.ts index 7ef5a16d35..f4ce74076c 100644 --- a/packages/cli/src/commands/browser.ts +++ b/packages/cli/src/commands/browser.ts @@ -1,7 +1,7 @@ import { failCommand } from "../utils/commandResult.js"; import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; -import * as clack from "@clack/prompts"; +import * as clack from "../ui/prompts.js"; import { c } from "../ui/colors.js"; export const examples: Example[] = [ diff --git a/packages/cli/src/commands/catalog.test.ts b/packages/cli/src/commands/catalog.test.ts index aefa921289..39ca652c9d 100644 --- a/packages/cli/src/commands/catalog.test.ts +++ b/packages/cli/src/commands/catalog.test.ts @@ -114,6 +114,11 @@ vi.mock("../registry/remote.js", () => ({ }), })); +// The terminal and CI switches below decide attendance; the agent running the suite must not. +vi.mock("../telemetry/agent_runtime.js", async (importOriginal) => ({ + ...(await importOriginal()), + isAgentDriven: () => false, +})); vi.mock("@clack/prompts", () => ({ confirm: async () => state.confirmAnswer, isCancel: (value: unknown) => value === null, diff --git a/packages/cli/src/commands/catalog.ts b/packages/cli/src/commands/catalog.ts index be2408ce6e..7c7c112f4e 100644 --- a/packages/cli/src/commands/catalog.ts +++ b/packages/cli/src/commands/catalog.ts @@ -10,7 +10,7 @@ export const examples: Example[] = [ ["Search (positional, same as --query)", 'hyperframes catalog "crossfade"'], ]; -import * as clack from "@clack/prompts"; +import * as clack from "../ui/prompts.js"; import { realpath, type ItemType, type RegistryItem } from "@hyperframes/core"; import { c } from "../ui/colors.js"; import { loadAllItems } from "../registry/resolver.js"; @@ -18,7 +18,7 @@ import { fetchRegistryManifest } from "../registry/remote.js"; import { loadProjectConfig, DEFAULT_PROJECT_CONFIG } from "../utils/projectConfig.js"; import { relative, resolve } from "node:path"; import { finishCommand } from "../utils/commandResult.js"; -import { isAttendedTerminal } from "../utils/attendedTerminal.js"; +import { isUnattended } from "../utils/attendedTerminal.js"; import { runAdd } from "./add.js"; import { hasNoSearchableTokens, searchByWords } from "../registry/localSearch.js"; import { @@ -271,7 +271,7 @@ export default defineCommand({ warnings = await prepareOnDeviceTier({ assumedYes: args.yes === true, artifactRevision, - canPrompt: isAttendedTerminal() && !json, + canPrompt: !isUnattended() && !json, installRuntime: args["on-device"] === true, registry: config.registry, registryNames, @@ -725,7 +725,7 @@ type LocalMode = "local-model" | "words"; * person to prompt instead, or when that person has already answered. */ function localModelHint(json: boolean, status: LocalModelStatus | undefined): string | null { - if (!json && isAttendedTerminal()) return null; + if (!json && !isUnattended()) return null; if (status?.status !== "not-asked") return null; return nonInteractiveConsentMessage(); } @@ -743,7 +743,7 @@ async function offerLocalModel( registryBaseUrl: string, artifactRevision?: string, ): Promise { - if (json || !isAttendedTerminal()) return; + if (json || isUnattended()) return; if (localModelStatus().status !== "not-asked") return; // Deliberately not gated on the number of results. That gate was set when the // catalog was small; against 411 moves a word match nearly always returns diff --git a/packages/cli/src/commands/cloud/delete.ts b/packages/cli/src/commands/cloud/delete.ts index 69a6bcde24..0cf8ba1245 100644 --- a/packages/cli/src/commands/cloud/delete.ts +++ b/packages/cli/src/commands/cloud/delete.ts @@ -11,6 +11,7 @@ import { createCloudClient } from "../../cloud/index.js"; import { reportApiError } from "../../cloud/errors.js"; import { withMeta } from "../../utils/updateCheck.js"; import { c } from "../../ui/colors.js"; +import { isUnattended } from "../../utils/attendedTerminal.js"; import { errorBox } from "../../ui/format.js"; export default defineCommand({ @@ -48,10 +49,10 @@ export default defineCommand({ // safety check. Force the caller to opt in via `--no-confirm` // so cron jobs, CI shells, and JSON consumers can't soft-delete // by accident. - if (args.json || !process.stdin.isTTY) { + if (args.json || isUnattended()) { errorBox( "Confirmation required", - "delete cannot prompt for confirmation here — stdin isn't a TTY or --json was passed.", + "delete cannot prompt for confirmation here: this run cannot answer a prompt, or --json was passed.", "Re-run with --no-confirm to acknowledge the irreversible delete.", ); failCommand(); @@ -87,7 +88,7 @@ export default defineCommand({ }); async function confirmDelete(id: string): Promise { - const clack = await import("@clack/prompts"); + const clack = await import("../../ui/prompts.js"); const answer = await clack.confirm({ message: `Delete render ${id}? This is irreversible.`, initialValue: false, diff --git a/packages/cli/src/commands/feedback.ts b/packages/cli/src/commands/feedback.ts index 66ee522d25..84fbc5e8c1 100644 --- a/packages/cli/src/commands/feedback.ts +++ b/packages/cli/src/commands/feedback.ts @@ -2,7 +2,7 @@ import { failCommand } from "../utils/commandResult.js"; import { randomUUID } from "node:crypto"; import { resolve } from "node:path"; import { defineCommand } from "../utils/defineCommand.js"; -import * as clack from "@clack/prompts"; +import * as clack from "../ui/prompts.js"; import open from "open"; import type { Example } from "./_examples.js"; import { @@ -18,6 +18,7 @@ import { submitCatalogSearchMiss, submitFeedback } from "../utils/submitFeedback import { buildIssueUrl, HYPERFRAMES_REPO_URL } from "../utils/feedbackIssue.js"; import { VERSION } from "../version.js"; import { c } from "../ui/colors.js"; +import { isUnattended } from "../utils/attendedTerminal.js"; import { parseFeedbackRating } from "../utils/feedbackRating.js"; import { feedbackEmail, parseFeedbackSource } from "../utils/feedbackSource.js"; import { lintFeedbackComment, type FeedbackLintInput } from "../utils/feedbackLint.js"; @@ -88,7 +89,7 @@ async function promptConfirm(): Promise { async function confirmFileIssue(dir: string, yes: boolean): Promise { printIssueConsent(dir); if (yes) return true; - if (!process.stdout.isTTY) { + if (isUnattended()) { console.log(` ${c.dim("Re-run with --yes to publish the repro and file the issue.")}\n`); return false; } diff --git a/packages/cli/src/commands/init.interactive.test.ts b/packages/cli/src/commands/init.interactive.test.ts index e1a4d568c4..c7ee264a3e 100644 --- a/packages/cli/src/commands/init.interactive.test.ts +++ b/packages/cli/src/commands/init.interactive.test.ts @@ -6,6 +6,11 @@ import { runCommand } from "citty"; const prompts = vi.hoisted(() => ({ errors: [] as string[], typedName: "" })); +// A person at a terminal: the prompts and spinners these tests drive are refused otherwise. +vi.mock("../utils/attendedTerminal.js", async (importOriginal) => ({ + ...(await importOriginal()), + isUnattended: () => false, +})); vi.mock("@clack/prompts", () => ({ intro: () => undefined, cancel: () => undefined, diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 72100465ae..06b6cfef7d 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -33,8 +33,9 @@ import { copyDirSync } from "../utils/copyDir.js"; import { resolve, basename, join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { execFileSync, spawn } from "node:child_process"; -import * as clack from "@clack/prompts"; +import * as clack from "../ui/prompts.js"; import { c } from "../ui/colors.js"; +import { isUnattended } from "../utils/attendedTerminal.js"; import { printBanner } from "../ui/banner.js"; import { BUNDLED_TEMPLATES, @@ -825,7 +826,7 @@ export default defineCommand({ modelFlag ?? DEFAULT_MODEL, languageFlag, ); - const interactive = !nonInteractive && process.stdout.isTTY === true; + const interactive = !nonInteractive && !isUnattended(); let resolutionPreset: CanvasResolution | undefined; if (args.resolution !== undefined) { diff --git a/packages/cli/src/commands/models.ts b/packages/cli/src/commands/models.ts index 2ea79fcb53..dbf448f3a0 100644 --- a/packages/cli/src/commands/models.ts +++ b/packages/cli/src/commands/models.ts @@ -1,5 +1,5 @@ import { defineCommand } from "../utils/defineCommand.js"; -import * as clack from "@clack/prompts"; +import * as clack from "../ui/prompts.js"; import type { Example } from "./_examples.js"; import { c } from "../ui/colors.js"; import { formatBytes } from "../ui/format.js"; diff --git a/packages/cli/src/commands/play.ts b/packages/cli/src/commands/play.ts index 0daf00e401..5df9fbe23f 100644 --- a/packages/cli/src/commands/play.ts +++ b/packages/cli/src/commands/play.ts @@ -21,7 +21,7 @@ export const examples: Example[] = [ import { resolve } from "node:path"; import type { Hono } from "hono"; import { requestSubPath } from "@hyperframes/studio-server"; -import * as clack from "@clack/prompts"; +import * as clack from "../ui/prompts.js"; import { c } from "../ui/colors.js"; import { resolveProject, type ProjectDir } from "../utils/project.js"; import { resolveAutoProxy } from "../utils/projectConfig.js"; diff --git a/packages/cli/src/commands/present.ts b/packages/cli/src/commands/present.ts index 9f0172f45b..f52e1ab860 100644 --- a/packages/cli/src/commands/present.ts +++ b/packages/cli/src/commands/present.ts @@ -11,7 +11,7 @@ export const examples: Example[] = [ ["Open with a specific browser", "hyperframes present --browser-path /usr/bin/chromium"], ]; import { resolve } from "node:path"; -import * as clack from "@clack/prompts"; +import * as clack from "../ui/prompts.js"; import { c } from "../ui/colors.js"; import { resolveProject, type ProjectDir } from "../utils/project.js"; import { normalizeErrorMessage } from "../utils/errorMessage.js"; diff --git a/packages/cli/src/commands/preview.startFailure.test.ts b/packages/cli/src/commands/preview.startFailure.test.ts index 94df3fc1c1..4a302805fd 100644 --- a/packages/cli/src/commands/preview.startFailure.test.ts +++ b/packages/cli/src/commands/preview.startFailure.test.ts @@ -5,6 +5,11 @@ const ui = vi.hoisted(() => ({ spinner: { start: vi.fn(), stop: vi.fn(), message: vi.fn() }, error: vi.fn(), })); +// A person at a terminal: the prompts and spinners these tests drive are refused otherwise. +vi.mock("../utils/attendedTerminal.js", async (importOriginal) => ({ + ...(await importOriginal()), + isUnattended: () => false, +})); vi.mock("@clack/prompts", async (importOriginal) => { const actual = await importOriginal(); return { diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index cbf06e0212..62bedf8338 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -37,7 +37,7 @@ import { existsSync, lstatSync, mkdirSync, readlinkSync, symlinkSync, unlinkSync import { resolve, dirname, basename, join } from "node:path"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; -import * as clack from "@clack/prompts"; +import * as clack from "../ui/prompts.js"; import { c } from "../ui/colors.js"; import { desktopHint, insideHostApp } from "../utils/desktopApp.js"; import { isDevMode } from "../utils/env.js"; diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index fb79c5da1c..4a1fa289b2 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -2,10 +2,11 @@ import { join, posix, relative, resolve } from "node:path"; import { failCommand, setCommandExitCode } from "../utils/commandResult.js"; import { existsSync } from "node:fs"; import { defineCommand } from "../utils/defineCommand.js"; -import * as clack from "@clack/prompts"; +import * as clack from "../ui/prompts.js"; import type { Example } from "./_examples.js"; import { c } from "../ui/colors.js"; +import { isUnattended } from "../utils/attendedTerminal.js"; import { definitiveEntryMismatchComposition, hasDefinitiveEntryMismatch, @@ -94,6 +95,9 @@ export default defineCommand({ }, }, async run({ args }) { + if (args.yes !== true && isUnattended()) { + clack.confirmationRequired("hyperframes publish", "Pass --yes to publish without asking."); + } const rawArg = args.dir; const dir = resolve(rawArg ?? "."); const indexPath = join(dir, "index.html"); diff --git a/packages/cli/src/commands/remove-background.ts b/packages/cli/src/commands/remove-background.ts index f56ead42f5..15368f692d 100644 --- a/packages/cli/src/commands/remove-background.ts +++ b/packages/cli/src/commands/remove-background.ts @@ -1,7 +1,7 @@ import { failCommand, failUsage } from "../utils/commandResult.js"; import { defineCommand } from "../utils/defineCommand.js"; import { existsSync } from "node:fs"; -import * as clack from "@clack/prompts"; +import * as clack from "../ui/prompts.js"; import { c } from "../ui/colors.js"; import { DEFAULT_MODEL, diff --git a/packages/cli/src/commands/render/execute.ts b/packages/cli/src/commands/render/execute.ts index 2e03c29c1b..71a35a6b2d 100644 --- a/packages/cli/src/commands/render/execute.ts +++ b/packages/cli/src/commands/render/execute.ts @@ -217,7 +217,7 @@ async function ensureRenderBrowser(plan: RenderPlan, signal?: AbortSignal): Prom if (plan.effectiveQuiet) { return (await ensureBrowser({ preferManagedChrome: true, signal })).executablePath; } - const clack = await import("@clack/prompts"); + const clack = await import("../../ui/prompts.js"); browserSpinner = clack.spinner(); browserSpinner.start("Checking browser..."); const info = await ensureBrowser({ diff --git a/packages/cli/src/commands/skills.ts b/packages/cli/src/commands/skills.ts index 7960bef65f..844f0ee49f 100644 --- a/packages/cli/src/commands/skills.ts +++ b/packages/cli/src/commands/skills.ts @@ -1,7 +1,7 @@ import { setCommandExitCode, CliResultSignal } from "../utils/commandResult.js"; import { defineCommand } from "../utils/defineCommand.js"; import { execFileSync, spawn } from "node:child_process"; -import * as clack from "@clack/prompts"; +import * as clack from "../ui/prompts.js"; import { c } from "../ui/colors.js"; import { diag } from "../ui/diagnostics.js"; import { buildNpxCommand } from "../utils/npxCommand.js"; diff --git a/packages/cli/src/commands/transcribe.ts b/packages/cli/src/commands/transcribe.ts index 51989ddf93..dd88cbba4c 100644 --- a/packages/cli/src/commands/transcribe.ts +++ b/packages/cli/src/commands/transcribe.ts @@ -2,7 +2,7 @@ import { createProgressWriter } from "../whisper/progress.js"; import { failCommand, setCommandExitCode } from "../utils/commandResult.js"; import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; -import * as clack from "@clack/prompts"; +import * as clack from "../ui/prompts.js"; import { c } from "../ui/colors.js"; import { DEFAULT_MODEL } from "../whisper/manager.js"; import { trackTranscribeUnavailable } from "../telemetry/events.js"; diff --git a/packages/cli/src/commands/tts.ts b/packages/cli/src/commands/tts.ts index 0f37d7dd4f..80a97be211 100644 --- a/packages/cli/src/commands/tts.ts +++ b/packages/cli/src/commands/tts.ts @@ -22,7 +22,7 @@ export const examples: Example[] = [ ["List available voices", "hyperframes tts --list"], ]; import { resolve } from "node:path"; -import * as clack from "@clack/prompts"; +import * as clack from "../ui/prompts.js"; import { c } from "../ui/colors.js"; import { errorBox } from "../ui/format.js"; import { diff --git a/packages/cli/src/commands/upgrade.ts b/packages/cli/src/commands/upgrade.ts index 9546f251c2..8f6c3691ed 100644 --- a/packages/cli/src/commands/upgrade.ts +++ b/packages/cli/src/commands/upgrade.ts @@ -1,11 +1,12 @@ import { setCommandExitCode } from "../utils/commandResult.js"; import { defineCommand } from "../utils/defineCommand.js"; import type { Example } from "./_examples.js"; -import * as clack from "@clack/prompts"; +import * as clack from "../ui/prompts.js"; import { execFileSync } from "node:child_process"; import { existsSync, readFileSync, writeFileSync, renameSync, readdirSync } from "node:fs"; import { resolve } from "node:path"; import { c } from "../ui/colors.js"; +import { isUnattended } from "../utils/attendedTerminal.js"; import { rewritePinnedHyperframesText, rewriteProjectPinnedScripts } from "../utils/projectPin.js"; export const examples: Example[] = [ @@ -59,6 +60,12 @@ export default defineCommand({ } const autoYes = args.yes === true; + if (!autoYes && !checkOnly && isUnattended()) { + clack.confirmationRequired( + "hyperframes upgrade", + "Pass --yes to upgrade, or --check to only look for a new version.", + ); + } clack.intro(c.bold("hyperframes upgrade")); const s = clack.spinner(); diff --git a/packages/cli/src/ui/prompts.test.ts b/packages/cli/src/ui/prompts.test.ts new file mode 100644 index 0000000000..9713437f92 --- /dev/null +++ b/packages/cli/src/ui/prompts.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CliUsageError } from "../utils/commandResult.js"; + +const state = vi.hoisted(() => ({ unattended: true })); +vi.mock("../utils/attendedTerminal.js", () => ({ isUnattended: () => state.unattended })); +const clack = vi.hoisted(() => ({ + confirm: vi.fn(async () => true), + select: vi.fn(async () => "a"), + text: vi.fn(async () => "t"), + password: vi.fn(async () => "p"), + spinner: vi.fn(() => ({ fromClack: true })), +})); +vi.mock("@clack/prompts", () => clack); + +const prompts = await import("./prompts.js"); + +afterEach(() => { + vi.clearAllMocks(); + state.unattended = true; +}); + +describe("prompt wrapper", () => { + it.each([ + ["confirm", () => prompts.confirm({ message: "?" })], + ["select", () => prompts.select({ message: "?", options: [{ value: "a" }] })], + ["text", () => prompts.text({ message: "?" })], + ["password", () => prompts.password({ message: "?" })], + ])("%s refuses to prompt when unattended", (name, ask) => { + expect(ask).toThrow(CliUsageError); + expect(clack[name as keyof typeof clack]).not.toHaveBeenCalled(); + }); + + it("asks when a person is at the terminal", async () => { + state.unattended = false; + await expect(prompts.confirm({ message: "?" })).resolves.toBe(true); + }); + + it("confirmationRequired is a usage error that carries the hint", () => { + try { + prompts.confirmationRequired("hyperframes publish", "Pass --yes."); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(CliUsageError); + expect((error as CliUsageError).result.exitCode).toBe(2); + expect((error as CliUsageError).hint).toBe("Pass --yes."); + } + }); + + it("an unattended spinner writes only its outcome, as a plain line on stderr", () => { + const write = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + const spin = prompts.spinner(); + spin.start("Checking..."); + spin.message("Still checking..."); + spin.stop("\u001b[32mDone\u001b[39m"); + expect(write.mock.calls).toEqual([["Done\n"]]); + expect(clack.spinner).not.toHaveBeenCalled(); + } finally { + write.mockRestore(); + } + }); + + it("an attended spinner is clack's", () => { + state.unattended = false; + expect(prompts.spinner()).toEqual({ fromClack: true }); + }); +}); diff --git a/packages/cli/src/ui/prompts.ts b/packages/cli/src/ui/prompts.ts new file mode 100644 index 0000000000..986b6272ce --- /dev/null +++ b/packages/cli/src/ui/prompts.ts @@ -0,0 +1,74 @@ +import * as clack from "@clack/prompts"; +import type { + ConfirmOptions, + PasswordOptions, + SelectOptions, + SpinnerOptions, + SpinnerResult, + TextOptions, +} from "@clack/prompts"; +import { stripVTControlCharacters } from "node:util"; +import { isUnattended } from "../utils/attendedTerminal.js"; +import { CliUsageError } from "../utils/commandResult.js"; + +// The only module that imports @clack/prompts (a lint rule enforces it), so no command can +// prompt, or draw a spinner, where nobody is there to see it. + +export { cancel, intro, isCancel, log, note, outro } from "@clack/prompts"; + +/** A command that would ask for confirmation, run where nobody can answer. */ +export function confirmationRequired(command: string, hint: string): never { + throw new CliUsageError( + `${command} asks for confirmation, and this run cannot answer a prompt.`, + { + hint, + }, + ); +} + +function assertCanPrompt(): void { + if (!isUnattended()) return; + throw new CliUsageError("This command asks a question, and this run cannot answer a prompt.", { + hint: "Pass the answer as a flag; the command's --help lists them.", + }); +} + +export function confirm(opts: ConfirmOptions): Promise { + assertCanPrompt(); + return clack.confirm(opts); +} + +export function select(opts: SelectOptions): Promise { + assertCanPrompt(); + return clack.select(opts); +} + +export function text(opts: TextOptions): Promise { + assertCanPrompt(); + return clack.text(opts); +} + +export function password(opts: PasswordOptions): Promise { + assertCanPrompt(); + return clack.password(opts); +} + +function lineSpinner(): SpinnerResult { + const say = (message?: string) => { + if (message) process.stderr.write(`${stripVTControlCharacters(message)}\n`); + }; + return { + start: () => {}, + message: () => {}, + stop: say, + cancel: say, + error: say, + clear: () => {}, + isCancelled: false, + }; +} + +/** Unattended, only the outcome is written, as one plain line on stderr. */ +export function spinner(opts?: SpinnerOptions): SpinnerResult { + return isUnattended() ? lineSpinner() : clack.spinner(opts); +} diff --git a/packages/cli/src/utils/autoUpdate.test.ts b/packages/cli/src/utils/autoUpdate.test.ts index 0773d8393c..144e08e5bc 100644 --- a/packages/cli/src/utils/autoUpdate.test.ts +++ b/packages/cli/src/utils/autoUpdate.test.ts @@ -26,6 +26,7 @@ function setupMocks(opts: { config?: ConfigShape; env?: Record; writeFails?: boolean; + unattended?: boolean; }): { writeSpy: ReturnType; spawnSpy: ReturnType; @@ -50,6 +51,10 @@ function setupMocks(opts: { writeConfig: writeSpy, })); vi.doMock("./env.js", () => ({ isDevMode: () => !!opts.devMode })); + vi.doMock("./attendedTerminal.js", async (importOriginal) => ({ + ...(await importOriginal()), + isUnattended: () => opts.unattended ?? false, + })); vi.doMock("./installerDetection.js", () => ({ detectInstaller: () => ({ kind: opts.installer.kind, @@ -167,6 +172,18 @@ describe("scheduleBackgroundInstall", () => { expect(spawnSpy).not.toHaveBeenCalled(); }); + it("skips when the run is unattended (an agent, no terminal)", async () => { + const { spawnSpy, config } = setupMocks({ + installer: { kind: "npm", command: "npm install -g hyperframes@0.4.4" }, + unattended: true, + }); + const { scheduleBackgroundInstall } = await import("./autoUpdate.js"); + + expect(scheduleBackgroundInstall("0.4.4", "0.4.3")).toBe(false); + expect(spawnSpy).not.toHaveBeenCalled(); + expect(config.pendingUpdate).toBeUndefined(); + }); + it("skips when HYPERFRAMES_NO_AUTO_INSTALL=1", async () => { const { spawnSpy } = setupMocks({ installer: { kind: "npm", command: "npm install -g hyperframes@0.4.4" }, diff --git a/packages/cli/src/utils/autoUpdate.ts b/packages/cli/src/utils/autoUpdate.ts index bb5350851c..b399d009f0 100644 --- a/packages/cli/src/utils/autoUpdate.ts +++ b/packages/cli/src/utils/autoUpdate.ts @@ -14,7 +14,7 @@ * Guardrails: * - Never auto-update across major versions. The user opts in explicitly * via `hyperframes upgrade`. - * - Skip on CI, dev mode, unknown installer, ephemeral exec (npx), + * - Skip on CI, unattended runs (agents, no TTY), dev mode, unknown installer, ephemeral exec (npx), * or when `HYPERFRAMES_NO_AUTO_INSTALL` / `HYPERFRAMES_NO_UPDATE_CHECK` * is set. * - If a previous install is still in flight (less than 10 min old), don't @@ -30,6 +30,7 @@ import { join } from "node:path"; import { compareVersions } from "compare-versions"; import { withFileLock } from "../media-use/lib/config-lock.mjs"; import { readConfig, writeConfig } from "../telemetry/config.js"; +import { isUnattended } from "./attendedTerminal.js"; import { updateCheckDisabled } from "./updateCheck.js"; import { RUNNING_DIR, RUNNING_STALE_MS } from "./runningCli.js"; import { @@ -47,7 +48,9 @@ const INSTALL_MAX_WAIT_MS = 60 * 60 * 1000; const INSTALL_POLL_MS = 2_000; function isAutoInstallDisabled(): boolean { - return updateCheckDisabled() || process.env["HYPERFRAMES_NO_AUTO_INSTALL"] === "1"; + return ( + updateCheckDisabled() || process.env["HYPERFRAMES_NO_AUTO_INSTALL"] === "1" || isUnattended() + ); } /** Parse a semver-ish string's major number; returns NaN for pre-releases etc. */ diff --git a/packages/cli/src/utils/updateCheck.test.ts b/packages/cli/src/utils/updateCheck.test.ts index 18b6b2e11b..dce81e4366 100644 --- a/packages/cli/src/utils/updateCheck.test.ts +++ b/packages/cli/src/utils/updateCheck.test.ts @@ -32,9 +32,15 @@ async function noticeWith(opts: { latestVersion?: string; isTTY?: boolean; env?: Record; + unattended?: boolean; + pendingVersion?: string; }): Promise { vi.resetModules(); vi.doMock("./env.js", () => ({ isDevMode: () => false })); + vi.doMock("./attendedTerminal.js", async (importOriginal) => ({ + ...(await importOriginal()), + isUnattended: () => opts.unattended ?? false, + })); vi.doMock("./installerDetection.js", () => ({ detectInstaller: () => ({ kind: opts.installerCommand ? "npm" : "skip", @@ -43,7 +49,14 @@ async function noticeWith(opts: { }), })); vi.doMock("../telemetry/config.js", () => ({ - readConfig: () => ({ latestVersion: opts.latestVersion ?? "9.9.9" }), + readConfig: () => ({ + latestVersion: opts.latestVersion ?? "9.9.9", + pendingUpdate: opts.pendingVersion && { + version: opts.pendingVersion, + command: "npm install -g hyperframes", + startedAt: new Date().toISOString(), + }, + }), writeConfig: () => {}, })); @@ -83,6 +96,7 @@ async function noticeWith(opts: { describe("printUpdateNotice — install-method-aware command", () => { afterEach(() => { vi.doUnmock("./env.js"); + vi.doUnmock("./attendedTerminal.js"); vi.doUnmock("./installerDetection.js"); vi.doUnmock("../telemetry/config.js"); vi.resetModules(); @@ -105,6 +119,19 @@ describe("printUpdateNotice — install-method-aware command", () => { expect(out).toBe(""); }); + it("is suppressed when the run is unattended", async () => { + const out = await noticeWith({ installerCommand: "npm i -g hyperframes", unattended: true }); + expect(out).toBe(""); + }); + + it("does not tell the user to install a version a background install is already installing", async () => { + const out = await noticeWith({ + installerCommand: "npm i -g hyperframes", + pendingVersion: "9.9.9", + }); + expect(out).toBe(""); + }); + it("is suppressed in CI", async () => { const out = await noticeWith({ installerCommand: "brew upgrade hyperframes", diff --git a/packages/cli/src/utils/updateCheck.ts b/packages/cli/src/utils/updateCheck.ts index abcda91471..4fc09d84fb 100644 --- a/packages/cli/src/utils/updateCheck.ts +++ b/packages/cli/src/utils/updateCheck.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { compareVersions } from "compare-versions"; import { readConfig, readConfigFresh, writeConfig } from "../telemetry/config.js"; import { VERSION } from "../version.js"; +import { isCI, isUnattended } from "./attendedTerminal.js"; import { isDevMode } from "./env.js"; import { hostAnswers } from "./hostAnswers.js"; import { detectInstaller } from "./installerDetection.js"; @@ -165,7 +166,7 @@ export function printDeprecationNotice(command: string): void { /** True when the update check is off: dev mode, CI, or the HYPERFRAMES_NO_UPDATE_CHECK opt-out. */ export function updateCheckDisabled(): boolean { if (isDevMode()) return true; - if (process.env["CI"] === "true" || process.env["CI"] === "1") return true; + if (isCI()) return true; return process.env["HYPERFRAMES_NO_UPDATE_CHECK"] === "1"; } @@ -174,7 +175,7 @@ export function updateCheckDisabled(): boolean { * terminal. Shared with the skills freshness notice so both honour the same gating. */ export function updateNoticesSuppressed(): boolean { - return updateCheckDisabled() || !process.stderr.isTTY; + return updateCheckDisabled() || !process.stderr.isTTY || isUnattended(); } /** @@ -186,6 +187,7 @@ export function printUpdateNotice(): void { const meta = getUpdateMeta(); if (!meta.updateAvailable || !meta.latestVersion) return; + if (readConfig().pendingUpdate?.version === meta.latestVersion) return; // Show the command that updates *this* install: the detected package // manager's upgrade for owned global installs (npm/bun/pnpm/brew), and the