From d88cc37f5a9766921eb8dc155cb59e3971834847 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 20 Aug 2026 13:46:30 +0100 Subject: [PATCH 1/3] fix(core): abort prompts on stdin EOF instead of spinning at 100% CPU --- .changeset/prompt-eof-spin.md | 5 + AGENTS.md | 2 +- CLAUDE.md | 2 + packages/cli/src/commands/apps/docker.ts | 3 +- .../cli/src/commands/apps/endpoints/add.ts | 3 +- packages/cli/src/commands/apps/link.ts | 3 +- packages/cli/src/commands/apps/suggestions.ts | 2 +- packages/cli/src/commands/apps/walkthrough.ts | 3 +- packages/cli/src/commands/auth/login.ts | 2 +- packages/cli/src/commands/db/create.ts | 13 +- packages/cli/src/commands/db/delete.ts | 1 + packages/cli/src/commands/db/link.ts | 3 +- packages/cli/src/commands/db/quickstart.ts | 3 +- packages/cli/src/commands/db/regions/add.ts | 3 +- .../cli/src/commands/db/regions/remove.ts | 3 +- .../cli/src/commands/db/regions/update.ts | 3 +- packages/cli/src/commands/db/resolve-db.ts | 3 +- packages/cli/src/commands/db/tokens/create.ts | 3 +- .../cli/src/commands/db/tokens/invalidate.ts | 8 +- packages/cli/src/commands/dns/interactive.ts | 11 +- packages/cli/src/commands/dns/record/add.ts | 3 +- .../cli/src/commands/dns/record/import.ts | 3 +- .../cli/src/commands/dns/record/preset.ts | 2 +- .../cli/src/commands/dns/record/update.ts | 3 +- packages/cli/src/commands/dns/record/write.ts | 3 +- .../cli/src/commands/dns/scripts/attach.ts | 3 +- .../cli/src/commands/dns/scripts/create.ts | 4 +- .../cli/src/commands/dns/scripts/deploy.ts | 3 +- packages/cli/src/commands/dns/scripts/init.ts | 9 +- .../src/commands/dns/scripts/interactive.ts | 3 +- packages/cli/src/commands/dns/scripts/link.ts | 3 +- packages/cli/src/commands/dns/zone/add.ts | 3 +- packages/cli/src/commands/registries/add.ts | 3 +- .../cli/src/commands/registries/update.ts | 3 +- packages/cli/src/commands/sandbox/create.ts | 3 +- packages/cli/src/commands/scripts/api.ts | 4 +- packages/cli/src/commands/scripts/create.ts | 4 +- .../cli/src/commands/scripts/env/remove.ts | 3 +- packages/cli/src/commands/scripts/env/set.ts | 3 +- packages/cli/src/commands/scripts/init.ts | 12 +- .../cli/src/commands/scripts/interactive.ts | 7 +- packages/cli/src/commands/scripts/link.ts | 3 +- .../cli/src/commands/sites/ci/scaffold.ts | 10 +- packages/cli/src/commands/sites/create.ts | 5 +- packages/cli/src/commands/sites/deploy.ts | 7 +- .../cli/src/commands/sites/interactive.ts | 9 +- packages/cli/src/commands/sites/provision.ts | 3 +- .../cli/src/commands/storage/connection.ts | 5 +- .../cli/src/commands/storage/interactive.ts | 8 +- packages/cli/src/commands/storage/zone/add.ts | 14 +- .../cli/src/commands/storage/zone/remove.ts | 3 +- .../cli/src/commands/storage/zone/update.ts | 6 +- packages/cli/src/core/hostnames/bunny-dns.ts | 8 +- packages/cli/src/core/hostnames/flow.ts | 5 +- packages/cli/src/core/ui.test.ts | 58 +++++++ packages/cli/src/core/ui.ts | 144 ++++++++++++++---- skills/bunny-cli/SKILL.md | 2 +- 57 files changed, 302 insertions(+), 151 deletions(-) create mode 100644 .changeset/prompt-eof-spin.md diff --git a/.changeset/prompt-eof-spin.md b/.changeset/prompt-eof-spin.md new file mode 100644 index 00000000..404e4ab1 --- /dev/null +++ b/.changeset/prompt-eof-spin.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": patch +--- + +fix(core): prompts no longer spin at 100% CPU forever when stdin closes before an answer (CI, cron, `< /dev/null`); every prompt now aborts fast at EOF, input prompts and destructive confirmations exit non-zero with a hint naming the flag to pass (`--force` or the value flag), offer-style prompts decline and continue, and piped answers keep working diff --git a/AGENTS.md b/AGENTS.md index 5e0f772c..3575bd48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,7 +206,7 @@ bunny-cli/ │ │ ├── stats.ts # Shared stats rendering: sumChart(), renderBarChart(), formatBucketLabel() (UTC date labels), BAR_WIDTH (used by dns/zone/stats + scripts/stats) │ │ ├── stats.test.ts # Tests for stats helpers │ │ ├── types.ts # GlobalArgs, OutputFormat, and shared type definitions -│ │ ├── ui.ts # readPassword(), confirm(), confirmTyped(), requireConfirmable() (unattended runs must pass --force instead of hanging on a prompt), spinner() wrappers +│ │ ├── ui.ts # prompts() EOF-safe wrapper (always import it from here, never from the prompts package: raw prompts spins at 100% CPU when stdin hits EOF), readPassword(), confirm() (gates throw with exit 1 when stdin closes unanswered; pass optional: true for offer prompts that should decline and continue), confirmTyped(), requireConfirmable() (unattended runs must pass --force instead of hanging on a prompt), spinner() wrappers │ │ ├── ui.test.ts # Tests for requireConfirmable (no-TTY guard, --force bypass) │ │ └── version.ts # VERSION constant from package.json │ │ diff --git a/CLAUDE.md b/CLAUDE.md index 955993b7..2464ee9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,8 @@ This is a Bun workspace monorepo with five packages: - Use `formatTable()` / `formatKeyValue()` from `packages/cli/src/core/format.ts` for non-JSON output. - Handle `--output json` first in every handler, then pass `output` to format functions. - Use `logger` from `packages/cli/src/core/logger.ts` for all user-facing output. +- Import `prompts` from `packages/cli/src/core/ui.ts`, never from the `prompts` package directly: the wrapper aborts safely when stdin is at EOF, while the raw library spins at 100% CPU. +- `confirm()` is a gate by default: if stdin closes before an answer it throws, so the command exits non-zero. Pass `optional: true` only for offer-style prompts (link this directory? save to .env?) where declining is a normal outcome and the command should continue. - Throw `UserError` for expected errors. - Import API clients from `@bunny.net/openapi-client`, not relative paths. Import generated types from the per-API entrypoints (`@bunny.net/openapi-client/`, e.g. `@bunny.net/openapi-client/core`); the older `generated/.d.ts` paths remain supported. - Use `clientOptions(config, verbose)` from `packages/cli/src/core/client-options.ts` when creating API clients in command handlers. diff --git a/packages/cli/src/commands/apps/docker.ts b/packages/cli/src/commands/apps/docker.ts index 32c0e48f..c8f104dc 100644 --- a/packages/cli/src/commands/apps/docker.ts +++ b/packages/cli/src/commands/apps/docker.ts @@ -3,12 +3,11 @@ import { homedir } from "node:os"; import { basename, isAbsolute, join, resolve } from "node:path"; import type { createMcClient } from "@bunny.net/openapi-client"; import type { components } from "@bunny.net/openapi-client/generated/magic-containers.d.ts"; -import prompts from "prompts"; import { resolveRegistryEndpoint } from "../../core/bunny-registry.ts"; import { dockerLogin, imageHostname } from "../../core/docker.ts"; import { ApiError, UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; -import { spinner } from "../../core/ui.ts"; +import { prompts, spinner } from "../../core/ui.ts"; export { dockerLogin, diff --git a/packages/cli/src/commands/apps/endpoints/add.ts b/packages/cli/src/commands/apps/endpoints/add.ts index 6d17ec4e..17995924 100644 --- a/packages/cli/src/commands/apps/endpoints/add.ts +++ b/packages/cli/src/commands/apps/endpoints/add.ts @@ -1,11 +1,10 @@ import { createMcClient } from "@bunny.net/openapi-client"; -import prompts from "prompts"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; -import { spinner } from "../../../core/ui.ts"; +import { prompts, spinner } from "../../../core/ui.ts"; import { resolveAppId, resolveContainerId } from "../config.ts"; const COMMAND = "add"; diff --git a/packages/cli/src/commands/apps/link.ts b/packages/cli/src/commands/apps/link.ts index 11f3b13b..ec34f532 100644 --- a/packages/cli/src/commands/apps/link.ts +++ b/packages/cli/src/commands/apps/link.ts @@ -1,13 +1,12 @@ import { createMcClient } from "@bunny.net/openapi-client"; import type { components } from "@bunny.net/openapi-client/generated/magic-containers.d.ts"; -import prompts from "prompts"; import { resolveConfig } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; import { loadManifest, saveManifest } from "../../core/manifest.ts"; -import { confirm, spinner } from "../../core/ui.ts"; +import { confirm, prompts, spinner } from "../../core/ui.ts"; import { APP_MANIFEST, type AppManifest } from "./constants.ts"; type Application = components["schemas"]["Application"]; diff --git a/packages/cli/src/commands/apps/suggestions.ts b/packages/cli/src/commands/apps/suggestions.ts index 9bcce8ca..f4e9a2ea 100644 --- a/packages/cli/src/commands/apps/suggestions.ts +++ b/packages/cli/src/commands/apps/suggestions.ts @@ -1,5 +1,5 @@ import type { components } from "@bunny.net/openapi-client/generated/magic-containers.d.ts"; -import prompts from "prompts"; +import { prompts } from "../../core/ui.ts"; import type { ContainerConfig } from "./config.ts"; type EndpointRequest = components["schemas"]["EndpointRequest"]; diff --git a/packages/cli/src/commands/apps/walkthrough.ts b/packages/cli/src/commands/apps/walkthrough.ts index 739b49fd..038fa9d1 100644 --- a/packages/cli/src/commands/apps/walkthrough.ts +++ b/packages/cli/src/commands/apps/walkthrough.ts @@ -1,9 +1,8 @@ import { existsSync, readFileSync } from "node:fs"; import { basename, dirname, isAbsolute, relative, resolve } from "node:path"; -import prompts from "prompts"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; -import { spinner } from "../../core/ui.ts"; +import { prompts, spinner } from "../../core/ui.ts"; import { composeToConfig, findComposeFile, diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index cb05d75a..913df3f7 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -1,6 +1,5 @@ import { randomBytes } from "node:crypto"; import { createCoreClient } from "@bunny.net/openapi-client"; -import prompts from "prompts"; import { profileExists, resolveConfig, @@ -15,6 +14,7 @@ import { confirm, isInteractive, openBrowser, + prompts, readPassword, spinner, } from "../../core/ui.ts"; diff --git a/packages/cli/src/commands/db/create.ts b/packages/cli/src/commands/db/create.ts index 61d3d9af..a1f1566d 100644 --- a/packages/cli/src/commands/db/create.ts +++ b/packages/cli/src/commands/db/create.ts @@ -1,6 +1,5 @@ import { createDbClient } from "@bunny.net/openapi-client"; import type { components } from "@bunny.net/openapi-client/generated/database.d.ts"; -import prompts from "prompts"; import { resolveConfig } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; @@ -8,7 +7,7 @@ import { UserError } from "../../core/errors.ts"; import { formatKeyValue } from "../../core/format.ts"; import { logger } from "../../core/logger.ts"; import { loadManifest, saveManifest } from "../../core/manifest.ts"; -import { confirm, spinner } from "../../core/ui.ts"; +import { confirm, prompts, spinner } from "../../core/ui.ts"; import { readEnvValue, writeEnvValue } from "../../utils/env-file.ts"; import { fetchRegionConfig, generateToken } from "./api.ts"; import { @@ -365,7 +364,7 @@ export const dbCreateCommand = defineCommand({ if (linkArg !== undefined) { shouldLink = linkArg; } else if (isInteractive) { - shouldLink = await confirm(linkPrompt, { force: false }); + shouldLink = await confirm(linkPrompt, { force: false, optional: true }); } else { shouldLink = false; } @@ -389,6 +388,7 @@ export const dbCreateCommand = defineCommand({ } else if (isInteractive) { shouldCreateToken = await confirm("Create an auth token?", { force: false, + optional: true, }); } else { shouldCreateToken = false; @@ -434,10 +434,13 @@ export const dbCreateCommand = defineCommand({ if (existingToken) { shouldWrite = await confirm( `${ENV_DATABASE_AUTH_TOKEN} already exists in ${existingToken.envPath} — overwrite?`, - { force: false }, + { force: false, optional: true }, ); } else { - shouldWrite = await confirm(`Save to .env?`, { force: false }); + shouldWrite = await confirm(`Save to .env?`, { + force: false, + optional: true, + }); } } else { shouldWrite = false; diff --git a/packages/cli/src/commands/db/delete.ts b/packages/cli/src/commands/db/delete.ts index c6b90cbf..9169912b 100644 --- a/packages/cli/src/commands/db/delete.ts +++ b/packages/cli/src/commands/db/delete.ts @@ -143,6 +143,7 @@ export const dbDeleteCommand = defineCommand({ if (envUrl && db.url && envUrl.value === db.url) { const shouldClean = await confirm( `Remove ${ENV_DATABASE_URL} from ${envUrl.envPath}?`, + { optional: true }, ); if (shouldClean) { diff --git a/packages/cli/src/commands/db/link.ts b/packages/cli/src/commands/db/link.ts index 09172960..bb83b6de 100644 --- a/packages/cli/src/commands/db/link.ts +++ b/packages/cli/src/commands/db/link.ts @@ -1,12 +1,11 @@ import { createDbClient } from "@bunny.net/openapi-client"; -import prompts from "prompts"; import { resolveConfig } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; import { saveManifest } from "../../core/manifest.ts"; -import { spinner } from "../../core/ui.ts"; +import { prompts, spinner } from "../../core/ui.ts"; import { fetchAllDatabases, fetchDatabase } from "./api.ts"; import { ARG_DATABASE_ID, diff --git a/packages/cli/src/commands/db/quickstart.ts b/packages/cli/src/commands/db/quickstart.ts index 9db9d810..8980fde7 100644 --- a/packages/cli/src/commands/db/quickstart.ts +++ b/packages/cli/src/commands/db/quickstart.ts @@ -1,12 +1,11 @@ import { createDbClient } from "@bunny.net/openapi-client"; import chalk from "chalk"; -import prompts from "prompts"; import { resolveConfig } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; -import { spinner } from "../../core/ui.ts"; +import { prompts, spinner } from "../../core/ui.ts"; import { readEnvValue } from "../../utils/env-file.ts"; import { generateToken } from "./api.ts"; import { diff --git a/packages/cli/src/commands/db/regions/add.ts b/packages/cli/src/commands/db/regions/add.ts index ec33709a..337d5621 100644 --- a/packages/cli/src/commands/db/regions/add.ts +++ b/packages/cli/src/commands/db/regions/add.ts @@ -1,12 +1,11 @@ import { createDbClient } from "@bunny.net/openapi-client"; import type { components } from "@bunny.net/openapi-client/generated/database.d.ts"; -import prompts from "prompts"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { formatTable } from "../../../core/format.ts"; import { logger } from "../../../core/logger.ts"; -import { spinner } from "../../../core/ui.ts"; +import { prompts, spinner } from "../../../core/ui.ts"; import { fetchDatabaseWithRegions, regionNameMap } from "../api.ts"; import { ARG_DATABASE_ID } from "../constants.ts"; import { groupedRegionChoices } from "../region-choices.ts"; diff --git a/packages/cli/src/commands/db/regions/remove.ts b/packages/cli/src/commands/db/regions/remove.ts index 3e565835..971b3a61 100644 --- a/packages/cli/src/commands/db/regions/remove.ts +++ b/packages/cli/src/commands/db/regions/remove.ts @@ -1,12 +1,11 @@ import { createDbClient } from "@bunny.net/openapi-client"; -import prompts from "prompts"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { formatTable } from "../../../core/format.ts"; import { logger } from "../../../core/logger.ts"; -import { confirm, spinner } from "../../../core/ui.ts"; +import { confirm, prompts, spinner } from "../../../core/ui.ts"; import { fetchDatabaseWithRegions, regionNameMap } from "../api.ts"; import { ARG_DATABASE_ID } from "../constants.ts"; import { resolveDbId } from "../resolve-db.ts"; diff --git a/packages/cli/src/commands/db/regions/update.ts b/packages/cli/src/commands/db/regions/update.ts index 84cfb434..f489d119 100644 --- a/packages/cli/src/commands/db/regions/update.ts +++ b/packages/cli/src/commands/db/regions/update.ts @@ -1,13 +1,12 @@ import { createDbClient } from "@bunny.net/openapi-client"; import type { components } from "@bunny.net/openapi-client/generated/database.d.ts"; -import prompts from "prompts"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { formatTable } from "../../../core/format.ts"; import { logger } from "../../../core/logger.ts"; -import { spinner } from "../../../core/ui.ts"; +import { prompts, spinner } from "../../../core/ui.ts"; import { fetchDatabaseWithRegions, regionNameMap } from "../api.ts"; import { ARG_DATABASE_ID } from "../constants.ts"; import { groupedRegionChoices } from "../region-choices.ts"; diff --git a/packages/cli/src/commands/db/resolve-db.ts b/packages/cli/src/commands/db/resolve-db.ts index 9aa211bd..7182e419 100644 --- a/packages/cli/src/commands/db/resolve-db.ts +++ b/packages/cli/src/commands/db/resolve-db.ts @@ -1,9 +1,8 @@ import type { createDbClient } from "@bunny.net/openapi-client"; import type { components } from "@bunny.net/openapi-client/generated/database.d.ts"; -import prompts from "prompts"; import { UserError } from "../../core/errors.ts"; import { loadManifest } from "../../core/manifest.ts"; -import { spinner } from "../../core/ui.ts"; +import { prompts, spinner } from "../../core/ui.ts"; import { readEnvValue } from "../../utils/env-file.ts"; import { fetchAllDatabases } from "./api.ts"; import { diff --git a/packages/cli/src/commands/db/tokens/create.ts b/packages/cli/src/commands/db/tokens/create.ts index 86a65558..45bc88a3 100644 --- a/packages/cli/src/commands/db/tokens/create.ts +++ b/packages/cli/src/commands/db/tokens/create.ts @@ -242,11 +242,12 @@ export const dbTokensCreateCommand = defineCommand<{ if (existingToken) { shouldWrite = await confirm( `${ENV_DATABASE_AUTH_TOKEN} already exists in ${existingToken.envPath} — overwrite?`, - { force }, + { force, optional: true }, ); } else { shouldWrite = await confirm(`Save ${ENV_DATABASE_AUTH_TOKEN} to .env?`, { force, + optional: true, }); } diff --git a/packages/cli/src/commands/db/tokens/invalidate.ts b/packages/cli/src/commands/db/tokens/invalidate.ts index a9050718..ec64d400 100644 --- a/packages/cli/src/commands/db/tokens/invalidate.ts +++ b/packages/cli/src/commands/db/tokens/invalidate.ts @@ -162,7 +162,7 @@ export const dbTokensInvalidateCommand = defineCommand<{ if (existingToken) { const shouldRemove = await confirm( `Remove ${ENV_DATABASE_AUTH_TOKEN} from ${existingToken.envPath}?`, - { force }, + { force, optional: true }, ); if (shouldRemove) { removeEnvValue(ENV_DATABASE_AUTH_TOKEN, existingToken.envPath); @@ -175,7 +175,7 @@ export const dbTokensInvalidateCommand = defineCommand<{ // Without --force: prompt the user const shouldCreate = force ? !!regenerate - : await confirm("Generate a new token?"); + : await confirm("Generate a new token?", { optional: true }); if (!shouldCreate) { logger.warn("All tokens have been invalidated. No valid tokens remain."); logger.dim( @@ -226,7 +226,9 @@ export const dbTokensInvalidateCommand = defineCommand<{ const shouldSave = saveEnv !== undefined ? saveEnv - : await confirm(`Save ${ENV_DATABASE_AUTH_TOKEN} to .env?`); + : await confirm(`Save ${ENV_DATABASE_AUTH_TOKEN} to .env?`, { + optional: true, + }); if (shouldSave) { const envPath = existingToken?.envPath; writeEnvValue(ENV_DATABASE_AUTH_TOKEN, newToken, envPath); diff --git a/packages/cli/src/commands/dns/interactive.ts b/packages/cli/src/commands/dns/interactive.ts index 80ad7736..dd30ce35 100644 --- a/packages/cli/src/commands/dns/interactive.ts +++ b/packages/cli/src/commands/dns/interactive.ts @@ -1,9 +1,8 @@ -import prompts from "prompts"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; import { loadManifest, saveManifest } from "../../core/manifest.ts"; import type { OutputFormat } from "../../core/types.ts"; -import { confirm, isInteractive, spinner } from "../../core/ui.ts"; +import { confirm, isInteractive, prompts, spinner } from "../../core/ui.ts"; import { type CoreClient, type DnsRecordModel, @@ -26,7 +25,12 @@ function writeDnsManifest(id: number, domain: string | undefined): void { /** Offer to remember a zone picked from the prompt; a no-op if the user declines. */ async function maybeLinkZone(zone: DnsZoneModel): Promise { - if (!(await confirm(`Link this directory to ${zone.Domain}?`))) return; + if ( + !(await confirm(`Link this directory to ${zone.Domain}?`, { + optional: true, + })) + ) + return; writeDnsManifest(zone.Id as number, zone.Domain ?? undefined); } @@ -45,6 +49,7 @@ export async function autoLinkDnsZone(zone: { existing.id && !(await confirm( `This directory is linked to DNS zone ${existing.domain ?? existing.id}. Relink to ${zone.domain ?? zone.id}?`, + { optional: true }, )) ) { return; diff --git a/packages/cli/src/commands/dns/record/add.ts b/packages/cli/src/commands/dns/record/add.ts index ae44ab79..d41cdb04 100644 --- a/packages/cli/src/commands/dns/record/add.ts +++ b/packages/cli/src/commands/dns/record/add.ts @@ -3,13 +3,12 @@ import { createCoreClient, } from "@bunny.net/openapi-client"; import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; -import prompts from "prompts"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; -import { isInteractive, spinner } from "../../../core/ui.ts"; +import { isInteractive, prompts, spinner } from "../../../core/ui.ts"; import type { CoreClient, DnsZoneModel } from "../api.ts"; import { resolveZoneInteractive } from "../interactive.ts"; import { diff --git a/packages/cli/src/commands/dns/record/import.ts b/packages/cli/src/commands/dns/record/import.ts index ecf17c4d..7c7e6c68 100644 --- a/packages/cli/src/commands/dns/record/import.ts +++ b/packages/cli/src/commands/dns/record/import.ts @@ -1,11 +1,10 @@ import { createCoreClient } from "@bunny.net/openapi-client"; -import prompts from "prompts"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; -import { spinner } from "../../../core/ui.ts"; +import { prompts, spinner } from "../../../core/ui.ts"; import type { CoreClient, DnsZoneModel } from "../api.ts"; import { resolveZoneInteractive } from "../interactive.ts"; diff --git a/packages/cli/src/commands/dns/record/preset.ts b/packages/cli/src/commands/dns/record/preset.ts index cd683aee..be2c07e9 100644 --- a/packages/cli/src/commands/dns/record/preset.ts +++ b/packages/cli/src/commands/dns/record/preset.ts @@ -1,11 +1,11 @@ import { createCoreClient } from "@bunny.net/openapi-client"; -import prompts from "prompts"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { formatTable } from "../../../core/format.ts"; import { logger } from "../../../core/logger.ts"; +import { prompts } from "../../../core/ui.ts"; import type { CoreClient, DnsZoneModel } from "../api.ts"; import { resolveZoneInteractive } from "../interactive.ts"; import { type DnsPreset, findPreset, PRESETS } from "./presets.ts"; diff --git a/packages/cli/src/commands/dns/record/update.ts b/packages/cli/src/commands/dns/record/update.ts index fdd76c44..72c5ec3d 100644 --- a/packages/cli/src/commands/dns/record/update.ts +++ b/packages/cli/src/commands/dns/record/update.ts @@ -1,12 +1,11 @@ import { createCoreClient } from "@bunny.net/openapi-client"; import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; -import prompts from "prompts"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; -import { isInteractive, spinner } from "../../../core/ui.ts"; +import { isInteractive, prompts, spinner } from "../../../core/ui.ts"; import { resolveRecordInteractive, resolveZoneInteractive, diff --git a/packages/cli/src/commands/dns/record/write.ts b/packages/cli/src/commands/dns/record/write.ts index 9325095e..ceb076f4 100644 --- a/packages/cli/src/commands/dns/record/write.ts +++ b/packages/cli/src/commands/dns/record/write.ts @@ -1,8 +1,7 @@ import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; -import prompts from "prompts"; import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; -import { spinner } from "../../../core/ui.ts"; +import { prompts, spinner } from "../../../core/ui.ts"; import type { CoreClient, DnsZoneModel } from "../api.ts"; import { recordName, recordTypeLabel } from "../record-types.ts"; diff --git a/packages/cli/src/commands/dns/scripts/attach.ts b/packages/cli/src/commands/dns/scripts/attach.ts index 6e273c22..6d068443 100644 --- a/packages/cli/src/commands/dns/scripts/attach.ts +++ b/packages/cli/src/commands/dns/scripts/attach.ts @@ -3,13 +3,12 @@ import { createCoreClient, } from "@bunny.net/openapi-client"; import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; -import prompts from "prompts"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; -import { confirm, isInteractive, spinner } from "../../../core/ui.ts"; +import { confirm, isInteractive, prompts, spinner } from "../../../core/ui.ts"; import { resolveZoneInteractive } from "../interactive.ts"; import { type DnsRecordModel, diff --git a/packages/cli/src/commands/dns/scripts/create.ts b/packages/cli/src/commands/dns/scripts/create.ts index aad47db1..8d850847 100644 --- a/packages/cli/src/commands/dns/scripts/create.ts +++ b/packages/cli/src/commands/dns/scripts/create.ts @@ -1,6 +1,5 @@ import { basename, resolve } from "node:path"; import { createComputeClient } from "@bunny.net/openapi-client"; -import prompts from "prompts"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; @@ -8,7 +7,7 @@ import { UserError } from "../../../core/errors.ts"; import { formatKeyValue } from "../../../core/format.ts"; import { logger } from "../../../core/logger.ts"; import { loadManifest, saveManifest } from "../../../core/manifest.ts"; -import { confirm, isInteractive, spinner } from "../../../core/ui.ts"; +import { confirm, isInteractive, prompts, spinner } from "../../../core/ui.ts"; import { createDnsScript } from "./api.ts"; import { DNS_SCRIPT_MANIFEST, @@ -102,6 +101,7 @@ export const dnsScriptsCreateCommand = defineCommand({ } else if (interactive && manifest.id && manifest.id !== created.id) { shouldLink = await confirm( `Replace existing link to ${manifest.name ?? manifest.id}?`, + { optional: true }, ); } else { shouldLink = true; diff --git a/packages/cli/src/commands/dns/scripts/deploy.ts b/packages/cli/src/commands/dns/scripts/deploy.ts index 24e996ff..77c2c072 100644 --- a/packages/cli/src/commands/dns/scripts/deploy.ts +++ b/packages/cli/src/commands/dns/scripts/deploy.ts @@ -1,14 +1,13 @@ import { existsSync } from "node:fs"; import { resolve } from "node:path"; import { createComputeClient } from "@bunny.net/openapi-client"; -import prompts from "prompts"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; import { loadManifest, manifestRoot } from "../../../core/manifest.ts"; -import { isInteractive, spinner } from "../../../core/ui.ts"; +import { isInteractive, prompts, spinner } from "../../../core/ui.ts"; import { publishScript, uploadCode } from "./api.ts"; import { DEFAULT_ENTRY, diff --git a/packages/cli/src/commands/dns/scripts/init.ts b/packages/cli/src/commands/dns/scripts/init.ts index 4a44cfc0..704668f9 100644 --- a/packages/cli/src/commands/dns/scripts/init.ts +++ b/packages/cli/src/commands/dns/scripts/init.ts @@ -1,7 +1,6 @@ import { existsSync, mkdirSync } from "node:fs"; import { basename, resolve } from "node:path"; import { createComputeClient } from "@bunny.net/openapi-client"; -import prompts from "prompts"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; @@ -9,7 +8,7 @@ import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; import { saveManifestAt } from "../../../core/manifest.ts"; import { pickPackageManager } from "../../../core/package-manager.ts"; -import { confirm, isInteractive, spinner } from "../../../core/ui.ts"; +import { confirm, isInteractive, prompts, spinner } from "../../../core/ui.ts"; import { createDnsScript } from "./api.ts"; import { DEFAULT_ENTRY, @@ -158,7 +157,7 @@ export const dnsScriptsInitCommand = defineCommand({ if (args[ARG_SKIP_INSTALL] !== true) { const install = interactive - ? await confirm("Install editor type dependencies?") + ? await confirm("Install editor type dependencies?", { optional: true }) : false; if (install) { const pm = await pickPackageManager(dirPath); @@ -198,7 +197,9 @@ export const dnsScriptsInitCommand = defineCommand({ args[ARG_DEPLOY] !== undefined ? args[ARG_DEPLOY] : interactive - ? await confirm("Create the DNS script on bunny.net?") + ? await confirm("Create the DNS script on bunny.net?", { + optional: true, + }) : false; if (shouldDeploy) { diff --git a/packages/cli/src/commands/dns/scripts/interactive.ts b/packages/cli/src/commands/dns/scripts/interactive.ts index 4b1ab972..edd45992 100644 --- a/packages/cli/src/commands/dns/scripts/interactive.ts +++ b/packages/cli/src/commands/dns/scripts/interactive.ts @@ -1,8 +1,7 @@ -import prompts from "prompts"; import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; import { loadManifest } from "../../../core/manifest.ts"; -import { spinner } from "../../../core/ui.ts"; +import { prompts, spinner } from "../../../core/ui.ts"; import { type ComputeClient, createDnsScript, diff --git a/packages/cli/src/commands/dns/scripts/link.ts b/packages/cli/src/commands/dns/scripts/link.ts index 7e6a40d9..4b026ded 100644 --- a/packages/cli/src/commands/dns/scripts/link.ts +++ b/packages/cli/src/commands/dns/scripts/link.ts @@ -1,12 +1,11 @@ import { createComputeClient } from "@bunny.net/openapi-client"; -import prompts from "prompts"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; import { loadManifest, saveManifest } from "../../../core/manifest.ts"; -import { spinner } from "../../../core/ui.ts"; +import { prompts, spinner } from "../../../core/ui.ts"; import { fetchDnsScript, fetchDnsScripts } from "./api.ts"; import { DEFAULT_ENTRY, diff --git a/packages/cli/src/commands/dns/zone/add.ts b/packages/cli/src/commands/dns/zone/add.ts index 227492a0..65ca9537 100644 --- a/packages/cli/src/commands/dns/zone/add.ts +++ b/packages/cli/src/commands/dns/zone/add.ts @@ -1,5 +1,4 @@ import { createCoreClient } from "@bunny.net/openapi-client"; -import prompts from "prompts"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; @@ -10,7 +9,7 @@ import { import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; import { detectRegistrar } from "../../../core/registrar.ts"; -import { isInteractive, spinner } from "../../../core/ui.ts"; +import { isInteractive, prompts, spinner } from "../../../core/ui.ts"; import { type CoreClient, type DnsZoneModel, fetchZone } from "../api.ts"; import { addRecordInteractive } from "../record/add.ts"; import { importZoneFile } from "../record/import.ts"; diff --git a/packages/cli/src/commands/registries/add.ts b/packages/cli/src/commands/registries/add.ts index 9d6f0700..71ddf9b6 100644 --- a/packages/cli/src/commands/registries/add.ts +++ b/packages/cli/src/commands/registries/add.ts @@ -1,11 +1,10 @@ import { createMcClient } from "@bunny.net/openapi-client"; -import prompts from "prompts"; import { resolveConfig } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; -import { spinner } from "../../core/ui.ts"; +import { prompts, spinner } from "../../core/ui.ts"; const COMMAND = "add"; const DESCRIPTION = "Add a container registry."; diff --git a/packages/cli/src/commands/registries/update.ts b/packages/cli/src/commands/registries/update.ts index 88633705..c40016a0 100644 --- a/packages/cli/src/commands/registries/update.ts +++ b/packages/cli/src/commands/registries/update.ts @@ -1,11 +1,10 @@ import { createMcClient } from "@bunny.net/openapi-client"; -import prompts from "prompts"; import { resolveConfig } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; -import { spinner } from "../../core/ui.ts"; +import { prompts, spinner } from "../../core/ui.ts"; const COMMAND = "update "; const DESCRIPTION = "Update a container registry."; diff --git a/packages/cli/src/commands/sandbox/create.ts b/packages/cli/src/commands/sandbox/create.ts index fcef1aa9..1621318d 100644 --- a/packages/cli/src/commands/sandbox/create.ts +++ b/packages/cli/src/commands/sandbox/create.ts @@ -1,10 +1,9 @@ import { Sandbox, SandboxError } from "@bunny.net/sandbox"; -import prompts from "prompts"; import { resolveConfig, setSandbox } from "../../config/index.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; -import { spinner } from "../../core/ui.ts"; +import { prompts, spinner } from "../../core/ui.ts"; import { collectEnv, type EnvOptionArgs, withEnvOptions } from "./env-args.ts"; const DEFAULT_REGION = "AMS"; diff --git a/packages/cli/src/commands/scripts/api.ts b/packages/cli/src/commands/scripts/api.ts index a6ae88de..e198e95a 100644 --- a/packages/cli/src/commands/scripts/api.ts +++ b/packages/cli/src/commands/scripts/api.ts @@ -161,7 +161,9 @@ export function logLiveHostnames( /** Prompt to open a script's hostname in the browser, with a deploy hint otherwise. */ export async function promptOpenInBrowser(hostname: string): Promise { - const shouldOpen = await confirm("Open script in browser?"); + const shouldOpen = await confirm("Open script in browser?", { + optional: true, + }); if (shouldOpen) { const url = hostname.startsWith("http") ? hostname : `https://${hostname}`; logger.dim(` Opening ${url}`); diff --git a/packages/cli/src/commands/scripts/create.ts b/packages/cli/src/commands/scripts/create.ts index 80dbe056..7931c87e 100644 --- a/packages/cli/src/commands/scripts/create.ts +++ b/packages/cli/src/commands/scripts/create.ts @@ -3,7 +3,6 @@ import { createComputeClient, createCoreClient, } from "@bunny.net/openapi-client"; -import prompts from "prompts"; import { resolveConfig } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; @@ -16,7 +15,7 @@ import { } from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; import { loadManifest, saveManifest } from "../../core/manifest.ts"; -import { confirm, spinner } from "../../core/ui.ts"; +import { confirm, prompts, spinner } from "../../core/ui.ts"; import { autoLinkDnsZone } from "../dns/interactive.ts"; import { promptOpenInBrowser } from "./api.ts"; import { @@ -293,6 +292,7 @@ export const scriptsCreateCommand = defineCommand({ } else if (isInteractive && manifest.id && manifest.id !== created.id) { shouldLink = await confirm( `Replace existing link to ${manifest.name ?? manifest.id}?`, + { optional: true }, ); } else { shouldLink = true; diff --git a/packages/cli/src/commands/scripts/env/remove.ts b/packages/cli/src/commands/scripts/env/remove.ts index 36566d7b..784b17b4 100644 --- a/packages/cli/src/commands/scripts/env/remove.ts +++ b/packages/cli/src/commands/scripts/env/remove.ts @@ -1,11 +1,10 @@ import { createComputeClient } from "@bunny.net/openapi-client"; -import prompts from "prompts"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; -import { confirm, spinner } from "../../../core/ui.ts"; +import { confirm, prompts, spinner } from "../../../core/ui.ts"; import { fetchEnvEntries } from "../api.ts"; import { type ScriptSelectorArgs, diff --git a/packages/cli/src/commands/scripts/env/set.ts b/packages/cli/src/commands/scripts/env/set.ts index e6bd2ceb..eeb73402 100644 --- a/packages/cli/src/commands/scripts/env/set.ts +++ b/packages/cli/src/commands/scripts/env/set.ts @@ -1,11 +1,10 @@ import { createComputeClient } from "@bunny.net/openapi-client"; -import prompts from "prompts"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; -import { spinner } from "../../../core/ui.ts"; +import { prompts, spinner } from "../../../core/ui.ts"; import { fetchEnvEntries } from "../api.ts"; import { type ScriptSelectorArgs, diff --git a/packages/cli/src/commands/scripts/init.ts b/packages/cli/src/commands/scripts/init.ts index 671fd107..920374b6 100644 --- a/packages/cli/src/commands/scripts/init.ts +++ b/packages/cli/src/commands/scripts/init.ts @@ -1,6 +1,5 @@ import { existsSync } from "node:fs"; import { basename, resolve } from "node:path"; -import prompts from "prompts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; import { normalizeHostname } from "../../core/hostnames/index.ts"; @@ -10,7 +9,7 @@ import { detectFromLockfile, pickPackageManager, } from "../../core/package-manager.ts"; -import { confirm, spinner } from "../../core/ui.ts"; +import { confirm, prompts, spinner } from "../../core/ui.ts"; import { promptOpenInBrowser } from "./api.ts"; import { type EdgeScriptTypes, @@ -240,6 +239,7 @@ export const scriptsInitCommand = defineCommand({ } else if (interactive) { enableGithubActions = await confirm( "Enable continuous integration with GitHub Actions?", + { optional: true }, ); } else { enableGithubActions = false; @@ -305,7 +305,7 @@ export const scriptsInitCommand = defineCommand({ // would otherwise run silently in non-interactive mode. const shouldInstall = interactive || args[ARG_TEMPLATE_REPO] - ? await confirm("Install dependencies?") + ? await confirm("Install dependencies?", { optional: true }) : true; if (shouldInstall) { const pm = await pickPackageManager(dirPath); @@ -355,7 +355,9 @@ export const scriptsInitCommand = defineCommand({ if (args[ARG_SKIP_GIT] !== true) { const shouldGit = enableGithubActions || - (interactive ? await confirm("Initialize git repository?") : true); + (interactive + ? await confirm("Initialize git repository?", { optional: true }) + : true); if (shouldGit) { const gitInit = Bun.spawn(["git", "init"], { cwd: dirPath, @@ -391,7 +393,7 @@ export const scriptsInitCommand = defineCommand({ args[ARG_DEPLOY] !== undefined ? args[ARG_DEPLOY] : interactive - ? await confirm("Create script on bunny.net?") + ? await confirm("Create script on bunny.net?", { optional: true }) : false; if (shouldDeploy) { diff --git a/packages/cli/src/commands/scripts/interactive.ts b/packages/cli/src/commands/scripts/interactive.ts index ad4595ba..8a56d8e5 100644 --- a/packages/cli/src/commands/scripts/interactive.ts +++ b/packages/cli/src/commands/scripts/interactive.ts @@ -1,12 +1,11 @@ import type { createComputeClient } from "@bunny.net/openapi-client"; import type { components } from "@bunny.net/openapi-client/generated/compute.d.ts"; -import prompts from "prompts"; import type { Argv } from "yargs"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; import { loadManifest, saveManifest } from "../../core/manifest.ts"; import type { OutputFormat } from "../../core/types.ts"; -import { confirm, spinner } from "../../core/ui.ts"; +import { confirm, prompts, spinner } from "../../core/ui.ts"; import { fetchScript, fetchScripts } from "./api.ts"; import { SCRIPT_MANIFEST } from "./constants.ts"; @@ -89,7 +88,9 @@ async function maybeLinkScript( const shouldLink = link !== undefined ? link - : await confirm(`Link this directory to ${script.Name}?`); + : await confirm(`Link this directory to ${script.Name}?`, { + optional: true, + }); if (!shouldLink) return; saveManifest(SCRIPT_MANIFEST, { diff --git a/packages/cli/src/commands/scripts/link.ts b/packages/cli/src/commands/scripts/link.ts index f907f7b8..fa49b152 100644 --- a/packages/cli/src/commands/scripts/link.ts +++ b/packages/cli/src/commands/scripts/link.ts @@ -1,13 +1,12 @@ import { createComputeClient } from "@bunny.net/openapi-client"; import type { components } from "@bunny.net/openapi-client/generated/compute.d.ts"; -import prompts from "prompts"; import { resolveConfig } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; import { saveManifest } from "../../core/manifest.ts"; -import { spinner } from "../../core/ui.ts"; +import { prompts, spinner } from "../../core/ui.ts"; import { fetchScript, fetchScripts } from "./api.ts"; import { SCRIPT_MANIFEST } from "./constants.ts"; diff --git a/packages/cli/src/commands/sites/ci/scaffold.ts b/packages/cli/src/commands/sites/ci/scaffold.ts index 683ed0ec..5e5eb445 100644 --- a/packages/cli/src/commands/sites/ci/scaffold.ts +++ b/packages/cli/src/commands/sites/ci/scaffold.ts @@ -1,10 +1,9 @@ import { existsSync, mkdirSync, realpathSync } from "node:fs"; import { dirname, isAbsolute, join, relative, sep } from "node:path"; -import prompts from "prompts"; import { UserError } from "../../../core/errors.ts"; import { runGit } from "../../../core/git.ts"; import { logger } from "../../../core/logger.ts"; -import { confirm } from "../../../core/ui.ts"; +import { confirm, prompts } from "../../../core/ui.ts"; import { detectFramework, detectPackageManager, @@ -197,7 +196,10 @@ export async function scaffoldSitesWorkflow(opts: { ); } if ( - !(await confirm(`Overwrite ${SITES_WORKFLOW_PATH}?`, { initial: false })) + !(await confirm(`Overwrite ${SITES_WORKFLOW_PATH}?`, { + initial: false, + optional: true, + })) ) { return null; } @@ -263,7 +265,7 @@ export async function offerGitHubSecret(opts: { if (opts.interactive && gh && opts.apiKey) { const proceed = await confirm( "Add the BUNNY_API_KEY secret to this GitHub repo now (runs `gh secret set`)?", - { initial: true }, + { initial: true, optional: true }, ); if (proceed) { // The key goes via stdin, never argv: process arguments are visible in `ps`. diff --git a/packages/cli/src/commands/sites/create.ts b/packages/cli/src/commands/sites/create.ts index a1624cb1..82b0b1c1 100644 --- a/packages/cli/src/commands/sites/create.ts +++ b/packages/cli/src/commands/sites/create.ts @@ -2,7 +2,6 @@ import { createComputeClient, createCoreClient, } from "@bunny.net/openapi-client"; -import prompts from "prompts"; import { resolveConfig } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; @@ -11,7 +10,7 @@ import { formatKeyValue } from "../../core/format.ts"; import { normalizeHostname } from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; import { saveManifest } from "../../core/manifest.ts"; -import { confirm, isInteractive } from "../../core/ui.ts"; +import { confirm, isInteractive, prompts } from "../../core/ui.ts"; import type { CoreClient, StorageZoneModel } from "../storage/api.ts"; import { siteContextFromZone } from "./api.ts"; import { @@ -225,7 +224,7 @@ export const sitesCreateCommand = defineCommand({ logger.log(); const setup = await confirm( "Set up GitHub deployments (preview on PRs, production on main)?", - { initial: true }, + { initial: true, optional: true }, ); if (setup) { const scaffold = await scaffoldSitesWorkflow({ diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 8a828676..871d4ef3 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -4,7 +4,6 @@ import { createComputeClient, createCoreClient, } from "@bunny.net/openapi-client"; -import prompts from "prompts"; import { resolveConfig } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; @@ -13,7 +12,7 @@ import { errorMessage, UserError } from "../../core/errors.ts"; import { formatBytes } from "../../core/format.ts"; import { normalizeHostname } from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; -import { confirm, isInteractive, withSpinner } from "../../core/ui.ts"; +import { confirm, isInteractive, prompts, withSpinner } from "../../core/ui.ts"; import { ensurePreviewZone, ensureRouterCurrent, @@ -236,7 +235,7 @@ export const sitesDeployCommand = defineCommand({ const prompt = configured ? `Run ${auto.label} (\`${auto.command}\`) before deploying?` : `Detected ${auto.label}. Run \`${auto.command}\` before deploying?`; - if (await confirm(prompt, { initial: true })) { + if (await confirm(prompt, { initial: true, optional: true })) { await runBuildCommand(auto.command, root, {}); } } @@ -281,7 +280,7 @@ export const sitesDeployCommand = defineCommand({ if (!publish && state.current === undefined && isInteractive(output)) { publish = await confirm( "This site has no production deploy yet. Publish this one to production?", - { initial: true }, + { initial: true, optional: true }, ); } diff --git a/packages/cli/src/commands/sites/interactive.ts b/packages/cli/src/commands/sites/interactive.ts index a4d4845c..0a6265b0 100644 --- a/packages/cli/src/commands/sites/interactive.ts +++ b/packages/cli/src/commands/sites/interactive.ts @@ -1,10 +1,9 @@ -import prompts from "prompts"; import type { Argv } from "yargs"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; import { loadManifest, saveManifest } from "../../core/manifest.ts"; import type { OutputFormat } from "../../core/types.ts"; -import { confirm, isInteractive, withSpinner } from "../../core/ui.ts"; +import { confirm, isInteractive, prompts, withSpinner } from "../../core/ui.ts"; import { type CoreClient, fetchStorageZone, @@ -222,7 +221,11 @@ export async function selectSite( return { site: context, offerLink: async () => { - if (await confirm(`Link this directory to ${context.state.name}?`)) { + if ( + await confirm(`Link this directory to ${context.state.name}?`, { + optional: true, + }) + ) { linkDirectory(context, args.output); } }, diff --git a/packages/cli/src/commands/sites/provision.ts b/packages/cli/src/commands/sites/provision.ts index fa44569c..26e390ff 100644 --- a/packages/cli/src/commands/sites/provision.ts +++ b/packages/cli/src/commands/sites/provision.ts @@ -1,9 +1,8 @@ import { basename } from "node:path"; -import prompts from "prompts"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; import { saveManifest } from "../../core/manifest.ts"; -import { withSpinner } from "../../core/ui.ts"; +import { prompts, withSpinner } from "../../core/ui.ts"; import type { CoreClient } from "../storage/api.ts"; import { type ComputeClient, diff --git a/packages/cli/src/commands/storage/connection.ts b/packages/cli/src/commands/storage/connection.ts index 0a559286..3a483278 100644 --- a/packages/cli/src/commands/storage/connection.ts +++ b/packages/cli/src/commands/storage/connection.ts @@ -1,9 +1,8 @@ -import prompts from "prompts"; import { UserError } from "../../core/errors.ts"; import { formatKeyValue, maskSecret } from "../../core/format.ts"; import { logger } from "../../core/logger.ts"; import type { OutputFormat } from "../../core/types.ts"; -import { confirm } from "../../core/ui.ts"; +import { confirm, prompts } from "../../core/ui.ts"; import { readEnvValue, writeEnvValue } from "../../utils/env-file.ts"; import { DOCS_BASE_URL } from "../docs.ts"; import type { StorageZoneModel } from "./api.ts"; @@ -351,7 +350,7 @@ export async function offerConnectionEnv( clash ? `${clash.key} already exists in ${readEnvValue(clash.key)?.envPath}. Overwrite?` : "Save these credentials to .env?", - { initial: !clash }, + { initial: !clash, optional: true }, ); } if (!save) return false; diff --git a/packages/cli/src/commands/storage/interactive.ts b/packages/cli/src/commands/storage/interactive.ts index 85b433d0..269c6cea 100644 --- a/packages/cli/src/commands/storage/interactive.ts +++ b/packages/cli/src/commands/storage/interactive.ts @@ -1,9 +1,8 @@ -import prompts from "prompts"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; import { loadManifest, saveManifest } from "../../core/manifest.ts"; import type { OutputFormat } from "../../core/types.ts"; -import { confirm, isInteractive, spinner } from "../../core/ui.ts"; +import { confirm, isInteractive, prompts, spinner } from "../../core/ui.ts"; import { type CoreClient, fetchStorageZone, @@ -23,7 +22,10 @@ export function writeStorageManifest(zone: StorageZoneModel): void { // Offer to remember a zone picked from the prompt; a no-op if the user declines. async function maybeLinkZone(zone: StorageZoneModel): Promise { - if (!(await confirm(`Link this directory to ${zone.Name}?`))) return; + if ( + !(await confirm(`Link this directory to ${zone.Name}?`, { optional: true })) + ) + return; writeStorageManifest(zone); logger.success(`Linked this directory to storage zone ${zone.Name}.`); } diff --git a/packages/cli/src/commands/storage/zone/add.ts b/packages/cli/src/commands/storage/zone/add.ts index 3ba26115..f0842533 100644 --- a/packages/cli/src/commands/storage/zone/add.ts +++ b/packages/cli/src/commands/storage/zone/add.ts @@ -1,5 +1,4 @@ import { createCoreClient } from "@bunny.net/openapi-client"; -import prompts from "prompts"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; @@ -14,7 +13,7 @@ import { } from "../../../core/hostnames/index.ts"; import { logger } from "../../../core/logger.ts"; import { loadManifest } from "../../../core/manifest.ts"; -import { confirm, isInteractive, spinner } from "../../../core/ui.ts"; +import { confirm, isInteractive, prompts, spinner } from "../../../core/ui.ts"; import { type CoreClient, fetchStorageZone, @@ -315,7 +314,10 @@ export const storageZoneAddCommand = defineCommand({ let s3Enabled = s3; if (s3Enabled === undefined && interactive) { logger.dim("S3 compatibility cannot be turned on later."); - s3Enabled = await confirm("Enable S3 compatibility?", { initial: true }); + s3Enabled = await confirm("Enable S3 compatibility?", { + initial: true, + optional: true, + }); } let replicationRegions = replication; @@ -375,6 +377,7 @@ export const storageZoneAddCommand = defineCommand({ if (shouldCreatePullZone === undefined && interactive && zoneId) { shouldCreatePullZone = await confirm( `Make ${zoneName} available on the web? This creates a pull zone (bunny's CDN layer) in front of it.`, + { optional: true }, ); } @@ -494,7 +497,7 @@ export const storageZoneAddCommand = defineCommand({ if ( customDomain === undefined && interactive && - (await confirm("Add a custom domain?")) + (await confirm("Add a custom domain?", { optional: true })) ) { const { value } = await prompts({ type: "text", @@ -535,6 +538,7 @@ export const storageZoneAddCommand = defineCommand({ existing.id && existing.id !== zoneId ? `Link this directory to ${zoneName}? (replaces the existing link to ${existing.name ?? existing.id})` : `Link this directory to ${zoneName}?`, + { optional: true }, ); } if (shouldLink) { @@ -545,7 +549,7 @@ export const storageZoneAddCommand = defineCommand({ let connectionType = requestedType; let toolFormat = format; if (connectionType === undefined && interactive) { - if (await confirm("Show connection details?")) { + if (await confirm("Show connection details?", { optional: true })) { connectionType = await promptConnectionType(created); if (connectionType) toolFormat = await promptClient(connectionType); } diff --git a/packages/cli/src/commands/storage/zone/remove.ts b/packages/cli/src/commands/storage/zone/remove.ts index 3ec4f57c..db8dc1bf 100644 --- a/packages/cli/src/commands/storage/zone/remove.ts +++ b/packages/cli/src/commands/storage/zone/remove.ts @@ -1,11 +1,10 @@ import { createCoreClient } from "@bunny.net/openapi-client"; -import prompts from "prompts"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { logger } from "../../../core/logger.ts"; import { loadManifest, removeManifest } from "../../../core/manifest.ts"; -import { confirm, spinner } from "../../../core/ui.ts"; +import { confirm, prompts, spinner } from "../../../core/ui.ts"; import { STORAGE_MANIFEST, type StorageZoneManifest } from "../constants.ts"; import { resolveStorageZoneInteractive } from "../interactive.ts"; diff --git a/packages/cli/src/commands/storage/zone/update.ts b/packages/cli/src/commands/storage/zone/update.ts index 13d388ca..36cef833 100644 --- a/packages/cli/src/commands/storage/zone/update.ts +++ b/packages/cli/src/commands/storage/zone/update.ts @@ -1,11 +1,11 @@ import { createCoreClient } from "@bunny.net/openapi-client"; -import prompts from "prompts"; +import type promptsLib from "prompts"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; -import { isInteractive, spinner } from "../../../core/ui.ts"; +import { isInteractive, prompts, spinner } from "../../../core/ui.ts"; import type { StorageZoneModel, StorageZoneSettingsModel } from "../api.ts"; import { confirmAddedReplicationRegions, @@ -61,7 +61,7 @@ async function promptSettings( (region) => !existing.includes(region.code), ); - const questions: prompts.PromptObject[] = [ + const questions: promptsLib.PromptObject[] = [ { type: "text", name: "custom404Path", diff --git a/packages/cli/src/core/hostnames/bunny-dns.ts b/packages/cli/src/core/hostnames/bunny-dns.ts index 747a51f3..3b926422 100644 --- a/packages/cli/src/core/hostnames/bunny-dns.ts +++ b/packages/cli/src/core/hostnames/bunny-dns.ts @@ -168,6 +168,7 @@ export async function offerBunnyDnsRecord(opts: { if ( !(await confirm(`Point ${hostname} at this pull zone now?`, { initial: true, + optional: true, })) ) { return "declined"; @@ -186,7 +187,12 @@ export async function offerBunnyDnsRecord(opts: { logger.warn( `${hostname} already ${detail} in your Bunny DNS (${zoneDomain}).`, ); - if (!(await confirm("Repoint it at this pull zone?", { initial: false }))) { + if ( + !(await confirm("Repoint it at this pull zone?", { + initial: false, + optional: true, + })) + ) { return "declined"; } if (existing.Id == null) { diff --git a/packages/cli/src/core/hostnames/flow.ts b/packages/cli/src/core/hostnames/flow.ts index 23e414d8..5629ea96 100644 --- a/packages/cli/src/core/hostnames/flow.ts +++ b/packages/cli/src/core/hostnames/flow.ts @@ -108,7 +108,10 @@ export async function offerDnsWaitAndSsl( const shouldWait = opts.dnsAlreadyLive || opts.assumeYes || - (await confirm("Wait for DNS and enable HTTPS now?", { initial: true })); + (await confirm("Wait for DNS and enable HTTPS now?", { + initial: true, + optional: true, + })); if (!shouldWait) { printSslHint(opts.sslHint); diff --git a/packages/cli/src/core/ui.test.ts b/packages/cli/src/core/ui.test.ts index 926a8c0f..268f2c91 100644 --- a/packages/cli/src/core/ui.test.ts +++ b/packages/cli/src/core/ui.test.ts @@ -1,6 +1,64 @@ import { expect, test } from "bun:test"; import { requireConfirmable } from "./ui.ts"; +const UI_PATH = new URL("./ui.ts", import.meta.url).pathname; + +// Prompts must run in a subprocess: they grab the real stdin, and the EOF spin (BunnyWay/cli#171) would hang the test runner itself. +async function runConfirm( + stdin: "ignore" | Blob, + call = 'confirm("sure?")', +): Promise<{ stdout: string; stderr: string; exitCode: number | null }> { + const script = `const { confirm } = await import(${JSON.stringify(UI_PATH)}); console.log("result:" + (await ${call}));`; + const proc = Bun.spawn({ + cmd: [process.execPath, "-e", script], + stdin, + stdout: "pipe", + stderr: "pipe", + }); + const timer = setTimeout(() => proc.kill(), 8000); + await proc.exited; + clearTimeout(timer); + return { + stdout: await new Response(proc.stdout).text(), + stderr: await new Response(proc.stderr).text(), + exitCode: proc.exitCode, + }; +} + +test("gate confirm fails non-zero when stdin is at EOF instead of spinning", async () => { + const run = await runConfirm("ignore"); + expect(run.exitCode).not.toBe(0); + expect(run.stderr).toContain("Confirmation required"); +}, 10_000); + +test("optional confirm declines quietly when stdin is at EOF", async () => { + const run = await runConfirm( + "ignore", + 'confirm("sure?", { optional: true })', + ); + expect(run.exitCode).toBe(0); + expect(run.stdout).toContain("result:false"); +}, 10_000); + +test("confirm still reads piped answers", async () => { + expect((await runConfirm(new Blob(["y\n"]))).stdout).toContain("result:true"); + expect((await runConfirm(new Blob(["n\n"]))).stdout).toContain( + "result:false", + ); +}, 10_000); + +// Only ui.ts may import the raw library: its EOF-safe prompts() wrapper is what keeps CI runs from spinning (type-only imports and prompts.inject in tests are fine). +test("no runtime imports of the prompts library outside ui.ts", async () => { + const srcRoot = new URL("..", import.meta.url).pathname; + const offenders: string[] = []; + for await (const rel of new Bun.Glob("**/*.ts").scan(srcRoot)) { + if (rel === "core/ui.ts" || rel.endsWith(".test.ts")) continue; + const text = await Bun.file(srcRoot + rel).text(); + if (/^import (?!type ).*from "prompts";/m.test(text)) offenders.push(rel); + } + expect(offenders).toEqual([]); +}); + const OPTS = { message: "Needs a prompt.", hint: "Re-run with --force." }; // `bun test` runs without a TTY, so every call here takes the unattended path. diff --git a/packages/cli/src/core/ui.ts b/packages/cli/src/core/ui.ts index 20f79271..d27922bd 100644 --- a/packages/cli/src/core/ui.ts +++ b/packages/cli/src/core/ui.ts @@ -1,6 +1,61 @@ import ora from "ora"; -import prompts from "prompts"; +import promptsLib from "prompts"; import { UserError } from "./errors.ts"; +import { logger } from "./logger.ts"; + +let stdinEnded = false; +let eofWarned = false; + +// Destroying stdin stops the poll; the escape code re-shows the cursor that prompts hid. +function abortPromptAtEof(): null { + process.stdin.destroy(); + if (!eofWarned) { + eofWarned = true; + process.stdout.write(process.stdout.isTTY ? "\x1b[?25h\n" : "\n"); + logger.warn( + "stdin closed before the prompt was answered. Pass the value as a flag, or --force to skip confirmations.", + ); + } + return null; +} + +// prompts busy-polls a closed stdin (100% CPU, forever), so EOF must abort the prompt; the null result maps to "cancelled" at each call site. +async function promptOrEof( + run: () => Promise, +): Promise { + if (stdinEnded || process.stdin.readableEnded || process.stdin.destroyed) { + return abortPromptAtEof(); + } + let onEnd = () => {}; + const eof = new Promise((resolve) => { + onEnd = () => { + stdinEnded = true; + // Grace period so an answer already in the pipe settles before EOF wins the race; runs at most once per process. + setTimeout(() => resolve(null), 250); + }; + process.stdin.once("end", onEnd); + }); + try { + const result = await Promise.race([run(), eof]); + return result === null ? abortPromptAtEof() : result; + } finally { + process.stdin.off("end", onEnd); + } +} + +/** + * EOF-safe drop-in for the `prompts` library; always use this instead of + * importing `prompts` directly. Same call shape, but when stdin can no longer + * answer (closed, ended, `< /dev/null`) it returns `{}` instead of spinning, + * so missing answers surface as `undefined` exactly like a Ctrl-C cancel. + */ +export async function prompts( + questions: promptsLib.PromptObject | Array>, + options?: promptsLib.Options, +): Promise> { + const result = await promptOrEof(() => promptsLib(questions, options)); + return result ?? ({} as promptsLib.Answers); +} /** * Masked password input. Returns an empty string if the user cancels. @@ -9,12 +64,22 @@ import { UserError } from "./errors.ts"; * (e.g. `--api-key`) that bypasses this prompt entirely. */ export async function readPassword(message: string): Promise { - const { value } = await prompts({ - type: "password", - name: "value", - message, - }); - return value ?? ""; + const result = await promptOrEof(() => + promptsLib({ + type: "password", + name: "value", + message, + }), + ); + return result?.value ?? ""; +} + +// Unanswerable gate confirmations must exit non-zero: agents and CI trust exit codes, and a 0 after "Cancelled." reads as success for work that never happened. +function stdinClosedError(): UserError { + return new UserError( + "Confirmation required, but stdin closed before the prompt was answered.", + "Re-run with --force to skip the confirmation.", + ); } /** @@ -23,19 +88,26 @@ export async function readPassword(message: string): Promise { * Pass `opts.force` to skip the prompt and return `true` immediately. * All commands with confirmations should expose a `--force` flag * so agents and scripts can run non-interactively. + * + * When stdin closes before an answer (CI, `< /dev/null`), a gate confirmation + * throws so the command exits non-zero. Pass `opts.optional` for offer-style + * prompts where declining is a normal outcome and the command should continue. */ export async function confirm( message: string, - opts?: { force?: boolean; initial?: boolean }, + opts?: { force?: boolean; initial?: boolean; optional?: boolean }, ): Promise { if (opts?.force) return true; - const { confirmed } = await prompts({ - type: "confirm", - name: "confirmed", - message, - initial: opts?.initial ?? false, - }); - return confirmed ?? false; + const result = await promptOrEof(() => + promptsLib({ + type: "confirm", + name: "confirmed", + message, + initial: opts?.initial ?? false, + }), + ); + if (result === null && !opts?.optional) throw stdinClosedError(); + return result?.confirmed ?? false; } /** Like confirm, but reports Ctrl-C as "cancel" instead of folding it into "no". */ @@ -44,20 +116,23 @@ export async function confirmOrCancel( opts?: { initial?: boolean }, ): Promise<"yes" | "no" | "cancel"> { let cancelled = false; - const { confirmed } = await prompts( - { - type: "confirm", - name: "confirmed", - message, - initial: opts?.initial ?? false, - }, - { - onCancel: () => { - cancelled = true; + const result = await promptOrEof(() => + promptsLib( + { + type: "confirm", + name: "confirmed", + message, + initial: opts?.initial ?? false, + }, + { + onCancel: () => { + cancelled = true; + }, }, - }, + ), ); - return cancelled ? "cancel" : confirmed ? "yes" : "no"; + if (result === null || cancelled) return "cancel"; + return result.confirmed ? "yes" : "no"; } export async function confirmTyped( @@ -65,12 +140,15 @@ export async function confirmTyped( opts?: { force?: boolean }, ): Promise { if (opts?.force) return true; - const { value } = await prompts({ - type: "text", - name: "value", - message: `Type "${expected}" to confirm:`, - }); - return value === expected; + const result = await promptOrEof(() => + promptsLib({ + type: "text", + name: "value", + message: `Type "${expected}" to confirm:`, + }), + ); + if (result === null) throw stdinClosedError(); + return result.value === expected; } export function isInteractive(output?: string): boolean { diff --git a/skills/bunny-cli/SKILL.md b/skills/bunny-cli/SKILL.md index 0e5d0b4d..bbd7686a 100644 --- a/skills/bunny-cli/SKILL.md +++ b/skills/bunny-cli/SKILL.md @@ -100,4 +100,4 @@ Available on every command: - **Forgetting to authenticate**: Run `bunny login` first. Without it, commands fail with a missing API key error. Use `bunny api GET /user` to verify. - **Hardcoding API keys in scripts**: Use `BUNNYNET_API_KEY` env var or `--api-key` flag instead of embedding keys. Better yet, use `bunny login` profiles. -- **Forgetting `--force` in CI/CD**: Interactive prompts block in non-TTY environments. Use `--force` to skip confirmations in automated pipelines. +- **Relying on prompts in automation**: Prompts are for terminals. In scripts, CI, and agent sessions, pass every value as a flag and add `--force` to skip confirmations. A command that would need a prompt with stdin closed does not wait: it fails fast with exit code 1 and names the flag to pass, so treat that as "retry with flags", never as success. From b0f717b247f773b36646d8584b8b0512d4204511 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 20 Aug 2026 14:05:02 +0100 Subject: [PATCH 2/3] fix(core): require an interactive terminal for prompts Refuse to prompt up front when stdin is not a TTY instead of racing EOF at read time, matching the standard CLI convention for non-interactive runs; the EOF race stays as a backstop for a terminal that hangs up mid-prompt. Piped prompt answers are no longer supported; flags and --force are the automation contract. prompts.inject() in tests is exempt since injected answers never touch stdin. --- .changeset/prompt-eof-spin.md | 2 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- packages/cli/src/core/ui.test.ts | 14 +++++++++---- packages/cli/src/core/ui.ts | 35 ++++++++++++++++++++++---------- skills/bunny-cli/SKILL.md | 2 +- 6 files changed, 38 insertions(+), 19 deletions(-) diff --git a/.changeset/prompt-eof-spin.md b/.changeset/prompt-eof-spin.md index 404e4ab1..4564df76 100644 --- a/.changeset/prompt-eof-spin.md +++ b/.changeset/prompt-eof-spin.md @@ -2,4 +2,4 @@ "@bunny.net/cli": patch --- -fix(core): prompts no longer spin at 100% CPU forever when stdin closes before an answer (CI, cron, `< /dev/null`); every prompt now aborts fast at EOF, input prompts and destructive confirmations exit non-zero with a hint naming the flag to pass (`--force` or the value flag), offer-style prompts decline and continue, and piped answers keep working +fix(core): prompts no longer spin at 100% CPU forever when run without a terminal (CI, cron, `< /dev/null`); prompts now require an interactive terminal, so input prompts and destructive confirmations fail fast with exit 1 and a hint naming the flag to pass (`--force` or the value flag), offer-style prompts decline and continue, and piped prompt answers are no longer supported (pass flags instead) diff --git a/AGENTS.md b/AGENTS.md index 3575bd48..204d044d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,7 +206,7 @@ bunny-cli/ │ │ ├── stats.ts # Shared stats rendering: sumChart(), renderBarChart(), formatBucketLabel() (UTC date labels), BAR_WIDTH (used by dns/zone/stats + scripts/stats) │ │ ├── stats.test.ts # Tests for stats helpers │ │ ├── types.ts # GlobalArgs, OutputFormat, and shared type definitions -│ │ ├── ui.ts # prompts() EOF-safe wrapper (always import it from here, never from the prompts package: raw prompts spins at 100% CPU when stdin hits EOF), readPassword(), confirm() (gates throw with exit 1 when stdin closes unanswered; pass optional: true for offer prompts that should decline and continue), confirmTyped(), requireConfirmable() (unattended runs must pass --force instead of hanging on a prompt), spinner() wrappers +│ │ ├── ui.ts # prompts() terminal-safe wrapper (always import it from here, never from the prompts package: raw prompts spins at 100% CPU when stdin hits EOF; the wrapper refuses non-TTY stdin up front, piped answers unsupported, prompts.inject() exempt), readPassword(), confirm() (gates throw with exit 1 when unanswerable; pass optional: true for offer prompts that should decline and continue), confirmTyped(), requireConfirmable() (unattended runs must pass --force instead of hanging on a prompt), spinner() wrappers │ │ ├── ui.test.ts # Tests for requireConfirmable (no-TTY guard, --force bypass) │ │ └── version.ts # VERSION constant from package.json │ │ diff --git a/CLAUDE.md b/CLAUDE.md index 2464ee9e..23ff3e76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ This is a Bun workspace monorepo with five packages: - Use `formatTable()` / `formatKeyValue()` from `packages/cli/src/core/format.ts` for non-JSON output. - Handle `--output json` first in every handler, then pass `output` to format functions. - Use `logger` from `packages/cli/src/core/logger.ts` for all user-facing output. -- Import `prompts` from `packages/cli/src/core/ui.ts`, never from the `prompts` package directly: the wrapper aborts safely when stdin is at EOF, while the raw library spins at 100% CPU. +- Import `prompts` from `packages/cli/src/core/ui.ts`, never from the `prompts` package directly: the wrapper refuses to prompt when stdin is not an interactive terminal (the raw library spins at 100% CPU on a closed stdin). Piped prompt answers are deliberately unsupported; flags and `--force` are the automation contract. `prompts.inject()` in tests still works. - `confirm()` is a gate by default: if stdin closes before an answer it throws, so the command exits non-zero. Pass `optional: true` only for offer-style prompts (link this directory? save to .env?) where declining is a normal outcome and the command should continue. - Throw `UserError` for expected errors. - Import API clients from `@bunny.net/openapi-client`, not relative paths. Import generated types from the per-API entrypoints (`@bunny.net/openapi-client/`, e.g. `@bunny.net/openapi-client/core`); the older `generated/.d.ts` paths remain supported. diff --git a/packages/cli/src/core/ui.test.ts b/packages/cli/src/core/ui.test.ts index 268f2c91..f3c56d08 100644 --- a/packages/cli/src/core/ui.test.ts +++ b/packages/cli/src/core/ui.test.ts @@ -40,11 +40,17 @@ test("optional confirm declines quietly when stdin is at EOF", async () => { expect(run.stdout).toContain("result:false"); }, 10_000); -test("confirm still reads piped answers", async () => { - expect((await runConfirm(new Blob(["y\n"]))).stdout).toContain("result:true"); - expect((await runConfirm(new Blob(["n\n"]))).stdout).toContain( - "result:false", +// Piped answers are deliberately unsupported: flags and --force are the automation contract. +test("piped stdin is refused instead of prompted", async () => { + const gate = await runConfirm(new Blob(["y\n"])); + expect(gate.exitCode).not.toBe(0); + expect(gate.stderr).toContain("Confirmation required"); + const offer = await runConfirm( + new Blob(["y\n"]), + 'confirm("sure?", { optional: true })', ); + expect(offer.exitCode).toBe(0); + expect(offer.stdout).toContain("result:false"); }, 10_000); // Only ui.ts may import the raw library: its EOF-safe prompts() wrapper is what keeps CI runs from spinning (type-only imports and prompts.inject in tests are fine). diff --git a/packages/cli/src/core/ui.ts b/packages/cli/src/core/ui.ts index d27922bd..00cac833 100644 --- a/packages/cli/src/core/ui.ts +++ b/packages/cli/src/core/ui.ts @@ -6,25 +6,38 @@ import { logger } from "./logger.ts"; let stdinEnded = false; let eofWarned = false; -// Destroying stdin stops the poll; the escape code re-shows the cursor that prompts hid. -function abortPromptAtEof(): null { +// Destroying stdin stops the library's poll; the escape code re-shows the cursor in case a prompt already hid it. +function abortUnanswerablePrompt(): null { process.stdin.destroy(); if (!eofWarned) { eofWarned = true; process.stdout.write(process.stdout.isTTY ? "\x1b[?25h\n" : "\n"); logger.warn( - "stdin closed before the prompt was answered. Pass the value as a flag, or --force to skip confirmations.", + "Can't prompt: stdin is not an interactive terminal. Pass values as flags, or --force to skip confirmations.", ); } return null; } -// prompts busy-polls a closed stdin (100% CPU, forever), so EOF must abort the prompt; the null result maps to "cancelled" at each call site. +// Injected test answers resolve without touching stdin, so they are exempt from the terminal requirement. +function hasInjectedAnswers(): boolean { + const injected = (promptsLib as unknown as { _injected?: unknown[] }) + ._injected; + return (injected?.length ?? 0) > 0; +} + +// Prompts require an interactive terminal: piped stdin is refused up front, and the EOF race below is a backstop for a terminal that hangs up mid-prompt, where the prompts library would otherwise busy-poll the dead stream at 100% CPU forever. The null result maps to "cancelled" at each call site. async function promptOrEof( run: () => Promise, ): Promise { - if (stdinEnded || process.stdin.readableEnded || process.stdin.destroyed) { - return abortPromptAtEof(); + if ( + !hasInjectedAnswers() && + (!process.stdin.isTTY || + stdinEnded || + process.stdin.readableEnded || + process.stdin.destroyed) + ) { + return abortUnanswerablePrompt(); } let onEnd = () => {}; const eof = new Promise((resolve) => { @@ -37,16 +50,16 @@ async function promptOrEof( }); try { const result = await Promise.race([run(), eof]); - return result === null ? abortPromptAtEof() : result; + return result === null ? abortUnanswerablePrompt() : result; } finally { process.stdin.off("end", onEnd); } } /** - * EOF-safe drop-in for the `prompts` library; always use this instead of - * importing `prompts` directly. Same call shape, but when stdin can no longer - * answer (closed, ended, `< /dev/null`) it returns `{}` instead of spinning, + * Terminal-safe drop-in for the `prompts` library; always use this instead of + * importing `prompts` directly. Same call shape, but when stdin cannot answer + * (not a terminal, closed, `< /dev/null`) it returns `{}` without prompting, * so missing answers surface as `undefined` exactly like a Ctrl-C cancel. */ export async function prompts( @@ -77,7 +90,7 @@ export async function readPassword(message: string): Promise { // Unanswerable gate confirmations must exit non-zero: agents and CI trust exit codes, and a 0 after "Cancelled." reads as success for work that never happened. function stdinClosedError(): UserError { return new UserError( - "Confirmation required, but stdin closed before the prompt was answered.", + "Confirmation required, but stdin is not an interactive terminal.", "Re-run with --force to skip the confirmation.", ); } diff --git a/skills/bunny-cli/SKILL.md b/skills/bunny-cli/SKILL.md index bbd7686a..28f9c9b9 100644 --- a/skills/bunny-cli/SKILL.md +++ b/skills/bunny-cli/SKILL.md @@ -100,4 +100,4 @@ Available on every command: - **Forgetting to authenticate**: Run `bunny login` first. Without it, commands fail with a missing API key error. Use `bunny api GET /user` to verify. - **Hardcoding API keys in scripts**: Use `BUNNYNET_API_KEY` env var or `--api-key` flag instead of embedding keys. Better yet, use `bunny login` profiles. -- **Relying on prompts in automation**: Prompts are for terminals. In scripts, CI, and agent sessions, pass every value as a flag and add `--force` to skip confirmations. A command that would need a prompt with stdin closed does not wait: it fails fast with exit code 1 and names the flag to pass, so treat that as "retry with flags", never as success. +- **Relying on prompts in automation**: Prompts require an interactive terminal; piped answers are not supported. In scripts, CI, and agent sessions, pass every value as a flag and add `--force` to skip confirmations. A command that would need a prompt without a terminal does not wait: it fails fast with exit code 1 and names the flag to pass, so treat that as "retry with flags", never as success. From 8e291f6ab384d38a5b0f742d120a95a1ec8a5f77 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 20 Aug 2026 14:28:43 +0100 Subject: [PATCH 3/3] fix(registries): fail update when no flags and no terminal An unattended registries update with no flags kept every existing value, sent an unchanged PUT, and reported success. It now exits non-zero up front naming the flags, matching the other update commands. Also pin the prompts.inject() escape hatch with a test that runs against the non-TTY test runner. --- .../cli/src/commands/registries/update.ts | 21 ++++++++++++------- packages/cli/src/core/ui.test.ts | 9 +++++++- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/registries/update.ts b/packages/cli/src/commands/registries/update.ts index c40016a0..0b52689e 100644 --- a/packages/cli/src/commands/registries/update.ts +++ b/packages/cli/src/commands/registries/update.ts @@ -4,7 +4,7 @@ import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; -import { prompts, spinner } from "../../core/ui.ts"; +import { isInteractive, prompts, spinner } from "../../core/ui.ts"; const COMMAND = "update "; const DESCRIPTION = "Update a container registry."; @@ -59,6 +59,17 @@ export const registryUpdateCommand = defineCommand({ verbose, apiKey, }) => { + const flagsProvided = Boolean( + nameFlag || usernameFlag !== undefined || passwordFlag !== undefined, + ); + // Without flags this command is a pure interactive editor; unattended it would keep every value and report a no-op update as success. + if (!flagsProvided && !isInteractive(output)) { + throw new UserError( + "No changes requested.", + "Pass --name, or --username and --password, or run in a terminal to edit interactively.", + ); + } + const config = resolveConfig(profile, apiKey, verbose); const client = createMcClient(clientOptions(config, verbose)); @@ -73,13 +84,9 @@ export const registryUpdateCommand = defineCommand({ throw new UserError(`Registry ${registryId} not found.`); } - const nonInteractive = Boolean( - nameFlag || usernameFlag !== undefined || passwordFlag !== undefined, - ); - // Resolve display name: flag → keep existing → prompt. let displayName = nameFlag ?? existing.displayName ?? ""; - if (!nonInteractive) { + if (!flagsProvided) { const { value } = await prompts({ type: "text", name: "value", @@ -123,7 +130,7 @@ export const registryUpdateCommand = defineCommand({ if (!password) { throw new UserError("Password is required when rotating credentials."); } - } else if (!nonInteractive) { + } else if (!flagsProvided) { const { value: rotate } = await prompts({ type: "confirm", name: "value", diff --git a/packages/cli/src/core/ui.test.ts b/packages/cli/src/core/ui.test.ts index f3c56d08..88ddbdcd 100644 --- a/packages/cli/src/core/ui.test.ts +++ b/packages/cli/src/core/ui.test.ts @@ -1,5 +1,12 @@ import { expect, test } from "bun:test"; -import { requireConfirmable } from "./ui.ts"; +import promptsLib from "prompts"; +import { confirm, requireConfirmable } from "./ui.ts"; + +// Pins the injection escape hatch: this runner has no TTY, so if the wrapper's probe of the library's injected-answers state ever breaks, this fails instead of every inject-driven test silently getting cancelled prompts. +test("prompts.inject() bypasses the terminal requirement", async () => { + promptsLib.inject([true]); + expect(await confirm("sure?")).toBe(true); +}); const UI_PATH = new URL("./ui.ts", import.meta.url).pathname;