From de74c9352f699eca2097d119e0505f740722c959 Mon Sep 17 00:00:00 2001 From: bogdan-at-bunny Date: Wed, 19 Aug 2026 15:13:42 +0000 Subject: [PATCH 01/10] feat(deploy): one command deploys a framework project or a directory Putting an Astro site on Edge Scripting took five steps, two passwords, three environment variables, and two raw API calls. It now takes one command: bunny deploy The CLI learns nothing about Astro. It reads `.bunny/build.json`, the build manifest a framework adapter writes, and that file says what to deploy: the one script file, the folder of client files, and the pull zone settings and script variables the site needs. So the next adapter needs no CLI release. The schema lives in @bunny.net/config, next to bunny.jsonc, because it is a contract two repositories share. A framework site is a `sites` site whose script comes from the build, which is why this re-uses almost everything: the storage zone, the `deploys/{id}/` layout, the state file, promote and rollback, domains, ssl, and delete. `state.kind` tells the two apart. The router commands refuse a framework site, and `deployments publish` delegates to the code path that publishes one. Two things a framework site needs that a static one does not: - The deploy's server bundle is kept in storage, under `_bunny/`, so a promote or a rollback restores the code and the files it names together. Astro puts hashed asset names inside the server bundle, so old files with a new renderer is a broken page. - The CLI writes `globalThis.__BUNNY_DEPLOY__` onto the front of the bundle at publish time. The release then carries the name of its own asset folder, and cannot read another deploy's files. Also: a project with no adapter installed or configured gets an offer to add one. The Astro config edit refuses any config it cannot edit safely, and prints the lines to paste instead. Verified against a real account with a fresh `npm create astro` project: provision from nothing, a page rendered per request, a prerendered page and assets from Storage, an unchanged redeploy that does nothing, and a rollback that brings back the old page with its own assets. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DNEZw6pebXfttb98s3ihG8 --- .changeset/framework-deploys.md | 24 + AGENTS.md | 4 + README.md | 5 + packages/cli/src/cli.ts | 5 + .../cli/src/commands/deploy/adapter.test.ts | 87 +++ packages/cli/src/commands/deploy/adapter.ts | 182 ++++++ packages/cli/src/commands/deploy/api.test.ts | 254 ++++++++ packages/cli/src/commands/deploy/api.ts | 486 +++++++++++++++ packages/cli/src/commands/deploy/framework.ts | 555 ++++++++++++++++++ packages/cli/src/commands/deploy/index.ts | 215 +++++++ .../cli/src/commands/deploy/manifest.test.ts | 141 +++++ packages/cli/src/commands/deploy/manifest.ts | 136 +++++ packages/cli/src/commands/deploy/rollback.ts | 83 +++ packages/cli/src/commands/sites/api.test.ts | 42 ++ packages/cli/src/commands/sites/api.ts | 30 +- .../cli/src/commands/sites/ci/frameworks.ts | 24 +- packages/cli/src/commands/sites/constants.ts | 42 ++ packages/cli/src/commands/sites/deploy.ts | 10 + .../src/commands/sites/deployments/publish.ts | 18 +- packages/cli/src/commands/sites/show.ts | 16 +- packages/cli/src/core/hostnames/client.ts | 17 +- packages/config/src/build-manifest.ts | 86 +++ packages/config/src/index.ts | 12 + 23 files changed, 2461 insertions(+), 13 deletions(-) create mode 100644 .changeset/framework-deploys.md create mode 100644 packages/cli/src/commands/deploy/adapter.test.ts create mode 100644 packages/cli/src/commands/deploy/adapter.ts create mode 100644 packages/cli/src/commands/deploy/api.test.ts create mode 100644 packages/cli/src/commands/deploy/api.ts create mode 100644 packages/cli/src/commands/deploy/framework.ts create mode 100644 packages/cli/src/commands/deploy/index.ts create mode 100644 packages/cli/src/commands/deploy/manifest.test.ts create mode 100644 packages/cli/src/commands/deploy/manifest.ts create mode 100644 packages/cli/src/commands/deploy/rollback.ts create mode 100644 packages/config/src/build-manifest.ts diff --git a/.changeset/framework-deploys.md b/.changeset/framework-deploys.md new file mode 100644 index 00000000..ffd0ae9c --- /dev/null +++ b/.changeset/framework-deploys.md @@ -0,0 +1,24 @@ +--- +"@bunny.net/cli": minor +"@bunny.net/config": minor +--- + +`bunny deploy`: one command for a framework project or a directory of files. + +A build that writes `.bunny/build.json` renders per request in an Edge Script, +with its client files in Bunny Storage. `bunny deploy` provisions the storage +zone, the script, and the pull zone on the first run, uploads the build, sets +every variable from what it already knows, applies the pull zone settings the +adapter asks for, and publishes. No password passes through the terminal. + +The manifest is the whole contract: `BuildManifestSchema` in `@bunny.net/config`. +The CLI knows no framework, so a new adapter needs no new CLI. A framework +project with no adapter installed or configured gets an offer to add one. + +Each deploy keeps its client files at `deploys/{id}/` and its server bundle at +`_bunny/deploys/{id}/server.js`, and the CLI writes the deploy's folder name into +the top of the bundle at publish time. So a published release can only read the +files it was built with, and `bunny rollback` (or `sites deployments publish`) +restores a page and its assets together. + +Anything else still deploys as a static site, through `sites deploy`. diff --git a/AGENTS.md b/AGENTS.md index 43677ebf..9521a7f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -988,6 +988,9 @@ bunny ├── login [--force] [--install-skill] Authenticate via browser; --install-skill/--no-install-skill decides the agent-skill offer without prompting ├── logout [--force] Remove stored authentication profile ├── whoami Show authenticated account (name, email, account id, profile) +├── deploy [dir] [--build [cmd]] [--env K=V] [--env-file] [--name] [--region] [--prod] [--preview] [--force] [--open] [--site] [--link] +│ Build and deploy this project. Reads `.bunny/build.json`, the build manifest a framework adapter writes (@bunny.net/config `BuildManifestSchema`): `kind: "ssr"` deploys as a framework site (see `sites`), anything else falls through to `sites deploy` with the manifest's asset dir. A framework project with no adapter installed or configured gets an offer to add one (detection carries the adapter package per preset; the Astro config edit is `patchAstroConfig`, which refuses any config it cannot edit safely). The build runs before any resource is created, so a failing build leaves no orphan site. `--preview` errors: preview environments are designed but not built +├── rollback [id] [--force] Publish an earlier deploy of the linked framework site (default: the previous one). Reads that deploy's stored bundle back out of storage, so the pages and the files they name are restored together ├── config │ ├── init [--api-key] Initialize config (create default profile) │ ├── show Display resolved configuration @@ -1141,6 +1144,7 @@ bunny │ └── stats [id] [--from] [--to] [--hourly] [--link] │ Show usage statistics (requests/CPU/cost totals + bar chart; defaults to last 30 days). No ID → linked script → interactive picker (offers to link; --no-link skips). JSON output skips the picker and errors. ├── sites (experimental, hidden from help and landing page) Manage sites. +│ │ Two kinds, one state file. `state.kind` is `static` (absent means static) or `framework`; `isFrameworkSite()` in constants.ts is the check. A framework site's script is the build's own server, deployed by `bunny deploy`: its pull zone's origin is the script (OriginType 4, `EdgeScriptId`, and `StorageZoneId: -1`), its client files still live at `deploys/{id}/` in the storage zone, and each deploy's bundle is kept at `_bunny/deploys/{id}/server.js` so a promote or rollback restores code and files together. The CLI prepends `globalThis.__BUNNY_DEPLOY__ = {...}` to the bundle at publish time, so a published release carries the name of its own asset folder and cannot read another deploy's files. `sites deploy` and the router commands refuse a framework site; `deployments publish` delegates to `republishDeploy`; `show`, `list`, `open`, `domains`, `ssl`, and `delete` work on both. │ │ Static-site hosting: one storage zone (files) + one pull zone (CDN) + one middleware router script per site. Zone names are `sites-{name}-{random suffix}` (prefixed for dashboard grouping; suffixed because zone names are global across bunny.net); the site keeps its clean name in state. Deploys are immutable directories (`deploys/{id}/`); promote/rollback flips the router's CURRENT_DEPLOY env var + purges the cache; no files move. Every deploy gets its own preview pull zone (`sites-dpl-{id}-{rand6}`, served at that name under b-cdn.net with instant HTTPS): same storage origin, same router, root-served, so client-side routers behave exactly like production and no custom domain or DNS setup is needed. Publishing is always explicit (--production, or the interactive first-deploy offer). Custom domains are production-only vanity hostnames. Site state lives at `_bunny/site.json` in the storage zone (403-blocked by the router); `.bunny/site.json` is the local pointer. Site resolution everywhere: explicit ref → .bunny/site.json → `sites.name` in bunny.jsonc → interactive picker (offers to link). The picker is skipped, with an error, under `--output json`/no TTY and on destructive commands run with `--force`. │ ├── create [name] [--region] [--domain] [--link] │ │ Provision a site (idempotent; a failed create re-runs cleanly; each resource is looked up by name first). A missing name comes from `sites.name` in bunny.jsonc (reported in text output), else interactive runs prompt for one (directory-name suggestion). --domain attaches a custom production domain; when omitted, interactive runs offer to add one (Bunny DNS record with confirmation, nameserver guidance when undelegated, DNS wait + SSL). GitHub repos then get an offer to scaffold the deploy workflow (declining prints it instead). diff --git a/README.md b/README.md index d308fa1b..34ce9b3c 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,9 @@ bun ny dns records scan example.com # scan for the domain's existing rec bun ny dns records preset list # list DNS record presets (email providers, verification, security) bun ny dns records preset google-workspace example.com # apply a preset record set bun ny dns records preset bluesky example.com --param did=did:plc:abc123 # apply a preset non-interactively +bun ny deploy # build and deploy this project: a framework build renders on Edge Scripting, anything else deploys as static files +bun ny deploy --build # run the project's build first +bun ny rollback # put the previous deploy back on production bun ny sites create my-site # provision a static site (storage zone + pull zone + edge router; zones are named sites-my-site-, served at sites-my-site-.b-cdn.net) 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 publish + attach a custom domain) bun ny sites deploy ./dist # deploy to an immutable HTTPS preview URL (sites-dpl--xxxxxx.b-cdn.net); no custom domain needed @@ -74,6 +77,8 @@ bun ny sites open # open the site's live URL in the br bun ny sites ci init # add a GitHub Actions workflow (previews on PRs, production on main) ``` +`bunny deploy` picks its path from the project. A build that writes `.bunny/build.json` (a [framework adapter](https://github.com/BunnyWay/bunny-adapters) does) renders per request in an Edge Script, with its client files in Bunny Storage; everything else is a directory of files, which is `bunny sites deploy`. The CLI knows no framework: it reads the manifest, so a new adapter needs no new CLI. A framework project with no adapter yet gets an offer to install one. + Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`) so a deploy needs no flags: `bun ny sites deploy --build --prod`. `bun ny sites ci init` writes the same `build` and `dir` into the generated workflow. See [`examples/sites/`](examples/sites/) for ready-to-copy configs (Vite, Astro, Next.js static export, Hugo, plain HTML, and a combined app + site file). ### Available Scripts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 0679e267..f1e28a2d 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -8,6 +8,8 @@ import { authLoginCommand } from "./commands/auth/login.ts"; import { authLogoutCommand } from "./commands/auth/logout.ts"; import { configNamespace } from "./commands/config/index.ts"; import { dbNamespace } from "./commands/db/index.ts"; +import { deployCommand } from "./commands/deploy/index.ts"; +import { rollbackCommand } from "./commands/deploy/rollback.ts"; import { dnsNamespace } from "./commands/dns/index.ts"; import { docsCommand } from "./commands/docs.ts"; import { openCommand } from "./commands/open.ts"; @@ -27,6 +29,8 @@ const commands: CommandModule[] = [ authLoginCommand, authLogoutCommand, whoamiCommand, + deployCommand, + rollbackCommand, dbNamespace, dnsNamespace, scriptsNamespace, @@ -143,6 +147,7 @@ export const cli = instance ["Create an edge script", "bunny scripts init"], ["Add a domain to manage DNS", "bunny dns zones add example.com"], ["Create a dev sandbox", "bunny sandbox create my-sandbox"], + ["Deploy this project", "bunny deploy"], // ["Deploy a static site", "bunny sites deploy"], // ["Deploy an app", "bunny apps deploy"], ]; diff --git a/packages/cli/src/commands/deploy/adapter.test.ts b/packages/cli/src/commands/deploy/adapter.test.ts new file mode 100644 index 00000000..0a65b2da --- /dev/null +++ b/packages/cli/src/commands/deploy/adapter.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import { patchAstroConfig } from "./adapter.ts"; + +const PKG = "@bunny.net/astro-adapter"; + +test("adds the import, the adapter, and server output to a fresh config", () => { + const source = [ + "// @ts-check", + 'import { defineConfig } from "astro/config";', + "", + "export default defineConfig({});", + "", + ].join("\n"); + + const patched = patchAstroConfig(source, PKG); + expect(patched).toContain('import bunny from "@bunny.net/astro-adapter";'); + expect(patched).toContain('output: "server"'); + expect(patched).toContain("adapter: bunny()"); + // The import goes after the last existing one, not above the file's comment. + expect(patched?.indexOf("// @ts-check")).toBe(0); +}); + +test("keeps existing options, and adds the adapter beside them", () => { + const source = [ + 'import { defineConfig } from "astro/config";', + 'import sitemap from "@astrojs/sitemap";', + "", + "export default defineConfig({", + " site: https://example.com,", + " integrations: [sitemap()],", + "});", + ].join("\n"); + + const patched = patchAstroConfig(source, PKG); + expect(patched).toContain("integrations: [sitemap()]"); + expect(patched).toContain("adapter: bunny()"); + // The adapter's import lands after the last one, so nothing is shadowed. + const importEnd = patched?.lastIndexOf("import ") ?? -1; + expect(patched?.slice(importEnd)).toContain("bunny"); +}); + +// A developer who already chose `output` meant it. +test("does not add output when the config sets it", () => { + const source = [ + 'import { defineConfig } from "astro/config";', + "export default defineConfig({", + ' output: "static",', + "});", + ].join("\n"); + + const patched = patchAstroConfig(source, PKG); + expect(patched).toContain('output: "static"'); + expect(patched).not.toContain('output: "server"'); + expect(patched).toContain("adapter: bunny()"); +}); + +// Another adapter is somebody's decision, so this refuses rather than fights. +test("refuses a config that already has an adapter", () => { + const source = [ + 'import { defineConfig } from "astro/config";', + 'import node from "@astrojs/node";', + "export default defineConfig({", + ' adapter: node({ mode: "standalone" }),', + "});", + ].join("\n"); + + expect(patchAstroConfig(source, PKG)).toBeNull(); +}); + +test("changes nothing when the adapter is already configured", () => { + const source = [ + 'import { defineConfig } from "astro/config";', + 'import bunny from "@bunny.net/astro-adapter";', + "export default defineConfig({", + ' output: "server",', + " adapter: bunny(),", + "});", + ].join("\n"); + + expect(patchAstroConfig(source, PKG)).toBe(source); +}); + +// Anything this cannot read safely is left alone, and the CLI prints the snippet. +test("refuses a config it cannot read", () => { + expect(patchAstroConfig("export default makeConfig();", PKG)).toBeNull(); + expect(patchAstroConfig("export default defineConfig({});", PKG)).toBeNull(); +}); diff --git a/packages/cli/src/commands/deploy/adapter.ts b/packages/cli/src/commands/deploy/adapter.ts new file mode 100644 index 00000000..a680e2bd --- /dev/null +++ b/packages/cli/src/commands/deploy/adapter.ts @@ -0,0 +1,182 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { UserError } from "../../core/errors.ts"; +import { logger } from "../../core/logger.ts"; +import { confirm, isInteractive } from "../../core/ui.ts"; +import { + detectFramework, + detectPackageManager, + type FrameworkPreset, + type PackageManager, + readPackageJson, +} from "../sites/ci/frameworks.ts"; + +/** Install command for a package, per package manager. */ +function installCommand(pm: PackageManager, pkg: string): string { + return pm === "npm" ? `npm install ${pkg}` : `${pm} add ${pkg}`; +} + +/** Is the adapter already a dependency of the project? */ +export async function hasAdapter(root: string, pkg: string): Promise { + const json = await readPackageJson(root); + const deps = { + ...(json?.dependencies as Record | undefined), + ...(json?.devDependencies as Record | undefined), + }; + return Boolean(deps[pkg]); +} + +/** The project's Astro config file, whichever extension it uses. */ +export function findAstroConfig(root: string): string | undefined { + for (const name of [ + "astro.config.mjs", + "astro.config.js", + "astro.config.ts", + "astro.config.mts", + ]) { + const path = join(root, name); + if (existsSync(path)) return path; + } + return undefined; +} + +/** + * Add the adapter to an Astro config. + * + * Returns the new source, or null when the config is not one this can edit + * safely. Editing somebody's configuration is only acceptable when the result is + * obviously right, so this handles the shape `astro create` writes and nothing + * cleverer. + */ +export function patchAstroConfig(source: string, pkg: string): string | null { + if (source.includes(pkg)) return source; + + const defineConfig = /defineConfig\(\{/.exec(source); + if (!defineConfig) return null; + // An existing adapter is somebody's decision. Leave it, and say so. + if (/\n\s*adapter\s*:/.test(source)) return null; + + const lastImport = [...source.matchAll(/^import .*?;?$/gm)].pop(); + if (lastImport?.index === undefined) return null; + const importEnd = lastImport.index + lastImport[0].length; + + const withImport = `${source.slice(0, importEnd)}\nimport bunny from "${pkg}";${source.slice(importEnd)}`; + + // Re-find the call: the import above moved it. + const call = /defineConfig\(\{/.exec(withImport); + if (call?.index === undefined) return null; + const insertAt = call.index + call[0].length; + const hasOutput = /\n\s*output\s*:/.test(withImport); + const added = hasOutput + ? "\n adapter: bunny()," + : '\n output: "server",\n adapter: bunny(),'; + + return withImport.slice(0, insertAt) + added + withImport.slice(insertAt); +} + +/** What to tell a developer whose config this cannot edit. */ +function manualSnippet(pkg: string): string { + return [ + `import bunny from "${pkg}";`, + "", + "export default defineConfig({", + ' output: "server",', + " adapter: bunny(),", + "});", + ].join("\n"); +} + +async function run(command: string, cwd: string): Promise { + logger.info(`Running: ${command}`); + const shell = + process.platform === "win32" + ? ["cmd", "/c", command] + : ["sh", "-c", command]; + const proc = Bun.spawn(shell, { + cwd, + stdin: "ignore", + stdout: "inherit", + stderr: "inherit", + }); + if ((await proc.exited) !== 0) { + throw new UserError(`\`${command}\` failed.`); + } +} + +export interface AdapterOffer { + /** True when the project now has the adapter, so a build can produce a manifest. */ + ready: boolean; + preset?: FrameworkPreset; +} + +/** + * Offer the bunny.net adapter for the framework this project uses. + * + * Called only when the project has no build manifest: either the adapter is not + * installed, or the project has not been built yet. Installing is always the + * developer's choice, so an unattended run reports what to do and changes + * nothing. + */ +export async function offerAdapter( + root: string, + output: string | undefined, +): Promise { + const preset = await detectFramework(root); + const adapter = preset?.adapter; + if (!preset || !adapter) return { ready: false, preset }; + + const installed = await hasAdapter(root, adapter.package); + const configPath = + adapter.configStyle === "astro" ? findAstroConfig(root) : undefined; + const source = configPath ? await Bun.file(configPath).text() : null; + const configured = source === null || source.includes(adapter.package); + if (installed && configured) return { ready: true, preset }; + + const pm = await detectPackageManager(root); + if (!isInteractive(output)) { + logger.warn( + `${preset.label} can render on the edge, and this project is not set up for it.`, + ); + if (!installed) logger.dim(` ${installCommand(pm, adapter.package)}`); + if (!configured) logger.dim(` ${manualSnippet(adapter.package)}`); + logger.dim(" Then re-run this command."); + return { ready: false, preset }; + } + + logger.info( + installed + ? `${preset.label} detected, with ${adapter.package} installed but not configured.` + : `${preset.label} detected, with no bunny.net adapter.`, + ); + const wanted = await confirm( + installed + ? `Add ${adapter.package} to the config and render on the edge?` + : `Add ${adapter.package} and render on the edge?`, + { initial: true }, + ); + if (!wanted) return { ready: false, preset }; + + if (!installed) await run(installCommand(pm, adapter.package), root); + + // The config edit. A config this cannot read safely is left alone, and the + // developer gets the exact lines to paste. + if (!configured) { + const patched = + source === null ? null : patchAstroConfig(source, adapter.package); + if (!configPath || patched === null) { + throw new UserError( + `${installed ? "This project has" : "Installed"} ${adapter.package}, and the config needs one more change.`, + `Add this, then re-run \`bunny deploy\`:\n\n${manualSnippet(adapter.package)}`, + ); + } + await Bun.write(configPath, patched); + const name = configPath.split("/").pop(); + logger.success( + patched.includes('output: "server"') && !source.includes("output") + ? `Set output: "server" and the adapter in ${name}.` + : `Added the adapter to ${name}.`, + ); + } + + return { ready: true, preset }; +} diff --git a/packages/cli/src/commands/deploy/api.test.ts b/packages/cli/src/commands/deploy/api.test.ts new file mode 100644 index 00000000..d2d09537 --- /dev/null +++ b/packages/cli/src/commands/deploy/api.test.ts @@ -0,0 +1,254 @@ +import { expect, test } from "bun:test"; +import type { BuildManifest } from "@bunny.net/config"; +import type { ComputeClient } from "../sites/api.ts"; +import type { CoreClient, StorageZoneModel } from "../storage/api.ts"; +import { + applyPullZoneSettings, + applyScriptEnv, + deployPreamble, + storageHostFor, +} from "./api.ts"; +import { resolveScriptEnv } from "./framework.ts"; + +interface Call { + method: string; + path: string; + body?: unknown; +} + +test("storageHostFor maps a region to its endpoint", () => { + expect(storageHostFor("DE")).toBe("storage.bunnycdn.com"); + expect(storageHostFor("de")).toBe("storage.bunnycdn.com"); + expect(storageHostFor("NY")).toBe("ny.storage.bunnycdn.com"); + expect(storageHostFor("syd")).toBe("syd.storage.bunnycdn.com"); + // A zone with no region reported is the default one. + expect(storageHostFor(null)).toBe("storage.bunnycdn.com"); +}); + +// The preamble is what keeps a release and its files together. +test("deployPreamble writes the deploy onto globalThis", () => { + const line = deployPreamble({ + id: "a1b2c3d4", + assetPrefix: "deploys/a1b2c3d4", + site: "my-site", + environment: "production", + }); + expect(line).toBe( + 'globalThis.__BUNNY_DEPLOY__ = {"id":"a1b2c3d4","assetPrefix":"deploys/a1b2c3d4","site":"my-site","environment":"production"};\n', + ); + // It is one line, so a source map's line numbers shift by exactly one. + expect(line.split("\n")).toHaveLength(2); +}); + +function fakePullZoneClient( + calls: Call[], + zone: Record, +): CoreClient { + return { + GET: async (path: string) => { + calls.push({ method: "GET", path }); + return { data: zone }; + }, + POST: async (path: string, init?: { body?: unknown }) => { + calls.push({ method: "POST", path, body: init?.body }); + return { data: {} }; + }, + } as unknown as CoreClient; +} + +test("only the settings that differ are written, and each is reported", async () => { + const calls: Call[] = []; + const client = fakePullZoneClient(calls, { + DisableCookies: true, + EnableSmartCache: true, + }); + + const result = await applyPullZoneSettings(client, 30, { + disableCookies: false, + enableSmartCache: false, + }); + + expect(result.changed).toEqual(["cookies on", "Smart Cache off"]); + expect(calls.filter((c) => c.method === "POST")).toHaveLength(1); + expect(calls.at(-1)?.body).toEqual({ + DisableCookies: false, + EnableSmartCache: false, + }); +}); + +test("a pull zone already configured is left alone", async () => { + const calls: Call[] = []; + const client = fakePullZoneClient(calls, { + DisableCookies: false, + EnableSmartCache: false, + }); + + const result = await applyPullZoneSettings(client, 30, { + disableCookies: false, + enableSmartCache: false, + }); + + expect(result.changed).toEqual([]); + expect(calls.some((c) => c.method === "POST")).toBe(false); +}); + +test("a build that asks for nothing changes nothing", async () => { + const calls: Call[] = []; + const client = fakePullZoneClient(calls, { DisableCookies: true }); + expect((await applyPullZoneSettings(client, 30, undefined)).changed).toEqual( + [], + ); + expect(calls).toEqual([]); +}); + +function fakeScriptClient( + calls: Call[], + existing: { + variables?: { Name: string; DefaultValue: string }[]; + secrets?: { Name: string }[]; + }, +): ComputeClient { + return { + GET: async (path: string) => { + calls.push({ method: "GET", path }); + if (path === "/compute/script/{id}/secrets") { + return { data: { Secrets: existing.secrets ?? [] } }; + } + return { data: { EdgeScriptVariables: existing.variables ?? [] } }; + }, + PUT: async (path: string, init?: { body?: unknown }) => { + calls.push({ method: "PUT", path, body: init?.body }); + return { data: {} }; + }, + } as unknown as ComputeClient; +} + +test("a variable already holding the right value is not written again", async () => { + const calls: Call[] = []; + const client = fakeScriptClient(calls, { + variables: [{ Name: "BUNNY_STORAGE_ZONE", DefaultValue: "my-site" }], + }); + + const set = await applyScriptEnv(client, 20, [ + { name: "BUNNY_STORAGE_ZONE", value: "my-site" }, + { name: "BUNNY_PULLZONE_ID", value: "30" }, + ]); + + expect(set).toEqual(["BUNNY_PULLZONE_ID"]); + const writes = calls.filter((c) => c.method === "PUT"); + expect(writes).toHaveLength(1); + expect(writes[0]?.body).toEqual({ + Name: "BUNNY_PULLZONE_ID", + DefaultValue: "30", + }); +}); + +// A secret cannot be read back, so a rotated password must survive a deploy. +test("an existing secret is left in place", async () => { + const calls: Call[] = []; + const client = fakeScriptClient(calls, { + secrets: [{ Name: "BUNNY_STORAGE_KEY" }], + }); + + const set = await applyScriptEnv(client, 20, [ + { name: "BUNNY_STORAGE_KEY", value: "a-new-password", secret: true }, + ]); + + expect(set).toEqual([]); + expect(calls.some((c) => c.method === "PUT")).toBe(false); +}); + +test("a secret that is not there yet is written once", async () => { + const calls: Call[] = []; + const client = fakeScriptClient(calls, {}); + + const set = await applyScriptEnv(client, 20, [ + { name: "bunny_storage_key", value: "password", secret: true }, + ]); + + expect(set).toEqual(["BUNNY_STORAGE_KEY"]); + expect(calls.at(-1)).toEqual({ + method: "PUT", + path: "/compute/script/{id}/secrets", + body: { Name: "BUNNY_STORAGE_KEY", Secret: "password" }, + }); +}); + +const ZONE = { + Id: 10, + Name: "sites-my-site-k3f9wq", + Region: "NY", + Password: "write-password", + ReadOnlyPassword: "read-password", +} as StorageZoneModel; + +function manifest(requires?: BuildManifest["requires"]): BuildManifest { + return { + manifestVersion: 1, + adapter: { package: "@bunny.net/astro-adapter" }, + framework: { name: "astro" }, + kind: "ssr", + script: { entry: "dist/index.js", type: "standalone" }, + assets: { dir: "dist/client" }, + requires, + }; +} + +test("the script gets the read-only password for assets, and no typing", () => { + const { entries } = resolveScriptEnv( + manifest({ + env: [ + { name: "BUNNY_STORAGE_ZONE" }, + { name: "BUNNY_STORAGE_HOST" }, + { name: "BUNNY_STORAGE_KEY", secret: true }, + ], + }), + ZONE, + 30, + ); + + expect(entries).toEqual([ + { name: "BUNNY_STORAGE_ZONE", value: "sites-my-site-k3f9wq" }, + { name: "BUNNY_STORAGE_HOST", value: "ny.storage.bunnycdn.com" }, + { name: "BUNNY_STORAGE_KEY", value: "read-password", secret: true }, + ]); +}); + +// Sessions have to write, and only sessions do. +test("sessions get the password that can write, and only when asked for", () => { + const withSessions = resolveScriptEnv( + manifest({ + storage: { write: true }, + env: [{ name: "BUNNY_SESSION_ZONE" }, { name: "BUNNY_SESSION_KEY" }], + }), + ZONE, + 30, + ); + expect(withSessions.entries).toEqual([ + { name: "BUNNY_SESSION_ZONE", value: "sites-my-site-k3f9wq" }, + { name: "BUNNY_SESSION_KEY", value: "write-password", secret: true }, + ]); + + const without = resolveScriptEnv( + manifest({ env: [{ name: "BUNNY_SESSION_ZONE" }] }), + ZONE, + 30, + ); + expect(without.entries).toEqual([]); + expect(without.unset).toEqual(["BUNNY_SESSION_ZONE"]); +}); + +test("a variable the CLI cannot supply is reported, not invented", () => { + const { entries, unset } = resolveScriptEnv( + manifest({ + env: [ + { name: "BUNNY_PULLZONE_ID" }, + { name: "BUNNY_API_KEY", secret: true, optional: true }, + ], + }), + ZONE, + 30, + ); + expect(entries).toEqual([{ name: "BUNNY_PULLZONE_ID", value: "30" }]); + expect(unset).toEqual(["BUNNY_API_KEY"]); +}); diff --git a/packages/cli/src/commands/deploy/api.ts b/packages/cli/src/commands/deploy/api.ts new file mode 100644 index 00000000..f4d238f2 --- /dev/null +++ b/packages/cli/src/commands/deploy/api.ts @@ -0,0 +1,486 @@ +import type { BuildManifest, ManifestPullZone } from "@bunny.net/config"; +import { ApiError, errorMessage, UserError } from "../../core/errors.ts"; +import { + createPullZone, + setForceSsl, + systemHostname, +} from "../../core/hostnames/index.ts"; +import { logger } from "../../core/logger.ts"; +import { fetchEnvEntries, fetchScripts } from "../scripts/api.ts"; +import { SCRIPT_TYPE_STANDALONE } from "../scripts/constants.ts"; +import { + type ComputeClient, + promoteVerification, + siteContextFromZone, + siteFiles, + writeRemoteState, +} from "../sites/api.ts"; +import { + type RemoteSiteState, + STATE_VERSION, + serverScriptName, + siteResourcePattern, + suffixedResourceName, +} from "../sites/constants.ts"; +import { + type CoreClient, + fetchStorageZone, + type StorageZoneModel, +} from "../storage/api.ts"; +import type { StorageZone } from "../storage/files-api.ts"; + +/** Frankfurt has no prefix; every other region is `.storage.bunnycdn.com`. */ +export function storageHostFor(region: string | null | undefined): string { + const code = (region ?? "de").toLowerCase(); + return code === "de" || code === "" + ? "storage.bunnycdn.com" + : `${code}.storage.bunnycdn.com`; +} + +// The globally-unique name is taken (often by another account, so a pre-create lookup missed it): a 409, or a 400 that says so. +function isNameTaken(err: unknown): boolean { + if (!(err instanceof ApiError)) return false; + if (err.status === 409) return true; + return ( + err.status === 400 && + /already (exists|taken|in use)|not available|is taken/i.test(err.message) + ); +} + +export interface CreateFrameworkSiteOptions { + coreClient: CoreClient; + computeClient: ComputeClient; + name: string; + region: string; + manifest: BuildManifest; + onStep?: (message: string) => void; +} + +export interface CreateFrameworkSiteResult { + state: RemoteSiteState; + storageZone: StorageZoneModel; + systemHostname?: string; + reused: { storageZone: boolean; script: boolean; pullZone: boolean }; +} + +/** + * Provision a framework site: a storage zone for the files, a standalone Edge + * Script for the framework's server, and the pull zone that serves it. + * + * Every step looks its resource up by name first, so a half-finished create + * re-runs cleanly. A zone that already carries site state is never + * re-provisioned. + */ +export async function createFrameworkSite( + opts: CreateFrameworkSiteOptions, +): Promise { + const { coreClient, computeClient, name, region, manifest } = opts; + const step = opts.onStep ?? (() => {}); + const reused = { storageZone: false, script: false, pullZone: false }; + + // 1. Storage zone. It is the site's identity, and it holds the state file. + step("Creating the storage zone..."); + let storageZone = await findFrameworkStorageZone(coreClient, name); + if (storageZone) { + const existing = await siteContextFromZone(storageZone); + if (existing) { + throw new UserError( + `Site "${name}" already exists.`, + `Run \`bunny deploy\` from the project it belongs to, or pick another name.`, + ); + } + reused.storageZone = true; + } else { + // The suffix keeps a globally-unique name from colliding with another + // account's zone; retry with 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 }, + }); + if (!data?.Id) { + throw new UserError(`Failed to create storage zone "${zoneName}".`); + } + // Re-fetch for the full record, which carries the zone passwords. + storageZone = await fetchStorageZone(coreClient, data.Id); + } catch (err) { + if (!isNameTaken(err)) throw err; + } + } + } + if (!storageZone?.Id) { + throw new UserError( + `Couldn't find an available storage zone name for "${name}".`, + "Re-run the command, or choose a different site name.", + ); + } + const storageZoneId = storageZone.Id; + const resourceName = storageZone.Name ?? name; + + // 2. The script, with the pull zone it is the origin of. A standalone script + // is its own origin, so the compute API creates the zone: that is one call + // rather than a public zone with no origin for a moment. + step("Creating the Edge Script..."); + const scriptName = serverScriptName(resourceName); + let script = (await fetchScripts(computeClient)).find( + (s) => s.Name === scriptName, + ); + if (script?.Id != null) { + reused.script = true; + } else { + const { data } = await computeClient.POST("/compute/script", { + body: { + Name: scriptName, + ScriptType: SCRIPT_TYPE_STANDALONE, + CreateLinkedPullZone: true, + LinkedPullZoneName: resourceName, + }, + }); + if (data?.Id == null) { + throw new UserError(`Failed to create Edge Script "${scriptName}".`); + } + script = data; + } + const scriptId = script.Id as number; + + // 3. The pull zone. Normally the script created it; adopt an existing one on a + // resumed create, and create one when the compute API made none. + step("Creating the pull zone..."); + let pullZoneId = script.LinkedPullZones?.[0]?.Id ?? undefined; + let hostnames: Parameters[0] | undefined; + if (pullZoneId == null) { + const found = await findFrameworkPullZone(coreClient, name, scriptId); + if (found?.Id != null) { + reused.pullZone = true; + pullZoneId = found.Id; + hostnames = found.Hostnames ?? undefined; + } + } + if (pullZoneId == null) { + // No linked zone: make one whose origin is this script. + const zone = await createPullZone(coreClient, resourceName, 0, { + edgeScriptId: scriptId, + }); + if (zone.Id == null) { + throw new UserError(`Failed to create pull zone "${resourceName}".`); + } + pullZoneId = zone.Id; + hostnames = zone.Hostnames ?? undefined; + } + + // A pull zone the compute API created reports no hostnames in that response. + if (!hostnames) { + const { data } = await coreClient.GET("/pullzone/{id}", { + params: { path: { id: pullZoneId } }, + }); + hostnames = data?.Hostnames ?? undefined; + } + + // Force HTTPS on the `*.b-cdn.net` host. It is already on bunny's wildcard + // certificate, so this only redirects HTTP. Best effort: a site that serves + // over HTTP is still a site. + const systemHost = systemHostname(hostnames); + if (systemHost) { + try { + await setForceSsl(coreClient, pullZoneId, systemHost, true); + } catch (err) { + logger.warn( + `Couldn't force HTTPS on ${systemHost}: ${errorMessage(err)}`, + ); + } + } + + // 4. State. From here on the zone identifies as a site. + step("Writing the site state..."); + const state: RemoteSiteState = { + version: STATE_VERSION, + name, + kind: "framework", + framework: { + name: manifest.framework.name, + adapter: manifest.adapter.package, + ...(manifest.adapter.version + ? { version: manifest.adapter.version } + : {}), + }, + storageZoneId, + pullZoneId, + scriptId, + deploys: [], + }; + await writeRemoteState(siteFiles.connect(storageZone), state); + + return { state, storageZone, systemHostname: systemHost, reused }; +} + +/** A `sites-{name}-{suffix}` storage zone, re-fetched by ID so it carries the passwords. */ +async function findFrameworkStorageZone( + client: CoreClient, + name: string, +): Promise { + const { data } = await client.GET("/storagezone", { + params: { query: { search: name } }, + }); + const pattern = siteResourcePattern(name); + const match = (data ?? []).find( + (zone) => pattern.test(zone.Name ?? "") && zone.Id != null, + ); + return match?.Id == null + ? undefined + : fetchStorageZone(client, match.Id as number); +} + +/** The pull zone already pointing at this script, on a resumed create. */ +async function findFrameworkPullZone( + client: CoreClient, + name: string, + scriptId: number, +) { + const { data } = await client.GET("/pullzone", { + params: { query: { search: name, perPage: 1000 } }, + }); + // The endpoint answers with a plain array for some queries and an envelope for + // others, so read both shapes. + const raw = data as unknown; + const items = Array.isArray(raw) + ? raw + : ((raw as { Items?: unknown[] } | undefined)?.Items ?? []); + return ( + items as { + Id?: number; + EdgeScriptId?: number; + Hostnames?: Parameters[0]; + }[] + ).find((pz) => pz.EdgeScriptId === scriptId); +} + +/** + * The line the CLI puts at the top of the bundle, so the code carries the name + * of the folder its files are in. + * + * `var` and not `globalThis.x =` alone: a bundle is an ES module, and this has + * to be visible to code that reads `globalThis`. Assigning to `globalThis` does + * both, in every runtime the script may start in. + */ +export function deployPreamble(info: { + id: string; + assetPrefix: string; + site: string; + environment: string; +}): string { + return `globalThis.__BUNNY_DEPLOY__ = ${JSON.stringify(info)};\n`; +} + +export interface PublishOptions { + computeClient: ComputeClient; + coreClient: CoreClient; + scriptId: number; + pullZoneId: number; + /** The bundle as the build wrote it. The preamble is added here, never on disk. */ + code: string; + deploy: { + id: string; + assetPrefix: string; + site: string; + environment: string; + }; +} + +/** + * How long to let a new release reach the edge nodes before the second purge. + * + * A probe cannot tell the outgoing release from the incoming one: both answer + * 200 with a page. So this waits rather than polls, and the second purge is what + * clears anything the first one re-cached from the old release. + */ +const SETTLE_MS = 5000; + +/** + * Publish one deploy's code, and clear the cache in front of it. + * + * The preamble is prepended here, so the stored bundle stays exactly what the + * build produced and every publish of it pins the same prefix. + */ +export async function publishDeploy( + opts: PublishOptions, +): Promise<{ release?: string }> { + const { computeClient, coreClient, scriptId } = opts; + + await computeClient.POST("/compute/script/{id}/code", { + params: { path: { id: scriptId } }, + body: { Code: deployPreamble(opts.deploy) + opts.code }, + }); + await computeClient.POST("/compute/script/{id}/publish", { + params: { path: { id: scriptId, uuid: null } }, + body: {}, + }); + + // The cache in front of the script still holds the previous release's pages. + const purge = () => + coreClient + .POST("/pullzone/{id}/purgeCache", { + params: { path: { id: opts.pullZoneId } }, + body: {}, + }) + .catch((err) => { + logger.warn( + `Couldn't purge the cache; the site may serve the previous release for a while: ${errorMessage(err)}`, + ); + }); + + await purge(); + // Wait for the release to reach the nodes, then purge what the first purge + // re-cached from the outgoing one. Without this the command reports success + // while the site still serves the previous release. + await promoteVerification.wait(SETTLE_MS); + await purge(); + + return { release: await activeRelease(computeClient, scriptId) }; +} + +/** The live release's ID, for the deploy record. Best effort: it is a label, not a lever. */ +async function activeRelease( + client: ComputeClient, + scriptId: number, +): Promise { + try { + const { data } = await client.GET("/compute/script/{id}/releases/active", { + params: { path: { id: scriptId } }, + }); + const release = data as { Uuid?: string } | null; + return release?.Uuid ?? undefined; + } catch { + return undefined; + } +} + +/** Read a deploy's stored bundle. This is what makes a rollback exact. */ +export async function readStoredBundle( + connection: StorageZone, + path: string, +): Promise { + try { + const { stream } = await siteFiles.download(connection, path); + return await new Response(stream).text(); + } catch (err) { + throw new UserError( + `Couldn't read the stored bundle for this deploy: ${errorMessage(err)}`, + "Deploy again to publish the current build.", + ); + } +} + +export interface PullZoneSettingsResult { + /** One line for each setting this run changed. */ + changed: string[]; +} + +/** + * Apply the pull zone settings the build asks for. + * + * Only what differs is written, and every change is reported. A developer who + * changed one of these by hand should be able to see the CLI change it back. + */ +export async function applyPullZoneSettings( + client: CoreClient, + pullZoneId: number, + want: ManifestPullZone | undefined, +): Promise { + const changed: string[] = []; + if (!want) return { changed }; + + const { data: zone } = await client.GET("/pullzone/{id}", { + params: { path: { id: pullZoneId } }, + }); + if (!zone) return { changed }; + + const body: Record = {}; + const wanted: { + key: keyof ManifestPullZone; + field: string; + label: (value: boolean) => string; + }[] = [ + { + key: "disableCookies", + field: "DisableCookies", + label: (v) => (v ? "cookies off" : "cookies on"), + }, + { + key: "enableSmartCache", + field: "EnableSmartCache", + label: (v) => (v ? "Smart Cache on" : "Smart Cache off"), + }, + { + key: "enableCacheSlice", + field: "EnableCacheSlice", + label: (v) => + v ? "large object delivery on" : "large object delivery off", + }, + ]; + + for (const { key, field, label } of wanted) { + const value = want[key]; + if (value === undefined) continue; + if ((zone as Record)[field] === value) continue; + body[field] = value; + changed.push(label(value)); + } + + if (changed.length > 0) { + await client.POST("/pullzone/{id}", { + params: { path: { id: pullZoneId } }, + body, + }); + } + return { changed }; +} + +export interface ScriptEnv { + name: string; + value: string; + secret?: boolean; +} + +/** + * Set the variables the script needs, skipping the ones already correct. + * + * A secret cannot be read back, so it is written once, when the name is absent. + * That keeps a rotated password in place, and keeps a deploy from writing a + * secret on every run. + */ +export async function applyScriptEnv( + client: ComputeClient, + scriptId: number, + entries: ScriptEnv[], +): Promise { + const existing = await fetchEnvEntries(client, scriptId); + const variables = new Map( + existing + .filter((e) => !e.secret) + .map((e) => [e.name.toUpperCase(), e.value]), + ); + const secrets = new Set( + existing.filter((e) => e.secret).map((e) => e.name.toUpperCase()), + ); + + const set: string[] = []; + for (const entry of entries) { + const name = entry.name.toUpperCase(); + if (entry.secret) { + if (secrets.has(name)) continue; + await client.PUT("/compute/script/{id}/secrets", { + params: { path: { id: scriptId } }, + body: { Name: name, Secret: entry.value }, + }); + } else { + if (variables.get(name) === entry.value) continue; + await client.PUT("/compute/script/{id}/variables", { + params: { path: { id: scriptId } }, + body: { Name: name, DefaultValue: entry.value }, + }); + } + set.push(name); + } + return set; +} diff --git a/packages/cli/src/commands/deploy/framework.ts b/packages/cli/src/commands/deploy/framework.ts new file mode 100644 index 00000000..8a0293a1 --- /dev/null +++ b/packages/cli/src/commands/deploy/framework.ts @@ -0,0 +1,555 @@ +import type { BuildManifest } from "@bunny.net/config"; +import prompts from "prompts"; +import { errorMessage, UserError } from "../../core/errors.ts"; +import { formatBytes } from "../../core/format.ts"; +import { normalizeHostname } from "../../core/hostnames/index.ts"; +import { logger } from "../../core/logger.ts"; +import { loadManifest, saveManifest } from "../../core/manifest.ts"; +import { confirm, isInteractive, withSpinner } from "../../core/ui.ts"; +import { + type ComputeClient, + fetchSystemHostname, + type SiteContext, + siteContextFromZone, + siteFiles, + writeRemoteState, +} from "../sites/api.ts"; +import { + type DeployRecord, + isFrameworkSite, + markCurrent, + type RemoteSiteState, + SITES_MANIFEST, + type SiteManifest, + serverBundlePath, +} from "../sites/constants.ts"; +import { contentHashId, resolveDeployIdentity } from "../sites/deploy-id.ts"; +import { setupSiteDomain } from "../sites/domains/index.ts"; +import { promptSiteName } from "../sites/provision.ts"; +import { collectFiles, hashFiles, uploadDeploy } from "../sites/uploader.ts"; +import type { CoreClient, StorageZoneModel } from "../storage/api.ts"; +import { fetchStorageZone } from "../storage/api.ts"; +import { + applyPullZoneSettings, + applyScriptEnv, + createFrameworkSite, + publishDeploy, + readStoredBundle, + type ScriptEnv, + storageHostFor, +} from "./api.ts"; +import { + type LoadedBuildManifest, + resolveAssetsDir, + resolveScriptEntry, +} from "./manifest.ts"; + +/** Only production exists so far. A named preview environment is the next step. */ +export const PRODUCTION = "production"; + +/** Edge Scripting takes one JavaScript file of up to 10 MB. */ +const SCRIPT_SIZE_LIMIT = 10 * 1024 * 1024; + +const DOMAIN_HINT = " Add a custom domain: bunny domains add "; + +export interface FrameworkDeployArgs { + name?: string; + region?: string; + force?: boolean; + open?: boolean; + output?: string; + verbose: boolean; +} + +/** + * The site this directory deploys to, creating it on the first run. + * + * `.bunny/site.json` points at the storage zone, and the zone holds the state. + * So the source of truth travels with the site, and a second machine only needs + * the pointer. + */ +async function resolveSite(opts: { + coreClient: CoreClient; + computeClient: ComputeClient; + manifest: BuildManifest; + root: string; + name?: string; + region?: string; + output?: string; +}): Promise { + const linked = loadManifest(SITES_MANIFEST); + if (linked.id) { + const zone = await fetchStorageZone(opts.coreClient, linked.id); + const context = await siteContextFromZone(zone); + if (!context) { + throw new UserError( + `The linked site (storage zone ${linked.id}) holds no site state.`, + "Delete .bunny/site.json to start a new site here.", + ); + } + if (!isFrameworkSite(context.state)) { + throw new UserError( + `"${context.state.name}" is a static site, and this project builds a server.`, + "Run `bunny sites deploy` for that site, or link this directory to a new one.", + ); + } + return context; + } + + const name = await promptSiteName( + opts.name, + isInteractive(opts.output), + "Pass one: bunny deploy --name .", + ); + const created = await withSpinner(`Creating site "${name}"...`, (spin) => + createFrameworkSite({ + coreClient: opts.coreClient, + computeClient: opts.computeClient, + name, + region: (opts.region ?? "DE").toUpperCase(), + manifest: opts.manifest, + onStep: (message) => { + spin.text = message; + }, + }), + ); + + saveManifest(SITES_MANIFEST, { + id: created.state.storageZoneId, + name, + }); + + logger.success(`Created site "${name}".`); + logger.dim(` storage zone ${created.storageZone.Name}`); + logger.dim(` edge script ${created.state.scriptId}`); + logger.dim( + ` pull zone ${created.systemHostname ?? created.state.pullZoneId}`, + ); + + const context = await siteContextFromZone(created.storageZone); + if (!context) { + throw new UserError( + `Created site "${name}" but could not read its state back.`, + "Re-run `bunny deploy`.", + ); + } + return context; +} + +/** + * The variables the script needs, from what the CLI already knows. + * + * The developer types no password: the CLI created the zone, so it holds both. + * The asset password is the read-only one, and only sessions get the one that + * can write. + */ +export function resolveScriptEnv( + manifest: BuildManifest, + zone: StorageZoneModel, + pullZoneId: number, +): { entries: ScriptEnv[]; unset: string[] } { + const wanted = manifest.requires?.env ?? []; + const readOnly = zone.ReadOnlyPassword ?? zone.Password ?? ""; + const known: Record = { + BUNNY_STORAGE_ZONE: { name: "BUNNY_STORAGE_ZONE", value: zone.Name ?? "" }, + BUNNY_STORAGE_HOST: { + name: "BUNNY_STORAGE_HOST", + value: storageHostFor(zone.Region), + }, + BUNNY_STORAGE_KEY: { + name: "BUNNY_STORAGE_KEY", + value: readOnly, + secret: true, + }, + BUNNY_PULLZONE_ID: { + name: "BUNNY_PULLZONE_ID", + value: String(pullZoneId), + }, + }; + if (manifest.requires?.storage?.write) { + known.BUNNY_SESSION_ZONE = { + name: "BUNNY_SESSION_ZONE", + value: zone.Name ?? "", + }; + known.BUNNY_SESSION_KEY = { + name: "BUNNY_SESSION_KEY", + value: zone.Password ?? "", + secret: true, + }; + } + + const entries: ScriptEnv[] = []; + const unset: string[] = []; + for (const want of wanted) { + const entry = known[want.name.toUpperCase()]; + if (entry?.value) { + entries.push(entry); + } else if (!want.optional) { + unset.push(want.name); + } else { + unset.push(want.name); + } + } + return { entries, unset }; +} + +/** Deploy a framework build: upload the files and the bundle, then publish. */ +export async function deployFramework(opts: { + coreClient: CoreClient; + computeClient: ComputeClient; + loaded: LoadedBuildManifest; + args: FrameworkDeployArgs; +}): Promise<{ production?: string }> { + const { coreClient, computeClient, loaded, args } = opts; + const { manifest, root } = loaded; + const output = args.output; + + const entryPath = resolveScriptEntry(loaded); + const assetsDir = resolveAssetsDir(loaded); + + const site = await resolveSite({ + coreClient, + computeClient, + manifest, + root, + name: args.name, + region: args.region, + output, + }); + const { state, connection } = site; + let etag = site.etag; + const firstDeploy = state.deploys.length === 0; + + const code = await Bun.file(entryPath).text(); + const bundleBytes = Buffer.byteLength(code); + // Edge Scripting takes one file of up to 10 MB. Saying so before the upload is + // kinder than a rejected publish after it. + if (bundleBytes > SCRIPT_SIZE_LIMIT) { + throw new UserError( + `${manifest.script?.entry} is ${formatBytes(bundleBytes)}, and Edge Scripting takes ${formatBytes(SCRIPT_SIZE_LIMIT)}.`, + "Drop a dependency the server does not need, and build again.", + ); + } + + const files = await withSpinner("Hashing files...", () => + hashFiles(collectFiles(assetsDir)), + ); + if (files.length === 0) { + throw new UserError( + `Nothing to deploy; ${manifest.assets.dir} has no files.`, + "Dotfiles and node_modules are excluded.", + ); + } + const totalBytes = files.reduce((sum, f) => sum + f.size, 0); + + // The code and the files are one unit: a server bundle names the hashed asset + // it renders, so a deploy ID has to cover both. + const bundleHash = new Bun.CryptoHasher("sha256").update(code).digest("hex"); + const identity = await resolveDeployIdentity(root, [ + ...files, + { path: "_bunny/server.js", sha256: bundleHash }, + ]); + const contentHash = contentHashId([ + ...files, + { path: "_bunny/server.js", sha256: bundleHash }, + ]); + + const already = args.force + ? undefined + : state.deploys.find((d) => d.contentHash === contentHash); + const deployId = already?.id ?? identity.id; + const live = state.current === deployId; + + if (already && live) { + const urls = await siteUrls(coreClient, state); + if (output === "json") { + logger.log( + JSON.stringify( + { + site: state.name, + id: deployId, + unchanged: true, + live: true, + production: urls.production ?? null, + }, + null, + 2, + ), + ); + return urls; + } + logger.info( + `No changes: deploy ${deployId} is already live. Use --force to deploy it again.`, + ); + if (urls.production) logger.log(` ${urls.production}`); + return urls; + } + + // Settings first: a site that serves a page while the pull zone still strips + // Set-Cookie looks broken in a way nothing explains. + const settings = await withSpinner("Checking the pull zone...", () => + applyPullZoneSettings( + coreClient, + state.pullZoneId, + manifest.requires?.pullZone, + ), + ); + if (settings.changed.length > 0 && output !== "json") { + logger.info( + `Applied the settings ${manifest.adapter.package} asks for: ${settings.changed.join(", ")}.`, + ); + } + + const zone = site.storageZone; + const { entries, unset } = resolveScriptEnv(manifest, zone, state.pullZoneId); + const set = await withSpinner("Setting the script's variables...", () => + applyScriptEnv(computeClient, state.scriptId, entries), + ); + if (set.length > 0 && output !== "json") { + logger.info(`Set ${set.length} script variable(s): ${set.join(", ")}.`); + } + + await withSpinner(`Uploading ${files.length} files...`, (spin) => + uploadDeploy(connection, deployId, files, { + onFileUploaded: (done, total) => { + spin.text = `Uploading ${done}/${total} files (${formatBytes(totalBytes)} total)...`; + }, + }), + ); + + // The bundle goes to storage too, under `_bunny/` where nothing serves it. + // That is what makes a rollback restore a matched pair. + await withSpinner("Uploading the server bundle...", () => + siteFiles.upload( + connection, + serverBundlePath(deployId), + new Blob([code]).stream(), + ), + ); + + const published = await withSpinner("Publishing...", () => + publishDeploy({ + computeClient, + coreClient, + scriptId: state.scriptId, + pullZoneId: state.pullZoneId, + code, + deploy: { + id: deployId, + assetPrefix: `deploys/${deployId}`, + site: state.name, + environment: PRODUCTION, + }, + }), + ); + + const record: DeployRecord = { + id: deployId, + createdAt: new Date().toISOString(), + source: identity.source, + gitSha: identity.gitSha, + dirty: identity.dirty, + contentHash, + files: files.length, + bytes: totalBytes, + scriptBytes: bundleBytes, + ...(published.release ? { release: published.release } : {}), + }; + state.deploys = [record, ...state.deploys.filter((d) => d.id !== deployId)]; + markCurrent(state, deployId); + etag = await writeRemoteState(connection, state, etag, { + promotedTo: deployId, + }); + + const urls = await siteUrls(coreClient, state); + + if (output === "json") { + logger.log( + JSON.stringify( + { + site: state.name, + id: deployId, + source: identity.source, + files: files.length, + bytes: totalBytes, + scriptBytes: bundleBytes, + release: published.release ?? null, + production: urls.production ?? null, + }, + null, + 2, + ), + ); + return urls; + } + + logger.success( + `Deployed ${deployId}: ${files.length} files (${formatBytes(totalBytes)}), script ${formatBytes(bundleBytes)}.`, + ); + if (urls.production) logger.info(`Production ${urls.production}`); + + const missing = unset.filter((name) => name !== "BUNNY_API_KEY"); + if (missing.length > 0) { + logger.dim( + ` ${manifest.adapter.package} also reads ${missing.join(", ")}. Set them with \`bunny env set\`.`, + ); + } + if (unset.includes("BUNNY_API_KEY")) { + logger.dim( + " Cache purging needs an account API key: bunny scripts env set BUNNY_API_KEY --secret", + ); + } + + // The first deploy is the one moment to offer a domain: the list is never + // empty again, so a later offer would just be noise. + if (!state.domain) { + logger.log(); + let handled = false; + if (firstDeploy && isInteractive(output)) { + const { value } = await prompts({ + type: "text", + name: "value", + message: "Custom domain for this site (leave blank to skip):", + }); + const domain = normalizeHostname(value ?? "") || undefined; + if (domain) { + handled = true; + site.etag = etag; + try { + await setupSiteDomain({ + coreClient, + site, + domain, + interactive: true, + verbose: args.verbose, + }); + } catch (err) { + logger.warn( + `Couldn't finish setting up ${domain}: ${errorMessage(err)}`, + ); + logger.dim(` Retry later: bunny sites domains add ${domain}`); + } + } + } + if (!handled) logger.dim(DOMAIN_HINT); + } + + return urls; +} + +/** Publish a deploy that is already uploaded: the rollback path. */ +export async function republishDeploy(opts: { + coreClient: CoreClient; + computeClient: ComputeClient; + site: SiteContext; + deployId: string; + output?: string; +}): Promise { + const { coreClient, computeClient, site, deployId, output } = opts; + const { state, connection } = site; + + const record = state.deploys.find((d) => d.id === deployId); + if (!record) { + throw new UserError( + `Site "${state.name}" has no deploy ${deployId}.`, + "Run `bunny deployments list` to see what it keeps.", + ); + } + + const code = await withSpinner("Reading the stored bundle...", () => + readStoredBundle(connection, serverBundlePath(deployId)), + ); + + const published = await withSpinner(`Publishing ${deployId}...`, () => + publishDeploy({ + computeClient, + coreClient, + scriptId: state.scriptId, + pullZoneId: state.pullZoneId, + code, + deploy: { + id: deployId, + assetPrefix: `deploys/${deployId}`, + site: state.name, + environment: PRODUCTION, + }, + }), + ); + + if (published.release) record.release = published.release; + markCurrent(state, deployId); + await writeRemoteState(connection, state, site.etag, { + promotedTo: deployId, + }); + + const urls = await siteUrls(coreClient, state); + if (output === "json") { + logger.log( + JSON.stringify( + { + site: state.name, + id: deployId, + live: true, + production: urls.production ?? null, + }, + null, + 2, + ), + ); + return; + } + logger.success(`Deploy ${deployId} is live.`); + if (urls.production) logger.info(`Production ${urls.production}`); +} + +/** The site's production URL: its custom domain when it has one, else the system host. */ +export async function siteUrls( + coreClient: CoreClient, + state: RemoteSiteState, +): Promise<{ production?: string }> { + const host = + state.domain ?? (await fetchSystemHostname(coreClient, state.pullZoneId)); + return { production: host ? `https://${host}` : undefined }; +} + +/** Load the framework site this directory is linked to, or fail with a useful message. */ +export async function requireLinkedFrameworkSite( + coreClient: CoreClient, +): Promise { + const linked = loadManifest(SITES_MANIFEST); + if (!linked.id) { + throw new UserError( + "This directory is not linked to a site.", + "Run `bunny deploy` here first.", + ); + } + const zone = await fetchStorageZone(coreClient, linked.id); + const context = await siteContextFromZone(zone); + if (!context) { + throw new UserError( + `The linked site (storage zone ${linked.id}) holds no site state.`, + ); + } + if (!isFrameworkSite(context.state)) { + throw new UserError( + `"${context.state.name}" is a static site.`, + "Use `bunny sites deployments` for it.", + ); + } + return context; +} + +/** Ask before doing something to production without a TTY to answer. */ +export async function confirmProduction( + message: string, + opts: { force?: boolean; output?: string }, +): Promise { + if (opts.force) return true; + if (!isInteractive(opts.output)) { + throw new UserError( + "This changes what production serves, and there is nobody to ask.", + "Pass --force to run it unattended.", + ); + } + return confirm(message, { initial: true }); +} diff --git a/packages/cli/src/commands/deploy/index.ts b/packages/cli/src/commands/deploy/index.ts new file mode 100644 index 00000000..da96a289 --- /dev/null +++ b/packages/cli/src/commands/deploy/index.ts @@ -0,0 +1,215 @@ +import { + createComputeClient, + createCoreClient, +} from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../config/index.ts"; +import { clientOptions } from "../../core/client-options.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { collectEnv } from "../../core/env.ts"; +import { UserError } from "../../core/errors.ts"; +import { logger } from "../../core/logger.ts"; +import { confirm, isInteractive, openBrowser } from "../../core/ui.ts"; +import { + resolveAutoBuild, + resolveRequestedBuild, + runBuildCommand, +} from "../sites/build.ts"; +import { loadSiteConfig } from "../sites/config.ts"; +import { sitesDeployCommand } from "../sites/deploy.ts"; +import { offerAdapter } from "./adapter.ts"; +import { deployFramework } from "./framework.ts"; +import { loadBuildManifest } from "./manifest.ts"; + +interface DeployArgs { + dir?: string; + build?: string; + env?: string[]; + "env-file"?: string; + name?: string; + region?: string; + production?: boolean; + preview?: string; + force?: boolean; + open?: boolean; + site?: string; + link?: boolean; +} + +/** + * Deploy this project to bunny.net. + * + * One command for both shapes of site. A project whose build writes + * `.bunny/build.json` renders per request in an Edge Script, and its files come + * from Bunny Storage. Anything else is a directory of files, which is what + * `bunny sites deploy` has always done. + * + * The CLI knows no framework. It reads the manifest the adapter wrote, so a new + * adapter needs no new CLI. + */ +export const deployCommand = defineCommand({ + command: "deploy [dir]", + describe: "Build and deploy this project.", + examples: [ + ["$0 deploy", "Build if needed, then deploy"], + ["$0 deploy --build", "Run the project's build first"], + ["$0 deploy --force", "Deploy even when nothing changed"], + ["$0 deploy ./dist", "Deploy a directory of static files"], + ], + + builder: (yargs) => + yargs + .positional("dir", { + type: "string", + describe: + "Directory of static files to deploy. Ignored for a framework build, which the manifest describes", + }) + .option("build", { + type: "string", + describe: + "Run a build first. Pass a command, or use the bare flag for the project's own build", + }) + .option("env", { + type: "string", + array: true, + describe: "Build-time env override (KEY=VALUE, repeatable)", + }) + .option("env-file", { + type: "string", + describe: "Read build-time env overrides from a dotenv-style file", + }) + .option("name", { + type: "string", + describe: "Site name, for the first deploy from this directory", + }) + .option("region", { + type: "string", + describe: "Storage region for a new site (default: DE)", + }) + .option("production", { + alias: "prod", + type: "boolean", + describe: + "Publish as the live site. A framework build always publishes; a static one needs this", + }) + .option("preview", { + type: "string", + describe: "Deploy to a named preview environment", + }) + .option("force", { + type: "boolean", + default: false, + describe: "Deploy even when nothing changed", + }) + .option("open", { + type: "boolean", + default: false, + describe: "Open the site when the deploy finishes", + }) + .option("site", { type: "string", describe: "Target a specific site" }) + .option("link", { + type: "boolean", + describe: "Link this directory to the site it deploys to", + }), + + handler: async (args) => { + const { profile, output, verbose, apiKey } = args; + + if (args.preview !== undefined) { + throw new UserError( + "Preview environments are not built yet.", + "A framework deploy publishes to production. Track the design in BunnyWay/bunny-adapters, plans/one-command-deploys.md.", + ); + } + + if (args.build === undefined && (args.env?.length || args["env-file"])) { + throw new UserError( + "--env/--env-file only apply to builds.", + "Add --build to run the build with these variables.", + ); + } + + const siteConfig = loadSiteConfig(); + const configRoot = siteConfig?.root ?? process.cwd(); + + // A project with no manifest may only need its adapter, or its build. + let loaded = await loadBuildManifest(); + if (!loaded && args.dir === undefined) { + const offer = await offerAdapter(configRoot, output); + if (offer.ready && args.build === undefined) { + // The adapter is in place, so a build is what produces the manifest. + args.build = ""; + } + } + + // Build before anything is created: a failing build must not leave a site behind. + if (args.build !== undefined) { + const requested = await resolveRequestedBuild( + args.build, + siteConfig?.config.build, + configRoot, + ); + if (requested.label) logger.info(`Detected ${requested.label}.`); + const overrides = await collectEnv(args.env, args["env-file"]); + await runBuildCommand(requested.command, configRoot, overrides); + loaded = await loadBuildManifest(); + } else if (!loaded && isInteractive(output) && args.dir === undefined) { + // No manifest and no --build: offer the project's own build, as the + // static path does. + const auto = siteConfig?.config.build + ? { command: siteConfig.config.build, label: "the configured build" } + : await resolveAutoBuild(configRoot); + if ( + auto && + (await confirm(`Run \`${auto.command}\` before deploying?`, { + initial: true, + })) + ) { + await runBuildCommand(auto.command, configRoot, {}); + loaded = await loadBuildManifest(); + } + } + + // A static build deploys as a directory of files, whatever wrote it. + if (!loaded || loaded.manifest.kind !== "ssr") { + if (loaded) { + logger.info( + `${loaded.manifest.adapter.package} built a static site; deploying ${loaded.manifest.assets.dir}.`, + ); + } + // `sites deploy` owns static sites, and already has previews, promote, + // and rollback for them. Don't build twice. + await sitesDeployCommand.handler({ + ...args, + dir: args.dir ?? loaded?.manifest.assets.dir, + build: undefined, + } as never); + return; + } + + const config = resolveConfig(profile, apiKey, verbose); + const options = clientOptions(config, verbose); + const coreClient = createCoreClient(options); + const computeClient = createComputeClient(options); + + if (args.dir !== undefined) { + logger.warn( + `This project builds a server, so the deploy follows ${loaded.manifest.assets.dir} from the build manifest, not ${args.dir}.`, + ); + } + + const result = await deployFramework({ + coreClient, + computeClient, + loaded, + args: { + name: args.name ?? siteConfig?.config.name, + region: args.region, + force: args.force, + output, + verbose, + }, + }); + + if (args.open && result.production) openBrowser(result.production); + }, +}); diff --git a/packages/cli/src/commands/deploy/manifest.test.ts b/packages/cli/src/commands/deploy/manifest.test.ts new file mode 100644 index 00000000..ed2ec8fd --- /dev/null +++ b/packages/cli/src/commands/deploy/manifest.test.ts @@ -0,0 +1,141 @@ +import { expect, test } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { useTempDir } from "../../test-utils/temp-dir.ts"; +import { + loadBuildManifest, + minimumCliVersion, + resolveAssetsDir, + resolveScriptEntry, +} from "./manifest.ts"; + +const tempDir = useTempDir("bunny-manifest-"); + +function validManifest(overrides?: Record) { + return { + manifestVersion: 1, + adapter: { package: "@bunny.net/astro-adapter", version: "0.2.0" }, + framework: { name: "astro", version: "7.2.3" }, + kind: "ssr", + script: { entry: "dist/index.js", type: "standalone", bytes: 1234 }, + assets: { dir: "dist/client" }, + ...overrides, + }; +} + +/** Write a manifest, and optionally the build it describes. */ +function project( + root: string, + manifest: unknown, + opts?: { build?: boolean }, +): void { + mkdirSync(join(root, ".bunny"), { recursive: true }); + writeFileSync( + join(root, ".bunny/build.json"), + typeof manifest === "string" ? manifest : JSON.stringify(manifest), + ); + if (opts?.build) { + mkdirSync(join(root, "dist/client/_astro"), { recursive: true }); + writeFileSync(join(root, "dist/index.js"), "export default 1;"); + writeFileSync(join(root, "dist/client/_astro/app.css"), "body{}"); + } +} + +test("minimumCliVersion reads a >= floor and ignores anything else", () => { + expect(minimumCliVersion(">=2.6.0")).toBe("2.6.0"); + expect(minimumCliVersion(" >= 2.6.0 ")).toBe("2.6.0"); + // An unparseable range must not stop a deploy: an adapter's guess about a + // future CLI is not worth failing over. + expect(minimumCliVersion("^2.6.0")).toBeNull(); + expect(minimumCliVersion(undefined)).toBeNull(); +}); + +test("no manifest is not an error; it means the project builds no server", async () => { + expect(await loadBuildManifest(tempDir())).toBeNull(); +}); + +test("a manifest is read, and its root is the directory holding .bunny", async () => { + const root = tempDir(); + project(root, validManifest()); + const loaded = await loadBuildManifest(root); + expect(loaded?.root).toBe(root); + expect(loaded?.manifest.kind).toBe("ssr"); + expect(loaded?.manifest.script?.entry).toBe("dist/index.js"); +}); + +test("the manifest is found from a subdirectory of the project", async () => { + const root = tempDir(); + project(root, validManifest()); + mkdirSync(join(root, "src/pages"), { recursive: true }); + const loaded = await loadBuildManifest(join(root, "src/pages")); + expect(loaded?.root).toBe(root); +}); + +test("a manifest that is not JSON stops the deploy", async () => { + const root = tempDir(); + project(root, "{ not json"); + await expect(loadBuildManifest(root)).rejects.toThrow(/not valid JSON/); +}); + +test("a manifest missing a required field stops the deploy", async () => { + const root = tempDir(); + project(root, { manifestVersion: 1, kind: "ssr" }); + await expect(loadBuildManifest(root)).rejects.toThrow( + /not a build manifest this CLI understands/, + ); +}); + +// Half a deployed site is worse than none, so an unknown shape is refused. +test("a newer manifest version asks for a newer CLI", async () => { + const root = tempDir(); + project(root, validManifest({ manifestVersion: 99 })); + await expect(loadBuildManifest(root)).rejects.toThrow( + /version 99, and this CLI reads 1/, + ); +}); + +test("an adapter that needs a newer CLI says so, naming the version", async () => { + const root = tempDir(); + project(root, validManifest({ requires: { cliVersion: ">=999.0.0" } })); + await expect(loadBuildManifest(root)).rejects.toThrow( + /needs bunny CLI 999\.0\.0 or newer/, + ); +}); + +test("the CLI's own version satisfies a floor it is above", async () => { + const root = tempDir(); + project(root, validManifest({ requires: { cliVersion: ">=0.0.1" } })); + expect((await loadBuildManifest(root))?.manifest.kind).toBe("ssr"); +}); + +test("a server build with no script named is refused", async () => { + const root = tempDir(); + project(root, validManifest({ script: undefined })); + await expect(loadBuildManifest(root)).rejects.toThrow( + /server build with no script/, + ); +}); + +test("a static manifest needs no script", async () => { + const root = tempDir(); + project(root, validManifest({ kind: "static", script: undefined })); + expect((await loadBuildManifest(root))?.manifest.kind).toBe("static"); +}); + +test("the script entry and the assets directory resolve against the root", async () => { + const root = tempDir(); + project(root, validManifest(), { build: true }); + const loaded = await loadBuildManifest(root); + if (!loaded) throw new Error("expected a manifest"); + expect(resolveScriptEntry(loaded)).toBe(join(root, "dist/index.js")); + expect(resolveAssetsDir(loaded)).toBe(join(root, "dist/client")); +}); + +test("a manifest that points at a missing build says to build again", async () => { + const root = tempDir(); + project(root, validManifest()); + const loaded = await loadBuildManifest(root); + if (!loaded) throw new Error("expected a manifest"); + expect(() => resolveScriptEntry(loaded)).toThrow(/which is not there/); + expect(() => resolveAssetsDir(loaded)).toThrow(/not a directory/); +}); diff --git a/packages/cli/src/commands/deploy/manifest.ts b/packages/cli/src/commands/deploy/manifest.ts new file mode 100644 index 00000000..3bfc420e --- /dev/null +++ b/packages/cli/src/commands/deploy/manifest.ts @@ -0,0 +1,136 @@ +import { existsSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { + BUILD_MANIFEST_PATH, + BUILD_MANIFEST_VERSION, + type BuildManifest, + BuildManifestSchema, +} from "@bunny.net/config"; +import { UserError } from "../../core/errors.ts"; +import { VERSION } from "../../core/version.ts"; + +export interface LoadedBuildManifest { + manifest: BuildManifest; + /** The directory holding `.bunny/build.json`; every path in the manifest resolves against it. */ + root: string; +} + +/** Walk up from `from` looking for `.bunny/build.json`. */ +function findManifest(from: string): string | null { + let dir = resolve(from); + while (true) { + const candidate = join(dir, BUILD_MANIFEST_PATH); + if (existsSync(candidate)) return candidate; + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +// Compare dotted numbers; a suffix like `-beta.1` is ignored, which is the right call for a floor check. +function isAtLeast(version: string, minimum: string): boolean { + const parts = (v: string) => + (v.split("-")[0] ?? "").split(".").map((n) => Number.parseInt(n, 10) || 0); + const [a, b] = [parts(version), parts(minimum)]; + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const diff = (a[i] ?? 0) - (b[i] ?? 0); + if (diff !== 0) return diff > 0; + } + return true; +} + +/** + * The `>=x.y.z` floor from a `requires.cliVersion` range, or null. + * + * Only that one form is honoured. A range this CLI cannot parse must not stop a + * deploy: an adapter's opinion about a future CLI is not worth a hard failure. + */ +export function minimumCliVersion(range: string | undefined): string | null { + const match = /^\s*>=\s*(\d+\.\d+\.\d+[\w.-]*)\s*$/.exec(range ?? ""); + return match?.[1] ?? null; +} + +/** + * Read the build manifest, or null when the project has none. + * + * A manifest that exists but does not parse is an error: it means an adapter + * wrote something this CLI cannot act on, and deploying half a site is worse + * than stopping. + */ +export async function loadBuildManifest( + from: string = process.cwd(), +): Promise { + const path = findManifest(from); + if (!path) return null; + + let data: unknown; + try { + data = await Bun.file(path).json(); + } catch { + throw new UserError( + `${path} is not valid JSON.`, + "Run the project's build again to rewrite it.", + ); + } + + const parsed = BuildManifestSchema.safeParse(data); + if (!parsed.success) { + throw new UserError( + `${path} is not a build manifest this CLI understands.`, + parsed.error.issues + .map((i) => `${i.path.join(".") || "manifest"}: ${i.message}`) + .join("; "), + ); + } + const manifest = parsed.data; + + if (manifest.manifestVersion > BUILD_MANIFEST_VERSION) { + throw new UserError( + `${manifest.adapter.package} wrote a build manifest of version ${manifest.manifestVersion}, and this CLI reads ${BUILD_MANIFEST_VERSION}.`, + "Update the CLI: npm install -g @bunny.net/cli", + ); + } + + const floor = minimumCliVersion(manifest.requires?.cliVersion); + if (floor && !isAtLeast(VERSION, floor)) { + throw new UserError( + `${manifest.adapter.package} needs bunny CLI ${floor} or newer, and this is ${VERSION}.`, + "Update the CLI: npm install -g @bunny.net/cli", + ); + } + + if (manifest.kind === "ssr" && !manifest.script) { + throw new UserError( + `${manifest.adapter.package} reports a server build with no script to deploy.`, + "Run the project's build again. Report it to the adapter if it persists.", + ); + } + + return { manifest, root: dirname(dirname(path)) }; +} + +/** The built file to deploy, checked for existence. */ +export function resolveScriptEntry(loaded: LoadedBuildManifest): string { + const entry = loaded.manifest.script?.entry; + if (!entry) throw new UserError("The build manifest names no script entry."); + const path = resolve(loaded.root, entry); + if (!existsSync(path) || !statSync(path).isFile()) { + throw new UserError( + `The build manifest points at ${entry}, which is not there.`, + "Run the build again, or run `bunny deploy --build`.", + ); + } + return path; +} + +/** The folder of client files to upload, checked for existence. */ +export function resolveAssetsDir(loaded: LoadedBuildManifest): string { + const path = resolve(loaded.root, loaded.manifest.assets.dir); + if (!existsSync(path) || !statSync(path).isDirectory()) { + throw new UserError( + `The build manifest points at ${loaded.manifest.assets.dir}, which is not a directory.`, + "Run the build again, or run `bunny deploy --build`.", + ); + } + return path; +} diff --git a/packages/cli/src/commands/deploy/rollback.ts b/packages/cli/src/commands/deploy/rollback.ts new file mode 100644 index 00000000..8528c02c --- /dev/null +++ b/packages/cli/src/commands/deploy/rollback.ts @@ -0,0 +1,83 @@ +import { + createComputeClient, + createCoreClient, +} from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../config/index.ts"; +import { clientOptions } from "../../core/client-options.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { UserError } from "../../core/errors.ts"; +import { logger } from "../../core/logger.ts"; +import { + confirmProduction, + republishDeploy, + requireLinkedFrameworkSite, +} from "./framework.ts"; + +interface RollbackArgs { + id?: string; + force?: boolean; +} + +/** + * Put an earlier deploy back on production. + * + * Each deploy kept its own files and its own server bundle, so this restores a + * matched pair. Nothing is rebuilt, and nothing is moved: the bundle comes back + * out of storage and is published as it was. + */ +export const rollbackCommand = defineCommand({ + command: "rollback [id]", + describe: "Put the previous deploy back on production.", + examples: [ + ["$0 rollback", "Back to the deploy that was live before"], + ["$0 rollback a1b2c3d4", "Back to a named deploy"], + ], + + builder: (yargs) => + yargs + .positional("id", { + type: "string", + describe: "Deploy to publish (default: the previous one)", + }) + .option("force", { + type: "boolean", + default: false, + describe: "Skip the confirmation", + }), + + handler: async ({ id, force, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const options = clientOptions(config, verbose); + const coreClient = createCoreClient(options); + const computeClient = createComputeClient(options); + + const site = await requireLinkedFrameworkSite(coreClient); + const { state } = site; + + const target = id ?? state.previous; + if (!target) { + throw new UserError( + `Site "${state.name}" has no earlier deploy to go back to.`, + "Run `bunny sites deployments list` to see what it keeps.", + ); + } + if (target === state.current) { + logger.info(`Deploy ${target} is already live.`); + return; + } + + const proceed = await confirmProduction( + `Publish deploy ${target} to production?`, + { force, output }, + ); + if (!proceed) throw new UserError("Rollback cancelled."); + + await republishDeploy({ + coreClient, + computeClient, + site, + deployId: target, + output, + }); + }, +}); diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index 15dce9ab..15c63c89 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -1187,3 +1187,45 @@ test("deleteSiteResources aborts before deleting anything when the preview sweep ).rejects.toThrow(/preview zones/); expect(calls.some((c) => c.method === "DELETE")).toBe(false); }); + +// A framework site's pull zone is served by the build's own script, so it has no +// storage zone of its own: the API reports that as -1, and the site's storage +// zone shares the pull zone's name. +test("fetchSites finds a framework site through its script origin", async () => { + store.set( + REMOTE_STATE_PATH, + JSON.stringify(fakeState({ kind: "framework", pullZoneId: 40 })), + ); + const coreClient = fakeCoreClient({ + calls: [], + storageZones: [ZONE], + pullZones: [ + { + Id: 40, + Name: "my-site", + EdgeScriptId: 20, + StorageZoneId: -1, + Hostnames: [{ IsSystemHostname: true, Value: "my-site.b-cdn.net" }], + }, + ], + }); + + const sites = await fetchSites(coreClient); + expect(sites).toHaveLength(1); + expect(sites[0]?.state.name).toBe("my-site"); + expect(sites[0]?.systemHostname).toBe("my-site.b-cdn.net"); +}); + +// -1 is "no storage zone", so a middleware zone reporting it is not a site. +test("fetchSites ignores a middleware pull zone with no storage zone", async () => { + store.set(REMOTE_STATE_PATH, JSON.stringify(fakeState())); + const coreClient = fakeCoreClient({ + calls: [], + storageZones: [ZONE], + pullZones: [ + { Id: 30, Name: "my-site", MiddlewareScriptId: 20, StorageZoneId: -1 }, + ], + }); + + expect(await fetchSites(coreClient)).toHaveLength(0); +}); diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 4d2e90a5..54c6ca0a 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -206,12 +206,25 @@ async function fetchPullZones( } } -// Discover sites: a pull zone listing narrows to storage+middleware candidates (preview zones share that shape, so their name pattern skips them), only those get the per-zone `_bunny/site.json` read. +/** The storage zone that shares a framework pull zone's name, or null. */ +async function frameworkStorageZoneId( + client: CoreClient, + pz: PullZone, +): Promise { + if (!pz.Name) return null; + const { data } = await client.GET("/storagezone", { + params: { query: { search: pz.Name } }, + }); + const match = (data ?? []).find((zone) => zone.Name === pz.Name); + return match?.Id ?? null; +} + +// Discover sites from a pull zone listing (preview zones share the static shape, so their name pattern skips them), then read `_bunny/site.json` for each candidate. Two shapes count: a static site's storage origin with a router, and a framework site's script origin, whose storage zone comes from the state file rather than the zone record. export async function fetchSites(client: CoreClient): Promise { const candidates = (await fetchPullZones(client)).filter( (pz: PullZone) => - pz.MiddlewareScriptId != null && - pz.StorageZoneId != null && + ((pz.MiddlewareScriptId != null && (pz.StorageZoneId ?? 0) > 0) || + pz.EdgeScriptId != null) && !isPreviewZoneName(pz.Name), ); @@ -220,7 +233,16 @@ export async function fetchSites(client: CoreClient): Promise { 8, async (pz: PullZone): Promise => { try { - const zone = await fetchStorageZone(client, pz.StorageZoneId as number); + // A framework site's pull zone has no storage zone of its own, and the + // API reports that as -1 rather than null. The site shares its name with + // the storage zone, so look it up by name instead. + const own = + pz.StorageZoneId != null && pz.StorageZoneId > 0 + ? pz.StorageZoneId + : null; + const storageZoneId = own ?? (await frameworkStorageZoneId(client, pz)); + if (storageZoneId == null) return null; + const zone = await fetchStorageZone(client, storageZoneId); const context = await siteContextFromZone(zone); if (!context || context.state.pullZoneId !== pz.Id) return null; return { diff --git a/packages/cli/src/commands/sites/ci/frameworks.ts b/packages/cli/src/commands/sites/ci/frameworks.ts index ea509e44..ce85ab98 100644 --- a/packages/cli/src/commands/sites/ci/frameworks.ts +++ b/packages/cli/src/commands/sites/ci/frameworks.ts @@ -6,12 +6,23 @@ export type PackageManager = "bun" | "pnpm" | "yarn" | "npm"; export interface FrameworkPreset { id: string; label: string; - /** Directory the build writes, relative to the repo root; the deploy target. */ + /** Directory the build writes, relative to the repo root; the deploy target for a static build. */ dir: string; /** Which setup/build steps the workflow needs. */ toolchain: "js" | "ruby" | "hugo" | "python" | "zola" | "dotnet" | "none"; /** Explicit build command; js presets run it via the package manager, others run it directly. Omit on js to run the package.json `build` script. */ build?: string; + /** + * The bunny.net adapter that makes this framework render per request on Edge + * Scripting. `bunny deploy` offers to install it, and then reads the build + * manifest the adapter writes: `dir` above stops applying, because a server + * build has two halves. + */ + adapter?: { + package: string; + /** How to add it to the framework's config, when the CLI can do that safely. */ + configStyle?: "astro"; + }; } // Static must stay last: the interactive prompt defaults to it. @@ -19,7 +30,16 @@ export const FRAMEWORK_PRESETS: FrameworkPreset[] = [ { id: "analog", label: "Analog", dir: "dist/analog/public", toolchain: "js" }, // Angular's application builder emits dist//browser; adjust if yours differs. { id: "angular", label: "Angular", dir: "dist", toolchain: "js" }, - { id: "astro", label: "Astro", dir: "dist", toolchain: "js" }, + { + id: "astro", + label: "Astro", + dir: "dist", + toolchain: "js", + adapter: { + package: "@bunny.net/astro-adapter", + configStyle: "astro", + }, + }, { id: "brunch", label: "Brunch", diff --git a/packages/cli/src/commands/sites/constants.ts b/packages/cli/src/commands/sites/constants.ts index 4a8652b6..b89bd477 100644 --- a/packages/cli/src/commands/sites/constants.ts +++ b/packages/cli/src/commands/sites/constants.ts @@ -20,6 +20,24 @@ export interface SiteManifest { name?: string; } +/** + * What serves a site. + * + * `static` sites get the router this CLI generates. A `framework` site's script + * is the build's own server, described by `.bunny/build.json`; `bunny deploy` + * owns those, and the static-only commands refuse them. + */ +export type SiteKind = "static" | "framework"; + +/** State written before this field existed is static. */ +export function siteKind(state: Pick): SiteKind { + return state.kind ?? "static"; +} + +export function isFrameworkSite(state: Pick): boolean { + return siteKind(state) === "framework"; +} + export interface DeployRecord { id: string; createdAt: string; @@ -34,12 +52,20 @@ export interface DeployRecord { previewZoneId?: number; /** The preview zone's `*.b-cdn.net` system hostname; always HTTPS-ready. */ previewHost?: string; + /** Framework sites: the script release this deploy published, for the record. */ + release?: string; + /** Framework sites: the size of the stored server bundle. */ + scriptBytes?: number; } // Source of truth (at `_bunny/site.json`) for a site's resource triple and deploys; `.bunny/site.json` is just a local pointer to it. export interface RemoteSiteState { version: number; name: string; + /** Absent means `static`; read it through {@link siteKind}. */ + kind?: SiteKind; + /** Framework sites: what built the script, for display and for support. */ + framework?: { name: string; adapter: string; version?: string }; storageZoneId: number; pullZoneId: number; scriptId: number; @@ -57,6 +83,17 @@ export function deployPrefix(deployId: string): string { return `${DEPLOYS_DIR}/${deployId}`; } +/** + * Where a framework deploy's server bundle is kept. + * + * Under `_bunny/`, so the router never serves it. Keeping it is what makes a + * rollback one step: the bundle and the files it was built against come back + * together, and neither was ever overwritten. + */ +export function serverBundlePath(deployId: string): string { + return `_bunny/${DEPLOYS_DIR}/${deployId}/server.js`; +} + /** Point production at `deployId`, remembering the outgoing deploy as previous. */ export function markCurrent(state: RemoteSiteState, deployId: string): void { if (state.current && state.current !== deployId) { @@ -85,6 +122,11 @@ export function routerScriptName(siteName: string): string { return `${siteName}-router`; } +/** A framework site's script name; namespaced so a resumed create finds it. */ +export function serverScriptName(siteName: string): string { + return `${siteName}-server`; +} + // Deploy IDs are git short-shas or content hashes (lowercase hex-ish); the router regex and storage paths rely on this. const DEPLOY_ID_RE = /^[a-z0-9]{4,40}$/; diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 8a828676..c75d1f72 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -30,6 +30,7 @@ import { import { loadSiteConfig } from "./config.ts"; import { type DeployRecord, + isFrameworkSite, markCurrent, type RemoteSiteState, } from "./constants.ts"; @@ -177,6 +178,15 @@ export const sitesDeployCommand = defineCommand({ }); const { state, connection } = site; + // A framework site's script is the build's own server, so this command's + // router, preview zones, and CURRENT_DEPLOY lever do not apply to it. + if (isFrameworkSite(state)) { + throw new UserError( + `"${state.name}" is deployed from a framework build.`, + "Run `bunny deploy` for it.", + ); + } + // Publishing is always explicit (--production, or the interactive first-deploy offer below); an implicit publish would let a CI preview run go live on a fresh site. let publish = args.production === true; // The site's first-ever deploy is the one moment we offer a custom domain; declining self-limits, since the list is never empty again. diff --git a/packages/cli/src/commands/sites/deployments/publish.ts b/packages/cli/src/commands/sites/deployments/publish.ts index a15477a3..3e98cff7 100644 --- a/packages/cli/src/commands/sites/deployments/publish.ts +++ b/packages/cli/src/commands/sites/deployments/publish.ts @@ -8,8 +8,9 @@ import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; import { confirm, requireConfirmable, withSpinner } from "../../../core/ui.ts"; +import { republishDeploy } from "../../deploy/framework.ts"; import { promoteDeploy, writeRemoteState } from "../api.ts"; -import { markCurrent } from "../constants.ts"; +import { isFrameworkSite, markCurrent } from "../constants.ts"; import { type SiteSelectorArgs, selectSite, @@ -130,6 +131,21 @@ export const sitesDeploymentsPublishCommand = defineCommand({ return; } + // A framework site publishes code, not a router variable: the deploy's own + // server bundle comes back out of storage, so the pages and the files they + // name are restored together. + if (isFrameworkSite(state)) { + await republishDeploy({ + coreClient, + computeClient, + site, + deployId: targetId, + output, + }); + await offerLink(); + return; + } + await withSpinner("Publishing...", async () => { await promoteDeploy({ computeClient, diff --git a/packages/cli/src/commands/sites/show.ts b/packages/cli/src/commands/sites/show.ts index 0109c1ef..0cafaad0 100644 --- a/packages/cli/src/commands/sites/show.ts +++ b/packages/cli/src/commands/sites/show.ts @@ -14,6 +14,7 @@ import { } from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; import { withSpinner } from "../../core/ui.ts"; +import { isFrameworkSite } from "./constants.ts"; import { type SiteSelectorArgs, selectSite, @@ -72,7 +73,20 @@ export const sitesShowCommand = defineCommand({ { key: "Site", value: state.name }, { key: "Storage zone", value: String(state.storageZoneId) }, { key: "Pull zone", value: String(state.pullZoneId) }, - { key: "Router script", value: String(state.scriptId) }, + { + // A framework site's script is the build's own server, not this + // CLI's router, and the label has to say which one it is. + key: isFrameworkSite(state) ? "Server script" : "Router script", + value: String(state.scriptId), + }, + ...(state.framework + ? [ + { + key: "Built by", + value: `${state.framework.adapter}${state.framework.version ? ` ${state.framework.version}` : ""}`, + }, + ] + : []), { key: "Domain", value: state.domain ?? "-" }, { key: "Current deploy", value: state.current ?? "-" }, { diff --git a/packages/cli/src/core/hostnames/client.ts b/packages/cli/src/core/hostnames/client.ts index 1df2d1c7..b478f33d 100644 --- a/packages/cli/src/core/hostnames/client.ts +++ b/packages/cli/src/core/hostnames/client.ts @@ -117,21 +117,28 @@ export function liveHostnames(hostnames: Hostname[]): { }; } -// PullZoneOriginType: 2 = StorageZone. +// PullZoneOriginType: 2 = StorageZone, 4 = EdgeScript. const ORIGIN_TYPE_STORAGE_ZONE = 2; +const ORIGIN_TYPE_EDGE_SCRIPT = 4; -/** Create a pull zone served from a storage zone, with delivery enabled in every geo region. Pass `middlewareScriptId` to attach a router in the same call: a zone is publicly reachable the moment it exists, so attaching afterwards leaves a window where it serves the raw storage origin. */ +/** Create a pull zone with delivery enabled in every geo region, served from a storage zone or (with `edgeScriptId`) from a standalone Edge Script. Pass `middlewareScriptId` to attach a router in the same call: a zone is publicly reachable the moment it exists, so attaching afterwards leaves a window where it serves the raw storage origin. */ export async function createPullZone( client: CoreClient, name: string, storageZoneId: number, - opts?: { middlewareScriptId?: number }, + opts?: { middlewareScriptId?: number; edgeScriptId?: number }, ): Promise { + const script = opts?.edgeScriptId; const { data } = await client.POST("/pullzone", { body: { Name: name, - StorageZoneId: storageZoneId, - OriginType: ORIGIN_TYPE_STORAGE_ZONE, + // A script is its own origin, so a script-backed zone has no storage zone. + ...(script != null + ? { OriginType: ORIGIN_TYPE_EDGE_SCRIPT, EdgeScriptId: script } + : { + OriginType: ORIGIN_TYPE_STORAGE_ZONE, + StorageZoneId: storageZoneId, + }), ...(opts?.middlewareScriptId != null ? { MiddlewareScriptId: opts.middlewareScriptId } : {}), diff --git a/packages/config/src/build-manifest.ts b/packages/config/src/build-manifest.ts new file mode 100644 index 00000000..50c3c96d --- /dev/null +++ b/packages/config/src/build-manifest.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; + +/** + * The build manifest: `.bunny/build.json`, written by a framework adapter and + * read by `bunny deploy`. + * + * This file is the whole contract between the CLI and an adapter. The CLI knows + * no framework: it reads the manifest, so a new adapter needs no new CLI. The + * specification lives beside the adapters, at + * https://github.com/BunnyWay/bunny-adapters/blob/main/docs/writing-an-adapter.md + */ + +/** Where an adapter writes the manifest, relative to the project root. */ +export const BUILD_MANIFEST_PATH = ".bunny/build.json"; + +/** + * The manifest shape this CLI understands. + * + * Bump it only for a change an older CLI cannot read. A new optional field is + * not one: the CLI ignores what it does not know, so adapters can add fields + * without waiting for a release. + */ +export const BUILD_MANIFEST_VERSION = 1; + +/** A variable the script reads. The CLI sets what it can, and names the rest. */ +export const ManifestEnvSchema = z.object({ + name: z.string(), + reason: z.string().optional(), + secret: z.boolean().optional(), + optional: z.boolean().optional(), +}); + +/** + * Pull zone settings the build needs. + * + * A framework that renders per request usually needs both of these. The CLI + * applies them, reports every change, and never changes one back in silence. + */ +export const ManifestPullZoneSchema = z.object({ + /** `false` lets `Set-Cookie` through. A script-backed zone strips it by default. */ + disableCookies: z.boolean().optional(), + /** `false` lets the pull zone cache HTML, so the adapter's cache headers count. */ + enableSmartCache: z.boolean().optional(), + /** `true` fetches a large object in chunks, so the first request is seekable. */ + enableCacheSlice: z.boolean().optional(), +}); + +export const ManifestRequiresSchema = z.object({ + /** The lowest CLI version that understands this build, as a semver range. */ + cliVersion: z.string().optional(), + pullZone: ManifestPullZoneSchema.optional(), + /** The script writes to the storage zone, so it needs a password that can write. */ + storage: z + .object({ write: z.boolean().optional(), reason: z.string().optional() }) + .optional(), + env: z.array(ManifestEnvSchema).optional(), +}); + +export const BuildManifestSchema = z.object({ + manifestVersion: z.number().int().positive(), + adapter: z.object({ package: z.string(), version: z.string().optional() }), + framework: z.object({ name: z.string(), version: z.string().optional() }), + /** `ssr` needs an Edge Script. `static` is files only, and deploys like any other static site. */ + kind: z.enum(["ssr", "static"]), + /** The one file to deploy. Required for `ssr`. */ + script: z + .object({ + /** Path to the built file, relative to the project root. */ + entry: z.string(), + type: z.enum(["standalone", "middleware"]), + bytes: z.number().int().nonnegative().optional(), + }) + .optional(), + assets: z.object({ + /** The folder to upload, relative to the project root. */ + dir: z.string(), + }), + requires: ManifestRequiresSchema.optional(), + dev: z + .object({ command: z.string().optional(), preview: z.string().optional() }) + .optional(), +}); + +export type BuildManifest = z.infer; +export type ManifestEnv = z.infer; +export type ManifestPullZone = z.infer; diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 8e9f321f..4dda986b 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,5 +1,17 @@ // Schemas +// The build manifest an adapter writes and `bunny deploy` reads. +export { + BUILD_MANIFEST_PATH, + BUILD_MANIFEST_VERSION, + type BuildManifest, + BuildManifestSchema, + type ManifestEnv, + ManifestEnvSchema, + type ManifestPullZone, + ManifestPullZoneSchema, + ManifestRequiresSchema, +} from "./build-manifest.ts"; // API conversion export { apiToConfig, From 82ecdc5170809b7c264a50cd8776b3ed50a7513a Mon Sep 17 00:00:00 2001 From: bogdan-at-bunny Date: Wed, 19 Aug 2026 16:12:26 +0000 Subject: [PATCH 02/10] Make `bunny deploy` work on the Astro projects people actually have Three real projects met the command: Starlight, astro.build and AstroWind. None of them deployed. Each failure has its own fix: - A workspace root is not a project. The command now finds the projects below it and offers them, so `bunny deploy` at Starlight's root reaches `docs/`. - The package manager comes from the nearest lockfile up the tree. In `starlight/docs` there is none, so npm was assumed, and npm cannot read `workspace:*`. - `pnpm add` at a workspace root needs `-w`, and Yarn needs `-W`. - Another vendor's adapter is replaced rather than reported, and the prompt names both sides of the swap. - `output: "server"` is never written. Astro's own default prerenders, and a page asks for the edge itself; overriding it tripled astro.build's script. - The 10 MB script check runs before anything is created, so a script that cannot be deployed leaves no half-made site behind. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DNEZw6pebXfttb98s3ihG8 --- .changeset/framework-deploys.md | 21 ++ .../cli/src/commands/deploy/adapter.test.ts | 105 ++++++-- packages/cli/src/commands/deploy/adapter.ts | 225 ++++++++++++++---- packages/cli/src/commands/deploy/framework.ts | 48 ++-- packages/cli/src/commands/deploy/index.ts | 29 ++- .../cli/src/commands/deploy/project.test.ts | 74 ++++++ packages/cli/src/commands/deploy/project.ts | 188 +++++++++++++++ .../src/commands/sites/ci/frameworks.test.ts | 67 ++++++ .../cli/src/commands/sites/ci/frameworks.ts | 72 +++++- 9 files changed, 743 insertions(+), 86 deletions(-) create mode 100644 packages/cli/src/commands/deploy/project.test.ts create mode 100644 packages/cli/src/commands/deploy/project.ts diff --git a/.changeset/framework-deploys.md b/.changeset/framework-deploys.md index ffd0ae9c..d1130fe1 100644 --- a/.changeset/framework-deploys.md +++ b/.changeset/framework-deploys.md @@ -15,6 +15,27 @@ The manifest is the whole contract: `BuildManifestSchema` in `@bunny.net/config` The CLI knows no framework, so a new adapter needs no new CLI. A framework project with no adapter installed or configured gets an offer to add one. +That offer was measured against three real projects: `withastro/starlight`, +`withastro/astro.build` and `arthelokyo/astrowind`. What it learned: + +- A monorepo root is not a project. `bunny deploy` at the root of a workspace + looks for the projects below it, and offers them. Starlight keeps `astro` in the + root `package.json` for `astro check`, and its site is `docs/`. +- The package manager comes from the nearest lockfile up the tree, not from the + directory. `starlight/docs` has no lockfile, so it looked like npm, and `npm + install` stopped on `workspace:*`. +- `pnpm add` at a workspace root gets `-w`, and Yarn's gets `-W`. Both refuse + without it. +- A project that already has another vendor's adapter gets it replaced, in one + edit that names both: `Replace @astrojs/cloudflare with + @bunny.net/astro-adapter in astro.config.mjs?` +- The adapter is added to the config, and `output` is not touched. Setting + `output: "server"` on a project that never mentioned it turns every prerendered + page into one that renders per request: on astro.build that took the script from + 7.83 MB to 22.30 MB, past the 10 MB limit. +- The 10 MB check happens before a site is created. astro.build used to leave a + storage zone, a script and a pull zone behind on the way to that error. + Each deploy keeps its client files at `deploys/{id}/` and its server bundle at `_bunny/deploys/{id}/server.js`, and the CLI writes the deploy's folder name into the top of the bundle at publish time. So a published release can only read the diff --git a/packages/cli/src/commands/deploy/adapter.test.ts b/packages/cli/src/commands/deploy/adapter.test.ts index 0a65b2da..682d5cb4 100644 --- a/packages/cli/src/commands/deploy/adapter.test.ts +++ b/packages/cli/src/commands/deploy/adapter.test.ts @@ -1,9 +1,9 @@ import { expect, test } from "bun:test"; -import { patchAstroConfig } from "./adapter.ts"; +import { patchAstroConfig, vendorAdapterIn } from "./adapter.ts"; const PKG = "@bunny.net/astro-adapter"; -test("adds the import, the adapter, and server output to a fresh config", () => { +test("adds the import and the adapter to a fresh config", () => { const source = [ "// @ts-check", 'import { defineConfig } from "astro/config";', @@ -12,14 +12,25 @@ test("adds the import, the adapter, and server output to a fresh config", () => "", ].join("\n"); - const patched = patchAstroConfig(source, PKG); + const patched = patchAstroConfig(source, PKG)?.source; expect(patched).toContain('import bunny from "@bunny.net/astro-adapter";'); - expect(patched).toContain('output: "server"'); expect(patched).toContain("adapter: bunny()"); // The import goes after the last existing one, not above the file's comment. expect(patched?.indexOf("// @ts-check")).toBe(0); }); +// Since Astro 5, a project that says nothing prerenders its pages, and a page +// asks for the edge with `export const prerender = false`. Setting +// `output: "server"` here took astro.build's script from 7.83 MB to 22.30 MB. +test("never sets output", () => { + const source = [ + 'import { defineConfig } from "astro/config";', + "export default defineConfig({});", + ].join("\n"); + + expect(patchAstroConfig(source, PKG)?.source).not.toContain("output"); +}); + test("keeps existing options, and adds the adapter beside them", () => { const source = [ 'import { defineConfig } from "astro/config";', @@ -31,7 +42,7 @@ test("keeps existing options, and adds the adapter beside them", () => { "});", ].join("\n"); - const patched = patchAstroConfig(source, PKG); + const patched = patchAstroConfig(source, PKG)?.source; expect(patched).toContain("integrations: [sitemap()]"); expect(patched).toContain("adapter: bunny()"); // The adapter's import lands after the last one, so nothing is shadowed. @@ -39,23 +50,53 @@ test("keeps existing options, and adds the adapter beside them", () => { expect(patched?.slice(importEnd)).toContain("bunny"); }); -// A developer who already chose `output` meant it. -test("does not add output when the config sets it", () => { +// Moving to bunny.net from another host is the commonest first deploy there is. +test("replaces another vendor's adapter, and says which one", () => { const source = [ 'import { defineConfig } from "astro/config";', + "import cloudflare from '@astrojs/cloudflare';", + 'import sitemap from "@astrojs/sitemap";', "export default defineConfig({", - ' output: "static",', + " integrations: [sitemap()],", + " adapter: cloudflare({", + " imageService: 'cloudflare-binding',", + " }),", "});", ].join("\n"); - const patched = patchAstroConfig(source, PKG); - expect(patched).toContain('output: "static"'); - expect(patched).not.toContain('output: "server"'); - expect(patched).toContain("adapter: bunny()"); + const patch = patchAstroConfig(source, PKG); + expect(patch?.replaced).toBe("@astrojs/cloudflare"); + // The name follows the package: `cloudflare()` pointing at bunny.net would + // work, and would read like a mistake. + expect(patch?.source).toContain( + 'import bunny from "@bunny.net/astro-adapter";', + ); + expect(patch?.source).toContain("adapter: bunny(),"); + expect(patch?.source).not.toContain("cloudflare"); + expect(patch?.source).not.toContain("cloudflare-binding"); + // Nothing else moved. + expect(patch?.source).toContain("integrations: [sitemap()]"); }); -// Another adapter is somebody's decision, so this refuses rather than fights. -test("refuses a config that already has an adapter", () => { +// A file that already has a `bunny` keeps its own name, so nothing is shadowed. +test("keeps the old name when bunny is taken", () => { + const source = [ + 'import { defineConfig } from "astro/config";', + 'import node from "@astrojs/node";', + "const bunny = 1;", + "export default defineConfig({", + " adapter: node(),", + "});", + ].join("\n"); + + const patch = patchAstroConfig(source, PKG); + expect(patch?.source).toContain( + 'import node from "@bunny.net/astro-adapter";', + ); + expect(patch?.source).toContain("adapter: node(),"); +}); + +test("replaces an adapter whose options span nothing at all", () => { const source = [ 'import { defineConfig } from "astro/config";', 'import node from "@astrojs/node";', @@ -64,6 +105,22 @@ test("refuses a config that already has an adapter", () => { "});", ].join("\n"); + const patch = patchAstroConfig(source, PKG); + expect(patch?.replaced).toBe("@astrojs/node"); + expect(patch?.source).not.toContain("standalone"); +}); + +// An adapter nobody has heard of is somebody's decision, so this refuses rather +// than fights. The CLI then names the file and quotes the lines to write. +test("refuses to replace an adapter it does not know", () => { + const source = [ + 'import { defineConfig } from "astro/config";', + 'import mystery from "astro-adapter-mystery";', + "export default defineConfig({", + " adapter: mystery(),", + "});", + ].join("\n"); + expect(patchAstroConfig(source, PKG)).toBeNull(); }); @@ -72,12 +129,11 @@ test("changes nothing when the adapter is already configured", () => { 'import { defineConfig } from "astro/config";', 'import bunny from "@bunny.net/astro-adapter";', "export default defineConfig({", - ' output: "server",', " adapter: bunny(),", "});", ].join("\n"); - expect(patchAstroConfig(source, PKG)).toBe(source); + expect(patchAstroConfig(source, PKG)?.source).toBe(source); }); // Anything this cannot read safely is left alone, and the CLI prints the snippet. @@ -85,3 +141,20 @@ test("refuses a config it cannot read", () => { expect(patchAstroConfig("export default makeConfig();", PKG)).toBeNull(); expect(patchAstroConfig("export default defineConfig({});", PKG)).toBeNull(); }); + +test("names the adapter in the way, so the prompt can say it", () => { + const cloudflare = [ + "import cloudflare from '@astrojs/cloudflare';", + "export default defineConfig({", + " adapter: cloudflare(),", + "});", + ].join("\n"); + expect(vendorAdapterIn(cloudflare)).toBe("@astrojs/cloudflare"); + + const none = [ + "export default defineConfig({", + " site: 'https://example.com',", + "});", + ].join("\n"); + expect(vendorAdapterIn(none)).toBeUndefined(); +}); diff --git a/packages/cli/src/commands/deploy/adapter.ts b/packages/cli/src/commands/deploy/adapter.ts index a680e2bd..2a4b07eb 100644 --- a/packages/cli/src/commands/deploy/adapter.ts +++ b/packages/cli/src/commands/deploy/adapter.ts @@ -5,15 +5,30 @@ import { logger } from "../../core/logger.ts"; import { confirm, isInteractive } from "../../core/ui.ts"; import { detectFramework, - detectPackageManager, + detectWorkspace, type FrameworkPreset, - type PackageManager, readPackageJson, + type Workspace, } from "../sites/ci/frameworks.ts"; -/** Install command for a package, per package manager. */ -function installCommand(pm: PackageManager, pkg: string): string { - return pm === "npm" ? `npm install ${pkg}` : `${pm} add ${pkg}`; +/** + * Install command for a package, per package manager. + * + * A workspace root needs to be told that the root is meant. pnpm refuses without + * `-w`, and Yarn's classic line is `-W`; both of them stop the deploy otherwise. + */ +function installCommand(workspace: Workspace, pkg: string): string { + const root = workspace.isRoot; + switch (workspace.pm) { + case "npm": + return `npm install ${pkg}`; + case "pnpm": + return root ? `pnpm add -w ${pkg}` : `pnpm add ${pkg}`; + case "yarn": + return root ? `yarn add -W ${pkg}` : `yarn add ${pkg}`; + case "bun": + return `bun add ${pkg}`; + } } /** Is the adapter already a dependency of the project? */ @@ -41,37 +56,132 @@ export function findAstroConfig(root: string): string | undefined { } /** - * Add the adapter to an Astro config. + * Adapters people move to bunny.net from. + * + * Replacing one is a mechanical edit: the import goes, and the `adapter` value + * becomes ours. Naming them is what lets the CLI say "this project uses + * @astrojs/cloudflare" instead of "the config needs one more change". + */ +const VENDOR_ADAPTERS = [ + "@astrojs/cloudflare", + "@astrojs/vercel", + "@astrojs/netlify", + "@astrojs/node", + "@astrojs/deno", + "@deno/astro-adapter", + "astro-sst", + "@sveltejs/adapter-auto", +]; + +/** What the adapter is called in a config this writes. */ +const LOCAL_NAME = "bunny"; + +export interface ConfigPatch { + /** The new config source. */ + source: string; + /** The adapter package this took out of the config, when it replaced one. */ + replaced?: string; +} + +/** The end of the call that starts at `open`, by counting brackets. */ +function endOfCall(source: string, open: number): number | null { + let depth = 0; + for (let i = open; i < source.length; i++) { + const char = source[i]; + if (char === "(" || char === "{" || char === "[") depth++; + else if (char === ")" || char === "}" || char === "]") { + depth--; + if (depth === 0) return i + 1; + } else if (char === '"' || char === "'" || char === "`") { + // Skip the string, so a bracket inside it does not count. + for (i++; i < source.length; i++) { + if (source[i] === "\\") i++; + else if (source[i] === char) break; + } + } + } + return null; +} + +/** The import statement that brings `local` into the file. */ +function importOf( + source: string, + local: string, +): { text: string; from: string } | null { + const pattern = new RegExp( + `^import\\s+${local}\\s*(?:,\\s*\\{[^}]*\\}\\s*)?from\\s*["']([^"']+)["'];?\\s*$`, + "m", + ); + const match = pattern.exec(source); + return match ? { text: match[0], from: match[1] ?? "" } : null; +} + +/** + * Add the adapter to an Astro config, replacing another vendor's when one is + * there. * * Returns the new source, or null when the config is not one this can edit * safely. Editing somebody's configuration is only acceptable when the result is - * obviously right, so this handles the shape `astro create` writes and nothing - * cleverer. + * obviously right, so this handles the shape `astro create` writes, and the one + * line another host's adapter occupies. + * + * It does not touch `output`. Since Astro 5 a project that says nothing gets + * prerendered pages, and a page asks for the edge with + * `export const prerender = false`. Setting `output: "server"` on such a project + * turns every page into one that renders per request: measured on + * `withastro/astro.build`, it took the script from 7.83 MB to 22.30 MB and + * prerendered none of its 4499 pages. */ -export function patchAstroConfig(source: string, pkg: string): string | null { - if (source.includes(pkg)) return source; +export function patchAstroConfig( + source: string, + pkg: string, +): ConfigPatch | null { + if (source.includes(pkg)) return { source }; - const defineConfig = /defineConfig\(\{/.exec(source); - if (!defineConfig) return null; - // An existing adapter is somebody's decision. Leave it, and say so. - if (/\n\s*adapter\s*:/.test(source)) return null; + if (!/defineConfig\(\{/.test(source)) return null; const lastImport = [...source.matchAll(/^import .*?;?$/gm)].pop(); if (lastImport?.index === undefined) return null; - const importEnd = lastImport.index + lastImport[0].length; - const withImport = `${source.slice(0, importEnd)}\nimport bunny from "${pkg}";${source.slice(importEnd)}`; + // An adapter already in the config: replace it when it is one we know, and + // leave it alone when it is not. + const existing = /(\n[ \t]*adapter\s*:\s*)([A-Za-z_$][\w$]*)\s*\(/.exec( + source, + ); + if (existing) { + const local = existing[2] ?? ""; + const found = importOf(source, local); + if (!found || !VENDOR_ADAPTERS.includes(found.from)) return null; + + const callStart = existing.index + existing[0].length - 1; + const callEnd = endOfCall(source, callStart); + if (callEnd === null) return null; + + // `import cloudflare from "@bunny.net/astro-adapter"` would work and read + // like a mistake, so the name changes with the package. It only stays when + // the file already has something called `bunny`. + const name = new RegExp(`\\b${LOCAL_NAME}\\b`).test(source) + ? local + : LOCAL_NAME; + const withAdapter = `${source.slice(0, callStart - local.length)}${name}()${source.slice(callEnd)}`; + // The import keeps its place, so the file's order is the one it had. + return { + source: withAdapter.replace(found.text, `import ${name} from "${pkg}";`), + replaced: found.from, + }; + } + + const importEnd = lastImport.index + lastImport[0].length; + const withImport = `${source.slice(0, importEnd)}\nimport ${LOCAL_NAME} from "${pkg}";${source.slice(importEnd)}`; // Re-find the call: the import above moved it. const call = /defineConfig\(\{/.exec(withImport); if (call?.index === undefined) return null; const insertAt = call.index + call[0].length; - const hasOutput = /\n\s*output\s*:/.test(withImport); - const added = hasOutput - ? "\n adapter: bunny()," - : '\n output: "server",\n adapter: bunny(),'; - return withImport.slice(0, insertAt) + added + withImport.slice(insertAt); + return { + source: `${withImport.slice(0, insertAt)}\n adapter: ${LOCAL_NAME}(),${withImport.slice(insertAt)}`, + }; } /** What to tell a developer whose config this cannot edit. */ @@ -80,7 +190,6 @@ function manualSnippet(pkg: string): string { `import bunny from "${pkg}";`, "", "export default defineConfig({", - ' output: "server",', " adapter: bunny(),", "});", ].join("\n"); @@ -132,51 +241,83 @@ export async function offerAdapter( const configured = source === null || source.includes(adapter.package); if (installed && configured) return { ready: true, preset }; - const pm = await detectPackageManager(root); + const workspace = await detectWorkspace(root); + const install = installCommand(workspace, adapter.package); + // What is in the way, when something is: another vendor's adapter. + const inTheWay = source === null ? null : vendorAdapterIn(source); + const file = configPath?.split("/").pop() ?? "the Astro config"; + if (!isInteractive(output)) { logger.warn( - `${preset.label} can render on the edge, and this project is not set up for it.`, + `${preset.label} detected, and this project has no bunny.net adapter.`, ); - if (!installed) logger.dim(` ${installCommand(pm, adapter.package)}`); - if (!configured) logger.dim(` ${manualSnippet(adapter.package)}`); + if (!installed) logger.dim(` ${install}`); + if (!configured) { + if (inTheWay) { + logger.dim( + ` In ${file}, replace ${inTheWay} with ${adapter.package}.`, + ); + } + logger.dim(` ${manualSnippet(adapter.package)}`); + } logger.dim(" Then re-run this command."); return { ready: false, preset }; } logger.info( - installed - ? `${preset.label} detected, with ${adapter.package} installed but not configured.` - : `${preset.label} detected, with no bunny.net adapter.`, + inTheWay + ? `${preset.label} detected, using ${inTheWay}.` + : installed + ? `${preset.label} detected, with ${adapter.package} installed but not configured.` + : `${preset.label} detected, with no bunny.net adapter.`, ); const wanted = await confirm( - installed - ? `Add ${adapter.package} to the config and render on the edge?` - : `Add ${adapter.package} and render on the edge?`, + inTheWay + ? `Replace ${inTheWay} with ${adapter.package} in ${file}?` + : configured + ? `Add ${adapter.package} to this project?` + : `Add ${adapter.package} to ${file}?`, { initial: true }, ); if (!wanted) return { ready: false, preset }; - if (!installed) await run(installCommand(pm, adapter.package), root); + if (!installed) await run(install, root); // The config edit. A config this cannot read safely is left alone, and the // developer gets the exact lines to paste. if (!configured) { - const patched = + const patch = source === null ? null : patchAstroConfig(source, adapter.package); - if (!configPath || patched === null) { + if (!configPath || patch === null) { throw new UserError( - `${installed ? "This project has" : "Installed"} ${adapter.package}, and the config needs one more change.`, - `Add this, then re-run \`bunny deploy\`:\n\n${manualSnippet(adapter.package)}`, + `${installed ? "This project has" : "Installed"} ${adapter.package}, and ${file} needs one change this cannot make safely.`, + [ + ...(inTheWay + ? [`Take out the ${inTheWay} adapter, and add this:`] + : ["Add this:"]), + "", + manualSnippet(adapter.package), + "", + "Then re-run `bunny deploy`.", + ].join("\n"), ); } - await Bun.write(configPath, patched); - const name = configPath.split("/").pop(); + await Bun.write(configPath, patch.source); logger.success( - patched.includes('output: "server"') && !source.includes("output") - ? `Set output: "server" and the adapter in ${name}.` - : `Added the adapter to ${name}.`, + patch.replaced + ? `Replaced ${patch.replaced} with ${adapter.package} in ${file}.` + : `Added the adapter to ${file}.`, ); } return { ready: true, preset }; } + +/** The vendor adapter a config already uses, when it uses one. */ +export function vendorAdapterIn(source: string): string | undefined { + const existing = /\n[ \t]*adapter\s*:\s*([A-Za-z_$][\w$]*)\s*\(/.exec(source); + const local = existing?.[1]; + if (!local) return undefined; + const from = importOf(source, local)?.from; + return from && from !== "astro/config" ? from : undefined; +} diff --git a/packages/cli/src/commands/deploy/framework.ts b/packages/cli/src/commands/deploy/framework.ts index 8a0293a1..25c16e89 100644 --- a/packages/cli/src/commands/deploy/framework.ts +++ b/packages/cli/src/commands/deploy/framework.ts @@ -2,9 +2,16 @@ import type { BuildManifest } from "@bunny.net/config"; import prompts from "prompts"; import { errorMessage, UserError } from "../../core/errors.ts"; import { formatBytes } from "../../core/format.ts"; -import { normalizeHostname } from "../../core/hostnames/index.ts"; +import { + looksLikeHostname, + normalizeHostname, +} from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; -import { loadManifest, saveManifest } from "../../core/manifest.ts"; +import { + ignoreManifestDir, + loadManifest, + saveManifest, +} from "../../core/manifest.ts"; import { confirm, isInteractive, withSpinner } from "../../core/ui.ts"; import { type ComputeClient, @@ -120,6 +127,11 @@ async function resolveSite(opts: { }); logger.success(`Created site "${name}".`); + if (ignoreManifestDir()) { + logger.dim( + " .gitignore .bunny/ added; it holds a build output and this link", + ); + } logger.dim(` storage zone ${created.storageZone.Name}`); logger.dim(` edge script ${created.state.scriptId}`); logger.dim( @@ -207,6 +219,18 @@ export async function deployFramework(opts: { const entryPath = resolveScriptEntry(loaded); const assetsDir = resolveAssetsDir(loaded); + // Before anything is created. A script that cannot be deployed used to be + // found out after the storage zone, the script and the pull zone were all + // made, which left three empty resources behind and nothing to deploy. + const code = await Bun.file(entryPath).text(); + const bundleBytes = Buffer.byteLength(code); + if (bundleBytes > SCRIPT_SIZE_LIMIT) { + throw new UserError( + `${manifest.script?.entry} is ${formatBytes(bundleBytes)}, and Edge Scripting takes ${formatBytes(SCRIPT_SIZE_LIMIT)}.`, + "Prerender the routes that do not need a server, or drop a dependency the server does not need, and build again.", + ); + } + const site = await resolveSite({ coreClient, computeClient, @@ -220,17 +244,6 @@ export async function deployFramework(opts: { let etag = site.etag; const firstDeploy = state.deploys.length === 0; - const code = await Bun.file(entryPath).text(); - const bundleBytes = Buffer.byteLength(code); - // Edge Scripting takes one file of up to 10 MB. Saying so before the upload is - // kinder than a rejected publish after it. - if (bundleBytes > SCRIPT_SIZE_LIMIT) { - throw new UserError( - `${manifest.script?.entry} is ${formatBytes(bundleBytes)}, and Edge Scripting takes ${formatBytes(SCRIPT_SIZE_LIMIT)}.`, - "Drop a dependency the server does not need, and build again.", - ); - } - const files = await withSpinner("Hashing files...", () => hashFiles(collectFiles(assetsDir)), ); @@ -411,7 +424,14 @@ export async function deployFramework(opts: { name: "value", message: "Custom domain for this site (leave blank to skip):", }); - const domain = normalizeHostname(value ?? "") || undefined; + const typed = normalizeHostname(value ?? ""); + // A one-word answer here used to reach the API, which calls it "An error + // has occurred." and leaves the developer with nothing to fix. + if (typed && !looksLikeHostname(typed)) { + logger.warn(`"${typed}" is not a domain name. Skipping it.`); + logger.dim(" Add one later: bunny sites domains add www.example.com"); + } + const domain = (looksLikeHostname(typed) ? typed : "") || undefined; if (domain) { handled = true; site.etag = etag; diff --git a/packages/cli/src/commands/deploy/index.ts b/packages/cli/src/commands/deploy/index.ts index da96a289..6e09b1b4 100644 --- a/packages/cli/src/commands/deploy/index.ts +++ b/packages/cli/src/commands/deploy/index.ts @@ -14,11 +14,13 @@ import { resolveRequestedBuild, runBuildCommand, } from "../sites/build.ts"; +import { detectFramework } from "../sites/ci/frameworks.ts"; import { loadSiteConfig } from "../sites/config.ts"; import { sitesDeployCommand } from "../sites/deploy.ts"; import { offerAdapter } from "./adapter.ts"; import { deployFramework } from "./framework.ts"; import { loadBuildManifest } from "./manifest.ts"; +import { enterProject } from "./project.ts"; interface DeployArgs { dir?: string; @@ -128,11 +130,23 @@ export const deployCommand = defineCommand({ ); } - const siteConfig = loadSiteConfig(); - const configRoot = siteConfig?.root ?? process.cwd(); + let siteConfig = loadSiteConfig(); + let configRoot = siteConfig?.root ?? process.cwd(); // A project with no manifest may only need its adapter, or its build. let loaded = await loadBuildManifest(); + + // A workspace root is not a project. When the framework's project is a + // directory below this one, the whole deploy moves there. + if (!loaded && args.dir === undefined) { + const detected = await detectFramework(configRoot); + if (detected?.adapter && (await enterProject(configRoot, output))) { + siteConfig = loadSiteConfig(); + configRoot = siteConfig?.root ?? process.cwd(); + loaded = await loadBuildManifest(); + } + } + if (!loaded && args.dir === undefined) { const offer = await offerAdapter(configRoot, output); if (offer.ready && args.build === undefined) { @@ -150,7 +164,12 @@ export const deployCommand = defineCommand({ ); if (requested.label) logger.info(`Detected ${requested.label}.`); const overrides = await collectEnv(args.env, args["env-file"]); - await runBuildCommand(requested.command, configRoot, overrides); + await runBuildCommand( + requested.command, + configRoot, + overrides, + "bunny deploy", + ); loaded = await loadBuildManifest(); } else if (!loaded && isInteractive(output) && args.dir === undefined) { // No manifest and no --build: offer the project's own build, as the @@ -164,7 +183,7 @@ export const deployCommand = defineCommand({ initial: true, })) ) { - await runBuildCommand(auto.command, configRoot, {}); + await runBuildCommand(auto.command, configRoot, {}, "bunny deploy"); loaded = await loadBuildManifest(); } } @@ -182,6 +201,8 @@ export const deployCommand = defineCommand({ ...args, dir: args.dir ?? loaded?.manifest.assets.dir, build: undefined, + // The build already ran here, so `sites deploy` must not offer it again. + built: args.build !== undefined, } as never); return; } diff --git a/packages/cli/src/commands/deploy/project.test.ts b/packages/cli/src/commands/deploy/project.test.ts new file mode 100644 index 00000000..1befce7d --- /dev/null +++ b/packages/cli/src/commands/deploy/project.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { findAstroProjects, isAstroProject } from "./project.ts"; + +/** Build a tree from paths; a path ending in `/` is a directory. */ +function tree(paths: string[]): string { + const root = mkdtempSync(join(tmpdir(), "bunny-project-")); + for (const path of paths) { + const full = join(root, path); + if (path.endsWith("/")) { + mkdirSync(full, { recursive: true }); + continue; + } + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, ""); + } + return root; +} + +test("a directory with an Astro config is a project", () => { + expect(isAstroProject(tree(["astro.config.mjs"]))).toBe(true); + expect(isAstroProject(tree(["astro.config.ts"]))).toBe(true); +}); + +// Astro needs no config file. A project with pages is still a project. +test("a directory with pages and no config is a project", () => { + expect(isAstroProject(tree(["src/pages/index.astro"]))).toBe(true); +}); + +test("a workspace root is not a project", () => { + expect( + isAstroProject(tree(["package.json", "pnpm-workspace.yaml", "docs/"])), + ).toBe(false); +}); + +// The shape of withastro/starlight: the site is docs/, and the examples are not. +test("finds the projects below a monorepo root, likeliest first", () => { + const root = tree([ + "package.json", + "pnpm-workspace.yaml", + "docs/astro.config.mjs", + "examples/basics/astro.config.mjs", + "examples/tailwind/astro.config.mjs", + "packages/starlight/package.json", + ]); + + const found = findAstroProjects(root).map((candidate) => candidate.label); + expect(found).toEqual(["docs", "examples/basics", "examples/tailwind"]); +}); + +test("looks inside apps/, and stops at the project it finds", () => { + const root = tree([ + "package.json", + "apps/web/astro.config.mjs", + "apps/web/tests/fixtures/nested/astro.config.mjs", + "apps/api/package.json", + ]); + + expect(findAstroProjects(root).map((c) => c.label)).toEqual(["apps/web"]); +}); + +test("does not walk into node_modules", () => { + const root = tree([ + "package.json", + "node_modules/astro-thing/astro.config.mjs", + ]); + expect(findAstroProjects(root)).toEqual([]); +}); + +test("finds nothing when there is nothing", () => { + expect(findAstroProjects(tree(["package.json", "src/index.ts"]))).toEqual([]); +}); diff --git a/packages/cli/src/commands/deploy/project.ts b/packages/cli/src/commands/deploy/project.ts new file mode 100644 index 00000000..b5d5e656 --- /dev/null +++ b/packages/cli/src/commands/deploy/project.ts @@ -0,0 +1,188 @@ +/** + * Which directory a deploy is about. + * + * A monorepo root is not a project. `withastro/starlight` keeps `astro` in the + * root `package.json` for `astro check`, and its site is `docs/`. Reading only + * the root, the CLI detected Astro, offered to add an adapter to a package that + * builds nothing, and `pnpm add` refused to touch a workspace root at all. + * + * So a framework project has to look like one: it needs a config file, or pages. + * When this directory has neither, the workspace usually holds one that does. + */ +import { existsSync, readdirSync } from "node:fs"; +import { join, relative } from "node:path"; +import prompts from "prompts"; +import { UserError } from "../../core/errors.ts"; +import { logger } from "../../core/logger.ts"; +import { isInteractive } from "../../core/ui.ts"; +import { findAstroConfig } from "./adapter.ts"; + +/** Directories that hold no deployable site, however deep the search goes. */ +const SKIP = new Set([ + "node_modules", + ".git", + ".astro", + ".bunny", + ".cache", + ".github", + ".vscode", + "dist", + "build", + "out", + "public", + "src", + "test", + "tests", + "__tests__", + "e2e", + "fixtures", + "coverage", +]); + +/** How far down to look. Deeper than this is a fixture, not the site. */ +const MAX_DEPTH = 3; + +/** Names that usually hold the site a repository is about. */ +const LIKELY = [ + "docs", + "site", + "sites", + "www", + "web", + "app", + "apps", + "frontend", + "website", +]; + +/** Names that usually hold something else that happens to be a site. */ +const UNLIKELY = [ + "example", + "examples", + "demo", + "demos", + "playground", + "template", + "templates", +]; + +export interface Candidate { + dir: string; + /** The path to show, relative to where the search started. */ + label: string; +} + +/** True when this directory is itself an Astro project. */ +export function isAstroProject(dir: string): boolean { + return Boolean(findAstroConfig(dir)) || existsSync(join(dir, "src/pages")); +} + +/** Rank: a likely name first, an example last, and a shallower path before a deeper one. */ +function score(label: string): number { + const parts = label.split("/"); + const first = parts[0] ?? ""; + let value = parts.length * 10; + if (LIKELY.includes(first)) value -= 100; + if (parts.some((part) => UNLIKELY.includes(part))) value += 100; + return value; +} + +/** + * Every Astro project under `root`, nearest first. + * + * `root` itself is not a candidate: this is only called when it is not one. + */ +export function findAstroProjects(root: string): Candidate[] { + const found: Candidate[] = []; + + const walk = (dir: string, depth: number): void => { + if (depth > MAX_DEPTH) return; + let entries: string[]; + try { + entries = readdirSync(dir, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + !SKIP.has(entry.name) && + entry.name[0] !== ".", + ) + .map((entry) => entry.name); + } catch { + return; + } + for (const name of entries) { + const child = join(dir, name); + if (isAstroProject(child)) { + found.push({ + dir: child, + label: relative(root, child).split("\\").join("/"), + }); + // A project inside a project is that project's own fixture. + continue; + } + walk(child, depth + 1); + } + }; + + walk(root, 1); + return found.sort( + (a, b) => score(a.label) - score(b.label) || a.label.localeCompare(b.label), + ); +} + +/** + * Move into the project this deploy is about, when this directory is not one. + * + * Returns the directory now in use. Everything after this reads `process.cwd()`, + * so the whole deploy follows: the config, the build, and the `.bunny/site.json` + * that links the directory to its site. + */ +export async function enterProject( + root: string, + output: string | undefined, +): Promise { + if (isAstroProject(root)) return null; + + const candidates = findAstroProjects(root); + if (candidates.length === 0) return null; + + const list = candidates.map((candidate) => ` ${candidate.label}`).join("\n"); + if (!isInteractive(output)) { + throw new UserError( + `There is no Astro project in this directory, and ${candidates.length} below it.`, + `Deploy one of them:\n${list}\n\nRun \`bunny deploy\` from the one you want.`, + ); + } + + logger.info( + candidates.length === 1 + ? "This directory holds no Astro project, and one below it does." + : `This directory holds no Astro project, and ${candidates.length} below it do.`, + ); + + const { value } = await prompts({ + type: "select", + name: "value", + message: "Which one should be deployed?", + choices: [ + ...candidates.map((candidate) => ({ + title: candidate.label, + value: candidate.dir, + })), + { title: "None of these", value: "" }, + ], + initial: 0, + }); + + const chosen = value as string | undefined; + if (!chosen) { + throw new UserError( + "Nothing to deploy here.", + "Run `bunny deploy` in the project's own directory.", + ); + } + + process.chdir(chosen); + logger.info(`Deploying ${relative(root, chosen) || "."}.`); + return chosen; +} diff --git a/packages/cli/src/commands/sites/ci/frameworks.test.ts b/packages/cli/src/commands/sites/ci/frameworks.test.ts index 2d9c5549..4b49516d 100644 --- a/packages/cli/src/commands/sites/ci/frameworks.test.ts +++ b/packages/cli/src/commands/sites/ci/frameworks.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { detectFramework, detectPackageManager, + detectWorkspace, findPreset, presetBuildCommand, } from "./frameworks.ts"; @@ -135,3 +136,69 @@ test("detectPackageManager reads the lockfile", async () => { ); expect(await detectPackageManager(tempRepo({}))).toBe("npm"); }); + +// The lockfile is at the root of a monorepo, not beside each package. Reading +// only the package made `starlight/docs` look like an npm project, and `npm +// install` then met `"@astrojs/starlight": "workspace:*"` and stopped. +test("detectWorkspace finds the package manager up the tree", async () => { + const root = tempRepo({ + "pnpm-lock.yaml": "", + "pnpm-workspace.yaml": "packages:\n - 'docs'\n", + "package.json": JSON.stringify({ name: "root", private: true }), + }); + const docs = join(root, "docs"); + mkdirSync(docs); + writeFileSync(join(docs, "package.json"), pkg({ astro: "^7.0.0" })); + + const workspace = await detectWorkspace(docs); + expect(workspace.pm).toBe("pnpm"); + expect(workspace.root).toBe(root); + // The package is not the workspace root, so `pnpm add` needs no `-w`. + expect(workspace.isRoot).toBe(false); +}); + +test("detectWorkspace knows when the project is the workspace root", async () => { + const root = tempRepo({ + "pnpm-lock.yaml": "", + "pnpm-workspace.yaml": "packages:\n - 'packages/**'\n", + "package.json": JSON.stringify({ name: "root", private: true }), + }); + expect((await detectWorkspace(root)).isRoot).toBe(true); +}); + +// astro.build has a pnpm-workspace.yaml holding only settings, and `pnpm add` +// works there without `-w`. +test("detectWorkspace does not call a settings-only pnpm-workspace.yaml a root", async () => { + const root = tempRepo({ + "pnpm-lock.yaml": "", + "pnpm-workspace.yaml": "minimumReleaseAge: 4320\n", + "package.json": pkg({ astro: "^7.0.0" }), + }); + expect((await detectWorkspace(root)).isRoot).toBe(false); +}); + +test("detectWorkspace reads npm and yarn workspaces too", async () => { + const npmRoot = tempRepo({ + "package-lock.json": "{}", + "package.json": JSON.stringify({ name: "root", workspaces: ["apps/*"] }), + }); + const npm = await detectWorkspace(npmRoot); + expect(npm.pm).toBe("npm"); + expect(npm.isRoot).toBe(true); + + const yarnRoot = tempRepo({ + "yarn.lock": "", + "package.json": JSON.stringify({ + name: "root", + workspaces: { packages: ["apps/*"] }, + }), + }); + const yarn = await detectWorkspace(yarnRoot); + expect(yarn.pm).toBe("yarn"); + expect(yarn.isRoot).toBe(true); +}); + +test("detectPackageManager still answers npm when nothing says otherwise", async () => { + const dir = tempRepo({ "package.json": pkg({ astro: "^7.0.0" }) }); + expect(await detectPackageManager(dir)).toBe("npm"); +}); diff --git a/packages/cli/src/commands/sites/ci/frameworks.ts b/packages/cli/src/commands/sites/ci/frameworks.ts index ce85ab98..71cfc006 100644 --- a/packages/cli/src/commands/sites/ci/frameworks.ts +++ b/packages/cli/src/commands/sites/ci/frameworks.ts @@ -1,5 +1,5 @@ import { access } from "node:fs/promises"; -import { join } from "node:path"; +import { dirname, join, resolve } from "node:path"; export type PackageManager = "bun" | "pnpm" | "yarn" | "npm"; @@ -287,16 +287,68 @@ export async function detectFramework( return undefined; } +/** The lockfile each package manager writes, in the order to believe them. */ +const LOCKFILES: Array<[file: string, pm: PackageManager]> = [ + ["bun.lock", "bun"], + ["bun.lockb", "bun"], + ["pnpm-lock.yaml", "pnpm"], + ["yarn.lock", "yarn"], + ["package-lock.json", "npm"], +]; + +export interface Workspace { + pm: PackageManager; + /** Where the lockfile is. The same as the project, unless the project is in a monorepo. */ + root: string; + /** True when the project is the root of a workspace that holds other packages. */ + isRoot: boolean; +} + +/** + * The package manager for a project, and where its workspace root is. + * + * The lockfile lives at the root of a monorepo, not beside each package. Looking + * only beside the project made `starlight/docs` look like an npm project, and + * `npm install` then met `"@astrojs/starlight": "workspace:*"` and stopped. So + * this walks up, the way every package manager does. + */ +export async function detectWorkspace(project: string): Promise { + const start = resolve(project); + let dir = start; + while (true) { + for (const [file, pm] of LOCKFILES) { + if (await exists(join(dir, file))) { + return { + pm, + root: dir, + isRoot: dir === start && (await holdsPackages(dir)), + }; + } + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return { pm: "npm", root: start, isRoot: await holdsPackages(start) }; +} + +/** True when this directory is a workspace root with packages under it. */ +async function holdsPackages(dir: string): Promise { + const workspaceFile = await readText(join(dir, "pnpm-workspace.yaml")); + // A pnpm-workspace.yaml holding only settings is not a workspace root; the + // `packages:` key is what makes one. + if (workspaceFile !== null && /^packages:/m.test(workspaceFile)) return true; + const pkg = await readPackageJson(dir); + const workspaces = pkg?.workspaces; + return Array.isArray(workspaces) + ? workspaces.length > 0 + : Boolean( + (workspaces as { packages?: unknown[] } | undefined)?.packages?.length, + ); +} + export async function detectPackageManager( root: string, ): Promise { - if ( - (await exists(join(root, "bun.lock"))) || - (await exists(join(root, "bun.lockb"))) - ) { - return "bun"; - } - if (await exists(join(root, "pnpm-lock.yaml"))) return "pnpm"; - if (await exists(join(root, "yarn.lock"))) return "yarn"; - return "npm"; + return (await detectWorkspace(root)).pm; } From 46bf94638dd4aa440358b13ee9ab2f8a43e4ffb8 Mon Sep 17 00:00:00 2001 From: bogdan-at-bunny Date: Wed, 19 Aug 2026 16:12:26 +0000 Subject: [PATCH 03/10] Stop a deploy asking twice, and refusing to explain itself Four small things a real deploy ran into: - `bunny sites deploy` no longer offers the build that `bunny deploy` has already run. A project whose build produced files was asked twice. - `bunny deploy --name` is honoured when the deploy creates a static site. - The domain prompt refuses an answer that is not a hostname, and says so. It used to send it, and the API answers "An error has occurred." - A failing build names the command that was actually running. A first deploy also adds `.bunny/` to .gitignore, in a git repository that does not ignore it yet. The directory holds a build output and a link to a site, as `bunny scripts init` already knows. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DNEZw6pebXfttb98s3ihG8 --- .changeset/deploy-polish.md | 16 ++++++ packages/cli/src/commands/sites/build.ts | 4 +- packages/cli/src/commands/sites/deploy.ts | 24 +++++++-- packages/cli/src/commands/sites/provision.ts | 7 ++- packages/cli/src/core/hostnames/client.ts | 14 ++++++ packages/cli/src/core/hostnames/index.ts | 1 + packages/cli/src/core/manifest.test.ts | 53 ++++++++++++++++++++ packages/cli/src/core/manifest.ts | 20 ++++++++ 8 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 .changeset/deploy-polish.md create mode 100644 packages/cli/src/core/manifest.test.ts diff --git a/.changeset/deploy-polish.md b/.changeset/deploy-polish.md new file mode 100644 index 00000000..bb0dffbb --- /dev/null +++ b/.changeset/deploy-polish.md @@ -0,0 +1,16 @@ +--- +"@bunny.net/cli": patch +--- + +Three smaller things around a deploy. + +- `bunny sites deploy` no longer offers to run the build when `bunny deploy` + already ran it. A framework project whose build produced files rather than a + server was asked twice. +- `bunny deploy --name ` is honoured when that deploy creates a static + site. It was read only on the framework path, so the prompt asked anyway. +- The domain prompt after a first deploy refuses a value that is not a hostname, + and says so. It used to send it, and the API's answer is `An error has + occurred.` +- A failing build names the command to run again. It said + `bunny sites deploy --build` whichever command was running. diff --git a/packages/cli/src/commands/sites/build.ts b/packages/cli/src/commands/sites/build.ts index 2ce3dc31..f0bd9414 100644 --- a/packages/cli/src/commands/sites/build.ts +++ b/packages/cli/src/commands/sites/build.ts @@ -65,6 +65,8 @@ export async function runBuildCommand( command: string, cwd: string, env: Record, + /** The command to name when the build fails. The two deploy paths differ. */ + retry = "bunny sites deploy --build", ): Promise { logger.info(`Running build: ${command}`); const shell = @@ -82,7 +84,7 @@ export async function runBuildCommand( if (code !== 0) { throw new UserError( `Build command failed with exit code ${code}.`, - "Fix the build and re-run `bunny sites deploy --build`.", + `Fix the build and run \`${retry}\` again.`, ); } } diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index c75d1f72..e6be9065 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -11,7 +11,10 @@ import { defineCommand } from "../../core/define-command.ts"; import { collectEnv } from "../../core/env.ts"; import { errorMessage, UserError } from "../../core/errors.ts"; import { formatBytes } from "../../core/format.ts"; -import { normalizeHostname } from "../../core/hostnames/index.ts"; +import { + looksLikeHostname, + normalizeHostname, +} from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; import { confirm, isInteractive, withSpinner } from "../../core/ui.ts"; import { @@ -52,6 +55,10 @@ interface DeployArgs extends SiteSelectorArgs { "env-file"?: string; production?: boolean; force?: boolean; + /** Site name for a site this creates, from `bunny deploy --name`. */ + name?: string; + /** Set by `bunny deploy`, which has already run the build. Not a flag. */ + built?: boolean; } const DOMAIN_HINT = @@ -172,7 +179,7 @@ export const sitesDeployCommand = defineCommand({ link: args.link, output, offerCreate: async () => { - const name = await promptSiteName(undefined, true); + const name = await promptSiteName(args.name, true); return createLinkedSite({ coreClient, computeClient, name }); }, }); @@ -234,7 +241,7 @@ export const sitesDeployCommand = defineCommand({ if (explicitDir === undefined) autoDir = requestedBuild.dir; const overrides = await collectEnv(args.env, args["env-file"]); await runBuildCommand(requestedBuild.command, root, overrides); - } else if (isInteractive(output)) { + } else if (isInteractive(output) && !args.built) { // No --build: offer to run the configured build, else a detected one. const configured = siteConfig?.config.build; const auto = configured @@ -473,7 +480,16 @@ export const sitesDeployCommand = defineCommand({ message: "Custom domain for this site's production URL (leave blank to skip):", }); - const domain = normalizeHostname(value ?? "") || undefined; + const typed = normalizeHostname(value ?? ""); + // A one-word answer here used to reach the API, which calls it "An error + // has occurred." and leaves the developer with nothing to fix. + if (typed && !looksLikeHostname(typed)) { + logger.warn(`"${typed}" is not a domain name. Skipping it.`); + logger.dim( + " Add one later: bunny sites domains add www.example.com", + ); + } + const domain = (looksLikeHostname(typed) ? typed : "") || undefined; if (domain) { handled = true; // The domain flow writes state, so it needs the etag from this deploy's writes, not the stale read. diff --git a/packages/cli/src/commands/sites/provision.ts b/packages/cli/src/commands/sites/provision.ts index fa44569c..618ebe7e 100644 --- a/packages/cli/src/commands/sites/provision.ts +++ b/packages/cli/src/commands/sites/provision.ts @@ -2,7 +2,7 @@ import { basename } from "node:path"; import prompts from "prompts"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; -import { saveManifest } from "../../core/manifest.ts"; +import { ignoreManifestDir, saveManifest } from "../../core/manifest.ts"; import { withSpinner } from "../../core/ui.ts"; import type { CoreClient } from "../storage/api.ts"; import { @@ -93,6 +93,11 @@ export async function createLinkedSite(opts: { id: result.state.storageZoneId, name: opts.name, }); + if (ignoreManifestDir()) { + logger.dim( + " Added .bunny/ to .gitignore; it holds the link to this site.", + ); + } const context = await siteContextFromZone(result.storageZone); if (!context) { diff --git a/packages/cli/src/core/hostnames/client.ts b/packages/cli/src/core/hostnames/client.ts index b478f33d..cdf37ea7 100644 --- a/packages/cli/src/core/hostnames/client.ts +++ b/packages/cli/src/core/hostnames/client.ts @@ -46,6 +46,20 @@ export function normalizeHostname(value: string): string { .replace(/\/+$/, ""); } +/** + * True when this looks like a hostname somebody could own. + * + * The API answers "An error has occurred." for anything it does not like, which + * tells a developer who typed one word into the domain prompt nothing at all. + * Two labels and no illegal character is the whole test: the API still owns the + * question of whether the name is available. + */ +export function looksLikeHostname(value: string): boolean { + return /^(?=.{1,253}$)(?!-)[a-z0-9-]{1,63}(? = {}, git = true): string { + const dir = mkdtempSync(join(tmpdir(), "bunny-manifest-")); + if (git) mkdirSync(join(dir, ".git")); + for (const [name, content] of Object.entries(files)) { + writeFileSync(join(dir, name), content); + } + return dir; +} + +test("adds .bunny/ to a repository with no .gitignore", () => { + const dir = repo(); + expect(ignoreManifestDir(dir)).toBe(true); + expect(readFileSync(join(dir, ".gitignore"), "utf8")).toBe(".bunny/\n"); +}); + +test("keeps what the .gitignore already had, and ends the file with a newline", () => { + const dir = repo({ ".gitignore": "dist\nnode_modules" }); + expect(ignoreManifestDir(dir)).toBe(true); + expect(readFileSync(join(dir, ".gitignore"), "utf8")).toBe( + "dist\nnode_modules\n.bunny/\n", + ); +}); + +test("does nothing when a rule for .bunny is already there", () => { + for (const line of [".bunny/", ".bunny", "/.bunny/", " .bunny/ "]) { + const dir = repo({ ".gitignore": `dist\n${line}\n` }); + expect(ignoreManifestDir(dir)).toBe(false); + expect(readFileSync(join(dir, ".gitignore"), "utf8")).toBe( + `dist\n${line}\n`, + ); + } +}); + +// A directory that is not a repository gets no file it did not ask for. +test("writes nothing outside a git repository", () => { + const dir = repo({}, false); + expect(ignoreManifestDir(dir)).toBe(false); +}); + +// `.bunnyrc` is not `.bunny`, and a comment is not a rule. +test("is not fooled by a similar line", () => { + const dir = repo({ ".gitignore": "# .bunny/\n.bunnyrc\n" }); + expect(ignoreManifestDir(dir)).toBe(true); + expect(readFileSync(join(dir, ".gitignore"), "utf8")).toContain( + "\n.bunny/\n", + ); +}); diff --git a/packages/cli/src/core/manifest.ts b/packages/cli/src/core/manifest.ts index 97dc2536..58175ed5 100644 --- a/packages/cli/src/core/manifest.ts +++ b/packages/cli/src/core/manifest.ts @@ -38,6 +38,26 @@ function manifestPath(filename: string): string { return join(findRoot(filename), MANIFEST_DIR, filename); } +/** + * Add `.bunny/` to the repository's `.gitignore`, when it is not there already. + * + * `.bunny/` holds a build output and a link to a site, and neither belongs in a + * commit. Only a directory that is a git repository is touched, and an existing + * rule for `.bunny` is left alone. Returns true when the line was added, so the + * caller can say so. + */ +export function ignoreManifestDir(root: string = process.cwd()): boolean { + if (!existsSync(join(root, ".git"))) return false; + + const path = join(root, ".gitignore"); + const existing = existsSync(path) ? readFileSync(path, "utf8") : ""; + if (/^\s*\/?\.bunny\/?\s*$/m.test(existing)) return false; + + const separator = existing === "" || existing.endsWith("\n") ? "" : "\n"; + writeFileSync(path, `${existing}${separator}.bunny/\n`); + return true; +} + export function manifestDir(filename: string): string { return join(findRoot(filename), MANIFEST_DIR); } From c921ae150a952882f0b4f6889e79b1c72a8b27d5 Mon Sep 17 00:00:00 2001 From: bogdan-at-bunny Date: Wed, 19 Aug 2026 16:35:07 +0000 Subject: [PATCH 04/10] Ask the site for a page before calling a deploy a success A published script that will not start makes the edge answer 400 with an empty body. `bunny deploy` printed a green line and a URL above it, and astro.build deployed exactly that way. The command now probes production, gives a cold start two more chances, and says what to do when the answer is a fault. A script above 7.5 MB gets the size named, because that is where scripts stop starting, whatever the documented 10 MB says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DNEZw6pebXfttb98s3ihG8 --- .changeset/deploy-health-check.md | 18 ++++ packages/cli/src/commands/deploy/framework.ts | 87 +++++++++++++++++++ .../cli/src/commands/deploy/health.test.ts | 62 +++++++++++++ packages/cli/src/commands/deploy/index.ts | 34 +++++++- 4 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 .changeset/deploy-health-check.md create mode 100644 packages/cli/src/commands/deploy/health.test.ts diff --git a/.changeset/deploy-health-check.md b/.changeset/deploy-health-check.md new file mode 100644 index 00000000..49990345 --- /dev/null +++ b/.changeset/deploy-health-check.md @@ -0,0 +1,18 @@ +--- +"@bunny.net/cli": patch +--- + +`bunny deploy` asks the site for a page before it calls the deploy a success. + +A published Edge Script that will not start makes the edge answer 400 with an +empty body, and the deploy said nothing: a green line, a URL, and a site that +served nothing. `withastro/astro.build` deployed exactly like that. + +The check probes the production URL up to three times, each with its own query so +the CDN cache cannot hold the answer. A redirect or a 404 counts as a working +script; only 400 and 5xx are faults, and a site that cannot be reached at all is +not called one. When the script is above 7.5 MB the warning says so, because that +is where the trouble starts: measured in August 2026, the same code served every +request at 7.44 MB and none at 7.83 MB, well under the documented 10 MB. + +`--output json` carries `serving`, and `status` when it is not. diff --git a/packages/cli/src/commands/deploy/framework.ts b/packages/cli/src/commands/deploy/framework.ts index 25c16e89..b85534f8 100644 --- a/packages/cli/src/commands/deploy/framework.ts +++ b/packages/cli/src/commands/deploy/framework.ts @@ -57,6 +57,63 @@ export const PRODUCTION = "production"; /** Edge Scripting takes one JavaScript file of up to 10 MB. */ const SCRIPT_SIZE_LIMIT = 10 * 1024 * 1024; +/** + * Above this, a published script often fails to start, and the edge answers 400 + * with an empty body. + * + * Measured in August 2026 on a standalone script in DE, in the units this CLI + * prints: the same code served every request at 7.44 MB (7,798,944 bytes) and + * none at 7.83 MB (8,209,699 bytes). Between the two, the first request failed + * and later ones worked. Nothing in the API reports this, so the only place a + * developer can hear it is here. + */ +const SCRIPT_START_RISK = 7.5 * 1024 * 1024; + +/** How many times to ask a fresh deploy for a page before believing the answer. */ +const HEALTH_ATTEMPTS = 3; +const HEALTH_INTERVAL_MS = 3000; + +/** Overridden by the test, which has no nine seconds to spare. */ +export const health = { + wait: (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)), +}; + +/** + * Ask the site for its home page, and answer with a status that means it is down. + * + * Returns null when the site answered anything a working script can answer, a + * redirect and a 404 included, and when it could not be reached at all. A deploy + * that prints a green line above a URL answering 400 is the worst thing this + * command can do, and the script's own size is the usual reason. + */ +export async function findDeployFault( + url: string, + deployId: string, +): Promise { + let fault: number | null = null; + for (let attempt = 0; attempt < HEALTH_ATTEMPTS; attempt++) { + if (attempt > 0) await health.wait(HEALTH_INTERVAL_MS); + try { + // A unique query per attempt keeps the probe out of the CDN cache, so a + // cached failure cannot outlive the release that caused it. + const response = await fetch( + `${url}/?__bunny_check=${deployId}-${attempt}`, + { + redirect: "manual", + signal: AbortSignal.timeout(10_000), + }, + ); + if (response.status !== 400 && response.status < 500) return null; + fault = response.status; + } catch { + // Unreachable is not a verdict: DNS and TLS take their own time. + return fault; + } + } + return fault; +} + const DOMAIN_HINT = " Add a custom domain: bunny domains add "; export interface FrameworkDeployArgs { @@ -376,6 +433,14 @@ export async function deployFramework(opts: { const urls = await siteUrls(coreClient, state); + // A deploy that leaves the site answering 400 has to say so, whichever format + // the caller reads. + const fault = urls.production + ? await withSpinner("Checking the site...", () => + findDeployFault(urls.production as string, deployId), + ) + : null; + if (output === "json") { logger.log( JSON.stringify( @@ -388,6 +453,8 @@ export async function deployFramework(opts: { scriptBytes: bundleBytes, release: published.release ?? null, production: urls.production ?? null, + serving: fault === null, + ...(fault === null ? {} : { status: fault }), }, null, 2, @@ -401,6 +468,26 @@ export async function deployFramework(opts: { ); if (urls.production) logger.info(`Production ${urls.production}`); + if (fault !== null) { + logger.warn(`The site answered ${fault}, so the script is not serving.`); + if (bundleBytes > SCRIPT_START_RISK) { + logger.dim( + ` The script is ${formatBytes(bundleBytes)}, and one this large often fails to start.`, + ); + logger.dim( + " Measured in August 2026: the same code served every request at 7.4 MB, and none at 7.8 MB.", + ); + logger.dim( + " Prerender a route, or drop a dependency the server does not need, and deploy again.", + ); + } else { + logger.dim( + " The script may be failing as it starts. Read its logs in the dashboard: Scripting > your script > Logs.", + ); + } + logger.dim(` The deploy before it is still there: bunny rollback`); + } + const missing = unset.filter((name) => name !== "BUNNY_API_KEY"); if (missing.length > 0) { logger.dim( diff --git a/packages/cli/src/commands/deploy/health.test.ts b/packages/cli/src/commands/deploy/health.test.ts new file mode 100644 index 00000000..ff926362 --- /dev/null +++ b/packages/cli/src/commands/deploy/health.test.ts @@ -0,0 +1,62 @@ +import { afterEach, expect, test } from "bun:test"; +import { findDeployFault, health } from "./framework.ts"; + +const realFetch = globalThis.fetch; +const realWait = health.wait; + +afterEach(() => { + globalThis.fetch = realFetch; + health.wait = realWait; +}); + +/** Answer each call with the next status, and record what was asked for. */ +function answerWith(statuses: Array): string[] { + const asked: string[] = []; + let call = 0; + health.wait = () => Promise.resolve(); + globalThis.fetch = ((url: string) => { + asked.push(url); + const status = statuses[Math.min(call++, statuses.length - 1)]; + if (status === "throw") return Promise.reject(new Error("unreachable")); + return Promise.resolve(new Response(null, { status })); + }) as typeof fetch; + return asked; +} + +test("a page is a working site", async () => { + answerWith([200]); + expect(await findDeployFault("https://site.test", "abc")).toBeNull(); +}); + +// A script that answers 404 or redirects is a script that ran. +test("a 404 and a redirect are answers, not faults", async () => { + answerWith([404]); + expect(await findDeployFault("https://site.test", "abc")).toBeNull(); + answerWith([301]); + expect(await findDeployFault("https://site.test", "abc")).toBeNull(); +}); + +// The 400 a script that will not start answers, on every attempt. +test("reports a 400 that does not go away", async () => { + const asked = answerWith([400]); + expect(await findDeployFault("https://site.test", "abc")).toBe(400); + expect(asked.length).toBe(3); + // Each probe carries its own query, so no answer can come from the CDN cache. + expect(new Set(asked).size).toBe(3); +}); + +test("gives a cold start the chance to warm up", async () => { + answerWith([400, 200]); + expect(await findDeployFault("https://site.test", "abc")).toBeNull(); +}); + +test("reports a 5xx", async () => { + answerWith([503]); + expect(await findDeployFault("https://site.test", "abc")).toBe(503); +}); + +// DNS or TLS not being ready is not the deploy's verdict. +test("says nothing when the site cannot be reached", async () => { + answerWith(["throw"]); + expect(await findDeployFault("https://site.test", "abc")).toBeNull(); +}); diff --git a/packages/cli/src/commands/deploy/index.ts b/packages/cli/src/commands/deploy/index.ts index 6e09b1b4..54be76e7 100644 --- a/packages/cli/src/commands/deploy/index.ts +++ b/packages/cli/src/commands/deploy/index.ts @@ -14,7 +14,10 @@ import { resolveRequestedBuild, runBuildCommand, } from "../sites/build.ts"; -import { detectFramework } from "../sites/ci/frameworks.ts"; +import { + detectFramework, + type FrameworkPreset, +} from "../sites/ci/frameworks.ts"; import { loadSiteConfig } from "../sites/config.ts"; import { sitesDeployCommand } from "../sites/deploy.ts"; import { offerAdapter } from "./adapter.ts"; @@ -147,8 +150,12 @@ export const deployCommand = defineCommand({ } } + /** True once a build has run here, so a missing manifest is not a missing build. */ + let built = false; + let framework: FrameworkPreset | undefined; if (!loaded && args.dir === undefined) { const offer = await offerAdapter(configRoot, output); + framework = offer.preset; if (offer.ready && args.build === undefined) { // The adapter is in place, so a build is what produces the manifest. args.build = ""; @@ -170,6 +177,7 @@ export const deployCommand = defineCommand({ overrides, "bunny deploy", ); + built = true; loaded = await loadBuildManifest(); } else if (!loaded && isInteractive(output) && args.dir === undefined) { // No manifest and no --build: offer the project's own build, as the @@ -184,10 +192,27 @@ export const deployCommand = defineCommand({ })) ) { await runBuildCommand(auto.command, configRoot, {}, "bunny deploy"); + built = true; loaded = await loadBuildManifest(); } } + // A framework project with no build has nothing to deploy. Falling through + // here would upload the directory as it stands, which for an unbuilt project + // means its source: `src/`, `package.json`, and the site nowhere in sight. + if (!loaded && !built && framework?.adapter && args.dir === undefined) { + throw new UserError( + `This is a ${framework.label} project, and there is no build to deploy.`, + [ + "Build it first, and deploy what the build wrote:", + "", + " bunny deploy --build", + "", + `Or deploy a directory of files as they are: bunny deploy ${framework.dir}`, + ].join("\n"), + ); + } + // A static build deploys as a directory of files, whatever wrote it. if (!loaded || loaded.manifest.kind !== "ssr") { if (loaded) { @@ -199,10 +224,13 @@ export const deployCommand = defineCommand({ // and rollback for them. Don't build twice. await sitesDeployCommand.handler({ ...args, - dir: args.dir ?? loaded?.manifest.assets.dir, + // With no manifest, the framework's own output directory is the answer. + // Leaving it unset would deploy the directory the build ran in, which is + // the project's source. + dir: args.dir ?? loaded?.manifest.assets.dir ?? framework?.dir, build: undefined, // The build already ran here, so `sites deploy` must not offer it again. - built: args.build !== undefined, + built, } as never); return; } From 2a4b1bfe002abba24773a29952395717b2b4367d Mon Sep 17 00:00:00 2001 From: bogdan-at-bunny Date: Wed, 19 Aug 2026 16:37:25 +0000 Subject: [PATCH 05/10] Point at the startup budget when a deploy answers 400 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DNEZw6pebXfttb98s3ihG8 --- packages/cli/src/commands/deploy/framework.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/deploy/framework.ts b/packages/cli/src/commands/deploy/framework.ts index b85534f8..a4bc159f 100644 --- a/packages/cli/src/commands/deploy/framework.ts +++ b/packages/cli/src/commands/deploy/framework.ts @@ -58,8 +58,8 @@ export const PRODUCTION = "production"; const SCRIPT_SIZE_LIMIT = 10 * 1024 * 1024; /** - * Above this, a published script often fails to start, and the edge answers 400 - * with an empty body. + * Above this, a published script often misses its 500 ms startup budget, and the + * edge answers 400 with an empty body. * * Measured in August 2026 on a standalone script in DE, in the units this CLI * prints: the same code served every request at 7.44 MB (7,798,944 bytes) and @@ -472,7 +472,7 @@ export async function deployFramework(opts: { logger.warn(`The site answered ${fault}, so the script is not serving.`); if (bundleBytes > SCRIPT_START_RISK) { logger.dim( - ` The script is ${formatBytes(bundleBytes)}, and one this large often fails to start.`, + ` The script is ${formatBytes(bundleBytes)}, and a script has 500 ms to start. Every byte is parsed first.`, ); logger.dim( " Measured in August 2026: the same code served every request at 7.4 MB, and none at 7.8 MB.", From 91ce7db22a63ec8e19a2030be91800392846124f Mon Sep 17 00:00:00 2001 From: bogdan-at-bunny Date: Wed, 19 Aug 2026 16:39:37 +0000 Subject: [PATCH 06/10] Let a name be enough to create a site, attended or not `bunny deploy --name my-site` stopped an unattended static deploy with "No site specified and no linked site found", though the name is exactly what a first deploy needs. `bunny sites deploy` also never declared `--name`, so passing it printed the help. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DNEZw6pebXfttb98s3ihG8 --- .changeset/deploy-polish.md | 4 +++- packages/cli/src/commands/sites/deploy.ts | 7 ++++++- packages/cli/src/commands/sites/interactive.ts | 6 ++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.changeset/deploy-polish.md b/.changeset/deploy-polish.md index bb0dffbb..6eef5beb 100644 --- a/.changeset/deploy-polish.md +++ b/.changeset/deploy-polish.md @@ -8,7 +8,9 @@ Three smaller things around a deploy. already ran it. A framework project whose build produced files rather than a server was asked twice. - `bunny deploy --name ` is honoured when that deploy creates a static - site. It was read only on the framework path, so the prompt asked anyway. + site. It was read only on the framework path, so the prompt asked anyway, and an + unattended run stopped with "No site specified and no linked site found." + `bunny sites deploy` takes `--name` too, which it never declared. - The domain prompt after a first deploy refuses a value that is not a hostname, and says so. It used to send it, and the API's answer is `An error has occurred.` diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index e6be9065..a9ab7bc0 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -143,6 +143,10 @@ export const sitesDeployCommand = defineCommand({ type: "boolean", default: false, describe: "Deploy even when the content is unchanged", + }) + .option("name", { + type: "string", + describe: "Site name, for the first deploy from this directory", }), ), @@ -178,8 +182,9 @@ export const sitesDeployCommand = defineCommand({ site: args.site, link: args.link, output, + name: args.name, offerCreate: async () => { - const name = await promptSiteName(args.name, true); + const name = await promptSiteName(args.name, isInteractive(output)); return createLinkedSite({ coreClient, computeClient, name }); }, }); diff --git a/packages/cli/src/commands/sites/interactive.ts b/packages/cli/src/commands/sites/interactive.ts index a4d4845c..c4d3d76e 100644 --- a/packages/cli/src/commands/sites/interactive.ts +++ b/packages/cli/src/commands/sites/interactive.ts @@ -119,6 +119,8 @@ export async function selectSite( args: SiteSelectorArgs & { output: OutputFormat; force?: boolean; + /** A name for a site to create, from `--name`. Enough to run unattended. */ + name?: string; offerCreate?: () => Promise; }, ): Promise { @@ -161,6 +163,10 @@ export async function selectSite( return linked(site); } + // A name is an instruction, so an unattended run with one needs nothing else. + // Without it there is nothing to create and nobody to ask. + if (args.name && args.offerCreate) return linked(await args.offerCreate()); + // `--force` skips the confirmation too, so picking a site from a list would act on it unprompted. if (args.force || !isInteractive(args.output)) { throw new UserError( From 7c80e8b5ff8276feab0518761314fb2c09084973 Mon Sep 17 00:00:00 2001 From: bogdan-at-bunny Date: Wed, 19 Aug 2026 16:40:28 +0000 Subject: [PATCH 07/10] Count bytes while uploading, not only files astro.build's deploy is 8828 files and 1.4 GB, and it spends ten minutes in one spinner. A file count does not say how much of that is left; the bytes do. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DNEZw6pebXfttb98s3ihG8 --- .changeset/deploy-polish.md | 3 +++ packages/cli/src/commands/deploy/framework.ts | 8 ++++++-- packages/cli/src/commands/sites/deploy.ts | 8 ++++++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.changeset/deploy-polish.md b/.changeset/deploy-polish.md index 6eef5beb..8d86cf67 100644 --- a/.changeset/deploy-polish.md +++ b/.changeset/deploy-polish.md @@ -16,3 +16,6 @@ Three smaller things around a deploy. occurred.` - A failing build names the command to run again. It said `bunny sites deploy --build` whichever command was running. +- The upload counts bytes as well as files. `withastro/astro.build` sends 1.4 GB + in 8828 files, and ten minutes of `4210/8828 files` says nothing about how much + is left. diff --git a/packages/cli/src/commands/deploy/framework.ts b/packages/cli/src/commands/deploy/framework.ts index a4bc159f..efff5469 100644 --- a/packages/cli/src/commands/deploy/framework.ts +++ b/packages/cli/src/commands/deploy/framework.ts @@ -379,10 +379,14 @@ export async function deployFramework(opts: { logger.info(`Set ${set.length} script variable(s): ${set.join(", ")}.`); } + let sent = 0; await withSpinner(`Uploading ${files.length} files...`, (spin) => uploadDeploy(connection, deployId, files, { - onFileUploaded: (done, total) => { - spin.text = `Uploading ${done}/${total} files (${formatBytes(totalBytes)} total)...`; + onFileUploaded: (done, total, file) => { + // Bytes, not only files: a 1.4 GB deploy spends ten minutes here, and a + // file count says nothing about how much of it is left. + sent += file.size; + spin.text = `Uploading ${done}/${total} files (${formatBytes(sent)} of ${formatBytes(totalBytes)})...`; }, }), ); diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index a9ab7bc0..2fce7bd5 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -367,10 +367,14 @@ export const sitesDeployCommand = defineCommand({ let record = alreadyUploaded; let reusedDeployId = false; if (!skipUpload) { + let sent = 0; await withSpinner(`Uploading ${files.length} files...`, (spin) => uploadDeploy(connection, deployId, files, { - onFileUploaded: (done, total) => { - spin.text = `Uploading ${done}/${total} files (${formatBytes(totalBytes)} total)...`; + onFileUploaded: (done, total, file) => { + // Bytes, not only files: a big deploy spends minutes here, and a file + // count says nothing about how much of it is left. + sent += file.size; + spin.text = `Uploading ${done}/${total} files (${formatBytes(sent)} of ${formatBytes(totalBytes)})...`; }, }), ); From 2776c7fb3e77aa3977fe20159791f19cc23f56f4 Mon Sep 17 00:00:00 2001 From: bogdan-at-bunny Date: Thu, 20 Aug 2026 14:25:51 +0000 Subject: [PATCH 08/10] Serve the static layer from the router, for every framework Router v4 reads three file names out of the deploy it is serving: `404.html`, `_redirects`, and `_headers`. Cloudflare Pages and Netlify read the same three, so nothing here knows a framework and all thirty static presets get it. The files are read once per deploy and held in memory, never written into the script: one script serves production and every preview, so a promote stays an environment variable change. Without a 404 page of its own a pull zone answers a miss with bunny.net's, which shipped: a documentation site went up and every wrong URL showed bunny.net's page. The router answers it with the deploy's own page now, at status 404 and `no-cache`, so the next deploy's fix is not outlived. `_redirects` is the subset both hosts agree on: `from to [status]`, `#` comments, a trailing `*` captured as `:splat`, and `!` to beat a file at the same path. Only a forced rule is answered before the origin is asked, which is what makes a real file win. A rewrite (`200`) is left out: it would have the router fetch another path of its own site, and a pathological pair of rules can make that loop. The router reads its own files through a reserved `/_bunny/router/` path with a four-name allowlist. That path is the whole permission, so nothing else under `_bunny/` becomes reachable, and the mapped request carries a flag that stops the response phase touching it or recursing through it. `CacheControlMaxAgeOverride` goes to -1 on a site's zone and every preview zone, because the router now owns `Cache-Control` and the zone default of 2592000 replaced every answer it gave: an HTML page could be a month stale in a browser no purge reaches, and a 404 could outlive the deploy that fixed it. A page gets 60 seconds, anything else keeps 30 days, and `_headers` wins where it speaks. The edge hit rate for HTML has not been measured either side of this change. `deploy/health.ts` is the module the plan named. `findDeployFault` moves there from `framework.ts`, and `findMissingPageFault` joins it: both deploy paths ask the published site for a path it cannot hold, and report an answer that is not the deploy's own page. The probe is a path, not a query string, because a sites zone ignores query strings. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DNEZw6pebXfttb98s3ihG8 --- .changeset/router-static-layer.md | 38 +++ AGENTS.md | 8 +- packages/cli/README.md | 12 + packages/cli/src/commands/deploy/framework.ts | 72 ++--- .../cli/src/commands/deploy/health.test.ts | 90 +++++- packages/cli/src/commands/deploy/health.ts | 122 ++++++++ packages/cli/src/commands/sites/api.test.ts | 65 ++++- packages/cli/src/commands/sites/api.ts | 47 ++- packages/cli/src/commands/sites/constants.ts | 15 + packages/cli/src/commands/sites/deploy.ts | 34 ++- .../src/commands/sites/router/source.test.ts | 229 +++++++++++++-- .../cli/src/commands/sites/router/source.ts | 274 ++++++++++++++++-- .../cli/src/commands/sites/upgrade-router.ts | 5 +- skills/bunny-cli/references/sites.md | 16 + 14 files changed, 923 insertions(+), 104 deletions(-) create mode 100644 .changeset/router-static-layer.md create mode 100644 packages/cli/src/commands/deploy/health.ts diff --git a/.changeset/router-static-layer.md b/.changeset/router-static-layer.md new file mode 100644 index 00000000..7901bcd3 --- /dev/null +++ b/.changeset/router-static-layer.md @@ -0,0 +1,38 @@ +--- +"@bunny.net/cli": minor +--- + +Serve a static site's 404 page, redirects, and headers from the router. + +Router v4 reads three file names out of the deploy it is serving: `404.html`, +`_redirects`, and `_headers`. Cloudflare Pages and Netlify read the same three, +so nothing in the router knows about a framework and every preset gets it. + +- **`404.html`** answers a path the deploy does not hold, at status 404. Without + it the pull zone answers with bunny.net's error page, whatever the site built. + That shipped: a documentation site went up and every wrong URL showed + bunny.net's page. +- **`_redirects`** sends a real redirect. One rule per line, `/from /to [status]`, + `#` comments, a trailing `*` captured as `:splat`, and `!` to beat a file at the + same path. 301 is the default status; 302, 303, 307 and 308 are read too. A + rewrite (`200`) is not: it would have the router fetch another path of its own + site, which can be made to loop. +- **`_headers`** carries the headers Bunny Storage cannot hold. A `/path` line + opens a block, `Name: value` lines under it belong to it, and a later block + wins the same name. + +A rule and a header match on a trailing-slash-normalised path, so `/about` and +`/about/` are one rule. The rules are read once per deploy and held in memory, +never written into the script: one script serves production and every preview, so +a promote stays an environment variable change. + +The router now sets `Cache-Control` on every response, and a site's pull zone +stops overriding it (`CacheControlMaxAgeOverride: -1`). The zone default of 30 +days replaced every answer the script gave, so an HTML page could be a month +stale in a browser that no purge reaches. A page now gets 60 seconds, anything +else 30 days as before, and `_headers` wins where it says anything. +`bunny sites upgrade-router` applies the router and the setting together, and +`bunny sites deploy` does it for a site whose router lags. + +`bunny deploy` and `bunny sites deploy` also ask the published site for a path it +cannot hold, and report when the answer is not the deploy's own 404 page. diff --git a/AGENTS.md b/AGENTS.md index 9521a7f7..28437dfb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -390,12 +390,12 @@ bunny-cli/ │ │ │ ├── index.ts # defineNamespace("sites", false, ...): create/list/show/deploy/deployments/domains/link/unlink/upgrade-router/delete; describe:false keeps it out of help while it stabilizes │ │ │ ├── constants.ts # SITES_MANIFEST (.bunny/site.json), REMOTE_STATE_PATH (_bunny/site.json), RemoteSiteState/DeployRecord types (DeployRecord carries previewZoneId/previewHost; state carries routerVersion), parseRemoteState (shape-checked; null = not a site), deployPrefix, deploy-ID + site-name validators (3-47 chars; `dpl-` names reserved so a site can't collide with preview-zone names), suffixedResourceName/siteResourcePattern (zone names are `sites-{name}-{random 6}`: the prefix marks them in the dashboard, the suffix dodges the global zone namespace), previewZoneName/deployIdFromPreviewZoneName/isPreviewZoneName (`sites-dpl-{deployId}-{rand6}` preview zones: no dashes in deploy ids keeps parsing unambiguous, and the worst case fits the 63-char DNS label limit) │ │ │ ├── constants.test.ts # parseRemoteState round-trip/rejection + helper tests -│ │ │ ├── api.ts # siteFiles IO seam (connect/download/upload/remove; swap in tests instead of mock.module), remote state read/write (sha256 etag optimistic lock: concurrent deploy records merge on mismatch, ours win per id; current/previous follow promotedTo, so last promote wins and non-promoting writers adopt the concurrent pointers), siteContextFromZone, fetchSites (pull zone listing → middleware+storage candidates → per-zone state verification), createSite (idempotent provisioning: storage zone → router script code+publish+CURRENT_DEPLOY → pull zone + MiddlewareScriptId attach → state; both zones share a random name suffix so globally-taken names can't block the create, retrying fresh suffixes on collision; resume adopts a stateless name-pattern zone, and state.name keeps the clean site name), promoteDeploy (env var PUT + purgeCache POST), ensureRouterCurrent (republishes the router when state.routerVersion lags ROUTER_VERSION, so preview hostnames can't silently serve production on a stale router), ensurePreviewZone (per-deploy preview pull zone `sites-dpl-{id}-{rand6}`: adopt-by-name+storage-zone first so a failed state write converges on retry, else create with fresh-suffix retries. A pull zone is public the instant it exists and an unrouted one serves the raw storage origin, so the router goes on in the create call itself (PullZoneAddModel.MiddlewareScriptId); the follow-up attach POST confirms it and is what repairs an adopted orphan, and if it fails a zone this run created is deleted rather than left exposed. Force SSL uses the derived b-cdn.net host (responses don't always carry Hostnames) and a failure returns ready:false, which keeps the zone out of the deploy record so the next deploy re-adopts and repairs it; null on failure, the deploy warns and retries next run), findPreviewZones (name shape + StorageZoneId sweep, catches zones missing from state) + deletePreviewZone (best-effort; 404 counts as deleted so retries converge), fetchSystemHostname, deleteSiteResources (preview zones → pull zone → script → storage zone, best-effort; a failed preview sweep aborts BEFORE anything is deleted, since the storage zone is the association key the sweep needs), deleteDeployFiles. fetchSites skips preview-shaped candidates by name before any state read +│ │ │ ├── api.ts # siteFiles IO seam (connect/download/upload/remove; swap in tests instead of mock.module), remote state read/write (sha256 etag optimistic lock: concurrent deploy records merge on mismatch, ours win per id; current/previous follow promotedTo, so last promote wins and non-promoting writers adopt the concurrent pointers), siteContextFromZone, fetchSites (pull zone listing → middleware+storage candidates → per-zone state verification), createSite (idempotent provisioning: storage zone → router script code+publish+CURRENT_DEPLOY → pull zone + MiddlewareScriptId attach → state; both zones share a random name suffix so globally-taken names can't block the create, retrying fresh suffixes on collision; resume adopts a stateless name-pattern zone, and state.name keeps the clean site name), promoteDeploy (env var PUT + purgeCache POST), ensureRouterCurrent (republishes the router when state.routerVersion lags ROUTER_VERSION, so preview hostnames can't silently serve production on a stale router, then applies applySiteZoneSettings), applySiteZoneSettings (STATIC_SITE_ZONE_SETTINGS on the site's zone and every preview zone: `CacheControlMaxAgeOverride: -1`, because from router v4 the router owns Cache-Control and the zone default of 2592000 would replace every answer it gives — a month-stale page in a browser no purge reaches, and a 404 outliving the deploy that fixes it. Best-effort and idempotent, so a failure warns and the next republish retries), ensurePreviewZone (per-deploy preview pull zone `sites-dpl-{id}-{rand6}`: adopt-by-name+storage-zone first so a failed state write converges on retry, else create with fresh-suffix retries. A pull zone is public the instant it exists and an unrouted one serves the raw storage origin, so the router goes on in the create call itself (PullZoneAddModel.MiddlewareScriptId); the follow-up attach POST confirms it and is what repairs an adopted orphan, and if it fails a zone this run created is deleted rather than left exposed. Force SSL uses the derived b-cdn.net host (responses don't always carry Hostnames) and a failure returns ready:false, which keeps the zone out of the deploy record so the next deploy re-adopts and repairs it; null on failure, the deploy warns and retries next run), findPreviewZones (name shape + StorageZoneId sweep, catches zones missing from state) + deletePreviewZone (best-effort; 404 counts as deleted so retries converge), fetchSystemHostname, deleteSiteResources (preview zones → pull zone → script → storage zone, best-effort; a failed preview sweep aborts BEFORE anything is deleted, since the storage zone is the association key the sweep needs), deleteDeployFiles. fetchSites skips preview-shaped candidates by name before any state read │ │ │ ├── api.test.ts # In-memory siteFiles store + path-branching fake clients: state round-trip, etag conflict, createSite fresh/resume/already-exists, promote, fetchSites filtering │ │ │ ├── interactive.ts # selectSite: explicit ref (storage zone ID/name, falling back to a state.name match since zone names carry a suffix) → .bunny/site.json → bunny.jsonc sites.name → picker (offerLink like scripts); `force` errors instead of opening the picker (destructive commands pass their --force, which also skips the confirmation, so a picked site would be acted on unprompted; deploy's --force means "redeploy unchanged content" and is not passed); optional offerCreate (deploy only) adds a new-vs-existing prompt, and creates straight away when the account has no sites; siteOptionBuilder (--site) + sitePositionalBuilder ([site]) + siteLinkOption (--link, mounted only by the commands that call offerLink); an explicit --link links whatever site was resolved (ref or bunny.jsonc included) during resolution, not via offerLink, since every command returns from its `--output json` branch before offerLink runs (and the confirmation line is suppressed under json); the picker keeps prompting unless --link/--no-link already decided it │ │ │ ├── provision.ts # promptSiteName (normalize/validate, directory-name suggestion) + createSiteWithProgress (createSite under a step-tracking spinner; shared with create.ts) + createLinkedSite (create + manifest link → SiteContext, skipping create's domain/CI prompts) for the deploy picker's new-site branch │ │ │ ├── config.ts # loadSiteConfig: reads bunny.jsonc via core/bunny-config.ts and validates ONLY the `sites` block (SiteConfigSchema from @bunny.net/config), so sites-only configs work without an `app` block or `version` -│ │ │ ├── router/source.ts # routerSource + ROUTER_VERSION (recorded in site state; deploy republishes stale routers via ensureRouterCurrent and purges preview-zone caches on upgrade): the middleware Edge Script (one script per site, attached to the main pull zone AND every preview zone). CRITICAL platform gotcha: at the edge ctx.request.url is the ORIGIN-facing address (http://:9000/...), NOT the requested host; the client hostname comes from the CDN-Host/Host headers (clientHostname()), and the index-retry probe URL must be rebuilt on that host so it re-enters the CDN instead of hitting unrouted storage paths. production hosts → CURRENT_DEPLOY, sites-dpl-{id}-{rand6}.b-cdn.net → that deploy (both root-served, so client-side routers and root-absolute assets work as-is), /_bunny/* → 403 (the client-sent x-bunny-index-retry header is stripped; the flag is router-internal), trailing-slash → index.html, and a slashless GET/HEAD 404 probes its directory index (re-entrant HEAD of the URL + "/") and 301-redirects to the slash URL when it exists (so /blog resolves in production and previews with the right relative-URL base, while exact extensionless objects and dotted directories stay reachable). onOriginResponse: X-Robots-Tag: noindex on preview hosts +│ │ │ ├── router/source.ts # routerSource + ROUTER_VERSION (recorded in site state; deploy republishes stale routers via ensureRouterCurrent and purges preview-zone caches on upgrade): the middleware Edge Script (one script per site, attached to the main pull zone AND every preview zone). CRITICAL platform gotcha: at the edge ctx.request.url is the ORIGIN-facing address (http://:9000/...), NOT the requested host; the client hostname comes from the CDN-Host/Host headers (clientHostname()), and the index-retry probe URL must be rebuilt on that host so it re-enters the CDN instead of hitting unrouted storage paths. production hosts → CURRENT_DEPLOY, sites-dpl-{id}-{rand6}.b-cdn.net → that deploy (both root-served, so client-side routers and root-absolute assets work as-is), /_bunny/* → 403 (the client-sent x-bunny-index-retry header is stripped; the flag is router-internal), trailing-slash → index.html, and a slashless GET/HEAD 404 probes its directory index (re-entrant HEAD of the URL + "/") and 301-redirects to the slash URL when it exists (so /blog resolves in production and previews with the right relative-URL base, while exact extensionless objects and dotted directories stay reachable). and the deploy's own configuration is applied around all of it. v4: the router reads three names out of the deploy it is serving — `404.html` (answers a miss, at status 404, with `Cache-Control: no-cache` so the next deploy's fix is not outlived), `_redirects` and `_headers` — through its own reserved `/_bunny/router/` path, which is the whole permission (the allowlist is those names plus `404/index.html`, so nothing else under `_bunny/` becomes reachable; the mapped request carries `x-bunny-raw`, so the response phase adds nothing to it and cannot recurse). Cloudflare Pages and Netlify read the same names, so every one of the ~30 static presets gets this and nothing here knows a framework. The rules are parsed once per deploy and held in memory (`configs`), never inlined in the source: one script serves production and every preview, so a promote stays an env var change; a read that failed is forgotten rather than remembered as "no rules". `_redirects` subset: `from to [status]`, `#` comments, trailing `*` captured as `:splat`, `!` forces the rule ahead of the origin (the only kind answered before it, which is what makes a real file win), statuses 301/302/303/307/308 (a `200` rewrite is deliberately out: it would have the router fetch its own site and can be made to loop). `_headers` subset: a `/path` line opens a block, `Name: value` lines under it belong to it, a later block wins a name. Both match on a trailing-slash-normalised path (`/about/` and `/about` are one rule), and `_headers` also matches the index-expanded object path. `x-bunny-path` carries the client's path to the response phase, because by then the URL is the rewritten origin one. onOriginResponse: `_headers`, then a `Cache-Control` for every response that carries none (`public, max-age=60` for a document, `public, max-age=2592000` for anything else — the zone override is off, see `STATIC_SITE_ZONE_SETTINGS`, so this is the answer the visitor gets), then X-Robots-Tag: noindex on preview hosts │ │ │ ├── deploy-id.ts # gitIdentity (short sha + dirty check via Bun.spawn), contentHashId (sorted path+sha256 merkle → 8 hex), resolveDeployIdentity (clean git → sha, else content hash) │ │ │ ├── deploy-id.test.ts # Hash determinism + real temp git repos (clean → sha, dirty → content hash) │ │ │ ├── uploader.ts # collectFiles (recursive walk, skips dotfiles/node_modules, sorted), hashFiles (streaming sha256), uploadDeploy (8-way concurrency, per-file checksum, 3-attempt backoff retry) via siteFiles.upload @@ -407,7 +407,7 @@ bunny-cli/ │ │ │ ├── show.ts # Site details + hostname table (SSL cert + Force SSL columns); a failed hostname fetch hides the table, never the site │ │ │ ├── open.ts # bunny sites open [site]: open the live URL (recorded custom domain when the zone still serves it, else system host) in the browser; --print emits it, siteLiveUrl is the pure resolver │ │ │ ├── ssl.ts # bunny sites ssl [site]: toggle Force HTTPS on the site's b-cdn.net system host via setForceSsl (no cert issued; --no-force-ssl allows HTTP); custom domains use `sites domains ssl` -│ │ │ ├── deploy.ts # bunny sites deploy [dir]: resolve site (picker offers to create a new site when none is linked) → build (--build resolves flag command → `sites.build` → detected build, failing before any site is created; no --build offers a detected/configured build interactively; without a dir arg, --build deploys the detected framework's output dir) → router upgrade check (ensureRouterCurrent; a failed republish skips preview creation so a stale router never serves production on a preview URL) → hash → no-op if unchanged → upload deploys/{id}/ → ensurePreviewZone (every deploy gets its own `sites-dpl-{id}-{rand6}.b-cdn.net` preview zone, recorded on the DeployRecord; no-op and skip-upload runs backfill a missing one so re-runs converge) → state write → publish when --production. Publishing is always explicit: --production/--prod, or the interactive offer when the site has no production deploy yet (a CI preview run on a fresh site must never go live). A domainless site's first-ever deploy also offers a custom production domain (setupSiteDomain, interactive text runs only); later domainless deploys print a dim `sites domains add` hint +│ │ │ ├── deploy.ts # bunny sites deploy [dir]: resolve site (picker offers to create a new site when none is linked) → build (--build resolves flag command → `sites.build` → detected build, failing before any site is created; no --build offers a detected/configured build interactively; without a dir arg, --build deploys the detected framework's output dir) → router upgrade check (ensureRouterCurrent; a failed republish skips preview creation so a stale router never serves production on a preview URL) → hash → no-op if unchanged → upload deploys/{id}/ → ensurePreviewZone (every deploy gets its own `sites-dpl-{id}-{rand6}.b-cdn.net` preview zone, recorded on the DeployRecord; no-op and skip-upload runs backfill a missing one so re-runs converge) → state write → publish when --production → a published deploy is asked for a path it cannot hold, and the answer has to be the deploy's own 404 page (findMissingPageFault in deploy/health.ts; a pull zone with no error page of its own answers a miss with bunny.net's, which shipped once on a documentation site). Publishing is always explicit: --production/--prod, or the interactive offer when the site has no production deploy yet (a CI preview run on a fresh site must never go live). A domainless site's first-ever deploy also offers a custom production domain (setupSiteDomain, interactive text runs only); later domainless deploys print a dim `sites domains add` hint │ │ │ ├── link.ts # Link directory to a site (.bunny/site.json) │ │ │ ├── unlink.ts # Remove .bunny/site.json │ │ │ ├── upgrade-router.ts # Republish the site's router script with the CLI's current source and record ROUTER_VERSION in state (deploy also auto-republishes stale routers) @@ -989,7 +989,7 @@ bunny ├── logout [--force] Remove stored authentication profile ├── whoami Show authenticated account (name, email, account id, profile) ├── deploy [dir] [--build [cmd]] [--env K=V] [--env-file] [--name] [--region] [--prod] [--preview] [--force] [--open] [--site] [--link] -│ Build and deploy this project. Reads `.bunny/build.json`, the build manifest a framework adapter writes (@bunny.net/config `BuildManifestSchema`): `kind: "ssr"` deploys as a framework site (see `sites`), anything else falls through to `sites deploy` with the manifest's asset dir. A framework project with no adapter installed or configured gets an offer to add one (detection carries the adapter package per preset; the Astro config edit is `patchAstroConfig`, which refuses any config it cannot edit safely). The build runs before any resource is created, so a failing build leaves no orphan site. `--preview` errors: preview environments are designed but not built +│ Build and deploy this project. Reads `.bunny/build.json`, the build manifest a framework adapter writes (@bunny.net/config `BuildManifestSchema`): `kind: "ssr"` deploys as a framework site (see `sites`), anything else falls through to `sites deploy` with the manifest's asset dir. A framework project with no adapter installed or configured gets an offer to add one (detection carries the adapter package per preset; the Astro config edit is `patchAstroConfig`, which refuses any config it cannot edit safely). The build runs before any resource is created, so a failing build leaves no orphan site. `deploy/health.ts` is what a fresh deploy is asked before the command calls it a success: findDeployFault (the home page, three tries, a 400 or 5xx that does not go away is a script that will not start) and findMissingPageFault (a path the deploy cannot hold has to answer with the deploy's own 404 page; the probe is a path, not a query string, because a sites zone ignores query strings). Both run on the framework path, and the 404 one on the static path too. `--preview` errors: preview environments are designed but not built ├── rollback [id] [--force] Publish an earlier deploy of the linked framework site (default: the previous one). Reads that deploy's stored bundle back out of storage, so the pages and the files they name are restored together ├── config │ ├── init [--api-key] Initialize config (create default profile) diff --git a/packages/cli/README.md b/packages/cli/README.md index c62ef809..4fca7cd8 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -960,6 +960,18 @@ bunny sites delete my-site --keep-storage # typed-name confirmation; Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`) and a deploy needs no arguments: `bunny sites deploy --build --prod`. `sites ci init` reads the same block, so the generated workflow builds and deploys exactly what the local command does; without it, the framework is detected from `package.json` deps, `Gemfile`, or a `hugo`/`python`/`zola` config file, with the lockfile picking the package manager. `sites create` offers to scaffold the workflow on GitHub repos. +The router serves the deploy's own configuration, from three file names Cloudflare Pages and Netlify read too. They belong to the build, not to bunny.net, so any framework that already writes them works here unchanged: + +| File in the deploy | What the router does with it | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------- | +| `404.html` | Answers a path the deploy does not hold, at status 404. Without it a miss gets bunny.net's error page | +| `_redirects` | One rule per line: `/from /to [status]`. A trailing `*` in the path is captured as `:splat`, and `!` beats a real file | +| `_headers` | A `/path` line, then indented `Name: value` lines. This is where a build asks for a CSP, or for immutable assets | + +A rule needs no status, and 301 is the default; 302, 303, 307 and 308 are read too. A rewrite (`200`) is not: it would have the router fetch another path of its own site, which can be made to loop. A rule without `!` applies only when the deploy holds no file at that path, so a real file always wins. Both files are read once per deploy and held in memory, and `bunny sites deploy` asks the live site for a path it cannot hold, so a 404 page that never reaches a visitor is reported rather than shipped. + +The router also sets `Cache-Control` on every response, because Bunny Storage sends none for HTML: 60 seconds for a page, 30 days for anything else, and whatever `_headers` says where it says anything. So `sites create` turns the pull zone's own cache override off, which is what lets the router's answer through. `sites upgrade-router` applies both to a site made by an earlier CLI. + A deploy's preview URL is `https://sites-dpl--.b-cdn.net`: its own pull zone pointed at the same storage zone with the same router attached, so previews are root-served (client-side routing and absolute asset paths behave exactly like production) and HTTPS works immediately under bunny's own certificate. Previews never publish anything; only `--production` (or `deployments publish`) changes the live site. Site state lives at `_bunny/site.json` inside the storage zone (the router blocks it with a 403); `.bunny/site.json` is only a local pointer, so a fresh clone can `sites link` and pick up where the last machine left off. | Flag | Commands | Description | diff --git a/packages/cli/src/commands/deploy/framework.ts b/packages/cli/src/commands/deploy/framework.ts index efff5469..3f3be175 100644 --- a/packages/cli/src/commands/deploy/framework.ts +++ b/packages/cli/src/commands/deploy/framework.ts @@ -45,6 +45,11 @@ import { type ScriptEnv, storageHostFor, } from "./api.ts"; +import { + findDeployFault, + findMissingPageFault, + readNotFoundPage, +} from "./health.ts"; import { type LoadedBuildManifest, resolveAssetsDir, @@ -69,51 +74,6 @@ const SCRIPT_SIZE_LIMIT = 10 * 1024 * 1024; */ const SCRIPT_START_RISK = 7.5 * 1024 * 1024; -/** How many times to ask a fresh deploy for a page before believing the answer. */ -const HEALTH_ATTEMPTS = 3; -const HEALTH_INTERVAL_MS = 3000; - -/** Overridden by the test, which has no nine seconds to spare. */ -export const health = { - wait: (ms: number): Promise => - new Promise((resolve) => setTimeout(resolve, ms)), -}; - -/** - * Ask the site for its home page, and answer with a status that means it is down. - * - * Returns null when the site answered anything a working script can answer, a - * redirect and a 404 included, and when it could not be reached at all. A deploy - * that prints a green line above a URL answering 400 is the worst thing this - * command can do, and the script's own size is the usual reason. - */ -export async function findDeployFault( - url: string, - deployId: string, -): Promise { - let fault: number | null = null; - for (let attempt = 0; attempt < HEALTH_ATTEMPTS; attempt++) { - if (attempt > 0) await health.wait(HEALTH_INTERVAL_MS); - try { - // A unique query per attempt keeps the probe out of the CDN cache, so a - // cached failure cannot outlive the release that caused it. - const response = await fetch( - `${url}/?__bunny_check=${deployId}-${attempt}`, - { - redirect: "manual", - signal: AbortSignal.timeout(10_000), - }, - ); - if (response.status !== 400 && response.status < 500) return null; - fault = response.status; - } catch { - // Unreachable is not a verdict: DNS and TLS take their own time. - return fault; - } - } - return fault; -} - const DOMAIN_HINT = " Add a custom domain: bunny domains add "; export interface FrameworkDeployArgs { @@ -438,12 +398,22 @@ export async function deployFramework(opts: { const urls = await siteUrls(coreClient, state); // A deploy that leaves the site answering 400 has to say so, whichever format - // the caller reads. + // the caller reads. So does one whose 404 page never reaches a visitor. const fault = urls.production ? await withSpinner("Checking the site...", () => findDeployFault(urls.production as string, deployId), ) : null; + const notFoundFault = + urls.production && fault === null + ? await withSpinner("Checking a missing page...", async () => + findMissingPageFault({ + url: urls.production as string, + deployId, + page: await readNotFoundPage(assetsDir, files), + }), + ) + : null; if (output === "json") { logger.log( @@ -459,6 +429,7 @@ export async function deployFramework(opts: { production: urls.production ?? null, serving: fault === null, ...(fault === null ? {} : { status: fault }), + ...(notFoundFault === null ? {} : { notFoundStatus: notFoundFault }), }, null, 2, @@ -492,6 +463,15 @@ export async function deployFramework(opts: { logger.dim(` The deploy before it is still there: bunny rollback`); } + if (notFoundFault !== null) { + logger.warn( + `A path this site does not hold answered ${notFoundFault}, and not with your 404 page.`, + ); + logger.dim( + " Check the script's logs in the dashboard: Scripting > your script > Logs.", + ); + } + const missing = unset.filter((name) => name !== "BUNNY_API_KEY"); if (missing.length > 0) { logger.dim( diff --git a/packages/cli/src/commands/deploy/health.test.ts b/packages/cli/src/commands/deploy/health.test.ts index ff926362..38c34309 100644 --- a/packages/cli/src/commands/deploy/health.test.ts +++ b/packages/cli/src/commands/deploy/health.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, test } from "bun:test"; -import { findDeployFault, health } from "./framework.ts"; +import { findDeployFault, findMissingPageFault, health } from "./health.ts"; const realFetch = globalThis.fetch; const realWait = health.wait; @@ -60,3 +60,91 @@ test("says nothing when the site cannot be reached", async () => { answerWith(["throw"]); expect(await findDeployFault("https://site.test", "abc")).toBeNull(); }); + +/** Answer each call with the next body, and record what was asked for. */ +function answerBodies( + bodies: Array<{ status: number; body: string }>, +): string[] { + const asked: string[] = []; + let call = 0; + health.wait = () => Promise.resolve(); + globalThis.fetch = ((url: string) => { + asked.push(url); + const next = bodies[Math.min(call++, bodies.length - 1)] as { + status: number; + body: string; + }; + return Promise.resolve(new Response(next.body, { status: next.status })); + }) as typeof fetch; + return asked; +} + +const PAGE = "nothing here"; + +// The fault that shipped: the zone has no error page of its own, so bunny.net's +// answers every miss and the site's own page is never seen. +test("reports a miss answered by anything but the deploy's own page", async () => { + const asked = answerBodies([{ status: 404, body: "bunny.net" }]); + expect( + await findMissingPageFault({ + url: "https://site.test", + deployId: "abc", + page: PAGE, + }), + ).toBe(404); + // A path, not a query: a sites zone ignores query strings, so a cache-buster + // in the query is the same URL to the cache. + expect(asked[0]).toBe("https://site.test/_bunny_check/abc/0"); + expect(new Set(asked).size).toBe(3); +}); + +test("says nothing when the deploy's own page answers", async () => { + answerBodies([{ status: 404, body: `\n${PAGE}\n` }]); + expect( + await findMissingPageFault({ + url: "https://site.test", + deployId: "abc", + page: PAGE, + }), + ).toBeNull(); +}); + +// A rewrite to 200 is a choice a site may make, and it is not this check's to +// overrule; a 200 is still not a 404, so it is reported and the deploy decides. +test("reports a miss that answered 200", async () => { + answerBodies([{ status: 200, body: PAGE }]); + expect( + await findMissingPageFault({ + url: "https://site.test", + deployId: "abc", + page: PAGE, + }), + ).toBe(200); +}); + +// A deploy with no 404 page of its own has nothing to be wrong about. +test("asks nothing when the deploy has no page of its own", async () => { + const asked = answerBodies([{ status: 404, body: "" }]); + expect( + await findMissingPageFault({ + url: "https://site.test", + deployId: "abc", + page: null, + }), + ).toBeNull(); + expect(asked).toEqual([]); +}); + +test("gives a fresh deploy the chance to propagate", async () => { + answerBodies([ + { status: 404, body: "bunny.net" }, + { status: 404, body: PAGE }, + ]); + expect( + await findMissingPageFault({ + url: "https://site.test", + deployId: "abc", + page: PAGE, + }), + ).toBeNull(); +}); diff --git a/packages/cli/src/commands/deploy/health.ts b/packages/cli/src/commands/deploy/health.ts new file mode 100644 index 00000000..0609cbae --- /dev/null +++ b/packages/cli/src/commands/deploy/health.ts @@ -0,0 +1,122 @@ +/** + * What a fresh deploy is asked before the command calls it a success. + * + * A green line printed above a URL that does not serve is the worst thing a + * deploy can do. Two faults have happened for real, so two things are checked: + * a script that will not start, and a site answering a miss with bunny.net's + * error page rather than its own. + */ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +/** How many times to ask a fresh deploy before believing the answer. */ +const HEALTH_ATTEMPTS = 3; +const HEALTH_INTERVAL_MS = 3000; + +/** Overridden by the test, which has no nine seconds to spare. */ +export const health = { + wait: (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)), +}; + +/** + * Ask the site for its home page, and answer with a status that means it is down. + * + * Returns null when the site answered anything a working script can answer, a + * redirect and a 404 included, and when it could not be reached at all. A deploy + * that prints a green line above a URL answering 400 is the worst thing this + * command can do, and the script's own size is the usual reason. + */ +export async function findDeployFault( + url: string, + deployId: string, +): Promise { + let fault: number | null = null; + for (let attempt = 0; attempt < HEALTH_ATTEMPTS; attempt++) { + if (attempt > 0) await health.wait(HEALTH_INTERVAL_MS); + try { + // A unique query per attempt keeps the probe out of the CDN cache, so a + // cached failure cannot outlive the release that caused it. + const response = await fetch( + `${url}/?__bunny_check=${deployId}-${attempt}`, + { + redirect: "manual", + signal: AbortSignal.timeout(10_000), + }, + ); + if (response.status !== 400 && response.status < 500) return null; + fault = response.status; + } catch { + // Unreachable is not a verdict: DNS and TLS take their own time. + return fault; + } + } + return fault; +} + +/** The names a deploy's own error page is written under. Both hosts read the first. */ +const NOT_FOUND_FILES = ["404.html", "404/index.html"]; + +/** + * The deploy's own 404 page, or null when it has none. + * + * `files` is what the deploy uploaded, so this asks the build what it produced + * rather than guessing from a framework. + */ +export async function readNotFoundPage( + dir: string, + files: Array<{ path: string }>, +): Promise { + const name = NOT_FOUND_FILES.find((candidate) => + files.some((file) => file.path === candidate), + ); + if (!name) return null; + try { + // A deploy path is POSIX, whatever the machine that built it. + return await readFile(join(dir, ...name.split("/")), "utf8"); + } catch { + return null; + } +} + +/** + * Ask for a path the deploy cannot hold, and check the deploy's own page + * answers it. + * + * A pull zone with no error page of its own answers a miss with bunny.net's, + * whatever the build produced. That shipped: a documentation site went up and + * every wrong URL showed bunny.net's page instead of the site's. Nothing in the + * API reports it, and nobody reads a 404 on the happy path, so it is asked for + * here. + * + * The probe is a path, not a query string: a sites pull zone ignores query + * strings, so `?x=1` is the same URL to the cache. Returns the status that + * answered when the page was not the deploy's, and null when it was, when the + * deploy has no page of its own, or when the site could not be reached. + */ +export async function findMissingPageFault(opts: { + url: string; + deployId: string; + /** The deploy's own 404 page, from {@link readNotFoundPage}. */ + page: string | null; +}): Promise { + if (opts.page === null) return null; + const wanted = opts.page.trim(); + let fault: number | null = null; + for (let attempt = 0; attempt < HEALTH_ATTEMPTS; attempt++) { + if (attempt > 0) await health.wait(HEALTH_INTERVAL_MS); + try { + const response = await fetch( + `${opts.url}/_bunny_check/${opts.deployId}/${attempt}`, + { redirect: "manual", signal: AbortSignal.timeout(10_000) }, + ); + const body = await response.text(); + if (response.status === 404 && body.trim() === wanted) return null; + fault = response.status; + } catch { + // Unreachable is not a verdict: DNS and TLS take their own time. + return null; + } + } + return fault; +} diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index 15c63c89..016d6a4b 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -459,7 +459,12 @@ test("createSite provisions storage zone → router → pull zone → state", as const attach = coreCalls.find( (c) => c.method === "POST" && c.path === "/pullzone/{id}", ); - expect(attach?.body).toEqual({ MiddlewareScriptId: 20 }); + // The router and the cache override go on together: the router decides what a + // response may be cached for, and the override would replace its answer. + expect(attach?.body).toEqual({ + MiddlewareScriptId: 20, + CacheControlMaxAgeOverride: -1, + }); // The system host redirects HTTP → HTTPS out of the box. const forceSsl = coreCalls.find( @@ -793,7 +798,12 @@ test("ensurePreviewZone creates the zone, attaches the router, and returns its h const attach = calls.find( (c) => c.method === "POST" && c.path === "/pullzone/{id}", ); - expect(attach?.body).toEqual({ MiddlewareScriptId: 20 }); + // The router and the cache override go on together: the router decides what a + // response may be cached for, and the override would replace its answer. + expect(attach?.body).toEqual({ + MiddlewareScriptId: 20, + CacheControlMaxAgeOverride: -1, + }); expect(zone?.ready).toBe(true); }); @@ -946,7 +956,12 @@ test("ensurePreviewZone adopts an existing zone for the deploy", async () => { (c) => c.method === "POST" && c.path === "/pullzone/{id}", ); expect(attach?.params).toEqual({ path: { id: 77 } }); - expect(attach?.body).toEqual({ MiddlewareScriptId: 20 }); + // The router and the cache override go on together: the router decides what a + // response may be cached for, and the override would replace its answer. + expect(attach?.body).toEqual({ + MiddlewareScriptId: 20, + CacheControlMaxAgeOverride: -1, + }); }); // A preview failure must not fail the deploy; the caller warns and the next run retries. @@ -990,19 +1005,59 @@ test("findPreviewZones matches by name shape and the site's storage zone", async test("ensureRouterCurrent republishes an outdated router and stamps the version", async () => { const calls: Call[] = []; const computeClient = fakeComputeClient({ calls }); - const state = fakeState(); + const coreCalls: Call[] = []; + const coreClient = fakeCoreClient({ calls: coreCalls }); + const state = fakeState({ + deploys: [ + { + id: "a1b2c3d4", + createdAt: "2026-01-01T00:00:00Z", + source: "git", + contentHash: "hash1", + files: 1, + bytes: 1, + previewZoneId: 31, + }, + ], + }); - expect(await ensureRouterCurrent({ computeClient, state })).toBe(true); + expect(await ensureRouterCurrent({ coreClient, computeClient, state })).toBe( + true, + ); expect(state.routerVersion).toBe(ROUTER_VERSION); expect(calls.map((c) => c.path)).toEqual([ "/compute/script/{id}/code", "/compute/script/{id}/publish", ]); + // The router owns Cache-Control from v4 on, so the zone override goes off on + // the site's zone and on every preview zone it has. Leaving it on would have + // the edge replace every answer the router gives, including a 404 that must + // not outlive the deploy which fixes it. + expect( + coreCalls.map((c) => ({ + path: c.path, + id: (c.params as { path: { id: number } }).path.id, + body: c.body, + })), + ).toEqual([ + { + path: "/pullzone/{id}", + id: 30, + body: { CacheControlMaxAgeOverride: -1 }, + }, + { + path: "/pullzone/{id}", + id: 31, + body: { CacheControlMaxAgeOverride: -1 }, + }, + ]); + // Already current: no calls at all. const noCalls: Call[] = []; expect( await ensureRouterCurrent({ + coreClient: fakeCoreClient({ calls: [] }), computeClient: fakeComputeClient({ calls: noCalls }), state, }), diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 54c6ca0a..0172220b 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -34,6 +34,7 @@ import { type RemoteSiteState, routerScriptName, STATE_VERSION, + STATIC_SITE_ZONE_SETTINGS, siteResourcePattern, suffixedResourceName, } from "./constants.ts"; @@ -441,7 +442,7 @@ export async function createSite( } await coreClient.POST("/pullzone/{id}", { params: { path: { id: pullZone.Id } }, - body: { MiddlewareScriptId: scriptId }, + body: { MiddlewareScriptId: scriptId, ...STATIC_SITE_ZONE_SETTINGS }, }); // Force HTTPS on the .b-cdn.net system host (already on bunny's wildcard cert, so this just redirects HTTP); best-effort. @@ -493,8 +494,45 @@ export async function fetchSystemHostname( } } +/** + * Apply {@link STATIC_SITE_ZONE_SETTINGS} to the site's zone and to every + * preview zone it has. + * + * The router and these settings are one change: the router decides what a + * response may be cached for, and the zone's override would replace its answer. + * Best-effort, and idempotent, so a failure here is a warning rather than a + * failed deploy, and the next republish tries again. + */ +export async function applySiteZoneSettings(opts: { + coreClient: CoreClient; + state: RemoteSiteState; +}): Promise { + const ids = [ + opts.state.pullZoneId, + ...opts.state.deploys.flatMap((d) => + d.previewZoneId ? [d.previewZoneId] : [], + ), + ]; + for (const id of ids) { + try { + await opts.coreClient.POST("/pullzone/{id}", { + params: { path: { id } }, + body: { ...STATIC_SITE_ZONE_SETTINGS }, + }); + } catch (err) { + logger.warn( + `Couldn't turn the cache override off on pull zone ${id}: ${errorMessage(err)}`, + ); + logger.dim( + " Until it is off, the zone replaces the Cache-Control the router sends.", + ); + } + } +} + // Republish the site's router when its recorded source generation lags the CLI's (pre-preview-zone routers would silently serve production on preview hostnames). Mutates state.routerVersion; the caller's next state write persists it, and a missed write just re-runs this next time. export async function ensureRouterCurrent(opts: { + coreClient: CoreClient; computeClient: ComputeClient; state: RemoteSiteState; }): Promise { @@ -509,6 +547,8 @@ export async function ensureRouterCurrent(opts: { params: { path: { id: state.scriptId, uuid: null } }, body: {}, }); + // The new router owns Cache-Control, so the zone must stop overriding it. + await applySiteZoneSettings(opts); state.routerVersion = ROUTER_VERSION; return true; } @@ -561,7 +601,10 @@ export async function ensurePreviewZone(opts: { try { await coreClient.POST("/pullzone/{id}", { params: { path: { id: zone.Id } }, - body: { MiddlewareScriptId: state.scriptId }, + body: { + MiddlewareScriptId: state.scriptId, + ...STATIC_SITE_ZONE_SETTINGS, + }, }); } catch (err) { // The zone can't be proven routed now, so a zone this run created is taken back down rather than left publicly serving the storage origin. An adopted one predates this run: leave it (it may well be routed) for the next deploy or a cleanup sweep. diff --git a/packages/cli/src/commands/sites/constants.ts b/packages/cli/src/commands/sites/constants.ts index b89bd477..6b38eca3 100644 --- a/packages/cli/src/commands/sites/constants.ts +++ b/packages/cli/src/commands/sites/constants.ts @@ -10,6 +10,21 @@ export const DEPLOYS_DIR = "deploys"; // Router env var selecting the production deploy; updating it is the promote/rollback lever (no republish). export const CURRENT_DEPLOY_VAR = "CURRENT_DEPLOY"; +/** + * Pull zone settings the router depends on. Applied to a site's zone and to + * every preview zone, at create and whenever the router is republished. + * + * `-1` turns the cache override off. With the zone default of 2592000 in place + * the edge rewrites every `Cache-Control` it forwards, so nothing the router + * returns reaches the visitor: an HTML page is a month stale in a browser that + * a purge cannot reach, and a 404 the next deploy fixes outlives it by weeks. + * With it off the edge follows the origin, and Bunny Storage sends no + * `Cache-Control` for HTML, so the router sets one on every response. + */ +export const STATIC_SITE_ZONE_SETTINGS = { + CacheControlMaxAgeOverride: -1, +} as const; + export const STATE_VERSION = 1; export const DEFAULT_KEEP_DEPLOYS = 5; diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 2fce7bd5..01833f74 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -17,6 +17,7 @@ import { } from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; import { confirm, isInteractive, withSpinner } from "../../core/ui.ts"; +import { findMissingPageFault, readNotFoundPage } from "../deploy/health.ts"; import { ensurePreviewZone, ensureRouterCurrent, @@ -210,7 +211,11 @@ export const sitesDeployCommand = defineCommand({ let routerReady = true; let routerUpgraded = false; try { - routerUpgraded = await ensureRouterCurrent({ computeClient, state }); + routerUpgraded = await ensureRouterCurrent({ + coreClient, + computeClient, + state, + }); if (routerUpgraded && output !== "json") { logger.info("Republished the site's router (new preview routing)."); } @@ -435,6 +440,21 @@ export const sitesDeployCommand = defineCommand({ const urls = deployUrls(state, record, systemHost); + // A site whose 404 page never reaches a visitor looks deployed and is not. + // The pull zone answers a miss with bunny.net's page unless the router + // answers it first, and nobody reads a 404 on the happy path. This shipped + // once, on a documentation site, so the deploy asks. + const notFoundFault = + publish && urls.production + ? await withSpinner("Checking a missing page...", async () => + findMissingPageFault({ + url: urls.production as string, + deployId, + page: await readNotFoundPage(dir, files), + }), + ) + : null; + if (output === "json") { logger.log( JSON.stringify( @@ -447,6 +467,9 @@ export const sitesDeployCommand = defineCommand({ promoted: publish, production: urls.production ?? null, preview: urls.preview ?? null, + ...(notFoundFault === null + ? {} + : { notFoundStatus: notFoundFault }), }, null, 2, @@ -478,6 +501,15 @@ export const sitesDeployCommand = defineCommand({ ); } + if (notFoundFault !== null) { + logger.warn( + `A path this site does not hold answered ${notFoundFault}, and not with your 404 page.`, + ); + logger.dim( + " Its router may be older than this CLI: bunny sites upgrade-router", + ); + } + // Domainless sites: the first deploy offers a custom production domain, later ones just hint. if (!state.domain) { logger.log(); diff --git a/packages/cli/src/commands/sites/router/source.test.ts b/packages/cli/src/commands/sites/router/source.test.ts index 01d4dff3..ee2fbaf2 100644 --- a/packages/cli/src/commands/sites/router/source.test.ts +++ b/packages/cli/src/commands/sites/router/source.test.ts @@ -1,44 +1,88 @@ import { expect, test } from "bun:test"; import { routerSource } from "./source.ts"; -// Extracts a top-level function from the generated script and evaluates it, so tests run the shipped code rather than a mirror of it. -function extractFn(name: string): (...args: unknown[]) => unknown { +// Extracts a top-level function from the generated script, so the tests run the shipped code rather than a mirror of it. +function fnSource(name: string): string { const match = routerSource.match( new RegExp(`function ${name}\\([^]*?\\n\\}`), ); if (!match) throw new Error(`function ${name} not found in routerSource`); - return new Function(`return (${match[0]});`)() as ( - ...args: unknown[] - ) => unknown; + return match[0]; } -const indexRetryUrl = extractFn("indexRetryUrl") as ( +// A top-level `const NAME = ...;` declaration, for the ones the functions close over. +function constSource(name: string): string { + const match = routerSource.match(new RegExp(`^const ${name} = .*;$`, "m")); + if (!match) throw new Error(`const ${name} not found in routerSource`); + return match[0]; +} + +// Evaluate the named functions together with the declarations they close over, and hand back the last one. +function load( + names: string[], + consts: string[] = [], +): (...args: never[]) => unknown { + const parts = [ + ...consts.map(constSource), + ...names.slice(0, -1).map(fnSource), + `return (${fnSource(names[names.length - 1] as string)});`, + ]; + return new Function(parts.join("\n"))() as (...args: never[]) => unknown; +} + +const indexRetryUrl = load(["indexRetryUrl"]) as ( rawUrl: string, host: string, ) => string | null; -const clientHostname = extractFn("clientHostname") as (request: { +const clientHostname = load(["clientHostname"]) as (request: { url: string; headers: Map; }) => string; +const previewDeployId = load(["previewDeployId"], ["PREVIEW_ZONE_HOST"]) as ( + hostname: string, +) => string | null; + +const matchPath = load(["matchPath"]) as (pathname: string) => string; + +interface RedirectRule { + from: string; + to: string; + status: number; + force: boolean; +} + +const parseRedirects = load( + ["matchPath", "parseRedirects"], + ["REDIRECT_STATUS"], +) as (text: string) => RedirectRule[]; + +const parseHeaders = load(["matchPath", "parseHeaders"]) as ( + text: string, +) => Array<{ from: string; entries: Array<[string, string]> }>; + +const matchRedirect = load(["ruleSplat", "matchRedirect"]) as ( + rules: RedirectRule[], + path: string, + forcedOnly: boolean, +) => RedirectRule | null; + +const matchHeaders = load(["ruleSplat", "matchHeaders"]) as ( + rules: Array<{ from: string; entries: Array<[string, string]> }>, + paths: string[], +) => Map; + +const defaultCacheControl = load( + ["defaultCacheControl"], + ["PAGE_CACHE", "ASSET_CACHE", "PAGE_EXT"], +) as (path: string) => string; + // A minimal Headers-alike; the router only calls headers.get(). function req(url: string, headers: Record) { return { url, headers: new Map(Object.entries(headers)) }; } -// previewDeployId closes over the hostname regex, so both are extracted and evaluated together. -function extractPreviewDeployId(): (hostname: string) => string | null { - const hostRe = routerSource.match(/const PREVIEW_ZONE_HOST = .*;/); - const fn = routerSource.match(/function previewDeployId\([\s\S]*?\n\}/); - if (!hostRe || !fn) throw new Error("previewDeployId not found"); - return new Function(`${hostRe[0]}\nreturn (${fn[0]});`)() as ( - hostname: string, - ) => string | null; -} - -const previewDeployId = extractPreviewDeployId(); - test("routerSource wires up the deploy routing", () => { const src = routerSource; expect(src).toContain("bunny sites router"); @@ -54,11 +98,35 @@ test("routerSource wires up the deploy routing", () => { ); // Slashless 404s probe the directory index and redirect to the slash URL, after the exact lookup misses. expect(src).toContain('const RETRY_HEADER = "x-bunny-index-retry";'); - expect(src).toContain("if (retry && ctx.response.status === 404)"); - expect(src).toContain("{ status: 301, headers: { Location: retry } }"); - // The client-sent flag must be stripped, or it'd poison cached HTML. + expect(src).toContain("if (retry) {"); + expect(src).toContain( + "status: 301,\n headers: { Location: retry }", + ); + // The client-sent flags must be stripped, or they'd poison cached HTML. expect(src).toContain("headers.delete(RETRY_HEADER);"); + expect(src).toContain("headers.delete(PATH_HEADER);"); + expect(src).toContain("headers.delete(RAW_HEADER);"); expect(src).toContain("X-Robots-Tag"); + // Only a request this router sent to the origin is answered in the response + // phase; a response the request phase produced itself is already final. + expect(src).toContain("if (requested === null) return;"); +}); + +// The three names a deploy configures the router with. A framework writes them; nothing here knows which framework. +test("routerSource reads the deploy's own configuration, and nothing else", () => { + expect(routerSource).toContain('const CONFIG_PATH = "/_bunny/router/";'); + expect(routerSource).toContain( + 'const CONFIG_FILES = ["_redirects", "_headers", "404.html", "404/index.html"];', + ); + // The reserved path is the whole permission: anything else under `_bunny/` is still forbidden. + expect(routerSource).toContain( + 'if (wanted === null && (path === "/_bunny" || path.startsWith("/_bunny/")))', + ); + expect(routerSource).toContain( + 'return new Response("Forbidden", { status: 403 });', + ); + // The configuration is read per deploy and held, never written into the source. + expect(routerSource).toContain("const configs = new Map();"); }); // The raw URL at the edge is an internal origin address; the retry target must be rebuilt on the client host so the probe re-enters the CDN and this router. @@ -112,3 +180,120 @@ test("previewDeployId parses preview-zone hostnames only", () => { expect(previewDeployId("shop.example.com")).toBeNull(); expect(previewDeployId("sites-dpl-a1b2c3d4-x1y2z3.example.com")).toBeNull(); }); + +// `/about` and `/about/` are one page to every static host, so a rule written either way matches both. +test("matchPath drops the trailing slash, and keeps the root", () => { + expect(matchPath("/about/")).toBe("/about"); + expect(matchPath("/about")).toBe("/about"); + expect(matchPath("/")).toBe("/"); + expect(matchPath("/a/b//")).toBe("/a/b"); +}); + +test("parseRedirects reads the subset both hosts agree on", () => { + const rules = parseRedirects( + [ + "# a comment", + "", + "/old /about", + "/gone /about 302", + "/forced /about 301!", + "/blog/* /news/:splat 308", + " /indented /about ", + ].join("\n"), + ); + expect(rules).toEqual([ + { from: "/old", to: "/about", status: 301, force: false }, + { from: "/gone", to: "/about", status: 302, force: false }, + { from: "/forced", to: "/about", status: 301, force: true }, + { from: "/blog/*", to: "/news/:splat", status: 308, force: false }, + { from: "/indented", to: "/about", status: 301, force: false }, + ]); +}); + +// A line this router cannot act on is dropped, not guessed at. A rewrite (200) is deliberately outside the subset. +test("parseRedirects drops what it cannot send", () => { + expect( + parseRedirects( + [ + "/nowhere", + "relative /about", + "/spa/* /index.html 200", + "/x /y 999", + ].join("\n"), + ), + ).toEqual([]); +}); + +test("parseHeaders reads a path and the lines under it", () => { + expect( + parseHeaders( + [ + "# a comment", + "/_astro/*", + " Cache-Control: public, max-age=31536000, immutable", + "/about/", + " X-Frame-Options: DENY", + " Content-Security-Policy: default-src 'self'; img-src *", + "/empty", + ].join("\n"), + ), + ).toEqual([ + { + from: "/_astro/*", + entries: [["Cache-Control", "public, max-age=31536000, immutable"]], + }, + { + from: "/about", + entries: [ + ["X-Frame-Options", "DENY"], + ["Content-Security-Policy", "default-src 'self'; img-src *"], + ], + }, + ]); +}); + +test("matchRedirect takes the first rule, and fills in the splat", () => { + const rules = parseRedirects( + ["/blog/* /news/:splat 301", "/old /about 302!"].join("\n"), + ); + expect(matchRedirect(rules, "/blog/2026/hello", false)).toMatchObject({ + to: "/news/2026/hello", + status: 301, + }); + expect(matchRedirect(rules, "/nothing", false)).toBeNull(); + // A forced rule is the only kind answered before the origin is asked, because it is the only kind that beats a real file. + expect(matchRedirect(rules, "/blog/x", true)).toBeNull(); + expect(matchRedirect(rules, "/old", true)).toMatchObject({ to: "/about" }); +}); + +test("matchHeaders collects every matching block, and a later one wins", () => { + const rules = parseHeaders( + [ + "/*", + " X-Frame-Options: SAMEORIGIN", + " X-Content-Type-Options: nosniff", + "/about", + " X-Frame-Options: DENY", + ].join("\n"), + ); + expect(Object.fromEntries(matchHeaders(rules, ["/about"]))).toEqual({ + "x-frame-options": "DENY", + "x-content-type-options": "nosniff", + }); + expect(Object.fromEntries(matchHeaders(rules, ["/other"]))).toEqual({ + "x-frame-options": "SAMEORIGIN", + "x-content-type-options": "nosniff", + }); +}); + +// A page is rewritten in place by the next deploy, and a promote purges the edge; a browser may only keep it briefly. The zone's own override is off, so this answer is the one the visitor gets. +test("defaultCacheControl separates a document from everything else", () => { + expect(defaultCacheControl("/about/")).toBe("public, max-age=60"); + expect(defaultCacheControl("/index.html")).toBe("public, max-age=60"); + expect(defaultCacheControl("/feed.xml")).toBe("public, max-age=60"); + expect(defaultCacheControl("/data.json")).toBe("public, max-age=60"); + expect(defaultCacheControl("/_astro/app.a1b2.js")).toBe( + "public, max-age=2592000", + ); + expect(defaultCacheControl("/logo.png")).toBe("public, max-age=2592000"); +}); diff --git a/packages/cli/src/commands/sites/router/source.ts b/packages/cli/src/commands/sites/router/source.ts index 03aae9e6..ba644ed7 100644 --- a/packages/cli/src/commands/sites/router/source.ts +++ b/packages/cli/src/commands/sites/router/source.ts @@ -1,13 +1,35 @@ // The published router source's generation; recorded in site state so deploy republishes routers that predate the current source. -export const ROUTER_VERSION = 3; +export const ROUTER_VERSION = 4; -// The site's middleware Edge Script: maps production hosts to the published deploy dir and preview hosts to theirs. Previews are per-deploy pull zones (`sites-dpl-{id}-{suffix}.b-cdn.net`). The edge hands the script an origin-facing URL (an internal `ip:9000` address), so the client hostname MUST come from the CDN-Host/Host headers; matching on `url.hostname` silently routes every request like production. (See AGENTS.md and the SOURCE comments below; BunnySDK hook and header names are the platform contract.) +// The site's middleware Edge Script. It maps production hosts to the published deploy dir and preview hosts to theirs (previews are per-deploy pull zones, `sites-dpl-{id}-{suffix}.b-cdn.net`), and it serves the deploy's own `404.html`, `_redirects` and `_headers`. Those three names are the whole contract with a framework: Cloudflare Pages and Netlify read the same ones, so nothing here knows about any framework, and every preset gets the same behaviour. +// +// The edge hands the script an origin-facing URL (an internal `ip:9000` address), so the client hostname MUST come from the CDN-Host/Host headers; matching on `url.hostname` silently routes every request like production. The deploy's configuration is read at run time and held in memory, never inlined below: one script serves production and every preview, so a promote has to stay an environment variable change. +// +// The zone's `CacheControlMaxAgeOverride` is off (see `STATIC_SITE_ZONE_SETTINGS`), which means whatever this script returns is what the visitor gets. So every response leaves here with a `Cache-Control`, because Bunny Storage sends none for HTML. +// +// (See AGENTS.md and the SOURCE comments below; BunnySDK hook and header names are the platform contract.) export const routerSource = `// bunny sites router v${ROUTER_VERSION}, generated by the bunny CLI. Do not edit: // \`bunny sites upgrade-router\` overwrites this script. import * as BunnySDK from "@bunny.net/edgescript-sdk"; const PREVIEW_ZONE_HOST = /^sites-dpl-([a-z0-9]{4,40})-[a-z0-9]{6}\\.b-cdn\\.net$/i; const RETRY_HEADER = "x-bunny-index-retry"; +// The path the client asked for, carried to the response phase: by then the URL is the rewritten origin one, and \`_redirects\` and \`_headers\` are written against what the visitor typed. +const PATH_HEADER = "x-bunny-path"; +// Marks a request this router made for itself, so the response phase adds nothing to it and cannot recurse through it. +const RAW_HEADER = "x-bunny-raw"; + +// The files a deploy configures the router with. Host-standard names: Cloudflare Pages and Netlify read the same ones, so a framework that already writes them needs nothing new. The router reads them through its own reserved path, which is the whole permission: nothing else under \`_bunny/\` becomes reachable. +const CONFIG_PATH = "/_bunny/router/"; +const CONFIG_FILES = ["_redirects", "_headers", "404.html", "404/index.html"]; + +// A page is rewritten in place by the next deploy, so a browser may only keep it briefly; a promote purges the edge. Everything else is content a new deploy renames, and \`_headers\` is where a build says which directory is hashed. +const PAGE_CACHE = "public, max-age=60"; +const ASSET_CACHE = "public, max-age=2592000"; +// Extensions whose object is a document: entered by URL, and replaced in place. +const PAGE_EXT = /\\.(?:html?|json|xml|txt|rss|atom|webmanifest|map)$/i; +// A body these statuses may not carry. Constructing one with a body throws. +const BODYLESS = [101, 204, 205, 304]; // ctx.request.url carries the ORIGIN address at the edge, not the requested host; the platform passes the client hostname in CDN-Host (Host covers local harnesses). function clientHostname(request) { @@ -36,29 +58,156 @@ function indexRetryUrl(rawUrl, host) { return "https://" + host + u.pathname + "/" + u.search; } +// The path a rule is matched against: no trailing slash, and never empty. \`/about/\` and \`/about\` are one page to every static host, so they are one rule here. +function matchPath(pathname) { + const trimmed = pathname.replace(/\\/+$/, ""); + return trimmed === "" ? "/" : trimmed; +} + +// Statuses a rule may ask for. A rewrite (\`200\`) is deliberately not one: it would have this router fetch another path of its own site, which can be made to loop, and no deploy needs it yet. +const REDIRECT_STATUS = [301, 302, 303, 307, 308]; + +// \`_redirects\`, in the subset Cloudflare Pages and Netlify agree on: \`from to [status]\` per line, \`#\` comments, a trailing \`*\` in \`from\` captured as \`:splat\`, and \`!\` after the status to beat a file at the same path. +function parseRedirects(text) { + const rules = []; + for (const raw of text.split("\\n")) { + const line = raw.trim(); + if (line === "" || line.startsWith("#")) continue; + const parts = line.split(/\\s+/); + if (parts.length < 2) continue; + const from = parts[0]; + const to = parts[1]; + if (!from.startsWith("/")) continue; + const asked = parts[2] ?? "301"; + const status = Number.parseInt(asked.replace("!", ""), 10); + if (!REDIRECT_STATUS.includes(status)) continue; + rules.push({ from: matchPath(from), to, status, force: asked.endsWith("!") }); + } + return rules; +} + +// \`_headers\`: a line starting with \`/\` opens a block, and each \`Name: value\` line below it belongs to that block. +function parseHeaders(text) { + const rules = []; + let current = null; + for (const raw of text.split("\\n")) { + const line = raw.trim(); + if (line === "" || line.startsWith("#")) continue; + if (line.startsWith("/")) { + current = { from: matchPath(line), entries: [] }; + rules.push(current); + continue; + } + if (!current) continue; + const colon = line.indexOf(":"); + if (colon < 1) continue; + const name = line.slice(0, colon).trim(); + const value = line.slice(colon + 1).trim(); + if (name !== "" && value !== "") current.entries.push([name, value]); + } + return rules.filter((rule) => rule.entries.length > 0); +} + +// What a trailing \`*\` in the rule's path captured, "" for an exact match, or null when the rule does not apply. +function ruleSplat(from, path) { + if (!from.endsWith("*")) return from === path ? "" : null; + const prefix = from.slice(0, -1); + return path.startsWith(prefix) ? path.slice(prefix.length) : null; +} + +// The first rule that matches, with \`:splat\` filled in. +function matchRedirect(rules, path, forcedOnly) { + for (const rule of rules) { + if (forcedOnly && !rule.force) continue; + const splat = ruleSplat(rule.from, path); + if (splat === null) continue; + return { ...rule, to: rule.to.replaceAll(":splat", splat) }; + } + return null; +} + +// Every header the matching blocks ask for, in file order, so a later block overrides an earlier one on the same name. +function matchHeaders(rules, paths) { + const found = new Map(); + for (const rule of rules) { + if (!paths.some((path) => ruleSplat(rule.from, path) !== null)) continue; + for (const [name, value] of rule.entries) { + found.set(name.toLowerCase(), value); + } + } + return found; +} + +// What a response may be cached for when \`_headers\` says nothing. The pull zone's own override is off on a sites zone, so this is the answer the visitor gets. +function defaultCacheControl(path) { + return PAGE_EXT.test(path) || !path.includes(".") ? PAGE_CACHE : ASSET_CACHE; +} + +// Read one of the deploy's configuration files. "" means the deploy does not hold it, and null means it could not be read at all, which must not be remembered as "no rules". +async function readFile(host, name) { + try { + const response = await fetch("https://" + host + CONFIG_PATH + name, { + headers: { [RAW_HEADER]: "1" }, + }); + if (response.status === 404) return ""; + return response.ok ? await response.text() : null; + } catch { + return null; + } +} + +// The deploy's rules, read once and held for the life of the isolate. They are never inlined in this script: one script serves production and every preview, and a promote has to stay an environment variable change. +const configs = new Map(); + +function readConfig(host, deploy) { + const held = configs.get(deploy); + if (held) return held; + const loading = (async () => { + const [redirects, headers, page, nested] = await Promise.all([ + readFile(host, "_redirects"), + readFile(host, "_headers"), + readFile(host, "404.html"), + readFile(host, "404/index.html"), + ]); + // A read that failed is not an answer. Forget the lot and try again on the + // next request, rather than serving a deploy without its rules for hours. + if (redirects === null || headers === null) configs.delete(deploy); + return { + redirects: parseRedirects(redirects ?? ""), + headers: parseHeaders(headers ?? ""), + notFound: page || nested || null, + }; + })(); + configs.set(deploy, loading); + return loading; +} + BunnySDK.net.http .servePullZone() .onOriginRequest(async (ctx) => { const url = new URL(ctx.request.url); const host = clientHostname(ctx.request); + const requested = url.pathname; + + // The router's own reads, which skip every rule below. + const wanted = requested.startsWith(CONFIG_PATH) + ? requested.slice(CONFIG_PATH.length) + : null; + // Storage serves no directory indexes: expand \`/dir/\` to \`/dir/index.html\` on every route. if (url.pathname.endsWith("/")) url.pathname += "index.html"; const path = url.pathname; // Internal site metadata (state, env) is never served. - if (path === "/_bunny" || path.startsWith("/_bunny/")) { + if (wanted === null && (path === "/_bunny" || path.startsWith("/_bunny/"))) { return new Response("Forbidden", { status: 403 }); } - // The flag is router-internal: client-sent copies are stripped, or they'd poison cached HTML. + // Every flag is router-internal: client-sent copies are stripped, or they'd poison cached HTML. const headers = new Headers(ctx.request.headers); headers.delete(RETRY_HEADER); - - // Exact objects win: a slashless GET/HEAD miss retries as its directory index in the response phase. - if (ctx.request.method === "GET" || ctx.request.method === "HEAD") { - const retry = indexRetryUrl(ctx.request.url, host); - if (retry) headers.set(RETRY_HEADER, retry); - } + headers.delete(PATH_HEADER); + headers.delete(RAW_HEADER); const preview = previewDeployId(host); const deploy = preview ?? (process.env.CURRENT_DEPLOY || ""); @@ -70,26 +219,107 @@ BunnySDK.net.http }); } + if (wanted !== null) { + if (!CONFIG_FILES.includes(wanted)) { + return new Response("Not Found", { status: 404 }); + } + headers.set(RAW_HEADER, "1"); + url.pathname = "/deploys/" + deploy + "/" + wanted; + return new Request(new Request(url.toString(), ctx.request), { headers }); + } + + headers.set(PATH_HEADER, requested); + + if (ctx.request.method === "GET" || ctx.request.method === "HEAD") { + // A forced rule beats a file at the same path, so it is the only kind that can be answered before the origin is asked. An unforced one waits for the 404, which is what makes a real file win. + const rules = await readConfig(host, deploy); + const forced = matchRedirect(rules.redirects, matchPath(requested), true); + if (forced) { + return new Response(null, { + status: forced.status, + headers: { Location: forced.to, "Cache-Control": PAGE_CACHE }, + }); + } + // Exact objects win: a slashless GET/HEAD miss retries as its directory index in the response phase. + const retry = indexRetryUrl(ctx.request.url, host); + if (retry) headers.set(RETRY_HEADER, retry); + } + url.pathname = "/deploys/" + deploy + path; return new Request(new Request(url.toString(), ctx.request), { headers }); }) .onOriginResponse(async (ctx) => { - // A flagged 404 probes its directory index and redirects to the slash URL when it exists (/blog -> /blog/), so relative references resolve against the right base; the probe re-enters this router and, slash-terminated, can never retry further. - const retry = ctx.request.headers.get(RETRY_HEADER); - if (retry && ctx.response.status === 404) { - const probe = await fetch(retry, { method: "HEAD" }); - if (probe.ok) { - return new Response(null, { status: 301, headers: { Location: retry } }); + // The router's own read. Nothing is applied to it, and nothing recurses through it. + if (ctx.request.headers.get(RAW_HEADER)) return; + + // Only a request this router sent to the origin carries the path, so only + // one of those is answered here. A response the request phase produced by + // itself (a forced redirect, a 403, an unpublished site) is already final. + const requested = ctx.request.headers.get(PATH_HEADER); + if (requested === null) return; + + const host = clientHostname(ctx.request); + const preview = previewDeployId(host); + const deploy = preview ?? (process.env.CURRENT_DEPLOY || ""); + const rules = await readConfig(host, deploy); + let response = ctx.response; + + if (response.status === 404) { + // A flagged 404 probes its directory index and redirects to the slash URL when it exists (/blog -> /blog/), so relative references resolve against the right base; the probe re-enters this router and, slash-terminated, can never retry further. + const retry = ctx.request.headers.get(RETRY_HEADER); + if (retry) { + const probe = await fetch(retry, { method: "HEAD" }); + if (probe.ok) { + return new Response(null, { + status: 301, + headers: { Location: retry }, + }); + } + } + + const rule = matchRedirect(rules.redirects, matchPath(requested), false); + if (rule) { + return new Response(null, { + status: rule.status, + headers: { Location: rule.to, "Cache-Control": PAGE_CACHE }, + }); + } + + // The deploy's own 404 page. Without this the CDN answers a miss with bunny.net's page, whatever the site built. + if (rules.notFound !== null) { + return new Response(rules.notFound, { + status: 404, + headers: { + "Content-Type": "text/html; charset=utf-8", + // A miss the next deploy fixes must not outlive it. + "Cache-Control": "no-cache", + ...(preview === null ? {} : { "X-Robots-Tag": "noindex" }), + }, + }); } } + // Bunny Storage holds no headers, so \`_headers\` is where the deploy keeps them. The requested path and the object it resolved to are both matched, so a rule may be written either way. + const headers = new Headers(response.headers); + const paths = [matchPath(requested)]; + if (requested.endsWith("/")) { + paths.push(matchPath(requested + "index.html")); + } + for (const [name, value] of matchHeaders(rules.headers, paths)) { + headers.set(name, value); + } + + // The pull zone applies no expiry of its own to a sites zone, so a response carrying no directive would reach the visitor with none. + if (!headers.has("Cache-Control")) { + headers.set("Cache-Control", defaultCacheControl(requested)); + } + // Previews must never be indexed. - if (previewDeployId(clientHostname(ctx.request)) === null) return; - const headers = new Headers(ctx.response.headers); - headers.set("X-Robots-Tag", "noindex"); - return new Response(ctx.response.body, { - status: ctx.response.status, - statusText: ctx.response.statusText, + if (preview !== null) headers.set("X-Robots-Tag", "noindex"); + + return new Response(BODYLESS.includes(response.status) ? null : response.body, { + status: response.status, + statusText: response.statusText, headers, }); }); diff --git a/packages/cli/src/commands/sites/upgrade-router.ts b/packages/cli/src/commands/sites/upgrade-router.ts index 36c050a8..bc0b57b5 100644 --- a/packages/cli/src/commands/sites/upgrade-router.ts +++ b/packages/cli/src/commands/sites/upgrade-router.ts @@ -8,7 +8,7 @@ import { defineCommand } from "../../core/define-command.ts"; import { errorMessage } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; import { withSpinner } from "../../core/ui.ts"; -import { writeRemoteState } from "./api.ts"; +import { applySiteZoneSettings, writeRemoteState } from "./api.ts"; import { type SiteSelectorArgs, selectSite, @@ -53,6 +53,9 @@ export const sitesUpgradeRouterCommand = defineCommand({ params: { path: { id: state.scriptId, uuid: null } }, body: {}, }); + // The router decides what a response may be cached for, so the zone must + // stop overriding its answer. One change, applied together. + await applySiteZoneSettings({ coreClient, state }); }); // Record the published generation so deploy stops re-upgrading; best-effort (a missed write just republishes next deploy). diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index 889d9079..734593f4 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -42,6 +42,22 @@ This is the rule that shapes every other command here: Previews are root-served on their own host, not under a path prefix, so client-side routers (TanStack Router, React Router, Vue Router in history mode) and root-absolute assets behave exactly as they do in production. Preview responses carry `X-Robots-Tag: noindex`. Deploys are not otherwise addressable: `/deploys//` URLs are internal to the storage layout and are not publicly served. Preview URLs live as long as their deploy: `deployments prune` deletes old deploys together with their preview zones. +## What the deploy configures + +The router reads three file names out of the deploy it serves. Cloudflare Pages and Netlify read the same three, so a build that already writes them needs nothing bunny-specific. + +| File in the deploy | What it does | +| ------------------ | ----------------------------------------------------------------------------------------------------------------- | +| `404.html` | Answers a path the deploy does not hold, at status 404. Without it a miss gets bunny.net's error page | +| `_redirects` | `/from /to [status]` per line. `#` comments, a trailing `*` captured as `:splat`, `!` to beat a file at that path | +| `_headers` | A `/path` line, then indented `Name: value` lines | + +301 is the default status; 302, 303, 307 and 308 are read too. A rewrite (`200`) is not supported. A rule without `!` applies only where the deploy holds no file, so a real file always wins. A path matches with or without its trailing slash. + +The router also sets `Cache-Control` on every response, because Bunny Storage sends none for HTML: 60 seconds for a page, 30 days for anything else, and whatever `_headers` says. `sites create` turns the pull zone's own cache override off so that answer reaches the visitor, and `sites upgrade-router` does it for a site made by an earlier CLI. + +A published `deploy` asks the live site for a path it cannot hold, and reports it when the answer is not the deploy's own 404 page. + ## Deploy IDs - The deploy ID is the **git short-sha** when the working tree is clean, otherwise an 8-char **content hash**. Re-deploying identical content is a no-op (`--force` overrides). From 37c20988cb1b5d3da525d1e8b1c81622ba01eebd Mon Sep 17 00:00:00 2001 From: bogdan-at-bunny Date: Fri, 21 Aug 2026 15:19:08 +0000 Subject: [PATCH 09/10] Make `bunny sites deploy` the one deploy command `bunny deploy` and `bunny sites deploy` both deployed a site, and a developer had to know which. The project already says which shape it is, in the build manifest, so the choice was never the developer's to make. `bunny deploy` and `bunny rollback` are gone, and `bunny sites deploy` takes both paths. Neither command shipped, so nothing here is a migration. `deploy/` moves into `sites/`: the build manifest reader and the health probe sit beside the command that reads them, and everything a framework site alone needs is under `sites/framework/`. Detection is the part that needed a design. `bunny deploy` offered its adapter to any Astro project, which is wrong: an Astro project that prerenders every page is a directory of files, and `bunny sites deploy` has always deployed one. So `projectNeedsServer` names four signals, and only a project that shows one hears about an adapter: another vendor's adapter in the config, `output: "server"`, our adapter as a dependency, or a route under `src/pages/` with `prerender = false`. The route scan matters most. Since Astro 5 a project prerenders every page unless a page opts out, and `astro build` stops with its own error when a page opts out and no adapter is installed. Reading the routes puts the offer before that failure instead of after it. Three things the merge fixed on the way: - The build now runs before the site is resolved, so a failing build cannot leave an empty site behind. Only the framework path did this before. - A build and a site have to be the same kind. A script's type is fixed when the API creates it, so the deploy says which mismatch it found instead of uploading into it. - `deployments delete` and `prune` delete the deploy's server bundle with it. Every pruned framework deploy used to leave one behind, forever. The first-deploy domain offer was written twice, and one copy printed `bunny domains add`, which is not a command. It is one helper now. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NpdDkzyH7pPq5FZqPCRhzE --- .changeset/deploy-health-check.md | 3 +- .changeset/deploy-polish.md | 13 +- .changeset/framework-deploys.md | 49 ++- .changeset/router-static-layer.md | 4 +- AGENTS.md | 18 +- README.md | 7 +- packages/cli/README.md | 44 +-- packages/cli/src/cli.ts | 7 +- packages/cli/src/commands/deploy/index.ts | 264 --------------- packages/cli/src/commands/deploy/rollback.ts | 83 ----- packages/cli/src/commands/sites/api.test.ts | 18 + packages/cli/src/commands/sites/api.ts | 18 + .../build-manifest.test.ts} | 2 +- .../manifest.ts => sites/build-manifest.ts} | 4 +- packages/cli/src/commands/sites/build.ts | 4 +- .../cli/src/commands/sites/ci/frameworks.ts | 6 +- packages/cli/src/commands/sites/constants.ts | 5 +- packages/cli/src/commands/sites/deploy.ts | 317 ++++++++++++------ .../src/commands/sites/deployments/publish.ts | 2 +- .../cli/src/commands/sites/domains/index.ts | 65 ++++ .../framework}/adapter.test.ts | 0 .../{deploy => sites/framework}/adapter.ts | 35 +- .../{deploy => sites/framework}/api.test.ts | 6 +- .../{deploy => sites/framework}/api.ts | 28 +- .../framework/deploy.ts} | 295 ++++------------ .../commands/sites/framework/detect.test.ts | 137 ++++++++ .../src/commands/sites/framework/detect.ts | 132 ++++++++ .../framework}/project.test.ts | 0 .../{deploy => sites/framework}/project.ts | 10 +- .../commands/{deploy => sites}/health.test.ts | 0 .../src/commands/{deploy => sites}/health.ts | 0 packages/cli/src/commands/sites/provision.ts | 58 ++++ packages/config/src/build-manifest.ts | 2 +- packages/config/src/index.ts | 2 +- skills/bunny-cli/SKILL.md | 7 +- skills/bunny-cli/references/sites.md | 21 +- 36 files changed, 861 insertions(+), 805 deletions(-) delete mode 100644 packages/cli/src/commands/deploy/index.ts delete mode 100644 packages/cli/src/commands/deploy/rollback.ts rename packages/cli/src/commands/{deploy/manifest.test.ts => sites/build-manifest.test.ts} (99%) rename packages/cli/src/commands/{deploy/manifest.ts => sites/build-manifest.ts} (97%) rename packages/cli/src/commands/{deploy => sites/framework}/adapter.test.ts (100%) rename packages/cli/src/commands/{deploy => sites/framework}/adapter.ts (91%) rename packages/cli/src/commands/{deploy => sites/framework}/api.test.ts (97%) rename packages/cli/src/commands/{deploy => sites/framework}/api.ts (96%) rename packages/cli/src/commands/{deploy/framework.ts => sites/framework/deploy.ts} (64%) create mode 100644 packages/cli/src/commands/sites/framework/detect.test.ts create mode 100644 packages/cli/src/commands/sites/framework/detect.ts rename packages/cli/src/commands/{deploy => sites/framework}/project.test.ts (100%) rename packages/cli/src/commands/{deploy => sites/framework}/project.ts (93%) rename packages/cli/src/commands/{deploy => sites}/health.test.ts (100%) rename packages/cli/src/commands/{deploy => sites}/health.ts (100%) diff --git a/.changeset/deploy-health-check.md b/.changeset/deploy-health-check.md index 49990345..6a554ddf 100644 --- a/.changeset/deploy-health-check.md +++ b/.changeset/deploy-health-check.md @@ -2,7 +2,8 @@ "@bunny.net/cli": patch --- -`bunny deploy` asks the site for a page before it calls the deploy a success. +`bunny sites deploy` asks the site for a page before it calls the deploy a +success. A published Edge Script that will not start makes the edge answer 400 with an empty body, and the deploy said nothing: a green line, a URL, and a site that diff --git a/.changeset/deploy-polish.md b/.changeset/deploy-polish.md index 8d86cf67..69cba2b6 100644 --- a/.changeset/deploy-polish.md +++ b/.changeset/deploy-polish.md @@ -2,20 +2,15 @@ "@bunny.net/cli": patch --- -Three smaller things around a deploy. +Four smaller things around `bunny sites deploy`. -- `bunny sites deploy` no longer offers to run the build when `bunny deploy` - already ran it. A framework project whose build produced files rather than a - server was asked twice. -- `bunny deploy --name ` is honoured when that deploy creates a static - site. It was read only on the framework path, so the prompt asked anyway, and an +- `--name ` is honoured when the deploy creates the site. Without it an unattended run stopped with "No site specified and no linked site found." - `bunny sites deploy` takes `--name` too, which it never declared. +- `--region ` chooses the storage region for a site the deploy creates. + Only `sites create` could name one before. - The domain prompt after a first deploy refuses a value that is not a hostname, and says so. It used to send it, and the API's answer is `An error has occurred.` -- A failing build names the command to run again. It said - `bunny sites deploy --build` whichever command was running. - The upload counts bytes as well as files. `withastro/astro.build` sends 1.4 GB in 8828 files, and ten minutes of `4210/8828 files` says nothing about how much is left. diff --git a/.changeset/framework-deploys.md b/.changeset/framework-deploys.md index d1130fe1..04346b62 100644 --- a/.changeset/framework-deploys.md +++ b/.changeset/framework-deploys.md @@ -3,24 +3,37 @@ "@bunny.net/config": minor --- -`bunny deploy`: one command for a framework project or a directory of files. +`bunny sites deploy` now deploys a project that renders per request, as well as a +directory of files. -A build that writes `.bunny/build.json` renders per request in an Edge Script, -with its client files in Bunny Storage. `bunny deploy` provisions the storage -zone, the script, and the pull zone on the first run, uploads the build, sets -every variable from what it already knows, applies the pull zone settings the +One command, and the build decides which it is. A build that writes +`.bunny/build.json` and asks for a server renders per request in an Edge Script, +with its client files in Bunny Storage. `bunny sites deploy` provisions the +storage zone, the script, and the pull zone on the first run, uploads the build, +sets every variable from what it already knows, applies the pull zone settings the adapter asks for, and publishes. No password passes through the terminal. The manifest is the whole contract: `BuildManifestSchema` in `@bunny.net/config`. -The CLI knows no framework, so a new adapter needs no new CLI. A framework -project with no adapter installed or configured gets an offer to add one. +The CLI knows no framework, so a new adapter needs no new CLI. -That offer was measured against three real projects: `withastro/starlight`, +Only a project that asks for a server hears about an adapter. Four things count +as asking: the Astro config names another vendor's adapter, the config sets +`output: "server"`, `@bunny.net/astro-adapter` is already a dependency, or a +route under `src/pages/` sets `prerender = false`. A project that prerenders +every page is a directory of files, and it deploys exactly as it did before: no +adapter, no config edit, and no mention of either. + +The route scan is what puts the offer in the right place. Since Astro 5 a project +prerenders every page unless a page opts out, and `astro build` stops with its own +error when a page opts out and no adapter is installed. So the offer arrives +before that failure, not after it. + +The adapter offer was measured against three real projects: `withastro/starlight`, `withastro/astro.build` and `arthelokyo/astrowind`. What it learned: -- A monorepo root is not a project. `bunny deploy` at the root of a workspace - looks for the projects below it, and offers them. Starlight keeps `astro` in the - root `package.json` for `astro check`, and its site is `docs/`. +- A monorepo root is not a project. At the root of a workspace the command looks + for the projects below it, and offers them. Starlight keeps `astro` in the root + `package.json` for `astro check`, and its site is `docs/`. - The package manager comes from the nearest lockfile up the tree, not from the directory. `starlight/docs` has no lockfile, so it looked like npm, and `npm install` stopped on `workspace:*`. @@ -33,13 +46,17 @@ That offer was measured against three real projects: `withastro/starlight`, `output: "server"` on a project that never mentioned it turns every prerendered page into one that renders per request: on astro.build that took the script from 7.83 MB to 22.30 MB, past the 10 MB limit. -- The 10 MB check happens before a site is created. astro.build used to leave a - storage zone, a script and a pull zone behind on the way to that error. +- The build and the 10 MB check both run before any resource is created. + astro.build used to leave a storage zone, a script and a pull zone behind on the + way to that error. Each deploy keeps its client files at `deploys/{id}/` and its server bundle at `_bunny/deploys/{id}/server.js`, and the CLI writes the deploy's folder name into the top of the bundle at publish time. So a published release can only read the -files it was built with, and `bunny rollback` (or `sites deployments publish`) -restores a page and its assets together. +files it was built with, and `bunny sites deployments publish --previous` +restores a page and its assets together. Deleting or pruning a deploy now deletes +its server bundle with it. -Anything else still deploys as a static site, through `sites deploy`. +A build and a site have to be the same shape. A script's type is fixed when the +API creates it, so a static site cannot serve a server build, or the other way +round. The deploy says which mismatch it found, before it uploads anything. diff --git a/.changeset/router-static-layer.md b/.changeset/router-static-layer.md index 7901bcd3..a6de6e1c 100644 --- a/.changeset/router-static-layer.md +++ b/.changeset/router-static-layer.md @@ -34,5 +34,5 @@ else 30 days as before, and `_headers` wins where it says anything. `bunny sites upgrade-router` applies the router and the setting together, and `bunny sites deploy` does it for a site whose router lags. -`bunny deploy` and `bunny sites deploy` also ask the published site for a path it -cannot hold, and report when the answer is not the deploy's own 404 page. +`bunny sites deploy` also asks the published site for a path it cannot hold, and +reports when the answer is not the deploy's own 404 page. diff --git a/AGENTS.md b/AGENTS.md index 28437dfb..04aedbbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -386,7 +386,7 @@ bunny-cli/ │ │ │ ├── upload.ts # Upload a local file ( positional, --zone, --to, --checksum streams a SHA256, --content-type) │ │ │ ├── download.ts # Download a file to disk ( positional, --zone, --out) │ │ │ └── remove.ts # Delete a file or directory (alias: rm; positional, --zone, trailing slash = recursive) -│ │ ├── sites/ # Experimental (hidden from help and landing page) — static-site hosting (storage zone + pull zone + middleware router) +│ │ ├── sites/ # Experimental (hidden from help and landing page) — site hosting (storage zone + pull zone + Edge Script): a static site's script is the CLI's router, a framework site's is the build's own server │ │ │ ├── index.ts # defineNamespace("sites", false, ...): create/list/show/deploy/deployments/domains/link/unlink/upgrade-router/delete; describe:false keeps it out of help while it stabilizes │ │ │ ├── constants.ts # SITES_MANIFEST (.bunny/site.json), REMOTE_STATE_PATH (_bunny/site.json), RemoteSiteState/DeployRecord types (DeployRecord carries previewZoneId/previewHost; state carries routerVersion), parseRemoteState (shape-checked; null = not a site), deployPrefix, deploy-ID + site-name validators (3-47 chars; `dpl-` names reserved so a site can't collide with preview-zone names), suffixedResourceName/siteResourcePattern (zone names are `sites-{name}-{random 6}`: the prefix marks them in the dashboard, the suffix dodges the global zone namespace), previewZoneName/deployIdFromPreviewZoneName/isPreviewZoneName (`sites-dpl-{deployId}-{rand6}` preview zones: no dashes in deploy ids keeps parsing unambiguous, and the worst case fits the 63-char DNS label limit) │ │ │ ├── constants.test.ts # parseRemoteState round-trip/rejection + helper tests @@ -402,16 +402,21 @@ bunny-cli/ │ │ │ ├── uploader.test.ts # Walk/skip/hash tests + upload paths/checksums/retry via siteFiles swap │ │ │ ├── build.ts # resolveAutoBuild (framework preset or package.json build script, via ci/frameworks detection) + runBuildCommand (Bun.spawn shell, caller env + overrides, throws on non-zero exit) │ │ │ ├── build.test.ts # Env parsing + real build spawn success/failure +│ │ │ ├── build-manifest.ts # loadBuildManifest: finds `.bunny/build.json` by walking up, validates it against @bunny.net/config BuildManifestSchema (a manifest that exists and does not parse is an error: deploying half a site is worse than stopping), refuses a manifestVersion this CLI cannot read and a `requires.cliVersion` floor above VERSION (only the `>=x.y.z` form is honoured; an adapter's opinion about a future CLI must not stop a deploy), then resolveScriptEntry/resolveAssetsDir check that what it names is there +│ │ │ ├── build-manifest.test.ts # Version floors, malformed manifests, path resolution +│ │ │ ├── health.ts # What a fresh deploy is asked before the command calls it a success: findDeployFault (the home page, three tries, each with its own query so the CDN cannot hold the answer; a 400 or 5xx that does not go away is a script that will not start) and findMissingPageFault + readNotFoundPage (a path the deploy cannot hold has to answer with the deploy's own 404 page; the probe is a path, not a query string, because a sites zone ignores query strings). Both run on the framework path, and the 404 one on the static path too +│ │ │ ├── health.test.ts # Fault/no-fault statuses, retries, unreachable sites │ │ │ ├── create.ts # bunny sites create [name] (falls back to `sites.name` in bunny.jsonc, else prompted with a directory-name suggestion): createSite (storage + router + pull zone; forces HTTPS on the system host, best-effort) + manifest link + custom production domain via setupSiteDomain (--domain flag, offered interactively when omitted; domain failure warns, never fails the create) │ │ │ ├── list.ts # List sites (name, URL, deploy count, current) via fetchSites │ │ │ ├── show.ts # Site details + hostname table (SSL cert + Force SSL columns); a failed hostname fetch hides the table, never the site │ │ │ ├── open.ts # bunny sites open [site]: open the live URL (recorded custom domain when the zone still serves it, else system host) in the browser; --print emits it, siteLiveUrl is the pure resolver │ │ │ ├── ssl.ts # bunny sites ssl [site]: toggle Force HTTPS on the site's b-cdn.net system host via setForceSsl (no cert issued; --no-force-ssl allows HTTP); custom domains use `sites domains ssl` -│ │ │ ├── deploy.ts # bunny sites deploy [dir]: resolve site (picker offers to create a new site when none is linked) → build (--build resolves flag command → `sites.build` → detected build, failing before any site is created; no --build offers a detected/configured build interactively; without a dir arg, --build deploys the detected framework's output dir) → router upgrade check (ensureRouterCurrent; a failed republish skips preview creation so a stale router never serves production on a preview URL) → hash → no-op if unchanged → upload deploys/{id}/ → ensurePreviewZone (every deploy gets its own `sites-dpl-{id}-{rand6}.b-cdn.net` preview zone, recorded on the DeployRecord; no-op and skip-upload runs backfill a missing one so re-runs converge) → state write → publish when --production → a published deploy is asked for a path it cannot hold, and the answer has to be the deploy's own 404 page (findMissingPageFault in deploy/health.ts; a pull zone with no error page of its own answers a miss with bunny.net's, which shipped once on a documentation site). Publishing is always explicit: --production/--prod, or the interactive offer when the site has no production deploy yet (a CI preview run on a fresh site must never go live). A domainless site's first-ever deploy also offers a custom production domain (setupSiteDomain, interactive text runs only); later domainless deploys print a dim `sites domains add` hint +│ │ │ ├── deploy.ts # bunny sites deploy [dir]: THE deploy command, for both kinds of site. loadBuildManifest (.bunny/build.json, walked up) → enterProject when a workspace root holds no project of its own → projectNeedsServer + offerAdapter when there is no manifest and no dir (only a project that asks for a server is offered one; a fully prerendered project is a directory of files and hears nothing about adapters) → build (--build resolves flag command → `sites.build` → detected build; no --build offers the detected/configured one interactively; the build runs BEFORE the site is resolved, so a failing build cannot leave an empty site behind) → re-read the manifest the build wrote → readServerBundle for a `kind: "ssr"` manifest (the 10 MB refusal also lands before any resource exists) → selectSite (picker offers to create; offerCreate branches on the kind, createLinkedFrameworkSite vs createLinkedSite) → the kind check: isFrameworkSite(state) must match the build, because a script's type is fixed at create and neither script can serve the other's deploy → framework/deploy.ts, or the static path: router upgrade check (ensureRouterCurrent; a failed republish skips preview creation so a stale router never serves production on a preview URL) → hash → no-op if unchanged → upload deploys/{id}/ → ensurePreviewZone (every deploy gets its own `sites-dpl-{id}-{rand6}.b-cdn.net` preview zone, recorded on the DeployRecord; no-op and skip-upload runs backfill a missing one so re-runs converge) → state write → publish when --production → a published deploy is asked for a path it cannot hold, and the answer has to be the deploy's own 404 page (findMissingPageFault in sites/health.ts; a pull zone with no error page of its own answers a miss with bunny.net's, which shipped once on a documentation site). Publishing a static deploy is always explicit: --production/--prod, or the interactive offer when the site has no production deploy yet (a CI preview run on a fresh site must never go live); a framework deploy always publishes, because one script serves one release, and it says so rather than doing it in silence. Both paths end in offerFirstDomain │ │ │ ├── link.ts # Link directory to a site (.bunny/site.json) │ │ │ ├── unlink.ts # Remove .bunny/site.json │ │ │ ├── upgrade-router.ts # Republish the site's router script with the CLI's current source and record ROUTER_VERSION in state (deploy also auto-republishes stale routers) │ │ │ ├── delete.ts # Delete a site (typed-name confirm; --keep-storage; drops .bunny/site.json if it pointed here) +│ │ │ ├── framework/ # A site whose script is the build's own server. detect.ts (projectNeedsServer: the four signals that mean a project renders on demand — another vendor's adapter in the config, `output: "server"`, our adapter as a dependency, or a route under src/pages/ with `prerender = false`; only routes are read, because that is where `prerender` applies, and the route scan is what puts the adapter offer BEFORE the `astro build` error it prevents), adapter.ts (offerAdapter: install + patchAstroConfig, which replaces a known vendor adapter and refuses any config it cannot edit safely, and never touches `output` — setting `output: "server"` on astro.build took the script from 7.83 MB to 22.30 MB), project.ts (enterProject: a monorepo root is not a project; finds and offers the Astro projects below it), api.ts (createFrameworkSite: storage zone + standalone Edge Script + its linked pull zone, each looked up by name first so a half-finished create re-runs; publishDeploy prepends the `globalThis.__BUNNY_DEPLOY__` preamble at publish time and purges twice around a settle wait, because a probe cannot tell the outgoing release from the incoming one; applyPullZoneSettings and applyScriptEnv apply only what the manifest asks for and report every change; a secret is written once, when the name is absent, so a rotated password stays), deploy.ts (deployFramework and republishDeploy: upload deploys/{id}/, keep the bundle at _bunny/deploys/{id}/server.js, publish, then ask the live site for a page) + tests │ │ │ ├── ci/ # frameworks.ts (preset table of ~30 frameworks across js/ruby/hugo/python/zola/dotnet toolchains + detection: package.json deps/Gemfile/python+zola config files + lockfile pm), workflow.ts (renderSitesWorkflow -> .github/workflows/bunny-sites.yml using BunnyWay/actions/deploy-site; optional dir/build override the preset, workingDirectory/cacheDependencyPath place a project that sits below the workflow root (`defaults.run.working-directory` covers every run step, `uses` inputs take the prefix via workflowPath instead), installDeps adds the JS setup/install steps to a configured build the static preset wouldn't have installed for, and a configured build command is always a quoted scalar so YAML can't retype it), scaffold.ts (git helpers, projectPrefix (bunny.jsonc directory relative to the git root, realpath-resolved; undefined when it escapes the root, which drops its paths with a warning), framework/package-manager detection runs in that project directory, scaffoldSitesWorkflow -> ScaffoldResult.dir is the effective root-relative deploy dir, printWorkflowInstructions, offerGitHubSecret via gh), init.ts (bunny sites ci init) + tests │ │ │ ├── deployments/ # list (● Live/○ Previous), publish [id]|--previous (alias promote; confirm + promote + current/previous swap), prune --keep N (resolveKeepCount validates the count first; pruneVictims never drops current/previous; each pruned deploy's preview zone is deleted first, discovered via findPreviewZones for records whose zone create raced a failed state write; a failed zone deletion keeps the record and files so the next prune retries) + prune.test.ts, delete [id] (single-deploy cleanup for CI, e.g. a closed PR's preview: deleteBlocker refuses current/previous with --force only skipping the confirmation, revalidated on freshly re-read state inside the destructive phase since PR cleanup can race the production publish of the same sha; an already-gone id is a no-op success so re-runs converge; a failed zone listing always aborts because a forgotten record would never be retried, unlike prune) + delete.test.ts │ │ │ └── domains/index.ts # Mounts core/hostnames createHostnamesCommands as "sites domains" with onAdded/onRemoved hooks: the first added domain is recorded as state.domain (display-only production URL; previews run on their own b-cdn.net zones and never depend on it; recordSiteDomain rolls back the in-memory value if the state write fails), and an add on a site with nothing published hints at `deploy --production` (the domain serves the router's 404 until then); remove clears state.domain. setupSiteDomain (create --domain + deploy's first-run offer) records the domain only once the hostname is verifiably on the zone @@ -988,9 +993,6 @@ bunny ├── login [--force] [--install-skill] Authenticate via browser; --install-skill/--no-install-skill decides the agent-skill offer without prompting ├── logout [--force] Remove stored authentication profile ├── whoami Show authenticated account (name, email, account id, profile) -├── deploy [dir] [--build [cmd]] [--env K=V] [--env-file] [--name] [--region] [--prod] [--preview] [--force] [--open] [--site] [--link] -│ Build and deploy this project. Reads `.bunny/build.json`, the build manifest a framework adapter writes (@bunny.net/config `BuildManifestSchema`): `kind: "ssr"` deploys as a framework site (see `sites`), anything else falls through to `sites deploy` with the manifest's asset dir. A framework project with no adapter installed or configured gets an offer to add one (detection carries the adapter package per preset; the Astro config edit is `patchAstroConfig`, which refuses any config it cannot edit safely). The build runs before any resource is created, so a failing build leaves no orphan site. `deploy/health.ts` is what a fresh deploy is asked before the command calls it a success: findDeployFault (the home page, three tries, a 400 or 5xx that does not go away is a script that will not start) and findMissingPageFault (a path the deploy cannot hold has to answer with the deploy's own 404 page; the probe is a path, not a query string, because a sites zone ignores query strings). Both run on the framework path, and the 404 one on the static path too. `--preview` errors: preview environments are designed but not built -├── rollback [id] [--force] Publish an earlier deploy of the linked framework site (default: the previous one). Reads that deploy's stored bundle back out of storage, so the pages and the files they name are restored together ├── config │ ├── init [--api-key] Initialize config (create default profile) │ ├── show Display resolved configuration @@ -1151,8 +1153,8 @@ bunny │ ├── list (alias: ls) List sites (middleware+storage pull zones with matching remote state) │ ├── show [site] [--link] Show resources, domains (with SSL + Force SSL state), current deploy; warns when a newer router is available │ ├── open [site] [--print] Open the live URL (recorded custom domain when live, else system host) in the browser; --print emits it -│ ├── deploy [dir] [--site] [--link] [--build [cmd]] [--env K=V] [--env-file] [--production/--prod] [--force] -│ │ Deploy a directory: git short-sha ID when the tree is clean, content hash otherwise; identical IDs are no-ops (an already-uploaded ID skips the upload and just publishes). Every deploy gets an immutable `sites-dpl-{id}-{rand6}.b-cdn.net` preview URL; --production/--prod publishes it as the live site (a fresh site's interactive first deploy offers this, since nothing is live yet). The target site resolves via selectSite (--site → linked → bunny.jsonc → picker); when nothing is linked, the interactive picker offers to create a new site (or, with no sites yet, goes straight to create) and links it. A [dir] arg is cwd-relative; without it the target is `sites.dir` (or the detected output dir), resolved against the bunny.jsonc directory where the build runs, else that directory (dotfiles + node_modules excluded). --build runs the command (or `sites.build`, else the detected build; resolved before any site is created so a missing command can't leave an orphan site) in the caller's environment plus --env/--env-file overrides. Without --build, an interactive run offers to run the configured `sites.build`, else a detected build (the CI framework preset's command, else a package.json `build` script); confirming builds first and, when no dir was given, deploys the framework's output dir. +│ ├── deploy [dir] [--site] [--link] [--build [cmd]] [--env K=V] [--env-file] [--name] [--region] [--production/--prod] [--force] +│ │ Build and deploy this project, whichever shape it is. The build decides: a `.bunny/build.json` with `kind: "ssr"` deploys an Edge Script plus its client files, anything else is a directory of files. A project that asks for a server and has no adapter gets an offer to add one (four signals; see sites/framework/detect.ts), and a fully prerendered project never hears about one. The build and the 10 MB script check both run before any resource is created, so a failing build leaves no empty site behind. A build and a site must be the same kind: a script's type is fixed at create, so the deploy refuses the mismatch rather than uploading into it. Static: git short-sha ID when the tree is clean, content hash otherwise; identical IDs are no-ops (an already-uploaded ID skips the upload and just publishes). Every static deploy gets an immutable `sites-dpl-{id}-{rand6}.b-cdn.net` preview URL; --production/--prod publishes it as the live site (a fresh site's interactive first deploy offers this, since nothing is live yet). A framework deploy publishes to production and says so: one script serves one release, so it has no preview URL. The target site resolves via selectSite (--site → linked → bunny.jsonc → picker); when nothing is linked, the interactive picker offers to create a new site of the right kind (or, with no sites yet, goes straight to create) and links it, and --name (with --region) creates one unattended. A [dir] arg is cwd-relative; without it the target is `sites.dir`, then the build manifest's asset dir, then the detected output dir, resolved against the bunny.jsonc directory where the build ran, else that directory (dotfiles + node_modules excluded). --build runs the command (or `sites.build`, else the detected build) in the caller's environment plus --env/--env-file overrides. Without --build, an interactive run offers the configured or detected build first. │ ├── deployments │ │ ├── list [site] [--link] (alias: ls) List deploys (● Live / ○ Previous markers, created, source, files, size) │ │ ├── publish [id] [--previous] [--site] [--link] [--force] (alias: promote) @@ -1550,7 +1552,7 @@ bunny db shell seed.sql ## Conventions for Adding New Commands -1. Create a new directory under `packages/cli/src/commands/` for the domain (e.g., `packages/cli/src/commands/deploy/`). +1. Create a new directory under `packages/cli/src/commands/` for the domain (e.g., `packages/cli/src/commands/queues/`). 2. Create `index.ts` using `defineCommand()` for leaf commands or `defineNamespace()` for groups. 3. Use `builder` to define command-specific flags. Use positionals for required arguments (`command: "create "`). 4. **Add flag equivalents for every interactive prompt** so the command is fully scriptable (see "Agent & Scripting Compatibility"). diff --git a/README.md b/README.md index 34ce9b3c..487b3507 100644 --- a/README.md +++ b/README.md @@ -59,11 +59,8 @@ bun ny dns records scan example.com # scan for the domain's existing rec bun ny dns records preset list # list DNS record presets (email providers, verification, security) bun ny dns records preset google-workspace example.com # apply a preset record set bun ny dns records preset bluesky example.com --param did=did:plc:abc123 # apply a preset non-interactively -bun ny deploy # build and deploy this project: a framework build renders on Edge Scripting, anything else deploys as static files -bun ny deploy --build # run the project's build first -bun ny rollback # put the previous deploy back on production bun ny sites create my-site # provision a static site (storage zone + pull zone + edge router; zones are named sites-my-site-, served at sites-my-site-.b-cdn.net) -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 publish + attach a custom domain) +bun ny sites deploy # build and deploy this project: a build that renders per request goes to an Edge Script, anything else deploys as files (no linked site? offers to create one or pick an existing; a site's first deploy offers to publish + attach a custom domain) bun ny sites deploy ./dist # deploy to an immutable HTTPS preview URL (sites-dpl--xxxxxx.b-cdn.net); no custom domain needed bun ny sites deploy ./dist --production # publish as the live site (--prod works too) 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) @@ -77,7 +74,7 @@ bun ny sites open # open the site's live URL in the br bun ny sites ci init # add a GitHub Actions workflow (previews on PRs, production on main) ``` -`bunny deploy` picks its path from the project. A build that writes `.bunny/build.json` (a [framework adapter](https://github.com/BunnyWay/bunny-adapters) does) renders per request in an Edge Script, with its client files in Bunny Storage; everything else is a directory of files, which is `bunny sites deploy`. The CLI knows no framework: it reads the manifest, so a new adapter needs no new CLI. A framework project with no adapter yet gets an offer to install one. +`bunny sites deploy` picks its path from the build. A build that writes `.bunny/build.json` (a [framework adapter](https://github.com/BunnyWay/bunny-adapters) does) and asks for a server renders per request in an Edge Script, with its client files in Bunny Storage; everything else is a directory of files. The CLI knows no framework: it reads the manifest, so a new adapter needs no new CLI. A project that asks for a server and has no adapter yet gets an offer to install one, and a project that prerenders every page never hears about one. Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`) so a deploy needs no flags: `bun ny sites deploy --build --prod`. `bun ny sites ci init` writes the same `build` and `dir` into the generated workflow. See [`examples/sites/`](examples/sites/) for ready-to-copy configs (Vite, Astro, Next.js static export, Hugo, plain HTML, and a combined app + site file). diff --git a/packages/cli/README.md b/packages/cli/README.md index 4fca7cd8..3e47fd4a 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -908,7 +908,11 @@ bunny scripts docs > **Experimental**: hidden from `--help` and the landing page while it stabilizes. -Host static sites on bunny.net. Each site is three resources provisioned and wired together for you: a **storage zone** holding the files, a **pull zone** serving them over the CDN, and a **middleware router** (an Edge Script) that maps incoming requests to the deploy that should answer them. Zones are named `sites--` (the prefix groups them in the dashboard; the suffix is because zone names are global across bunny.net) while commands take the clean site name. +Host sites on bunny.net. Each site is three resources provisioned and wired together for you: a **storage zone** holding the files, a **pull zone** serving them over the CDN, and an **Edge Script**. Zones are named `sites--` (the prefix groups them in the dashboard; the suffix is because zone names are global across bunny.net) while commands take the clean site name. + +The script is what the two kinds of site differ by. A **static** site gets the middleware router this CLI generates, which maps an incoming request to the deploy that should answer it. A **framework** site's script is the build's own server, described by the `.bunny/build.json` a [framework adapter](https://github.com/BunnyWay/bunny-adapters) writes; its client files still live in the storage zone, and each deploy's bundle is kept at `_bunny/deploys//server.js` so publishing an earlier deploy restores its pages and its assets together. `sites deploy` reads the manifest and takes the path it names, so the CLI knows no framework and a new adapter needs no new CLI. + +A project that asks for a server and has no bunny.net adapter yet gets an offer to install one. Four things count as asking: the framework's config names another vendor's adapter, the config sets `output: "server"`, the adapter is already a dependency, or a route under `src/pages/` sets `prerender = false`. A project that prerenders every page is a directory of files and never hears about an adapter. The build, and the 10 MB script check, both run before any resource is created, so a failing build leaves no empty site behind. Deploys are immutable: every `sites deploy` uploads to its own `deploys//` directory and gets its own preview pull zone, a permanent root-served HTTPS URL (`sites-dpl--.b-cdn.net`) that needs no DNS or certificate setup. Publishing flips the router's `CURRENT_DEPLOY` variable and purges the cache, so going live and rolling back are instant and move no files. Deploy IDs are the git short SHA when the working tree is clean and a content hash otherwise, which makes redeploying identical content a no-op. @@ -922,12 +926,13 @@ bunny sites create my-site --region NY # store the files in New Y bunny sites create my-site --domain example.com # also attach a custom production domain # Deploy -bunny sites deploy # detects the framework, offers to build, then deploys +bunny sites deploy # build if needed, then deploy: an Edge Script for a build that renders per request, files otherwise bunny sites deploy ./dist # deploy a directory to an immutable HTTPS preview URL bunny sites deploy ./dist --production # deploy and publish as the live site (--prod works too; the interactive first deploy offers this) bunny sites deploy --build # run `sites.build` from bunny.jsonc (else the detected build), then deploy bunny sites deploy --build "npm run build" --env API_URL=https://api.example.com bunny sites deploy ./dist --site my-site --force # target a site explicitly; redeploy unchanged content +bunny sites deploy --name my-site --region NY # create the site this deploy needs, unattended # Deploys: list, publish (roll back), prune bunny sites deployments list # ● Live / ○ Previous markers, created, source, files, size @@ -970,26 +975,27 @@ The router serves the deploy's own configuration, from three file names Cloudfla A rule needs no status, and 301 is the default; 302, 303, 307 and 308 are read too. A rewrite (`200`) is not: it would have the router fetch another path of its own site, which can be made to loop. A rule without `!` applies only when the deploy holds no file at that path, so a real file always wins. Both files are read once per deploy and held in memory, and `bunny sites deploy` asks the live site for a path it cannot hold, so a 404 page that never reaches a visitor is reported rather than shipped. -The router also sets `Cache-Control` on every response, because Bunny Storage sends none for HTML: 60 seconds for a page, 30 days for anything else, and whatever `_headers` says where it says anything. So `sites create` turns the pull zone's own cache override off, which is what lets the router's answer through. `sites upgrade-router` applies both to a site made by an earlier CLI. +The router also sets `Cache-Control` on every response, because Bunny Storage sends none for HTML: 60 seconds for a page, 30 days for anything else, and whatever `_headers` says where it says anything. So `sites create` turns the pull zone's own cache override off, which is what lets the router's answer through. `sites upgrade-router` applies both to a site made by an earlier CLI. None of this reaches a framework site: its script is the build's own server, and the build decides what it answers with. A deploy's preview URL is `https://sites-dpl--.b-cdn.net`: its own pull zone pointed at the same storage zone with the same router attached, so previews are root-served (client-side routing and absolute asset paths behave exactly like production) and HTTPS works immediately under bunny's own certificate. Previews never publish anything; only `--production` (or `deployments publish`) changes the live site. Site state lives at `_bunny/site.json` inside the storage zone (the router blocks it with a 403); `.bunny/site.json` is only a local pointer, so a fresh clone can `sites link` and pick up where the last machine left off. -| Flag | Commands | Description | -| -------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| `--region`, `--domain` | `create` | Main storage region code (default `DE`); custom production domain to attach | -| `--site` | `deploy`, `ci init`, `deployments publish` | Site name or storage zone ID (defaults to the linked site) | -| `--build [cmd]`, `--env`, `--env-file` | `deploy` | Build before deploying (bare flag uses the configured or detected build); build-time env overrides | -| `--production`, `--prod` | `deploy` | Publish the deploy as the live site instead of a preview only | -| `--force` | `deploy` | Deploy even when the content is unchanged | -| `--previous` | `deployments publish` | Publish the previous deploy (instant rollback) | -| `--keep` | `deployments prune` | Number of recent deploys to keep (default 5; live and previous are always kept) | -| `--ssl`, `--wait`, `--force-ssl` | `domains add` | Issue SSL now; wait up to 10 minutes for DNS then issue it; `--no-force-ssl` keeps HTTP working | -| `--force-ssl` | `ssl` | Force HTTP→HTTPS on the system host; `--no-force-ssl` allows plain HTTP | -| `--framework` | `ci init` | Framework preset for the workflow's build steps (default: detected) | -| `--print` | `open` | Print the URL instead of opening a browser | -| `--link` | `create`, `deploy`, `show`, `ci init`, `deployments` | Link the directory to the site; `--no-link` never links | -| `--keep-storage` | `delete` | Delete the pull zone and router but keep the storage zone and its deploy files | -| `--force`, `-f` | `deployments publish`, `prune`, `domains remove`, `delete` | Skip the confirmation prompts | +| Flag | Commands | Description | +| -------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--region`, `--domain` | `create` | Main storage region code (default `DE`); custom production domain to attach | +| `--name`, `--region` | `deploy` | Site name and storage region, for a site this deploy creates | +| `--site` | `deploy`, `ci init`, `deployments publish` | Site name or storage zone ID (defaults to the linked site) | +| `--build [cmd]`, `--env`, `--env-file` | `deploy` | Build before deploying (bare flag uses the configured or detected build); build-time env overrides | +| `--production`, `--prod` | `deploy` | Publish the deploy as the live site instead of a preview only. A build that renders per request always publishes: one script serves one release, so it has no preview URL | +| `--force` | `deploy` | Deploy even when the content is unchanged | +| `--previous` | `deployments publish` | Publish the previous deploy (instant rollback) | +| `--keep` | `deployments prune` | Number of recent deploys to keep (default 5; live and previous are always kept) | +| `--ssl`, `--wait`, `--force-ssl` | `domains add` | Issue SSL now; wait up to 10 minutes for DNS then issue it; `--no-force-ssl` keeps HTTP working | +| `--force-ssl` | `ssl` | Force HTTP→HTTPS on the system host; `--no-force-ssl` allows plain HTTP | +| `--framework` | `ci init` | Framework preset for the workflow's build steps (default: detected) | +| `--print` | `open` | Print the URL instead of opening a browser | +| `--link` | `create`, `deploy`, `show`, `ci init`, `deployments` | Link the directory to the site; `--no-link` never links | +| `--keep-storage` | `delete` | Delete the pull zone and router but keep the storage zone and its deploy files | +| `--force`, `-f` | `deployments publish`, `prune`, `domains remove`, `delete` | Skip the confirmation prompts | ### `bunny sandbox` diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f1e28a2d..2d348d23 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -8,8 +8,6 @@ import { authLoginCommand } from "./commands/auth/login.ts"; import { authLogoutCommand } from "./commands/auth/logout.ts"; import { configNamespace } from "./commands/config/index.ts"; import { dbNamespace } from "./commands/db/index.ts"; -import { deployCommand } from "./commands/deploy/index.ts"; -import { rollbackCommand } from "./commands/deploy/rollback.ts"; import { dnsNamespace } from "./commands/dns/index.ts"; import { docsCommand } from "./commands/docs.ts"; import { openCommand } from "./commands/open.ts"; @@ -29,8 +27,6 @@ const commands: CommandModule[] = [ authLoginCommand, authLogoutCommand, whoamiCommand, - deployCommand, - rollbackCommand, dbNamespace, dnsNamespace, scriptsNamespace, @@ -147,8 +143,7 @@ export const cli = instance ["Create an edge script", "bunny scripts init"], ["Add a domain to manage DNS", "bunny dns zones add example.com"], ["Create a dev sandbox", "bunny sandbox create my-sandbox"], - ["Deploy this project", "bunny deploy"], - // ["Deploy a static site", "bunny sites deploy"], + // ["Deploy this project", "bunny sites deploy"], // ["Deploy an app", "bunny apps deploy"], ]; diff --git a/packages/cli/src/commands/deploy/index.ts b/packages/cli/src/commands/deploy/index.ts deleted file mode 100644 index 54be76e7..00000000 --- a/packages/cli/src/commands/deploy/index.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { - createComputeClient, - createCoreClient, -} from "@bunny.net/openapi-client"; -import { resolveConfig } from "../../config/index.ts"; -import { clientOptions } from "../../core/client-options.ts"; -import { defineCommand } from "../../core/define-command.ts"; -import { collectEnv } from "../../core/env.ts"; -import { UserError } from "../../core/errors.ts"; -import { logger } from "../../core/logger.ts"; -import { confirm, isInteractive, openBrowser } from "../../core/ui.ts"; -import { - resolveAutoBuild, - resolveRequestedBuild, - runBuildCommand, -} from "../sites/build.ts"; -import { - detectFramework, - type FrameworkPreset, -} from "../sites/ci/frameworks.ts"; -import { loadSiteConfig } from "../sites/config.ts"; -import { sitesDeployCommand } from "../sites/deploy.ts"; -import { offerAdapter } from "./adapter.ts"; -import { deployFramework } from "./framework.ts"; -import { loadBuildManifest } from "./manifest.ts"; -import { enterProject } from "./project.ts"; - -interface DeployArgs { - dir?: string; - build?: string; - env?: string[]; - "env-file"?: string; - name?: string; - region?: string; - production?: boolean; - preview?: string; - force?: boolean; - open?: boolean; - site?: string; - link?: boolean; -} - -/** - * Deploy this project to bunny.net. - * - * One command for both shapes of site. A project whose build writes - * `.bunny/build.json` renders per request in an Edge Script, and its files come - * from Bunny Storage. Anything else is a directory of files, which is what - * `bunny sites deploy` has always done. - * - * The CLI knows no framework. It reads the manifest the adapter wrote, so a new - * adapter needs no new CLI. - */ -export const deployCommand = defineCommand({ - command: "deploy [dir]", - describe: "Build and deploy this project.", - examples: [ - ["$0 deploy", "Build if needed, then deploy"], - ["$0 deploy --build", "Run the project's build first"], - ["$0 deploy --force", "Deploy even when nothing changed"], - ["$0 deploy ./dist", "Deploy a directory of static files"], - ], - - builder: (yargs) => - yargs - .positional("dir", { - type: "string", - describe: - "Directory of static files to deploy. Ignored for a framework build, which the manifest describes", - }) - .option("build", { - type: "string", - describe: - "Run a build first. Pass a command, or use the bare flag for the project's own build", - }) - .option("env", { - type: "string", - array: true, - describe: "Build-time env override (KEY=VALUE, repeatable)", - }) - .option("env-file", { - type: "string", - describe: "Read build-time env overrides from a dotenv-style file", - }) - .option("name", { - type: "string", - describe: "Site name, for the first deploy from this directory", - }) - .option("region", { - type: "string", - describe: "Storage region for a new site (default: DE)", - }) - .option("production", { - alias: "prod", - type: "boolean", - describe: - "Publish as the live site. A framework build always publishes; a static one needs this", - }) - .option("preview", { - type: "string", - describe: "Deploy to a named preview environment", - }) - .option("force", { - type: "boolean", - default: false, - describe: "Deploy even when nothing changed", - }) - .option("open", { - type: "boolean", - default: false, - describe: "Open the site when the deploy finishes", - }) - .option("site", { type: "string", describe: "Target a specific site" }) - .option("link", { - type: "boolean", - describe: "Link this directory to the site it deploys to", - }), - - handler: async (args) => { - const { profile, output, verbose, apiKey } = args; - - if (args.preview !== undefined) { - throw new UserError( - "Preview environments are not built yet.", - "A framework deploy publishes to production. Track the design in BunnyWay/bunny-adapters, plans/one-command-deploys.md.", - ); - } - - if (args.build === undefined && (args.env?.length || args["env-file"])) { - throw new UserError( - "--env/--env-file only apply to builds.", - "Add --build to run the build with these variables.", - ); - } - - let siteConfig = loadSiteConfig(); - let configRoot = siteConfig?.root ?? process.cwd(); - - // A project with no manifest may only need its adapter, or its build. - let loaded = await loadBuildManifest(); - - // A workspace root is not a project. When the framework's project is a - // directory below this one, the whole deploy moves there. - if (!loaded && args.dir === undefined) { - const detected = await detectFramework(configRoot); - if (detected?.adapter && (await enterProject(configRoot, output))) { - siteConfig = loadSiteConfig(); - configRoot = siteConfig?.root ?? process.cwd(); - loaded = await loadBuildManifest(); - } - } - - /** True once a build has run here, so a missing manifest is not a missing build. */ - let built = false; - let framework: FrameworkPreset | undefined; - if (!loaded && args.dir === undefined) { - const offer = await offerAdapter(configRoot, output); - framework = offer.preset; - if (offer.ready && args.build === undefined) { - // The adapter is in place, so a build is what produces the manifest. - args.build = ""; - } - } - - // Build before anything is created: a failing build must not leave a site behind. - if (args.build !== undefined) { - const requested = await resolveRequestedBuild( - args.build, - siteConfig?.config.build, - configRoot, - ); - if (requested.label) logger.info(`Detected ${requested.label}.`); - const overrides = await collectEnv(args.env, args["env-file"]); - await runBuildCommand( - requested.command, - configRoot, - overrides, - "bunny deploy", - ); - built = true; - loaded = await loadBuildManifest(); - } else if (!loaded && isInteractive(output) && args.dir === undefined) { - // No manifest and no --build: offer the project's own build, as the - // static path does. - const auto = siteConfig?.config.build - ? { command: siteConfig.config.build, label: "the configured build" } - : await resolveAutoBuild(configRoot); - if ( - auto && - (await confirm(`Run \`${auto.command}\` before deploying?`, { - initial: true, - })) - ) { - await runBuildCommand(auto.command, configRoot, {}, "bunny deploy"); - built = true; - loaded = await loadBuildManifest(); - } - } - - // A framework project with no build has nothing to deploy. Falling through - // here would upload the directory as it stands, which for an unbuilt project - // means its source: `src/`, `package.json`, and the site nowhere in sight. - if (!loaded && !built && framework?.adapter && args.dir === undefined) { - throw new UserError( - `This is a ${framework.label} project, and there is no build to deploy.`, - [ - "Build it first, and deploy what the build wrote:", - "", - " bunny deploy --build", - "", - `Or deploy a directory of files as they are: bunny deploy ${framework.dir}`, - ].join("\n"), - ); - } - - // A static build deploys as a directory of files, whatever wrote it. - if (!loaded || loaded.manifest.kind !== "ssr") { - if (loaded) { - logger.info( - `${loaded.manifest.adapter.package} built a static site; deploying ${loaded.manifest.assets.dir}.`, - ); - } - // `sites deploy` owns static sites, and already has previews, promote, - // and rollback for them. Don't build twice. - await sitesDeployCommand.handler({ - ...args, - // With no manifest, the framework's own output directory is the answer. - // Leaving it unset would deploy the directory the build ran in, which is - // the project's source. - dir: args.dir ?? loaded?.manifest.assets.dir ?? framework?.dir, - build: undefined, - // The build already ran here, so `sites deploy` must not offer it again. - built, - } as never); - return; - } - - const config = resolveConfig(profile, apiKey, verbose); - const options = clientOptions(config, verbose); - const coreClient = createCoreClient(options); - const computeClient = createComputeClient(options); - - if (args.dir !== undefined) { - logger.warn( - `This project builds a server, so the deploy follows ${loaded.manifest.assets.dir} from the build manifest, not ${args.dir}.`, - ); - } - - const result = await deployFramework({ - coreClient, - computeClient, - loaded, - args: { - name: args.name ?? siteConfig?.config.name, - region: args.region, - force: args.force, - output, - verbose, - }, - }); - - if (args.open && result.production) openBrowser(result.production); - }, -}); diff --git a/packages/cli/src/commands/deploy/rollback.ts b/packages/cli/src/commands/deploy/rollback.ts deleted file mode 100644 index 8528c02c..00000000 --- a/packages/cli/src/commands/deploy/rollback.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { - createComputeClient, - createCoreClient, -} from "@bunny.net/openapi-client"; -import { resolveConfig } from "../../config/index.ts"; -import { clientOptions } from "../../core/client-options.ts"; -import { defineCommand } from "../../core/define-command.ts"; -import { UserError } from "../../core/errors.ts"; -import { logger } from "../../core/logger.ts"; -import { - confirmProduction, - republishDeploy, - requireLinkedFrameworkSite, -} from "./framework.ts"; - -interface RollbackArgs { - id?: string; - force?: boolean; -} - -/** - * Put an earlier deploy back on production. - * - * Each deploy kept its own files and its own server bundle, so this restores a - * matched pair. Nothing is rebuilt, and nothing is moved: the bundle comes back - * out of storage and is published as it was. - */ -export const rollbackCommand = defineCommand({ - command: "rollback [id]", - describe: "Put the previous deploy back on production.", - examples: [ - ["$0 rollback", "Back to the deploy that was live before"], - ["$0 rollback a1b2c3d4", "Back to a named deploy"], - ], - - builder: (yargs) => - yargs - .positional("id", { - type: "string", - describe: "Deploy to publish (default: the previous one)", - }) - .option("force", { - type: "boolean", - default: false, - describe: "Skip the confirmation", - }), - - handler: async ({ id, force, profile, output, verbose, apiKey }) => { - const config = resolveConfig(profile, apiKey, verbose); - const options = clientOptions(config, verbose); - const coreClient = createCoreClient(options); - const computeClient = createComputeClient(options); - - const site = await requireLinkedFrameworkSite(coreClient); - const { state } = site; - - const target = id ?? state.previous; - if (!target) { - throw new UserError( - `Site "${state.name}" has no earlier deploy to go back to.`, - "Run `bunny sites deployments list` to see what it keeps.", - ); - } - if (target === state.current) { - logger.info(`Deploy ${target} is already live.`); - return; - } - - const proceed = await confirmProduction( - `Publish deploy ${target} to production?`, - { force, output }, - ); - if (!proceed) throw new UserError("Rollback cancelled."); - - await republishDeploy({ - coreClient, - computeClient, - site, - deployId: target, - output, - }); - }, -}); diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index 016d6a4b..47ba2bc6 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -4,6 +4,7 @@ import type { CoreClient, StorageZoneModel } from "../storage/api.ts"; import { type ComputeClient, createSite, + deleteDeployFiles, deletePreviewZone, deleteSiteResources, ensurePreviewZone, @@ -1284,3 +1285,20 @@ test("fetchSites ignores a middleware pull zone with no storage zone", async () expect(await fetchSites(coreClient)).toHaveLength(0); }); + +// A framework deploy keeps its server bundle under `_bunny/`, outside the deploy +// directory. Deleting only the directory would leave one bundle per pruned +// deploy in the zone, forever. +test("deleting a deploy removes its files and its server bundle", async () => { + store.set("deploys/a1b2c3d4/index.html", "

live

"); + store.set("_bunny/deploys/a1b2c3d4/server.js", "export default {}"); + store.set("deploys/e5f6a7b8/index.html", "

other

"); + store.set("_bunny/deploys/e5f6a7b8/server.js", "export default {}"); + + await deleteDeployFiles(fakeConnection(), "a1b2c3d4"); + + expect([...store.keys()].sort()).toEqual([ + "_bunny/deploys/e5f6a7b8/server.js", + "deploys/e5f6a7b8/index.html", + ]); +}); diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 0172220b..5b7d10dc 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -35,6 +35,7 @@ import { routerScriptName, STATE_VERSION, STATIC_SITE_ZONE_SETTINGS, + serverBundlePath, siteResourcePattern, suffixedResourceName, } from "./constants.ts"; @@ -825,9 +826,26 @@ export async function deleteSiteResources(opts: { return results; } +/** + * Delete everything one deploy holds in the storage zone. + * + * A framework deploy is two halves: its client files, and the server bundle kept + * under `_bunny/`. Deleting only the files would leave a bundle nothing can + * publish, for every deploy the site ever pruned. A static deploy has no bundle, + * and a missing path is not an error, so the same call serves both. + */ export async function deleteDeployFiles( connection: StorageZone, deployId: string, ): Promise { await siteFiles.remove(connection, `${deployPrefix(deployId)}/`); + await siteFiles + .remove(connection, serverBundlePath(deployId)) + .catch((err) => { + // The client files are gone, so the deploy is gone. A bundle left behind is + // wasted storage, not a failed delete. + logger.warn( + `Couldn't delete the server bundle for ${deployId}: ${errorMessage(err)}`, + ); + }); } diff --git a/packages/cli/src/commands/deploy/manifest.test.ts b/packages/cli/src/commands/sites/build-manifest.test.ts similarity index 99% rename from packages/cli/src/commands/deploy/manifest.test.ts rename to packages/cli/src/commands/sites/build-manifest.test.ts index ed2ec8fd..b829cb54 100644 --- a/packages/cli/src/commands/deploy/manifest.test.ts +++ b/packages/cli/src/commands/sites/build-manifest.test.ts @@ -7,7 +7,7 @@ import { minimumCliVersion, resolveAssetsDir, resolveScriptEntry, -} from "./manifest.ts"; +} from "./build-manifest.ts"; const tempDir = useTempDir("bunny-manifest-"); diff --git a/packages/cli/src/commands/deploy/manifest.ts b/packages/cli/src/commands/sites/build-manifest.ts similarity index 97% rename from packages/cli/src/commands/deploy/manifest.ts rename to packages/cli/src/commands/sites/build-manifest.ts index 3bfc420e..317d7be0 100644 --- a/packages/cli/src/commands/deploy/manifest.ts +++ b/packages/cli/src/commands/sites/build-manifest.ts @@ -117,7 +117,7 @@ export function resolveScriptEntry(loaded: LoadedBuildManifest): string { if (!existsSync(path) || !statSync(path).isFile()) { throw new UserError( `The build manifest points at ${entry}, which is not there.`, - "Run the build again, or run `bunny deploy --build`.", + "Run the build again, or run `bunny sites deploy --build`.", ); } return path; @@ -129,7 +129,7 @@ export function resolveAssetsDir(loaded: LoadedBuildManifest): string { if (!existsSync(path) || !statSync(path).isDirectory()) { throw new UserError( `The build manifest points at ${loaded.manifest.assets.dir}, which is not a directory.`, - "Run the build again, or run `bunny deploy --build`.", + "Run the build again, or run `bunny sites deploy --build`.", ); } return path; diff --git a/packages/cli/src/commands/sites/build.ts b/packages/cli/src/commands/sites/build.ts index f0bd9414..df9f24da 100644 --- a/packages/cli/src/commands/sites/build.ts +++ b/packages/cli/src/commands/sites/build.ts @@ -65,8 +65,6 @@ export async function runBuildCommand( command: string, cwd: string, env: Record, - /** The command to name when the build fails. The two deploy paths differ. */ - retry = "bunny sites deploy --build", ): Promise { logger.info(`Running build: ${command}`); const shell = @@ -84,7 +82,7 @@ export async function runBuildCommand( if (code !== 0) { throw new UserError( `Build command failed with exit code ${code}.`, - `Fix the build and run \`${retry}\` again.`, + "Fix the build and run `bunny sites deploy --build` again.", ); } } diff --git a/packages/cli/src/commands/sites/ci/frameworks.ts b/packages/cli/src/commands/sites/ci/frameworks.ts index 71cfc006..aad70ca0 100644 --- a/packages/cli/src/commands/sites/ci/frameworks.ts +++ b/packages/cli/src/commands/sites/ci/frameworks.ts @@ -14,9 +14,9 @@ export interface FrameworkPreset { build?: string; /** * The bunny.net adapter that makes this framework render per request on Edge - * Scripting. `bunny deploy` offers to install it, and then reads the build - * manifest the adapter writes: `dir` above stops applying, because a server - * build has two halves. + * Scripting. `bunny sites deploy` offers to install it when the project asks + * for a server, and then reads the build manifest the adapter writes: `dir` + * above stops applying, because a server build has two halves. */ adapter?: { package: string; diff --git a/packages/cli/src/commands/sites/constants.ts b/packages/cli/src/commands/sites/constants.ts index 6b38eca3..8a3f3092 100644 --- a/packages/cli/src/commands/sites/constants.ts +++ b/packages/cli/src/commands/sites/constants.ts @@ -39,8 +39,9 @@ export interface SiteManifest { * What serves a site. * * `static` sites get the router this CLI generates. A `framework` site's script - * is the build's own server, described by `.bunny/build.json`; `bunny deploy` - * owns those, and the static-only commands refuse them. + * is the build's own server, described by `.bunny/build.json`. `bunny sites + * deploy` reads that manifest and takes the path it names, and the static-only + * commands refuse a framework site. */ export type SiteKind = "static" | "framework"; diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 01833f74..882c23a9 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -4,20 +4,14 @@ import { createComputeClient, createCoreClient, } from "@bunny.net/openapi-client"; -import prompts from "prompts"; import { resolveConfig } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; import { collectEnv } from "../../core/env.ts"; import { errorMessage, UserError } from "../../core/errors.ts"; import { formatBytes } from "../../core/format.ts"; -import { - looksLikeHostname, - normalizeHostname, -} from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; import { confirm, isInteractive, withSpinner } from "../../core/ui.ts"; -import { findMissingPageFault, readNotFoundPage } from "../deploy/health.ts"; import { ensurePreviewZone, ensureRouterCurrent, @@ -31,6 +25,8 @@ import { resolveRequestedBuild, runBuildCommand, } from "./build.ts"; +import { loadBuildManifest } from "./build-manifest.ts"; +import { detectFramework, type FrameworkPreset } from "./ci/frameworks.ts"; import { loadSiteConfig } from "./config.ts"; import { type DeployRecord, @@ -39,14 +35,23 @@ import { type RemoteSiteState, } from "./constants.ts"; import { resolveDeployIdentity } from "./deploy-id.ts"; -import { setupSiteDomain } from "./domains/index.ts"; +import { DOMAIN_HINT, offerFirstDomain } from "./domains/index.ts"; +import { offerAdapter } from "./framework/adapter.ts"; +import { deployFramework, readServerBundle } from "./framework/deploy.ts"; +import { projectNeedsServer, type ServerNeed } from "./framework/detect.ts"; +import { enterProject } from "./framework/project.ts"; +import { findMissingPageFault, readNotFoundPage } from "./health.ts"; import { type SiteSelectorArgs, selectSite, siteLinkOption, siteOptionBuilder, } from "./interactive.ts"; -import { createLinkedSite, promptSiteName } from "./provision.ts"; +import { + createLinkedFrameworkSite, + createLinkedSite, + promptSiteName, +} from "./provision.ts"; import { collectFiles, hashFiles, uploadDeploy } from "./uploader.ts"; interface DeployArgs extends SiteSelectorArgs { @@ -56,15 +61,12 @@ interface DeployArgs extends SiteSelectorArgs { "env-file"?: string; production?: boolean; force?: boolean; - /** Site name for a site this creates, from `bunny deploy --name`. */ + /** Site name, for the first deploy from this directory. */ name?: string; - /** Set by `bunny deploy`, which has already run the build. Not a flag. */ - built?: boolean; + /** Storage region for a site this deploy creates. */ + region?: string; } -const DOMAIN_HINT = - " Add a custom production domain: bunny sites domains add "; - // Production and preview URLs for a deploy: production is the custom domain (else the site's b-cdn.net host), the preview is the deploy's own preview zone. Both are https-only (b-cdn.net hosts carry bunny's certificate). export function deployUrls( state: RemoteSiteState, @@ -89,20 +91,32 @@ export function resolveDeployDir( return resolve(root, configDir ?? autoDir ?? "."); } -// Deploy a directory: hash, skip if unchanged, upload to `deploys/{id}/`, record state, then serve it. Every deploy gets its own preview pull zone (an immutable `sites-dpl-{id}-*.b-cdn.net` URL, HTTPS out of the box); `--production` publishes it as the live site, and the interactive first deploy offers to. `--build` runs the build first with `--env`/`--env-file` overrides. +/** + * Deploy this project, whichever shape it is. + * + * The build decides. A build that writes `.bunny/build.json` and asks for a + * server deploys as an Edge Script, with its client files in Bunny Storage. + * Anything else is a directory of files: hash it, skip an unchanged deploy, + * upload to `deploys/{id}/`, record the state, then serve it. + * + * The CLI knows no framework. It reads the manifest an adapter wrote, so a new + * adapter needs no new CLI. A project that asks for a server and has no adapter + * yet gets an offer to add one. + */ export const sitesDeployCommand = defineCommand({ command: "deploy [dir]", - describe: "Deploy a directory to a site.", + describe: "Build and deploy this project.", examples: [ + ["$0 sites deploy", "Build if needed, then deploy"], + ["$0 sites deploy --build", "Run the project's own build first"], [ "$0 sites deploy ./dist", - "Deploy to an immutable preview URL (the first deploy offers to publish)", + "Deploy a directory to an immutable preview URL (the first deploy offers to publish)", ], [ "$0 sites deploy ./dist --production", "Deploy and publish as the live site", ], - ["$0 sites deploy --build", "Run the configured build, then deploy"], [ '$0 sites deploy ./dist --build "npm run build"', "Explicit build command", @@ -116,7 +130,7 @@ export const sitesDeployCommand = defineCommand({ yargs.positional("dir", { type: "string", describe: - "Directory to deploy (defaults to `sites.dir` in bunny.jsonc, then the detected framework's output dir when building, then the current directory)", + "Directory to deploy (defaults to `sites.dir` in bunny.jsonc, then the build manifest's or the detected framework's output dir, then the current directory). Ignored by a build that renders per request, which the manifest describes", }), ) .option("build", { @@ -148,14 +162,17 @@ export const sitesDeployCommand = defineCommand({ .option("name", { type: "string", describe: "Site name, for the first deploy from this directory", + }) + .option("region", { + type: "string", + describe: "Storage region for a new site (default: DE)", }), ), handler: async (args) => { const { profile, output, verbose, apiKey } = args; - const siteConfig = loadSiteConfig(); - const root = siteConfig?.root ?? process.cwd(); - const explicitDir = args.dir ?? siteConfig?.config.dir; + let siteConfig = loadSiteConfig(); + let root = siteConfig?.root ?? process.cwd(); if (args.build === undefined && (args.env?.length || args["env-file"])) { throw new UserError( @@ -164,13 +181,121 @@ export const sitesDeployCommand = defineCommand({ ); } - let requestedBuild: RequestedBuild | undefined; + // What this project builds. A manifest is an adapter's own answer, and it + // decides every choice below, so it is read before anything else. + let loaded = await loadBuildManifest(); + + // A workspace root is not a project. When the framework's project is a + // directory below this one, the whole deploy moves there: `withastro/starlight` + // keeps `astro` in the root `package.json` for `astro check`, and its site is + // `docs/`. A configured directory means the answer is already known. + if ( + !loaded && + args.dir === undefined && + siteConfig?.config.dir === undefined + ) { + const detected = await detectFramework(root); + if (detected?.adapter && (await enterProject(root, output))) { + siteConfig = loadSiteConfig(); + root = siteConfig?.root ?? process.cwd(); + loaded = await loadBuildManifest(); + } + } + + const explicitDir = args.dir ?? siteConfig?.config.dir; + + // No manifest yet, so read the project. Only a project that asks for a + // server hears about an adapter: one that prerenders every page is a + // directory of files, which is what this command has always deployed. + let needsServer: ServerNeed | null = null; + let preset: FrameworkPreset | undefined; + // A directory named here or in bunny.jsonc is an instruction: deploy these + // files. Nothing below second-guesses it. + if (!loaded && explicitDir === undefined) { + preset = await detectFramework(root); + needsServer = preset ? await projectNeedsServer(root, preset) : null; + if (needsServer) { + const offer = await offerAdapter(root, output, needsServer); + // The adapter is in place, so a build is what produces the manifest. + if (offer.ready && args.build === undefined) args.build = ""; + } + } + + // The build runs before any resource is created, so a failing build cannot + // leave an empty site behind. + let built = false; + let autoDir: string | undefined; if (args.build !== undefined) { - requestedBuild = await resolveRequestedBuild( + const requested: RequestedBuild = await resolveRequestedBuild( args.build, siteConfig?.config.build, root, ); + if (requested.label) logger.info(`Detected ${requested.label}.`); + // No dir given: target the detected framework's output dir, not the repo root the build ran in. + if (explicitDir === undefined) autoDir = requested.dir; + const overrides = await collectEnv(args.env, args["env-file"]); + await runBuildCommand(requested.command, root, overrides); + built = true; + } else if (isInteractive(output)) { + // No --build: offer to run the configured build, else a detected one. + const configured = siteConfig?.config.build; + const auto = configured + ? { command: configured, label: "the configured build" } + : await resolveAutoBuild(root); + if (auto) { + // Target the framework's output dir unless one was given (whether or not the build runs). + if (explicitDir === undefined && "dir" in auto) autoDir = auto.dir; + const prompt = configured + ? `Run ${auto.label} (\`${auto.command}\`) before deploying?` + : `Detected ${auto.label}. Run \`${auto.command}\` before deploying?`; + if (await confirm(prompt, { initial: true })) { + await runBuildCommand(auto.command, root, {}); + built = true; + } + } + } + // The build is what writes the manifest, so read it again. + if (built) loaded = await loadBuildManifest(); + + // A project that renders on demand and was never built has nothing to + // deploy. Falling through here would upload the directory as it stands, + // which for an unbuilt project means its source: `src/`, `package.json`, and + // the site nowhere in sight. + if (!loaded && !built && needsServer && explicitDir === undefined) { + throw new UserError( + `This ${preset?.label ?? "project"} project renders pages on demand, and there is no build to deploy.`, + [ + "Build it first, and deploy what the build wrote:", + "", + " bunny sites deploy --build", + "", + `Or deploy a directory of files as they are: bunny sites deploy ${preset?.dir ?? "./dist"}`, + ].join("\n"), + ); + } + + /** A server build is one Edge Script and its files. Anything else is files. */ + const server = loaded?.manifest.kind === "ssr"; + // Read the bundle before any resource exists, so a script the platform + // refuses cannot leave a site behind on its way to the error. + const bundle = + server && loaded ? await readServerBundle(loaded) : undefined; + if (server && explicitDir !== undefined) { + logger.warn( + `This project builds a server, so the deploy follows ${loaded?.manifest.assets.dir} from the build manifest, not ${explicitDir}.`, + ); + } + // The directory a static build named. It wins over the preset's guess, + // because the build knows where it wrote. + let manifestDir: string | undefined; + if (loaded && !server && explicitDir === undefined) { + manifestDir = loaded.manifest.assets.dir; + if (output !== "json") { + logger.info( + `${loaded.manifest.adapter.package} built a static site; deploying ${manifestDir}.`, + ); + } } const config = resolveConfig(profile, apiKey, verbose); @@ -186,18 +311,60 @@ export const sitesDeployCommand = defineCommand({ name: args.name, offerCreate: async () => { const name = await promptSiteName(args.name, isInteractive(output)); - return createLinkedSite({ coreClient, computeClient, name }); + return server && loaded + ? createLinkedFrameworkSite({ + coreClient, + computeClient, + name, + region: args.region, + manifest: loaded.manifest, + }) + : createLinkedSite({ + coreClient, + computeClient, + name, + region: args.region, + }); }, }); const { state, connection } = site; - // A framework site's script is the build's own server, so this command's - // router, preview zones, and CURRENT_DEPLOY lever do not apply to it. - if (isFrameworkSite(state)) { - throw new UserError( - `"${state.name}" is deployed from a framework build.`, - "Run `bunny deploy` for it.", - ); + // The build and the site have to be the same shape. A script's type is fixed + // when the API creates it: a framework site's script is the build's own + // server, and a static site's is this CLI's router. Neither one can serve the + // other's deploy, so this stops before anything is uploaded. + if (isFrameworkSite(state) !== server) { + throw server + ? new UserError( + `This project builds a server, and "${state.name}" is a static site.`, + "Deploy it to a site of its own: run `bunny sites deploy --site `, or delete this one and let the deploy create it.", + ) + : new UserError( + `"${state.name}" serves a build with its own server, and this deploy has none.`, + "Deploy the project that builds that server, or name another site with --site.", + ); + } + + // A server build deploys as an Edge Script, and the rest of this command + // does not apply to it: one script serves one release, so there is no + // preview URL and no CURRENT_DEPLOY lever. + if (server && loaded && bundle) { + // Saying this is the alternative to publishing production in silence. + if (!args.production && output !== "json") { + logger.info( + "This project renders per request, so the deploy publishes to production.", + ); + } + await deployFramework({ + coreClient, + computeClient, + site, + loaded, + bundle, + args: { force: args.force, output, verbose }, + }); + await offerLink(); + return; } // Publishing is always explicit (--production, or the interactive first-deploy offer below); an implicit publish would let a CI preview run go live on a fresh site. @@ -243,39 +410,14 @@ export const sitesDeployCommand = defineCommand({ } } - let autoDir: string | undefined; - if (requestedBuild) { - if (requestedBuild.label) - logger.info(`Detected ${requestedBuild.label}.`); - // No dir given: target the detected framework's output dir, not the repo root the build ran in. - if (explicitDir === undefined) autoDir = requestedBuild.dir; - const overrides = await collectEnv(args.env, args["env-file"]); - await runBuildCommand(requestedBuild.command, root, overrides); - } else if (isInteractive(output) && !args.built) { - // No --build: offer to run the configured build, else a detected one. - const configured = siteConfig?.config.build; - const auto = configured - ? { command: configured, label: "the configured build" } - : await resolveAutoBuild(root); - if (auto) { - // Target the framework's output dir unless one was given (whether or not the build runs). - if (explicitDir === undefined && "dir" in auto) autoDir = auto.dir; - const prompt = configured - ? `Run ${auto.label} (\`${auto.command}\`) before deploying?` - : `Detected ${auto.label}. Run \`${auto.command}\` before deploying?`; - if (await confirm(prompt, { initial: true })) { - await runBuildCommand(auto.command, root, {}); - } - } - } - const dir = resolveDeployDir( args.dir, siteConfig?.config.dir, - autoDir, + manifestDir ?? autoDir, root, ); - if (autoDir && explicitDir === undefined) { + // The manifest's own line already said where a static build wrote. + if (autoDir && !manifestDir && explicitDir === undefined) { logger.info(`Deploying detected output directory: ${autoDir}`); } if (!existsSync(dir) || !statSync(dir).isDirectory()) { @@ -510,51 +652,16 @@ export const sitesDeployCommand = defineCommand({ ); } - // Domainless sites: the first deploy offers a custom production domain, later ones just hint. - if (!state.domain) { - logger.log(); - let handled = false; - if (firstDeploy && isInteractive(output)) { - const { value } = await prompts({ - type: "text", - name: "value", - message: - "Custom domain for this site's production URL (leave blank to skip):", - }); - const typed = normalizeHostname(value ?? ""); - // A one-word answer here used to reach the API, which calls it "An error - // has occurred." and leaves the developer with nothing to fix. - if (typed && !looksLikeHostname(typed)) { - logger.warn(`"${typed}" is not a domain name. Skipping it.`); - logger.dim( - " Add one later: bunny sites domains add www.example.com", - ); - } - const domain = (looksLikeHostname(typed) ? typed : "") || undefined; - if (domain) { - handled = true; - // The domain flow writes state, so it needs the etag from this deploy's writes, not the stale read. - site.etag = etag; - try { - await setupSiteDomain({ - coreClient, - site, - domain, - interactive: true, - verbose, - }); - } catch (err) { - logger.warn( - `Couldn't finish setting up ${domain}: ${errorMessage(err)}`, - ); - logger.dim( - ` Retry later: bunny sites domains add ${domain} ${state.name}`, - ); - } - } - } - if (!handled) logger.dim(DOMAIN_HINT); - } + // The domain flow writes state, so it needs the etag from this deploy's + // writes, not the stale read. + site.etag = etag; + await offerFirstDomain({ + coreClient, + site, + firstDeploy, + interactive: isInteractive(output), + verbose, + }); await offerLink(); }, diff --git a/packages/cli/src/commands/sites/deployments/publish.ts b/packages/cli/src/commands/sites/deployments/publish.ts index 3e98cff7..19ec87e2 100644 --- a/packages/cli/src/commands/sites/deployments/publish.ts +++ b/packages/cli/src/commands/sites/deployments/publish.ts @@ -8,9 +8,9 @@ import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; import { confirm, requireConfirmable, withSpinner } from "../../../core/ui.ts"; -import { republishDeploy } from "../../deploy/framework.ts"; import { promoteDeploy, writeRemoteState } from "../api.ts"; import { isFrameworkSite, markCurrent } from "../constants.ts"; +import { republishDeploy } from "../framework/deploy.ts"; import { type SiteSelectorArgs, selectSite, diff --git a/packages/cli/src/commands/sites/domains/index.ts b/packages/cli/src/commands/sites/domains/index.ts index ac380d56..684f1ccb 100644 --- a/packages/cli/src/commands/sites/domains/index.ts +++ b/packages/cli/src/commands/sites/domains/index.ts @@ -1,4 +1,5 @@ import { createCoreClient } from "@bunny.net/openapi-client"; +import prompts from "prompts"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { errorMessage } from "../../../core/errors.ts"; @@ -7,6 +8,8 @@ import { type CoreClient, createHostnamesCommands, fetchPullZoneHostnames, + looksLikeHostname, + normalizeHostname, type ResolvedPullZone, setupHostname, } from "../../../core/hostnames/index.ts"; @@ -125,3 +128,65 @@ export const sitesDomainsCommands = createHostnamesCommands({ } }, }); + +/** The dim line a domainless site's later deploys print. */ +export const DOMAIN_HINT = + " Add a custom production domain: bunny sites domains add "; + +/** + * Offer a custom domain after a deploy, and hint at one otherwise. + * + * The site's first-ever deploy is the one moment worth asking: the list is never + * empty again, so a later offer would only be noise. Both deploy paths end here, + * because a site that renders per request needs a domain for the same reason a + * static one does. + */ +export async function offerFirstDomain(opts: { + coreClient: CoreClient; + site: SiteContext; + /** True when this deploy is the site's first, which is what makes the offer. */ + firstDeploy: boolean; + interactive: boolean; + verbose: boolean; +}): Promise { + const { coreClient, site } = opts; + if (site.state.domain) return; + + logger.log(); + if (opts.firstDeploy && opts.interactive) { + const { value } = await prompts({ + type: "text", + name: "value", + message: + "Custom domain for this site's production URL (leave blank to skip):", + }); + const typed = normalizeHostname(value ?? ""); + // A one-word answer here used to reach the API, which calls it "An error has + // occurred." and leaves the developer with nothing to fix. + if (typed && !looksLikeHostname(typed)) { + logger.warn(`"${typed}" is not a domain name. Skipping it.`); + logger.dim(" Add one later: bunny sites domains add www.example.com"); + } + const domain = looksLikeHostname(typed) ? typed : undefined; + if (domain) { + try { + await setupSiteDomain({ + coreClient, + site, + domain, + interactive: true, + verbose: opts.verbose, + }); + } catch (err) { + logger.warn( + `Couldn't finish setting up ${domain}: ${errorMessage(err)}`, + ); + logger.dim( + ` Retry later: bunny sites domains add ${domain} ${site.state.name}`, + ); + } + return; + } + } + logger.dim(DOMAIN_HINT); +} diff --git a/packages/cli/src/commands/deploy/adapter.test.ts b/packages/cli/src/commands/sites/framework/adapter.test.ts similarity index 100% rename from packages/cli/src/commands/deploy/adapter.test.ts rename to packages/cli/src/commands/sites/framework/adapter.test.ts diff --git a/packages/cli/src/commands/deploy/adapter.ts b/packages/cli/src/commands/sites/framework/adapter.ts similarity index 91% rename from packages/cli/src/commands/deploy/adapter.ts rename to packages/cli/src/commands/sites/framework/adapter.ts index 2a4b07eb..9b66dbd4 100644 --- a/packages/cli/src/commands/deploy/adapter.ts +++ b/packages/cli/src/commands/sites/framework/adapter.ts @@ -1,15 +1,16 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; -import { UserError } from "../../core/errors.ts"; -import { logger } from "../../core/logger.ts"; -import { confirm, isInteractive } from "../../core/ui.ts"; +import { UserError } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; +import { confirm, isInteractive } from "../../../core/ui.ts"; import { detectFramework, detectWorkspace, type FrameworkPreset, readPackageJson, type Workspace, -} from "../sites/ci/frameworks.ts"; +} from "../ci/frameworks.ts"; +import type { ServerNeed } from "./detect.ts"; /** * Install command for a package, per package manager. @@ -221,14 +222,15 @@ export interface AdapterOffer { /** * Offer the bunny.net adapter for the framework this project uses. * - * Called only when the project has no build manifest: either the adapter is not - * installed, or the project has not been built yet. Installing is always the - * developer's choice, so an unattended run reports what to do and changes - * nothing. + * Called only when the project asks for a server and has no build manifest yet: + * either the adapter is not installed, or the project has not been built. + * Installing is always the developer's choice, so an unattended run reports what + * to do and changes nothing. */ export async function offerAdapter( root: string, output: string | undefined, + need: ServerNeed, ): Promise { const preset = await detectFramework(root); const adapter = preset?.adapter; @@ -249,7 +251,7 @@ export async function offerAdapter( if (!isInteractive(output)) { logger.warn( - `${preset.label} detected, and this project has no bunny.net adapter.`, + `This ${preset.label} project renders on demand (${need.reason}), and it has no bunny.net adapter.`, ); if (!installed) logger.dim(` ${install}`); if (!configured) { @@ -265,11 +267,7 @@ export async function offerAdapter( } logger.info( - inTheWay - ? `${preset.label} detected, using ${inTheWay}.` - : installed - ? `${preset.label} detected, with ${adapter.package} installed but not configured.` - : `${preset.label} detected, with no bunny.net adapter.`, + `This ${preset.label} project renders on demand: ${need.reason}.`, ); const wanted = await confirm( inTheWay @@ -279,7 +277,12 @@ export async function offerAdapter( : `Add ${adapter.package} to ${file}?`, { initial: true }, ); - if (!wanted) return { ready: false, preset }; + if (!wanted) { + logger.dim( + ` Without an adapter, ${preset.label} cannot build a route that renders on demand.`, + ); + return { ready: false, preset }; + } if (!installed) await run(install, root); @@ -298,7 +301,7 @@ export async function offerAdapter( "", manualSnippet(adapter.package), "", - "Then re-run `bunny deploy`.", + "Then run `bunny sites deploy` again.", ].join("\n"), ); } diff --git a/packages/cli/src/commands/deploy/api.test.ts b/packages/cli/src/commands/sites/framework/api.test.ts similarity index 97% rename from packages/cli/src/commands/deploy/api.test.ts rename to packages/cli/src/commands/sites/framework/api.test.ts index d2d09537..ae614df9 100644 --- a/packages/cli/src/commands/deploy/api.test.ts +++ b/packages/cli/src/commands/sites/framework/api.test.ts @@ -1,14 +1,14 @@ import { expect, test } from "bun:test"; import type { BuildManifest } from "@bunny.net/config"; -import type { ComputeClient } from "../sites/api.ts"; -import type { CoreClient, StorageZoneModel } from "../storage/api.ts"; +import type { CoreClient, StorageZoneModel } from "../../storage/api.ts"; +import type { ComputeClient } from "../api.ts"; import { applyPullZoneSettings, applyScriptEnv, deployPreamble, storageHostFor, } from "./api.ts"; -import { resolveScriptEnv } from "./framework.ts"; +import { resolveScriptEnv } from "./deploy.ts"; interface Call { method: string; diff --git a/packages/cli/src/commands/deploy/api.ts b/packages/cli/src/commands/sites/framework/api.ts similarity index 96% rename from packages/cli/src/commands/deploy/api.ts rename to packages/cli/src/commands/sites/framework/api.ts index f4d238f2..a527068b 100644 --- a/packages/cli/src/commands/deploy/api.ts +++ b/packages/cli/src/commands/sites/framework/api.ts @@ -1,33 +1,33 @@ import type { BuildManifest, ManifestPullZone } from "@bunny.net/config"; -import { ApiError, errorMessage, UserError } from "../../core/errors.ts"; +import { ApiError, errorMessage, UserError } from "../../../core/errors.ts"; import { createPullZone, setForceSsl, systemHostname, -} from "../../core/hostnames/index.ts"; -import { logger } from "../../core/logger.ts"; -import { fetchEnvEntries, fetchScripts } from "../scripts/api.ts"; -import { SCRIPT_TYPE_STANDALONE } from "../scripts/constants.ts"; +} from "../../../core/hostnames/index.ts"; +import { logger } from "../../../core/logger.ts"; +import { fetchEnvEntries, fetchScripts } from "../../scripts/api.ts"; +import { SCRIPT_TYPE_STANDALONE } from "../../scripts/constants.ts"; +import { + type CoreClient, + fetchStorageZone, + type StorageZoneModel, +} from "../../storage/api.ts"; +import type { StorageZone } from "../../storage/files-api.ts"; import { type ComputeClient, promoteVerification, siteContextFromZone, siteFiles, writeRemoteState, -} from "../sites/api.ts"; +} from "../api.ts"; import { type RemoteSiteState, STATE_VERSION, serverScriptName, siteResourcePattern, suffixedResourceName, -} from "../sites/constants.ts"; -import { - type CoreClient, - fetchStorageZone, - type StorageZoneModel, -} from "../storage/api.ts"; -import type { StorageZone } from "../storage/files-api.ts"; +} from "../constants.ts"; /** Frankfurt has no prefix; every other region is `.storage.bunnycdn.com`. */ export function storageHostFor(region: string | null | undefined): string { @@ -86,7 +86,7 @@ export async function createFrameworkSite( if (existing) { throw new UserError( `Site "${name}" already exists.`, - `Run \`bunny deploy\` from the project it belongs to, or pick another name.`, + "Run `bunny sites deploy` from the project it belongs to, or pick another name.", ); } reused.storageZone = true; diff --git a/packages/cli/src/commands/deploy/framework.ts b/packages/cli/src/commands/sites/framework/deploy.ts similarity index 64% rename from packages/cli/src/commands/deploy/framework.ts rename to packages/cli/src/commands/sites/framework/deploy.ts index 3f3be175..a80115a3 100644 --- a/packages/cli/src/commands/deploy/framework.ts +++ b/packages/cli/src/commands/sites/framework/deploy.ts @@ -1,60 +1,43 @@ import type { BuildManifest } from "@bunny.net/config"; -import prompts from "prompts"; -import { errorMessage, UserError } from "../../core/errors.ts"; -import { formatBytes } from "../../core/format.ts"; -import { - looksLikeHostname, - normalizeHostname, -} from "../../core/hostnames/index.ts"; -import { logger } from "../../core/logger.ts"; -import { - ignoreManifestDir, - loadManifest, - saveManifest, -} from "../../core/manifest.ts"; -import { confirm, isInteractive, withSpinner } from "../../core/ui.ts"; +import { UserError } from "../../../core/errors.ts"; +import { formatBytes } from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { isInteractive, withSpinner } from "../../../core/ui.ts"; +import type { CoreClient, StorageZoneModel } from "../../storage/api.ts"; import { type ComputeClient, fetchSystemHostname, type SiteContext, - siteContextFromZone, siteFiles, writeRemoteState, -} from "../sites/api.ts"; +} from "../api.ts"; +import { + type LoadedBuildManifest, + resolveAssetsDir, + resolveScriptEntry, +} from "../build-manifest.ts"; import { type DeployRecord, - isFrameworkSite, markCurrent, type RemoteSiteState, - SITES_MANIFEST, - type SiteManifest, serverBundlePath, -} from "../sites/constants.ts"; -import { contentHashId, resolveDeployIdentity } from "../sites/deploy-id.ts"; -import { setupSiteDomain } from "../sites/domains/index.ts"; -import { promptSiteName } from "../sites/provision.ts"; -import { collectFiles, hashFiles, uploadDeploy } from "../sites/uploader.ts"; -import type { CoreClient, StorageZoneModel } from "../storage/api.ts"; -import { fetchStorageZone } from "../storage/api.ts"; +} from "../constants.ts"; +import { contentHashId, resolveDeployIdentity } from "../deploy-id.ts"; +import { offerFirstDomain } from "../domains/index.ts"; +import { + findDeployFault, + findMissingPageFault, + readNotFoundPage, +} from "../health.ts"; +import { collectFiles, hashFiles, uploadDeploy } from "../uploader.ts"; import { applyPullZoneSettings, applyScriptEnv, - createFrameworkSite, publishDeploy, readStoredBundle, type ScriptEnv, storageHostFor, } from "./api.ts"; -import { - findDeployFault, - findMissingPageFault, - readNotFoundPage, -} from "./health.ts"; -import { - type LoadedBuildManifest, - resolveAssetsDir, - resolveScriptEntry, -} from "./manifest.ts"; /** Only production exists so far. A named preview environment is the next step. */ export const PRODUCTION = "production"; @@ -74,95 +57,42 @@ const SCRIPT_SIZE_LIMIT = 10 * 1024 * 1024; */ const SCRIPT_START_RISK = 7.5 * 1024 * 1024; -const DOMAIN_HINT = " Add a custom domain: bunny domains add "; - -export interface FrameworkDeployArgs { - name?: string; - region?: string; - force?: boolean; - open?: boolean; - output?: string; - verbose: boolean; +export interface ServerBundle { + /** The built file, as the build wrote it. The preamble is added at publish time. */ + code: string; + bytes: number; + /** The folder of client files this bundle renders against. */ + assetsDir: string; } /** - * The site this directory deploys to, creating it on the first run. + * Read what the build wrote, and refuse a script the platform cannot take. * - * `.bunny/site.json` points at the storage zone, and the zone holds the state. - * So the source of truth travels with the site, and a second machine only needs - * the pointer. + * The caller runs this before it resolves a site. A script that cannot be + * deployed used to be found out after the storage zone, the script and the pull + * zone were all made, which left three empty resources behind and nothing to + * deploy. */ -async function resolveSite(opts: { - coreClient: CoreClient; - computeClient: ComputeClient; - manifest: BuildManifest; - root: string; - name?: string; - region?: string; - output?: string; -}): Promise { - const linked = loadManifest(SITES_MANIFEST); - if (linked.id) { - const zone = await fetchStorageZone(opts.coreClient, linked.id); - const context = await siteContextFromZone(zone); - if (!context) { - throw new UserError( - `The linked site (storage zone ${linked.id}) holds no site state.`, - "Delete .bunny/site.json to start a new site here.", - ); - } - if (!isFrameworkSite(context.state)) { - throw new UserError( - `"${context.state.name}" is a static site, and this project builds a server.`, - "Run `bunny sites deploy` for that site, or link this directory to a new one.", - ); - } - return context; - } - - const name = await promptSiteName( - opts.name, - isInteractive(opts.output), - "Pass one: bunny deploy --name .", - ); - const created = await withSpinner(`Creating site "${name}"...`, (spin) => - createFrameworkSite({ - coreClient: opts.coreClient, - computeClient: opts.computeClient, - name, - region: (opts.region ?? "DE").toUpperCase(), - manifest: opts.manifest, - onStep: (message) => { - spin.text = message; - }, - }), - ); - - saveManifest(SITES_MANIFEST, { - id: created.state.storageZoneId, - name, - }); - - logger.success(`Created site "${name}".`); - if (ignoreManifestDir()) { - logger.dim( - " .gitignore .bunny/ added; it holds a build output and this link", - ); - } - logger.dim(` storage zone ${created.storageZone.Name}`); - logger.dim(` edge script ${created.state.scriptId}`); - logger.dim( - ` pull zone ${created.systemHostname ?? created.state.pullZoneId}`, - ); - - const context = await siteContextFromZone(created.storageZone); - if (!context) { +export async function readServerBundle( + loaded: LoadedBuildManifest, +): Promise { + const entryPath = resolveScriptEntry(loaded); + const assetsDir = resolveAssetsDir(loaded); + const code = await Bun.file(entryPath).text(); + const bytes = Buffer.byteLength(code); + if (bytes > SCRIPT_SIZE_LIMIT) { throw new UserError( - `Created site "${name}" but could not read its state back.`, - "Re-run `bunny deploy`.", + `${loaded.manifest.script?.entry} is ${formatBytes(bytes)}, and Edge Scripting takes ${formatBytes(SCRIPT_SIZE_LIMIT)}.`, + "Prerender the routes that do not need a server, or drop a dependency the server does not need, and build again.", ); } - return context; + return { code, bytes, assetsDir }; +} + +export interface FrameworkDeployArgs { + force?: boolean; + output?: string; + verbose: boolean; } /** @@ -226,37 +156,18 @@ export function resolveScriptEnv( export async function deployFramework(opts: { coreClient: CoreClient; computeClient: ComputeClient; + /** The site to deploy to, already resolved and checked for its kind. */ + site: SiteContext; loaded: LoadedBuildManifest; + /** What {@link readServerBundle} read, before this site existed. */ + bundle: ServerBundle; args: FrameworkDeployArgs; }): Promise<{ production?: string }> { - const { coreClient, computeClient, loaded, args } = opts; + const { coreClient, computeClient, site, loaded, args } = opts; const { manifest, root } = loaded; const output = args.output; + const { code, assetsDir, bytes: bundleBytes } = opts.bundle; - const entryPath = resolveScriptEntry(loaded); - const assetsDir = resolveAssetsDir(loaded); - - // Before anything is created. A script that cannot be deployed used to be - // found out after the storage zone, the script and the pull zone were all - // made, which left three empty resources behind and nothing to deploy. - const code = await Bun.file(entryPath).text(); - const bundleBytes = Buffer.byteLength(code); - if (bundleBytes > SCRIPT_SIZE_LIMIT) { - throw new UserError( - `${manifest.script?.entry} is ${formatBytes(bundleBytes)}, and Edge Scripting takes ${formatBytes(SCRIPT_SIZE_LIMIT)}.`, - "Prerender the routes that do not need a server, or drop a dependency the server does not need, and build again.", - ); - } - - const site = await resolveSite({ - coreClient, - computeClient, - manifest, - root, - name: args.name, - region: args.region, - output, - }); const { state, connection } = site; let etag = site.etag; const firstDeploy = state.deploys.length === 0; @@ -460,7 +371,9 @@ export async function deployFramework(opts: { " The script may be failing as it starts. Read its logs in the dashboard: Scripting > your script > Logs.", ); } - logger.dim(` The deploy before it is still there: bunny rollback`); + logger.dim( + " The deploy before it is still there: bunny sites deployments publish --previous", + ); } if (notFoundFault !== null) { @@ -475,7 +388,7 @@ export async function deployFramework(opts: { const missing = unset.filter((name) => name !== "BUNNY_API_KEY"); if (missing.length > 0) { logger.dim( - ` ${manifest.adapter.package} also reads ${missing.join(", ")}. Set them with \`bunny env set\`.`, + ` ${manifest.adapter.package} also reads ${missing.join(", ")}. Set them with \`bunny scripts env set\`.`, ); } if (unset.includes("BUNNY_API_KEY")) { @@ -484,46 +397,14 @@ export async function deployFramework(opts: { ); } - // The first deploy is the one moment to offer a domain: the list is never - // empty again, so a later offer would just be noise. - if (!state.domain) { - logger.log(); - let handled = false; - if (firstDeploy && isInteractive(output)) { - const { value } = await prompts({ - type: "text", - name: "value", - message: "Custom domain for this site (leave blank to skip):", - }); - const typed = normalizeHostname(value ?? ""); - // A one-word answer here used to reach the API, which calls it "An error - // has occurred." and leaves the developer with nothing to fix. - if (typed && !looksLikeHostname(typed)) { - logger.warn(`"${typed}" is not a domain name. Skipping it.`); - logger.dim(" Add one later: bunny sites domains add www.example.com"); - } - const domain = (looksLikeHostname(typed) ? typed : "") || undefined; - if (domain) { - handled = true; - site.etag = etag; - try { - await setupSiteDomain({ - coreClient, - site, - domain, - interactive: true, - verbose: args.verbose, - }); - } catch (err) { - logger.warn( - `Couldn't finish setting up ${domain}: ${errorMessage(err)}`, - ); - logger.dim(` Retry later: bunny sites domains add ${domain}`); - } - } - } - if (!handled) logger.dim(DOMAIN_HINT); - } + site.etag = etag; + await offerFirstDomain({ + coreClient, + site, + firstDeploy, + interactive: isInteractive(output), + verbose: args.verbose, + }); return urls; } @@ -543,7 +424,7 @@ export async function republishDeploy(opts: { if (!record) { throw new UserError( `Site "${state.name}" has no deploy ${deployId}.`, - "Run `bunny deployments list` to see what it keeps.", + "Run `bunny sites deployments list` to see what it keeps.", ); } @@ -602,45 +483,3 @@ export async function siteUrls( state.domain ?? (await fetchSystemHostname(coreClient, state.pullZoneId)); return { production: host ? `https://${host}` : undefined }; } - -/** Load the framework site this directory is linked to, or fail with a useful message. */ -export async function requireLinkedFrameworkSite( - coreClient: CoreClient, -): Promise { - const linked = loadManifest(SITES_MANIFEST); - if (!linked.id) { - throw new UserError( - "This directory is not linked to a site.", - "Run `bunny deploy` here first.", - ); - } - const zone = await fetchStorageZone(coreClient, linked.id); - const context = await siteContextFromZone(zone); - if (!context) { - throw new UserError( - `The linked site (storage zone ${linked.id}) holds no site state.`, - ); - } - if (!isFrameworkSite(context.state)) { - throw new UserError( - `"${context.state.name}" is a static site.`, - "Use `bunny sites deployments` for it.", - ); - } - return context; -} - -/** Ask before doing something to production without a TTY to answer. */ -export async function confirmProduction( - message: string, - opts: { force?: boolean; output?: string }, -): Promise { - if (opts.force) return true; - if (!isInteractive(opts.output)) { - throw new UserError( - "This changes what production serves, and there is nobody to ask.", - "Pass --force to run it unattended.", - ); - } - return confirm(message, { initial: true }); -} diff --git a/packages/cli/src/commands/sites/framework/detect.test.ts b/packages/cli/src/commands/sites/framework/detect.test.ts new file mode 100644 index 00000000..485f3103 --- /dev/null +++ b/packages/cli/src/commands/sites/framework/detect.test.ts @@ -0,0 +1,137 @@ +import { expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { FRAMEWORK_PRESETS, type FrameworkPreset } from "../ci/frameworks.ts"; +import { projectNeedsServer } from "./detect.ts"; + +const ASTRO = FRAMEWORK_PRESETS.find( + (preset) => preset.id === "astro", +) as FrameworkPreset; + +/** Write a project from a map of path to contents. */ +function project(files: Record): string { + const root = mkdtempSync(join(tmpdir(), "bunny-detect-")); + for (const [path, contents] of Object.entries(files)) { + const full = join(root, path); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, contents); + } + return root; +} + +const PLAIN_CONFIG = `import { defineConfig } from "astro/config"; + +export default defineConfig({ + site: "https://example.com", +}); +`; + +test("a project that prerenders every page needs no server", async () => { + const root = project({ + "package.json": JSON.stringify({ dependencies: { astro: "^5.0.0" } }), + "astro.config.mjs": PLAIN_CONFIG, + "src/pages/index.astro": "

Hello

", + "src/pages/about.astro": "---\nconst x = 1;\n---\n

{x}

", + }); + expect(await projectNeedsServer(root, ASTRO)).toBeNull(); +}); + +// The signal that matters most: `astro build` stops on its own here when no +// adapter is installed, so the offer has to come first. +test("a route that opts out of prerendering needs a server, and is named", async () => { + const root = project({ + "package.json": JSON.stringify({ dependencies: { astro: "^5.0.0" } }), + "astro.config.mjs": PLAIN_CONFIG, + "src/pages/index.astro": "

Hello

", + "src/pages/api/time.ts": + "export const prerender = false;\nexport const GET = () => new Response('');", + }); + const need = await projectNeedsServer(root, ASTRO); + expect(need?.reason).toBe("src/pages/api/time.ts sets prerender = false"); +}); + +test("a type annotation on the prerender export still counts", async () => { + const root = project({ + "src/pages/live.astro": + "---\nexport const prerender: boolean = false;\n---\n

now

", + }); + const need = await projectNeedsServer(root, ASTRO); + expect(need?.reason).toBe("src/pages/live.astro sets prerender = false"); +}); + +// `prerender` applies to a route, so only routes are read. A component that +// mentions the word is not a route. +test("a component outside src/pages is not read", async () => { + const root = project({ + "src/components/Note.astro": "export const prerender = false;", + "src/pages/index.astro": "

Hello

", + }); + expect(await projectNeedsServer(root, ASTRO)).toBeNull(); +}); + +test("another vendor's adapter needs a server, and is reported by name", async () => { + const root = project({ + "astro.config.mjs": `import { defineConfig } from "astro/config"; +import cloudflare from "@astrojs/cloudflare"; + +export default defineConfig({ + adapter: cloudflare(), +}); +`, + }); + const need = await projectNeedsServer(root, ASTRO); + expect(need?.vendor).toBe("@astrojs/cloudflare"); + expect(need?.reason).toBe("astro.config.mjs uses @astrojs/cloudflare"); +}); + +test('output: "server" needs a server', async () => { + const root = project({ + "astro.config.ts": `import { defineConfig } from "astro/config"; + +export default defineConfig({ + output: 'server', +}); +`, + }); + const need = await projectNeedsServer(root, ASTRO); + expect(need?.reason).toBe('astro.config.ts sets output: "server"'); + expect(need?.vendor).toBeUndefined(); +}); + +// The developer installed the adapter and never reached the config. That is an +// answer too, and the offer finishes the job. +test("the bunny.net adapter as a dependency needs a server", async () => { + const root = project({ + "package.json": JSON.stringify({ + devDependencies: { "@bunny.net/astro-adapter": "^0.1.0" }, + }), + "astro.config.mjs": PLAIN_CONFIG, + }); + const need = await projectNeedsServer(root, ASTRO); + expect(need?.reason).toBe("@bunny.net/astro-adapter is a dependency"); +}); + +test("the bunny.net adapter in the config needs a server", async () => { + const root = project({ + "astro.config.mjs": `import { defineConfig } from "astro/config"; +import bunny from "@bunny.net/astro-adapter"; + +export default defineConfig({ + adapter: bunny(), +}); +`, + }); + const need = await projectNeedsServer(root, ASTRO); + expect(need?.reason).toBe("astro.config.mjs uses @bunny.net/astro-adapter"); +}); + +// A framework with no bunny.net adapter has no server path to offer, whatever +// its project holds. +test("a framework with no adapter of ours never needs a server", async () => { + const hugo = FRAMEWORK_PRESETS.find( + (preset) => preset.id === "hugo", + ) as FrameworkPreset; + const root = project({ "src/pages/x.ts": "export const prerender = false;" }); + expect(await projectNeedsServer(root, hugo)).toBeNull(); +}); diff --git a/packages/cli/src/commands/sites/framework/detect.ts b/packages/cli/src/commands/sites/framework/detect.ts new file mode 100644 index 00000000..999aeeb3 --- /dev/null +++ b/packages/cli/src/commands/sites/framework/detect.ts @@ -0,0 +1,132 @@ +/** + * Does this project render pages on demand? + * + * The build manifest answers this after a build, and nothing here is needed. + * Before the first build there is no manifest, so the project is read instead. + * + * The answer decides whether the deploy mentions an adapter at all. A project + * that renders every page ahead of time is a directory of files, and it must + * never be offered a server it does not want: `bunny sites deploy` has always + * deployed such a project, and it still does. + */ +import { readdir, readFile } from "node:fs/promises"; +import { basename, join, relative } from "node:path"; +import type { FrameworkPreset } from "../ci/frameworks.ts"; +import { findAstroConfig, hasAdapter, vendorAdapterIn } from "./adapter.ts"; + +export interface ServerNeed { + /** What the project said, as a phrase that follows the framework's name. */ + reason: string; + /** Another vendor's adapter, when that is what asks for a server. */ + vendor?: string; +} + +/** Files that can hold a route, and therefore a `prerender` export. */ +const ROUTE_FILE = /\.(?:astro|ts|tsx|js|jsx|mjs|md|mdx)$/i; + +/** + * `export const prerender = false`, in the forms a route file writes it. + * + * A type annotation between the name and the value is allowed, because + * `export const prerender: boolean = false` is valid TypeScript and means the + * same thing. + */ +const ON_DEMAND = /export\s+const\s+prerender\s*(?::[^=]+)?=\s*false/; + +/** How deep to walk a routes directory, and how many files to read. */ +const MAX_DEPTH = 8; +const MAX_FILES = 3000; + +/** `output: "server"`, whichever quotes the config uses. */ +const OUTPUT_SERVER = /\boutput\s*:\s*["'`]server["'`]/; + +/** + * The first route under `dir` that renders on demand, or null. + * + * Only routes are read. `prerender` applies to a route, so a component or a + * content collection cannot carry one, and a project's `src/` can hold thousands + * of files that never could. + */ +async function findOnDemandRoute( + root: string, + dir: string, +): Promise { + let budget = MAX_FILES; + + const walk = async ( + current: string, + depth: number, + ): Promise => { + if (depth > MAX_DEPTH || budget <= 0) return null; + const entries = await readdir(current, { withFileTypes: true }).catch( + () => null, + ); + if (!entries) return null; + + const directories: string[] = []; + for (const entry of entries) { + if (entry.name.startsWith(".")) continue; + const path = join(current, entry.name); + if (entry.isDirectory()) { + directories.push(path); + continue; + } + if (!ROUTE_FILE.test(entry.name) || budget-- <= 0) continue; + const source = await readFile(path, "utf8").catch(() => null); + if (source !== null && ON_DEMAND.test(source)) { + return relative(root, path).split("\\").join("/"); + } + } + // A route beside this directory beats one below it, so the walk goes wide + // before it goes deep. + for (const child of directories) { + const found = await walk(child, depth + 1); + if (found) return found; + } + return null; + }; + + return walk(dir, 1); +} + +/** + * Why this project needs a server, or null when it needs none. + * + * Four signals, in the order they are worth reporting. Each one means the + * developer has already asked for on-demand rendering somewhere, so the deploy + * can offer the adapter that delivers it. + * + * The fourth is the one that matters most. Since Astro 5 a project prerenders + * every page unless a page opts out, and `astro build` stops with its own error + * when a page opts out and no adapter is installed. Reading the routes is what + * puts the offer before that failure rather than after it. + */ +export async function projectNeedsServer( + root: string, + preset: FrameworkPreset, +): Promise { + const adapter = preset.adapter; + if (adapter?.configStyle !== "astro") return null; + + const configPath = findAstroConfig(root); + const config = configPath ? await readFile(configPath, "utf8") : null; + const file = configPath ? basename(configPath) : "the Astro config"; + + if (config !== null) { + const vendor = vendorAdapterIn(config); + if (vendor) return { reason: `${file} uses ${vendor}`, vendor }; + if (config.includes(adapter.package)) { + return { reason: `${file} uses ${adapter.package}` }; + } + if (OUTPUT_SERVER.test(config)) { + return { reason: `${file} sets output: "server"` }; + } + } + + if (await hasAdapter(root, adapter.package)) { + return { reason: `${adapter.package} is a dependency` }; + } + + const route = await findOnDemandRoute(root, join(root, "src", "pages")); + return route === null ? null : { reason: `${route} sets prerender = false` }; +} diff --git a/packages/cli/src/commands/deploy/project.test.ts b/packages/cli/src/commands/sites/framework/project.test.ts similarity index 100% rename from packages/cli/src/commands/deploy/project.test.ts rename to packages/cli/src/commands/sites/framework/project.test.ts diff --git a/packages/cli/src/commands/deploy/project.ts b/packages/cli/src/commands/sites/framework/project.ts similarity index 93% rename from packages/cli/src/commands/deploy/project.ts rename to packages/cli/src/commands/sites/framework/project.ts index b5d5e656..cff29a8b 100644 --- a/packages/cli/src/commands/deploy/project.ts +++ b/packages/cli/src/commands/sites/framework/project.ts @@ -12,9 +12,9 @@ import { existsSync, readdirSync } from "node:fs"; import { join, relative } from "node:path"; import prompts from "prompts"; -import { UserError } from "../../core/errors.ts"; -import { logger } from "../../core/logger.ts"; -import { isInteractive } from "../../core/ui.ts"; +import { UserError } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; +import { isInteractive } from "../../../core/ui.ts"; import { findAstroConfig } from "./adapter.ts"; /** Directories that hold no deployable site, however deep the search goes. */ @@ -150,7 +150,7 @@ export async function enterProject( if (!isInteractive(output)) { throw new UserError( `There is no Astro project in this directory, and ${candidates.length} below it.`, - `Deploy one of them:\n${list}\n\nRun \`bunny deploy\` from the one you want.`, + `Deploy one of them:\n${list}\n\nRun \`bunny sites deploy\` from the one you want.`, ); } @@ -178,7 +178,7 @@ export async function enterProject( if (!chosen) { throw new UserError( "Nothing to deploy here.", - "Run `bunny deploy` in the project's own directory.", + "Run `bunny sites deploy` in the project's own directory.", ); } diff --git a/packages/cli/src/commands/deploy/health.test.ts b/packages/cli/src/commands/sites/health.test.ts similarity index 100% rename from packages/cli/src/commands/deploy/health.test.ts rename to packages/cli/src/commands/sites/health.test.ts diff --git a/packages/cli/src/commands/deploy/health.ts b/packages/cli/src/commands/sites/health.ts similarity index 100% rename from packages/cli/src/commands/deploy/health.ts rename to packages/cli/src/commands/sites/health.ts diff --git a/packages/cli/src/commands/sites/provision.ts b/packages/cli/src/commands/sites/provision.ts index 618ebe7e..0f8319db 100644 --- a/packages/cli/src/commands/sites/provision.ts +++ b/packages/cli/src/commands/sites/provision.ts @@ -1,4 +1,5 @@ import { basename } from "node:path"; +import type { BuildManifest } from "@bunny.net/config"; import prompts from "prompts"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; @@ -17,6 +18,7 @@ import { SITES_MANIFEST, type SiteManifest, } from "./constants.ts"; +import { createFrameworkSite } from "./framework/api.ts"; export const SITE_NAME_RULES = "Use 3-47 lowercase letters, digits, and dashes (no leading/trailing dash)."; @@ -110,3 +112,59 @@ export async function createLinkedSite(opts: { logger.success(`Created site "${opts.name}".`); return context; } + +/** + * Create the site for a build that renders per request, and link this directory + * to it. + * + * The same three resources as a static site, with one difference: the Edge + * Script is the build's own server rather than this CLI's router, so the pull + * zone's origin is the script. The manifest names the adapter that wrote the + * build, which the site state keeps for `bunny sites show`. + */ +export async function createLinkedFrameworkSite(opts: { + coreClient: CoreClient; + computeClient: ComputeClient; + name: string; + region?: string; + manifest: BuildManifest; +}): Promise { + const created = await withSpinner(`Creating site "${opts.name}"...`, (spin) => + createFrameworkSite({ + coreClient: opts.coreClient, + computeClient: opts.computeClient, + name: opts.name, + region: (opts.region ?? "DE").toUpperCase(), + manifest: opts.manifest, + onStep: (message) => { + spin.text = message; + }, + }), + ); + + saveManifest(SITES_MANIFEST, { + id: created.state.storageZoneId, + name: opts.name, + }); + + logger.success(`Created site "${opts.name}".`); + if (ignoreManifestDir()) { + logger.dim( + " Added .bunny/ to .gitignore; it holds the link to this site.", + ); + } + logger.dim(` storage zone ${created.storageZone.Name}`); + logger.dim(` edge script ${created.state.scriptId}`); + logger.dim( + ` pull zone ${created.systemHostname ?? created.state.pullZoneId}`, + ); + + const context = await siteContextFromZone(created.storageZone); + if (!context) { + throw new UserError( + `Created site "${opts.name}" but couldn't load its state.`, + "Re-run the command, or `bunny sites link` to retry.", + ); + } + return context; +} diff --git a/packages/config/src/build-manifest.ts b/packages/config/src/build-manifest.ts index 50c3c96d..a1d84e17 100644 --- a/packages/config/src/build-manifest.ts +++ b/packages/config/src/build-manifest.ts @@ -2,7 +2,7 @@ import { z } from "zod"; /** * The build manifest: `.bunny/build.json`, written by a framework adapter and - * read by `bunny deploy`. + * read by `bunny sites deploy`. * * This file is the whole contract between the CLI and an adapter. The CLI knows * no framework: it reads the manifest, so a new adapter needs no new CLI. The diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 4dda986b..cef73b94 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,6 +1,6 @@ // Schemas -// The build manifest an adapter writes and `bunny deploy` reads. +// The build manifest an adapter writes and `bunny sites deploy` reads. export { BUILD_MANIFEST_PATH, BUILD_MANIFEST_VERSION, diff --git a/skills/bunny-cli/SKILL.md b/skills/bunny-cli/SKILL.md index 0e5d0b4d..b096e17f 100644 --- a/skills/bunny-cli/SKILL.md +++ b/skills/bunny-cli/SKILL.md @@ -55,8 +55,9 @@ bunny dns records add example.com api A 198.51.100.1 bunny dns records preset google-workspace example.com # apply a preset record set bunny dns records list example.com -# host a static site -bunny sites create my-site # provision (served at sites-my-site-.b-cdn.net) +# host a site +bunny sites create my-site # provision a static site (served at sites-my-site-.b-cdn.net) +bunny sites deploy # build and deploy this project: an Edge Script when it renders per request, files otherwise bunny sites deploy ./dist # immutable preview URL (sites-dpl--.b-cdn.net); first deploy offers to publish bunny sites deploy ./dist --production # publish as the live site bunny sites domains add example.com --wait # custom production domain (previews never need one) @@ -71,7 +72,7 @@ Use this to route to the correct reference file: - **Database management (create, list, show, link, delete, shell, studio, regions, tokens)** -> `references/database.md` - **DNS (zones, delegation checks, records, presets, BIND import/export, DNSSEC, logging, Scriptable DNS scripts)** -> `references/dns.md` - **Edge Scripts (init, create, deploy, link, stats, deployments/rollback, env vars, custom domains)** -> `references/scripts.md` -- **Static sites (create, deploy, rollback, custom domains, domain-gated previews, GitHub Actions)** -> `references/sites.md` +- **Sites (create, deploy a directory or a framework project, rollback, custom domains, domain-gated previews, GitHub Actions)** -> `references/sites.md` - **Sandboxes (create, exec, ssh, files list/cp, public URLs, persistent env vars, Claude Code auth)** -> `references/sandbox.md` - **Make raw API requests** -> `references/api.md` - **CLI doesn't have a command for it** -> use `bunny api` as a fallback (see `references/api.md`) diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index 734593f4..eca856d9 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -1,6 +1,15 @@ -# Static Sites Commands +# Sites Commands -All site commands live under `bunny sites`. A site is one storage zone (files) + one pull zone (CDN) + one middleware router script, provisioned together by `sites create`. Deploys are immutable directories; promoting or rolling back flips a router env var and purges the cache; no files move, so it's instant. +All site commands live under `bunny sites`. A site is one storage zone (files) + one pull zone (CDN) + one Edge Script, provisioned together by `sites create` or by the first `sites deploy`. Deploys are immutable directories. + +There are two kinds of site, and the Edge Script is the difference: + +- A **static** site's script is the router this CLI generates. Promoting or rolling back flips a router env var and purges the cache; no files move, so it's instant. +- A **framework** site's script is the build's own server, from the `.bunny/build.json` a bunny.net framework adapter writes. Its client files still live in the storage zone, and each deploy's server bundle is kept beside them, so publishing an earlier deploy restores its pages and its assets together. + +`bunny sites deploy` is the one deploy command, and the build decides which path it takes. It also offers to install the adapter for a project that renders pages on demand — another vendor's adapter in the config, `output: "server"`, the adapter already a dependency, or a route under `src/pages/` with `prerender = false`. A project that prerenders every page deploys as files and is never offered one. A build and a site have to be the same kind: a script's type is fixed when it is created, so the deploy refuses a mismatch instead of uploading into it. + +A framework deploy publishes to production, and says so. One script serves one release, so it has no preview URL; preview environments for a framework site are not built yet. Most commands accept an optional site (a trailing `[site]` positional, or the `--site` flag on commands whose positionals are taken, like `deploy`). When omitted, the site resolves in this order: @@ -14,10 +23,14 @@ Commands that can link the directory (`deploy`, `show`, `deployments list/publis ## Typical workflows ```bash -# New site: provision, deploy, iterate +# New static site: provision, deploy, iterate bunny sites create my-site # served at https://sites-my-site-.b-cdn.net bunny sites deploy ./dist # immutable preview URL; the interactive first deploy offers to publish +# A project that renders per request: let the deploy create the site +bunny sites deploy # offers the adapter, builds, provisions, publishes +bunny sites deploy --name my-app --region NY # the same, unattended + # Build-and-deploy in one step (build command from bunny.jsonc or the flag) bunny sites deploy --build # runs `sites.build`, deploys `sites.dir` bunny sites deploy ./out --build "npm run build" @@ -56,7 +69,7 @@ The router reads three file names out of the deploy it serves. Cloudflare Pages The router also sets `Cache-Control` on every response, because Bunny Storage sends none for HTML: 60 seconds for a page, 30 days for anything else, and whatever `_headers` says. `sites create` turns the pull zone's own cache override off so that answer reaches the visitor, and `sites upgrade-router` does it for a site made by an earlier CLI. -A published `deploy` asks the live site for a path it cannot hold, and reports it when the answer is not the deploy's own 404 page. +A published `deploy` asks the live site for a path it cannot hold, and reports it when the answer is not the deploy's own 404 page. None of this applies to a framework site: its script is the build's own server, and the build decides what it answers with. ## Deploy IDs From 0c6a1a93c8307bac45f7627bd7887bb610d39de0 Mon Sep 17 00:00:00 2001 From: bogdan-at-bunny Date: Fri, 21 Aug 2026 15:31:51 +0000 Subject: [PATCH 10/10] Say "server script" when deleting a framework site `bunny sites delete` printed "Deleted router script 86366" for a site whose script is the build's own server. `sites show` already picks its label from the kind, and the teardown now does the same. Found while deploying a real Astro site and deleting it again. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NpdDkzyH7pPq5FZqPCRhzE --- packages/cli/src/commands/sites/api.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 5b7d10dc..4c1d2181 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -26,6 +26,7 @@ import { CURRENT_DEPLOY_VAR, deployIdFromPreviewZoneName, deployPrefix, + isFrameworkSite, isPreviewZoneName, PREVIEW_ZONE_PREFIX, parseRemoteState, @@ -744,7 +745,12 @@ export async function promoteDeploy(opts: { } export interface TeardownResult { - resource: "preview zone" | "pull zone" | "router script" | "storage zone"; + resource: + | "preview zone" + | "pull zone" + | "router script" + | "server script" + | "storage zone"; id: number; deleted: boolean; error?: string; @@ -799,10 +805,15 @@ export async function deleteSiteResources(opts: { params: { path: { id: state.pullZoneId } }, }), ); - await attempt("router script", state.scriptId, () => - computeClient.DELETE("/compute/script/{id}", { - params: { path: { id: state.scriptId } }, - }), + // A framework site's script is the build's own server, not this CLI's router, + // and a line that says "router script" about it is simply wrong. + await attempt( + isFrameworkSite(state) ? "server script" : "router script", + state.scriptId, + () => + computeClient.DELETE("/compute/script/{id}", { + params: { path: { id: state.scriptId } }, + }), ); if (opts.keepStorage) { // The zone survives, so remove its site marker, else list/link/show rediscover a "site" whose pull zone and router are gone. But only once everything else deleted: the marker is what makes a re-run able to find and retry the failures.