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-create-storage-tier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": patch
---

Add `bunny sites create --tier hdd|ssd` to pick the storage tier
1,840 changes: 328 additions & 1,512 deletions AGENTS.md

Large diffs are not rendered by default.

5 changes: 2 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ This is a Bun workspace monorepo with five packages:

## Documentation

When adding, changing, or removing commands or flags, update the corresponding sections in:
When adding, changing, or removing commands or flags, update `packages/cli/README.md` (user-facing command docs and examples). That is the command reference.

- `README.md` — user-facing command docs and examples.
- `AGENTS.md` — architecture docs, command reference tree, and file listing.
`AGENTS.md` documents architecture, conventions, and non-obvious decisions. Update it only when you change a _rule_, not when you add a command. It deliberately holds no command reference and no file listing: those duplicated the READMEs and the filesystem, and went stale.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ bun ny storage files list # list files in the linked storage z
bun ny storage files download # browse the zone and pick a file to download; same picker on `files remove`
bun ny storage files remove / # empty the zone; asks twice (yes/no, then type the zone name), and unattended runs need --force
bun ny sites create my-site # provision a static site (storage zone + pull zone + edge router; zones are named sites-my-site-<suffix>, served at sites-my-site-<suffix>.b-cdn.net)
bun ny sites create my-site --tier ssd # provision a site whose files live on the Edge (SSD) storage tier (always DE)
bun ny sites deploy # no linked site? offers to create one or pick an existing; detects the framework, offers to build, then deploys (a site's first deploy also offers to attach a custom domain)
bun ny sites deploy ./dist # deploy a directory and publish it as the live site
bun ny sites deploy --build # run `sites.build` from bunny.jsonc (else the detected framework's build), then deploy `sites.dir` (or the detected output dir)
Expand Down
95 changes: 94 additions & 1 deletion packages/cli/src/commands/sites/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,9 @@ test("writeRemoteState merges concurrent deploy records on an etag mismatch", as
connection,
fakeState({ current: "aaa", deploys: [ours] }),
etag,
{ promotedTo: "aaa" },
{
promotedTo: "aaa",
},
);
const read = await readRemoteState(connection);
// Our promote wins, their deploy record survives, and their promote becomes the rollback target.
Expand Down Expand Up @@ -571,6 +573,97 @@ test("createSite resumes a half-created suffixed site", async () => {
expect(result.state.name).toBe("my-site");
});

test("createSite refuses to resume a half-created zone on another tier", async () => {
const suffixed = { ...ZONE, Name: "sites-my-site-abc123" };
const coreClient = fakeCoreClient({ calls: [], storageZones: [suffixed] });
const computeClient = fakeComputeClient({ calls: [] });

// The zone is HDD, so an --tier ssd resume would silently finish on the wrong tier.
await expect(
createSite({
coreClient,
computeClient,
name: "my-site",
region: "DE",
tier: "ssd",
}),
).rejects.toThrow("but `--tier ssd` was requested");
});

test("createSite refuses to resume a half-created zone in another region", async () => {
const suffixed = { ...ZONE, Name: "sites-my-site-abc123", Region: "LA" };
const coreClient = fakeCoreClient({ calls: [], storageZones: [suffixed] });
const computeClient = fakeComputeClient({ calls: [] });

// The zone lives in LA, so an explicit --region de resume would silently keep the files there.
await expect(
createSite({
coreClient,
computeClient,
name: "my-site",
region: "DE",
}),
).rejects.toThrow("but `--region DE` was requested");
});

test("createSite resumes a half-created zone in another region when none was requested", async () => {
const suffixed = { ...ZONE, Name: "sites-my-site-abc123", Region: "LA" };
const coreClient = fakeCoreClient({
calls: [],
storageZones: [suffixed],
pullZones: [
{
Id: 30,
Name: "sites-my-site-abc123",
StorageZoneId: 10,
Hostnames: [],
},
],
});
const computeClient = fakeComputeClient({
calls: [],
scripts: [{ Id: 20, Name: "sites-my-site-abc123-router" }],
});

const result = await createSite({
coreClient,
computeClient,
name: "my-site",
});

expect(result.reused.storageZone).toBe(true);
});

