From a9a41b4f2971cabe8af5c42a5799764dba0fccec Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Fri, 31 Jul 2026 19:26:10 +0100 Subject: [PATCH 1/6] feat(sites): gate previews behind a custom domain --- .../sites-domainless-production-deploys.md | 5 ++ AGENTS.md | 10 +-- README.md | 10 +-- packages/cli/src/commands/sites/ci/init.ts | 17 +++- .../cli/src/commands/sites/ci/scaffold.ts | 5 +- .../src/commands/sites/ci/workflow.test.ts | 14 ++++ .../cli/src/commands/sites/ci/workflow.ts | 18 +++-- packages/cli/src/commands/sites/create.ts | 9 ++- packages/cli/src/commands/sites/deploy.ts | 80 ++++++++++++++---- .../src/commands/sites/router/source.test.ts | 40 +++------ .../cli/src/commands/sites/router/source.ts | 81 +++---------------- 11 files changed, 150 insertions(+), 139 deletions(-) create mode 100644 .changeset/sites-domainless-production-deploys.md diff --git a/.changeset/sites-domainless-production-deploys.md b/.changeset/sites-domainless-production-deploys.md new file mode 100644 index 00000000..d69ea0e9 --- /dev/null +++ b/.changeset/sites-domainless-production-deploys.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": minor +--- + +feat(sites): deploys publish straight to production when no custom domain is attached; previews are now a custom-domain feature. Attaching a domain unlocks the preview/--production flow with per-deploy `dpl-{id}.preview.{domain}` URLs, which are root-served so client-side routers (TanStack Router, React Router) work exactly like production. The `/deploys/{id}/` path previews and the router's HTMLRewriter are gone: they broke SPA route matching, and old deploys are no longer publicly browsable (run `bunny sites upgrade-router` to pick this up on existing sites). `bunny sites ci init` now scaffolds PR preview deploys only when the site has a custom domain; without one the workflow deploys production on pushes to main only. A domainless site's first deploy offers to attach a custom domain (interactive runs; blank to skip, and it never re-asks), and later domainless deploys print a `sites domains add` hint instead. diff --git a/AGENTS.md b/AGENTS.md index a8c25c40..63f3fef2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -387,7 +387,7 @@ bunny-cli/ │ │ │ ├── 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: the middleware Edge Script (one script per site; no version tracking; upgrade-router just republishes the latest). apex → CURRENT_DEPLOY, dpl-{id}.preview.{domain} → that deploy, /deploys/{id}/ passthrough (path preview) flagged with x-bunny-preview header, /_bunny/* → 403 (client-sent x-bunny-preview/x-bunny-index-retry headers are stripped; the flags are 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: HTMLRewriter rewrites root-absolute href/src/srcset in flagged path-preview HTML → /deploys/{id}/… (so Jekyll/SSG assets render on one PZ; each deploy's assets get a unique cache key), and X-Robots-Tag: noindex on all previews. Production HTML is never rewritten (no header), so promote doesn't churn its cache +│ │ │ ├── router/source.ts # routerSource: the middleware Edge Script (one script per site; no version tracking; upgrade-router just republishes the latest). apex → CURRENT_DEPLOY, dpl-{id}.preview.{domain} → that deploy (both root-served, so client-side routers work; no path previews and no HTML rewriting), /_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 │ │ │ ├── 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 @@ -399,7 +399,7 @@ bunny-cli/ │ │ │ ├── show.ts # Site details + hostname table (SSL cert + Force SSL columns) + router-outdated warning │ │ │ ├── open.ts # bunny sites open [site]: open the live URL (recorded custom domain when live, 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) → hash → no-op if unchanged → upload deploys/{id}/ → state update → immutable preview URL (custom-domain dpl-{id}.preview.* when a domain exists, else the sites--.b-cdn.net/deploys/{id}/ path; rendered correctly by the router's HTMLRewriter), or publish live with --production/--prod +│ │ │ ├── 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) → hash → no-op if unchanged → upload deploys/{id}/ → state update → publish. Without a custom domain every deploy publishes live (there are no preview hosts); with one the default is an immutable dpl-{id}.preview.* preview URL and --production/--prod publishes. A domainless site's first-ever deploy (state.deploys was empty; stateless, so declining never re-asks) offers the custom-domain prompt (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 (pushes router improvements to an existing site) @@ -1105,14 +1105,14 @@ 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 Manage sites. -│ │ 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. A deploy's immutable preview URL is `sites--.b-cdn.net/deploys/{id}/`; the router's HTMLRewriter rewrites root-absolute asset URLs in that path-preview HTML to `/deploys/{id}/…` so Jekyll/most SSGs render on a single pull zone (each deploy's assets get a unique cache key). Custom domains add isolated per-deploy subdomains (`dpl-{id}.preview.{domain}`, root-served, no rewriting). 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`. +│ │ 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. Previews only exist behind a custom domain: each deploy gets an isolated `dpl-{id}.preview.{domain}` subdomain (root-served via the `*.preview.{domain}` wildcard, so client-side routers behave exactly like production). Without a domain there are no preview URLs and every deploy publishes live. 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 also attaches *.preview. for per-deploy previews; 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). │ ├── 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 to a preview URL: git short-sha ID when the tree is clean, content hash otherwise; identical IDs are no-ops (an already-uploaded ID with --production skips the upload and just publishes). The immutable preview URL is `sites--.b-cdn.net/deploys/{id}/` (custom-domain `dpl-{id}.preview.*` when a domain exists), rendered correctly by the router's HTMLRewriter. 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. --production/--prod publishes the deploy as the live site. +│ │ 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). Without a custom domain every deploy publishes live; with one the default is the immutable `dpl-{id}.preview.*` preview URL and --production/--prod publishes. 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. │ ├── deployments │ │ ├── list [site] [--link] (alias: ls) List deploys (● Live / ○ Previous markers, created, source, files, size) │ │ ├── publish [id] [--previous] [--site] [--link] [--force] (alias: promote) @@ -1125,7 +1125,7 @@ bunny │ │ └── remove [site] [--force] Remove a domain (also removes its *.preview wildcard, onRemoved hook) │ ├── ssl [site] [--no-force-ssl] Toggle Force HTTPS on the site's b-cdn.net system host (no cert issued; custom domains use `sites domains ssl`) │ ├── ci -│ │ └── init [--site] [--link] [--framework] [--force] Write .github/workflows/bunny-sites.yml at the git root: `sites.dir`/`sites.build` from bunny.jsonc override the preset's directory and build command (so CI deploys what `sites deploy` does), and a bunny.jsonc below the git root sets the job's working directory + prefixes the deploy directory; else framework detection (package.json deps, Gemfile, hugo/python/zola config files; lockfile picks the package manager), previews on PRs + production on main via BunnyWay/actions/deploy-site, offers `gh secret set BUNNY_API_KEY` +│ │ └── init [--site] [--link] [--framework] [--force] Write .github/workflows/bunny-sites.yml at the git root: `sites.dir`/`sites.build` from bunny.jsonc override the preset's directory and build command (so CI deploys what `sites deploy` does), and a bunny.jsonc below the git root sets the job's working directory + prefixes the deploy directory; else framework detection (package.json deps, Gemfile, hugo/python/zola config files; lockfile picks the package manager), via BunnyWay/actions/deploy-site: previews on PRs + production on main when the site has a custom domain, production on main only when it doesn't (deploys publish, so PR builds must not run), offers `gh secret set BUNNY_API_KEY` │ ├── link [site] Link this directory to a site → .bunny/site.json │ ├── unlink Remove .bunny/site.json │ ├── upgrade-router [site] [--link] Republish the site's router script with the CLI's current source diff --git a/README.md b/README.md index c3e6ec22..07b94c86 100644 --- a/README.md +++ b/README.md @@ -57,17 +57,17 @@ bun ny dns records preset list # list DNS record presets (email pro 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 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 -bun ny sites deploy ./dist # deploy to an immutable preview URL (the site's b-cdn.net host + /deploys//); the router's HTMLRewriter keeps root-absolute assets working -bun ny sites deploy ./dist --production # deploy and publish as the live site (--prod works too) +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 a custom domain) +bun ny sites deploy ./dist # deploy the site: goes live directly, or to an immutable preview URL (dpl-.preview.) once a custom domain is attached +bun ny sites deploy ./dist --production # publish as the live site when a custom domain gives you previews (--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) bun ny sites deployments list # list deploys with the live one marked bun ny sites deployments publish --previous # instant rollback to the previous deploy bun ny sites deployments prune # delete old deploys (keeps the newest 5, never current/previous) -bun ny sites domains add example.com # attach a custom domain (+ *.preview.example.com for previews) +bun ny sites domains add example.com # attach a custom domain (+ *.preview.example.com, which unlocks per-deploy preview URLs) bun ny sites ssl --no-force-ssl # stop forcing HTTPS on the site's b-cdn.net system host bun ny sites open # open the site's live URL in the browser -bun ny sites ci init # add a GitHub Actions workflow (preview on PRs, production on main) +bun ny sites ci init # add a GitHub Actions workflow (with a custom domain: previews on PRs + production on main; without: production on main) ``` 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/src/commands/sites/ci/init.ts b/packages/cli/src/commands/sites/ci/init.ts index abf7a9ac..7434eef0 100644 --- a/packages/cli/src/commands/sites/ci/init.ts +++ b/packages/cli/src/commands/sites/ci/init.ts @@ -24,7 +24,7 @@ interface CiInitArgs extends SiteSelectorArgs { force?: boolean; } -// Scaffold `.github/workflows/bunny-sites.yml`: previews on PRs, production on merges to main, via the BunnyWay/actions deploy-site action. +// Scaffold `.github/workflows/bunny-sites.yml` via the BunnyWay/actions deploy-site action: with a custom domain, previews on PRs + production on merges to main; without one, production on merges to main only. export const sitesCiInitCommand = defineCommand({ command: "init", describe: "Add a GitHub Actions workflow that deploys this site.", @@ -72,6 +72,7 @@ export const sitesCiInitCommand = defineCommand({ // `sites.dir`/`sites.build` are what a local deploy uses, so the workflow follows them, relative to the bunny.jsonc directory they resolve against. const siteConfig = loadSiteConfig(); + const previews = Boolean(site.state.domain); const result = await scaffoldSitesWorkflow({ site: name, root, @@ -81,6 +82,7 @@ export const sitesCiInitCommand = defineCommand({ force: args.force, dir: siteConfig?.config.dir, build: siteConfig?.config.build, + previews, }); if (output === "json") { @@ -111,9 +113,16 @@ export const sitesCiInitCommand = defineCommand({ logger.log(); await offerGitHubSecret({ apiKey: config.apiKey, root, interactive }); logger.log(); - logger.dim( - " Push to GitHub: PRs get preview URLs, merges to main go live.", - ); + if (previews) { + logger.dim( + " Push to GitHub: PRs get preview URLs, merges to main go live.", + ); + } else { + logger.dim(" Push to GitHub: merges to main deploy the live site."); + logger.dim( + " Add a custom domain (`bunny sites domains add`), then re-run `bunny sites ci init --force` for PR previews.", + ); + } await offerLink(); }, diff --git a/packages/cli/src/commands/sites/ci/scaffold.ts b/packages/cli/src/commands/sites/ci/scaffold.ts index 683ed0ec..a600a4bc 100644 --- a/packages/cli/src/commands/sites/ci/scaffold.ts +++ b/packages/cli/src/commands/sites/ci/scaffold.ts @@ -168,6 +168,7 @@ export async function scaffoldSitesWorkflow(opts: { force?: boolean; dir?: string; build?: string; + previews?: boolean; }): Promise { const settings = await workflowSettings(opts); const preset = await resolvePreset( @@ -186,6 +187,7 @@ export async function scaffoldSitesWorkflow(opts: { workingDirectory: settings.prefix || undefined, cacheDependencyPath: settings.cacheDependencyPath, installDeps: await needsJsInstall(preset, settings), + previews: opts.previews, }); const target = join(opts.root, SITES_WORKFLOW_PATH); @@ -217,7 +219,7 @@ export async function scaffoldSitesWorkflow(opts: { export async function printWorkflowInstructions( site: string, root: string, - config?: { root?: string; dir?: string; build?: string }, + config?: { root?: string; dir?: string; build?: string; previews?: boolean }, ): Promise { const settings = await workflowSettings({ root, @@ -242,6 +244,7 @@ export async function printWorkflowInstructions( workingDirectory: settings.prefix || undefined, cacheDependencyPath: settings.cacheDependencyPath, installDeps: await needsJsInstall(preset, settings), + previews: config?.previews, }), ); printSecretHint(); diff --git a/packages/cli/src/commands/sites/ci/workflow.test.ts b/packages/cli/src/commands/sites/ci/workflow.test.ts index 51ed287b..13067ea3 100644 --- a/packages/cli/src/commands/sites/ci/workflow.test.ts +++ b/packages/cli/src/commands/sites/ci/workflow.test.ts @@ -28,6 +28,20 @@ test("astro + bun workflow builds with bun and deploys dist", () => { ); }); +// Without a custom domain there are no previews, and a preview-less deploy publishes, so the workflow must not run on PRs. +test("a previews-less workflow only deploys pushes to main, always as production", () => { + const yml = renderSitesWorkflow({ + site: "my-site", + preset: preset("astro"), + packageManager: "bun", + previews: false, + }); + expect(yml).not.toContain("pull_request"); + expect(yml).not.toContain("pull-requests: write"); + expect(yml).toContain("production: true"); + expect(yml).not.toContain("production: ${{"); +}); + test("jekyll workflow uses ruby and deploys _site", () => { const yml = renderSitesWorkflow({ site: "blog", diff --git a/packages/cli/src/commands/sites/ci/workflow.ts b/packages/cli/src/commands/sites/ci/workflow.ts index c0978702..c3cdc4ec 100644 --- a/packages/cli/src/commands/sites/ci/workflow.ts +++ b/packages/cli/src/commands/sites/ci/workflow.ts @@ -141,7 +141,7 @@ export function workflowPath(prefix: string | undefined, path: string): string { return path === "." ? prefix : `${prefix.replace(/\/$/, "")}/${path}`; } -// Render the GitHub Actions workflow: previews on PRs, production on pushes to main, via the BunnyWay/actions deploy-site action. `dir`/`build` carry `sites.dir`/`sites.build` from bunny.jsonc, `workingDirectory` is where that config lives relative to the workflow root, and `installDeps` adds the JS setup/install steps to a configured build the preset wouldn't have installed for, so CI builds and deploys exactly what `sites deploy` does. +// Render the GitHub Actions workflow via the BunnyWay/actions deploy-site action: with `previews` (a custom domain exists) PRs deploy previews and pushes to main go live, without it the workflow only runs on pushes to main (a preview-less deploy publishes, so PR builds must not deploy). `dir`/`build` carry `sites.dir`/`sites.build` from bunny.jsonc, `workingDirectory` is where that config lives relative to the workflow root, and `installDeps` adds the JS setup/install steps to a configured build the preset wouldn't have installed for, so CI builds and deploys exactly what `sites deploy` does. export function renderSitesWorkflow(opts: { site: string; preset: FrameworkPreset; @@ -151,8 +151,10 @@ export function renderSitesWorkflow(opts: { workingDirectory?: string; cacheDependencyPath?: string; installDeps?: boolean; + previews?: boolean; }): string { const { site, preset, packageManager, workingDirectory } = opts; + const previews = opts.previews !== false; // Every `run` step builds from the project directory; `uses` inputs stay workflow-root-relative, so the deploy directory carries the prefix instead. const defaults = workingDirectory ? [ @@ -166,7 +168,7 @@ export function renderSitesWorkflow(opts: { "on:", " push:", " branches: [main]", - " pull_request:", + ...(previews ? [" pull_request:"] : []), "", "# One deploy at a time per ref; a newer commit cancels the older build.", "concurrency:", @@ -177,11 +179,15 @@ export function renderSitesWorkflow(opts: { " deploy:", " runs-on: ubuntu-latest", ...defaults, - " # Fork PRs have no access to secrets; skip instead of failing.", - " if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository", + ...(previews + ? [ + " # Fork PRs have no access to secrets; skip instead of failing.", + " if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository", + ] + : []), " permissions:", " contents: read", - " pull-requests: write # preview comment", + ...(previews ? [" pull-requests: write # preview comment"] : []), " steps:", " - uses: actions/checkout@v4", "", @@ -198,7 +204,7 @@ export function renderSitesWorkflow(opts: { // Quote the interpolated values so they're always inert YAML scalars. ` site: ${JSON.stringify(site)}`, ` directory: ${JSON.stringify(workflowPath(workingDirectory, opts.dir ?? preset.dir))}`, - " production: ${{ github.event_name == 'push' }}", + ` production: ${previews ? "${{ github.event_name == 'push' }}" : "true"}`, " api_key: ${{ secrets.BUNNY_API_KEY }}", ]; return `${lines.join("\n")}\n`; diff --git a/packages/cli/src/commands/sites/create.ts b/packages/cli/src/commands/sites/create.ts index 2091adea..c94693ef 100644 --- a/packages/cli/src/commands/sites/create.ts +++ b/packages/cli/src/commands/sites/create.ts @@ -198,6 +198,8 @@ export const sitesCreateCommand = defineCommand({ }); chosenDomain = normalizeHostname(value ?? "") || undefined; } + // Previews (and the PR flow in CI) exist only once a custom domain is attached. + let previews = false; if (chosenDomain) { // A domain failure mustn't fail the create; the site already exists and the domain can be retried via `sites domains add`. logger.log(); @@ -208,6 +210,7 @@ export const sitesCreateCommand = defineCommand({ interactive, verbose, }); + previews = !domainError; if (domainError) { logger.warn( `Couldn't finish setting up ${chosenDomain}: ${domainError}`, @@ -224,7 +227,9 @@ export const sitesCreateCommand = defineCommand({ if (root && (await hasGitHubOrigin(root))) { logger.log(); const setup = await confirm( - "Set up GitHub deployments (preview on PRs, production on main)?", + previews + ? "Set up GitHub deployments (preview on PRs, production on main)?" + : "Set up GitHub deployments (production on pushes to main)?", { initial: true }, ); if (setup) { @@ -235,6 +240,7 @@ export const sitesCreateCommand = defineCommand({ interactive: true, dir: siteConfig?.dir, build: siteConfig?.build, + previews, }); if (scaffold) { logger.success( @@ -250,6 +256,7 @@ export const sitesCreateCommand = defineCommand({ await printWorkflowInstructions(name, root, { root: configRoot, ...siteConfig, + previews, }); } } diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index dd23877c..3320b9c5 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -4,12 +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 { UserError } from "../../core/errors.ts"; +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 { confirm, isInteractive, withSpinner } from "../../core/ui.ts"; import { @@ -27,11 +29,11 @@ import { import { loadSiteConfig } from "./config.ts"; import { type DeployRecord, - deployPrefix, markCurrent, previewHostname, } from "./constants.ts"; import { resolveDeployIdentity } from "./deploy-id.ts"; +import { setupSiteDomain } from "./domains/index.ts"; import { type SiteSelectorArgs, selectSite, @@ -50,7 +52,7 @@ interface DeployArgs extends SiteSelectorArgs { force?: boolean; } -// Production and preview URLs for a deploy: with a custom domain the preview is `dpl-{id}.preview.{domain}`, else the `/deploys/{id}/` path the router's HTMLRewriter renders correctly. +// Production and preview URLs for a deploy: previews are `dpl-{id}.preview.{domain}` hosts, so they only exist once a custom domain is attached. function deployUrls( site: SiteContext, deployId: string, @@ -62,9 +64,7 @@ function deployUrls( production: productionHost ? `https://${productionHost}` : undefined, preview: domain ? `https://${previewHostname(deployId, domain)}` - : productionHost - ? `https://${productionHost}/${deployPrefix(deployId)}/` - : undefined, + : undefined, }; } @@ -79,12 +79,15 @@ export function resolveDeployDir( return resolve(root, configDir ?? autoDir ?? "."); } -// Deploy a directory: hash, skip if unchanged, upload to `deploys/{id}/`, record state, serve a preview URL; `--production` also publishes it live, `--build` runs the build first with `--env`/`--env-file` overrides. +// Deploy a directory: hash, skip if unchanged, upload to `deploys/{id}/`, record state, publish. Without a custom domain every deploy goes live; with one the default is a preview and `--production` publishes. `--build` runs the build first with `--env`/`--env-file` overrides. export const sitesDeployCommand = defineCommand({ command: "deploy [dir]", describe: "Deploy a directory to a site.", examples: [ - ["$0 sites deploy ./dist", "Deploy to a preview URL"], + [ + "$0 sites deploy ./dist", + "Deploy the site (a preview when a custom domain is attached, else live)", + ], [ "$0 sites deploy ./dist --production", "Deploy and publish as the live site", @@ -125,7 +128,7 @@ export const sitesDeployCommand = defineCommand({ type: "boolean", default: false, describe: - "Publish the deploy as the live site (default: preview only)", + "Publish the deploy as the live site (always on when the site has no custom domain; with one, the default is a preview)", }) .option("force", { type: "boolean", @@ -173,6 +176,11 @@ export const sitesDeployCommand = defineCommand({ }); const { state, connection } = site; + // No custom domain means no preview hosts, so every deploy publishes; with a domain, previews are the default and --production is the publish switch. + const publish = args.production === true || !state.domain; + // 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. + const firstDeploy = state.deploys.length === 0; + let etag = site.etag; let autoDir: string | undefined; @@ -236,8 +244,8 @@ export const sitesDeployCommand = defineCommand({ const deployId = alreadyUploaded?.id ?? identity.id; const alreadyLive = state.current === deployId; - // Nothing to do: the deploy is already uploaded (and live, if --production). - if (skipUpload && (alreadyLive || !args.production)) { + // Nothing to do: the deploy is already uploaded (and live, if publishing). + if (skipUpload && (alreadyLive || !publish)) { const urls = deployUrls( site, deployId, @@ -300,7 +308,7 @@ export const sitesDeployCommand = defineCommand({ etag = await writeRemoteState(connection, state, etag); } - if (args.production) { + if (publish) { await withSpinner("Publishing to production...", async () => { await promoteDeploy({ computeClient, @@ -330,7 +338,7 @@ export const sitesDeployCommand = defineCommand({ source: identity.source, files: files.length, bytes: totalBytes, - promoted: args.production === true, + promoted: publish, production: urls.production ?? null, preview: urls.preview ?? null, }, @@ -348,7 +356,7 @@ export const sitesDeployCommand = defineCommand({ `Deployed ${deployId} (${files.length} files, ${formatBytes(totalBytes)}).`, ); } - if (args.production) { + if (publish) { if (urls.production) logger.info(`Production: ${urls.production}`); if (urls.preview) logger.log(` Preview: ${urls.preview}`); } else { @@ -358,6 +366,50 @@ export const sitesDeployCommand = defineCommand({ ); } + // Domainless sites: the first deploy offers a custom domain (which unlocks preview deploys), 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 (unlocks preview deploys; leave blank to skip):", + }); + const domain = normalizeHostname(value ?? "") || 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, + }); + logger.dim( + " From now on `bunny sites deploy` creates a preview; publish with --production.", + ); + } 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( + " Add a custom domain to unlock preview deploys: bunny sites domains add ", + ); + } + } + await offerLink(); }, }); diff --git a/packages/cli/src/commands/sites/router/source.test.ts b/packages/cli/src/commands/sites/router/source.test.ts index 7e91dc0a..cc7a74a2 100644 --- a/packages/cli/src/commands/sites/router/source.test.ts +++ b/packages/cli/src/commands/sites/router/source.test.ts @@ -12,35 +12,31 @@ function extractFn(name: string): (...args: unknown[]) => unknown { ) => unknown; } -const withDeploy = extractFn("withDeploy") as ( - id: string, - value: string, -) => string; const indexRetryUrl = extractFn("indexRetryUrl") as ( rawUrl: string, ) => string | null; -test("routerSource wires up the preview machinery", () => { +test("routerSource wires up the deploy routing", () => { const src = routerSource; expect(src).toContain("bunny sites router"); - // Serves the promoted deploy at the apex and per-deploy path previews. + // Serves the promoted deploy at the apex and each deploy on its dpl-{id}.preview.* host. expect(src).toContain("process.env.CURRENT_DEPLOY"); + expect(src).toContain("const PREVIEW_HOST = /^dpl-([a-z0-9]{4,40})"); expect(src).toContain('url.pathname = "/deploys/" + deploy + path;'); - // Directory URLs expand to index.html before any branching, so path previews get it too. + // Directory URLs expand to index.html before any branching. expect(src).toContain( 'if (url.pathname.endsWith("/")) url.pathname += "index.html";', ); - // Flags previews so the response phase rewrites their HTML (and never production's). - expect(src).toContain('const PREVIEW_HEADER = "x-bunny-preview";'); // 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 && response.status === 404)"); + expect(src).toContain("if (retry && ctx.response.status === 404)"); expect(src).toContain("{ status: 301, headers: { Location: retry } }"); - // Client-sent flags must be stripped, or they'd poison cached HTML. - expect(src).toContain("headers.delete(PREVIEW_HEADER);"); + // The client-sent flag must be stripped, or it'd poison cached HTML. expect(src).toContain("headers.delete(RETRY_HEADER);"); - expect(src).toContain("new HTMLRewriter()"); expect(src).toContain("X-Robots-Tag"); + // Path previews are gone: deploys are only reachable at the apex or a preview host. + expect(src).not.toContain("HTMLRewriter"); + expect(src).not.toContain("x-bunny-preview"); }); test("indexRetryUrl targets the directory index for slashless paths only", () => { @@ -51,9 +47,6 @@ test("indexRetryUrl targets the directory index for slashless paths only", () => expect(indexRetryUrl("https://x.b-cdn.net/v2.1/docs")).toBe( "https://x.b-cdn.net/v2.1/docs/", ); - expect(indexRetryUrl("https://x.b-cdn.net/deploys/abcd/blog")).toBe( - "https://x.b-cdn.net/deploys/abcd/blog/", - ); // Query strings survive the retry. expect(indexRetryUrl("https://x.b-cdn.net/blog?page=2")).toBe( "https://x.b-cdn.net/blog/?page=2", @@ -62,18 +55,3 @@ test("indexRetryUrl targets the directory index for slashless paths only", () => expect(indexRetryUrl("https://x.b-cdn.net/blog/")).toBeNull(); expect(indexRetryUrl("https://x.b-cdn.net/")).toBeNull(); }); - -test("withDeploy prefixes only root-absolute, un-prefixed paths", () => { - expect(withDeploy("abcd", "/assets/main.css")).toBe( - "/deploys/abcd/assets/main.css", - ); - // Already prefixed; left alone (idempotent). - expect(withDeploy("abcd", "/deploys/abcd/x.js")).toBe("/deploys/abcd/x.js"); - // Protocol-relative, absolute, relative, and anchors are untouched. - expect(withDeploy("abcd", "//cdn.example.com/x.js")).toBe( - "//cdn.example.com/x.js", - ); - expect(withDeploy("abcd", "https://x.com/a.css")).toBe("https://x.com/a.css"); - expect(withDeploy("abcd", "assets/main.css")).toBe("assets/main.css"); - expect(withDeploy("abcd", "#section")).toBe("#section"); -}); diff --git a/packages/cli/src/commands/sites/router/source.ts b/packages/cli/src/commands/sites/router/source.ts index 3ac6e1b7..238715e0 100644 --- a/packages/cli/src/commands/sites/router/source.ts +++ b/packages/cli/src/commands/sites/router/source.ts @@ -1,12 +1,10 @@ -// The site's middleware Edge Script: maps hosts to `/deploys/{id}/` origin paths and rewrites root-absolute asset URLs in path-preview HTML (see AGENTS.md and the SOURCE comments below). BunnySDK hook and HTMLRewriter names are the platform contract. +// The site's middleware Edge Script: maps the apex to the published deploy dir and `dpl-{id}.preview.*` hosts to theirs (see AGENTS.md and the SOURCE comments below). BunnySDK hook names are the platform contract. export const routerSource = `// bunny sites router, 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_HOST = /^dpl-([a-z0-9]{4,40})\\.preview\\./i; -const PREVIEW_HEADER = "x-bunny-preview"; const RETRY_HEADER = "x-bunny-index-retry"; -const DEPLOY_PATH = /^\\/deploys\\/([a-z0-9]{4,40})\\//i; const NO_DEPLOYS_PAGE = \` No deploys yet @@ -23,45 +21,11 @@ function indexRetryUrl(rawUrl) { return u.toString(); } -// Prefix root-absolute, un-prefixed paths with the deploy dir; leave everything else alone. -function withDeploy(id, value) { - if (!value || value[0] !== "/" || value[1] === "/") return value; - if (value.startsWith("/deploys/")) return value; - return "/deploys/" + id + value; -} - -function rewriteAttr(id, attr) { - return { - element(el) { - const v = el.getAttribute(attr); - if (v) el.setAttribute(attr, withDeploy(id, v)); - }, - }; -} - -function rewriteSrcset(id) { - return { - element(el) { - const v = el.getAttribute("srcset"); - if (!v) return; - const out = v - .split(",") - .map((part) => { - const seg = part.trim().split(/\\s+/); - seg[0] = withDeploy(id, seg[0]); - return seg.join(" "); - }) - .join(", "); - el.setAttribute("srcset", out); - }, - }; -} - BunnySDK.net.http .servePullZone() .onOriginRequest(async (ctx) => { const url = new URL(ctx.request.url); - // Storage serves no directory indexes: expand \`/dir/\` to \`/dir/index.html\` on every route, path previews included. + // 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; @@ -70,9 +34,8 @@ BunnySDK.net.http return new Response("Forbidden", { status: 403 }); } - // Both flags are router-internal: client-sent copies are stripped, or they'd poison cached HTML. + // The flag is router-internal: client-sent copies are stripped, or they'd poison cached HTML. const headers = new Headers(ctx.request.headers); - headers.delete(PREVIEW_HEADER); headers.delete(RETRY_HEADER); // Exact objects win: a slashless GET/HEAD miss retries as its directory index in the response phase. @@ -81,13 +44,6 @@ BunnySDK.net.http if (retry) headers.set(RETRY_HEADER, retry); } - // Path preview: serve the deploy dir directly, flagging the request so the response phase rewrites its HTML. - if (path === "/deploys" || path.startsWith("/deploys/")) { - const match = DEPLOY_PATH.exec(path); - if (match) headers.set(PREVIEW_HEADER, match[1].toLowerCase()); - return new Request(new Request(url.toString(), ctx.request), { headers }); - } - const preview = PREVIEW_HOST.exec(url.hostname); const deploy = preview ? preview[1].toLowerCase() @@ -104,41 +60,22 @@ BunnySDK.net.http return new Request(new Request(url.toString(), ctx.request), { headers }); }) .onOriginResponse(async (ctx) => { - let response = ctx.response; - // 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 && response.status === 404) { + 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 } }); } } - const previewId = ctx.request.headers.get(PREVIEW_HEADER); - const isCustomPreview = PREVIEW_HOST.test(new URL(ctx.request.url).hostname); - if (!previewId && !isCustomPreview) return; - - // Path previews serve under a subpath: rewrite root-absolute asset refs into the deploy. - const contentType = response.headers.get("content-type") || ""; - if (previewId && contentType.includes("text/html")) { - response = new HTMLRewriter() - .on("a[href]", rewriteAttr(previewId, "href")) - .on("link[href]", rewriteAttr(previewId, "href")) - .on("script[src]", rewriteAttr(previewId, "src")) - .on("img[src]", rewriteAttr(previewId, "src")) - .on("img[srcset]", rewriteSrcset(previewId)) - .on("source[src]", rewriteAttr(previewId, "src")) - .on("source[srcset]", rewriteSrcset(previewId)) - .transform(response); - } - // Previews must never be indexed. - const headers = new Headers(response.headers); + if (!PREVIEW_HOST.test(new URL(ctx.request.url).hostname)) return; + const headers = new Headers(ctx.response.headers); headers.set("X-Robots-Tag", "noindex"); - return new Response(response.body, { - status: response.status, - statusText: response.statusText, + return new Response(ctx.response.body, { + status: ctx.response.status, + statusText: ctx.response.statusText, headers, }); }); From a005abda05a5d58b0c9fda45d5e0c4426909858c Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Fri, 31 Jul 2026 19:39:11 +0100 Subject: [PATCH 2/6] fix(sites): record state.domain only when the preview wildcard attaches --- .../sites-domainless-production-deploys.md | 2 +- AGENTS.md | 6 +- packages/cli/src/commands/sites/create.ts | 32 ++++++----- packages/cli/src/commands/sites/deploy.ts | 9 ++- .../src/commands/sites/domains/index.test.ts | 57 +++++++++++++++++++ .../cli/src/commands/sites/domains/index.ts | 23 +++++--- .../src/commands/sites/router/source.test.ts | 3 - 7 files changed, 99 insertions(+), 33 deletions(-) create mode 100644 packages/cli/src/commands/sites/domains/index.test.ts diff --git a/.changeset/sites-domainless-production-deploys.md b/.changeset/sites-domainless-production-deploys.md index d69ea0e9..a88a9984 100644 --- a/.changeset/sites-domainless-production-deploys.md +++ b/.changeset/sites-domainless-production-deploys.md @@ -2,4 +2,4 @@ "@bunny.net/cli": minor --- -feat(sites): deploys publish straight to production when no custom domain is attached; previews are now a custom-domain feature. Attaching a domain unlocks the preview/--production flow with per-deploy `dpl-{id}.preview.{domain}` URLs, which are root-served so client-side routers (TanStack Router, React Router) work exactly like production. The `/deploys/{id}/` path previews and the router's HTMLRewriter are gone: they broke SPA route matching, and old deploys are no longer publicly browsable (run `bunny sites upgrade-router` to pick this up on existing sites). `bunny sites ci init` now scaffolds PR preview deploys only when the site has a custom domain; without one the workflow deploys production on pushes to main only. A domainless site's first deploy offers to attach a custom domain (interactive runs; blank to skip, and it never re-asks), and later domainless deploys print a `sites domains add` hint instead. +feat(sites): custom domains unlock preview deploys. Without a domain, `bunny sites deploy` publishes straight to production; attaching one switches deploys to immutable `dpl-{id}.preview.{domain}` preview URLs with `--production`/`--prod` publishing. Preview hosts are root-served, so client-side routers (TanStack Router, React Router) behave exactly like production, and deploys are only reachable through the apex or their preview host. `bunny sites ci init` scaffolds PR preview deploys when the site has a custom domain; without one the workflow deploys production on pushes to main only. A domainless site's first deploy offers to attach a custom domain (interactive runs; blank to skip, and it never re-asks), and later domainless deploys print a `sites domains add` hint. The domain is only recorded as the preview switch once its `*.preview` wildcard hostname is attached, so a partial domain setup can't route deploys at previews that can't serve. diff --git a/AGENTS.md b/AGENTS.md index 63f3fef2..30d94926 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -387,7 +387,7 @@ bunny-cli/ │ │ │ ├── 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: the middleware Edge Script (one script per site; no version tracking; upgrade-router just republishes the latest). apex → CURRENT_DEPLOY, dpl-{id}.preview.{domain} → that deploy (both root-served, so client-side routers work; no path previews and no HTML rewriting), /_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: the middleware Edge Script (one script per site; no version tracking; upgrade-router just republishes the latest). apex → CURRENT_DEPLOY, dpl-{id}.preview.{domain} → 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 │ │ │ ├── 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 @@ -406,7 +406,7 @@ bunny-cli/ │ │ │ ├── delete.ts # Delete a site (typed-name confirm; --keep-storage; drops .bunny/site.json if it pointed here) │ │ │ ├── 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) + prune.test.ts -│ │ │ └── domains/index.ts # Mounts core/hostnames createHostnamesCommands as "sites domains" with onAdded/onRemoved hooks: apex add also attaches *.preview. (attachPreviewWildcard, best-effort SSL) + records state.domain; remove takes the wildcard down too. setupSiteDomain composes setupHostname + wildcard for create --domain +│ │ │ └── domains/index.ts # Mounts core/hostnames createHostnamesCommands as "sites domains" with onAdded/onRemoved hooks: apex add also attaches *.preview. (attachPreviewWildcard, best-effort SSL) and records state.domain ONLY when the wildcard attached (state.domain is the previews-operational signal that flips deploy/CI into preview mode; recordSiteDomain rolls back the in-memory value if the state write fails); remove takes the wildcard down too. setupSiteDomain composes setupHostname + wildcard for create --domain │ │ ├── registries/ │ │ │ ├── index.ts # Manual CommandModule (not defineNamespace) — default handler runs list │ │ │ ├── list.ts # List container registries @@ -1119,7 +1119,7 @@ bunny │ │ │ Promote a past deploy; instant rollback (--previous = the previous deploy). Unattended runs need --force (the confirmation is guarded by requireConfirmable) │ │ └── prune [site] [--keep N] [--force] Delete old deploys (never current/previous; default keeps 5). --keep is validated as a whole number >= 0 before anything runs (resolveKeepCount: yargs turns `--keep abc` into NaN, which would otherwise slip through pruneVictims and prune everything). Unattended runs need --force; "nothing to prune" still succeeds without it │ ├── domains (hidden alias: hostnames); mounts core/hostnames createHostnamesCommands with a sites resolver -│ │ ├── add [site] [--ssl] [--wait] [--no-force-ssl] Add a domain; also attaches *.preview. + records the domain in site state (onAdded hook) +│ │ ├── add [site] [--ssl] [--wait] [--no-force-ssl] Add a domain; also attaches *.preview. and, only when that wildcard attached, records the domain in site state (onAdded hook; the recorded domain is what switches deploy/CI into preview mode) │ │ ├── ssl [site] Issue a free SSL certificate │ │ ├── list [site] (alias: ls) List domains │ │ └── remove [site] [--force] Remove a domain (also removes its *.preview wildcard, onRemoved hook) diff --git a/packages/cli/src/commands/sites/create.ts b/packages/cli/src/commands/sites/create.ts index c94693ef..0d7eeb0b 100644 --- a/packages/cli/src/commands/sites/create.ts +++ b/packages/cli/src/commands/sites/create.ts @@ -33,7 +33,7 @@ interface CreateArgs { link?: boolean; } -// Attach a custom domain to a just-created site; returns an error message on failure (never throws). +// Attach a custom domain to a just-created site; never throws. `previewsReady` mirrors state.domain, which setupSiteDomain only records once the preview wildcard attached. async function attachDomainToCreatedSite(opts: { coreClient: CoreClient; storageZone: StorageZoneModel; @@ -41,9 +41,9 @@ async function attachDomainToCreatedSite(opts: { interactive: boolean; verbose: boolean; json?: boolean; -}): Promise { +}): Promise<{ error?: string; previewsReady: boolean }> { const site = await siteContextFromZone(opts.storageZone); - if (!site) return undefined; + if (!site) return { previewsReady: false }; try { await setupSiteDomain({ coreClient: opts.coreClient, @@ -53,9 +53,9 @@ async function attachDomainToCreatedSite(opts: { verbose: opts.verbose, json: opts.json, }); - return undefined; + return { previewsReady: Boolean(site.state.domain) }; } catch (err) { - return errorMessage(err); + return { error: errorMessage(err), previewsReady: false }; } } @@ -141,7 +141,7 @@ export const sitesCreateCommand = defineCommand({ if (output === "json") { // --domain is attached non-interactively; a failure is reported but doesn't fail the create. - const domainError = domain + const attach = domain ? await attachDomainToCreatedSite({ coreClient, storageZone: result.storageZone, @@ -151,6 +151,7 @@ export const sitesCreateCommand = defineCommand({ json: true, }) : undefined; + const domainError = attach?.error; logger.log( JSON.stringify( { @@ -198,19 +199,20 @@ export const sitesCreateCommand = defineCommand({ }); chosenDomain = normalizeHostname(value ?? "") || undefined; } - // Previews (and the PR flow in CI) exist only once a custom domain is attached. + // Previews (and the PR flow in CI) exist only once the domain and its preview wildcard are attached. let previews = false; if (chosenDomain) { // A domain failure mustn't fail the create; the site already exists and the domain can be retried via `sites domains add`. logger.log(); - const domainError = await attachDomainToCreatedSite({ - coreClient, - storageZone: result.storageZone, - domain: chosenDomain, - interactive, - verbose, - }); - previews = !domainError; + const { error: domainError, previewsReady } = + await attachDomainToCreatedSite({ + coreClient, + storageZone: result.storageZone, + domain: chosenDomain, + interactive, + verbose, + }); + previews = previewsReady; if (domainError) { logger.warn( `Couldn't finish setting up ${chosenDomain}: ${domainError}`, diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 3320b9c5..0bc50cab 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -390,9 +390,12 @@ export const sitesDeployCommand = defineCommand({ interactive: true, verbose, }); - logger.dim( - " From now on `bunny sites deploy` creates a preview; publish with --production.", - ); + // state.domain stays unset when the preview wildcard didn't attach; deploys keep publishing (the wildcard failure already printed the retry hint). + if (site.state.domain) { + logger.dim( + " From now on `bunny sites deploy` creates a preview; publish with --production.", + ); + } } catch (err) { logger.warn( `Couldn't finish setting up ${domain}: ${errorMessage(err)}`, diff --git a/packages/cli/src/commands/sites/domains/index.test.ts b/packages/cli/src/commands/sites/domains/index.test.ts new file mode 100644 index 00000000..9c008711 --- /dev/null +++ b/packages/cli/src/commands/sites/domains/index.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from "bun:test"; +import type { CoreClient } from "../../../core/hostnames/index.ts"; +import { attachPreviewWildcard } from "./index.ts"; + +// A minimal core client covering the wildcard-attach calls: addHostname POST, hostname list GET, free-cert GET, force-SSL POST. +function stubClient(opts: { failAddHostname?: boolean; failSsl?: boolean }) { + return { + POST: async (route: string) => { + if (route === "/pullzone/{id}/addHostname" && opts.failAddHostname) { + throw new Error("hostname is already taken"); + } + return { data: undefined }; + }, + GET: async (route: string) => { + if (route === "/pullzone/loadFreeCertificate" && opts.failSsl) { + throw new Error("DNS not pointed yet"); + } + if (route === "/pullzone/{id}") { + return { + data: { Hostnames: [{ Value: "*.preview.example.com" }] }, + }; + } + return { data: undefined }; + }, + } as unknown as CoreClient; +} + +// `state.domain` (the previews-operational signal) keys off this return value, so a failed attach must report false. +test("attachPreviewWildcard reports failure when the hostname can't be added", async () => { + const attached = await attachPreviewWildcard({ + coreClient: stubClient({ failAddHostname: true }), + pullZoneId: 1, + domain: "example.com", + json: true, + }); + expect(attached).toBe(false); +}); + +test("attachPreviewWildcard succeeds once the hostname attaches, even when SSL is still pending", async () => { + expect( + await attachPreviewWildcard({ + coreClient: stubClient({}), + pullZoneId: 1, + domain: "example.com", + json: true, + }), + ).toBe(true); + // DNS-01 can't complete before the wildcard record exists; a pending cert must not block previews. + expect( + await attachPreviewWildcard({ + coreClient: stubClient({ failSsl: true }), + pullZoneId: 1, + domain: "example.com", + json: true, + }), + ).toBe(true); +}); diff --git a/packages/cli/src/commands/sites/domains/index.ts b/packages/cli/src/commands/sites/domains/index.ts index 55038957..b99f0050 100644 --- a/packages/cli/src/commands/sites/domains/index.ts +++ b/packages/cli/src/commands/sites/domains/index.ts @@ -45,27 +45,29 @@ function isPreviewHost(hostname: string): boolean { ); } -/** Persist the site's primary domain in the remote state (best-effort). */ +/** Persist the site's primary domain in the remote state (best-effort; rolls back the in-memory value on failure so it never claims previews the next run won't see). */ async function recordSiteDomain( site: SiteContext, domain: string | undefined, ): Promise { + const previous = site.state.domain; try { site.state.domain = domain; site.etag = await writeRemoteState(site.connection, site.state, site.etag); } catch (err) { + site.state.domain = previous; logger.warn(`Couldn't update the site state: ${errorMessage(err)}`); } } -// Attach the `*.preview.` wildcard that serves per-deploy previews; best-effort, since the apex is already added and this can be retried via `sites domains add`. +// Attach the `*.preview.` wildcard that serves per-deploy previews; returns whether the hostname attached (SSL may still be pending), since `state.domain` must only be recorded when previews can actually serve. export async function attachPreviewWildcard(opts: { coreClient: CoreClient; pullZoneId: number; domain: string; cnameTarget?: string; json?: boolean; -}): Promise { +}): Promise { const wildcard = previewWildcard(opts.domain); try { const { hostnames } = await addHostname( @@ -95,11 +97,15 @@ export async function attachPreviewWildcard(opts: { ); } } + return true; } catch (err) { if (!opts.json) { logger.warn(`Couldn't add ${wildcard}: ${errorMessage(err)}`); - logger.dim(" Previews will use /deploys// paths until it's added."); + logger.dim( + ` Previews stay off and deploys keep publishing directly; retry with \`bunny sites domains add ${opts.domain}\`.`, + ); } + return false; } } @@ -133,14 +139,15 @@ export async function setupSiteDomain(opts: { }); } - await attachPreviewWildcard({ + // `state.domain` switches deploy and CI into preview mode, so it's only recorded once the wildcard can serve previews. + const wildcardAttached = await attachPreviewWildcard({ coreClient, pullZoneId, domain, cnameTarget, json: opts.json, }); - await recordSiteDomain(site, domain); + if (wildcardAttached) await recordSiteDomain(site, domain); } /** The `domains` namespace + hidden `hostnames` alias, ready to spread into `sites`. */ @@ -158,14 +165,14 @@ export const sitesDomainsCommands = createHostnamesCommands({ onAdded: async ({ coreClient, pullZoneId, hostname, cnameTarget, args }) => { // Adding preview infrastructure by hand shouldn't recurse into itself. if (isPreviewHost(hostname)) return; - await attachPreviewWildcard({ + const wildcardAttached = await attachPreviewWildcard({ coreClient, pullZoneId, domain: hostname, cnameTarget, json: args.output === "json", }); - if (resolvedSite && !resolvedSite.state.domain) { + if (wildcardAttached && resolvedSite && !resolvedSite.state.domain) { await recordSiteDomain(resolvedSite, hostname); } }, diff --git a/packages/cli/src/commands/sites/router/source.test.ts b/packages/cli/src/commands/sites/router/source.test.ts index cc7a74a2..1755f8b3 100644 --- a/packages/cli/src/commands/sites/router/source.test.ts +++ b/packages/cli/src/commands/sites/router/source.test.ts @@ -34,9 +34,6 @@ test("routerSource wires up the deploy routing", () => { // The client-sent flag must be stripped, or it'd poison cached HTML. expect(src).toContain("headers.delete(RETRY_HEADER);"); expect(src).toContain("X-Robots-Tag"); - // Path previews are gone: deploys are only reachable at the apex or a preview host. - expect(src).not.toContain("HTMLRewriter"); - expect(src).not.toContain("x-bunny-preview"); }); test("indexRetryUrl targets the directory index for slashless paths only", () => { From 9d744e11fdba9bf65b77d513c31b2b7aab66afcd Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Sat, 1 Aug 2026 07:43:14 +0100 Subject: [PATCH 3/6] decide preview mode from the pull zone wildcard, not state alone --- .../sites-domainless-production-deploys.md | 2 +- AGENTS.md | 4 +- packages/cli/src/commands/sites/api.ts | 25 +++++++++++ .../cli/src/commands/sites/constants.test.ts | 17 ++++++++ packages/cli/src/commands/sites/constants.ts | 10 +++++ packages/cli/src/commands/sites/deploy.ts | 43 ++++++++++++------- 6 files changed, 82 insertions(+), 19 deletions(-) diff --git a/.changeset/sites-domainless-production-deploys.md b/.changeset/sites-domainless-production-deploys.md index a88a9984..e0903cd7 100644 --- a/.changeset/sites-domainless-production-deploys.md +++ b/.changeset/sites-domainless-production-deploys.md @@ -2,4 +2,4 @@ "@bunny.net/cli": minor --- -feat(sites): custom domains unlock preview deploys. Without a domain, `bunny sites deploy` publishes straight to production; attaching one switches deploys to immutable `dpl-{id}.preview.{domain}` preview URLs with `--production`/`--prod` publishing. Preview hosts are root-served, so client-side routers (TanStack Router, React Router) behave exactly like production, and deploys are only reachable through the apex or their preview host. `bunny sites ci init` scaffolds PR preview deploys when the site has a custom domain; without one the workflow deploys production on pushes to main only. A domainless site's first deploy offers to attach a custom domain (interactive runs; blank to skip, and it never re-asks), and later domainless deploys print a `sites domains add` hint. The domain is only recorded as the preview switch once its `*.preview` wildcard hostname is attached, so a partial domain setup can't route deploys at previews that can't serve. +feat(sites): custom domains unlock preview deploys. Without a domain, `bunny sites deploy` publishes straight to production; attaching one switches deploys to immutable `dpl-{id}.preview.{domain}` preview URLs with `--production`/`--prod` publishing. Preview hosts are root-served, so client-side routers (TanStack Router, React Router) behave exactly like production, and deploys are only reachable through the apex or their preview host. `bunny sites ci init` scaffolds PR preview deploys when the site has a custom domain; without one the workflow deploys production on pushes to main only. A domainless site's first deploy offers to attach a custom domain (interactive runs; blank to skip, and it never re-asks), and later domainless deploys print a `sites domains add` hint. The domain is only recorded as the preview switch once its `*.preview` wildcard hostname is attached, and deploy verifies the wildcard against the pull zone itself, healing stale state in either direction, so a partial or stale domain setup can neither route deploys at previews that can't serve nor publish a CI preview build to production. diff --git a/AGENTS.md b/AGENTS.md index 30d94926..67c21f17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -382,7 +382,7 @@ bunny-cli/ │ │ │ ├── index.ts # defineNamespace("sites", ...): create/list/show/deploy/deployments/domains/link/unlink/upgrade-router/delete │ │ │ ├── constants.ts # SITES_MANIFEST (.bunny/site.json), REMOTE_STATE_PATH (_bunny/site.json), RemoteSiteState/DeployRecord types, parseRemoteState (shape-checked; null = not a site), previewHostname/previewWildcard/deployPrefix helpers, deploy-ID + site-name validators (3-47 chars), suffixedResourceName/siteResourcePattern (zone names are `sites-{name}-{random 6}`: the prefix marks them in the dashboard, the suffix dodges the global zone namespace; the pattern also matches bare pre-suffix names) │ │ │ ├── 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), deleteSiteResources (pull zone → script → storage zone, best-effort), deleteDeployFiles +│ │ │ ├── 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), fetchSiteHostnames (one pull-zone read: system host + the *.preview.* wildcard's domain, deploy's preview-mode source of truth), deleteSiteResources (pull zone → script → storage zone, best-effort), deleteDeployFiles │ │ │ ├── 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 @@ -399,7 +399,7 @@ bunny-cli/ │ │ │ ├── show.ts # Site details + hostname table (SSL cert + Force SSL columns) + router-outdated warning │ │ │ ├── open.ts # bunny sites open [site]: open the live URL (recorded custom domain when live, 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) → hash → no-op if unchanged → upload deploys/{id}/ → state update → publish. Without a custom domain every deploy publishes live (there are no preview hosts); with one the default is an immutable dpl-{id}.preview.* preview URL and --production/--prod publishes. A domainless site's first-ever deploy (state.deploys was empty; stateless, so declining never re-asks) offers the custom-domain prompt (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) → hash → no-op if unchanged → upload deploys/{id}/ → state update → publish. Without a custom domain every deploy publishes live (there are no preview hosts); with one the default is an immutable dpl-{id}.preview.* preview URL and --production/--prod publishes. Preview mode is decided by the pull zone's *.preview.* wildcard (fetchSiteHostnames), not the best-effort state.domain alone: a stale-missing domain heals from the zone (so a CI preview deploy can't publish to production) and a stale-present one warns + publishes directly (no dead preview URLs); the heal persists on the next state write. A domainless site's first-ever deploy (state.deploys was empty; stateless, so declining never re-asks) offers the custom-domain prompt (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 (pushes router improvements to an existing site) diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index fb26cf09..0ceae476 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, deployPrefix, parseRemoteState, + previewDomainFromWildcard, REMOTE_STATE_PATH, type RemoteSiteState, routerScriptName, @@ -465,6 +466,30 @@ export async function fetchSystemHostname( } } +// One pull-zone read for deploy: the system host plus the domain served by an attached `*.preview.*` wildcard. The wildcard is the source of truth for preview mode; `fetched: false` (zone unreadable) tells callers to fall back to the recorded state. +export async function fetchSiteHostnames( + coreClient: CoreClient, + pullZoneId: number, +): Promise<{ fetched: boolean; systemHost?: string; previewDomain?: string }> { + try { + const { data } = await coreClient.GET("/pullzone/{id}", { + params: { path: { id: pullZoneId } }, + }); + if (!data) return { fetched: false }; + const hostnames = data.Hostnames ?? []; + const previewDomain = hostnames + .map((h) => previewDomainFromWildcard(h.Value ?? "")) + .find((domain) => domain !== undefined); + return { + fetched: true, + systemHost: systemHostname(hostnames), + previewDomain, + }; + } catch { + return { fetched: false }; + } +} + // Promote timing: `CURRENT_DEPLOY` is accepted instantly but reaches edge nodes async, so we confirm the edge serves it before the follow-up purge, then settle briefly. const PROBE_TIMEOUT_MS = 4000; const PROPAGATION_DEADLINE_MS = 20_000; diff --git a/packages/cli/src/commands/sites/constants.test.ts b/packages/cli/src/commands/sites/constants.test.ts index c28c26fc..2d471718 100644 --- a/packages/cli/src/commands/sites/constants.test.ts +++ b/packages/cli/src/commands/sites/constants.test.ts @@ -4,6 +4,7 @@ import { isValidDeployId, isValidSiteName, parseRemoteState, + previewDomainFromWildcard, previewHostname, previewWildcard, type RemoteSiteState, @@ -86,3 +87,19 @@ test("suffixed resource names round-trip through the site pattern", () => { expect(pattern.test("sites-my-site2-abcdef")).toBe(false); // different site expect(siteResourcePattern("other").test(zoneName)).toBe(false); }); + +// Deploy trusts the pull zone's wildcard over recorded state, so parsing must round-trip previewWildcard and reject everything else. +test("previewDomainFromWildcard extracts the domain from preview wildcards only", () => { + expect(previewDomainFromWildcard(previewWildcard("example.com"))).toBe( + "example.com", + ); + expect(previewDomainFromWildcard("*.PREVIEW.Example.COM")).toBe( + "example.com", + ); + expect(previewDomainFromWildcard("example.com")).toBeUndefined(); + expect(previewDomainFromWildcard("*.example.com")).toBeUndefined(); + expect( + previewDomainFromWildcard("dpl-abc.preview.example.com"), + ).toBeUndefined(); + expect(previewDomainFromWildcard("")).toBeUndefined(); +}); diff --git a/packages/cli/src/commands/sites/constants.ts b/packages/cli/src/commands/sites/constants.ts index 8c4c6392..8a84a7c6 100644 --- a/packages/cli/src/commands/sites/constants.ts +++ b/packages/cli/src/commands/sites/constants.ts @@ -87,6 +87,16 @@ export function previewWildcard(domain: string): string { return `*.${PREVIEW_LABEL}.${domain}`; } +/** The domain a `*.preview.` wildcard hostname serves; undefined for any other hostname. */ +export function previewDomainFromWildcard( + hostname: string, +): string | undefined { + const match = new RegExp(`^\\*\\.${PREVIEW_LABEL}\\.(.+)$`, "i").exec( + hostname, + ); + return match?.[1]?.toLowerCase(); +} + /** Router script name for a site; namespaced so `sites create` can find it on re-run. */ export function routerScriptName(siteName: string): string { return `${siteName}-router`; diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 0bc50cab..45d8ac8d 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -15,7 +15,7 @@ import { normalizeHostname } from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; import { confirm, isInteractive, withSpinner } from "../../core/ui.ts"; import { - fetchSystemHostname, + fetchSiteHostnames, promoteDeploy, type SiteContext, writeRemoteState, @@ -52,6 +52,9 @@ interface DeployArgs extends SiteSelectorArgs { force?: boolean; } +const DOMAIN_HINT = + " Add a custom domain to unlock preview deploys: bunny sites domains add "; + // Production and preview URLs for a deploy: previews are `dpl-{id}.preview.{domain}` hosts, so they only exist once a custom domain is attached. function deployUrls( site: SiteContext, @@ -176,6 +179,24 @@ export const sitesDeployCommand = defineCommand({ }); const { state, connection } = site; + // The pull zone's preview wildcard, not the best-effort recorded state, decides preview mode: a stale-missing domain must not publish a CI preview to production, and a stale-present one must not advertise preview URLs that can't serve. State heals to match (persisted by the next state write). + const zone = await fetchSiteHostnames(coreClient, state.pullZoneId); + if (zone.fetched) { + if (!state.domain && zone.previewDomain) { + state.domain = zone.previewDomain; + } else if (state.domain && !zone.previewDomain) { + if (output !== "json") { + logger.warn( + `The preview wildcard for ${state.domain} is missing from the pull zone; deploys publish directly until it's restored.`, + ); + logger.dim( + ` Restore it with: bunny sites domains add ${state.domain} ${state.name}`, + ); + } + state.domain = undefined; + } + } + // No custom domain means no preview hosts, so every deploy publishes; with a domain, previews are the default and --production is the publish switch. const publish = args.production === true || !state.domain; // 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. @@ -246,11 +267,7 @@ export const sitesDeployCommand = defineCommand({ // Nothing to do: the deploy is already uploaded (and live, if publishing). if (skipUpload && (alreadyLive || !publish)) { - const urls = deployUrls( - site, - deployId, - await fetchSystemHostname(coreClient, state.pullZoneId), - ); + const urls = deployUrls(site, deployId, zone.systemHost); if (output === "json") { logger.log( JSON.stringify( @@ -278,6 +295,8 @@ export const sitesDeployCommand = defineCommand({ ); } if (urls.preview) logger.log(` Preview: ${urls.preview}`); + // The common repeat path after declining the first-deploy domain offer still gets the hint. + if (!state.domain) logger.dim(DOMAIN_HINT); return; } @@ -323,11 +342,7 @@ export const sitesDeployCommand = defineCommand({ }); } - const urls = deployUrls( - site, - deployId, - await fetchSystemHostname(coreClient, state.pullZoneId), - ); + const urls = deployUrls(site, deployId, zone.systemHost); if (output === "json") { logger.log( @@ -406,11 +421,7 @@ export const sitesDeployCommand = defineCommand({ } } } - if (!handled) { - logger.dim( - " Add a custom domain to unlock preview deploys: bunny sites domains add ", - ); - } + if (!handled) logger.dim(DOMAIN_HINT); } await offerLink(); From df540cd63c11ef9f73280fa427bfae85938806b7 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Sat, 1 Aug 2026 09:37:34 +0100 Subject: [PATCH 4/6] resolve preview mode from the pull zone, not stored state --- .../sites-domainless-production-deploys.md | 2 +- AGENTS.md | 8 +- packages/cli/src/commands/sites/api.test.ts | 46 +++++++++++ packages/cli/src/commands/sites/api.ts | 21 ++++- packages/cli/src/commands/sites/ci/init.ts | 7 +- packages/cli/src/commands/sites/deploy.ts | 27 +++---- .../src/commands/sites/domains/index.test.ts | 35 +++++--- .../cli/src/commands/sites/domains/index.ts | 57 ++++++++----- skills/bunny-cli/SKILL.md | 6 +- skills/bunny-cli/references/sites.md | 80 +++++++++++++------ 10 files changed, 208 insertions(+), 81 deletions(-) diff --git a/.changeset/sites-domainless-production-deploys.md b/.changeset/sites-domainless-production-deploys.md index e0903cd7..8da3ad9f 100644 --- a/.changeset/sites-domainless-production-deploys.md +++ b/.changeset/sites-domainless-production-deploys.md @@ -2,4 +2,4 @@ "@bunny.net/cli": minor --- -feat(sites): custom domains unlock preview deploys. Without a domain, `bunny sites deploy` publishes straight to production; attaching one switches deploys to immutable `dpl-{id}.preview.{domain}` preview URLs with `--production`/`--prod` publishing. Preview hosts are root-served, so client-side routers (TanStack Router, React Router) behave exactly like production, and deploys are only reachable through the apex or their preview host. `bunny sites ci init` scaffolds PR preview deploys when the site has a custom domain; without one the workflow deploys production on pushes to main only. A domainless site's first deploy offers to attach a custom domain (interactive runs; blank to skip, and it never re-asks), and later domainless deploys print a `sites domains add` hint. The domain is only recorded as the preview switch once its `*.preview` wildcard hostname is attached, and deploy verifies the wildcard against the pull zone itself, healing stale state in either direction, so a partial or stale domain setup can neither route deploys at previews that can't serve nor publish a CI preview build to production. +feat(sites): custom domains unlock preview deploys. Without a domain, `bunny sites deploy` publishes straight to production; attaching one switches deploys to immutable `dpl-{id}.preview.{domain}` preview URLs with `--production`/`--prod` publishing. Preview hosts are root-served, so client-side routers (TanStack Router, React Router) behave exactly like production, and deploys are only reachable through the apex or their preview host. `bunny sites ci init` scaffolds PR preview deploys when the site has a custom domain; without one the workflow deploys production on pushes to main only. A domainless site's first deploy offers to attach a custom domain (interactive runs; blank to skip, and it never re-asks), and later domainless deploys print a `sites domains add` hint. The domain is only recorded as the preview switch once its `*.preview` wildcard hostname is attached, and `sites deploy`/`sites ci init` verify that wildcard against the pull zone itself, healing stale state in either direction, so a partial or stale domain setup can neither route deploys at previews that can't serve nor publish a CI preview build to production. Re-running `sites domains add` reconciles a half-finished setup instead of failing on the already-attached wildcard. diff --git a/AGENTS.md b/AGENTS.md index 67c21f17..b12ad5d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -382,7 +382,7 @@ bunny-cli/ │ │ │ ├── index.ts # defineNamespace("sites", ...): create/list/show/deploy/deployments/domains/link/unlink/upgrade-router/delete │ │ │ ├── constants.ts # SITES_MANIFEST (.bunny/site.json), REMOTE_STATE_PATH (_bunny/site.json), RemoteSiteState/DeployRecord types, parseRemoteState (shape-checked; null = not a site), previewHostname/previewWildcard/deployPrefix helpers, deploy-ID + site-name validators (3-47 chars), suffixedResourceName/siteResourcePattern (zone names are `sites-{name}-{random 6}`: the prefix marks them in the dashboard, the suffix dodges the global zone namespace; the pattern also matches bare pre-suffix names) │ │ │ ├── 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), fetchSiteHostnames (one pull-zone read: system host + the *.preview.* wildcard's domain, deploy's preview-mode source of truth), deleteSiteResources (pull zone → script → storage zone, best-effort), deleteDeployFiles +│ │ │ ├── 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), fetchSiteHostnames (one pull-zone read: system host + the *.preview.* wildcard's domain) + reconcilePreviewDomain (the zone's wildcard is the preview-mode source of truth; heals a drifted state.domain in memory and reports "attached"/"detached", since the state write is best-effort and can fail after the hostnames land — deploy and ci init both run it before reading preview mode), deleteSiteResources (pull zone → script → storage zone, best-effort), deleteDeployFiles │ │ │ ├── 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 @@ -399,14 +399,14 @@ bunny-cli/ │ │ │ ├── show.ts # Site details + hostname table (SSL cert + Force SSL columns) + router-outdated warning │ │ │ ├── open.ts # bunny sites open [site]: open the live URL (recorded custom domain when live, 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) → hash → no-op if unchanged → upload deploys/{id}/ → state update → publish. Without a custom domain every deploy publishes live (there are no preview hosts); with one the default is an immutable dpl-{id}.preview.* preview URL and --production/--prod publishes. Preview mode is decided by the pull zone's *.preview.* wildcard (fetchSiteHostnames), not the best-effort state.domain alone: a stale-missing domain heals from the zone (so a CI preview deploy can't publish to production) and a stale-present one warns + publishes directly (no dead preview URLs); the heal persists on the next state write. A domainless site's first-ever deploy (state.deploys was empty; stateless, so declining never re-asks) offers the custom-domain prompt (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) → hash → no-op if unchanged → upload deploys/{id}/ → state update → publish. Without a custom domain every deploy publishes live (there are no preview hosts); with one the default is an immutable dpl-{id}.preview.* preview URL and --production/--prod publishes. Preview mode is decided by the pull zone's *.preview.* wildcard (fetchSiteHostnames + reconcilePreviewDomain), not the best-effort state.domain alone: a stale-missing domain heals from the zone (so a CI preview deploy can't publish to production) and a stale-present one warns + publishes directly (no dead preview URLs); the heal persists on the next state write. A domainless site's first-ever deploy (state.deploys was empty; stateless, so declining never re-asks) offers the custom-domain prompt (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 (pushes router improvements to an existing site) │ │ │ ├── delete.ts # Delete a site (typed-name confirm; --keep-storage; drops .bunny/site.json if it pointed here) │ │ │ ├── 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) + prune.test.ts -│ │ │ └── domains/index.ts # Mounts core/hostnames createHostnamesCommands as "sites domains" with onAdded/onRemoved hooks: apex add also attaches *.preview. (attachPreviewWildcard, best-effort SSL) and records state.domain ONLY when the wildcard attached (state.domain is the previews-operational signal that flips deploy/CI into preview mode; recordSiteDomain rolls back the in-memory value if the state write fails); remove takes the wildcard down too. setupSiteDomain composes setupHostname + wildcard for create --domain +│ │ │ └── domains/index.ts # Mounts core/hostnames createHostnamesCommands as "sites domains" with onAdded/onRemoved hooks: apex add also attaches *.preview. (attachPreviewWildcard, best-effort SSL; idempotent, so a retry after a failed state write finds the existing wildcard on the zone and reconciles instead of reporting failure) and records state.domain ONLY when the wildcard attached (state.domain is the previews-operational signal that flips deploy/CI into preview mode; recordSiteDomain rolls back the in-memory value if the state write fails); remove takes the wildcard down too. setupSiteDomain composes setupHostname + wildcard for create --domain │ │ ├── registries/ │ │ │ ├── index.ts # Manual CommandModule (not defineNamespace) — default handler runs list │ │ │ ├── list.ts # List container registries @@ -1125,7 +1125,7 @@ bunny │ │ └── remove [site] [--force] Remove a domain (also removes its *.preview wildcard, onRemoved hook) │ ├── ssl [site] [--no-force-ssl] Toggle Force HTTPS on the site's b-cdn.net system host (no cert issued; custom domains use `sites domains ssl`) │ ├── ci -│ │ └── init [--site] [--link] [--framework] [--force] Write .github/workflows/bunny-sites.yml at the git root: `sites.dir`/`sites.build` from bunny.jsonc override the preset's directory and build command (so CI deploys what `sites deploy` does), and a bunny.jsonc below the git root sets the job's working directory + prefixes the deploy directory; else framework detection (package.json deps, Gemfile, hugo/python/zola config files; lockfile picks the package manager), via BunnyWay/actions/deploy-site: previews on PRs + production on main when the site has a custom domain, production on main only when it doesn't (deploys publish, so PR builds must not run), offers `gh secret set BUNNY_API_KEY` +│ │ └── init [--site] [--link] [--framework] [--force] Write .github/workflows/bunny-sites.yml at the git root: `sites.dir`/`sites.build` from bunny.jsonc override the preset's directory and build command (so CI deploys what `sites deploy` does), and a bunny.jsonc below the git root sets the job's working directory + prefixes the deploy directory; else framework detection (package.json deps, Gemfile, hugo/python/zola config files; lockfile picks the package manager), via BunnyWay/actions/deploy-site: previews on PRs + production on main when the site has a custom domain (checked against the pull zone's preview wildcard, same source of truth as deploy), production on main only when it doesn't (deploys publish, so PR builds must not run), offers `gh secret set BUNNY_API_KEY` │ ├── link [site] Link this directory to a site → .bunny/site.json │ ├── unlink Remove .bunny/site.json │ ├── upgrade-router [site] [--link] Republish the site's router script with the CLI's current source diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index d0440346..ac850c2b 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -9,6 +9,7 @@ import { promoteDeploy, promoteVerification, readRemoteState, + reconcilePreviewDomain, siteContextFromZone, siteFiles, writeRemoteState, @@ -831,3 +832,48 @@ test("fetchSites pages through the /pullzone envelope", async () => { expect(sites).toHaveLength(1); expect(sites[0]?.state.name).toBe("my-site"); }); + +// `state.domain` decides whether deploys preview or publish, but its write is best-effort, so the zone's wildcard reconciles it before anything reads it. +test("reconcilePreviewDomain heals drifted domain state from the zone", () => { + const state = (domain?: string) => ({ domain }) as RemoteSiteState; + + // Wildcard attached but the state write failed: previews work, so a CI preview build must not publish to production. + const stranded = state(undefined); + expect( + reconcilePreviewDomain(stranded, { + fetched: true, + previewDomain: "example.com", + }), + ).toBe("attached"); + expect(stranded.domain).toBe("example.com"); + + // Wildcard removed behind the CLI's back: previews can't serve, so don't advertise them. + const dangling = state("example.com"); + expect(reconcilePreviewDomain(dangling, { fetched: true })).toBe("detached"); + expect(dangling.domain).toBeUndefined(); + + // The domain moved: adopt whatever the zone actually serves. + const moved = state("old.example"); + expect( + reconcilePreviewDomain(moved, { + fetched: true, + previewDomain: "new.example", + }), + ).toBe("attached"); + expect(moved.domain).toBe("new.example"); + + // Agreement is not drift. + const agreed = state("example.com"); + expect( + reconcilePreviewDomain(agreed, { + fetched: true, + previewDomain: "example.com", + }), + ).toBeUndefined(); + expect(agreed.domain).toBe("example.com"); + + // An unreadable zone proves nothing; keep the recorded value. + const unknown = state("example.com"); + expect(reconcilePreviewDomain(unknown, { fetched: false })).toBeUndefined(); + expect(unknown.domain).toBe("example.com"); +}); diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 0ceae476..e9fd0ab0 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -490,13 +490,30 @@ export async function fetchSiteHostnames( } } -// Promote timing: `CURRENT_DEPLOY` is accepted instantly but reaches edge nodes async, so we confirm the edge serves it before the follow-up purge, then settle briefly. +export type PreviewDomainDrift = "attached" | "detached" | undefined; + +export function reconcilePreviewDomain( + state: RemoteSiteState, + zone: { fetched: boolean; previewDomain?: string }, +): PreviewDomainDrift { + if (!zone.fetched) return undefined; + if (zone.previewDomain) { + if (state.domain === zone.previewDomain) return undefined; + state.domain = zone.previewDomain; + return "attached"; + } + if (state.domain) { + state.domain = undefined; + return "detached"; + } + return undefined; +} + const PROBE_TIMEOUT_MS = 4000; const PROPAGATION_DEADLINE_MS = 20_000; const PROPAGATION_INTERVAL_MS = 1500; const SETTLE_FLOOR_MS = 2500; -// CDN-probe seam; tests swap these so promote runs without real network or timers. export const promoteVerification = { /** Probe the live site through the CDN; resolves to the HTTP status code. */ probe: async (url: string): Promise => { diff --git a/packages/cli/src/commands/sites/ci/init.ts b/packages/cli/src/commands/sites/ci/init.ts index 7434eef0..beabc430 100644 --- a/packages/cli/src/commands/sites/ci/init.ts +++ b/packages/cli/src/commands/sites/ci/init.ts @@ -4,6 +4,7 @@ import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { logger } from "../../../core/logger.ts"; import { isInteractive } from "../../../core/ui.ts"; +import { fetchSiteHostnames, reconcilePreviewDomain } from "../api.ts"; import { loadSiteConfig } from "../config.ts"; import { type SiteSelectorArgs, @@ -70,9 +71,13 @@ export const sitesCiInitCommand = defineCommand({ ); } + // PR previews must key off the same signal deploy uses: the zone's wildcard, since a failed state write can leave `state.domain` unset while previews work fine. + const zone = await fetchSiteHostnames(coreClient, site.state.pullZoneId); + reconcilePreviewDomain(site.state, zone); + const previews = Boolean(site.state.domain); + // `sites.dir`/`sites.build` are what a local deploy uses, so the workflow follows them, relative to the bunny.jsonc directory they resolve against. const siteConfig = loadSiteConfig(); - const previews = Boolean(site.state.domain); const result = await scaffoldSitesWorkflow({ site: name, root, diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 45d8ac8d..ed96f714 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -17,6 +17,7 @@ import { confirm, isInteractive, withSpinner } from "../../core/ui.ts"; import { fetchSiteHostnames, promoteDeploy, + reconcilePreviewDomain, type SiteContext, writeRemoteState, } from "./api.ts"; @@ -31,6 +32,7 @@ import { type DeployRecord, markCurrent, previewHostname, + previewWildcard, } from "./constants.ts"; import { resolveDeployIdentity } from "./deploy-id.ts"; import { setupSiteDomain } from "./domains/index.ts"; @@ -179,22 +181,17 @@ export const sitesDeployCommand = defineCommand({ }); const { state, connection } = site; - // The pull zone's preview wildcard, not the best-effort recorded state, decides preview mode: a stale-missing domain must not publish a CI preview to production, and a stale-present one must not advertise preview URLs that can't serve. State heals to match (persisted by the next state write). + // The pull zone's preview wildcard, not the best-effort recorded state, decides preview mode: a stale-missing domain must not publish a CI preview to production, and a stale-present one must not advertise preview URLs that can't serve. const zone = await fetchSiteHostnames(coreClient, state.pullZoneId); - if (zone.fetched) { - if (!state.domain && zone.previewDomain) { - state.domain = zone.previewDomain; - } else if (state.domain && !zone.previewDomain) { - if (output !== "json") { - logger.warn( - `The preview wildcard for ${state.domain} is missing from the pull zone; deploys publish directly until it's restored.`, - ); - logger.dim( - ` Restore it with: bunny sites domains add ${state.domain} ${state.name}`, - ); - } - state.domain = undefined; - } + const staleDomain = state.domain; + const drift = reconcilePreviewDomain(state, zone); + if (drift === "detached" && staleDomain && output !== "json") { + logger.warn( + `${previewWildcard(staleDomain)} isn't on the pull zone, so previews can't serve; this deploy publishes directly.`, + ); + logger.dim( + ` Restore it with: bunny sites domains add ${staleDomain} ${state.name}`, + ); } // No custom domain means no preview hosts, so every deploy publishes; with a domain, previews are the default and --production is the publish switch. diff --git a/packages/cli/src/commands/sites/domains/index.test.ts b/packages/cli/src/commands/sites/domains/index.test.ts index 9c008711..2180ffba 100644 --- a/packages/cli/src/commands/sites/domains/index.test.ts +++ b/packages/cli/src/commands/sites/domains/index.test.ts @@ -2,8 +2,15 @@ import { expect, test } from "bun:test"; import type { CoreClient } from "../../../core/hostnames/index.ts"; import { attachPreviewWildcard } from "./index.ts"; -// A minimal core client covering the wildcard-attach calls: addHostname POST, hostname list GET, free-cert GET, force-SSL POST. -function stubClient(opts: { failAddHostname?: boolean; failSsl?: boolean }) { +// A minimal core client covering the wildcard-attach calls: addHostname POST, hostname list GET, free-cert GET, force-SSL POST. `hostnames` is what the zone reports back. +function stubClient(opts: { + failAddHostname?: boolean; + failSsl?: boolean; + hostnames?: string[]; +}) { + const hostnames = (opts.hostnames ?? ["*.preview.example.com"]).map( + (Value) => ({ Value }), + ); return { POST: async (route: string) => { if (route === "/pullzone/{id}/addHostname" && opts.failAddHostname) { @@ -15,20 +22,16 @@ function stubClient(opts: { failAddHostname?: boolean; failSsl?: boolean }) { if (route === "/pullzone/loadFreeCertificate" && opts.failSsl) { throw new Error("DNS not pointed yet"); } - if (route === "/pullzone/{id}") { - return { - data: { Hostnames: [{ Value: "*.preview.example.com" }] }, - }; - } + if (route === "/pullzone/{id}") return { data: { Hostnames: hostnames } }; return { data: undefined }; }, } as unknown as CoreClient; } -// `state.domain` (the previews-operational signal) keys off this return value, so a failed attach must report false. +// `state.domain` (the previews-operational signal) keys off this return value, so an attach that leaves no wildcard on the zone must report false. test("attachPreviewWildcard reports failure when the hostname can't be added", async () => { const attached = await attachPreviewWildcard({ - coreClient: stubClient({ failAddHostname: true }), + coreClient: stubClient({ failAddHostname: true, hostnames: [] }), pullZoneId: 1, domain: "example.com", json: true, @@ -36,6 +39,20 @@ test("attachPreviewWildcard reports failure when the hostname can't be added", a expect(attached).toBe(false); }); +// Retrying `domains add` after a failed state write re-adds the wildcard; the API rejects the duplicate, but previews do work, so the retry must reconcile rather than report failure again. +test("attachPreviewWildcard succeeds when the wildcard is already on the zone", async () => { + const attached = await attachPreviewWildcard({ + coreClient: stubClient({ + failAddHostname: true, + hostnames: ["example.com", "*.PREVIEW.example.com"], + }), + pullZoneId: 1, + domain: "example.com", + json: true, + }); + expect(attached).toBe(true); +}); + test("attachPreviewWildcard succeeds once the hostname attaches, even when SSL is still pending", async () => { expect( await attachPreviewWildcard({ diff --git a/packages/cli/src/commands/sites/domains/index.ts b/packages/cli/src/commands/sites/domains/index.ts index b99f0050..b6bb07bc 100644 --- a/packages/cli/src/commands/sites/domains/index.ts +++ b/packages/cli/src/commands/sites/domains/index.ts @@ -7,6 +7,8 @@ import { type CoreClient, createHostnamesCommands, enableSsl, + fetchPullZoneHostnames, + type Hostname, type ResolvedPullZone, setupHostname, } from "../../../core/hostnames/index.ts"; @@ -69,44 +71,57 @@ export async function attachPreviewWildcard(opts: { json?: boolean; }): Promise { const wildcard = previewWildcard(opts.domain); + let hostnames: Hostname[]; try { - const { hostnames } = await addHostname( - opts.coreClient, - opts.pullZoneId, - wildcard, - ); + hostnames = (await addHostname(opts.coreClient, opts.pullZoneId, wildcard)) + .hostnames; if (!opts.json) { logger.success(`Added ${wildcard} for deploy previews.`); if (opts.cnameTarget) { logger.accent(` CNAME ${wildcard} → ${opts.cnameTarget}`); } } - try { - await enableSsl( - opts.coreClient, - opts.pullZoneId, - wildcard, - true, - hostnames, - ); - } catch { - // Wildcard certs need DNS in place (DNS-01); issue later, don't block. + } catch (err) { + // Retrying after a partial setup re-adds an existing wildcard, so the zone decides whether previews can serve, not the error. + const existing = await fetchPullZoneHostnames( + opts.coreClient, + opts.pullZoneId, + ).catch(() => [] as Hostname[]); + const attached = existing.some( + (h) => (h.Value ?? "").toLowerCase() === wildcard.toLowerCase(), + ); + if (!attached) { if (!opts.json) { + logger.warn(`Couldn't add ${wildcard}: ${errorMessage(err)}`); logger.dim( - ` Preview HTTPS pending; once DNS is live: bunny sites domains ssl "${wildcard}"`, + ` Previews stay off and deploys keep publishing directly; retry with \`bunny sites domains add ${opts.domain}\`.`, ); } + return false; } - return true; - } catch (err) { + hostnames = existing; + if (!opts.json) { + logger.info(`${wildcard} is already attached for deploy previews.`); + } + } + + try { + await enableSsl( + opts.coreClient, + opts.pullZoneId, + wildcard, + true, + hostnames, + ); + } catch { + // Wildcard certs need DNS in place (DNS-01); issue later, don't block. if (!opts.json) { - logger.warn(`Couldn't add ${wildcard}: ${errorMessage(err)}`); logger.dim( - ` Previews stay off and deploys keep publishing directly; retry with \`bunny sites domains add ${opts.domain}\`.`, + ` Preview HTTPS pending; once DNS is live: bunny sites domains ssl "${wildcard}"`, ); } - return false; } + return true; } // Full custom-domain setup for a site (used by `sites create --domain`): interactive runs get the DNS-wait/SSL flow, JSON runs just attach and report; the preview wildcard and state update happen in both. diff --git a/skills/bunny-cli/SKILL.md b/skills/bunny-cli/SKILL.md index b417e0fe..ddd7321d 100644 --- a/skills/bunny-cli/SKILL.md +++ b/skills/bunny-cli/SKILL.md @@ -58,7 +58,9 @@ bunny dns records list example.com # host a static site bunny sites create my-site # provision (served at my-site.b-cdn.net) -bunny sites deploy ./dist --production # deploy + publish as the live site +bunny sites deploy ./dist # no custom domain → publishes live +bunny sites domains add example.com --wait # attaches *.preview.example.com: turns previews on +bunny sites deploy ./dist # now a preview; add --production to publish bunny sites deployments publish --previous --force # instant rollback ``` @@ -71,7 +73,7 @@ Use this to route to the correct reference file: - **DNS (zones, delegation checks, records, presets, BIND import/export, DNSSEC, logging, Scriptable DNS scripts)** -> `references/dns.md` - **Edge Storage (zones, replication, S3 credentials, file upload/download, custom domains)** -> `references/storage.md` - **Edge Scripts (init, create, deploy, link, stats, deployments/rollback, env vars, custom domains)** -> `references/scripts.md` -- **Static sites (create, deploy, rollback, previews, custom domains)** -> `references/sites.md` +- **Static sites (create, deploy, rollback, custom domains, domain-gated previews, GitHub Actions)** -> `references/sites.md` - **Sandboxes (create, exec, ssh, cp, files, 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 8dd38f37..158a53dd 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -16,8 +16,7 @@ Commands that can link the directory (`deploy`, `show`, `deployments list/publis ```bash # New site: provision, deploy, iterate bunny sites create my-site # served at https://sites-my-site-.b-cdn.net -bunny sites deploy ./dist # uploads to a preview URL -bunny sites deploy ./dist --production # uploads + publishes as the live site +bunny sites deploy ./dist # no custom domain yet → publishes live # Build-and-deploy in one step (build command from bunny.jsonc or the flag) bunny sites deploy --build # runs `sites.build`, deploys `sites.dir` @@ -28,15 +27,26 @@ bunny sites deployments list # find the deploy ID (● Live marks bunny sites deployments publish --previous --force # instant rollback bunny sites deployments publish a1b2c3d4 --force # promote a specific deploy -# Custom domain with per-deploy previews +# Add a custom domain: this is what turns previews on bunny sites domains add example.com --wait # also attaches *.preview.example.com -# → production at https://example.com, previews at https://dpl-.preview.example.com +bunny sites deploy ./dist # now a preview: https://dpl-.preview.example.com +bunny sites deploy ./dist --production # publish live: https://example.com ``` -## Deploy IDs and previews +## Previews are a custom-domain feature + +This is the rule that shapes every other command here: + +- **No custom domain** → there are no preview hosts, so **every deploy publishes live**. `--production` is implied; passing it changes nothing. +- **Custom domain attached** → `deploy` defaults to a preview at `https://dpl-.preview.`, and `--production`/`--prod` publishes to the domain. + +Previews are served on their own root host (via the `*.preview.` wildcard), 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. + +The switch is the preview wildcard on the pull zone, not merely the domain being recorded. `deploy` and `ci init` check the pull zone directly and reconcile the site's stored domain against it, so a half-finished domain setup can't leave you deploying at previews that don't resolve, nor publishing a CI preview build to production. If the wildcard is missing, `deploy` says so, publishes directly, and prints the command to restore it; re-running `sites domains add ` reconciles a partial setup rather than failing on the already-attached wildcard. + +## 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). -- Every deploy stays addressable: `https:///deploys//` (path preview) and, once a custom domain exists, `https://dpl-.preview.` (subdomain preview). The router rewrites root-absolute asset URLs in path-preview HTML (via HTMLRewriter), so sites whose assets use absolute paths (Jekyll, most SSGs) render correctly under the `/deploys//` subpath. Both preview forms are served `X-Robots-Tag: noindex`. - Dotfiles and `node_modules` are never uploaded. --- @@ -51,11 +61,11 @@ bunny sites create my-site --domain example.com bunny sites create my-site --no-link # don't write .bunny/site.json ``` -| Flag | Description | -| ---------- | ---------------------------------------------------------------------------------------------------------------- | -| `--region` | Main storage region code (default `DE`) | -| `--domain` | Attach a custom domain (+ `*.preview.`) after provisioning; interactive runs prompt for one when omitted | -| `--link` | Link this directory (default true; `--no-link` to skip) | +| Flag | Description | +| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `--region` | Main storage region code (default `DE`) | +| `--domain` | Attach a custom domain (+ `*.preview.`, which turns previews on) after provisioning; interactive runs prompt for one when omitted | +| `--link` | Link this directory (default true; `--no-link` to skip) | Site names are 3-47 lowercase letters, digits, and dashes. The storage zone, pull zone, and b-cdn.net subdomain become `sites--xxxxxx` (a `sites-` prefix marking them in the dashboard, plus a shared random suffix since zone names are global across bunny.net); commands still take the clean site name. Creation is idempotent; a failed create re-runs cleanly, reusing whatever was already provisioned. @@ -64,29 +74,30 @@ Site names are 3-47 lowercase letters, digits, and dashes. The storage zone, pul ## `bunny sites deploy`; Deploy a directory ```bash -bunny sites deploy ./dist # preview only +bunny sites deploy ./dist # domainless: publishes live. With a domain: a preview bunny sites deploy ./dist --production # publish as the live site (--prod works too) bunny sites deploy --build # run `sites.build` from bunny.jsonc first bunny sites deploy ./out --build "npm run build" --env VITE_FLAG=1 ``` -| Flag | Description | -| -------------- | -------------------------------------------------------------------- | -| `[dir]` | Directory to deploy (default: `sites.dir` in bunny.jsonc, then cwd) | -| `--build` | Run a build first (bare flag: `sites.build`, else a detected build) | -| `--env` | Build-time env override `KEY=VALUE` (repeatable; requires `--build`) | -| `--env-file` | Dotenv file of build-time overrides (requires `--build`) | -| `--production` | Publish as the live site (alias `--prod`; default is preview only) | -| `--force` | Deploy even when content is unchanged | -| `--site` | Target site (name or storage zone ID) | -| `--link` | Link this directory to the deployed site (`--no-link` never links) | +| Flag | Description | +| -------------- | -------------------------------------------------------------------------------- | +| `[dir]` | Directory to deploy (default: `sites.dir` in bunny.jsonc, then cwd) | +| `--build` | Run a build first (bare flag: `sites.build`, else a detected build) | +| `--env` | Build-time env override `KEY=VALUE` (repeatable; requires `--build`) | +| `--env-file` | Dotenv file of build-time overrides (requires `--build`) | +| `--production` | Publish as the live site (alias `--prod`); always on when the site has no domain | +| `--force` | Deploy even when content is unchanged | +| `--site` | Target site (name or storage zone ID) | +| `--link` | Link this directory to the deployed site (`--no-link` never links) | With `--build`, the build runs in your shell environment plus the `--env`/`--env-file` overrides; there is no remote env store; put build-time values in your local `.env` or CI secrets. Deploying already-uploaded content with `--production` skips the upload and just publishes it. -Interactive `deploy` adds two conveniences (both skipped under `--output json`): +Interactive `deploy` adds three conveniences (all skipped under `--output json`): - **No linked site** → it offers to create a new site or pick an existing one, then links it (goes straight to create when the account has no sites). - **No `--build`** → it offers to run a build first: the configured `sites.build`, else a detected framework build (same detection as `ci init`), else a `package.json` `build` script. Confirming builds first, and when no `[dir]` was given it deploys the framework's output directory. +- **A domainless site's first deploy** → it offers to attach a custom domain (blank skips) and runs the same DNS/SSL flow as `sites domains add`, which switches later deploys into the preview/`--production` model. It asks only once per site; every later domainless deploy just prints a one-line `sites domains add` hint. --- @@ -113,7 +124,18 @@ bunny sites domains list bunny sites domains remove example.com ``` -Adding a domain also attaches `*.preview.` for per-deploy preview URLs (removing it takes the wildcard down too). If the domain is on a Bunny DNS zone in the account, the CLI offers to create the records; otherwise it prints the CNAME target. The wildcard certificate may need DNS in place before it can issue; re-run `bunny sites domains ssl "*.preview."` once DNS is live. +Adding a domain also attaches `*.preview.`, which is what unlocks per-deploy preview URLs (removing the domain takes the wildcard down too, returning the site to publish-on-deploy). If the domain is on a Bunny DNS zone in the account, the CLI offers to create the apex record; otherwise it prints the CNAME target. **The wildcard's DNS record is always yours to create** (`CNAME *.preview.`), and because wildcard certificates validate over DNS-01, the certificate usually can't issue until that record resolves; run `bunny sites domains ssl "*.preview."` once it does. + +Previews only turn on if the wildcard hostname actually attached; if it didn't, the site stays in publish-on-deploy mode and the CLI prints the retry command. Re-running `bunny sites domains add ` after a partial setup reconciles it (an already-attached wildcard counts as attached, not as an error). + +So the full path from a plain deploy to working previews is: + +```bash +bunny sites domains add example.com --wait # apex + *.preview.example.com +# create CNAME *.preview.example.com → at your DNS provider +bunny sites domains ssl "*.preview.example.com" # once that record resolves +bunny sites ci init --force # regenerate CI so PRs deploy previews +``` --- @@ -125,7 +147,12 @@ bunny sites ci init --framework astro # skip detection (astro, vite, react bunny sites ci init --site my-site --force # overwrite an existing workflow ``` -Writes a workflow that deploys previews on pull requests and publishes to production on merges to `main`, using the `BunnyWay/actions/deploy-site` action with the site name baked in. `sites.dir` and `sites.build` from `bunny.jsonc` override the preset's deploy directory and build command, so CI builds and deploys exactly what a local `sites deploy` does. The workflow is written at the git root; when `bunny.jsonc` lives below it (a monorepo package), the job gets `defaults.run.working-directory` and the deploy directory is prefixed, so those paths still mean what they do locally. Framework detection reads `package.json` dependencies, `Gemfile`, or Hugo config; the lockfile picks the package manager for the install steps. Fork PRs are skipped (no secrets there). After writing, the CLI offers to run `gh secret set BUNNY_API_KEY` (or prints the manual steps). `sites create` offers the same scaffold on GitHub repos; declining prints the workflow instead. +Writes a workflow using the `BunnyWay/actions/deploy-site` action with the site name baked in. What it generates depends on whether the site has a working custom domain (checked against the pull zone's preview wildcard, the same signal `deploy` uses): + +- **With a domain** → previews on pull requests, production on merges to `main`. +- **Without one** → production on merges to `main` only. There is deliberately no `pull_request` trigger: a domainless deploy publishes, so a PR build would push unreviewed changes live. + +Add a domain later and re-run `bunny sites ci init --force` to regenerate the workflow with PR previews. `sites.dir` and `sites.build` from `bunny.jsonc` override the preset's deploy directory and build command, so CI builds and deploys exactly what a local `sites deploy` does. The workflow is written at the git root; when `bunny.jsonc` lives below it (a monorepo package), the job gets `defaults.run.working-directory` and the deploy directory is prefixed, so those paths still mean what they do locally. Framework detection reads `package.json` dependencies, `Gemfile`, or Hugo config; the lockfile picks the package manager for the install steps. Fork PRs are skipped (no secrets there). After writing, the CLI offers to run `gh secret set BUNNY_API_KEY` (or prints the manual steps). `sites create` offers the same scaffold on GitHub repos; declining prints the workflow instead. --- @@ -160,4 +187,5 @@ An optional `sites` block configures the deploy defaults (validated on its own, - Pass `--force` on anything with a confirmation (publish, prune, remove, delete); without a TTY they error with a hint rather than waiting on a prompt. - Pass the site explicitly (or commit `bunny.jsonc` with `sites.name`); the interactive picker is disabled under `--output json` and by `--force`, so `sites delete --force` with nothing linked errors instead of prompting. -- `--output json` on every command emits machine-readable results (deploy prints `{ id, production, preview, promoted }`). +- `--output json` on every command emits machine-readable results. `deploy` prints `{ id, production, preview, promoted }`, where `preview` is `null` and `promoted` is `true` on a site with no custom domain; use `promoted` rather than assuming `--production` decided it. +- The first-deploy custom-domain prompt never runs under `--output json` or without a TTY, so CI deploys are unaffected. From f01a41a79b1252d875f0bf4514f301f952a378f1 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Sat, 1 Aug 2026 13:54:32 +0100 Subject: [PATCH 5/6] persist domain records --- AGENTS.md | 10 +++--- packages/cli/src/commands/sites/api.test.ts | 38 +++++++++++++++++++++ packages/cli/src/commands/sites/api.ts | 36 ++++++++++++++++--- packages/cli/src/commands/sites/ci/init.ts | 10 ++++-- packages/cli/src/commands/sites/deploy.ts | 3 ++ packages/cli/src/commands/sites/open.ts | 3 ++ packages/cli/src/commands/sites/show.ts | 10 ++++-- skills/bunny-cli/references/sites.md | 2 +- 8 files changed, 96 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b12ad5d2..1b242702 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -382,7 +382,7 @@ bunny-cli/ │ │ │ ├── index.ts # defineNamespace("sites", ...): create/list/show/deploy/deployments/domains/link/unlink/upgrade-router/delete │ │ │ ├── constants.ts # SITES_MANIFEST (.bunny/site.json), REMOTE_STATE_PATH (_bunny/site.json), RemoteSiteState/DeployRecord types, parseRemoteState (shape-checked; null = not a site), previewHostname/previewWildcard/deployPrefix helpers, deploy-ID + site-name validators (3-47 chars), suffixedResourceName/siteResourcePattern (zone names are `sites-{name}-{random 6}`: the prefix marks them in the dashboard, the suffix dodges the global zone namespace; the pattern also matches bare pre-suffix names) │ │ │ ├── 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), fetchSiteHostnames (one pull-zone read: system host + the *.preview.* wildcard's domain) + reconcilePreviewDomain (the zone's wildcard is the preview-mode source of truth; heals a drifted state.domain in memory and reports "attached"/"detached", since the state write is best-effort and can fail after the hostnames land — deploy and ci init both run it before reading preview mode), deleteSiteResources (pull zone → script → storage zone, best-effort), deleteDeployFiles +│ │ │ ├── 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), fetchSiteHostnames (one pull-zone read: system host + the *.preview.* wildcard's domain) / previewZone (same view from an already-fetched hostname list; null input = unread, never "no wildcard") / reconcilePreviewDomain (the zone's wildcard is the preview-mode source of truth; heals a drifted state.domain in memory and reports "attached"/"detached", since the state write is best-effort and can fail after the hostnames land) + persistReconciledDomain (best-effort write so read-only commands stop lagging). EVERY state.domain reader reconciles first: deploy + ci init (fetch, then persist on drift), show/open (reuse the hostnames they already fetch), fetchSites (reuses the pull-zone listing, so list is free), deleteSiteResources (pull zone → script → storage zone, best-effort), deleteDeployFiles │ │ │ ├── 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 @@ -395,11 +395,11 @@ bunny-cli/ │ │ │ ├── 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 │ │ │ ├── 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 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) -│ │ │ ├── show.ts # Site details + hostname table (SSL cert + Force SSL columns) + router-outdated warning -│ │ │ ├── open.ts # bunny sites open [site]: open the live URL (recorded custom domain when live, else system host) in the browser; --print emits it, siteLiveUrl is the pure resolver +│ │ │ ├── list.ts # List sites (name, URL, deploy count, current); domains come from fetchSites, which reconciles each against its pull-zone hostnames +│ │ │ ├── show.ts # Site details + hostname table (SSL cert + Force SSL columns) + router-outdated warning; reconciles state.domain against the hostnames it already fetched (a failed fetch is null, not [], so it never reads as "no wildcard") +│ │ │ ├── open.ts # bunny sites open [site]: open the live URL (recorded custom domain when live, else system host) in the browser; --print emits it, siteLiveUrl is the pure resolver, and state.domain is reconciled against the fetched hostnames first so a dropped domain can't beat the system host │ │ │ ├── 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) → hash → no-op if unchanged → upload deploys/{id}/ → state update → publish. Without a custom domain every deploy publishes live (there are no preview hosts); with one the default is an immutable dpl-{id}.preview.* preview URL and --production/--prod publishes. Preview mode is decided by the pull zone's *.preview.* wildcard (fetchSiteHostnames + reconcilePreviewDomain), not the best-effort state.domain alone: a stale-missing domain heals from the zone (so a CI preview deploy can't publish to production) and a stale-present one warns + publishes directly (no dead preview URLs); the heal persists on the next state write. A domainless site's first-ever deploy (state.deploys was empty; stateless, so declining never re-asks) offers the custom-domain prompt (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) → hash → no-op if unchanged → upload deploys/{id}/ → state update → publish. Without a custom domain every deploy publishes live (there are no preview hosts); with one the default is an immutable dpl-{id}.preview.* preview URL and --production/--prod publishes. Preview mode is decided by the pull zone's *.preview.* wildcard (fetchSiteHostnames + reconcilePreviewDomain), not the best-effort state.domain alone: a stale-missing domain heals from the zone (so a CI preview deploy can't publish to production) and a stale-present one warns + publishes directly (no dead preview URLs); a drifted heal is persisted immediately (persistReconciledDomain), so even a no-op/already-live run reconciles the state the read-only commands show. A domainless site's first-ever deploy (state.deploys was empty; stateless, so declining never re-asks) offers the custom-domain prompt (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 (pushes router improvements to an existing site) diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index ac850c2b..86a38e8f 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -1,11 +1,13 @@ import { afterAll, beforeEach, expect, test } from "bun:test"; import { ApiError } from "../../core/errors.ts"; +import type { Hostname } from "../../core/hostnames/index.ts"; import type { CoreClient, StorageZoneModel } from "../storage/api.ts"; import { type ComputeClient, createSite, deleteSiteResources, fetchSites, + previewZone, promoteDeploy, promoteVerification, readRemoteState, @@ -738,6 +740,42 @@ test("fetchSites keeps only middleware+storage pull zones with matching state", expect(sites[0]?.systemHostname).toBe("my-site.b-cdn.net"); }); +// `list` reads the same listing, so a domain the zone doesn't back must not be shown as live. +test("fetchSites reconciles each site's domain against its pull zone hostnames", async () => { + store.set( + REMOTE_STATE_PATH, + JSON.stringify(fakeState({ domain: "stale.example" })), + ); + const coreClient = fakeCoreClient({ + calls: [], + storageZones: [ZONE], + pullZones: [ + { + Id: 30, + Name: "my-site", + MiddlewareScriptId: 20, + StorageZoneId: 10, + Hostnames: [ + { IsSystemHostname: true, Value: "my-site.b-cdn.net" }, + { Value: "*.preview.live.example" }, + ], + }, + ], + }); + + const sites = await fetchSites(coreClient); + expect(sites[0]?.state.domain).toBe("live.example"); +}); + +test("previewZone tells a failed hostname read apart from a zone with no wildcard", () => { + // Null is "unknown", so a fetch failure can never be mistaken for "previews are off". + expect(previewZone(null)).toEqual({ fetched: false }); + expect(previewZone([])).toEqual({ fetched: true, previewDomain: undefined }); + expect( + previewZone([{ Value: "*.preview.example.com" }] as Hostname[]), + ).toEqual({ fetched: true, previewDomain: "example.com" }); +}); + test("siteContextFromZone is null for a zone without site state", async () => { expect(await siteContextFromZone(ZONE)).toBeNull(); }); diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index e9fd0ab0..d01dc7f1 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -4,6 +4,7 @@ import { mapWithConcurrency } from "../../core/concurrency.ts"; import { ApiError, errorMessage, UserError } from "../../core/errors.ts"; import { createPullZone, + type Hostname, setForceSsl, systemHostname, } from "../../core/hostnames/index.ts"; @@ -217,6 +218,8 @@ export async function fetchSites(client: CoreClient): Promise { const zone = await fetchStorageZone(client, pz.StorageZoneId as number); const context = await siteContextFromZone(zone); if (!context || context.state.pullZoneId !== pz.Id) return null; + // The listing already carries the hostnames, so a drifted domain costs nothing to correct here. + reconcilePreviewDomain(context.state, previewZone(pz.Hostnames)); return { state: context.state, storageZone: zone, @@ -466,6 +469,20 @@ export async function fetchSystemHostname( } } +/** The preview-mode view of an already-fetched hostname list, so callers that read hostnames anyway reconcile without a second request; null means the read failed (never "no wildcard"). */ +export function previewZone(hostnames: Hostname[] | null | undefined): { + fetched: boolean; + previewDomain?: string; +} { + if (!hostnames) return { fetched: false }; + return { + fetched: true, + previewDomain: hostnames + .map((h) => previewDomainFromWildcard(h.Value ?? "")) + .find((domain) => domain !== undefined), + }; +} + // One pull-zone read for deploy: the system host plus the domain served by an attached `*.preview.*` wildcard. The wildcard is the source of truth for preview mode; `fetched: false` (zone unreadable) tells callers to fall back to the recorded state. export async function fetchSiteHostnames( coreClient: CoreClient, @@ -477,13 +494,9 @@ export async function fetchSiteHostnames( }); if (!data) return { fetched: false }; const hostnames = data.Hostnames ?? []; - const previewDomain = hostnames - .map((h) => previewDomainFromWildcard(h.Value ?? "")) - .find((domain) => domain !== undefined); return { - fetched: true, + ...previewZone(hostnames), systemHost: systemHostname(hostnames), - previewDomain, }; } catch { return { fetched: false }; @@ -509,6 +522,19 @@ export function reconcilePreviewDomain( return undefined; } +// Persist a drift correction so the commands that only read state (show/list/open) stop lagging; best-effort, since the in-memory value already drives this run and the next one reconciles from the zone regardless. +export async function persistReconciledDomain( + site: SiteContext, +): Promise { + try { + site.etag = await writeRemoteState(site.connection, site.state, site.etag); + } catch (err) { + logger.warn( + `Couldn't record the site's domain state: ${errorMessage(err)}`, + ); + } +} + const PROBE_TIMEOUT_MS = 4000; const PROPAGATION_DEADLINE_MS = 20_000; const PROPAGATION_INTERVAL_MS = 1500; diff --git a/packages/cli/src/commands/sites/ci/init.ts b/packages/cli/src/commands/sites/ci/init.ts index beabc430..025a23bc 100644 --- a/packages/cli/src/commands/sites/ci/init.ts +++ b/packages/cli/src/commands/sites/ci/init.ts @@ -4,7 +4,11 @@ import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { logger } from "../../../core/logger.ts"; import { isInteractive } from "../../../core/ui.ts"; -import { fetchSiteHostnames, reconcilePreviewDomain } from "../api.ts"; +import { + fetchSiteHostnames, + persistReconciledDomain, + reconcilePreviewDomain, +} from "../api.ts"; import { loadSiteConfig } from "../config.ts"; import { type SiteSelectorArgs, @@ -73,7 +77,9 @@ export const sitesCiInitCommand = defineCommand({ // PR previews must key off the same signal deploy uses: the zone's wildcard, since a failed state write can leave `state.domain` unset while previews work fine. const zone = await fetchSiteHostnames(coreClient, site.state.pullZoneId); - reconcilePreviewDomain(site.state, zone); + if (reconcilePreviewDomain(site.state, zone)) { + await persistReconciledDomain(site); + } const previews = Boolean(site.state.domain); // `sites.dir`/`sites.build` are what a local deploy uses, so the workflow follows them, relative to the bunny.jsonc directory they resolve against. diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index ed96f714..0723bc32 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -16,6 +16,7 @@ import { logger } from "../../core/logger.ts"; import { confirm, isInteractive, withSpinner } from "../../core/ui.ts"; import { fetchSiteHostnames, + persistReconciledDomain, promoteDeploy, reconcilePreviewDomain, type SiteContext, @@ -193,6 +194,8 @@ export const sitesDeployCommand = defineCommand({ ` Restore it with: bunny sites domains add ${staleDomain} ${state.name}`, ); } + // Persist before the deploy's own writes so an unchanged/no-op run still heals the state the read-only commands show. + if (drift) await persistReconciledDomain(site); // No custom domain means no preview hosts, so every deploy publishes; with a domain, previews are the default and --production is the publish switch. const publish = args.production === true || !state.domain; diff --git a/packages/cli/src/commands/sites/open.ts b/packages/cli/src/commands/sites/open.ts index be0f06cd..626d0882 100644 --- a/packages/cli/src/commands/sites/open.ts +++ b/packages/cli/src/commands/sites/open.ts @@ -11,6 +11,7 @@ import { } from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; import { openBrowser } from "../../core/ui.ts"; +import { previewZone, reconcilePreviewDomain } from "./api.ts"; import type { RemoteSiteState } from "./constants.ts"; import { type SiteSelectorArgs, @@ -65,6 +66,8 @@ export const sitesOpenCommand = defineCommand({ const { state } = site; const hostnames = await fetchPullZoneHostnames(client, state.pullZoneId); + // A domain the zone no longer serves must not win over the system host. + reconcilePreviewDomain(state, previewZone(hostnames)); const url = siteLiveUrl(state, hostnames); if (!url) { throw new UserError( diff --git a/packages/cli/src/commands/sites/show.ts b/packages/cli/src/commands/sites/show.ts index 51f02668..314339e3 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 { previewZone, reconcilePreviewDomain } from "./api.ts"; import { type SiteSelectorArgs, selectSite, @@ -45,10 +46,13 @@ export const sitesShowCommand = defineCommand({ }); const { state } = site; - // Hostnames are informational; a fetch failure shouldn't hide the site. - const hostnames = await withSpinner("Fetching hostnames...", () => - fetchPullZoneHostnames(client, state.pullZoneId).catch(() => []), + // Hostnames are informational; a fetch failure shouldn't hide the site (null, not [], so a failed read never reads as "no preview wildcard"). + const fetched = await withSpinner("Fetching hostnames...", () => + fetchPullZoneHostnames(client, state.pullZoneId).catch(() => null), ); + const hostnames = fetched ?? []; + // The zone's wildcard, not the best-effort record, says whether previews are on. + reconcilePreviewDomain(state, previewZone(fetched)); if (output === "json") { logger.log( diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index 158a53dd..ad1d6ce0 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -42,7 +42,7 @@ This is the rule that shapes every other command here: Previews are served on their own root host (via the `*.preview.` wildcard), 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. -The switch is the preview wildcard on the pull zone, not merely the domain being recorded. `deploy` and `ci init` check the pull zone directly and reconcile the site's stored domain against it, so a half-finished domain setup can't leave you deploying at previews that don't resolve, nor publishing a CI preview build to production. If the wildcard is missing, `deploy` says so, publishes directly, and prints the command to restore it; re-running `sites domains add ` reconciles a partial setup rather than failing on the already-attached wildcard. +The switch is the preview wildcard on the pull zone, not merely the domain being recorded. Every command that reads the site's domain (`deploy`, `ci init`, `show`, `list`, `open`) checks it against the zone's hostnames first and corrects a drifted record, so a half-finished domain setup can't leave you deploying at previews that don't resolve, nor publishing a CI preview build to production. If the wildcard is missing, `deploy` says so, publishes directly, and prints the command to restore it; re-running `sites domains add ` reconciles a partial setup rather than failing on the already-attached wildcard. ## Deploy IDs From 4acf2af8b356649bd643db0021a566d81d3ad98c Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Sun, 2 Aug 2026 12:19:33 +0100 Subject: [PATCH 6/6] fix idempotent domain adds and scheme-aware preview URLs --- .../sites-domainless-production-deploys.md | 2 +- AGENTS.md | 14 ++-- packages/cli/src/commands/sites/api.test.ts | 23 +++++- packages/cli/src/commands/sites/api.ts | 20 +++-- .../cli/src/commands/sites/deploy.test.ts | 29 ++++++- packages/cli/src/commands/sites/deploy.ts | 30 ++++++-- .../src/commands/sites/domains/index.test.ts | 50 ++++++++---- .../cli/src/commands/sites/domains/index.ts | 76 ++++++++++--------- .../cli/src/core/hostnames/client.test.ts | 61 ++++++++++++++- packages/cli/src/core/hostnames/client.ts | 35 +++++++-- packages/cli/src/core/hostnames/commands.ts | 18 +++-- packages/cli/src/core/hostnames/flow.ts | 11 ++- skills/bunny-cli/references/sites.md | 6 +- 13 files changed, 282 insertions(+), 93 deletions(-) diff --git a/.changeset/sites-domainless-production-deploys.md b/.changeset/sites-domainless-production-deploys.md index 8da3ad9f..b2fdc265 100644 --- a/.changeset/sites-domainless-production-deploys.md +++ b/.changeset/sites-domainless-production-deploys.md @@ -2,4 +2,4 @@ "@bunny.net/cli": minor --- -feat(sites): custom domains unlock preview deploys. Without a domain, `bunny sites deploy` publishes straight to production; attaching one switches deploys to immutable `dpl-{id}.preview.{domain}` preview URLs with `--production`/`--prod` publishing. Preview hosts are root-served, so client-side routers (TanStack Router, React Router) behave exactly like production, and deploys are only reachable through the apex or their preview host. `bunny sites ci init` scaffolds PR preview deploys when the site has a custom domain; without one the workflow deploys production on pushes to main only. A domainless site's first deploy offers to attach a custom domain (interactive runs; blank to skip, and it never re-asks), and later domainless deploys print a `sites domains add` hint. The domain is only recorded as the preview switch once its `*.preview` wildcard hostname is attached, and `sites deploy`/`sites ci init` verify that wildcard against the pull zone itself, healing stale state in either direction, so a partial or stale domain setup can neither route deploys at previews that can't serve nor publish a CI preview build to production. Re-running `sites domains add` reconciles a half-finished setup instead of failing on the already-attached wildcard. +feat(sites): custom domains unlock preview deploys. Without a domain, `bunny sites deploy` publishes straight to production; attaching one switches deploys to immutable `dpl-{id}.preview.{domain}` preview URLs with `--production`/`--prod` publishing. Preview hosts are root-served, so client-side routers (TanStack Router, React Router) behave exactly like production, and deploys are only reachable through the apex or their preview host. `bunny sites ci init` scaffolds PR preview deploys when the site has a custom domain; without one the workflow deploys production on pushes to main only. A domainless site's first deploy offers to attach a custom domain (interactive runs; blank to skip, and it never re-asks), and later domainless deploys print a `sites domains add` hint. The domain is only recorded as the preview switch once its `*.preview` wildcard hostname is attached, and `sites deploy`/`sites ci init` verify that wildcard against the pull zone itself, healing stale state in either direction, so a partial or stale domain setup can neither route deploys at previews that can't serve nor publish a CI preview build to production. Re-running `sites domains add` reconciles a half-finished setup instead of failing on the already-attached apex or wildcard, so the retry always reaches the remaining wildcard, certificate, and state-record steps. While the wildcard's certificate is still pending (it validates over DNS-01, so it needs the `*.preview` CNAME live), deploys print working `http://` preview URLs with a pending-HTTPS hint instead of `https://` URLs that fail TLS, upgrading automatically once the certificate issues. diff --git a/AGENTS.md b/AGENTS.md index 1b242702..e71c740a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -183,14 +183,14 @@ bunny-cli/ │ │ ├── format.test.ts # Tests for format utilities │ │ ├── hostnames/ # Reusable pull-zone hostname feature (mounted by scripts; apps next) │ │ │ ├── index.ts # Re-exports client helpers, DNS/flow helpers + createHostnamesCommands -│ │ │ ├── client.ts # hostnameUrl(), normalizeHostname(), addHostname(), fetchPullZoneHostnames(), enableSsl(), createPullZone() (storage-zone origin), systemHostname() + Hostname/ResolvedPullZone types -│ │ │ ├── client.test.ts # Tests for hostnameUrl() scheme logic +│ │ │ ├── client.ts # hostnameUrl(), normalizeHostname(), addHostname() (idempotent: a rejected duplicate the zone already serves reports alreadyAttached instead of throwing, so partial-setup retries reach their follow-up steps), fetchPullZoneHostnames(), enableSsl(), createPullZone() (storage-zone origin), systemHostname() + Hostname/ResolvedPullZone types +│ │ │ ├── client.test.ts # Tests for hostnameUrl() scheme logic + addHostname already-attached tolerance │ │ │ ├── dns.ts # dnsPointsAt()/anyResolverPointsAt(): DNS checks (CNAME or flattened A records) via system + public (1.1.1.1/8.8.8.8) resolvers, injectable for tests │ │ │ ├── dns.test.ts # Tests for DNS matching + multi-resolver checks with fake resolvers │ │ │ ├── flow.ts # offerDnsWaitAndSsl(): poll DNS + opportunistically attempt SSL issuance (~30s) since bunny's resolvers decide validation; printSslHint(). dnsAlreadyLive skips the poll (Bunny DNS record already live). offerBunnyDnsThenSsl() takes an optional onBunnyDnsZone(zone) callback fired when the hostname is on Bunny DNS (lets the command layer link the directory). setupHostname(): resource-agnostic add-hostname -> DNS -> SSL orchestration (caller supplies sslHint/retryHint); used by scripts setupCustomDomain and storage zone add │ │ │ ├── bunny-dns.ts # findBunnyDnsZone()/offerBunnyDnsRecord(): detect a hostname inside an account Bunny DNS zone, then add/repoint a PullZone record (always confirmed) so SSL can issue immediately │ │ │ ├── bunny-dns.test.ts # Tests for longest-suffix zone matching + record-name derivation with a fake core client -│ │ │ └── commands.ts # createHostnamesCommands(): add/ssl/list/remove factory parameterized by a pull-zone resolver; remove guards its confirmation with requireConfirmable (unattended runs need --force) +│ │ │ └── commands.ts # createHostnamesCommands(): add/ssl/list/remove factory parameterized by a pull-zone resolver; add treats an already-attached hostname as success (reported, and onAdded still runs, so retries finish companion/state work); remove guards its confirmation with requireConfirmable (unattended runs need --force) │ │ ├── bunny-config.ts # Shared bunny.jsonc discovery + raw read (findConfigRoot, configPath, configExists, readBunnyConfig); used by apps/ and sites/ config.ts │ │ ├── jsonc.ts # syncJsonc(): surgical JSONC editing that preserves comments, key order, and sibling blocks │ │ ├── jsonc.test.ts # Tests for syncJsonc (comment/formatting preservation, key add/remove) @@ -382,7 +382,7 @@ bunny-cli/ │ │ │ ├── index.ts # defineNamespace("sites", ...): create/list/show/deploy/deployments/domains/link/unlink/upgrade-router/delete │ │ │ ├── constants.ts # SITES_MANIFEST (.bunny/site.json), REMOTE_STATE_PATH (_bunny/site.json), RemoteSiteState/DeployRecord types, parseRemoteState (shape-checked; null = not a site), previewHostname/previewWildcard/deployPrefix helpers, deploy-ID + site-name validators (3-47 chars), suffixedResourceName/siteResourcePattern (zone names are `sites-{name}-{random 6}`: the prefix marks them in the dashboard, the suffix dodges the global zone namespace; the pattern also matches bare pre-suffix names) │ │ │ ├── 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), fetchSiteHostnames (one pull-zone read: system host + the *.preview.* wildcard's domain) / previewZone (same view from an already-fetched hostname list; null input = unread, never "no wildcard") / reconcilePreviewDomain (the zone's wildcard is the preview-mode source of truth; heals a drifted state.domain in memory and reports "attached"/"detached", since the state write is best-effort and can fail after the hostnames land) + persistReconciledDomain (best-effort write so read-only commands stop lagging). EVERY state.domain reader reconciles first: deploy + ci init (fetch, then persist on drift), show/open (reuse the hostnames they already fetch), fetchSites (reuses the pull-zone listing, so list is free), deleteSiteResources (pull zone → script → storage zone, best-effort), deleteDeployFiles +│ │ │ ├── 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), fetchSiteHostnames (one pull-zone read: system host + the *.preview.* wildcard's domain + previewSecure, whether the wildcard's certificate issued) / previewZone (same view from an already-fetched hostname list; null input = unread, never "no wildcard") / reconcilePreviewDomain (the zone's wildcard is the preview-mode source of truth; heals a drifted state.domain in memory and reports "attached"/"detached", since the state write is best-effort and can fail after the hostnames land) + persistReconciledDomain (best-effort write so read-only commands stop lagging). EVERY state.domain reader reconciles first: deploy + ci init (fetch, then persist on drift), show/open (reuse the hostnames they already fetch), fetchSites (reuses the pull-zone listing, so list is free), deleteSiteResources (pull zone → script → storage zone, best-effort), deleteDeployFiles │ │ │ ├── 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 @@ -399,14 +399,14 @@ bunny-cli/ │ │ │ ├── show.ts # Site details + hostname table (SSL cert + Force SSL columns) + router-outdated warning; reconciles state.domain against the hostnames it already fetched (a failed fetch is null, not [], so it never reads as "no wildcard") │ │ │ ├── open.ts # bunny sites open [site]: open the live URL (recorded custom domain when live, else system host) in the browser; --print emits it, siteLiveUrl is the pure resolver, and state.domain is reconciled against the fetched hostnames first so a dropped domain can't beat the system host │ │ │ ├── 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) → hash → no-op if unchanged → upload deploys/{id}/ → state update → publish. Without a custom domain every deploy publishes live (there are no preview hosts); with one the default is an immutable dpl-{id}.preview.* preview URL and --production/--prod publishes. Preview mode is decided by the pull zone's *.preview.* wildcard (fetchSiteHostnames + reconcilePreviewDomain), not the best-effort state.domain alone: a stale-missing domain heals from the zone (so a CI preview deploy can't publish to production) and a stale-present one warns + publishes directly (no dead preview URLs); a drifted heal is persisted immediately (persistReconciledDomain), so even a no-op/already-live run reconciles the state the read-only commands show. A domainless site's first-ever deploy (state.deploys was empty; stateless, so declining never re-asks) offers the custom-domain prompt (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) → hash → no-op if unchanged → upload deploys/{id}/ → state update → publish. Without a custom domain every deploy publishes live (there are no preview hosts); with one the default is an immutable dpl-{id}.preview.* preview URL and --production/--prod publishes. Preview mode is decided by the pull zone's *.preview.* wildcard (fetchSiteHostnames + reconcilePreviewDomain), not the best-effort state.domain alone: a stale-missing domain heals from the zone (so a CI preview deploy can't publish to production) and a stale-present one warns + publishes directly (no dead preview URLs); a drifted heal is persisted immediately (persistReconciledDomain), so even a no-op/already-live run reconciles the state the read-only commands show. A domainless site's first-ever deploy (state.deploys was empty; stateless, so declining never re-asks) offers the custom-domain prompt (setupSiteDomain, interactive text runs only); later domainless deploys print a dim `sites domains add` hint. Preview URLs carry the scheme the wildcard can serve (previewSecure): http:// plus a pending-HTTPS hint until its certificate issues, so a TLS-failing https URL is never printed │ │ │ ├── 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 (pushes router improvements to an existing site) │ │ │ ├── delete.ts # Delete a site (typed-name confirm; --keep-storage; drops .bunny/site.json if it pointed here) │ │ │ ├── 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) + prune.test.ts -│ │ │ └── domains/index.ts # Mounts core/hostnames createHostnamesCommands as "sites domains" with onAdded/onRemoved hooks: apex add also attaches *.preview. (attachPreviewWildcard, best-effort SSL; idempotent, so a retry after a failed state write finds the existing wildcard on the zone and reconciles instead of reporting failure) and records state.domain ONLY when the wildcard attached (state.domain is the previews-operational signal that flips deploy/CI into preview mode; recordSiteDomain rolls back the in-memory value if the state write fails); remove takes the wildcard down too. setupSiteDomain composes setupHostname + wildcard for create --domain +│ │ │ └── domains/index.ts # Mounts core/hostnames createHostnamesCommands as "sites domains" with onAdded/onRemoved hooks: apex add also attaches *.preview. (attachPreviewWildcard, best-effort SSL that skips issuance when the wildcard is already certified; idempotent via addHostname's already-attached tolerance, so a retry after a failed state write reconciles instead of reporting failure) and records state.domain ONLY when the wildcard attached (state.domain is the previews-operational signal that flips deploy/CI into preview mode; recordSiteDomain rolls back the in-memory value if the state write fails); remove takes the wildcard down too. setupSiteDomain composes setupHostname + wildcard for create --domain │ │ ├── registries/ │ │ │ ├── index.ts # Manual CommandModule (not defineNamespace) — default handler runs list │ │ │ ├── list.ts # List container registries @@ -1119,7 +1119,7 @@ bunny │ │ │ Promote a past deploy; instant rollback (--previous = the previous deploy). Unattended runs need --force (the confirmation is guarded by requireConfirmable) │ │ └── prune [site] [--keep N] [--force] Delete old deploys (never current/previous; default keeps 5). --keep is validated as a whole number >= 0 before anything runs (resolveKeepCount: yargs turns `--keep abc` into NaN, which would otherwise slip through pruneVictims and prune everything). Unattended runs need --force; "nothing to prune" still succeeds without it │ ├── domains (hidden alias: hostnames); mounts core/hostnames createHostnamesCommands with a sites resolver -│ │ ├── add [site] [--ssl] [--wait] [--no-force-ssl] Add a domain; also attaches *.preview. and, only when that wildcard attached, records the domain in site state (onAdded hook; the recorded domain is what switches deploy/CI into preview mode) +│ │ ├── add [site] [--ssl] [--wait] [--no-force-ssl] Add a domain; also attaches *.preview. and, only when that wildcard attached, records the domain in site state (onAdded hook; the recorded domain is what switches deploy/CI into preview mode). Re-running on an already-attached apex or wildcard reconciles the remaining steps instead of failing, so it's the retry for any partial setup │ │ ├── ssl [site] Issue a free SSL certificate │ │ ├── list [site] (alias: ls) List domains │ │ └── remove [site] [--force] Remove a domain (also removes its *.preview wildcard, onRemoved hook) diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index 86a38e8f..5e376315 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -773,7 +773,28 @@ test("previewZone tells a failed hostname read apart from a zone with no wildcar expect(previewZone([])).toEqual({ fetched: true, previewDomain: undefined }); expect( previewZone([{ Value: "*.preview.example.com" }] as Hostname[]), - ).toEqual({ fetched: true, previewDomain: "example.com" }); + ).toEqual({ + fetched: true, + previewDomain: "example.com", + previewSecure: false, + }); +}); + +// Preview URLs print with the scheme the wildcard can actually serve, so a pending certificate reads as http, never a TLS-failing https. +test("previewZone reports whether the wildcard's certificate has issued", () => { + expect( + previewZone([ + { Value: "*.preview.example.com", HasCertificate: true }, + ] as Hostname[]).previewSecure, + ).toBe(true); + expect( + previewZone([ + { Value: "*.preview.example.com", HasCertificate: false }, + ] as Hostname[]).previewSecure, + ).toBe(false); + expect( + previewZone([{ Value: "example.com" }] as Hostname[]).previewSecure, + ).toBeUndefined(); }); test("siteContextFromZone is null for a zone without site state", async () => { diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index d01dc7f1..d9f32b95 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -469,17 +469,22 @@ export async function fetchSystemHostname( } } -/** The preview-mode view of an already-fetched hostname list, so callers that read hostnames anyway reconcile without a second request; null means the read failed (never "no wildcard"). */ +/** The preview-mode view of an already-fetched hostname list, so callers that read hostnames anyway reconcile without a second request; null means the read failed (never "no wildcard"). `previewSecure: false` means the wildcard serves but its certificate hasn't issued, so preview URLs are http-only for now. */ export function previewZone(hostnames: Hostname[] | null | undefined): { fetched: boolean; previewDomain?: string; + previewSecure?: boolean; } { if (!hostnames) return { fetched: false }; + const wildcard = hostnames.find( + (h) => previewDomainFromWildcard(h.Value ?? "") !== undefined, + ); return { fetched: true, - previewDomain: hostnames - .map((h) => previewDomainFromWildcard(h.Value ?? "")) - .find((domain) => domain !== undefined), + previewDomain: wildcard + ? previewDomainFromWildcard(wildcard.Value ?? "") + : undefined, + previewSecure: wildcard ? wildcard.HasCertificate === true : undefined, }; } @@ -487,7 +492,12 @@ export function previewZone(hostnames: Hostname[] | null | undefined): { export async function fetchSiteHostnames( coreClient: CoreClient, pullZoneId: number, -): Promise<{ fetched: boolean; systemHost?: string; previewDomain?: string }> { +): Promise<{ + fetched: boolean; + systemHost?: string; + previewDomain?: string; + previewSecure?: boolean; +}> { try { const { data } = await coreClient.GET("/pullzone/{id}", { params: { path: { id: pullZoneId } }, diff --git a/packages/cli/src/commands/sites/deploy.test.ts b/packages/cli/src/commands/sites/deploy.test.ts index 459a6210..8859badd 100644 --- a/packages/cli/src/commands/sites/deploy.test.ts +++ b/packages/cli/src/commands/sites/deploy.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; import { resolve } from "node:path"; -import { resolveDeployDir } from "./deploy.ts"; +import type { SiteContext } from "./api.ts"; +import { deployUrls, resolveDeployDir } from "./deploy.ts"; const ROOT = "/project/root"; @@ -23,3 +24,29 @@ test("the detected framework output dir resolves against the root where the buil test("nothing specified falls back to the root, not cwd", () => { expect(resolveDeployDir(undefined, undefined, undefined, ROOT)).toBe(ROOT); }); + +const siteWithDomain = (domain?: string) => + ({ state: { domain } }) as SiteContext; + +test("deployUrls has no preview URL without a custom domain", () => { + expect( + deployUrls(siteWithDomain(undefined), "abc123", { + systemHost: "site.b-cdn.net", + }), + ).toEqual({ production: "https://site.b-cdn.net", preview: undefined }); +}); + +// Until the wildcard's certificate issues, an https preview URL fails TLS outright; the printed URL must match what the host can serve. +test("deployUrls previews are https only once the wildcard certificate issued", () => { + const site = siteWithDomain("example.com"); + expect(deployUrls(site, "abc123", { previewSecure: true }).preview).toBe( + "https://dpl-abc123.preview.example.com", + ); + expect(deployUrls(site, "abc123", { previewSecure: false }).preview).toBe( + "http://dpl-abc123.preview.example.com", + ); + // Zone unreadable: fall back to https rather than downgrading a working preview. + expect(deployUrls(site, "abc123", {}).preview).toBe( + "https://dpl-abc123.preview.example.com", + ); +}); diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 0723bc32..0df85590 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -59,17 +59,19 @@ const DOMAIN_HINT = " Add a custom domain to unlock preview deploys: bunny sites domains add "; // Production and preview URLs for a deploy: previews are `dpl-{id}.preview.{domain}` hosts, so they only exist once a custom domain is attached. -function deployUrls( +export function deployUrls( site: SiteContext, deployId: string, - systemHost: string | undefined, + zone: { systemHost?: string; previewSecure?: boolean }, ): { production?: string; preview?: string } { const domain = site.state.domain; - const productionHost = domain ?? systemHost; + const productionHost = domain ?? zone.systemHost; + // Until the wildcard's certificate issues, an https preview URL fails TLS outright, while http serves fine (ForceSSL is only set alongside the cert). + const previewScheme = zone.previewSecure === false ? "http" : "https"; return { production: productionHost ? `https://${productionHost}` : undefined, preview: domain - ? `https://${previewHostname(deployId, domain)}` + ? `${previewScheme}://${previewHostname(deployId, domain)}` : undefined, }; } @@ -265,9 +267,18 @@ export const sitesDeployCommand = defineCommand({ const deployId = alreadyUploaded?.id ?? identity.id; const alreadyLive = state.current === deployId; + // The preview host serves http-only until the wildcard's certificate issues; say so wherever a preview URL prints. + const previewTlsHint = () => { + if (zone.previewSecure === false && state.domain) { + logger.dim( + ` Preview HTTPS pending; once DNS is live: bunny sites domains ssl "${previewWildcard(state.domain)}" ${state.name}`, + ); + } + }; + // Nothing to do: the deploy is already uploaded (and live, if publishing). if (skipUpload && (alreadyLive || !publish)) { - const urls = deployUrls(site, deployId, zone.systemHost); + const urls = deployUrls(site, deployId, zone); if (output === "json") { logger.log( JSON.stringify( @@ -294,7 +305,10 @@ export const sitesDeployCommand = defineCommand({ `No changes: deploy ${deployId} is already uploaded. Publish it with \`bunny sites deploy --production\`.`, ); } - if (urls.preview) logger.log(` Preview: ${urls.preview}`); + if (urls.preview) { + logger.log(` Preview: ${urls.preview}`); + previewTlsHint(); + } // The common repeat path after declining the first-deploy domain offer still gets the hint. if (!state.domain) logger.dim(DOMAIN_HINT); return; @@ -342,7 +356,7 @@ export const sitesDeployCommand = defineCommand({ }); } - const urls = deployUrls(site, deployId, zone.systemHost); + const urls = deployUrls(site, deployId, zone); if (output === "json") { logger.log( @@ -374,8 +388,10 @@ export const sitesDeployCommand = defineCommand({ if (publish) { if (urls.production) logger.info(`Production: ${urls.production}`); if (urls.preview) logger.log(` Preview: ${urls.preview}`); + previewTlsHint(); } else { if (urls.preview) logger.info(`Preview: ${urls.preview}`); + previewTlsHint(); logger.info( `Publish it with \`bunny sites deploy --production\` or \`bunny sites deployments publish ${deployId}\`.`, ); diff --git a/packages/cli/src/commands/sites/domains/index.test.ts b/packages/cli/src/commands/sites/domains/index.test.ts index 2180ffba..3b39b2d5 100644 --- a/packages/cli/src/commands/sites/domains/index.test.ts +++ b/packages/cli/src/commands/sites/domains/index.test.ts @@ -2,16 +2,17 @@ import { expect, test } from "bun:test"; import type { CoreClient } from "../../../core/hostnames/index.ts"; import { attachPreviewWildcard } from "./index.ts"; -// A minimal core client covering the wildcard-attach calls: addHostname POST, hostname list GET, free-cert GET, force-SSL POST. `hostnames` is what the zone reports back. +// A minimal core client covering the wildcard-attach calls: addHostname POST, hostname list GET, free-cert GET, force-SSL POST. `hostnames` is what the zone reports back; `calls` counts certificate issuance attempts. function stubClient(opts: { failAddHostname?: boolean; failSsl?: boolean; - hostnames?: string[]; + hostnames?: Array; }) { - const hostnames = (opts.hostnames ?? ["*.preview.example.com"]).map( - (Value) => ({ Value }), + const hostnames = (opts.hostnames ?? ["*.preview.example.com"]).map((h) => + typeof h === "string" ? { Value: h } : h, ); - return { + const calls = { loadFreeCertificate: 0 }; + const client = { POST: async (route: string) => { if (route === "/pullzone/{id}/addHostname" && opts.failAddHostname) { throw new Error("hostname is already taken"); @@ -19,19 +20,22 @@ function stubClient(opts: { return { data: undefined }; }, GET: async (route: string) => { - if (route === "/pullzone/loadFreeCertificate" && opts.failSsl) { - throw new Error("DNS not pointed yet"); + if (route === "/pullzone/loadFreeCertificate") { + calls.loadFreeCertificate++; + if (opts.failSsl) throw new Error("DNS not pointed yet"); } if (route === "/pullzone/{id}") return { data: { Hostnames: hostnames } }; return { data: undefined }; }, } as unknown as CoreClient; + return { client, calls }; } // `state.domain` (the previews-operational signal) keys off this return value, so an attach that leaves no wildcard on the zone must report false. test("attachPreviewWildcard reports failure when the hostname can't be added", async () => { + const { client } = stubClient({ failAddHostname: true, hostnames: [] }); const attached = await attachPreviewWildcard({ - coreClient: stubClient({ failAddHostname: true, hostnames: [] }), + coreClient: client, pullZoneId: 1, domain: "example.com", json: true, @@ -41,11 +45,12 @@ test("attachPreviewWildcard reports failure when the hostname can't be added", a // Retrying `domains add` after a failed state write re-adds the wildcard; the API rejects the duplicate, but previews do work, so the retry must reconcile rather than report failure again. test("attachPreviewWildcard succeeds when the wildcard is already on the zone", async () => { + const { client } = stubClient({ + failAddHostname: true, + hostnames: ["example.com", "*.PREVIEW.example.com"], + }); const attached = await attachPreviewWildcard({ - coreClient: stubClient({ - failAddHostname: true, - hostnames: ["example.com", "*.PREVIEW.example.com"], - }), + coreClient: client, pullZoneId: 1, domain: "example.com", json: true, @@ -56,7 +61,7 @@ test("attachPreviewWildcard succeeds when the wildcard is already on the zone", test("attachPreviewWildcard succeeds once the hostname attaches, even when SSL is still pending", async () => { expect( await attachPreviewWildcard({ - coreClient: stubClient({}), + coreClient: stubClient({}).client, pullZoneId: 1, domain: "example.com", json: true, @@ -65,10 +70,27 @@ test("attachPreviewWildcard succeeds once the hostname attaches, even when SSL i // DNS-01 can't complete before the wildcard record exists; a pending cert must not block previews. expect( await attachPreviewWildcard({ - coreClient: stubClient({ failSsl: true }), + coreClient: stubClient({ failSsl: true }).client, pullZoneId: 1, domain: "example.com", json: true, }), ).toBe(true); }); + +// A retry on a fully set-up wildcard must not re-issue (or report HTTPS as pending when it isn't). +test("attachPreviewWildcard skips issuance when the wildcard already has a certificate", async () => { + const { client, calls } = stubClient({ + failAddHostname: true, + failSsl: true, + hostnames: [{ Value: "*.preview.example.com", HasCertificate: true }], + }); + const attached = await attachPreviewWildcard({ + coreClient: client, + pullZoneId: 1, + domain: "example.com", + json: true, + }); + expect(attached).toBe(true); + expect(calls.loadFreeCertificate).toBe(0); +}); diff --git a/packages/cli/src/commands/sites/domains/index.ts b/packages/cli/src/commands/sites/domains/index.ts index b6bb07bc..c904a5d4 100644 --- a/packages/cli/src/commands/sites/domains/index.ts +++ b/packages/cli/src/commands/sites/domains/index.ts @@ -7,7 +7,6 @@ import { type CoreClient, createHostnamesCommands, enableSsl, - fetchPullZoneHostnames, type Hostname, type ResolvedPullZone, setupHostname, @@ -72,53 +71,56 @@ export async function attachPreviewWildcard(opts: { }): Promise { const wildcard = previewWildcard(opts.domain); let hostnames: Hostname[]; + let alreadyAttached: boolean; try { - hostnames = (await addHostname(opts.coreClient, opts.pullZoneId, wildcard)) - .hostnames; + // A retry after a partial setup re-adds an existing wildcard; addHostname reconciles that against the zone instead of failing. + ({ hostnames, alreadyAttached } = await addHostname( + opts.coreClient, + opts.pullZoneId, + wildcard, + )); + } catch (err) { if (!opts.json) { + logger.warn(`Couldn't add ${wildcard}: ${errorMessage(err)}`); + logger.dim( + ` Previews stay off and deploys keep publishing directly; retry with \`bunny sites domains add ${opts.domain}\`.`, + ); + } + return false; + } + if (!opts.json) { + if (alreadyAttached) { + logger.info(`${wildcard} is already attached for deploy previews.`); + } else { logger.success(`Added ${wildcard} for deploy previews.`); if (opts.cnameTarget) { logger.accent(` CNAME ${wildcard} → ${opts.cnameTarget}`); } } - } catch (err) { - // Retrying after a partial setup re-adds an existing wildcard, so the zone decides whether previews can serve, not the error. - const existing = await fetchPullZoneHostnames( - opts.coreClient, - opts.pullZoneId, - ).catch(() => [] as Hostname[]); - const attached = existing.some( - (h) => (h.Value ?? "").toLowerCase() === wildcard.toLowerCase(), - ); - if (!attached) { + } + + // A retry on an already-certified wildcard skips issuance; re-running it would print a bogus pending hint. + const certified = hostnames.some( + (h) => + (h.Value ?? "").toLowerCase() === wildcard.toLowerCase() && + h.HasCertificate, + ); + if (!certified) { + try { + await enableSsl( + opts.coreClient, + opts.pullZoneId, + wildcard, + true, + hostnames, + ); + } catch { + // Wildcard certs need DNS in place (DNS-01); issue later, don't block. Deploys print http:// preview URLs until it lands. if (!opts.json) { - logger.warn(`Couldn't add ${wildcard}: ${errorMessage(err)}`); logger.dim( - ` Previews stay off and deploys keep publishing directly; retry with \`bunny sites domains add ${opts.domain}\`.`, + ` Preview HTTPS pending; once DNS is live: bunny sites domains ssl "${wildcard}"`, ); } - return false; - } - hostnames = existing; - if (!opts.json) { - logger.info(`${wildcard} is already attached for deploy previews.`); - } - } - - try { - await enableSsl( - opts.coreClient, - opts.pullZoneId, - wildcard, - true, - hostnames, - ); - } catch { - // Wildcard certs need DNS in place (DNS-01); issue later, don't block. - if (!opts.json) { - logger.dim( - ` Preview HTTPS pending; once DNS is live: bunny sites domains ssl "${wildcard}"`, - ); } } return true; diff --git a/packages/cli/src/core/hostnames/client.test.ts b/packages/cli/src/core/hostnames/client.test.ts index 726d3655..132a9459 100644 --- a/packages/cli/src/core/hostnames/client.test.ts +++ b/packages/cli/src/core/hostnames/client.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { type Hostname, hostnameUrl, toSafeHostname } from "./client.ts"; +import { + addHostname, + type CoreClient, + type Hostname, + hostnameUrl, + toSafeHostname, +} from "./client.ts"; describe("hostnameUrl", () => { test("respects an existing scheme", () => { @@ -62,3 +68,56 @@ describe("toSafeHostname", () => { }); }); }); + +describe("addHostname", () => { + // POST rejects, GET reports what the zone serves. + const stubClient = (opts: { fail?: boolean; hostnames: string[] }) => + ({ + POST: async () => { + if (opts.fail) throw new Error("hostname is already taken"); + return { data: undefined }; + }, + GET: async () => ({ + data: { + Hostnames: opts.hostnames.map((Value) => ({ + Value, + IsSystemHostname: Value.endsWith(".b-cdn.net"), + })), + }, + }), + }) as unknown as CoreClient; + + test("reports a fresh add", async () => { + const result = await addHostname( + stubClient({ hostnames: ["site.b-cdn.net", "shop.example.com"] }), + 1, + "shop.example.com", + ); + expect(result.alreadyAttached).toBe(false); + expect(result.cnameTarget).toBe("site.b-cdn.net"); + }); + + // Retries after a partial setup re-add an existing hostname; follow-up steps (companion wildcard, state record) must still run, so this is not a failure. + test("treats a rejected duplicate that the zone serves as already attached", async () => { + const result = await addHostname( + stubClient({ + fail: true, + hostnames: ["site.b-cdn.net", "SHOP.example.com"], + }), + 1, + "shop.example.com", + ); + expect(result.alreadyAttached).toBe(true); + expect(result.cnameTarget).toBe("site.b-cdn.net"); + }); + + test("rethrows when the rejected hostname is not on the zone", async () => { + expect( + addHostname( + stubClient({ fail: true, hostnames: ["site.b-cdn.net"] }), + 1, + "shop.example.com", + ), + ).rejects.toThrow("hostname is already taken"); + }); +}); diff --git a/packages/cli/src/core/hostnames/client.ts b/packages/cli/src/core/hostnames/client.ts index c75315f6..68bd42ae 100644 --- a/packages/cli/src/core/hostnames/client.ts +++ b/packages/cli/src/core/hostnames/client.ts @@ -142,19 +142,38 @@ export async function createPullZone( return data; } -/** Add a hostname to a pull zone, returning the zone's hostnames and the CNAME target to point DNS at. */ +/** Add a hostname to a pull zone, returning the zone's hostnames and the CNAME target to point DNS at. A hostname the zone already serves reports `alreadyAttached` instead of failing, so retries after a partial setup reach their follow-up steps. */ export async function addHostname( client: CoreClient, pullZoneId: number, hostname: string, -): Promise<{ hostnames: Hostname[]; cnameTarget?: string }> { - await client.POST("/pullzone/{id}/addHostname", { - params: { path: { id: pullZoneId } }, - body: { Hostname: hostname }, - }); - const hostnames = await fetchPullZoneHostnames(client, pullZoneId); +): Promise<{ + hostnames: Hostname[]; + cnameTarget?: string; + alreadyAttached: boolean; +}> { + let hostnames: Hostname[] | undefined; + let alreadyAttached = false; + try { + await client.POST("/pullzone/{id}/addHostname", { + params: { path: { id: pullZoneId } }, + body: { Hostname: hostname }, + }); + } catch (err) { + // The zone decides whether a rejected duplicate counts as attached, not the error. + const existing = await fetchPullZoneHostnames(client, pullZoneId).catch( + () => null, + ); + const attached = existing?.some( + (h) => (h.Value ?? "").toLowerCase() === hostname.toLowerCase(), + ); + if (!attached) throw err; + alreadyAttached = true; + hostnames = existing ?? undefined; + } + hostnames ??= await fetchPullZoneHostnames(client, pullZoneId); const cnameTarget = systemHostname(hostnames)?.replace(/^https?:\/\//i, ""); - return { hostnames, cnameTarget }; + return { hostnames, cnameTarget, alreadyAttached }; } /** Set a hostname's Force SSL (HTTP→HTTPS redirect) state; assumes the cert is already in place. */ diff --git a/packages/cli/src/core/hostnames/commands.ts b/packages/cli/src/core/hostnames/commands.ts index 7ceae748..f045f054 100644 --- a/packages/cli/src/core/hostnames/commands.ts +++ b/packages/cli/src/core/hostnames/commands.ts @@ -176,11 +176,11 @@ export function createHostnamesCommands( const spin = spinner(`Adding ${hostname}...`); spin.start(); - const { hostnames, cnameTarget: systemHostname } = await addHostname( - coreClient, - pullZoneId, - hostname, - ); + const { + hostnames, + cnameTarget: systemHostname, + alreadyAttached, + } = await addHostname(coreClient, pullZoneId, hostname); spin.stop(); @@ -257,7 +257,13 @@ export function createHostnamesCommands( return; } - logger.success(`Added ${hostname} to pull zone ${pullZoneId}.`); + if (alreadyAttached) { + logger.info( + `${hostname} is already on pull zone ${pullZoneId}; finishing setup.`, + ); + } else { + logger.success(`Added ${hostname} to pull zone ${pullZoneId}.`); + } if (sslIssued) { logger.log(); diff --git a/packages/cli/src/core/hostnames/flow.ts b/packages/cli/src/core/hostnames/flow.ts index 9be1c22e..e7acafb6 100644 --- a/packages/cli/src/core/hostnames/flow.ts +++ b/packages/cli/src/core/hostnames/flow.ts @@ -211,8 +211,9 @@ export async function setupHostname(opts: { spin.start(); let cnameTarget: string | undefined; + let alreadyAttached = false; try { - ({ cnameTarget } = await addHostname( + ({ cnameTarget, alreadyAttached } = await addHostname( opts.coreClient, opts.pullZoneId, opts.domain, @@ -226,7 +227,13 @@ export async function setupHostname(opts: { } spin.stop(); - logger.success(`Added ${opts.domain} to pull zone ${opts.pullZoneId}.`); + if (alreadyAttached) { + logger.info( + `${opts.domain} is already on pull zone ${opts.pullZoneId}; finishing setup.`, + ); + } else { + logger.success(`Added ${opts.domain} to pull zone ${opts.pullZoneId}.`); + } if (!cnameTarget) return false; if (opts.interactive) { diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index ad1d6ce0..0d2703a3 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -42,7 +42,7 @@ This is the rule that shapes every other command here: Previews are served on their own root host (via the `*.preview.` wildcard), 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. -The switch is the preview wildcard on the pull zone, not merely the domain being recorded. Every command that reads the site's domain (`deploy`, `ci init`, `show`, `list`, `open`) checks it against the zone's hostnames first and corrects a drifted record, so a half-finished domain setup can't leave you deploying at previews that don't resolve, nor publishing a CI preview build to production. If the wildcard is missing, `deploy` says so, publishes directly, and prints the command to restore it; re-running `sites domains add ` reconciles a partial setup rather than failing on the already-attached wildcard. +The switch is the preview wildcard on the pull zone, not merely the domain being recorded. Every command that reads the site's domain (`deploy`, `ci init`, `show`, `list`, `open`) checks it against the zone's hostnames first and corrects a drifted record, so a half-finished domain setup can't leave you deploying at previews that don't resolve, nor publishing a CI preview build to production. If the wildcard is missing, `deploy` says so, publishes directly, and prints the command to restore it; re-running `sites domains add ` reconciles a partial setup rather than failing on the already-attached apex or wildcard. ## Deploy IDs @@ -124,9 +124,9 @@ bunny sites domains list bunny sites domains remove example.com ``` -Adding a domain also attaches `*.preview.`, which is what unlocks per-deploy preview URLs (removing the domain takes the wildcard down too, returning the site to publish-on-deploy). If the domain is on a Bunny DNS zone in the account, the CLI offers to create the apex record; otherwise it prints the CNAME target. **The wildcard's DNS record is always yours to create** (`CNAME *.preview.`), and because wildcard certificates validate over DNS-01, the certificate usually can't issue until that record resolves; run `bunny sites domains ssl "*.preview."` once it does. +Adding a domain also attaches `*.preview.`, which is what unlocks per-deploy preview URLs (removing the domain takes the wildcard down too, returning the site to publish-on-deploy). If the domain is on a Bunny DNS zone in the account, the CLI offers to create the apex record; otherwise it prints the CNAME target. **The wildcard's DNS record is always yours to create** (`CNAME *.preview.`), and because wildcard certificates validate over DNS-01, the certificate usually can't issue until that record resolves; run `bunny sites domains ssl "*.preview."` once it does. Until it does, previews serve over plain HTTP: `deploy` prints `http://` preview URLs plus a pending-HTTPS hint, and switches to `https://` on its own once the certificate is on the zone. -Previews only turn on if the wildcard hostname actually attached; if it didn't, the site stays in publish-on-deploy mode and the CLI prints the retry command. Re-running `bunny sites domains add ` after a partial setup reconciles it (an already-attached wildcard counts as attached, not as an error). +Previews only turn on if the wildcard hostname actually attached; if it didn't, the site stays in publish-on-deploy mode and the CLI prints the retry command. Re-running `bunny sites domains add ` after a partial setup reconciles whatever is left (an already-attached apex or wildcard counts as attached, not as an error, so the retry always reaches the wildcard, certificate, and state-record steps). So the full path from a plain deploy to working previews is: