Skip to content

Commit 9ca267c

Browse files
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
1 parent bba2541 commit 9ca267c

16 files changed

Lines changed: 189 additions & 106 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@bunny.net/cli": patch
3+
---
4+
5+
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

AGENTS.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -350,8 +350,9 @@ bunny-cli/
350350
│ │ │ ├── index.ts # defineNamespace("storage", ...): registers zone + file groups + link + regions + docs (+ hidden bucket aliases)
351351
│ │ │ ├── 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)
352352
│ │ │ ├── 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)
353-
│ │ │ ├── interactive.ts # resolveStorageZoneInteractive: explicit name/ID arg → linked manifest (.bunny/storage.json, fetched by ID even when non-interactive) → zone picker
354-
│ │ │ ├── link.ts # Link the current directory to a storage zone (.bunny/storage.json); bunny storage link [zone]
353+
│ │ │ ├── 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
354+
│ │ │ ├── link.ts # Link the current directory to a storage zone (.bunny/storage.json) via the shared resolver with ignoreManifest; bunny storage link [zone]
355+
│ │ │ ├── unlink.ts # Remove .bunny/storage.json (confirmation unless --force); bunny storage unlink
355356
│ │ │ ├── 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)
356357
│ │ │ ├── files-api.test.ts # Tests for region mapping + delete error translation (NOT the SDK's URL building)
357358
│ │ │ ├── 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)
@@ -365,7 +366,7 @@ bunny-cli/
365366
│ │ │ │ ├── show.ts # Show zone details (region, replication, hostname, usage; adds S3 endpoint rows when S3-enabled)
366367
│ │ │ │ ├── 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
367368
│ │ │ │ ├── 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
368-
│ │ │ │ ├── remove.ts # Delete a storage zone and its files (alias: rm)
369+
│ │ │ │ ├── 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
369370
│ │ │ │ └── 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
370371
│ │ │ └── file/ # `bunny storage files` (canonical: files; aliases: file); zone is the --zone/-z flag (defaults to linked zone), the positional is the file/path
371372
│ │ │ ├── index.ts # defineNamespace("files", ...)
@@ -984,13 +985,13 @@ bunny
984985
│ │ Enable DNS query logging
985986
│ └── disable [domain] [--force] Disable DNS query logging
986987
├── storage (experimental, hidden from help and landing page)
987-
│ │ 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.
988+
│ │ 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).
988989
│ ├── zones (canonical; aliases: zone; hidden: bucket, buckets)
989990
│ │ ├── list List all storage zones (alias: ls)
990991
│ │ ├── 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)
991992
│ │ ├── show [zone] Show zone details (region, replication, hostname, usage)
992993
│ │ ├── 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)
993-
│ │ ├── remove [zone] [--force] Delete a storage zone and its files (alias: rm)
994+
│ │ ├── 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
994995
│ │ ├── credentials [zone] [--format rclone|aws|s3cmd|env] [--read-only] [--show-secret] (alias: creds)
995996
│ │ │ 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
996997
│ │ └── 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
@@ -1000,6 +1001,7 @@ bunny
10001001
│ │ ├── download <path> [--zone] [--out] Download a file
10011002
│ │ └── remove <path> [--zone] [--force] (alias: rm) Delete a file or directory (trailing slash = recursive)
10021003
│ ├── link [zone] Link the current directory to a storage zone (.bunny/storage.json); interactive picker when omitted
1004+
│ ├── unlink [--force/-f] Remove .bunny/storage.json, unlinking this directory (confirmation unless --force)
10031005
│ ├── regions List available storage regions (replication uses the same set minus the primary)
10041006
│ └── docs Open storage documentation in browser
10051007
├── db