test("createSite resumes a half-created zone when the tier matches", async () => {
const suffixed = { ...ZONE, Name: "sites-my-site-abc123" };
const coreClient = fakeCoreClient({
calls: [],
storageZones: [suffixed],
pullZones: [
{
Id: 30,
Name: "sites-my-site-abc123",
StorageZoneId: 10,
Hostnames: [],
},
],
});
const computeClient = fakeComputeClient({
calls: [],
scripts: [{ Id: 20, Name: "sites-my-site-abc123-router" }],
});

const result = await createSite({
coreClient,
computeClient,
name: "my-site",
region: "DE",
tier: "hdd",
});

expect(result.reused.storageZone).toBe(true);
});

test("createSite refuses to re-provision an existing suffixed site", async () => {
store.set(REMOTE_STATE_PATH, JSON.stringify(fakeState()));
const suffixed = { ...ZONE, Name: "sites-my-site-abc123" };
Expand Down
37 changes: 33 additions & 4 deletions packages/cli/src/commands/sites/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ import {
fetchStorageZone,
type StorageZoneModel,
} from "../storage/api.ts";
import {
type ZoneTierChoice,
zoneTierChoice,
zoneTierLabel,
zoneTierValue,
} from "../storage/constants.ts";
import {
connectStorageZone,
deleteFile,
Expand Down Expand Up @@ -275,7 +281,9 @@ export interface CreateSiteOptions {
coreClient: CoreClient;
computeClient: ComputeClient;
name: string;
region: string;
/** Explicitly requested region; a fresh zone falls back to DE, and a resumed zone must already be in it. */
region?: string;
tier?: ZoneTierChoice;
/** Progress callback; drives the spinner text. */
onStep?: (message: string) => void;
}
Expand All @@ -291,7 +299,7 @@ export interface CreateSiteResult {
export async function createSite(
opts: CreateSiteOptions,
): Promise<CreateSiteResult> {
const { coreClient, computeClient, name, region } = opts;
const { coreClient, computeClient, name, region, tier } = opts;
const step = opts.onStep ?? (() => {});
const reused = { storageZone: false, script: false, pullZone: false };

Expand All @@ -310,14 +318,33 @@ export async function createSite(
if (!existing && !storageZone) storageZone = zone;
}
if (storageZone) {
// Tier is fixed at creation, so a resumed zone can't be moved to the requested one; say so instead of finishing on the wrong tier.
if (tier && zoneTierChoice(storageZone) !== tier) {
Comment thread
jamie-at-bunny marked this conversation as resolved.
throw new UserError(
`A half-finished site zone for "${name}" is on the ${zoneTierLabel(storageZone, "long")} tier, but \`--tier ${tier}\` was requested.`,
`Storage tier can't be changed after a zone is created. Re-run with \`--tier ${zoneTierChoice(storageZone)}\` (or without \`--tier\`) to resume it, or delete storage zone "${storageZone.Name}" to start over.`,
);
}
// The primary region is fixed at creation too; only an explicit --region mismatch is an error, so a flagless retry resumes the zone where it is.
if (region && storageZone.Region && storageZone.Region !== region) {
throw new UserError(
`A half-finished site zone for "${name}" is in the ${storageZone.Region} region, but \`--region ${region}\` was requested.`,
`Storage region can't be changed after a zone is created. Re-run with \`--region ${storageZone.Region}\` (or without \`--region\`) to resume it, or delete storage zone "${storageZone.Name}" to start over.`,
);
}
reused.storageZone = true;
} else {
// 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);
try {
const { data } = await coreClient.POST("/storagezone", {
body: { Name: zoneName, Region: region, ReplicationRegions: null },
body: {
Name: zoneName,
Region: region ?? "DE",
ReplicationRegions: null,
ZoneTier: tier ? zoneTierValue(tier) : undefined,
Comment thread
jamie-at-bunny marked this conversation as resolved.
Comment thread
jamie-at-bunny marked this conversation as resolved.
},
});
if (!data?.Id) {
throw new UserError(`Failed to create storage zone "${zoneName}".`);
Expand Down Expand Up @@ -393,7 +420,9 @@ export async function createSite(
coreClient,
pullZoneName,
storageZoneId,
{ middlewareScriptId: scriptId },
{
middlewareScriptId: scriptId,
},
);
} catch (err) {
if (!isNameTaken(err)) throw err;
Expand Down
27 changes: 25 additions & 2 deletions packages/cli/src/commands/sites/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import { logger } from "../../core/logger.ts";
import { saveManifest } from "../../core/manifest.ts";
import { confirm, isInteractive, prompts } from "../../core/ui.ts";
import type { CoreClient, StorageZoneModel } from "../storage/api.ts";
import {
ZONE_TIER_CHOICES,
type ZoneTierChoice,
zoneTierChoice,
zoneTierLabel,
} from "../storage/constants.ts";
import { siteContextFromZone } from "./api.ts";
import {
gitTopLevel,
Expand All @@ -28,6 +34,7 @@ import { createSiteWithProgress, promptSiteName } from "./provision.ts";
interface CreateArgs {
name?: string;
region?: string;
tier?: ZoneTierChoice;
domain?: string;
link?: boolean;
}
Expand Down Expand Up @@ -76,6 +83,10 @@ export const sitesCreateCommand = defineCommand<CreateArgs>({
"Create and attach a custom domain",
],
["$0 sites create my-site --region NY", "Store files in New York"],
[
"$0 sites create my-site --tier ssd",
"Store files on the Edge (SSD) tier (always DE)",
],
],

builder: (yargs) =>
Expand All @@ -85,10 +96,16 @@ export const sitesCreateCommand = defineCommand<CreateArgs>({
describe:
"Site name; the storage zone, pull zone, and b-cdn.net subdomain become sites-<name>-xxxxxx (defaults to `sites.name` in bunny.jsonc, else prompted)",
})
// No parser default: an omitted --region must stay undefined so resuming a half-created zone in another region isn't rejected as a mismatch.
.option("region", {
type: "string",
default: "DE",
describe: "Main storage region code (e.g. DE, NY, LA, SG)",
describe: "Main storage region code (e.g. DE, NY, LA, SG; default DE)",
})
.option("tier", {
type: "string",
choices: ZONE_TIER_CHOICES,
describe:
"Storage tier for the site's files: hdd (Standard) or ssd (Edge, always DE)",
})
.option("domain", {
type: "string",
Expand Down Expand Up @@ -129,6 +146,7 @@ export const sitesCreateCommand = defineCommand<CreateArgs>({
computeClient,
name,
region: args.region,
tier: args.tier,
});

if (args.link !== false) {
Expand Down Expand Up @@ -159,6 +177,7 @@ export const sitesCreateCommand = defineCommand<CreateArgs>({
pullZoneId: result.state.pullZoneId,
scriptId: result.state.scriptId,
hostname: result.systemHostname ?? null,
tier: zoneTierChoice(result.storageZone),
domain: domain ?? null,
linked: args.link !== false,
...(domainError ? { domainError } : {}),
Expand All @@ -177,6 +196,10 @@ export const sitesCreateCommand = defineCommand<CreateArgs>({
[
{ key: "Site", value: name },
{ key: "Storage zone", value: String(result.state.storageZoneId) },
{
key: "Storage tier",
value: zoneTierLabel(result.storageZone, "long"),
},
{ key: "Pull zone", value: String(result.state.pullZoneId) },
{ key: "Router script", value: String(result.state.scriptId) },
...(result.systemHostname
Expand Down
20 changes: 19 additions & 1 deletion packages/cli/src/commands/sites/provision.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { expect, test } from "bun:test";
import prompts from "prompts";
import { promptSiteName, suggestSiteName } from "./provision.ts";
import {
promptSiteName,
resolveSiteRegion,
suggestSiteName,
} from "./provision.ts";

test("promptSiteName normalizes and validates a passed name", async () => {
expect(await promptSiteName("My-Site", false)).toBe("my-site");
Expand Down Expand Up @@ -34,3 +38,17 @@ test("suggestSiteName returns a slug or undefined, never an invalid name", () =>
expect(suggestion).toMatch(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/);
}
});

test("resolveSiteRegion uppercases an explicit region and passes through none", () => {
expect(resolveSiteRegion(undefined)).toBeUndefined();
expect(resolveSiteRegion("ny")).toBe("NY");
expect(resolveSiteRegion("ny", "hdd")).toBe("NY");
});

test("resolveSiteRegion pins the Edge (SSD) tier to DE", () => {
expect(resolveSiteRegion(undefined, "ssd")).toBeUndefined();
expect(resolveSiteRegion("de", "ssd")).toBe("DE");
expect(() => resolveSiteRegion("NY", "ssd")).toThrow(
"only available with DE",
);
});
27 changes: 26 additions & 1 deletion packages/cli/src/commands/sites/provision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { logger } from "../../core/logger.ts";
import { saveManifest } from "../../core/manifest.ts";
import { prompts, withSpinner } from "../../core/ui.ts";
import type { CoreClient } from "../storage/api.ts";
import {
SSD_PRIMARY_REGION,
type ZoneTierChoice,
} from "../storage/constants.ts";
import {
type ComputeClient,
type CreateSiteResult,
Expand Down Expand Up @@ -60,19 +64,39 @@ export async function promptSiteName(
return name;
}

// Edge (SSD) storage only exists in one region, and the API rewrites the region silently, so a conflicting `--region` is rejected instead.
// Normalizes an explicit region only; without one, createSite picks the default for a fresh zone and a resumed zone keeps its own.
export function resolveSiteRegion(
region: string | undefined,
tier?: ZoneTierChoice,
): string | undefined {
if (!region) return undefined;
const main = region.toUpperCase();
if (tier === "ssd" && main !== SSD_PRIMARY_REGION) {
throw new UserError(
`The Edge (SSD) tier is only available with ${SSD_PRIMARY_REGION} as the storage region, but --region ${region} was given.`,
`Drop --region to use ${SSD_PRIMARY_REGION}, or pass --tier hdd to keep ${main}.`,
);
}
return main;
}

/** Run {@link createSite} under a spinner whose text tracks each provisioning step. */
export async function createSiteWithProgress(opts: {
coreClient: CoreClient;
computeClient: ComputeClient;
name: string;
region?: string;
tier?: ZoneTierChoice;
}): Promise<CreateSiteResult> {
const region = resolveSiteRegion(opts.region, opts.tier);
return withSpinner(`Creating site "${opts.name}"...`, (spin) =>
createSite({
coreClient: opts.coreClient,
computeClient: opts.computeClient,
name: opts.name,
region: (opts.region ?? "DE").toUpperCase(),
region,
tier: opts.tier,
onStep: (message) => {
spin.text = message;
},
Expand All @@ -85,6 +109,7 @@ export async function createLinkedSite(opts: {
computeClient: ComputeClient;
name: string;
region?: string;
tier?: ZoneTierChoice;
}): Promise<SiteContext> {
const result = await createSiteWithProgress(opts);

Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/commands/storage/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export function zoneTierValue(choice: ZoneTierChoice): 0 | 1 {
return ZONE_TIERS[choice].value;
}

export function zoneTierChoice(zone: StorageZoneModel): ZoneTierChoice {
return zone.ZoneTier === ZONE_TIERS.ssd.value ? "ssd" : "hdd";
}

export function zoneTierLabel(
zone: StorageZoneModel,
form: "short" | "long" = "short",
Expand Down
Loading
Loading