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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-10-28 07:56:20 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 6cf4b56697
commit 0bdc128cb5
31 changed files with 300 additions and 37 deletions
+9 -1
View File
@@ -90,7 +90,11 @@
}, },
{ {
"files": ["packages/cli/src/**/*.ts"], "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": { "rules": {
"no-restricted-imports": [ "no-restricted-imports": [
"error", "error",
@@ -100,6 +104,10 @@
"name": "citty", "name": "citty",
"importNames": ["defineCommand"], "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." "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."
} }
] ]
} }
+13 -4
View File
@@ -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 `--non-interactive`, and `catalog --human-friendly` opens a picker. Nothing else
prompts, apart from confirmations you can skip with `--yes` or `--no-confirm`. 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 **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 `2` means the command line itself is wrong: an unknown command or flag, or a
missing or invalid value. missing or invalid value.
@@ -85,8 +91,10 @@ it cannot install — `npx`, an unknown installer — you get a notice instead:
Run: npx hyperframes@latest Run: npx hyperframes@latest
``` ```
Both stay silent in CI and non-TTY shells. `HYPERFRAMES_NO_UPDATE_CHECK=1` turns Both stay silent in unattended runs, and the background install never starts
off the check, the install, and the notice. 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 ## Create a project
@@ -1027,7 +1035,7 @@ npx hyperframes publish --space <space-id>
| Flag | Description | | 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 | | `--public` | Make the claimed project visible to anyone |
| `--update <url-or-id>` | Update an existing project in place. Requires sign-in. | | `--update <url-or-id>` | Update an existing project in place. Requires sign-in. |
| `--space <space-id>` | Publish into a shared team space. Requires sign-in. | | `--space <space-id>` | 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 `upgrade` compares your installed version against npm. `--check` exits without
prompting, `--yes, -y` upgrades a detected global install (otherwise it prints 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@<version>` script pins in a project's `package.json`. `hyperframes@<version>` script pins in a project's `package.json`.
`--check --json` returns: `--check --json` returns:
+23 -1
View File
@@ -1,5 +1,5 @@
import { spawn } from "node:child_process"; 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 { tmpdir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -152,4 +152,26 @@ describe("agent-mode contract", () => {
expectQuiet(run); expectQuiet(run);
expect(run.code, run.stderr).toBe(2); 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([]);
});
}); });
+1 -1
View File
@@ -468,7 +468,7 @@ async function readAllWithTimeout(
} }
async function promptForKey(): Promise<string> { async function promptForKey(): Promise<string> {
const clack = await import("@clack/prompts"); const clack = await import("../../ui/prompts.js");
const value = await clack.password({ const value = await clack.password({
message: "Enter HeyGen API key", message: "Enter HeyGen API key",
validate: (v) => { validate: (v) => {
+1 -1
View File
@@ -14,7 +14,7 @@ import { resolveProject } from "../utils/project.js";
import { loadProducer } from "../utils/producer.js"; import { loadProducer } from "../utils/producer.js";
import { c } from "../ui/colors.js"; import { c } from "../ui/colors.js";
import { formatBytes, formatDuration, errorBox } from "../ui/format.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 { withMeta } from "../utils/updateCheck.js";
import { fpsToFfmpegArg } from "@hyperframes/core"; import { fpsToFfmpegArg } from "@hyperframes/core";
@@ -2,6 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const spinners: Array<{ running: boolean }> = []; const spinners: Array<{ running: boolean }> = [];
const logError = vi.fn(); 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<typeof import("../utils/attendedTerminal.js")>()),
isUnattended: () => false,
}));
vi.mock("@clack/prompts", () => ({ vi.mock("@clack/prompts", () => ({
intro: vi.fn(), intro: vi.fn(),
outro: vi.fn(), outro: vi.fn(),
+1 -1
View File
@@ -1,7 +1,7 @@
import { failCommand } from "../utils/commandResult.js"; import { failCommand } from "../utils/commandResult.js";
import { defineCommand } from "../utils/defineCommand.js"; import { defineCommand } from "../utils/defineCommand.js";
import type { Example } from "./_examples.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 { c } from "../ui/colors.js";
export const examples: Example[] = [ export const examples: Example[] = [
@@ -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<typeof import("../telemetry/agent_runtime.js")>()),
isAgentDriven: () => false,
}));
vi.mock("@clack/prompts", () => ({ vi.mock("@clack/prompts", () => ({
confirm: async () => state.confirmAnswer, confirm: async () => state.confirmAnswer,
isCancel: (value: unknown) => value === null, isCancel: (value: unknown) => value === null,
+5 -5
View File
@@ -10,7 +10,7 @@ export const examples: Example[] = [
["Search (positional, same as --query)", 'hyperframes catalog "crossfade"'], ["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 { realpath, type ItemType, type RegistryItem } from "@hyperframes/core";
import { c } from "../ui/colors.js"; import { c } from "../ui/colors.js";
import { loadAllItems } from "../registry/resolver.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 { loadProjectConfig, DEFAULT_PROJECT_CONFIG } from "../utils/projectConfig.js";
import { relative, resolve } from "node:path"; import { relative, resolve } from "node:path";
import { finishCommand } from "../utils/commandResult.js"; import { finishCommand } from "../utils/commandResult.js";
import { isAttendedTerminal } from "../utils/attendedTerminal.js"; import { isUnattended } from "../utils/attendedTerminal.js";
import { runAdd } from "./add.js"; import { runAdd } from "./add.js";
import { hasNoSearchableTokens, searchByWords } from "../registry/localSearch.js"; import { hasNoSearchableTokens, searchByWords } from "../registry/localSearch.js";
import { import {
@@ -271,7 +271,7 @@ export default defineCommand({
warnings = await prepareOnDeviceTier({ warnings = await prepareOnDeviceTier({
assumedYes: args.yes === true, assumedYes: args.yes === true,
artifactRevision, artifactRevision,
canPrompt: isAttendedTerminal() && !json, canPrompt: !isUnattended() && !json,
installRuntime: args["on-device"] === true, installRuntime: args["on-device"] === true,
registry: config.registry, registry: config.registry,
registryNames, registryNames,
@@ -725,7 +725,7 @@ type LocalMode = "local-model" | "words";
* person to prompt instead, or when that person has already answered. * person to prompt instead, or when that person has already answered.
*/ */
function localModelHint(json: boolean, status: LocalModelStatus | undefined): string | null { 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; if (status?.status !== "not-asked") return null;
return nonInteractiveConsentMessage(); return nonInteractiveConsentMessage();
} }
@@ -743,7 +743,7 @@ async function offerLocalModel(
registryBaseUrl: string, registryBaseUrl: string,
artifactRevision?: string, artifactRevision?: string,
): Promise<void> { ): Promise<void> {
if (json || !isAttendedTerminal()) return; if (json || isUnattended()) return;
if (localModelStatus().status !== "not-asked") return; if (localModelStatus().status !== "not-asked") return;
// Deliberately not gated on the number of results. That gate was set when the // 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 // catalog was small; against 411 moves a word match nearly always returns
+4 -3
View File
@@ -11,6 +11,7 @@ import { createCloudClient } from "../../cloud/index.js";
import { reportApiError } from "../../cloud/errors.js"; import { reportApiError } from "../../cloud/errors.js";
import { withMeta } from "../../utils/updateCheck.js"; import { withMeta } from "../../utils/updateCheck.js";
import { c } from "../../ui/colors.js"; import { c } from "../../ui/colors.js";
import { isUnattended } from "../../utils/attendedTerminal.js";
import { errorBox } from "../../ui/format.js"; import { errorBox } from "../../ui/format.js";
export default defineCommand({ export default defineCommand({
@@ -48,10 +49,10 @@ export default defineCommand({
// safety check. Force the caller to opt in via `--no-confirm` // safety check. Force the caller to opt in via `--no-confirm`
// so cron jobs, CI shells, and JSON consumers can't soft-delete // so cron jobs, CI shells, and JSON consumers can't soft-delete
// by accident. // by accident.
if (args.json || !process.stdin.isTTY) { if (args.json || isUnattended()) {
errorBox( errorBox(
"Confirmation required", "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.", "Re-run with --no-confirm to acknowledge the irreversible delete.",
); );
failCommand(); failCommand();
@@ -87,7 +88,7 @@ export default defineCommand({
}); });
async function confirmDelete(id: string): Promise<boolean> { async function confirmDelete(id: string): Promise<boolean> {
const clack = await import("@clack/prompts"); const clack = await import("../../ui/prompts.js");
const answer = await clack.confirm({ const answer = await clack.confirm({
message: `Delete render ${id}? This is irreversible.`, message: `Delete render ${id}? This is irreversible.`,
initialValue: false, initialValue: false,
+3 -2
View File
@@ -2,7 +2,7 @@ import { failCommand } from "../utils/commandResult.js";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { defineCommand } from "../utils/defineCommand.js"; import { defineCommand } from "../utils/defineCommand.js";
import * as clack from "@clack/prompts"; import * as clack from "../ui/prompts.js";
import open from "open"; import open from "open";
import type { Example } from "./_examples.js"; import type { Example } from "./_examples.js";
import { import {
@@ -18,6 +18,7 @@ import { submitCatalogSearchMiss, submitFeedback } from "../utils/submitFeedback
import { buildIssueUrl, HYPERFRAMES_REPO_URL } from "../utils/feedbackIssue.js"; import { buildIssueUrl, HYPERFRAMES_REPO_URL } from "../utils/feedbackIssue.js";
import { VERSION } from "../version.js"; import { VERSION } from "../version.js";
import { c } from "../ui/colors.js"; import { c } from "../ui/colors.js";
import { isUnattended } from "../utils/attendedTerminal.js";
import { parseFeedbackRating } from "../utils/feedbackRating.js"; import { parseFeedbackRating } from "../utils/feedbackRating.js";
import { feedbackEmail, parseFeedbackSource } from "../utils/feedbackSource.js"; import { feedbackEmail, parseFeedbackSource } from "../utils/feedbackSource.js";
import { lintFeedbackComment, type FeedbackLintInput } from "../utils/feedbackLint.js"; import { lintFeedbackComment, type FeedbackLintInput } from "../utils/feedbackLint.js";
@@ -88,7 +89,7 @@ async function promptConfirm(): Promise<boolean> {
async function confirmFileIssue(dir: string, yes: boolean): Promise<boolean> { async function confirmFileIssue(dir: string, yes: boolean): Promise<boolean> {
printIssueConsent(dir); printIssueConsent(dir);
if (yes) return true; 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`); console.log(` ${c.dim("Re-run with --yes to publish the repro and file the issue.")}\n`);
return false; return false;
} }
@@ -6,6 +6,11 @@ import { runCommand } from "citty";
const prompts = vi.hoisted(() => ({ errors: [] as string[], typedName: "" })); 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<typeof import("../utils/attendedTerminal.js")>()),
isUnattended: () => false,
}));
vi.mock("@clack/prompts", () => ({ vi.mock("@clack/prompts", () => ({
intro: () => undefined, intro: () => undefined,
cancel: () => undefined, cancel: () => undefined,
+3 -2
View File
@@ -33,8 +33,9 @@ import { copyDirSync } from "../utils/copyDir.js";
import { resolve, basename, join, dirname } from "node:path"; import { resolve, basename, join, dirname } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { execFileSync, spawn } from "node:child_process"; 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 { c } from "../ui/colors.js";
import { isUnattended } from "../utils/attendedTerminal.js";
import { printBanner } from "../ui/banner.js"; import { printBanner } from "../ui/banner.js";
import { import {
BUNDLED_TEMPLATES, BUNDLED_TEMPLATES,
@@ -825,7 +826,7 @@ export default defineCommand({
modelFlag ?? DEFAULT_MODEL, modelFlag ?? DEFAULT_MODEL,
languageFlag, languageFlag,
); );
const interactive = !nonInteractive && process.stdout.isTTY === true; const interactive = !nonInteractive && !isUnattended();
let resolutionPreset: CanvasResolution | undefined; let resolutionPreset: CanvasResolution | undefined;
if (args.resolution !== undefined) { if (args.resolution !== undefined) {
+1 -1
View File
@@ -1,5 +1,5 @@
import { defineCommand } from "../utils/defineCommand.js"; 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 type { Example } from "./_examples.js";
import { c } from "../ui/colors.js"; import { c } from "../ui/colors.js";
import { formatBytes } from "../ui/format.js"; import { formatBytes } from "../ui/format.js";
+1 -1
View File
@@ -21,7 +21,7 @@ export const examples: Example[] = [
import { resolve } from "node:path"; import { resolve } from "node:path";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { requestSubPath } from "@hyperframes/studio-server"; 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 { c } from "../ui/colors.js";
import { resolveProject, type ProjectDir } from "../utils/project.js"; import { resolveProject, type ProjectDir } from "../utils/project.js";
import { resolveAutoProxy } from "../utils/projectConfig.js"; import { resolveAutoProxy } from "../utils/projectConfig.js";
+1 -1
View File
@@ -11,7 +11,7 @@ export const examples: Example[] = [
["Open with a specific browser", "hyperframes present --browser-path /usr/bin/chromium"], ["Open with a specific browser", "hyperframes present --browser-path /usr/bin/chromium"],
]; ];
import { resolve } from "node:path"; 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 { c } from "../ui/colors.js";
import { resolveProject, type ProjectDir } from "../utils/project.js"; import { resolveProject, type ProjectDir } from "../utils/project.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js"; import { normalizeErrorMessage } from "../utils/errorMessage.js";
@@ -5,6 +5,11 @@ const ui = vi.hoisted(() => ({
spinner: { start: vi.fn(), stop: vi.fn(), message: vi.fn() }, spinner: { start: vi.fn(), stop: vi.fn(), message: vi.fn() },
error: 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<typeof import("../utils/attendedTerminal.js")>()),
isUnattended: () => false,
}));
vi.mock("@clack/prompts", async (importOriginal) => { vi.mock("@clack/prompts", async (importOriginal) => {
const actual = await importOriginal<typeof import("@clack/prompts")>(); const actual = await importOriginal<typeof import("@clack/prompts")>();
return { return {
+1 -1
View File
@@ -37,7 +37,7 @@ import { existsSync, lstatSync, mkdirSync, readlinkSync, symlinkSync, unlinkSync
import { resolve, dirname, basename, join } from "node:path"; import { resolve, dirname, basename, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { createRequire } from "node:module"; 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 { c } from "../ui/colors.js";
import { desktopHint, insideHostApp } from "../utils/desktopApp.js"; import { desktopHint, insideHostApp } from "../utils/desktopApp.js";
import { isDevMode } from "../utils/env.js"; import { isDevMode } from "../utils/env.js";
+5 -1
View File
@@ -2,10 +2,11 @@ import { join, posix, relative, resolve } from "node:path";
import { failCommand, setCommandExitCode } from "../utils/commandResult.js"; import { failCommand, setCommandExitCode } from "../utils/commandResult.js";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { defineCommand } from "../utils/defineCommand.js"; 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 type { Example } from "./_examples.js";
import { c } from "../ui/colors.js"; import { c } from "../ui/colors.js";
import { isUnattended } from "../utils/attendedTerminal.js";
import { import {
definitiveEntryMismatchComposition, definitiveEntryMismatchComposition,
hasDefinitiveEntryMismatch, hasDefinitiveEntryMismatch,
@@ -94,6 +95,9 @@ export default defineCommand({
}, },
}, },
async run({ args }) { async run({ args }) {
if (args.yes !== true && isUnattended()) {
clack.confirmationRequired("hyperframes publish", "Pass --yes to publish without asking.");
}
const rawArg = args.dir; const rawArg = args.dir;
const dir = resolve(rawArg ?? "."); const dir = resolve(rawArg ?? ".");
const indexPath = join(dir, "index.html"); const indexPath = join(dir, "index.html");
@@ -1,7 +1,7 @@
import { failCommand, failUsage } from "../utils/commandResult.js"; import { failCommand, failUsage } from "../utils/commandResult.js";
import { defineCommand } from "../utils/defineCommand.js"; import { defineCommand } from "../utils/defineCommand.js";
import { existsSync } from "node:fs"; 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 { c } from "../ui/colors.js";
import { import {
DEFAULT_MODEL, DEFAULT_MODEL,
+1 -1
View File
@@ -217,7 +217,7 @@ async function ensureRenderBrowser(plan: RenderPlan, signal?: AbortSignal): Prom
if (plan.effectiveQuiet) { if (plan.effectiveQuiet) {
return (await ensureBrowser({ preferManagedChrome: true, signal })).executablePath; return (await ensureBrowser({ preferManagedChrome: true, signal })).executablePath;
} }
const clack = await import("@clack/prompts"); const clack = await import("../../ui/prompts.js");
browserSpinner = clack.spinner(); browserSpinner = clack.spinner();
browserSpinner.start("Checking browser..."); browserSpinner.start("Checking browser...");
const info = await ensureBrowser({ const info = await ensureBrowser({
+1 -1
View File
@@ -1,7 +1,7 @@
import { setCommandExitCode, CliResultSignal } from "../utils/commandResult.js"; import { setCommandExitCode, CliResultSignal } from "../utils/commandResult.js";
import { defineCommand } from "../utils/defineCommand.js"; import { defineCommand } from "../utils/defineCommand.js";
import { execFileSync, spawn } from "node:child_process"; 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 { c } from "../ui/colors.js";
import { diag } from "../ui/diagnostics.js"; import { diag } from "../ui/diagnostics.js";
import { buildNpxCommand } from "../utils/npxCommand.js"; import { buildNpxCommand } from "../utils/npxCommand.js";
+1 -1
View File
@@ -2,7 +2,7 @@ import { createProgressWriter } from "../whisper/progress.js";
import { failCommand, setCommandExitCode } from "../utils/commandResult.js"; import { failCommand, setCommandExitCode } from "../utils/commandResult.js";
import { defineCommand } from "../utils/defineCommand.js"; import { defineCommand } from "../utils/defineCommand.js";
import type { Example } from "./_examples.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 { c } from "../ui/colors.js";
import { DEFAULT_MODEL } from "../whisper/manager.js"; import { DEFAULT_MODEL } from "../whisper/manager.js";
import { trackTranscribeUnavailable } from "../telemetry/events.js"; import { trackTranscribeUnavailable } from "../telemetry/events.js";
+1 -1
View File
@@ -22,7 +22,7 @@ export const examples: Example[] = [
["List available voices", "hyperframes tts --list"], ["List available voices", "hyperframes tts --list"],
]; ];
import { resolve } from "node:path"; 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 { c } from "../ui/colors.js";
import { errorBox } from "../ui/format.js"; import { errorBox } from "../ui/format.js";
import { import {
+8 -1
View File
@@ -1,11 +1,12 @@
import { setCommandExitCode } from "../utils/commandResult.js"; import { setCommandExitCode } from "../utils/commandResult.js";
import { defineCommand } from "../utils/defineCommand.js"; import { defineCommand } from "../utils/defineCommand.js";
import type { Example } from "./_examples.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 { execFileSync } from "node:child_process";
import { existsSync, readFileSync, writeFileSync, renameSync, readdirSync } from "node:fs"; import { existsSync, readFileSync, writeFileSync, renameSync, readdirSync } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { c } from "../ui/colors.js"; import { c } from "../ui/colors.js";
import { isUnattended } from "../utils/attendedTerminal.js";
import { rewritePinnedHyperframesText, rewriteProjectPinnedScripts } from "../utils/projectPin.js"; import { rewritePinnedHyperframesText, rewriteProjectPinnedScripts } from "../utils/projectPin.js";
export const examples: Example[] = [ export const examples: Example[] = [
@@ -59,6 +60,12 @@ export default defineCommand({
} }
const autoYes = args.yes === true; 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")); clack.intro(c.bold("hyperframes upgrade"));
const s = clack.spinner(); const s = clack.spinner();
+67
View File
@@ -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 });
});
});
+74
View File
@@ -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<boolean | symbol> {
assertCanPrompt();
return clack.confirm(opts);
}
export function select<Value>(opts: SelectOptions<Value>): Promise<Value | symbol> {
assertCanPrompt();
return clack.select(opts);
}
export function text(opts: TextOptions): Promise<string | symbol> {
assertCanPrompt();
return clack.text(opts);
}
export function password(opts: PasswordOptions): Promise<string | symbol> {
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);
}
+17
View File
@@ -26,6 +26,7 @@ function setupMocks(opts: {
config?: ConfigShape; config?: ConfigShape;
env?: Record<string, string | undefined>; env?: Record<string, string | undefined>;
writeFails?: boolean; writeFails?: boolean;
unattended?: boolean;
}): { }): {
writeSpy: ReturnType<typeof vi.fn>; writeSpy: ReturnType<typeof vi.fn>;
spawnSpy: ReturnType<typeof vi.fn>; spawnSpy: ReturnType<typeof vi.fn>;
@@ -50,6 +51,10 @@ function setupMocks(opts: {
writeConfig: writeSpy, writeConfig: writeSpy,
})); }));
vi.doMock("./env.js", () => ({ isDevMode: () => !!opts.devMode })); vi.doMock("./env.js", () => ({ isDevMode: () => !!opts.devMode }));
vi.doMock("./attendedTerminal.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./attendedTerminal.js")>()),
isUnattended: () => opts.unattended ?? false,
}));
vi.doMock("./installerDetection.js", () => ({ vi.doMock("./installerDetection.js", () => ({
detectInstaller: () => ({ detectInstaller: () => ({
kind: opts.installer.kind, kind: opts.installer.kind,
@@ -167,6 +172,18 @@ describe("scheduleBackgroundInstall", () => {
expect(spawnSpy).not.toHaveBeenCalled(); 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 () => { it("skips when HYPERFRAMES_NO_AUTO_INSTALL=1", async () => {
const { spawnSpy } = setupMocks({ const { spawnSpy } = setupMocks({
installer: { kind: "npm", command: "npm install -g hyperframes@0.4.4" }, installer: { kind: "npm", command: "npm install -g hyperframes@0.4.4" },
+5 -2
View File
@@ -14,7 +14,7 @@
* Guardrails: * Guardrails:
* - Never auto-update across major versions. The user opts in explicitly * - Never auto-update across major versions. The user opts in explicitly
* via `hyperframes upgrade`. * 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` * or when `HYPERFRAMES_NO_AUTO_INSTALL` / `HYPERFRAMES_NO_UPDATE_CHECK`
* is set. * is set.
* - If a previous install is still in flight (less than 10 min old), don't * - 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 { compareVersions } from "compare-versions";
import { withFileLock } from "../media-use/lib/config-lock.mjs"; import { withFileLock } from "../media-use/lib/config-lock.mjs";
import { readConfig, writeConfig } from "../telemetry/config.js"; import { readConfig, writeConfig } from "../telemetry/config.js";
import { isUnattended } from "./attendedTerminal.js";
import { updateCheckDisabled } from "./updateCheck.js"; import { updateCheckDisabled } from "./updateCheck.js";
import { RUNNING_DIR, RUNNING_STALE_MS } from "./runningCli.js"; import { RUNNING_DIR, RUNNING_STALE_MS } from "./runningCli.js";
import { import {
@@ -47,7 +48,9 @@ const INSTALL_MAX_WAIT_MS = 60 * 60 * 1000;
const INSTALL_POLL_MS = 2_000; const INSTALL_POLL_MS = 2_000;
function isAutoInstallDisabled(): boolean { 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. */ /** Parse a semver-ish string's major number; returns NaN for pre-releases etc. */
+28 -1
View File
@@ -32,9 +32,15 @@ async function noticeWith(opts: {
latestVersion?: string; latestVersion?: string;
isTTY?: boolean; isTTY?: boolean;
env?: Record<string, string | undefined>; env?: Record<string, string | undefined>;
unattended?: boolean;
pendingVersion?: string;
}): Promise<string> { }): Promise<string> {
vi.resetModules(); vi.resetModules();
vi.doMock("./env.js", () => ({ isDevMode: () => false })); vi.doMock("./env.js", () => ({ isDevMode: () => false }));
vi.doMock("./attendedTerminal.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./attendedTerminal.js")>()),
isUnattended: () => opts.unattended ?? false,
}));
vi.doMock("./installerDetection.js", () => ({ vi.doMock("./installerDetection.js", () => ({
detectInstaller: () => ({ detectInstaller: () => ({
kind: opts.installerCommand ? "npm" : "skip", kind: opts.installerCommand ? "npm" : "skip",
@@ -43,7 +49,14 @@ async function noticeWith(opts: {
}), }),
})); }));
vi.doMock("../telemetry/config.js", () => ({ 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: () => {}, writeConfig: () => {},
})); }));
@@ -83,6 +96,7 @@ async function noticeWith(opts: {
describe("printUpdateNotice — install-method-aware command", () => { describe("printUpdateNotice — install-method-aware command", () => {
afterEach(() => { afterEach(() => {
vi.doUnmock("./env.js"); vi.doUnmock("./env.js");
vi.doUnmock("./attendedTerminal.js");
vi.doUnmock("./installerDetection.js"); vi.doUnmock("./installerDetection.js");
vi.doUnmock("../telemetry/config.js"); vi.doUnmock("../telemetry/config.js");
vi.resetModules(); vi.resetModules();
@@ -105,6 +119,19 @@ describe("printUpdateNotice — install-method-aware command", () => {
expect(out).toBe(""); 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 () => { it("is suppressed in CI", async () => {
const out = await noticeWith({ const out = await noticeWith({
installerCommand: "brew upgrade hyperframes", installerCommand: "brew upgrade hyperframes",
+4 -2
View File
@@ -3,6 +3,7 @@ import { join } from "node:path";
import { compareVersions } from "compare-versions"; import { compareVersions } from "compare-versions";
import { readConfig, readConfigFresh, writeConfig } from "../telemetry/config.js"; import { readConfig, readConfigFresh, writeConfig } from "../telemetry/config.js";
import { VERSION } from "../version.js"; import { VERSION } from "../version.js";
import { isCI, isUnattended } from "./attendedTerminal.js";
import { isDevMode } from "./env.js"; import { isDevMode } from "./env.js";
import { hostAnswers } from "./hostAnswers.js"; import { hostAnswers } from "./hostAnswers.js";
import { detectInstaller } from "./installerDetection.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. */ /** True when the update check is off: dev mode, CI, or the HYPERFRAMES_NO_UPDATE_CHECK opt-out. */
export function updateCheckDisabled(): boolean { export function updateCheckDisabled(): boolean {
if (isDevMode()) return true; 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"; 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. * terminal. Shared with the skills freshness notice so both honour the same gating.
*/ */
export function updateNoticesSuppressed(): boolean { 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(); const meta = getUpdateMeta();
if (!meta.updateAvailable || !meta.latestVersion) return; if (!meta.updateAvailable || !meta.latestVersion) return;
if (readConfig().pendingUpdate?.version === meta.latestVersion) return;
// Show the command that updates *this* install: the detected package // Show the command that updates *this* install: the detected package
// manager's upgrade for owned global installs (npm/bun/pnpm/brew), and the // manager's upgrade for owned global installs (npm/bun/pnpm/brew), and the