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/sites-lifecycle.md
Original file line number Diff line number Diff line change
@@ -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
31 changes: 26 additions & 5 deletions packages/cli/src/commands/sites/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ export async function fetchSites(client: CoreClient): Promise<SiteSummary[]> {
(pz: PullZone) => pz.StorageZoneId != null,
);

let unreadable = 0;
const summaries = await mapWithConcurrency(
candidates,
8,
Expand All @@ -272,10 +273,16 @@ export async function fetchSites(client: CoreClient): Promise<SiteSummary[]> {
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)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Comment on lines +1048 to +1059

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Recovery branches lack tests

The existing deletion tests cover only success, and their fake DELETE always succeeds. The new recovery behavior can regress without a test catching it. Add cases for a failed pull-zone deletion leaving storage intact, a missing pull zone allowing storage cleanup, and a failed storage deletion followed by a successful retry.

Fix in Claude Code

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)) {
Expand Down
12 changes: 7 additions & 5 deletions packages/cli/src/commands/sites/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,14 @@ export const sitesDeleteCommand = defineCommand<DeleteArgs>({
}),
);

// 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<SiteManifest>(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(
Expand Down Expand Up @@ -126,7 +126,9 @@ export const sitesDeleteCommand = defineCommand<DeleteArgs>({
);
}
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);
}
},
Expand Down
2 changes: 1 addition & 1 deletion skills/bunny-cli/references/sites.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<name>-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-<name>-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.

---

Expand Down
Loading