Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/storage-behavioral-consistency.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .changeset/storage-convention-parity.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .changeset/storage-wording-polish.md
Original file line number Diff line number Diff line change
@@ -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
14 changes: 8 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<region>-s3.storage.bunnycdn.com), s3Credentials (name=access key, password=secret), renderS3ToolConfig (rclone/aws/s3cmd/env formatters)
Expand All @@ -365,8 +366,8 @@ 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)
│ │ │ │ └── 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
│ │ │ │ ├── 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 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)
Expand Down Expand Up @@ -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
Expand All @@ -1004,6 +1005,7 @@ bunny
│ │ ├── download <path> [--zone] [--out] Download a file
│ │ └── remove <path> [--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
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/storage/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
);
}
5 changes: 4 additions & 1 deletion packages/cli/src/commands/storage/file/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ export const storageFileDownloadCommand = defineCommand<DownloadArgs>({
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);

Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/commands/storage/file/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ export const storageFileListCommand = defineCommand<ListArgs>({
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...");
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/commands/storage/file/remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ export const storageFileRemoveCommand = defineCommand<RemoveArgs>({
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.
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/commands/storage/file/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@ export const storageFileUploadCommand = defineCommand<UploadArgs>({
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}...`);
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/storage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -12,6 +13,7 @@ export const storageNamespace = defineNamespace("storage", false, [
storageZoneNamespace,
storageFileNamespace,
storageLinkCommand,
storageUnlinkCommand,
storageRegionsCommand,
storageDocsCommand,
...storageZoneHiddenAliases,
Expand Down
68 changes: 53 additions & 15 deletions packages/cli/src/commands/storage/interactive.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<StorageZoneManifest>(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<void> {
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<StorageZoneModel> {
if (ref) {
const spin = spinner("Resolving storage zone...");
Expand All @@ -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<StorageZoneManifest>(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<StorageZoneManifest>(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`.",
);
}

Expand Down Expand Up @@ -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;
}
84 changes: 12 additions & 72 deletions packages/cli/src/commands/storage/link.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -37,71 +30,18 @@ export const storageLinkCommand = defineCommand<LinkArgs>({
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 <name>".',
);
}

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<StorageZoneManifest>(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}).`);
}
Loading