From ea062fedb73b34cce73f92d09c234f5c8aa59d03 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Wed, 7 Oct 2026 17:19:30 +0100 Subject: [PATCH 1/2] fix(sites): recoverable delete and create, duplicate name checks, and list, open and link fixes --- .changeset/sites-lifecycle.md | 5 +++ packages/cli/src/commands/sites/api.ts | 40 +++++++++++++++---- packages/cli/src/commands/sites/delete.ts | 12 +++--- .../cli/src/commands/sites/interactive.ts | 6 ++- packages/cli/src/commands/sites/link.ts | 2 + packages/cli/src/commands/sites/open.ts | 5 +++ packages/cli/src/commands/sites/provision.ts | 10 ++++- skills/bunny-cli/references/sites.md | 2 +- 8 files changed, 65 insertions(+), 17 deletions(-) create mode 100644 .changeset/sites-lifecycle.md diff --git a/.changeset/sites-lifecycle.md b/.changeset/sites-lifecycle.md new file mode 100644 index 00000000..9604a2ce --- /dev/null +++ b/.changeset/sites-lifecycle.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": patch +--- + +`bunny sites delete` can be re-run after a partial failure, `create` says how to resume and refuses names already in use, `list` warns about unreadable zones, `open` explains the 404 before the first deploy, and `link` with no argument shows the picker diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 1b599de6..b11be89f 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -257,11 +257,15 @@ async function fetchPullZones( } // Discover sites: every storage-backed pull zone gets the per-zone `_bunny/site.json` read (concurrency-capped). A candidate is only a site when the state names it as the site's own pull zone, so another zone pointed at the same storage origin is never mistaken for one. -export async function fetchSites(client: CoreClient): Promise { +export async function fetchSites( + client: CoreClient, + opts: { strict?: boolean } = {}, +): Promise { const candidates = (await fetchPullZones(client)).filter( (pz: PullZone) => pz.StorageZoneId != null, ); + let unreadable = 0; const summaries = await mapWithConcurrency( candidates, 8, @@ -272,10 +276,17 @@ export async function fetchSites(client: CoreClient): Promise { if (!context || context.state.pullZoneId !== pz.Id) return null; return { ...context, systemHostname: systemHostname(pz.Hostnames) }; } catch { + unreadable++; return null; } }, ); + if (unreadable > 0) { + const message = `Couldn't read ${unreadable} storage zone${unreadable === 1 ? "" : "s"}, so some sites may be missing`; + // A uniqueness check can't trust a partial list. + if (opts.strict) throw new UserError(`${message}.`, "Re-run to retry."); + logger.warn(`${message}; re-run to retry.`); + } return summaries .filter((s): s is SiteSummary => s !== null) @@ -520,6 +531,14 @@ export async function createSite( } reused.storageZone = true; } else { + // An imported site keeps its original zone names, so the name-pattern scan above can't see it. + const sites = await fetchSites(coreClient, { strict: true }); + if (sites.some((s) => s.state.name === name)) { + throw new UserError( + `Site "${name}" already exists.`, + `Run \`bunny sites link ${name}\` to use it from this directory.`, + ); + } // The suffix keeps the globally-unique name from colliding with other accounts; retry fresh suffixes on the off chance one still does. for (let attempt = 0; !storageZone && attempt < 3; attempt++) { const zoneName = suffixedResourceName(name); @@ -717,7 +736,7 @@ export async function planSiteImport(opts: { ); } - const taken = (await fetchSites(coreClient)).some( + const taken = (await fetchSites(coreClient, { strict: true })).some( (site) => site.state.name === name, ); if (taken) { @@ -1031,11 +1050,18 @@ export async function deleteSiteResources(opts: { } }; - await attempt("pull zone", state.pullZoneId, () => - coreClient.DELETE("/pullzone/{id}", { - params: { path: { id: state.pullZoneId } }, - }), - ); + await attempt("pull zone", state.pullZoneId, async () => { + try { + await coreClient.DELETE("/pullzone/{id}", { + params: { path: { id: state.pullZoneId } }, + }); + } catch (err) { + // Already gone (a re-run after a partial delete) is the goal. + if (!(err instanceof ApiError && err.status === 404)) throw err; + } + }); + // The storage zone's site marker is the only way back to a pull zone that failed to delete, so keep both for the re-run. + if (!results.every((r) => r.deleted)) return results; if (opts.keepStorage) { // The zone survives, so remove its site marker, else list/link/show rediscover a "site" whose pull zone is gone. But only once everything else deleted: the marker is what makes a re-run able to find and retry the failures. if (opts.connection && results.every((r) => r.deleted)) { diff --git a/packages/cli/src/commands/sites/delete.ts b/packages/cli/src/commands/sites/delete.ts index c6c54606..1f5b8d3e 100644 --- a/packages/cli/src/commands/sites/delete.ts +++ b/packages/cli/src/commands/sites/delete.ts @@ -91,14 +91,14 @@ export const sitesDeleteCommand = defineCommand({ }), ); - // Only drop the local link when it pointed at this site. + const failures = results.filter((r) => !r.deleted); + + // Only drop the local link when it pointed at this site, and keep it while a re-run still has work to do. const manifest = loadManifest(SITES_MANIFEST); - if (manifest.id === state.storageZoneId) { + if (failures.length === 0 && manifest.id === state.storageZoneId) { removeManifest(SITES_MANIFEST); } - const failures = results.filter((r) => !r.deleted); - if (output === "json") { logger.log( JSON.stringify( @@ -126,7 +126,9 @@ export const sitesDeleteCommand = defineCommand({ ); } if (failures.length > 0) { - logger.dim(" Re-run the command to retry the failed deletions."); + logger.dim( + ` Re-run \`bunny sites delete ${state.storageZoneId}\` to retry the failed deletions.`, + ); process.exit(1); } }, diff --git a/packages/cli/src/commands/sites/interactive.ts b/packages/cli/src/commands/sites/interactive.ts index 80bbb0a2..3bc50fac 100644 --- a/packages/cli/src/commands/sites/interactive.ts +++ b/packages/cli/src/commands/sites/interactive.ts @@ -122,6 +122,8 @@ export async function selectSite( output: OutputFormat; force?: boolean; offerCreate?: () => Promise; + /** Go straight to the picker, ignoring the linked site and bunny.jsonc (`sites link` switching sites). */ + pick?: boolean; }, ): Promise { const noLink = async () => {}; @@ -140,7 +142,7 @@ export async function selectSite( } const manifest = loadManifest(SITES_MANIFEST); - if (manifest.id) { + if (manifest.id && !args.pick) { const id = manifest.id; const context = await withSpinner("Loading linked site...", async () => siteContextFromZone(await fetchStorageZone(client, id)), @@ -154,7 +156,7 @@ export async function selectSite( return { site: context, offerLink: noLink }; } - const configured = loadSiteConfig()?.config.name; + const configured = args.pick ? undefined : loadSiteConfig()?.config.name; if (configured) { const site = await withSpinner( `Resolving site "${configured}" from bunny.jsonc...`, diff --git a/packages/cli/src/commands/sites/link.ts b/packages/cli/src/commands/sites/link.ts index c4e52202..e6740718 100644 --- a/packages/cli/src/commands/sites/link.ts +++ b/packages/cli/src/commands/sites/link.ts @@ -3,6 +3,7 @@ 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 { isInteractive } from "@/core/ui.ts"; import { saveSiteLink, selectSite } from "./interactive.ts"; interface LinkArgs { @@ -32,6 +33,7 @@ export const sitesLinkCommand = defineCommand({ site: ref, link: false, output, + pick: isInteractive(output), }); saveSiteLink(site.state); diff --git a/packages/cli/src/commands/sites/open.ts b/packages/cli/src/commands/sites/open.ts index 76b39b83..b11509fa 100644 --- a/packages/cli/src/commands/sites/open.ts +++ b/packages/cli/src/commands/sites/open.ts @@ -83,6 +83,11 @@ export const sitesOpenCommand = defineCommand({ return; } + if (!state.current) { + logger.dim( + " Nothing is published yet, so this URL serves a 404: run `bunny sites deploy`.", + ); + } logger.info(`Opening ${url}`); openBrowser(url); }, diff --git a/packages/cli/src/commands/sites/provision.ts b/packages/cli/src/commands/sites/provision.ts index 94b68731..71d553a0 100644 --- a/packages/cli/src/commands/sites/provision.ts +++ b/packages/cli/src/commands/sites/provision.ts @@ -4,7 +4,7 @@ import { SSD_PRIMARY_REGION, type ZoneTierChoice, } from "@/commands/storage/constants.ts"; -import { UserError } from "@/core/errors.ts"; +import { ApiError, UserError } from "@/core/errors.ts"; import { logger } from "@/core/logger.ts"; import { prompts, withSpinner } from "@/core/ui.ts"; import { type CreateSiteResult, createSite, type SiteContext } from "./api.ts"; @@ -89,7 +89,13 @@ export async function createSiteWithProgress(opts: { spin.text = message; }, }), - ); + ).catch((err) => { + // A create that fails partway leaves zones a re-run picks up; the hint says so without hiding the API error's status or the credential hint. + if (err instanceof ApiError && !err.hint) { + err.hint = `Re-run \`bunny sites create ${opts.name}\` to resume where it stopped.`; + } + throw err; + }); } export async function createLinkedSite(opts: { diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index 8b907231..4f9535e0 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -71,7 +71,7 @@ bunny sites create my-site --no-link # don't write .bunny/site.json | `--domain` | Attach a custom production domain after provisioning; interactive runs prompt for one when omitted | | `--link` | Link this directory (default true; `--no-link` to skip) | -Site names are 3-47 lowercase letters, digits, and dashes. The storage zone, pull zone, and b-cdn.net subdomain become `sites--xxxxxx` (a `sites-` prefix marking them in the dashboard, plus a shared random suffix since zone names are global across bunny.net); commands still take the clean site name. Creation is idempotent; a failed create re-runs cleanly, reusing whatever was already provisioned. +Site names are 3-47 lowercase letters, digits, and dashes. The storage zone, pull zone, and b-cdn.net subdomain become `sites--xxxxxx` (a `sites-` prefix marking them in the dashboard, plus a shared random suffix since zone names are global across bunny.net); commands still take the clean site name. Creation is idempotent; a failed create re-runs cleanly (the error says so), reusing whatever was already provisioned. A name already used by any site, including an imported one, is refused. --- From e1b910504468b0cad23fd8b52219aee88d115bb6 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Wed, 7 Oct 2026 17:57:15 +0100 Subject: [PATCH 2/2] fix(sites): drop the create, open and link extras, and keep --keep-storage in the delete retry hint --- .changeset/sites-lifecycle.md | 2 +- packages/cli/src/commands/sites/api.ts | 29 ++++++++----------- packages/cli/src/commands/sites/delete.ts | 2 +- .../cli/src/commands/sites/interactive.ts | 6 ++-- packages/cli/src/commands/sites/link.ts | 2 -- packages/cli/src/commands/sites/open.ts | 5 ---- packages/cli/src/commands/sites/provision.ts | 10 ++----- skills/bunny-cli/references/sites.md | 2 +- 8 files changed, 19 insertions(+), 39 deletions(-) diff --git a/.changeset/sites-lifecycle.md b/.changeset/sites-lifecycle.md index 9604a2ce..d79c5735 100644 --- a/.changeset/sites-lifecycle.md +++ b/.changeset/sites-lifecycle.md @@ -2,4 +2,4 @@ "@bunny.net/cli": patch --- -`bunny sites delete` can be re-run after a partial failure, `create` says how to resume and refuses names already in use, `list` warns about unreadable zones, `open` explains the 404 before the first deploy, and `link` with no argument shows the picker +`bunny sites delete` can be re-run after a partial failure, `create` refuses a name an imported site already uses, and `list` warns when it couldn't read every zone diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index b11be89f..0d67f0f7 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -257,10 +257,7 @@ async function fetchPullZones( } // Discover sites: every storage-backed pull zone gets the per-zone `_bunny/site.json` read (concurrency-capped). A candidate is only a site when the state names it as the site's own pull zone, so another zone pointed at the same storage origin is never mistaken for one. -export async function fetchSites( - client: CoreClient, - opts: { strict?: boolean } = {}, -): Promise { +export async function fetchSites(client: CoreClient): Promise { const candidates = (await fetchPullZones(client)).filter( (pz: PullZone) => pz.StorageZoneId != null, ); @@ -282,10 +279,9 @@ export async function fetchSites( }, ); if (unreadable > 0) { - const message = `Couldn't read ${unreadable} storage zone${unreadable === 1 ? "" : "s"}, so some sites may be missing`; - // A uniqueness check can't trust a partial list. - if (opts.strict) throw new UserError(`${message}.`, "Re-run to retry."); - logger.warn(`${message}; re-run to retry.`); + logger.warn( + `Couldn't read ${unreadable} storage zone${unreadable === 1 ? "" : "s"}, so some sites may be missing; re-run to retry.`, + ); } return summaries @@ -503,6 +499,13 @@ export async function createSite( // 1. Storage zone; the site's identity. // A stateless name-pattern match is a half-finished create to resume; one carrying this site's state already is the site. step("Creating storage zone..."); + // An imported site keeps its original zone names, so the name-pattern scan below can't see it. + if ((await fetchSites(coreClient)).some((s) => s.state.name === name)) { + throw new UserError( + `Site "${name}" already exists.`, + `Run \`bunny sites link ${name}\` to use it from this directory.`, + ); + } let storageZone: StorageZoneModel | undefined; for (const zone of await findSiteStorageZones(coreClient, name)) { const existing = await siteContextFromZone(zone); @@ -531,14 +534,6 @@ export async function createSite( } reused.storageZone = true; } else { - // An imported site keeps its original zone names, so the name-pattern scan above can't see it. - const sites = await fetchSites(coreClient, { strict: true }); - if (sites.some((s) => s.state.name === name)) { - throw new UserError( - `Site "${name}" already exists.`, - `Run \`bunny sites link ${name}\` to use it from this directory.`, - ); - } // The suffix keeps the globally-unique name from colliding with other accounts; retry fresh suffixes on the off chance one still does. for (let attempt = 0; !storageZone && attempt < 3; attempt++) { const zoneName = suffixedResourceName(name); @@ -736,7 +731,7 @@ export async function planSiteImport(opts: { ); } - const taken = (await fetchSites(coreClient, { strict: true })).some( + const taken = (await fetchSites(coreClient)).some( (site) => site.state.name === name, ); if (taken) { diff --git a/packages/cli/src/commands/sites/delete.ts b/packages/cli/src/commands/sites/delete.ts index 1f5b8d3e..3d99886e 100644 --- a/packages/cli/src/commands/sites/delete.ts +++ b/packages/cli/src/commands/sites/delete.ts @@ -127,7 +127,7 @@ export const sitesDeleteCommand = defineCommand({ } if (failures.length > 0) { logger.dim( - ` Re-run \`bunny sites delete ${state.storageZoneId}\` to retry the failed deletions.`, + ` Re-run \`bunny sites delete ${state.storageZoneId}${args["keep-storage"] ? " --keep-storage" : ""}\` to retry the failed deletions.`, ); process.exit(1); } diff --git a/packages/cli/src/commands/sites/interactive.ts b/packages/cli/src/commands/sites/interactive.ts index 3bc50fac..80bbb0a2 100644 --- a/packages/cli/src/commands/sites/interactive.ts +++ b/packages/cli/src/commands/sites/interactive.ts @@ -122,8 +122,6 @@ export async function selectSite( output: OutputFormat; force?: boolean; offerCreate?: () => Promise; - /** Go straight to the picker, ignoring the linked site and bunny.jsonc (`sites link` switching sites). */ - pick?: boolean; }, ): Promise { const noLink = async () => {}; @@ -142,7 +140,7 @@ export async function selectSite( } const manifest = loadManifest(SITES_MANIFEST); - if (manifest.id && !args.pick) { + if (manifest.id) { const id = manifest.id; const context = await withSpinner("Loading linked site...", async () => siteContextFromZone(await fetchStorageZone(client, id)), @@ -156,7 +154,7 @@ export async function selectSite( return { site: context, offerLink: noLink }; } - const configured = args.pick ? undefined : loadSiteConfig()?.config.name; + const configured = loadSiteConfig()?.config.name; if (configured) { const site = await withSpinner( `Resolving site "${configured}" from bunny.jsonc...`, diff --git a/packages/cli/src/commands/sites/link.ts b/packages/cli/src/commands/sites/link.ts index e6740718..c4e52202 100644 --- a/packages/cli/src/commands/sites/link.ts +++ b/packages/cli/src/commands/sites/link.ts @@ -3,7 +3,6 @@ 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 { isInteractive } from "@/core/ui.ts"; import { saveSiteLink, selectSite } from "./interactive.ts"; interface LinkArgs { @@ -33,7 +32,6 @@ export const sitesLinkCommand = defineCommand({ site: ref, link: false, output, - pick: isInteractive(output), }); saveSiteLink(site.state); diff --git a/packages/cli/src/commands/sites/open.ts b/packages/cli/src/commands/sites/open.ts index b11509fa..76b39b83 100644 --- a/packages/cli/src/commands/sites/open.ts +++ b/packages/cli/src/commands/sites/open.ts @@ -83,11 +83,6 @@ export const sitesOpenCommand = defineCommand({ return; } - if (!state.current) { - logger.dim( - " Nothing is published yet, so this URL serves a 404: run `bunny sites deploy`.", - ); - } logger.info(`Opening ${url}`); openBrowser(url); }, diff --git a/packages/cli/src/commands/sites/provision.ts b/packages/cli/src/commands/sites/provision.ts index 71d553a0..94b68731 100644 --- a/packages/cli/src/commands/sites/provision.ts +++ b/packages/cli/src/commands/sites/provision.ts @@ -4,7 +4,7 @@ import { SSD_PRIMARY_REGION, type ZoneTierChoice, } from "@/commands/storage/constants.ts"; -import { ApiError, UserError } from "@/core/errors.ts"; +import { UserError } from "@/core/errors.ts"; import { logger } from "@/core/logger.ts"; import { prompts, withSpinner } from "@/core/ui.ts"; import { type CreateSiteResult, createSite, type SiteContext } from "./api.ts"; @@ -89,13 +89,7 @@ export async function createSiteWithProgress(opts: { spin.text = message; }, }), - ).catch((err) => { - // A create that fails partway leaves zones a re-run picks up; the hint says so without hiding the API error's status or the credential hint. - if (err instanceof ApiError && !err.hint) { - err.hint = `Re-run \`bunny sites create ${opts.name}\` to resume where it stopped.`; - } - throw err; - }); + ); } export async function createLinkedSite(opts: { diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index 4f9535e0..7faf9abf 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -71,7 +71,7 @@ bunny sites create my-site --no-link # don't write .bunny/site.json | `--domain` | Attach a custom production domain after provisioning; interactive runs prompt for one when omitted | | `--link` | Link this directory (default true; `--no-link` to skip) | -Site names are 3-47 lowercase letters, digits, and dashes. The storage zone, pull zone, and b-cdn.net subdomain become `sites--xxxxxx` (a `sites-` prefix marking them in the dashboard, plus a shared random suffix since zone names are global across bunny.net); commands still take the clean site name. Creation is idempotent; a failed create re-runs cleanly (the error says so), reusing whatever was already provisioned. A name already used by any site, including an imported one, is refused. +Site names are 3-47 lowercase letters, digits, and dashes. The storage zone, pull zone, and b-cdn.net subdomain become `sites--xxxxxx` (a `sites-` prefix marking them in the dashboard, plus a shared random suffix since zone names are global across bunny.net); commands still take the clean site name. Creation is idempotent; a failed create re-runs cleanly, reusing whatever was already provisioned. A name already used by any site, including an imported one, is refused. ---