From 439b6e76d162ddcedace9a16b06fca285d451703 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Fri, 10 Jul 2026 11:43:34 +0200 Subject: [PATCH 1/4] fix(storage): align prompt fallbacks and TTY handling with CLI conventions - Use the shared isInteractive() gate (stdin + stdout TTY, json-aware) instead of stdout-only checks in the zone resolver, link, and zone add - zones update: require field flags whenever non-interactive (json, non-TTY, or --force) instead of only under --output json, and abort the whole interactive edit on a mid-flow cancel rather than applying partial answers - zones update --force now matches zones add: skip prompts and confirmations, use flag values only - storage zone domains commands now resolve the zone like every other storage command: explicit ref, then linked zone, then picker --- .changeset/storage-behavioral-consistency.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/storage-behavioral-consistency.md 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 From 8ed72ed5397801bb0e6c786139d046b0e04f2d03 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Fri, 10 Jul 2026 12:29:29 +0200 Subject: [PATCH 2/4] feat(storage): close convention gaps with the other command groups - zones remove: double confirmation (yes/no, then type the zone name) unless --force, matching db and scripts delete; also removes a stale .bunny/storage.json that pointed at the deleted zone - New storage unlink command mirroring dns zones unlink - resolveStorageZoneInteractive takes DNS-style opts (output, force, offerLink, ignoreManifest); the picker now offers to link the directory on non-destructive commands, and link reuses the resolver instead of duplicating it - The zone-required error now hints at bunny storage link - Replication confirmation defaults to no: adding a replica is permanent and adds cost --- .changeset/storage-convention-parity.md | 5 ++ AGENTS.md | 12 +-- .../cli/src/commands/storage/constants.ts | 2 +- .../cli/src/commands/storage/file/download.ts | 5 +- .../cli/src/commands/storage/file/list.ts | 5 +- .../cli/src/commands/storage/file/remove.ts | 5 +- .../cli/src/commands/storage/file/upload.ts | 5 +- packages/cli/src/commands/storage/index.ts | 2 + .../cli/src/commands/storage/interactive.ts | 68 +++++++++++---- packages/cli/src/commands/storage/link.ts | 84 +++---------------- packages/cli/src/commands/storage/unlink.ts | 54 ++++++++++++ .../src/commands/storage/zone/credentials.ts | 5 +- .../commands/storage/zone/hostnames/index.ts | 9 +- .../cli/src/commands/storage/zone/remove.ts | 24 +++++- .../cli/src/commands/storage/zone/show.ts | 5 +- .../cli/src/commands/storage/zone/update.ts | 4 +- 16 files changed, 188 insertions(+), 106 deletions(-) create mode 100644 .changeset/storage-convention-parity.md create mode 100644 packages/cli/src/commands/storage/unlink.ts diff --git a/.changeset/storage-convention-parity.md b/.changeset/storage-convention-parity.md new file mode 100644 index 00000000..6054f330 --- /dev/null +++ b/.changeset/storage-convention-parity.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": patch +--- + +fix(storage): type-to-confirm zone deletion, new storage unlink command, offer-to-link from the zone picker, and the replication confirmation now defaults to no diff --git a/AGENTS.md b/AGENTS.md index 8f2cd21e..11578448 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -350,8 +350,9 @@ bunny-cli/ │ │ │ ├── index.ts # defineNamespace("storage", ...): registers zone + file groups + link + regions + docs (+ hidden bucket aliases) │ │ │ ├── api.ts # CoreClient type, fetchStorageZones/fetchStorageZone, resolveStorageZone (name-or-ID to zone, re-fetched by ID), toSafeStorageZone (strips Password/ReadOnlyPassword; used by every command that emits a raw zone as JSON: show/list/add) │ │ │ ├── constants.ts # STORAGE_REGIONS (from SDK enum; /storagezone/regions API endpoint is not reliable) + replicationChoices/normalizeReplicationRegions (replication uses the same regions minus the primary; the SDK file ZoneSchema is the physical footprint, NOT the create input) + STORAGE_MANIFEST/StorageZoneManifest (.bunny/storage.json, written by storage link) -│ │ │ ├── interactive.ts # resolveStorageZoneInteractive: explicit name/ID arg → linked manifest (.bunny/storage.json, fetched by ID even when non-interactive) → zone picker -│ │ │ ├── link.ts # Link the current directory to a storage zone (.bunny/storage.json); bunny storage link [zone] +│ │ │ ├── interactive.ts # resolveStorageZoneInteractive: explicit name/ID arg → linked manifest (.bunny/storage.json, fetched by ID even when non-interactive) → zone picker; opts: force (no picker), offerLink (picker offers to link the directory), ignoreManifest (always pick, used by link); writeStorageManifest writer shared with link +│ │ │ ├── link.ts # Link the current directory to a storage zone (.bunny/storage.json) via the shared resolver with ignoreManifest; bunny storage link [zone] +│ │ │ ├── unlink.ts # Remove .bunny/storage.json (confirmation unless --force); bunny storage unlink │ │ │ ├── files-api.ts # Adapter over @bunny.net/storage-sdk: connectStorageZone (zone → SDK connection, Region→StorageRegion enum + password), listFiles/uploadFile/downloadFile/deleteFile (deleteFile translates the SDK's boolean return into a UserError) │ │ │ ├── files-api.test.ts # Tests for region mapping + delete error translation (NOT the SDK's URL building) │ │ │ ├── s3.ts # S3 (closed preview): isS3Enabled (StorageZoneType===1), s3Endpoint (-s3.storage.bunnycdn.com), s3Credentials (name=access key, password=secret), renderS3ToolConfig (rclone/aws/s3cmd/env formatters) @@ -365,7 +366,7 @@ bunny-cli/ │ │ │ │ ├── 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 (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) +│ │ │ │ ├── remove.ts # Delete a storage zone and its files (alias: rm); double confirmation (yes/no + type the zone name) unless --force, and removes a stale .bunny/storage.json that pointed at the deleted 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", ...) @@ -988,13 +989,13 @@ bunny │ │ Enable DNS query logging │ └── disable [domain] [--force] Disable DNS query logging ├── storage (experimental, hidden from help and landing page) -│ │ Two resource groups: `zones` (the zone, via core API + account key) and `files` (zone contents, via @bunny.net/storage-sdk + the zone password/region host, resolved automatically). The zone is a name or numeric ID; `zones` commands take it as the `[zone]` positional, `files` commands as the `--zone`/`-z` flag (the positional is the file/path). When the zone is omitted it resolves from a linked zone (`bunny storage link`) then an interactive picker. +│ │ Two resource groups: `zones` (the zone, via core API + account key) and `files` (zone contents, via @bunny.net/storage-sdk + the zone password/region host, resolved automatically). The zone is a name or numeric ID; `zones` commands take it as the `[zone]` positional, `files` commands as the `--zone`/`-z` flag (the positional is the file/path). When the zone is omitted it resolves from a linked zone (`bunny storage link`) then an interactive picker, which offers to link the directory to the picked zone (except on destructive commands). │ ├── zones (canonical; aliases: zone; hidden: bucket, buckets) │ │ ├── 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; --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) +│ │ ├── remove [zone] [--force] Delete a storage zone and its files (alias: rm); double confirmation (yes/no + type the zone name) unless --force; cleans up a stale .bunny/storage.json │ │ ├── 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 (positional, else linked zone, else picker) to its linked pull zone @@ -1004,6 +1005,7 @@ bunny │ │ ├── download [--zone] [--out] Download a file │ │ └── remove [--zone] [--force] (alias: rm) Delete a file or directory (trailing slash = recursive) │ ├── link [zone] Link the current directory to a storage zone (.bunny/storage.json); interactive picker when omitted +│ ├── unlink [--force/-f] Remove .bunny/storage.json, unlinking this directory (confirmation unless --force) │ ├── regions List available storage regions (replication uses the same set minus the primary) │ └── docs Open storage documentation in browser ├── db diff --git a/packages/cli/src/commands/storage/constants.ts b/packages/cli/src/commands/storage/constants.ts index 27fd39b1..9bbdd76d 100644 --- a/packages/cli/src/commands/storage/constants.ts +++ b/packages/cli/src/commands/storage/constants.ts @@ -61,6 +61,6 @@ export async function confirmAddedReplicationRegions( if (added.length === 0) return true; return confirm( `Add replication region(s) ${added.join(", ")}? They cannot be removed once added.`, - { force: opts?.force, initial: true }, + { force: opts?.force }, ); } diff --git a/packages/cli/src/commands/storage/file/download.ts b/packages/cli/src/commands/storage/file/download.ts index 2ffc1b37..a1b3a76d 100644 --- a/packages/cli/src/commands/storage/file/download.ts +++ b/packages/cli/src/commands/storage/file/download.ts @@ -62,7 +62,10 @@ export const storageFileDownloadCommand = defineCommand({ 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, + offerLink: true, + }); const connection = connectStorageZone(zone); const dest = out ?? basename(path); diff --git a/packages/cli/src/commands/storage/file/list.ts b/packages/cli/src/commands/storage/file/list.ts index d6ae3b78..8d7e061a 100644 --- a/packages/cli/src/commands/storage/file/list.ts +++ b/packages/cli/src/commands/storage/file/list.ts @@ -43,7 +43,10 @@ export const storageFileListCommand = defineCommand({ 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, + offerLink: true, + }); const connection = connectStorageZone(zone); const spin = spinner("Listing files..."); diff --git a/packages/cli/src/commands/storage/file/remove.ts b/packages/cli/src/commands/storage/file/remove.ts index 4d1df1ef..39b3f41f 100644 --- a/packages/cli/src/commands/storage/file/remove.ts +++ b/packages/cli/src/commands/storage/file/remove.ts @@ -60,7 +60,10 @@ export const storageFileRemoveCommand = defineCommand({ 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, + offerLink: true, + }); const connection = connectStorageZone(zone); // A trailing slash deletes a directory and everything under it, recursively. diff --git a/packages/cli/src/commands/storage/file/upload.ts b/packages/cli/src/commands/storage/file/upload.ts index 96ad10c6..2a2c26da 100644 --- a/packages/cli/src/commands/storage/file/upload.ts +++ b/packages/cli/src/commands/storage/file/upload.ts @@ -82,7 +82,10 @@ export const storageFileUploadCommand = defineCommand({ 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, + offerLink: true, + }); const connection = connectStorageZone(zone); const spin = spinner(`Uploading ${remotePath}...`); diff --git a/packages/cli/src/commands/storage/index.ts b/packages/cli/src/commands/storage/index.ts index 0a204ccf..41282d14 100644 --- a/packages/cli/src/commands/storage/index.ts +++ b/packages/cli/src/commands/storage/index.ts @@ -3,6 +3,7 @@ import { storageDocsCommand } from "./docs.ts"; import { storageFileNamespace } from "./file/index.ts"; import { storageLinkCommand } from "./link.ts"; import { storageRegionsCommand } from "./regions.ts"; +import { storageUnlinkCommand } from "./unlink.ts"; import { storageZoneHiddenAliases, storageZoneNamespace, @@ -12,6 +13,7 @@ export const storageNamespace = defineNamespace("storage", false, [ storageZoneNamespace, storageFileNamespace, storageLinkCommand, + storageUnlinkCommand, storageRegionsCommand, storageDocsCommand, ...storageZoneHiddenAliases, diff --git a/packages/cli/src/commands/storage/interactive.ts b/packages/cli/src/commands/storage/interactive.ts index 22b443d2..85b433d0 100644 --- a/packages/cli/src/commands/storage/interactive.ts +++ b/packages/cli/src/commands/storage/interactive.ts @@ -1,8 +1,9 @@ import prompts from "prompts"; import { UserError } from "../../core/errors.ts"; -import { loadManifest } from "../../core/manifest.ts"; +import { logger } from "../../core/logger.ts"; +import { loadManifest, saveManifest } from "../../core/manifest.ts"; import type { OutputFormat } from "../../core/types.ts"; -import { isInteractive, spinner } from "../../core/ui.ts"; +import { confirm, isInteractive, spinner } from "../../core/ui.ts"; import { type CoreClient, fetchStorageZone, @@ -12,11 +13,39 @@ import { } from "./api.ts"; import { STORAGE_MANIFEST, type StorageZoneManifest } from "./constants.ts"; +/** Write `.bunny/storage.json` pointing at the zone. */ +export function writeStorageManifest(zone: StorageZoneModel): void { + saveManifest(STORAGE_MANIFEST, { + id: zone.Id ?? 0, + name: zone.Name ?? undefined, + }); +} + +// 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; + writeStorageManifest(zone); + logger.success(`Linked this directory to storage zone ${zone.Name}.`); +} + +/** + * Resolve a zone by name/ID, or prompt the user to pick one when no + * reference is given. Manages its own spinner so it never spins over a prompt. + * + * When `offerLink` is set and the zone is chosen via the picker (not an + * explicit ref or the existing manifest), offer to link the directory to it. + * Pass `ignoreManifest` to always pick (used when (re)linking a directory). + * Never prompts non-interactively (json output, no TTY, or `force`): errors instead. + */ export async function resolveStorageZoneInteractive( client: CoreClient, ref: string | undefined, - output?: OutputFormat, - opts?: { force?: boolean }, + opts: { + output?: OutputFormat; + force?: boolean; + offerLink?: boolean; + ignoreManifest?: boolean; + } = {}, ): Promise { if (ref) { const spin = spinner("Resolving storage zone..."); @@ -29,22 +58,24 @@ export async function resolveStorageZoneInteractive( } // A zone linked via `bunny storage link` stands in for an explicit ref, even unattended. - const manifest = loadManifest(STORAGE_MANIFEST); - if (manifest.id) { - const spin = spinner("Loading linked storage zone..."); - spin.start(); - try { - return await fetchStorageZone(client, manifest.id); - } finally { - spin.stop(); + if (!opts.ignoreManifest) { + const manifest = loadManifest(STORAGE_MANIFEST); + if (manifest.id) { + const spin = spinner("Loading linked storage zone..."); + spin.start(); + try { + return await fetchStorageZone(client, manifest.id); + } finally { + spin.stop(); + } } } // No zone given: only fall back to the picker when we can actually prompt (--force opts out too). - if (opts?.force || !isInteractive(output)) { + if (opts.force || !isInteractive(opts.output)) { throw new UserError( "A storage zone is required.", - "Pass the zone name or ID.", + "Pass the zone name or ID, or link one with `bunny storage link`.", ); } @@ -74,9 +105,16 @@ export async function resolveStorageZoneInteractive( const loadSpin = spinner("Loading storage zone..."); loadSpin.start(); + let zone: StorageZoneModel; try { - return await fetchStorageZone(client, id); + zone = await fetchStorageZone(client, id); } finally { loadSpin.stop(); } + + // The picker only runs interactively, so the link offer can't taint machine output. + if (opts.offerLink) { + await maybeLinkZone(zone); + } + return zone; } diff --git a/packages/cli/src/commands/storage/link.ts b/packages/cli/src/commands/storage/link.ts index 626926ca..5f240b04 100644 --- a/packages/cli/src/commands/storage/link.ts +++ b/packages/cli/src/commands/storage/link.ts @@ -1,19 +1,12 @@ 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 { saveManifest } from "../../core/manifest.ts"; -import type { OutputFormat } from "../../core/types.ts"; -import { isInteractive, spinner } from "../../core/ui.ts"; import { - fetchStorageZones, - resolveStorageZone, - type StorageZoneModel, -} from "./api.ts"; -import { STORAGE_MANIFEST, type StorageZoneManifest } from "./constants.ts"; + resolveStorageZoneInteractive, + writeStorageManifest, +} from "./interactive.ts"; interface LinkArgs { zone?: string; @@ -37,71 +30,18 @@ export const storageLinkCommand = defineCommand({ const config = resolveConfig(profile, apiKey, verbose); const client = createCoreClient(clientOptions(config, verbose)); - if (ref) { - const spin = spinner("Resolving storage zone..."); - spin.start(); - let zone: StorageZoneModel; - try { - zone = await resolveStorageZone(client, ref); - } finally { - spin.stop(); - } - linkZone(zone, output); - return; - } - - // Without a TTY (or in JSON mode) there is no one to answer the picker. - if (!isInteractive(output)) { - throw new UserError( - "A storage zone is required.", - "Pass the zone name or ID.", - ); - } - - const spin = spinner("Fetching storage zones..."); - spin.start(); - let zones: StorageZoneModel[]; - try { - zones = await fetchStorageZones(client); - } finally { - spin.stop(); - } - - if (zones.length === 0) { - throw new UserError( - "No storage zones found.", - 'Create one with "bunny storage zones add ".', - ); - } - - const { selected } = await prompts({ - type: "select", - name: "selected", - message: "Select a storage zone to link:", - choices: zones.map((zone) => ({ - title: `${zone.Name ?? ""} (${zone.Id})`, - value: zone, - })), + // Always re-pick: linking is how the manifest changes, so the existing one must not short-circuit. + const zone = await resolveStorageZoneInteractive(client, ref, { + output, + ignoreManifest: true, }); + writeStorageManifest(zone); - if (!selected) { - throw new UserError("Link cancelled."); + if (output === "json") { + logger.log(JSON.stringify({ id: zone.Id, name: zone.Name })); + return; } - linkZone(selected, output); + logger.success(`Linked to ${zone.Name} (${zone.Id}).`); }, }); - -function linkZone(zone: StorageZoneModel, output: OutputFormat): void { - saveManifest(STORAGE_MANIFEST, { - id: zone.Id ?? 0, - name: zone.Name ?? undefined, - }); - - if (output === "json") { - logger.log(JSON.stringify({ id: zone.Id, name: zone.Name })); - return; - } - - logger.success(`Linked to ${zone.Name} (${zone.Id}).`); -} diff --git a/packages/cli/src/commands/storage/unlink.ts b/packages/cli/src/commands/storage/unlink.ts new file mode 100644 index 00000000..059d7cf7 --- /dev/null +++ b/packages/cli/src/commands/storage/unlink.ts @@ -0,0 +1,54 @@ +import { defineCommand } from "../../core/define-command.ts"; +import { logger } from "../../core/logger.ts"; +import { loadManifest, removeManifest } from "../../core/manifest.ts"; +import { confirm } from "../../core/ui.ts"; +import { STORAGE_MANIFEST, type StorageZoneManifest } from "./constants.ts"; + +interface UnlinkArgs { + force?: boolean; +} + +export const storageUnlinkCommand = defineCommand({ + command: "unlink", + describe: `Remove .bunny/${STORAGE_MANIFEST}, unlinking this directory from its storage zone.`, + + builder: (yargs) => + yargs.option("force", { + alias: "f", + type: "boolean", + describe: "Skip the confirmation prompt", + }), + + handler: async ({ force, output }) => { + const existing = loadManifest(STORAGE_MANIFEST); + + if (!existing.id) { + if (output === "json") { + logger.log(JSON.stringify({ unlinked: false, reason: "no-manifest" })); + return; + } + logger.log( + `Nothing to unlink: no .bunny/${STORAGE_MANIFEST} in this tree.`, + ); + return; + } + + if (!force) { + const confirmed = await confirm( + `Unlink from ${existing.name ?? existing.id}?`, + ); + if (!confirmed) { + logger.log("Unlink cancelled."); + return; + } + } + + removeManifest(STORAGE_MANIFEST); + + if (output === "json") { + logger.log(JSON.stringify({ unlinked: true, id: existing.id })); + return; + } + logger.success("Unlinked."); + }, +}); diff --git a/packages/cli/src/commands/storage/zone/credentials.ts b/packages/cli/src/commands/storage/zone/credentials.ts index 8eec3fb2..e81c040e 100644 --- a/packages/cli/src/commands/storage/zone/credentials.ts +++ b/packages/cli/src/commands/storage/zone/credentials.ts @@ -78,7 +78,10 @@ export const storageZoneCredentialsCommand = defineCommand({ 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, + offerLink: true, + }); const creds = s3Credentials(zone, readOnly ?? false); if (!isS3Enabled(zone)) { diff --git a/packages/cli/src/commands/storage/zone/hostnames/index.ts b/packages/cli/src/commands/storage/zone/hostnames/index.ts index 10ea304d..2f5373c9 100644 --- a/packages/cli/src/commands/storage/zone/hostnames/index.ts +++ b/packages/cli/src/commands/storage/zone/hostnames/index.ts @@ -56,11 +56,10 @@ async function resolveStorageZonePullZone(args: { const coreClient = createCoreClient(clientOptions(config, args.verbose)); // Explicit ref → linked zone (.bunny/storage.json) → interactive picker, like every other storage command. - const zone = await resolveStorageZoneInteractive( - coreClient, - args.zone, - args.output, - ); + const zone = await resolveStorageZoneInteractive(coreClient, args.zone, { + output: args.output, + offerLink: true, + }); const pullZoneId = resolvePullZoneId(zone, args["pull-zone"]); return { pullZoneId, coreClient }; diff --git a/packages/cli/src/commands/storage/zone/remove.ts b/packages/cli/src/commands/storage/zone/remove.ts index aacbbc72..cb17c671 100644 --- a/packages/cli/src/commands/storage/zone/remove.ts +++ b/packages/cli/src/commands/storage/zone/remove.ts @@ -1,9 +1,12 @@ 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 { STORAGE_MANIFEST, type StorageZoneManifest } from "../constants.ts"; import { resolveStorageZoneInteractive } from "../interactive.ts"; interface ZoneRemoveArgs { @@ -38,7 +41,7 @@ export const storageZoneRemoveCommand = defineCommand({ 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 }); const confirmed = await confirm( `Delete storage zone ${zone.Name} and all ${zone.FilesStored ?? 0} file(s)? This cannot be undone.`, @@ -49,6 +52,19 @@ export const storageZoneRemoveCommand = defineCommand({ return; } + // Second confirmation: deleting a zone destroys its files, so require typing the name unless --force. + if (!force) { + const { value } = await prompts({ + type: "text", + name: "value", + message: `Type "${zone.Name}" to confirm:`, + }); + if (value !== zone.Name) { + logger.log("Cancelled."); + return; + } + } + const removeSpin = spinner("Deleting storage zone..."); removeSpin.start(); try { @@ -59,6 +75,11 @@ export const storageZoneRemoveCommand = defineCommand({ removeSpin.stop(); } + // Drop a manifest that pointed at the deleted zone so later commands don't resolve a ghost. + const manifest = loadManifest(STORAGE_MANIFEST); + const unlinked = manifest.id === zone.Id; + if (unlinked) removeManifest(STORAGE_MANIFEST); + if (output === "json") { logger.log( JSON.stringify( @@ -71,5 +92,6 @@ export const storageZoneRemoveCommand = defineCommand({ } logger.success(`Deleted storage zone ${zone.Name}.`); + if (unlinked) logger.dim(`Removed stale .bunny/${STORAGE_MANIFEST}.`); }, }); diff --git a/packages/cli/src/commands/storage/zone/show.ts b/packages/cli/src/commands/storage/zone/show.ts index 58980f4f..84110bdb 100644 --- a/packages/cli/src/commands/storage/zone/show.ts +++ b/packages/cli/src/commands/storage/zone/show.ts @@ -34,7 +34,10 @@ export const storageZoneShowCommand = defineCommand({ 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, + offerLink: true, + }); if (output === "json") { logger.log(JSON.stringify(toSafeStorageZone(zone), null, 2)); diff --git a/packages/cli/src/commands/storage/zone/update.ts b/packages/cli/src/commands/storage/zone/update.ts index f0cb41f9..4ee08f91 100644 --- a/packages/cli/src/commands/storage/zone/update.ts +++ b/packages/cli/src/commands/storage/zone/update.ts @@ -159,8 +159,10 @@ export const storageZoneUpdateCommand = defineCommand({ 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, + offerLink: true, }); // Flags take full precedence over the editor: a partial set of flags is a partial update. const settings = hasFlags From d2bd2303c68f65bfed60c114630741c65a49bc6b Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Fri, 10 Jul 2026 14:37:41 +0200 Subject: [PATCH 3/4] fix(storage): wording and edge-case polish - Hints and the domains factory commandPath use the canonical plural (storage zones add / storage zones domains) - Declining the replication confirm in zones add prints Cancelled. and exits cleanly instead of throwing, matching the other declines - --custom-404-path "" clears the custom 404 (null), matching the prompt's blank-for-none behavior --- .changeset/storage-wording-polish.md | 5 +++++ AGENTS.md | 2 +- packages/cli/src/commands/storage/zone/add.ts | 7 ++++--- packages/cli/src/commands/storage/zone/hostnames/index.ts | 4 ++-- packages/cli/src/commands/storage/zone/update.ts | 3 ++- 5 files changed, 14 insertions(+), 7 deletions(-) create mode 100644 .changeset/storage-wording-polish.md diff --git a/.changeset/storage-wording-polish.md b/.changeset/storage-wording-polish.md new file mode 100644 index 00000000..b96dda09 --- /dev/null +++ b/.changeset/storage-wording-polish.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": patch +--- + +fix(storage): canonical plural command paths in hints, consistent decline handling in zones add, and --custom-404-path "" now clears the custom 404 diff --git a/AGENTS.md b/AGENTS.md index 11578448..df768227 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -367,7 +367,7 @@ bunny-cli/ │ │ │ │ ├── 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 (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); double confirmation (yes/no + type the zone name) unless --force, and removes a stale .bunny/storage.json that pointed at the deleted 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 +│ │ │ │ └── hostnames/index.ts # Mounts core/hostnames createHostnamesCommands as "storage zones 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) diff --git a/packages/cli/src/commands/storage/zone/add.ts b/packages/cli/src/commands/storage/zone/add.ts index 52db80ca..83feb61c 100644 --- a/packages/cli/src/commands/storage/zone/add.ts +++ b/packages/cli/src/commands/storage/zone/add.ts @@ -169,7 +169,8 @@ export const storageZoneAddCommand = defineCommand({ replicationCodes.length && !(await confirmAddedReplicationRegions(replicationCodes, { force })) ) { - throw new UserError("Creation cancelled."); + logger.log("Cancelled."); + return; } const spin = spinner("Creating storage zone..."); @@ -295,8 +296,8 @@ export const storageZoneAddCommand = defineCommand({ coreClient: client, pullZoneId: pullZoneResult.id, domain: host, - sslHint: `bunny storage zone domains ssl ${host} ${zoneName}`, - retryHint: `bunny storage zone domains add ${host} ${zoneName}`, + sslHint: `bunny storage zones domains ssl ${host} ${zoneName}`, + retryHint: `bunny storage zones domains add ${host} ${zoneName}`, forceSsl: true, interactive, verbose, diff --git a/packages/cli/src/commands/storage/zone/hostnames/index.ts b/packages/cli/src/commands/storage/zone/hostnames/index.ts index 2f5373c9..30d7b337 100644 --- a/packages/cli/src/commands/storage/zone/hostnames/index.ts +++ b/packages/cli/src/commands/storage/zone/hostnames/index.ts @@ -27,7 +27,7 @@ function resolvePullZoneId(zone: StorageZoneModel, flag?: number): number { if (zones.length === 0) { throw new UserError( `Storage zone ${zone.Name} has no pull zone.`, - 'Create one with "bunny storage zone add --pull-zone".', + 'Create one with "bunny storage zones add --pull-zone".', ); } @@ -66,7 +66,7 @@ async function resolveStorageZonePullZone(args: { } export const storageZoneHostnamesCommands = createHostnamesCommands({ - commandPath: "storage zone domains", + commandPath: "storage zones domains", namespace: "domains", describe: "Manage custom domains for a storage zone's pull zone.", hiddenAliases: ["hostnames"], diff --git a/packages/cli/src/commands/storage/zone/update.ts b/packages/cli/src/commands/storage/zone/update.ts index 4ee08f91..13d388ca 100644 --- a/packages/cli/src/commands/storage/zone/update.ts +++ b/packages/cli/src/commands/storage/zone/update.ts @@ -38,8 +38,9 @@ function settingsFromFlags( primaryCode?: string, ): StorageZoneSettingsModel { const settings: StorageZoneSettingsModel = {}; + // An empty value clears the custom 404, matching the prompt's blank-for-none behavior. if (args.custom404Path !== undefined) - settings.Custom404FilePath = args.custom404Path; + settings.Custom404FilePath = args.custom404Path || null; if (args.rewrite404To200 !== undefined) settings.Rewrite404To200 = args.rewrite404To200; if (args.replication !== undefined) From ed845651129d589d8e68e2a8dcd6d02a6547ec69 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Mon, 20 Jul 2026 08:30:21 +0100 Subject: [PATCH 4/4] fix(storage): close Greptile P2 gaps in remove and unlink - remove: compare the type-to-confirm value against zone.Name ?? "" so a Ctrl+C (undefined) can never match an undefined zone name and skip the guard - unlink: throw when run non-interactively without --force instead of silently cancelling and leaving the manifest, matching the other commands --- packages/cli/src/commands/storage/unlink.ts | 10 +++++++++- packages/cli/src/commands/storage/zone/remove.ts | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/storage/unlink.ts b/packages/cli/src/commands/storage/unlink.ts index 059d7cf7..0e06ee3a 100644 --- a/packages/cli/src/commands/storage/unlink.ts +++ b/packages/cli/src/commands/storage/unlink.ts @@ -1,7 +1,8 @@ import { defineCommand } from "../../core/define-command.ts"; +import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; import { loadManifest, removeManifest } from "../../core/manifest.ts"; -import { confirm } from "../../core/ui.ts"; +import { confirm, isInteractive } from "../../core/ui.ts"; import { STORAGE_MANIFEST, type StorageZoneManifest } from "./constants.ts"; interface UnlinkArgs { @@ -34,6 +35,13 @@ export const storageUnlinkCommand = defineCommand({ } if (!force) { + // Non-interactive (json output or no TTY) can't answer the prompt; require --force instead of silently no-op. + if (!isInteractive(output)) { + throw new UserError( + "Unlinking requires confirmation.", + "Re-run with --force to unlink non-interactively.", + ); + } const confirmed = await confirm( `Unlink from ${existing.name ?? existing.id}?`, ); diff --git a/packages/cli/src/commands/storage/zone/remove.ts b/packages/cli/src/commands/storage/zone/remove.ts index cb17c671..923c90e1 100644 --- a/packages/cli/src/commands/storage/zone/remove.ts +++ b/packages/cli/src/commands/storage/zone/remove.ts @@ -59,7 +59,7 @@ export const storageZoneRemoveCommand = defineCommand({ name: "value", message: `Type "${zone.Name}" to confirm:`, }); - if (value !== zone.Name) { + if (value !== (zone.Name ?? "")) { logger.log("Cancelled."); return; }