diff --git a/.changeset/sites-lifecycle.md b/.changeset/sites-lifecycle.md new file mode 100644 index 00000000..d79c5735 --- /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` 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 1b599de6..0d67f0f7 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -262,6 +262,7 @@ export async function fetchSites(client: CoreClient): Promise { (pz: PullZone) => pz.StorageZoneId != null, ); + let unreadable = 0; const summaries = await mapWithConcurrency( candidates, 8, @@ -272,10 +273,16 @@ 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) { + logger.warn( + `Couldn't read ${unreadable} storage zone${unreadable === 1 ? "" : "s"}, so some sites may be missing; re-run to retry.`, + ); + } return summaries .filter((s): s is SiteSummary => s !== null) @@ -492,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); @@ -1031,11 +1045,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..3d99886e 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}${args["keep-storage"] ? " --keep-storage" : ""}\` to retry the failed deletions.`, + ); process.exit(1); } }, diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index 8b907231..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, 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, reusing whatever was already provisioned. A name already used by any site, including an imported one, is refused. ---