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
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 <file> [--zone] [--to] [--checksum] [--content-type] Upload a local file
Expand Down
7 changes: 4 additions & 3 deletions packages/cli/src/commands/storage/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -16,6 +16,7 @@ export async function resolveStorageZoneInteractive(
client: CoreClient,
ref: string | undefined,
output?: OutputFormat,
opts?: { force?: boolean },
): Promise<StorageZoneModel> {
if (ref) {
const spin = spinner("Resolving storage zone...");
Expand All @@ -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.",
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/storage/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -51,7 +51,7 @@ export const storageLinkCommand = defineCommand<LinkArgs>({
}

// 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.",
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/commands/storage/zone/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -104,8 +104,7 @@ export const storageZoneAddCommand = defineCommand<ZoneAddArgs>({
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)) {
Expand Down
19 changes: 11 additions & 8 deletions packages/cli/src/commands/storage/zone/hostnames/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -46,19 +48,19 @@ async function resolveStorageZonePullZone(args: {
profile: string;
apiKey?: string;
verbose: boolean;
output: OutputFormat;
zone?: string;
"pull-zone"?: number;
}): Promise<ResolvedPullZone> {
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 };
Expand All @@ -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,
}),
Expand Down
35 changes: 24 additions & 11 deletions packages/cli/src/commands/storage/zone/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -133,23 +143,26 @@ export const storageZoneUpdateCommand = defineCommand<ZoneUpdateArgs>({
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;
Comment thread
jamie-at-bunny marked this conversation as resolved.
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);
Expand Down