diff --git a/.changeset/storage-behavioral-consistency.md b/.changeset/storage-behavioral-consistency.md new file mode 100644 index 00000000..2ec24141 --- /dev/null +++ b/.changeset/storage-behavioral-consistency.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": patch +--- + +fix(storage): shared TTY detection, non-interactive guard and cancel handling for zones update, aligned --force semantics, and linked-zone fallback for domains commands diff --git a/AGENTS.md b/AGENTS.md index 8226df37..7da07dda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -364,9 +364,9 @@ bunny-cli/ │ │ │ │ ├── add.ts # Create a storage zone (prompts for name + region when omitted; offers/--pull-zone creates a pull zone via core/hostnames createPullZone, then offers/--domain a custom domain via setupHostname). Under --output json it stays non-interactive: --domain is attached via addHostname (no DNS/SSL prompts) and reported in the CustomDomain field (with cnameTarget or error) │ │ │ │ ├── show.ts # Show zone details (region, replication, hostname, usage; adds S3 endpoint rows when S3-enabled) │ │ │ │ ├── credentials.ts # S3 credentials / tool config for the zone (alias: creds; --format, --read-only, --show-secret); table masks the secret unless --show-secret, JSON/--format always emit it in full -│ │ │ │ ├── update.ts # Update zone settings (custom 404, rewrite 404->200, replication); replication is additive (replicas can't be removed, so existing ones are kept and the prompt only offers new regions, confirming before adding); interactive pre-filled editor when no flags, non-interactive under --output json +│ │ │ │ ├── update.ts # Update zone settings (custom 404, rewrite 404->200, replication); replication is additive (replicas can't be removed, so existing ones are kept and the prompt only offers new regions, confirming before adding); interactive pre-filled editor when no flags (a mid-flow cancel aborts the whole edit); --output json/non-TTY/--force require flags and error "No changes requested." without them │ │ │ │ ├── remove.ts # Delete a storage zone and its files (alias: rm) -│ │ │ │ └── hostnames/index.ts # Mounts core/hostnames createHostnamesCommands as "storage zone domains" (alias hostnames); resolver maps a storage zone (name/ID positional, --pull-zone) to its linked pull zone +│ │ │ │ └── hostnames/index.ts # Mounts core/hostnames createHostnamesCommands as "storage zone domains" (alias hostnames); resolver maps a storage zone (name/ID positional, else linked zone, else picker via resolveStorageZoneInteractive; --pull-zone) to its linked pull zone │ │ │ └── file/ # `bunny storage files` (canonical: files; aliases: file); zone is the --zone/-z flag (defaults to linked zone), the positional is the file/path │ │ │ ├── index.ts # defineNamespace("files", ...) │ │ │ ├── list.ts # List files in a directory (alias: ls; directories first; [path] positional, --zone flag) @@ -989,11 +989,11 @@ bunny │ │ ├── list List all storage zones (alias: ls) │ │ ├── add [name] [--region] [--replication] [--pull-zone] [--pull-zone-name] [--domain] [--force/-f] Create a storage zone (prompts for name + region when omitted; offers/--pull-zone creates a pull zone to serve it on the web, then offers/--domain a custom domain via setupHostname; replicas are permanent so adding any is confirmed; --force/--output json skip all prompts and use flag values only) │ │ ├── show [zone] Show zone details (region, replication, hostname, usage) -│ │ ├── update [zone] [--custom-404-path] [--rewrite-404-to-200] [--replication] [--force/-f] Update zone settings (edits interactively pre-filled when no flags; replication is additive and adding a replica is confirmed unless --force; non-interactive under --output json) +│ │ ├── update [zone] [--custom-404-path] [--rewrite-404-to-200] [--replication] [--force/-f] Update zone settings (edits interactively pre-filled when no flags; replication is additive and adding a replica is confirmed unless --force; --force/--output json/non-TTY require flags and error "No changes requested." without them) │ │ ├── remove [zone] [--force] Delete a storage zone and its files (alias: rm) │ │ ├── credentials [zone] [--format rclone|aws|s3cmd|env] [--read-only] [--show-secret] (alias: creds) │ │ │ S3 credentials for the zone (name = access key, password = secret); --format emits tool config, else table/--output json; table masks the secret unless --show-secret -│ │ └── domains (canonical; alias: hostnames) custom domains on the zone's pull zone; mounts core/hostnames createHostnamesCommands; resolver maps the storage zone to its linked pull zone +│ │ └── domains (canonical; alias: hostnames) custom domains on the zone's pull zone; mounts core/hostnames createHostnamesCommands; resolver maps the storage zone (positional, else linked zone, else picker) to its linked pull zone │ ├── files (canonical; aliases: file) [--zone|-z] defaults to the linked zone on every file command │ │ ├── list [path] [--zone] (alias: ls) List files in a directory (trailing slash on path) │ │ ├── upload [--zone] [--to] [--checksum] [--content-type] Upload a local file diff --git a/packages/cli/src/commands/storage/interactive.ts b/packages/cli/src/commands/storage/interactive.ts index 1cf9d563..22b443d2 100644 --- a/packages/cli/src/commands/storage/interactive.ts +++ b/packages/cli/src/commands/storage/interactive.ts @@ -2,7 +2,7 @@ import prompts from "prompts"; import { UserError } from "../../core/errors.ts"; import { loadManifest } from "../../core/manifest.ts"; import type { OutputFormat } from "../../core/types.ts"; -import { spinner } from "../../core/ui.ts"; +import { isInteractive, spinner } from "../../core/ui.ts"; import { type CoreClient, fetchStorageZone, @@ -16,6 +16,7 @@ export async function resolveStorageZoneInteractive( client: CoreClient, ref: string | undefined, output?: OutputFormat, + opts?: { force?: boolean }, ): Promise { if (ref) { const spin = spinner("Resolving storage zone..."); @@ -39,8 +40,8 @@ export async function resolveStorageZoneInteractive( } } - // No zone given: only fall back to the picker when we can actually prompt. - if (output === "json" || !process.stdout.isTTY) { + // No zone given: only fall back to the picker when we can actually prompt (--force opts out too). + if (opts?.force || !isInteractive(output)) { throw new UserError( "A storage zone is required.", "Pass the zone name or ID.", diff --git a/packages/cli/src/commands/storage/link.ts b/packages/cli/src/commands/storage/link.ts index d6646fd7..626926ca 100644 --- a/packages/cli/src/commands/storage/link.ts +++ b/packages/cli/src/commands/storage/link.ts @@ -7,7 +7,7 @@ import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; import { saveManifest } from "../../core/manifest.ts"; import type { OutputFormat } from "../../core/types.ts"; -import { spinner } from "../../core/ui.ts"; +import { isInteractive, spinner } from "../../core/ui.ts"; import { fetchStorageZones, resolveStorageZone, @@ -51,7 +51,7 @@ export const storageLinkCommand = defineCommand({ } // Without a TTY (or in JSON mode) there is no one to answer the picker. - if (output === "json" || !process.stdout.isTTY) { + if (!isInteractive(output)) { throw new UserError( "A storage zone is required.", "Pass the zone name or ID.", diff --git a/packages/cli/src/commands/storage/zone/add.ts b/packages/cli/src/commands/storage/zone/add.ts index 621bdf70..52db80ca 100644 --- a/packages/cli/src/commands/storage/zone/add.ts +++ b/packages/cli/src/commands/storage/zone/add.ts @@ -11,7 +11,7 @@ import { setupHostname, } from "../../../core/hostnames/index.ts"; import { logger } from "../../../core/logger.ts"; -import { confirm, spinner } from "../../../core/ui.ts"; +import { confirm, isInteractive, spinner } from "../../../core/ui.ts"; import { type StorageZoneModel, toSafeStorageZone } from "../api.ts"; import { confirmAddedReplicationRegions, @@ -104,8 +104,7 @@ export const storageZoneAddCommand = defineCommand({ const client = createCoreClient(clientOptions(config, verbose)); // JSON output, non-TTY, and --force all stay non-interactive; values must come from flags. - const interactive = - output !== "json" && process.stdout.isTTY === true && !force; + const interactive = isInteractive(output) && !force; // The region and replication choices both drive storage pricing, so flag it up front. if (interactive && (region === undefined || replication === undefined)) { diff --git a/packages/cli/src/commands/storage/zone/hostnames/index.ts b/packages/cli/src/commands/storage/zone/hostnames/index.ts index 86287952..10ea304d 100644 --- a/packages/cli/src/commands/storage/zone/hostnames/index.ts +++ b/packages/cli/src/commands/storage/zone/hostnames/index.ts @@ -6,7 +6,9 @@ import { createHostnamesCommands, type ResolvedPullZone, } from "../../../../core/hostnames/index.ts"; -import { resolveStorageZone, type StorageZoneModel } from "../../api.ts"; +import type { OutputFormat } from "../../../../core/types.ts"; +import type { StorageZoneModel } from "../../api.ts"; +import { resolveStorageZoneInteractive } from "../../interactive.ts"; // Pick the storage zone's linked pull zone; require --pull-zone when several exist. function resolvePullZoneId(zone: StorageZoneModel, flag?: number): number { @@ -46,19 +48,19 @@ async function resolveStorageZonePullZone(args: { profile: string; apiKey?: string; verbose: boolean; + output: OutputFormat; zone?: string; "pull-zone"?: number; }): Promise { const config = resolveConfig(args.profile, args.apiKey, args.verbose); const coreClient = createCoreClient(clientOptions(config, args.verbose)); - if (!args.zone) { - throw new UserError( - "A storage zone is required.", - "Pass the zone name or ID.", - ); - } - const zone = await resolveStorageZone(coreClient, args.zone); + // Explicit ref → linked zone (.bunny/storage.json) → interactive picker, like every other storage command. + const zone = await resolveStorageZoneInteractive( + coreClient, + args.zone, + args.output, + ); const pullZoneId = resolvePullZoneId(zone, args["pull-zone"]); return { pullZoneId, coreClient }; @@ -84,6 +86,7 @@ export const storageZoneHostnamesCommands = createHostnamesCommands({ profile: args.profile, apiKey: args.apiKey, verbose: args.verbose, + output: args.output, zone: args.zone as string | undefined, "pull-zone": args["pull-zone"] as number | undefined, }), diff --git a/packages/cli/src/commands/storage/zone/update.ts b/packages/cli/src/commands/storage/zone/update.ts index d08a0b60..f0cb41f9 100644 --- a/packages/cli/src/commands/storage/zone/update.ts +++ b/packages/cli/src/commands/storage/zone/update.ts @@ -5,7 +5,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 { spinner } from "../../../core/ui.ts"; +import { isInteractive, spinner } from "../../../core/ui.ts"; import type { StorageZoneModel, StorageZoneSettingsModel } from "../api.ts"; import { confirmAddedReplicationRegions, @@ -88,14 +88,24 @@ async function promptSettings( })), }); - const answers = await prompts(questions); - if (Object.keys(answers).length === 0) - throw new UserError("Update cancelled."); + // Abort the whole edit on cancel so a mid-flow Ctrl+C never applies partial answers. + let cancelled = false; + const answers = await prompts(questions, { + onCancel: () => { + cancelled = true; + return false; + }, + }); + if (cancelled) throw new UserError("Update cancelled."); + // Omit ReplicationZones when nothing new was picked so the PATCH body leaves replication untouched. + const newReplicas: string[] = answers.replication ?? []; return { Custom404FilePath: answers.custom404Path || null, Rewrite404To200: answers.rewrite404To200, - ReplicationZones: [...existing, ...(answers.replication ?? [])], + ReplicationZones: newReplicas.length + ? [...existing, ...newReplicas] + : undefined, }; } @@ -133,23 +143,26 @@ export const storageZoneUpdateCommand = defineCommand({ alias: "f", type: "boolean", default: false, - describe: - "Skip the confirmation prompt when adding replication regions", + describe: "Skip prompts and confirmations (use flag values only)", }), handler: async (args) => { const { zone: ref, profile, output, verbose, apiKey } = args; const hasFlags = hasAnyFlag(args); - // JSON output stays non-interactive; settings must come from flags. - if (!hasFlags && output === "json") { - throw new UserError("Nothing to update.", FLAG_HINT); + // JSON output, non-TTY, and --force all stay non-interactive; settings must come from flags. + const interactive = isInteractive(output) && !args.force; + if (!hasFlags && !interactive) { + throw new UserError("No changes requested.", FLAG_HINT); } const config = resolveConfig(profile, apiKey, verbose); const client = createCoreClient(clientOptions(config, verbose)); - const zone = await resolveStorageZoneInteractive(client, ref, output); + const zone = await resolveStorageZoneInteractive(client, ref, output, { + force: args.force, + }); + // Flags take full precedence over the editor: a partial set of flags is a partial update. const settings = hasFlags ? settingsFromFlags(args, zone.Region ?? undefined) : await promptSettings(zone);