packages/cli/src/commands/storage/constants.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,9 @@ export async function confirmAddedReplicationRegions(
5959
opts?: { force?: boolean },
6060
): Promise<boolean> {
6161
if (added.length === 0) return true;
62+
// Defaults to no: adding a replica is permanent and adds cost, like every other destructive confirm.
6263
return confirm(
6364
`Add replication region(s) ${added.join(", ")}? They cannot be removed once added.`,
64-
{ force: opts?.force, initial: true },
65+
{ force: opts?.force },
6566
);
6667
}

packages/cli/src/commands/storage/file/download.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,10 @@ export const storageFileDownloadCommand = defineCommand<DownloadArgs>({
6262
const config = resolveConfig(profile, apiKey, verbose);
6363
const client = createCoreClient(clientOptions(config, verbose));
6464

65-
const zone = await resolveStorageZoneInteractive(client, ref, output);
65+
const zone = await resolveStorageZoneInteractive(client, ref, {
66+
output,
67+
offerLink: true,
68+
});
6669
const connection = connectStorageZone(zone);
6770
const dest = out ?? basename(path);
6871

packages/cli/src/commands/storage/file/list.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ export const storageFileListCommand = defineCommand<ListArgs>({
4343
const config = resolveConfig(profile, apiKey, verbose);
4444
const client = createCoreClient(clientOptions(config, verbose));
4545

46-
const zone = await resolveStorageZoneInteractive(client, ref, output);
46+
const zone = await resolveStorageZoneInteractive(client, ref, {
47+
output,
48+
offerLink: true,
49+
});
4750
const connection = connectStorageZone(zone);
4851

4952
const spin = spinner("Listing files...");

packages/cli/src/commands/storage/file/remove.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,10 @@ export const storageFileRemoveCommand = defineCommand<RemoveArgs>({
6060
const config = resolveConfig(profile, apiKey, verbose);
6161
const client = createCoreClient(clientOptions(config, verbose));
6262

63-
const zone = await resolveStorageZoneInteractive(client, ref, output);
63+
const zone = await resolveStorageZoneInteractive(client, ref, {
64+
output,
65+
offerLink: true,
66+
});
6467
const connection = connectStorageZone(zone);
6568

6669
// A trailing slash deletes a directory and everything under it, recursively.

packages/cli/src/commands/storage/file/upload.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,10 @@ export const storageFileUploadCommand = defineCommand<UploadArgs>({
8282
const config = resolveConfig(profile, apiKey, verbose);
8383
const client = createCoreClient(clientOptions(config, verbose));
8484

85-
const zone = await resolveStorageZoneInteractive(client, ref, output);
85+
const zone = await resolveStorageZoneInteractive(client, ref, {
86+
output,
87+
offerLink: true,
88+
});
8689
const connection = connectStorageZone(zone);
8790

8891
const spin = spinner(`Uploading ${remotePath}...`);

packages/cli/src/commands/storage/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { storageDocsCommand } from "./docs.ts";
33
import { storageFileNamespace } from "./file/index.ts";
44
import { storageLinkCommand } from "./link.ts";
55
import { storageRegionsCommand } from "./regions.ts";
6+
import { storageUnlinkCommand } from "./unlink.ts";
67
import {
78
storageZoneHiddenAliases,
89
storageZoneNamespace,
@@ -12,6 +13,7 @@ export const storageNamespace = defineNamespace("storage", false, [
1213
storageZoneNamespace,
1314
storageFileNamespace,
1415
storageLinkCommand,
16+
storageUnlinkCommand,
1517
storageRegionsCommand,
1618
storageDocsCommand,
1719
...storageZoneHiddenAliases,

packages/cli/src/commands/storage/interactive.ts

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import prompts from "prompts";
22
import { UserError } from "../../core/errors.ts";
3-
import { loadManifest } from "../../core/manifest.ts";
3+
import { logger } from "../../core/logger.ts";
4+
import { loadManifest, saveManifest } from "../../core/manifest.ts";
45
import type { OutputFormat } from "../../core/types.ts";
5-
import { isInteractive, spinner } from "../../core/ui.ts";
6+
import { confirm, isInteractive, spinner } from "../../core/ui.ts";
67
import {
78
type CoreClient,
89
fetchStorageZone,
@@ -12,11 +13,39 @@ import {
1213
} from "./api.ts";
1314
import { STORAGE_MANIFEST, type StorageZoneManifest } from "./constants.ts";
1415

16+
/** Write `.bunny/storage.json` pointing at the zone. */
17+
export function writeStorageManifest(zone: StorageZoneModel): void {
18+
saveManifest<StorageZoneManifest>(STORAGE_MANIFEST, {
19+
id: zone.Id ?? 0,
20+
name: zone.Name ?? undefined,
21+
});
22+
}
23+
24+
// Offer to remember a zone picked from the prompt; a no-op if the user declines.
25+
async function maybeLinkZone(zone: StorageZoneModel): Promise<void> {
26+
if (!(await confirm(`Link this directory to ${zone.Name}?`))) return;
27+
writeStorageManifest(zone);
28+
logger.success(`Linked this directory to storage zone ${zone.Name}.`);
29+
}
30+
31+
/**
32+
* Resolve a zone by name/ID, or prompt the user to pick one when no
33+
* reference is given. Manages its own spinner so it never spins over a prompt.
34+
*
35+
* When `offerLink` is set and the zone is chosen via the picker (not an
36+
* explicit ref or the existing manifest), offer to link the directory to it.
37+
* Pass `ignoreManifest` to always pick (used when (re)linking a directory).
38+
* Never prompts non-interactively (json output, no TTY, or `force`): errors instead.
39+
*/
1540
export async function resolveStorageZoneInteractive(
1641
client: CoreClient,
1742
ref: string | undefined,
18-
output?: OutputFormat,
19-
opts?: { force?: boolean },
43+
opts: {
44+
output?: OutputFormat;
45+
force?: boolean;
46+
offerLink?: boolean;
47+
ignoreManifest?: boolean;
48+
} = {},
2049
): Promise<StorageZoneModel> {
2150
if (ref) {
2251
const spin = spinner("Resolving storage zone...");
@@ -29,22 +58,24 @@ export async function resolveStorageZoneInteractive(
2958
}
3059

3160
// A zone linked via `bunny storage link` stands in for an explicit ref, even unattended.
32-
const manifest = loadManifest<StorageZoneManifest>(STORAGE_MANIFEST);
33-
if (manifest.id) {
34-
const spin = spinner("Loading linked storage zone...");
35-
spin.start();
36-
try {
37-
return await fetchStorageZone(client, manifest.id);
38-
} finally {
39-
spin.stop();
61+
if (!opts.ignoreManifest) {
62+
const manifest = loadManifest<StorageZoneManifest>(STORAGE_MANIFEST);
63+
if (manifest.id) {
64+
const spin = spinner("Loading linked storage zone...");
65+
spin.start();
66+
try {
67+
return await fetchStorageZone(client, manifest.id);
68+
} finally {
69+
spin.stop();
70+
}
4071
}
4172
}
4273

4374
// No zone given: only fall back to the picker when we can actually prompt (--force opts out too).
44-
if (opts?.force || !isInteractive(output)) {
75+
if (opts.force || !isInteractive(opts.output)) {
4576
throw new UserError(
4677
"A storage zone is required.",
47-
"Pass the zone name or ID.",
78+
"Pass the zone name or ID, or link one with `bunny storage link`.",
4879
);
4980
}
5081

@@ -74,9 +105,16 @@ export async function resolveStorageZoneInteractive(
74105

75106
const loadSpin = spinner("Loading storage zone...");
76107
loadSpin.start();
108+
let zone: StorageZoneModel;
77109
try {
78-
return await fetchStorageZone(client, id);
110+
zone = await fetchStorageZone(client, id);
79111
} finally {
80112
loadSpin.stop();
81113
}
114+
115+
// The picker only runs interactively, so the link offer can't taint machine output.
116+
if (opts.offerLink) {
117+
await maybeLinkZone(zone);
118+
}
119+
return zone;
82120
}
Lines changed: 12 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,12 @@
11
import { createCoreClient } from "@bunny.net/openapi-client";
2-
import prompts from "prompts";
32
import { resolveConfig } from "../../config/index.ts";
43
import { clientOptions } from "../../core/client-options.ts";
54
import { defineCommand } from "../../core/define-command.ts";
6-
import { UserError } from "../../core/errors.ts";
75
import { logger } from "../../core/logger.ts";
8-
import { saveManifest } from "../../core/manifest.ts";
9-
import type { OutputFormat } from "../../core/types.ts";
10-
import { isInteractive, spinner } from "../../core/ui.ts";
116
import {
12-
fetchStorageZones,
13-
resolveStorageZone,
14-
type StorageZoneModel,
15-
} from "./api.ts";
16-
import { STORAGE_MANIFEST, type StorageZoneManifest } from "./constants.ts";
7+
resolveStorageZoneInteractive,
8+
writeStorageManifest,
9+
} from "./interactive.ts";
1710

1811
interface LinkArgs {
1912
zone?: string;
@@ -37,71 +30,18 @@ export const storageLinkCommand = defineCommand<LinkArgs>({
3730
const config = resolveConfig(profile, apiKey, verbose);
3831
const client = createCoreClient(clientOptions(config, verbose));
3932

40-
if (ref) {
41-
const spin = spinner("Resolving storage zone...");
42-
spin.start();
43-
let zone: StorageZoneModel;
44-
try {
45-
zone = await resolveStorageZone(client, ref);
46-
} finally {
47-
spin.stop();
48-
}
49-
linkZone(zone, output);
50-
return;
51-
}
52-
53-
// Without a TTY (or in JSON mode) there is no one to answer the picker.
54-
if (!isInteractive(output)) {
55-
throw new UserError(
56-
"A storage zone is required.",
57-
"Pass the zone name or ID.",
58-
);
59-
}
60-
61-
const spin = spinner("Fetching storage zones...");
62-
spin.start();
63-
let zones: StorageZoneModel[];
64-
try {
65-
zones = await fetchStorageZones(client);
66-
} finally {
67-
spin.stop();
68-
}
69-
70-
if (zones.length === 0) {
71-
throw new UserError(
72-
"No storage zones found.",
73-
'Create one with "bunny storage zones add <name>".',
74-
);
75-
}
76-
77-
const { selected } = await prompts({
78-
type: "select",
79-
name: "selected",
80-
message: "Select a storage zone to link:",
81-
choices: zones.map((zone) => ({
82-
title: `${zone.Name ?? ""} (${zone.Id})`,
83-
value: zone,
84-
})),
33+
// Always re-pick: linking is how the manifest changes, so the existing one must not short-circuit.
34+
const zone = await resolveStorageZoneInteractive(client, ref, {
35+
output,
36+
ignoreManifest: true,
8537
});
38+
writeStorageManifest(zone);
8639

87-
if (!selected) {
88-
throw new UserError("Link cancelled.");
40+
if (output === "json") {
41+
logger.log(JSON.stringify({ id: zone.Id, name: zone.Name }));
42+
return;
8943
}
9044

91-
linkZone(selected, output);
45+
logger.success(`Linked to ${zone.Name} (${zone.Id}).`);
9246
},
9347
});
94-
95-
function linkZone(zone: StorageZoneModel, output: OutputFormat): void {
96-
saveManifest<StorageZoneManifest>(STORAGE_MANIFEST, {
97-
id: zone.Id ?? 0,
98-
name: zone.Name ?? undefined,
99-
});
100-
101-
if (output === "json") {
102-
logger.log(JSON.stringify({ id: zone.Id, name: zone.Name }));
103-
return;
104-
}
105-
106-
logger.success(`Linked to ${zone.Name} (${zone.Id}).`);
107-
}

0 commit comments

Comments
 (0)