From 215874a663f4cc7a9756aa81efcd98da2bf8d2ce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 14:15:15 +0000 Subject: [PATCH 01/24] docs: add phased implementation plan for the sites namespace Grounds the bunny sites plan in verified repo machinery: defineCommand/ defineNamespace registration in cli.ts, core/manifest.ts local-link pattern, storage files-api uploader (caller-side SHA-256 checksums), scripts env-var promote lever, createHostnamesCommands factory for the domains phase, and the changeset/testing/docs conventions each PR must follow. Adds a Phase 0 spike question for how a middleware script attaches to a storage-backed pull zone. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01J6CzqnCzQrV6exqx4PrF13 --- docs/plans/sites.md | 179 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 docs/plans/sites.md diff --git a/docs/plans/sites.md b/docs/plans/sites.md new file mode 100644 index 00000000..22f95104 --- /dev/null +++ b/docs/plans/sites.md @@ -0,0 +1,179 @@ +# Implementation plan: `bunny sites` + +Phased plan for adding the `sites` namespace to the CLI — static-site hosting on top of a storage zone + pull zone + middleware Edge Script router, with atomic deploys, instant rollback, and per-deploy preview URLs. + +Each phase is one reviewable PR. Every path, signature, and endpoint below has been verified against the current `main`. + +## Decisions locked (from the RFC discussion) + +- **One storage zone + one pull zone + one middleware router per site.** No DNS scripting — all deploys share one origin, so there is nothing for DNS to choose between. +- **Preview scheme:** `dpl-{id}.preview.{domain}` — a namespaced wildcard that can't shadow user subdomains, and preview-wide behavior (noindex, etc.) hangs on one host branch in the router. +- **Promote verb:** `sites deployments publish` for consistency with `scripts deployments publish`; `promote` as a hidden alias. +- **Deploy IDs:** git short-sha when the tree is clean, content hash otherwise; identical-hash deploys are no-ops. +- **Site identity:** the storage zone ID is the site ID (it's the root resource; the pull zone and script hang off it). The local manifest and remote state carry the full resource triple. + +## What the repo already gives us (verified) + +| Machinery | Where | Notes | +| --- | --- | --- | +| Command framework | `packages/cli/src/core/define-command.ts`, `define-namespace.ts` | `defineCommand({ command, describe, examples, builder, handler, preRun?, postRun? })`; `defineNamespace(command, describe \| false, subcommands, aliases?)`. Global args (`profile`, `verbose`, `output`, `apiKey`) are merged automatically; central error handling emits JSON errors when `output === "json"` and maps `UserError`/`ApiError` to exit 1, unexpected to exit 2. | +| Root registration | `packages/cli/src/cli.ts` | Two arrays: `commands` (visible) and `experimentalCommands` (registered but hidden from help/landing — `apps`, `registries`, `sandbox`, `storage` live here). `sites` starts experimental, promoted in Phase 5. | +| Middleware scripts | `commands/scripts/constants.ts` | `SCRIPT_TYPE_MIDDLEWARE: EdgeScriptTypes = 2`, typed from `components["schemas"]["EdgeScriptTypes"]` in `@bunny.net/openapi-client/generated/compute.d.ts`. `parseScriptType("middleware") → 2`. | +| Script creation | `commands/scripts/create.ts` | `createScript(opts)` → `POST /compute/script` with `{ Name, ScriptType, CreateLinkedPullZone, LinkedPullZoneName? }`. **The compute API creates the linked pull zone server-side** — see the Phase 0 attach question. | +| Env vars (promote lever) | `commands/scripts/env/set.ts` | `PUT /compute/script/{id}/variables` body `{ Name, DefaultValue }`. A single PUT, **no republish call exists or is needed** at the API level (runtime propagation latency is a Phase 0 question). `fetchEnvEntries(client, id)` in `scripts/api.ts` reads them back. | +| Publish/rollback UX | `commands/scripts/deployments/publish.ts` | `selectScript(client, { id, link, output })` (flag → `.bunny/` manifest → interactive picker with offer-to-link, from `scripts/interactive.ts`) → `confirm(msg, { force })` → POST. `deployments/list.ts` shows the `● Live` / `○ Archived` table style. | +| Storage files API | `commands/storage/files-api.ts` | `connectStorageZone(zone)` (needs the **full** zone record incl. `Password` — `resolveStorageZone`/`fetchStorageZone` in `storage/api.ts` re-fetch by ID to get it), `listFiles`, `uploadFile(zone, remotePath, stream, options?)`, `downloadFile`, `deleteFile`. **Checksums are caller-side**: `storage/file/upload.ts` computes streaming SHA-256 via `Bun.CryptoHasher("sha256")`, `.digest("hex").toUpperCase()`, passed as `UploadOptions.sha256Checksum`. | +| Storage zone creation | `commands/storage/zone/add.ts` | `POST /storagezone` body `{ Name, Region, ReplicationRegions }` on the core client. This file is also the template for create-then-domain orchestration (it composes `createPullZone` + `setupHostname`). | +| Pull zone creation | `core/hostnames/client.ts` | `createPullZone(coreClient, name, storageZoneId)` → `POST /pullzone` with `{ Name, StorageZoneId, OriginType: 2, EnableGeoZone*: true }`. Also `addHostname`, `enableSsl` (`GET /pullzone/loadFreeCertificate` + `POST /pullzone/{id}/setForceSSL`), `fetchPullZoneHostnames`, `normalizeHostname`, `hostnameUrl`, `liveHostnames`. | +| Hostname orchestration | `core/hostnames/flow.ts`, `commands.ts`, `bunny-dns.ts`, `dns.ts` | `setupHostname(opts)` is the full top-level flow (add hostname → Bunny-DNS auto-record or manual CNAME → DNS wait → SSL with retries). `createHostnamesCommands(opts)` is a **namespace factory** returning ready-made `add`/`ssl`/`list`/`remove` commands for any pull-zone-backed resource — its docstring explicitly anticipates new resource types. `findBunnyDnsZone`/`offerBunnyDnsRecord` handle PULLZONE-type DNS records; delegation checks come from `core/dns-nameservers.ts` (`checkDelegation`, `expectedNameservers`). `core/registrar.ts` is RDAP registrar *detection* only (useful for naming the registrar in NS instructions, nothing more). | +| DNS record presets | `commands/dns/record/presets.ts` | `DnsPreset { id, title, category, params, build(ctx) → AddDnsRecordModel[] }` with `mx/txt/cname/caa` builder helpers. Note: `PRESETS` is a user-facing catalog (email/verification) — the sites apex + wildcard pair should reuse the *builder-helper pattern* internally, not ship as a public preset. | +| Local manifests | `core/manifest.ts` | `loadManifest` / `saveManifest` (mode 0600) / `removeManifest` / `saveManifestAt` / `resolveManifestId(filename, id, resourceType)` — all keyed by filename under `.bunny/`, walking up the tree. Pattern: each resource exports a manifest filename constant (`SCRIPT_MANIFEST = "script.json"`, `STORAGE_MANIFEST = "storage.json"`). | +| Output/UI | `core/format.ts`, `core/logger.ts`, `core/ui.ts` | `formatTable(headers, rows, format)` / `formatKeyValue(entries, format)` handle `text\|table\|csv\|markdown` but **not** `json` — handlers check `output === "json"` first and `logger.log(JSON.stringify(data, null, 2))`. `logger.log` is the only stdout method (everything else is stderr, so JSON stays clean). `spinner()`, `confirm(msg, { force })`, `isInteractive(output)`, plus `formatBytes`, `progressBar`, `maskSecret` in format.ts. | +| Cache purge | `specs/core.json` | `POST /pullzone/{id}/purgeCache` exists in the spec; **nothing in the CLI calls it yet** — promote will be the first consumer. | +| Clients | `@bunny.net/openapi-client` | `createCoreClient` (storage zones, pull zones, DNS), `createComputeClient` (scripts), `createStorageClient`. Wiring: `resolveConfig(profile, apiKey, verbose)` (from `packages/cli/src/config/index.ts`) → `clientOptions(config, verbose)` → factory. | + +## Phase 0 — Spike: validate platform assumptions (no merge) + +The design rests on six assumptions. Kill or confirm each with a throwaway script/dashboard session before writing CLI code: + +1. **Attach mechanism.** How does a middleware script get onto a *storage-backed* pull zone? The CLI's only current path is `POST /compute/script` with `CreateLinkedPullZone: true`, where the compute API creates the pull zone itself. Determine which works: + - (a) create the script with a linked pull zone, then repoint that pull zone's origin to the storage zone (`POST /pullzone/{id}` with `{ OriginType: 2, StorageZoneId }`), or + - (b) create the pull zone via `createPullZone()` first, then link the script to it (find the linking field/endpoint in `specs/core.json` / `specs/compute.json`). + The answer decides the `sites create` orchestration order in Phase 1. +2. **Per-request rewrite.** Middleware attached to the pull zone can rewrite the origin request path per-request based on the `Host` header. +3. **Env-var propagation.** Updating `CURRENT_DEPLOY` via `PUT /compute/script/{id}/variables` takes effect without republishing code (the API requires no republish — verified in code), and propagates globally in acceptable time (target: seconds). +4. **Wildcard hostnames + certs.** `*.preview.example.com` can be added via `POST /pullzone/{id}/addHostname`, and cert issuance automates when the zone is on Bunny DNS. Specifically check whether `GET /pullzone/loadFreeCertificate` (what `enableSsl` uses) handles wildcards, or whether a DNS-01 flow needs a different endpoint. +5. **Cache isolation.** Cache keys include the original request URL/host, so previews and production don't cross-contaminate, and one `POST /pullzone/{id}/purgeCache` on promote is sufficient. +6. **Upload throughput.** `uploadFile` through `storage/files-api.ts` is acceptable for a ~200-file site at 8-way concurrency. + +Exit artifact: a short findings note in the Phase 1 PR description, plus the working router script from the spike. + +**Fallback if (2) or (3) fails:** static path-based previews (`preview.domain/deploys/{id}/`) with promote via edge-rule swap — the v1 RFC design. The command surface is identical either way, so later phases are unaffected. + +## Phase 1 — Scaffolding + site lifecycle + +**Commands:** `sites create [--domain]`, `list`, `show [site]`, `link [site]`, `unlink`, `delete [--force] [--keep-storage]` + +New files under `packages/cli/src/commands/sites/`: + +``` +index.ts # export const sitesNamespace = defineNamespace("sites", false, [...]) +constants.ts # SITES_MANIFEST = "site.json" (→ .bunny/site.json) + # REMOTE_STATE_PATH = "_bunny/site.json" + # SiteManifest { id: number; name?: string; pullZoneId?: number; scriptId?: number } + # RemoteSiteState { version; siteName; storageZoneId; pullZoneId; scriptId; + # routerVersion; current?; previous?; deploys: DeployRecord[]; + # stateChecksum? } +api.ts # provisioning orchestration + remote-state read/write + fetch/resolve helpers +interactive.ts # resolveSiteInteractive — mirrors storage/interactive.ts +router/source.ts # router script source as an exported template string (from the spike) +create.ts, list.ts, show.ts, link.ts, unlink.ts, delete.ts +``` + +Registration: add `sitesNamespace` to the `experimentalCommands` array in `packages/cli/src/cli.ts` (hidden while WIP — the `storage` pattern). Promote to `commands` in Phase 5. + +**`create` orchestration** (in `api.ts`; each step looks up by name before creating, so a failed create re-runs cleanly): + +1. Create storage zone (`POST /storagezone`, core client — reuse the body shape from `storage/zone/add.ts`). +2. Create pull zone with storage origin + create middleware router from `router/source.ts` — exact order/mechanism per the Phase 0 attach answer. Script upload uses the existing `POST /compute/script/{id}/code` + `POST /compute/script/{id}/publish` pair from `scripts/deploy.ts`. +3. Set `CURRENT_DEPLOY=""` (`PUT /compute/script/{id}/variables`). +4. Write remote `_bunny/site.json` via `connectStorageZone` + `uploadFile`. +5. `saveManifest(SITES_MANIFEST, { id, name, pullZoneId, scriptId })` unless `--no-link` (mirror `scripts/create.ts`). +6. If `--domain`: invoke the Phase 3 flow. The flag ships **hidden** in Phase 1 (stub that throws `UserError` with a "coming soon" hint). + +Client wiring in every handler, per convention: + +```ts +const config = resolveConfig(profile, apiKey, verbose); +const coreClient = createCoreClient(clientOptions(config, verbose)); +const computeClient = createComputeClient(clientOptions(config, verbose)); +``` + +**Router script v1** (~30 lines, from the spike): apex/`.b-cdn.net` host → serve `/deploys/{CURRENT_DEPLOY}{path}`; `^dpl-[a-z0-9]+\.preview\.` host → that deploy's prefix; `/_bunny/*` → 403; empty `CURRENT_DEPLOY` → friendly "no deploys yet" page. + +**Command behaviors:** + +- `list` — `formatTable(["ID", "Name", "Hostname", "Deploys", "Current"], rows, output)`; `output === "json"` first; empty state via `logger.info`. +- `show [site]` — `resolveSiteInteractive` → `formatKeyValue` sections (site, resources, current deploy, hostnames via `fetchPullZoneHostnames` + `liveHostnames`). +- `link`/`unlink` — `saveManifest`/`removeManifest`, mirroring `storage/link.ts`. +- `delete` — `confirm(..., { force })`; teardown order: script (detach/delete first), pull zone, then storage zone unless `--keep-storage`. + +**Tests** (co-located `*.test.ts`, `bun:test`): provisioning-order and idempotent re-run against a hand-rolled fake client (object literal branching on path strings, cast `as unknown as CoreClient` — the pattern in `core/hostnames/bunny-dns.test.ts`, with `mock.module` + dynamic import where sibling modules need stubbing); manifest round-trip; router source snapshot. + +## Phase 2 — Deploy + deployments (the core loop) + +**Commands:** `sites deploy [dir]`, `sites deployments list`, `sites deployments publish [--previous] [--force]` (alias `promote`) + +New files: `deploy.ts`, `deploy-id.ts` (+ test), `uploader.ts` (+ test), `deployments/{index,list,publish}.ts` + +Work items, in dependency order: + +1. **`deploy-id.ts`** — pure functions, easy unit tests: `gitShaId()` (shell out via `Bun.spawn` to `git rev-parse --short HEAD`, detect dirty tree with `git status --porcelain`), `contentHashId(files)` (sorted path+sha256 merkle → 8 hex chars). +2. **`uploader.ts`** — walk dir (v1: upload everything except dotfiles); per-file streaming SHA-256 via `Bun.CryptoHasher("sha256")` → `UploadOptions.sha256Checksum` (the `storage/file/upload.ts` pattern); 8-way concurrent `uploadFile` with retry/backoff; progress via `spinner()` from `core/ui.ts` + `progressBar()`/`formatBytes()` from `core/format.ts` (stderr, so `--output json` stays clean). +3. **Remote state read-modify-write** in `api.ts` — `downloadFile` `_bunny/site.json`, append deploy record, re-upload. Include a `stateChecksum` field checked before write (cheap optimistic lock; log-and-overwrite on mismatch in v1). Reading the storage zone for `connectStorageZone` must go through `fetchStorageZone`/`resolveStorageZone` (they return the full record with `Password`). +4. **`deploy.ts`** — resolve site (flag → manifest → picker with offer-to-link, mirroring `selectScript` in `scripts/interactive.ts`) → compute ID → short-circuit if ID equals `current` ("no changes") → upload to `/deploys/{id}/` → update state → promote (env var + `POST /pullzone/{id}/purgeCache`) unless `--no-promote` → print production + preview URLs via `hostnameUrl`. +5. **`deployments list`** — table `["ID", "Age", "Git", "Source", "Files", "Size", "Status"]` with `● ACTIVE` marker (the `scripts/deployments/list.ts` style, `formatDateTime` for dates); `--output json` free via the shared flag. +6. **`deployments publish`** — reuse the `scripts/deployments/publish.ts` confirm/`--force` UX; update env var, purge, swap `current`/`previous` in state. `--previous` is sugar for instant rollback. + +End state: the full Pages loop works against `.b-cdn.net`, with subdomain-preview infrastructure already live (previews resolve once a domain exists; path previews `/deploys/{id}/` work immediately). + +## Phase 3 — Domains + +**Commands:** `sites domains add `, `list`, `remove [--purge-dns]` + +Mount `createHostnamesCommands()` from `core/hostnames/commands.ts` — it exists for exactly this ("any resource backed by a pull zone") and returns ready-made `add`/`ssl`/`list`/`remove` commands given a `resolve: (args) => Promise`: + +```ts +createHostnamesCommands({ + commandPath: "sites domains", + namespace: "domains", + resolve: resolveSitePullZone, // site arg/manifest/picker → { pullZoneId, coreClient } + hiddenAliases: ["hostnames"], +}) +``` + +Sites-specific extension on top of the factory's `add` (wrap it or add a hook): after the apex succeeds, also attach `*.preview.` via `addHostname`, create the DNS pair on the owning Bunny DNS zone (apex as PULLZONE-type record via `findBunnyDnsZone`/`offerBunnyDnsRecord`; wildcard record alongside — model the pair with the `mx/txt/cname`-style builder helpers from `dns/record/presets.ts`, kept internal to sites rather than added to the public `PRESETS` catalog), then trigger cert issuance and poll with a spinner until valid (`offerDnsWaitAndSsl` already does poll-then-issue with retries; wildcard-cert specifics per the Phase 0 answer). + +Also: un-hide `create --domain` and wire it to this flow (`storage/zone/add.ts` and `setupCustomDomain` in `scripts/create.ts` are the composition templates). `show` gains a domains section with cert status. + +Edge cases handled explicitly: + +- Nameservers not pointed at Bunny yet — `checkDelegation`/`expectedNameservers` from `core/dns-nameservers.ts` detect it (that's how `bunny-dns.ts` sets `match.delegated`); print NS instructions (use `detectRegistrar` from `core/registrar.ts` to name the registrar) and exit 0 with a "re-run when propagated" hint. +- A preexisting conflicting record at the apex — `offerBunnyDnsRecord` already handles repointing with a prompt; surface it clearly. + +## Phase 4 — Env vars + builds + +**Commands:** `sites env set/list/remove/pull`, plus `deploy --build [cmd]` + +- Env store at `_bunny/env.json` (already 403-blocked by the router), read/write through `downloadFile`/`uploadFile`; values echoed masked in `list` via `maskSecret()` from `core/format.ts` unless `--show`. +- `deploy --build`: merge remote env + `--env`/`--env-file` overrides → spawn build command via `Bun.spawn` (from flag or `bunny.jsonc`) with merged environment → then the normal deploy path on the output dir. Record `envHash` in the deploy entry. +- **`bunny.jsonc` support:** add an optional top-level `sites` block to `BunnyAppConfigSchema` in `packages/app-config/src/schema.ts` — `sites: SitesConfigSchema.optional()` with `{ name?, dir?, build? }`, exported sub-schema + `z.infer` type, mirroring `ProbeConfigSchema` et al. Regenerate `generated/schema.json` via `packages/app-config/scripts/generate-schema.ts` (`z.toJSONSchema(..., { target: "draft-2020-12" })`). `saveConfig`'s re-keying (`$schema`, `version`, `...rest`) preserves the new key automatically; consider bumping `CURRENT_VERSION` (date-versioned). Separate changeset, minor bump for `@bunny.net/app-config`. +- Loud docs + CLI warning: build-time env is baked into the bundle; **not a secret store**. + +## Phase 5 — Polish + ship + +- `deployments prune [--keep N]` (never prunes `current`/`previous`). +- `X-Robots-Tag: noindex` on preview hosts in the router; router version handling — store `routerVersion` in remote state; `sites show` warns when an upgrade is available; `sites upgrade` republishes the router source. +- Promote `sitesNamespace` from `experimentalCommands` to `commands` in `cli.ts` with a real `describe`. +- Update `skills/bunny-cli/` — Quick Start mention in `SKILL.md` plus a new `references/sites.md` (matching the existing per-area reference docs: `api.md`, `auth.md`, `database.md`, `dns.md`, `scripts.md`). This is the tie-in to the sandbox project: the app-builder supervisor just runs `bunny sites deploy ./dist --build`. +- Docs PR to `BunnyWay/documentation` (Mintlify page under CDN or a new Sites section) with the deploy→rollback quickstart. +- Release: changesets (minor `@bunny.net/cli`, minor `@bunny.net/app-config`; the `fixed` group in `.changeset/config.json` propagates the CLI bump to the platform binaries automatically), `bun run typecheck`, `bun run lint`, `bun test`. + +## Cross-cutting conventions checklist (applies to every phase) + +Per `AGENTS.md` "Conventions for Adding New Commands" and `CLAUDE.md`: + +- `defineCommand()`/`defineNamespace()` for everything; register in `cli.ts`. +- Flag equivalents for every interactive prompt; prompts gated on `isInteractive(output)` and skipped under `--force`. +- `--output json` handled first in every handler (`logger.log(JSON.stringify(data, null, 2)); return;`); all status/progress output through `logger.*` (stderr) or `spinner()` so JSON stays parseable. +- `UserError(message, hint?)` for expected failures; never raw `throw new Error` for user-facing conditions. +- `resolveConfig(profile, apiKey, verbose)` + `clientOptions(config, verbose)` for all clients; import clients from `@bunny.net/openapi-client` and generated types from `@bunny.net/openapi-client/generated/.d.ts` (`core.d.ts` for storage/pull zones/DNS, `compute.d.ts` for scripts). Prefer `Pick` over inline primitives. +- Co-located `*.test.ts` with `bun:test`; fake clients as path-branching object literals; `mock.module` for sibling stubs. +- Update `README.md` (command examples) and `AGENTS.md` (Command Reference tree + file listing + manifest section) in the same PR as the code. +- One changeset per PR. + +## Sequencing and effort + +Phase 0 is a day of poking the platform and decides everything — do it first, alone. Phases 1–2 are the meat (roughly a week together) and ship a usable feature on their own. Phase 3 is mostly composition of `core/hostnames/` (1–2 days if the wildcard-cert API cooperates — `createHostnamesCommands` removes most of the surface work). Phases 4–5 are independent and can trail. + +Branch/PR naming: `feat/sites-scaffolding`, `feat/sites-deploy`, `feat/sites-domains`, `feat/sites-env`, `feat/sites-polish`, each with its own changeset. From 7828145c231118b97afbe6d4347a3e9b69155542 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 15:14:28 +0000 Subject: [PATCH 02/24] =?UTF-8?q?feat(sites):=20add=20the=20sites=20namesp?= =?UTF-8?q?ace=20=E2=80=94=20static-site=20hosting=20with=20atomic=20deplo?= =?UTF-8?q?ys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the docs/plans/sites.md plan (phases 1-5): - sites create provisions storage zone + pull zone + middleware router (attached via the pull zone's MiddlewareScriptId); idempotent re-runs converge on half-finished creates - sites deploy uploads immutable deploys/{id}/ directories (git short-sha when clean, content hash otherwise; unchanged deploys are no-ops) and promotes by flipping the router's CURRENT_DEPLOY env var + purging the cache; --build runs the configured build with remote env merged - sites deployments list/publish/prune: instant rollback via --previous, prune never drops current/previous - sites domains mounts core/hostnames createHostnamesCommands (new onAdded/onRemoved hooks) and pairs every apex with a *.preview. wildcard for per-deploy preview URLs - sites env manages build-time vars at _bunny/env.json (403-blocked by the router, masked list, dotenv pull) - site state lives at _bunny/site.json in the storage zone with a sha256-etag optimistic lock; .bunny/site.json is the local pointer - @bunny.net/app-config gains an optional sites { name, dir, build } block consumed for deploy defaults - co-located bun tests (36) via a swappable siteFiles IO seam and path-branching fake clients; README/AGENTS/skills docs + changesets Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01J6CzqnCzQrV6exqx4PrF13 --- .changeset/app-config-sites-block.md | 5 + .changeset/sites-namespace.md | 5 + AGENTS.md | 54 ++ README.md | 7 + docs/plans/sites.md | 42 +- packages/app-config/generated/schema.json | 15 + packages/app-config/src/index.ts | 2 + packages/app-config/src/schema.ts | 13 + packages/cli/src/cli.ts | 2 + packages/cli/src/commands/sites/api.test.ts | 395 +++++++++++++++ packages/cli/src/commands/sites/api.ts | 469 ++++++++++++++++++ packages/cli/src/commands/sites/build.test.ts | 67 +++ packages/cli/src/commands/sites/build.ts | 92 ++++ packages/cli/src/commands/sites/config.ts | 47 ++ .../cli/src/commands/sites/constants.test.ts | 70 +++ packages/cli/src/commands/sites/constants.ts | 124 +++++ packages/cli/src/commands/sites/create.ts | 177 +++++++ packages/cli/src/commands/sites/delete.ts | 147 ++++++ .../cli/src/commands/sites/deploy-id.test.ts | 82 +++ packages/cli/src/commands/sites/deploy-id.ts | 81 +++ packages/cli/src/commands/sites/deploy.ts | 307 ++++++++++++ .../src/commands/sites/deployments/index.ts | 14 + .../src/commands/sites/deployments/list.ts | 90 ++++ .../commands/sites/deployments/prune.test.ts | 29 ++ .../src/commands/sites/deployments/prune.ts | 150 ++++++ .../src/commands/sites/deployments/publish.ts | 160 ++++++ .../cli/src/commands/sites/domains/index.ts | 198 ++++++++ packages/cli/src/commands/sites/env/index.ts | 16 + packages/cli/src/commands/sites/env/list.ts | 89 ++++ .../cli/src/commands/sites/env/pull.test.ts | 25 + packages/cli/src/commands/sites/env/pull.ts | 105 ++++ packages/cli/src/commands/sites/env/remove.ts | 92 ++++ packages/cli/src/commands/sites/env/set.ts | 108 ++++ packages/cli/src/commands/sites/index.ts | 26 + .../cli/src/commands/sites/interactive.ts | 193 +++++++ packages/cli/src/commands/sites/link.ts | 60 +++ packages/cli/src/commands/sites/list.ts | 75 +++ .../cli/src/commands/sites/router/source.ts | 80 +++ packages/cli/src/commands/sites/show.ts | 128 +++++ packages/cli/src/commands/sites/unlink.ts | 28 ++ packages/cli/src/commands/sites/upgrade.ts | 113 +++++ .../cli/src/commands/sites/uploader.test.ts | 100 ++++ packages/cli/src/commands/sites/uploader.ts | 129 +++++ packages/cli/src/core/hostnames/commands.ts | 38 ++ packages/cli/src/core/hostnames/index.ts | 1 + skills/bunny-cli/SKILL.md | 8 +- skills/bunny-cli/references/sites.md | 151 ++++++ 47 files changed, 4387 insertions(+), 22 deletions(-) create mode 100644 .changeset/app-config-sites-block.md create mode 100644 .changeset/sites-namespace.md create mode 100644 packages/cli/src/commands/sites/api.test.ts create mode 100644 packages/cli/src/commands/sites/api.ts create mode 100644 packages/cli/src/commands/sites/build.test.ts create mode 100644 packages/cli/src/commands/sites/build.ts create mode 100644 packages/cli/src/commands/sites/config.ts create mode 100644 packages/cli/src/commands/sites/constants.test.ts create mode 100644 packages/cli/src/commands/sites/constants.ts create mode 100644 packages/cli/src/commands/sites/create.ts create mode 100644 packages/cli/src/commands/sites/delete.ts create mode 100644 packages/cli/src/commands/sites/deploy-id.test.ts create mode 100644 packages/cli/src/commands/sites/deploy-id.ts create mode 100644 packages/cli/src/commands/sites/deploy.ts create mode 100644 packages/cli/src/commands/sites/deployments/index.ts create mode 100644 packages/cli/src/commands/sites/deployments/list.ts create mode 100644 packages/cli/src/commands/sites/deployments/prune.test.ts create mode 100644 packages/cli/src/commands/sites/deployments/prune.ts create mode 100644 packages/cli/src/commands/sites/deployments/publish.ts create mode 100644 packages/cli/src/commands/sites/domains/index.ts create mode 100644 packages/cli/src/commands/sites/env/index.ts create mode 100644 packages/cli/src/commands/sites/env/list.ts create mode 100644 packages/cli/src/commands/sites/env/pull.test.ts create mode 100644 packages/cli/src/commands/sites/env/pull.ts create mode 100644 packages/cli/src/commands/sites/env/remove.ts create mode 100644 packages/cli/src/commands/sites/env/set.ts create mode 100644 packages/cli/src/commands/sites/index.ts create mode 100644 packages/cli/src/commands/sites/interactive.ts create mode 100644 packages/cli/src/commands/sites/link.ts create mode 100644 packages/cli/src/commands/sites/list.ts create mode 100644 packages/cli/src/commands/sites/router/source.ts create mode 100644 packages/cli/src/commands/sites/show.ts create mode 100644 packages/cli/src/commands/sites/unlink.ts create mode 100644 packages/cli/src/commands/sites/upgrade.ts create mode 100644 packages/cli/src/commands/sites/uploader.test.ts create mode 100644 packages/cli/src/commands/sites/uploader.ts create mode 100644 skills/bunny-cli/references/sites.md diff --git a/.changeset/app-config-sites-block.md b/.changeset/app-config-sites-block.md new file mode 100644 index 00000000..89053161 --- /dev/null +++ b/.changeset/app-config-sites-block.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/app-config": minor +--- + +feat: optional top-level `sites` block (`name`, `dir`, `build`) in the bunny.jsonc schema, consumed by `bunny sites deploy` for the default deploy directory and build command. Exported as `SiteConfigSchema`/`SiteConfig`; the generated JSON Schema includes the new block. diff --git a/.changeset/sites-namespace.md b/.changeset/sites-namespace.md new file mode 100644 index 00000000..1154ef58 --- /dev/null +++ b/.changeset/sites-namespace.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": minor +--- + +feat(sites): new `bunny sites` namespace for static-site hosting. `sites create` provisions a storage zone + pull zone + middleware router per site; `sites deploy` uploads immutable deploys (git-sha or content-hash IDs, no-op when unchanged) and promotes by flipping the router's `CURRENT_DEPLOY` env var + purging the cache; `sites deployments list/publish/prune` cover rollback and cleanup; `sites domains` attaches custom domains plus a `*.preview.` wildcard for per-deploy preview URLs; `sites env` manages build-time variables merged into `sites deploy --build`; `sites link/unlink/show/upgrade/delete` round out the lifecycle. Site state lives at `_bunny/site.json` inside the storage zone (403-blocked by the router). The shared hostnames factory gains optional `onAdded`/`onRemoved` hooks. diff --git a/AGENTS.md b/AGENTS.md index 7da07dda..ed2d1a23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -373,6 +373,32 @@ bunny-cli/ │ │ │ ├── upload.ts # Upload a local file ( positional, --zone, --to, --checksum streams a SHA256, --content-type) │ │ │ ├── download.ts # Download a file to disk ( positional, --zone, --out) │ │ │ └── remove.ts # Delete a file or directory (alias: rm; positional, --zone, trailing slash = recursive) +│ │ ├── sites/ # Experimental (hidden from help and landing page) — static-site hosting (storage zone + pull zone + middleware router) +│ │ │ ├── index.ts # defineNamespace("sites", ...): create/list/show/deploy/deployments/domains/env/link/unlink/upgrade/delete +│ │ │ ├── constants.ts # SITES_MANIFEST (.bunny/site.json), REMOTE_STATE_PATH/REMOTE_ENV_PATH (_bunny/*.json), RemoteSiteState/DeployRecord types, parseRemoteState (shape-checked; null = not a site), previewHostname/previewWildcard/deployPrefix helpers, deploy-ID + site-name validators +│ │ │ ├── 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: warn + overwrite on mismatch), remote env read/write, 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), promoteDeploy (env var PUT + purgeCache POST), 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, env tolerance, createSite fresh/resume/already-exists, promote, fetchSites filtering +│ │ │ ├── interactive.ts # selectSite: explicit ref → .bunny/site.json → bunny.jsonc sites.name → picker (offerLink like scripts); siteOptionBuilder (--site) + sitePositionalBuilder ([site]) +│ │ │ ├── config.ts # loadSiteConfig: lenient bunny.jsonc reader — validates ONLY the `sites` block (SiteConfigSchema from @bunny.net/app-config), so sites-only configs work without an `app` block +│ │ │ ├── router/source.ts # ROUTER_VERSION + routerSource(): the middleware Edge Script attached to the pull zone. Host → deploy dir mapping (apex → CURRENT_DEPLOY, dpl-{id}.preview.* → that deploy), /_bunny/* → 403, /deploys/* passthrough (path previews), trailing-slash → index.html, 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 +│ │ │ ├── uploader.test.ts # Walk/skip/hash tests + upload paths/checksums/retry via siteFiles swap +│ │ │ ├── build.ts # parseEnvAssignments/parseEnvFile/envHash + runBuildCommand (Bun.spawn shell, merged env, throws on non-zero exit) +│ │ │ ├── build.test.ts # Env parsing + hash stability + real build spawn success/failure +│ │ │ ├── create.ts # bunny sites create : createSite + manifest link + optional --domain via setupSiteDomain (domain failure warns, never fails the create) +│ │ │ ├── list.ts # List sites (name, URL, deploy count, current) +│ │ │ ├── show.ts # Site details + hostname/SSL table + router-outdated warning +│ │ │ ├── deploy.ts # bunny sites deploy [dir]: optional build → hash → no-op if unchanged → upload deploys/{id}/ → state update → promote unless --no-promote → production/preview URLs +│ │ │ ├── link.ts # Link directory to a site (.bunny/site.json) +│ │ │ ├── unlink.ts # Remove .bunny/site.json +│ │ │ ├── upgrade.ts # Republish the router at ROUTER_VERSION + bump state.routerVersion +│ │ │ ├── delete.ts # Delete a site (typed-name confirm; --keep-storage; drops .bunny/site.json if it pointed here) +│ │ │ ├── deployments/ # list (● Live/○ Previous), publish [id]|--previous (alias promote; confirm + promote + current/previous swap), prune --keep N (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 +│ │ │ └── env/ # set/list/remove/pull over _bunny/env.json (masked list unless --show; pull refuses to overwrite without --force) + pull.test.ts (toDotenv quoting) │ │ ├── registries/ │ │ │ ├── index.ts # Manual CommandModule (not defineNamespace) — default handler runs list │ │ │ ├── list.ts # List container registries @@ -1059,6 +1085,33 @@ bunny │ ├── show [id] Show Edge Script details (uses linked script if omitted) │ └── stats [id] [--from] [--to] [--hourly] [--link] │ Show usage statistics (requests/CPU/cost totals + bar chart; defaults to last 30 days). No ID → linked script → interactive picker (offers to link; --no-link skips). JSON output skips the picker and errors. +├── sites (experimental — hidden from help and landing page) +│ │ Static-site hosting: one storage zone (files) + one pull zone (CDN) + one middleware router script per site. Deploys are immutable directories (`deploys/{id}/`); promote/rollback flips the router's CURRENT_DEPLOY env var + purges the cache — no files move. 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). +│ ├── create [--region] [--domain] [--link] +│ │ Provision a site (idempotent — a failed create re-runs cleanly; each resource is looked up by name first). --domain also attaches *.preview. for per-deploy previews. +│ ├── list (alias: ls) List sites (middleware+storage pull zones with matching remote state) +│ ├── show [site] Show resources, domains (with SSL state), current deploy; warns when a newer router is available +│ ├── deploy [dir] [--site] [--build [cmd]] [--env K=V] [--env-file] [--no-promote] [--force] +│ │ Deploy a directory: git short-sha ID when the tree is clean, content hash otherwise; identical IDs are no-ops. [dir] defaults to `sites.dir` in bunny.jsonc, then cwd (dotfiles + node_modules excluded). --build runs the command (or `sites.build`) with remote env + overrides merged. Prints production + preview URLs. +│ ├── deployments +│ │ ├── list [site] (alias: ls) List deploys (● Live / ○ Previous markers, created, source, files, size) +│ │ ├── publish [id] [--previous] [--site] [--force] (alias: promote) +│ │ │ Promote a past deploy — instant rollback (--previous = the previous deploy) +│ │ └── prune [--keep N] [--site] [--force] Delete old deploys (never current/previous; default keeps 5) +│ ├── 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) +│ │ ├── 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) +│ ├── env Build-time env stored at `_bunny/env.json` (NOT runtime env, NOT a secret store — values are baked into build output) +│ │ ├── set [name] [value] [--site] Set a variable (prompts when omitted) +│ │ ├── list [--show] [--site] List variables (masked unless --show) +│ │ ├── remove [--site] [--force] Remove a variable (alias: rm) +│ │ └── pull [file] [--site] [--force] Write env to a dotenv file (default .env; refuses to overwrite) +│ ├── link [site] Link this directory to a site → .bunny/site.json +│ ├── unlink Remove .bunny/site.json +│ ├── upgrade [site] [--force] Republish the router script at the CLI's ROUTER_VERSION +│ └── delete [site] [--force] [--keep-storage] Delete pull zone → router → storage zone (typed-name confirmation; best-effort so re-runs finish a partial delete) ├── docs Open bunny.net documentation in browser ├── open [--print] Open bunny.net dashboard in browser (or print URL) ├── --profile, -p Profile to use (default: "default") @@ -1257,6 +1310,7 @@ Commands that operate on a specific remote resource (e.g. a script, an app) can ### How it works - **`.bunny/script.json`** (gitignored) — links the current directory to a remote Edge Script. +- **`.bunny/site.json`** (gitignored) — links the current directory to a site (the site's storage zone ID). Written by `bunny sites link`/`create`; the site's own state (resource triple, deploys, current/previous) lives remotely at `_bunny/site.json` inside the storage zone, so the local manifest is only a pointer. - The manifest is machine-managed: written by `bunny scripts link`, read by other script commands. - `resolveManifestId()` in `packages/cli/src/core/manifest.ts` handles the resolution: explicit ID flag → manifest file → error with hint. - `findRoot()` walks up the directory tree to find `.bunny/`, so it works from subdirectories. diff --git a/README.md b/README.md index 0ab21879..c967f195 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,13 @@ bun ny dns records scan example.com # scan for the domain's existing rec bun ny dns records preset list # list DNS record presets (email providers, verification, security) bun ny dns records preset google-workspace example.com # apply a preset record set bun ny dns records preset bluesky example.com --param did=did:plc:abc123 # apply a preset non-interactively +bun ny sites create my-site # provision a static site (storage zone + pull zone + edge router) +bun ny sites deploy ./dist # deploy a directory and promote it to production +bun ny sites deploy --build # run `sites.build` from bunny.jsonc, then deploy `sites.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 domains add example.com # attach a custom domain (+ *.preview.example.com for previews) +bun ny sites env set VITE_API_URL https://api.example.com # build-time env (not a secret store) ``` ### Available Scripts diff --git a/docs/plans/sites.md b/docs/plans/sites.md index 22f95104..a0566215 100644 --- a/docs/plans/sites.md +++ b/docs/plans/sites.md @@ -14,32 +14,32 @@ Each phase is one reviewable PR. Every path, signature, and endpoint below has b ## What the repo already gives us (verified) -| Machinery | Where | Notes | -| --- | --- | --- | -| Command framework | `packages/cli/src/core/define-command.ts`, `define-namespace.ts` | `defineCommand({ command, describe, examples, builder, handler, preRun?, postRun? })`; `defineNamespace(command, describe \| false, subcommands, aliases?)`. Global args (`profile`, `verbose`, `output`, `apiKey`) are merged automatically; central error handling emits JSON errors when `output === "json"` and maps `UserError`/`ApiError` to exit 1, unexpected to exit 2. | -| Root registration | `packages/cli/src/cli.ts` | Two arrays: `commands` (visible) and `experimentalCommands` (registered but hidden from help/landing — `apps`, `registries`, `sandbox`, `storage` live here). `sites` starts experimental, promoted in Phase 5. | -| Middleware scripts | `commands/scripts/constants.ts` | `SCRIPT_TYPE_MIDDLEWARE: EdgeScriptTypes = 2`, typed from `components["schemas"]["EdgeScriptTypes"]` in `@bunny.net/openapi-client/generated/compute.d.ts`. `parseScriptType("middleware") → 2`. | -| Script creation | `commands/scripts/create.ts` | `createScript(opts)` → `POST /compute/script` with `{ Name, ScriptType, CreateLinkedPullZone, LinkedPullZoneName? }`. **The compute API creates the linked pull zone server-side** — see the Phase 0 attach question. | -| Env vars (promote lever) | `commands/scripts/env/set.ts` | `PUT /compute/script/{id}/variables` body `{ Name, DefaultValue }`. A single PUT, **no republish call exists or is needed** at the API level (runtime propagation latency is a Phase 0 question). `fetchEnvEntries(client, id)` in `scripts/api.ts` reads them back. | -| Publish/rollback UX | `commands/scripts/deployments/publish.ts` | `selectScript(client, { id, link, output })` (flag → `.bunny/` manifest → interactive picker with offer-to-link, from `scripts/interactive.ts`) → `confirm(msg, { force })` → POST. `deployments/list.ts` shows the `● Live` / `○ Archived` table style. | -| Storage files API | `commands/storage/files-api.ts` | `connectStorageZone(zone)` (needs the **full** zone record incl. `Password` — `resolveStorageZone`/`fetchStorageZone` in `storage/api.ts` re-fetch by ID to get it), `listFiles`, `uploadFile(zone, remotePath, stream, options?)`, `downloadFile`, `deleteFile`. **Checksums are caller-side**: `storage/file/upload.ts` computes streaming SHA-256 via `Bun.CryptoHasher("sha256")`, `.digest("hex").toUpperCase()`, passed as `UploadOptions.sha256Checksum`. | -| Storage zone creation | `commands/storage/zone/add.ts` | `POST /storagezone` body `{ Name, Region, ReplicationRegions }` on the core client. This file is also the template for create-then-domain orchestration (it composes `createPullZone` + `setupHostname`). | -| Pull zone creation | `core/hostnames/client.ts` | `createPullZone(coreClient, name, storageZoneId)` → `POST /pullzone` with `{ Name, StorageZoneId, OriginType: 2, EnableGeoZone*: true }`. Also `addHostname`, `enableSsl` (`GET /pullzone/loadFreeCertificate` + `POST /pullzone/{id}/setForceSSL`), `fetchPullZoneHostnames`, `normalizeHostname`, `hostnameUrl`, `liveHostnames`. | -| Hostname orchestration | `core/hostnames/flow.ts`, `commands.ts`, `bunny-dns.ts`, `dns.ts` | `setupHostname(opts)` is the full top-level flow (add hostname → Bunny-DNS auto-record or manual CNAME → DNS wait → SSL with retries). `createHostnamesCommands(opts)` is a **namespace factory** returning ready-made `add`/`ssl`/`list`/`remove` commands for any pull-zone-backed resource — its docstring explicitly anticipates new resource types. `findBunnyDnsZone`/`offerBunnyDnsRecord` handle PULLZONE-type DNS records; delegation checks come from `core/dns-nameservers.ts` (`checkDelegation`, `expectedNameservers`). `core/registrar.ts` is RDAP registrar *detection* only (useful for naming the registrar in NS instructions, nothing more). | -| DNS record presets | `commands/dns/record/presets.ts` | `DnsPreset { id, title, category, params, build(ctx) → AddDnsRecordModel[] }` with `mx/txt/cname/caa` builder helpers. Note: `PRESETS` is a user-facing catalog (email/verification) — the sites apex + wildcard pair should reuse the *builder-helper pattern* internally, not ship as a public preset. | -| Local manifests | `core/manifest.ts` | `loadManifest` / `saveManifest` (mode 0600) / `removeManifest` / `saveManifestAt` / `resolveManifestId(filename, id, resourceType)` — all keyed by filename under `.bunny/`, walking up the tree. Pattern: each resource exports a manifest filename constant (`SCRIPT_MANIFEST = "script.json"`, `STORAGE_MANIFEST = "storage.json"`). | -| Output/UI | `core/format.ts`, `core/logger.ts`, `core/ui.ts` | `formatTable(headers, rows, format)` / `formatKeyValue(entries, format)` handle `text\|table\|csv\|markdown` but **not** `json` — handlers check `output === "json"` first and `logger.log(JSON.stringify(data, null, 2))`. `logger.log` is the only stdout method (everything else is stderr, so JSON stays clean). `spinner()`, `confirm(msg, { force })`, `isInteractive(output)`, plus `formatBytes`, `progressBar`, `maskSecret` in format.ts. | -| Cache purge | `specs/core.json` | `POST /pullzone/{id}/purgeCache` exists in the spec; **nothing in the CLI calls it yet** — promote will be the first consumer. | -| Clients | `@bunny.net/openapi-client` | `createCoreClient` (storage zones, pull zones, DNS), `createComputeClient` (scripts), `createStorageClient`. Wiring: `resolveConfig(profile, apiKey, verbose)` (from `packages/cli/src/config/index.ts`) → `clientOptions(config, verbose)` → factory. | +| Machinery | Where | Notes | +| ------------------------ | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Command framework | `packages/cli/src/core/define-command.ts`, `define-namespace.ts` | `defineCommand({ command, describe, examples, builder, handler, preRun?, postRun? })`; `defineNamespace(command, describe \| false, subcommands, aliases?)`. Global args (`profile`, `verbose`, `output`, `apiKey`) are merged automatically; central error handling emits JSON errors when `output === "json"` and maps `UserError`/`ApiError` to exit 1, unexpected to exit 2. | +| Root registration | `packages/cli/src/cli.ts` | Two arrays: `commands` (visible) and `experimentalCommands` (registered but hidden from help/landing — `apps`, `registries`, `sandbox`, `storage` live here). `sites` starts experimental, promoted in Phase 5. | +| Middleware scripts | `commands/scripts/constants.ts` | `SCRIPT_TYPE_MIDDLEWARE: EdgeScriptTypes = 2`, typed from `components["schemas"]["EdgeScriptTypes"]` in `@bunny.net/openapi-client/generated/compute.d.ts`. `parseScriptType("middleware") → 2`. | +| Script creation | `commands/scripts/create.ts` | `createScript(opts)` → `POST /compute/script` with `{ Name, ScriptType, CreateLinkedPullZone, LinkedPullZoneName? }`. **The compute API creates the linked pull zone server-side** — see the Phase 0 attach question. | +| Env vars (promote lever) | `commands/scripts/env/set.ts` | `PUT /compute/script/{id}/variables` body `{ Name, DefaultValue }`. A single PUT, **no republish call exists or is needed** at the API level (runtime propagation latency is a Phase 0 question). `fetchEnvEntries(client, id)` in `scripts/api.ts` reads them back. | +| Publish/rollback UX | `commands/scripts/deployments/publish.ts` | `selectScript(client, { id, link, output })` (flag → `.bunny/` manifest → interactive picker with offer-to-link, from `scripts/interactive.ts`) → `confirm(msg, { force })` → POST. `deployments/list.ts` shows the `● Live` / `○ Archived` table style. | +| Storage files API | `commands/storage/files-api.ts` | `connectStorageZone(zone)` (needs the **full** zone record incl. `Password` — `resolveStorageZone`/`fetchStorageZone` in `storage/api.ts` re-fetch by ID to get it), `listFiles`, `uploadFile(zone, remotePath, stream, options?)`, `downloadFile`, `deleteFile`. **Checksums are caller-side**: `storage/file/upload.ts` computes streaming SHA-256 via `Bun.CryptoHasher("sha256")`, `.digest("hex").toUpperCase()`, passed as `UploadOptions.sha256Checksum`. | +| Storage zone creation | `commands/storage/zone/add.ts` | `POST /storagezone` body `{ Name, Region, ReplicationRegions }` on the core client. This file is also the template for create-then-domain orchestration (it composes `createPullZone` + `setupHostname`). | +| Pull zone creation | `core/hostnames/client.ts` | `createPullZone(coreClient, name, storageZoneId)` → `POST /pullzone` with `{ Name, StorageZoneId, OriginType: 2, EnableGeoZone*: true }`. Also `addHostname`, `enableSsl` (`GET /pullzone/loadFreeCertificate` + `POST /pullzone/{id}/setForceSSL`), `fetchPullZoneHostnames`, `normalizeHostname`, `hostnameUrl`, `liveHostnames`. | +| Hostname orchestration | `core/hostnames/flow.ts`, `commands.ts`, `bunny-dns.ts`, `dns.ts` | `setupHostname(opts)` is the full top-level flow (add hostname → Bunny-DNS auto-record or manual CNAME → DNS wait → SSL with retries). `createHostnamesCommands(opts)` is a **namespace factory** returning ready-made `add`/`ssl`/`list`/`remove` commands for any pull-zone-backed resource — its docstring explicitly anticipates new resource types. `findBunnyDnsZone`/`offerBunnyDnsRecord` handle PULLZONE-type DNS records; delegation checks come from `core/dns-nameservers.ts` (`checkDelegation`, `expectedNameservers`). `core/registrar.ts` is RDAP registrar _detection_ only (useful for naming the registrar in NS instructions, nothing more). | +| DNS record presets | `commands/dns/record/presets.ts` | `DnsPreset { id, title, category, params, build(ctx) → AddDnsRecordModel[] }` with `mx/txt/cname/caa` builder helpers. Note: `PRESETS` is a user-facing catalog (email/verification) — the sites apex + wildcard pair should reuse the _builder-helper pattern_ internally, not ship as a public preset. | +| Local manifests | `core/manifest.ts` | `loadManifest` / `saveManifest` (mode 0600) / `removeManifest` / `saveManifestAt` / `resolveManifestId(filename, id, resourceType)` — all keyed by filename under `.bunny/`, walking up the tree. Pattern: each resource exports a manifest filename constant (`SCRIPT_MANIFEST = "script.json"`, `STORAGE_MANIFEST = "storage.json"`). | +| Output/UI | `core/format.ts`, `core/logger.ts`, `core/ui.ts` | `formatTable(headers, rows, format)` / `formatKeyValue(entries, format)` handle `text\|table\|csv\|markdown` but **not** `json` — handlers check `output === "json"` first and `logger.log(JSON.stringify(data, null, 2))`. `logger.log` is the only stdout method (everything else is stderr, so JSON stays clean). `spinner()`, `confirm(msg, { force })`, `isInteractive(output)`, plus `formatBytes`, `progressBar`, `maskSecret` in format.ts. | +| Cache purge | `specs/core.json` | `POST /pullzone/{id}/purgeCache` exists in the spec; **nothing in the CLI calls it yet** — promote will be the first consumer. | +| Clients | `@bunny.net/openapi-client` | `createCoreClient` (storage zones, pull zones, DNS), `createComputeClient` (scripts), `createStorageClient`. Wiring: `resolveConfig(profile, apiKey, verbose)` (from `packages/cli/src/config/index.ts`) → `clientOptions(config, verbose)` → factory. | ## Phase 0 — Spike: validate platform assumptions (no merge) The design rests on six assumptions. Kill or confirm each with a throwaway script/dashboard session before writing CLI code: -1. **Attach mechanism.** How does a middleware script get onto a *storage-backed* pull zone? The CLI's only current path is `POST /compute/script` with `CreateLinkedPullZone: true`, where the compute API creates the pull zone itself. Determine which works: +1. **Attach mechanism.** How does a middleware script get onto a _storage-backed_ pull zone? The CLI's only current path is `POST /compute/script` with `CreateLinkedPullZone: true`, where the compute API creates the pull zone itself. Determine which works: - (a) create the script with a linked pull zone, then repoint that pull zone's origin to the storage zone (`POST /pullzone/{id}` with `{ OriginType: 2, StorageZoneId }`), or - (b) create the pull zone via `createPullZone()` first, then link the script to it (find the linking field/endpoint in `specs/core.json` / `specs/compute.json`). - The answer decides the `sites create` orchestration order in Phase 1. + The answer decides the `sites create` orchestration order in Phase 1. 2. **Per-request rewrite.** Middleware attached to the pull zone can rewrite the origin request path per-request based on the `Host` header. 3. **Env-var propagation.** Updating `CURRENT_DEPLOY` via `PUT /compute/script/{id}/variables` takes effect without republishing code (the API requires no republish — verified in code), and propagates globally in acceptable time (target: seconds). 4. **Wildcard hostnames + certs.** `*.preview.example.com` can be added via `POST /pullzone/{id}/addHostname`, and cert issuance automates when the zone is on Bunny DNS. Specifically check whether `GET /pullzone/loadFreeCertificate` (what `enableSsl` uses) handles wildcards, or whether a DNS-01 flow needs a different endpoint. @@ -127,9 +127,9 @@ Mount `createHostnamesCommands()` from `core/hostnames/commands.ts` — it exist createHostnamesCommands({ commandPath: "sites domains", namespace: "domains", - resolve: resolveSitePullZone, // site arg/manifest/picker → { pullZoneId, coreClient } + resolve: resolveSitePullZone, // site arg/manifest/picker → { pullZoneId, coreClient } hiddenAliases: ["hostnames"], -}) +}); ``` Sites-specific extension on top of the factory's `add` (wrap it or add a hook): after the apex succeeds, also attach `*.preview.` via `addHostname`, create the DNS pair on the owning Bunny DNS zone (apex as PULLZONE-type record via `findBunnyDnsZone`/`offerBunnyDnsRecord`; wildcard record alongside — model the pair with the `mx/txt/cname`-style builder helpers from `dns/record/presets.ts`, kept internal to sites rather than added to the public `PRESETS` catalog), then trigger cert issuance and poll with a spinner until valid (`offerDnsWaitAndSsl` already does poll-then-issue with retries; wildcard-cert specifics per the Phase 0 answer). diff --git a/packages/app-config/generated/schema.json b/packages/app-config/generated/schema.json index 2a0e4e45..c635d4c5 100644 --- a/packages/app-config/generated/schema.json +++ b/packages/app-config/generated/schema.json @@ -210,6 +210,21 @@ }, "required": ["name", "containers"], "additionalProperties": false + }, + "sites": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "dir": { + "type": "string" + }, + "build": { + "type": "string" + } + }, + "additionalProperties": false } }, "required": ["version", "app"], diff --git a/packages/app-config/src/index.ts b/packages/app-config/src/index.ts index d06704a7..0cffb31a 100644 --- a/packages/app-config/src/index.ts +++ b/packages/app-config/src/index.ts @@ -16,6 +16,7 @@ export type { EndpointConfig, ProbeConfig, RegionsConfig, + SiteConfig, VolumeConfig, } from "./schema.ts"; export { @@ -26,5 +27,6 @@ export { normalizeRegions, ProbeConfigSchema, RegionsConfigSchema, + SiteConfigSchema, VolumeConfigSchema, } from "./schema.ts"; diff --git a/packages/app-config/src/schema.ts b/packages/app-config/src/schema.ts index 7c7421df..b215f0a2 100644 --- a/packages/app-config/src/schema.ts +++ b/packages/app-config/src/schema.ts @@ -67,6 +67,17 @@ export const RegionsConfigSchema = z.union([ }), ]); +/** + * Static-site config (`bunny sites`). All fields are optional: `name` links + * the directory to a site, `dir` is the deploy root, and `build` is the + * command `bunny sites deploy --build` runs before uploading. + */ +export const SiteConfigSchema = z.object({ + name: z.string().optional(), + dir: z.string().optional(), + build: z.string().optional(), +}); + export const BunnyAppConfigSchema = z.object({ $schema: z.string().optional(), version: VersionSchema, @@ -77,9 +88,11 @@ export const BunnyAppConfigSchema = z.object({ regions: RegionsConfigSchema.optional(), containers: z.record(z.string(), ContainerConfigSchema), }), + sites: SiteConfigSchema.optional(), }); export type BunnyAppConfig = z.infer; +export type SiteConfig = z.infer; export type ContainerConfig = z.infer; export type EndpointConfig = z.infer; export type VolumeConfig = z.infer; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index fdc20598..3b1397ed 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -14,6 +14,7 @@ import { openCommand } from "./commands/open.ts"; import { registriesNamespace } from "./commands/registries/index.ts"; import { sandboxNamespace } from "./commands/sandbox/index.ts"; import { scriptsNamespace } from "./commands/scripts/index.ts"; +import { sitesNamespace } from "./commands/sites/index.ts"; import { storageNamespace } from "./commands/storage/index.ts"; import { whoamiCommand } from "./commands/whoami.ts"; import { bunny } from "./core/colors.ts"; @@ -38,6 +39,7 @@ const experimentalCommands: CommandModule[] = [ appsNamespace, registriesNamespace, sandboxNamespace, + sitesNamespace, storageNamespace, ]; diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts new file mode 100644 index 00000000..7ad583f1 --- /dev/null +++ b/packages/cli/src/commands/sites/api.test.ts @@ -0,0 +1,395 @@ +import { afterAll, beforeEach, expect, test } from "bun:test"; +import type { CoreClient, StorageZoneModel } from "../storage/api.ts"; +import { + type ComputeClient, + createSite, + fetchSites, + promoteDeploy, + readRemoteEnv, + readRemoteState, + siteContextFromZone, + siteFiles, + writeRemoteState, +} from "./api.ts"; +import { + REMOTE_ENV_PATH, + REMOTE_STATE_PATH, + type RemoteSiteState, + STATE_VERSION, +} from "./constants.ts"; +import { ROUTER_VERSION } from "./router/source.ts"; + +// ---- in-memory storage-file store (replaces the storage SDK) ---- + +const store = new Map(); +const original = { ...siteFiles }; + +beforeEach(() => { + store.clear(); + siteFiles.connect = (zone) => + ({ zoneName: zone.Name }) as unknown as ReturnType< + typeof siteFiles.connect + >; + siteFiles.download = async (_zone, path) => { + const content = store.get(path); + if (content === undefined) throw new Error("404 Not Found"); + return { + stream: new Blob([content]).stream(), + response: new Response(content), + length: content.length, + }; + }; + siteFiles.upload = async (_zone, path, stream) => { + store.set(path, await new Response(stream).text()); + }; + siteFiles.remove = async (_zone, path) => { + for (const key of [...store.keys()]) { + if (key.startsWith(path)) store.delete(key); + } + }; +}); + +afterAll(() => { + Object.assign(siteFiles, original); +}); + +// ---- fake clients: object literals branching on the path string ---- + +interface Call { + method: string; + path: string; + params?: Record; + body?: unknown; +} + +const ZONE: StorageZoneModel = { + Id: 10, + Name: "my-site", + Region: "DE", + Password: "pw", +} as StorageZoneModel; + +function fakeConnection() { + return siteFiles.connect(ZONE); +} + +function fakeState(overrides?: Partial): RemoteSiteState { + return { + version: STATE_VERSION, + name: "my-site", + storageZoneId: 10, + pullZoneId: 30, + scriptId: 20, + routerVersion: ROUTER_VERSION, + deploys: [], + ...overrides, + }; +} + +function fakeCoreClient(opts: { + calls: Call[]; + storageZones?: StorageZoneModel[]; + pullZones?: Array>; +}): CoreClient { + const zones = opts.storageZones ?? []; + const pullZones = opts.pullZones ?? []; + return { + GET: async ( + path: string, + options?: { params?: { path?: { id?: number }; query?: unknown } }, + ) => { + opts.calls.push({ method: "GET", path, params: options?.params }); + if (path === "/storagezone") return { data: zones }; + if (path === "/storagezone/{id}") { + const zone = zones.find((z) => z.Id === options?.params?.path?.id); + return { data: zone }; + } + if (path === "/pullzone") return { data: pullZones }; + throw new Error(`unexpected GET ${path}`); + }, + POST: async ( + path: string, + options?: { params?: unknown; body?: unknown }, + ) => { + opts.calls.push({ + method: "POST", + path, + params: options?.params as Record, + body: options?.body, + }); + if (path === "/storagezone") { + const zone = { + ...ZONE, + Id: 10, + Name: (options?.body as { Name: string }).Name, + }; + zones.push(zone); + return { data: zone }; + } + if (path === "/pullzone") { + const pz = { + Id: 30, + Name: (options?.body as { Name: string }).Name, + Hostnames: [{ IsSystemHostname: true, Value: "my-site.b-cdn.net" }], + }; + pullZones.push(pz); + return { data: pz }; + } + if (path === "/pullzone/{id}") return { data: {} }; + if (path === "/pullzone/{id}/purgeCache") return { data: undefined }; + throw new Error(`unexpected POST ${path}`); + }, + DELETE: async (path: string, options?: { params?: unknown }) => { + opts.calls.push({ + method: "DELETE", + path, + params: options?.params as Record, + }); + return { data: undefined }; + }, + } as unknown as CoreClient; +} + +function fakeComputeClient(opts: { + calls: Call[]; + scripts?: Array<{ Id: number; Name: string }>; +}): ComputeClient { + const scripts = opts.scripts ?? []; + return { + GET: async (path: string) => { + opts.calls.push({ method: "GET", path }); + if (path === "/compute/script") return { data: { Items: scripts } }; + throw new Error(`unexpected GET ${path}`); + }, + POST: async (path: string, options?: { body?: unknown }) => { + opts.calls.push({ method: "POST", path, body: options?.body }); + if (path === "/compute/script") { + const script = { + Id: 20, + Name: (options?.body as { Name: string }).Name, + }; + scripts.push(script); + return { data: script }; + } + return { data: {} }; + }, + PUT: async (path: string, options?: { body?: unknown }) => { + opts.calls.push({ method: "PUT", path, body: options?.body }); + return { data: {} }; + }, + DELETE: async (path: string) => { + opts.calls.push({ method: "DELETE", path }); + return { data: undefined }; + }, + } as unknown as ComputeClient; +} + +// ---- remote state round-trip ---- + +test("writeRemoteState/readRemoteState round-trip with a stable etag", async () => { + const connection = fakeConnection(); + const state = fakeState(); + + const etag = await writeRemoteState(connection, state); + const read = await readRemoteState(connection); + + expect(read?.state).toEqual(state); + expect(read?.etag).toBe(etag); +}); + +test("readRemoteState is null for missing or invalid state", async () => { + const connection = fakeConnection(); + expect(await readRemoteState(connection)).toBeNull(); + + store.set(REMOTE_STATE_PATH, "not json"); + expect(await readRemoteState(connection)).toBeNull(); +}); + +test("writeRemoteState overwrites (with a warning) on an etag mismatch", async () => { + const connection = fakeConnection(); + const etag = await writeRemoteState(connection, fakeState()); + + // Simulate a concurrent deploy changing the remote state. + store.set(REMOTE_STATE_PATH, JSON.stringify(fakeState({ current: "zzz" }))); + + await writeRemoteState(connection, fakeState({ current: "aaa" }), etag); + const read = await readRemoteState(connection); + expect(read?.state.current).toBe("aaa"); +}); + +test("readRemoteEnv tolerates missing and malformed files", async () => { + const connection = fakeConnection(); + expect(await readRemoteEnv(connection)).toEqual({}); + + store.set(REMOTE_ENV_PATH, "{broken"); + expect(await readRemoteEnv(connection)).toEqual({}); + + store.set(REMOTE_ENV_PATH, JSON.stringify({ A: "1", B: 2, C: "3" })); + // Non-string values are dropped, not crashed on. + expect(await readRemoteEnv(connection)).toEqual({ A: "1", C: "3" }); +}); + +// ---- provisioning ---- + +test("createSite provisions storage zone → router → pull zone → state", async () => { + const coreCalls: Call[] = []; + const computeCalls: Call[] = []; + const coreClient = fakeCoreClient({ calls: coreCalls }); + const computeClient = fakeComputeClient({ calls: computeCalls }); + + const result = await createSite({ + coreClient, + computeClient, + name: "my-site", + region: "DE", + }); + + expect(result.reused).toEqual({ + storageZone: false, + script: false, + pullZone: false, + }); + expect(result.systemHostname).toBe("my-site.b-cdn.net"); + + // The router script is uploaded, published, and gets CURRENT_DEPLOY="". + const computePaths = computeCalls.map((c) => `${c.method} ${c.path}`); + expect(computePaths).toContain("POST /compute/script"); + expect(computePaths).toContain("POST /compute/script/{id}/code"); + expect(computePaths).toContain("POST /compute/script/{id}/publish"); + const envSet = computeCalls.find( + (c) => c.path === "/compute/script/{id}/variables", + ); + expect(envSet?.body).toEqual({ Name: "CURRENT_DEPLOY", DefaultValue: "" }); + + // The pull zone is created from the storage zone and the router is attached. + const pzCreate = coreCalls.find( + (c) => c.method === "POST" && c.path === "/pullzone", + ); + expect(pzCreate?.body).toMatchObject({ Name: "my-site", StorageZoneId: 10 }); + const attach = coreCalls.find( + (c) => c.method === "POST" && c.path === "/pullzone/{id}", + ); + expect(attach?.body).toEqual({ MiddlewareScriptId: 20 }); + + // Remote state marks the zone as a site. + const written = await readRemoteState(fakeConnection()); + expect(written?.state).toMatchObject({ + name: "my-site", + storageZoneId: 10, + pullZoneId: 30, + scriptId: 20, + routerVersion: ROUTER_VERSION, + }); +}); + +test("createSite re-run reuses existing resources and converges", async () => { + const coreCalls: Call[] = []; + const computeCalls: Call[] = []; + // Everything already exists — but no remote state (a half-finished create). + const coreClient = fakeCoreClient({ + calls: coreCalls, + storageZones: [ZONE], + pullZones: [{ Id: 30, Name: "my-site", Hostnames: [] }], + }); + const computeClient = fakeComputeClient({ + calls: computeCalls, + scripts: [{ Id: 20, Name: "my-site-router" }], + }); + + const result = await createSite({ + coreClient, + computeClient, + name: "my-site", + region: "DE", + }); + + expect(result.reused).toEqual({ + storageZone: true, + script: true, + pullZone: true, + }); + // Nothing new was created… + expect( + coreCalls.filter((c) => c.method === "POST" && c.path === "/storagezone"), + ).toHaveLength(0); + expect( + computeCalls.filter( + (c) => c.method === "POST" && c.path === "/compute/script", + ), + ).toHaveLength(0); + // …but the router republish and attach still ran (idempotent convergence). + expect(computeCalls.map((c) => c.path)).toContain( + "/compute/script/{id}/code", + ); + expect(coreCalls.map((c) => `${c.method} ${c.path}`)).toContain( + "POST /pullzone/{id}", + ); + expect(await readRemoteState(fakeConnection())).not.toBeNull(); +}); + +test("createSite refuses to re-provision an existing site", async () => { + store.set(REMOTE_STATE_PATH, JSON.stringify(fakeState())); + const coreClient = fakeCoreClient({ calls: [], storageZones: [ZONE] }); + const computeClient = fakeComputeClient({ calls: [] }); + + await expect( + createSite({ coreClient, computeClient, name: "my-site", region: "DE" }), + ).rejects.toThrow('Site "my-site" already exists.'); +}); + +// ---- promote ---- + +test("promoteDeploy sets CURRENT_DEPLOY and purges the pull zone cache", async () => { + const coreCalls: Call[] = []; + const computeCalls: Call[] = []; + const coreClient = fakeCoreClient({ calls: coreCalls }); + const computeClient = fakeComputeClient({ calls: computeCalls }); + + await promoteDeploy({ + computeClient, + coreClient, + state: fakeState(), + deployId: "a1b2c3d4", + }); + + const envSet = computeCalls.find((c) => c.method === "PUT"); + expect(envSet?.body).toEqual({ + Name: "CURRENT_DEPLOY", + DefaultValue: "a1b2c3d4", + }); + const purge = coreCalls.find((c) => c.path === "/pullzone/{id}/purgeCache"); + expect(purge?.params).toEqual({ path: { id: 30 } }); +}); + +// ---- discovery ---- + +test("fetchSites keeps only middleware+storage pull zones with matching state", async () => { + store.set(REMOTE_STATE_PATH, JSON.stringify(fakeState())); + const coreClient = fakeCoreClient({ + calls: [], + storageZones: [ZONE], + pullZones: [ + // A real site. + { + Id: 30, + Name: "my-site", + MiddlewareScriptId: 20, + StorageZoneId: 10, + Hostnames: [{ IsSystemHostname: true, Value: "my-site.b-cdn.net" }], + }, + // Plain storage pull zone — no middleware, never fetched. + { Id: 31, Name: "not-a-site", StorageZoneId: 10 }, + // Middleware pull zone whose state points elsewhere. + { Id: 32, Name: "other", MiddlewareScriptId: 9, StorageZoneId: 10 }, + ], + }); + + const sites = await fetchSites(coreClient); + expect(sites).toHaveLength(1); + expect(sites[0]?.state.name).toBe("my-site"); + expect(sites[0]?.systemHostname).toBe("my-site.b-cdn.net"); +}); + +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 new file mode 100644 index 00000000..b9db4b04 --- /dev/null +++ b/packages/cli/src/commands/sites/api.ts @@ -0,0 +1,469 @@ +import type { createComputeClient } from "@bunny.net/openapi-client"; +import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; +import { UserError } from "../../core/errors.ts"; +import { createPullZone } from "../../core/hostnames/index.ts"; +import { logger } from "../../core/logger.ts"; +import { fetchScripts } from "../scripts/api.ts"; +import { SCRIPT_TYPE_MIDDLEWARE } from "../scripts/constants.ts"; +import { + type CoreClient, + fetchStorageZone, + type StorageZoneModel, +} from "../storage/api.ts"; +import { + connectStorageZone, + deleteFile, + downloadFile, + type StorageZone, + uploadFile, +} from "../storage/files-api.ts"; +import { + CURRENT_DEPLOY_VAR, + parseRemoteState, + REMOTE_ENV_PATH, + REMOTE_STATE_PATH, + type RemoteSiteState, + routerScriptName, + STATE_VERSION, +} from "./constants.ts"; +import { ROUTER_VERSION, routerSource } from "./router/source.ts"; + +export type ComputeClient = ReturnType; +type PullZone = components["schemas"]["PullZoneModel"]; + +/** + * The storage-file IO seam. Everything sites reads/writes in a storage zone + * goes through here so tests can swap the entries for an in-memory store + * (bun's `mock.module` leaks across test files; this doesn't). + */ +export const siteFiles = { + connect: connectStorageZone, + download: downloadFile, + upload: uploadFile, + remove: deleteFile, +}; + +/** Everything a sites command needs once the site is resolved. */ +export interface SiteContext { + state: RemoteSiteState; + /** Checksum of the state as read — the optimistic lock for writes. */ + etag: string; + storageZone: StorageZoneModel; + connection: StorageZone; +} + +export interface SiteSummary { + state: RemoteSiteState; + storageZone: StorageZoneModel; + systemHostname?: string; +} + +export function sha256Hex(text: string): string { + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(text); + return hasher.digest("hex"); +} + +async function downloadText( + connection: StorageZone, + path: string, +): Promise { + try { + const { stream } = await siteFiles.download(connection, path); + return await new Response(stream).text(); + } catch { + // Missing file and transient errors both read as "no data" — callers + // that need to distinguish should not be writing based on this alone. + return null; + } +} + +function textStream(text: string): ReadableStream { + return new Blob([text]).stream(); +} + +/** Read `_bunny/site.json`. Returns null when the zone isn't a site. */ +export async function readRemoteState( + connection: StorageZone, +): Promise<{ state: RemoteSiteState; etag: string } | null> { + const raw = await downloadText(connection, REMOTE_STATE_PATH); + if (raw === null) return null; + const state = parseRemoteState(raw); + if (!state) return null; + return { state, etag: sha256Hex(raw) }; +} + +/** + * Write `_bunny/site.json`. When `expectedEtag` is given, the current remote + * content is re-read first; a mismatch means someone else deployed since we + * read — v1 logs and overwrites (cheap optimistic lock, no hard failure). + * Returns the new etag. + */ +export async function writeRemoteState( + connection: StorageZone, + state: RemoteSiteState, + expectedEtag?: string, +): Promise { + if (expectedEtag) { + const current = await downloadText(connection, REMOTE_STATE_PATH); + if (current !== null && sha256Hex(current) !== expectedEtag) { + logger.warn( + "Remote site state changed since it was read (concurrent deploy?) — overwriting.", + ); + } + } + const raw = `${JSON.stringify(state, null, 2)}\n`; + await siteFiles.upload(connection, REMOTE_STATE_PATH, textStream(raw), { + sha256Checksum: sha256Hex(raw).toUpperCase(), + }); + return sha256Hex(raw); +} + +/** Read `_bunny/env.json` (build-time env). Missing file → empty env. */ +export async function readRemoteEnv( + connection: StorageZone, +): Promise> { + const raw = await downloadText(connection, REMOTE_ENV_PATH); + if (raw === null) return {}; + try { + const data = JSON.parse(raw); + if (!data || typeof data !== "object" || Array.isArray(data)) return {}; + const env: Record = {}; + for (const [key, value] of Object.entries(data)) { + if (typeof value === "string") env[key] = value; + } + return env; + } catch { + return {}; + } +} + +export async function writeRemoteEnv( + connection: StorageZone, + env: Record, +): Promise { + const sorted = Object.fromEntries( + Object.entries(env).sort(([a], [b]) => a.localeCompare(b)), + ); + const raw = `${JSON.stringify(sorted, null, 2)}\n`; + await siteFiles.upload(connection, REMOTE_ENV_PATH, textStream(raw), { + sha256Checksum: sha256Hex(raw).toUpperCase(), + }); +} + +/** Build a SiteContext from a full storage zone record, or null if it isn't a site. */ +export async function siteContextFromZone( + zone: StorageZoneModel, +): Promise { + const connection = siteFiles.connect(zone); + const remote = await readRemoteState(connection); + if (!remote) return null; + return { ...remote, storageZone: zone, connection }; +} + +async function mapWithConcurrency( + items: T[], + concurrency: number, + fn: (item: T) => Promise, +): Promise { + const results: R[] = new Array(items.length); + let next = 0; + const worker = async () => { + while (true) { + const index = next++; + if (index >= items.length) return; + results[index] = await fn(items[index] as T); + } + }; + await Promise.all( + Array.from({ length: Math.min(concurrency, items.length) }, worker), + ); + return results; +} + +/** + * Discover the account's sites. + * + * A site is a storage-backed pull zone with a middleware script whose storage + * zone carries a matching `_bunny/site.json`. One pull zone listing narrows + * the candidates; only those get the per-zone state read. + */ +export async function fetchSites(client: CoreClient): Promise { + const { data } = await client.GET("/pullzone"); + const candidates = (data ?? []).filter( + (pz: PullZone) => pz.MiddlewareScriptId != null && pz.StorageZoneId != null, + ); + + const summaries = await mapWithConcurrency( + candidates, + 8, + async (pz: PullZone): Promise => { + try { + const zone = await fetchStorageZone(client, pz.StorageZoneId as number); + const context = await siteContextFromZone(zone); + if (!context || context.state.pullZoneId !== pz.Id) return null; + return { + state: context.state, + storageZone: zone, + systemHostname: + (pz.Hostnames ?? []).find((h) => h.IsSystemHostname)?.Value ?? + undefined, + }; + } catch { + return null; + } + }, + ); + + return summaries + .filter((s): s is SiteSummary => s !== null) + .sort((a, b) => a.state.name.localeCompare(b.state.name)); +} + +async function findStorageZoneByName( + client: CoreClient, + name: string, +): Promise { + const { data } = await client.GET("/storagezone", { + params: { query: { search: name } }, + }); + const match = (data ?? []).find( + (zone) => (zone.Name ?? "").toLowerCase() === name.toLowerCase(), + ); + // Re-fetch by ID: search results may omit the zone password. + return match?.Id ? fetchStorageZone(client, match.Id) : undefined; +} + +async function findPullZoneByName( + client: CoreClient, + name: string, +): Promise { + const { data } = await client.GET("/pullzone", { + params: { query: { search: name } }, + }); + return (data ?? []).find( + (pz) => (pz.Name ?? "").toLowerCase() === name.toLowerCase(), + ); +} + +export interface CreateSiteOptions { + coreClient: CoreClient; + computeClient: ComputeClient; + name: string; + region: string; + /** Progress callback — drives the spinner text. */ + onStep?: (message: string) => void; +} + +export interface CreateSiteResult { + state: RemoteSiteState; + storageZone: StorageZoneModel; + systemHostname?: string; + reused: { storageZone: boolean; script: boolean; pullZone: boolean }; +} + +/** + * Provision a site: storage zone → router script (middleware) → storage-backed + * pull zone with the router attached → remote state. Every step looks up the + * resource by name before creating it, so a half-finished create re-runs + * cleanly. A storage zone that already carries site state is an existing site + * and is never re-provisioned. + */ +export async function createSite( + opts: CreateSiteOptions, +): Promise { + const { coreClient, computeClient, name, region } = opts; + const step = opts.onStep ?? (() => {}); + const reused = { storageZone: false, script: false, pullZone: false }; + + // 1. Storage zone — the site's identity. + step("Creating storage zone..."); + let storageZone = await findStorageZoneByName(coreClient, name); + if (storageZone) { + const existing = await siteContextFromZone(storageZone); + if (existing) { + throw new UserError( + `Site "${name}" already exists.`, + `Run \`bunny sites link ${name}\` to use it from this directory.`, + ); + } + reused.storageZone = true; + } else { + const { data } = await coreClient.POST("/storagezone", { + body: { Name: name, Region: region, ReplicationRegions: null }, + }); + if (!data?.Id) { + throw new UserError(`Failed to create storage zone "${name}".`); + } + // Re-fetch for the full record (including the zone password). + storageZone = await fetchStorageZone(coreClient, data.Id); + } + const storageZoneId = storageZone.Id; + if (storageZoneId == null) { + throw new UserError(`Storage zone "${name}" has no ID.`); + } + + // 2. Router script (middleware). Code upload + publish + env var are all + // idempotent PUTs/POSTs, so they always run — a resumed create converges. + step("Creating router script..."); + const scriptName = routerScriptName(name); + let scriptId = (await fetchScripts(computeClient)).find( + (s) => s.Name === scriptName, + )?.Id; + if (scriptId != null) { + reused.script = true; + } else { + const { data: script } = await computeClient.POST("/compute/script", { + body: { + Name: scriptName, + ScriptType: SCRIPT_TYPE_MIDDLEWARE, + CreateLinkedPullZone: false, + }, + }); + if (script?.Id == null) { + throw new UserError(`Failed to create router script "${scriptName}".`); + } + scriptId = script.Id; + } + + step("Publishing router..."); + await computeClient.POST("/compute/script/{id}/code", { + params: { path: { id: scriptId } }, + body: { Code: routerSource() }, + }); + await computeClient.POST("/compute/script/{id}/publish", { + params: { path: { id: scriptId, uuid: null } }, + body: {}, + }); + await computeClient.PUT("/compute/script/{id}/variables", { + params: { path: { id: scriptId } }, + body: { Name: CURRENT_DEPLOY_VAR, DefaultValue: "" }, + }); + + // 3. Pull zone with the storage origin, router attached. + step("Creating pull zone..."); + let pullZone = await findPullZoneByName(coreClient, name); + if (pullZone) { + reused.pullZone = true; + } else { + pullZone = await createPullZone(coreClient, name, storageZoneId); + } + if (pullZone.Id == null) { + throw new UserError(`Pull zone "${name}" has no ID.`); + } + await coreClient.POST("/pullzone/{id}", { + params: { path: { id: pullZone.Id } }, + body: { MiddlewareScriptId: scriptId }, + }); + + // 4. Remote state — from here on the zone identifies as a site. + step("Writing site state..."); + const state: RemoteSiteState = { + version: STATE_VERSION, + name, + storageZoneId, + pullZoneId: pullZone.Id, + scriptId, + routerVersion: ROUTER_VERSION, + deploys: [], + }; + const connection = siteFiles.connect(storageZone); + await writeRemoteState(connection, state); + + return { + state, + storageZone, + systemHostname: + (pullZone.Hostnames ?? []).find((h) => h.IsSystemHostname)?.Value ?? + undefined, + reused, + }; +} + +/** + * Point production at a deploy: update the router's `CURRENT_DEPLOY` env var + * (takes effect without republishing code) and purge the pull zone cache. + */ +export async function promoteDeploy(opts: { + computeClient: ComputeClient; + coreClient: CoreClient; + state: RemoteSiteState; + deployId: string; +}): Promise { + await opts.computeClient.PUT("/compute/script/{id}/variables", { + params: { path: { id: opts.state.scriptId } }, + body: { Name: CURRENT_DEPLOY_VAR, DefaultValue: opts.deployId }, + }); + await opts.coreClient.POST("/pullzone/{id}/purgeCache", { + params: { path: { id: opts.state.pullZoneId } }, + body: {}, + }); +} + +export interface TeardownResult { + resource: "pull zone" | "router script" | "storage zone"; + id: number; + deleted: boolean; + error?: string; +} + +/** + * Tear down a site's resources. Order matters: the pull zone references both + * the script and the storage zone, so it goes first. Each step is best-effort + * so a partially-deleted site can be re-deleted. + */ +export async function deleteSiteResources(opts: { + coreClient: CoreClient; + computeClient: ComputeClient; + state: RemoteSiteState; + keepStorage?: boolean; +}): Promise { + const { coreClient, computeClient, state } = opts; + const results: TeardownResult[] = []; + + const attempt = async ( + resource: TeardownResult["resource"], + id: number, + fn: () => Promise, + ) => { + try { + await fn(); + results.push({ resource, id, deleted: true }); + } catch (err) { + results.push({ + resource, + id, + deleted: false, + error: err instanceof Error ? err.message : String(err), + }); + } + }; + + await attempt("pull zone", state.pullZoneId, () => + coreClient.DELETE("/pullzone/{id}", { + params: { path: { id: state.pullZoneId } }, + }), + ); + await attempt("router script", state.scriptId, () => + computeClient.DELETE("/compute/script/{id}", { + params: { path: { id: state.scriptId } }, + }), + ); + if (!opts.keepStorage) { + await attempt("storage zone", state.storageZoneId, () => + coreClient.DELETE("/storagezone/{id}", { + params: { path: { id: state.storageZoneId } }, + }), + ); + } + + return results; +} + +/** Delete a deploy's files (`deploys/{id}/`) from the storage zone. */ +export async function deleteDeployFiles( + connection: StorageZone, + deployId: string, +): Promise { + await siteFiles.remove(connection, `deploys/${deployId}/`); +} diff --git a/packages/cli/src/commands/sites/build.test.ts b/packages/cli/src/commands/sites/build.test.ts new file mode 100644 index 00000000..fa12b33a --- /dev/null +++ b/packages/cli/src/commands/sites/build.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + envHash, + parseEnvAssignments, + parseEnvFile, + runBuildCommand, +} from "./build.ts"; + +test("parseEnvAssignments parses KEY=VALUE pairs", () => { + expect(parseEnvAssignments(["A=1", "B=with=equals", "C_1="])).toEqual({ + A: "1", + B: "with=equals", + C_1: "", + }); + expect(parseEnvAssignments(undefined)).toEqual({}); +}); + +test("parseEnvAssignments rejects malformed entries", () => { + expect(() => parseEnvAssignments(["NOEQUALS"])).toThrow("Invalid --env"); + expect(() => parseEnvAssignments(["=value"])).toThrow("Invalid --env"); + expect(() => parseEnvAssignments(["1BAD=x"])).toThrow("not a valid"); +}); + +test("parseEnvFile handles comments, blanks, and quotes", () => { + const env = parseEnvFile( + [ + "# comment", + "", + "PLAIN=value", + 'QUOTED="hello world"', + "SINGLE='single'", + " SPACED = spaced-out ", + "not a var line", + ].join("\n"), + ); + expect(env).toEqual({ + PLAIN: "value", + QUOTED: "hello world", + SINGLE: "single", + SPACED: "spaced-out", + }); +}); + +test("envHash is stable across ordering and sensitive to values", () => { + const a = envHash({ A: "1", B: "2" }); + const b = envHash({ B: "2", A: "1" }); + expect(a).toBe(b); + expect(a).toMatch(/^[0-9a-f]{8}$/); + expect(envHash({ A: "1", B: "3" })).not.toBe(a); +}); + +test("runBuildCommand passes env and throws on failure", async () => { + const dir = mkdtempSync(join(tmpdir(), "bunny-sites-build-")); + const out = join(dir, "out.txt"); + + await runBuildCommand(`echo -n "$MY_VAR" > ${JSON.stringify(out)}`, dir, { + MY_VAR: "built", + }); + expect(await Bun.file(out).text()).toBe("built"); + + await expect(runBuildCommand("exit 3", dir, {})).rejects.toThrow( + "exit code 3", + ); +}); diff --git a/packages/cli/src/commands/sites/build.ts b/packages/cli/src/commands/sites/build.ts new file mode 100644 index 00000000..425ad027 --- /dev/null +++ b/packages/cli/src/commands/sites/build.ts @@ -0,0 +1,92 @@ +import { UserError } from "../../core/errors.ts"; +import { logger } from "../../core/logger.ts"; +import { sha256Hex } from "./api.ts"; + +const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; + +function assertEnvName(name: string): void { + if (!ENV_NAME_RE.test(name)) { + throw new UserError( + `"${name}" is not a valid environment variable name.`, + "Names must start with a letter or underscore and contain only letters, digits, and underscores.", + ); + } +} + +/** Parse repeated `--env KEY=VALUE` flags. */ +export function parseEnvAssignments( + entries: string[] | undefined, +): Record { + const env: Record = {}; + for (const entry of entries ?? []) { + const eq = entry.indexOf("="); + if (eq <= 0) { + throw new UserError( + `Invalid --env value "${entry}".`, + "Use --env KEY=VALUE.", + ); + } + const name = entry.slice(0, eq); + assertEnvName(name); + env[name] = entry.slice(eq + 1); + } + return env; +} + +/** Parse a dotenv-style file: KEY=VALUE lines, `#` comments, optional quotes. */ +export function parseEnvFile(content: string): Record { + const env: Record = {}; + for (const rawLine of content.split("\n")) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq <= 0) continue; + const name = line.slice(0, eq).trim(); + if (!ENV_NAME_RE.test(name)) continue; + let value = line.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + env[name] = value; + } + return env; +} + +/** Stable 8-hex-char fingerprint of a build env, recorded on the deploy. */ +export function envHash(env: Record): string { + const sorted = Object.entries(env).sort(([a], [b]) => a.localeCompare(b)); + return sha256Hex(JSON.stringify(sorted)).slice(0, 8); +} + +/** + * Run the build command in a shell with the merged environment, streaming + * its output. Throws on a non-zero exit so a broken build never deploys. + */ +export async function runBuildCommand( + command: string, + cwd: string, + env: Record, +): Promise { + logger.info(`Running build: ${command}`); + const shell = + process.platform === "win32" + ? ["cmd", "/c", command] + : ["sh", "-c", command]; + const proc = Bun.spawn(shell, { + cwd, + env: { ...process.env, ...env }, + stdin: "ignore", + stdout: "inherit", + stderr: "inherit", + }); + const code = await proc.exited; + if (code !== 0) { + throw new UserError( + `Build command failed with exit code ${code}.`, + "Fix the build and re-run `bunny sites deploy --build`.", + ); + } +} diff --git a/packages/cli/src/commands/sites/config.ts b/packages/cli/src/commands/sites/config.ts new file mode 100644 index 00000000..8ed269c3 --- /dev/null +++ b/packages/cli/src/commands/sites/config.ts @@ -0,0 +1,47 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { type SiteConfig, SiteConfigSchema } from "@bunny.net/app-config"; +import { parse as parseJsonc } from "jsonc-parser"; +import { UserError } from "../../core/errors.ts"; + +const CONFIG_FILENAME = "bunny.jsonc"; + +export interface LoadedSiteConfig { + config: SiteConfig; + /** Directory containing bunny.jsonc — `sites.dir` resolves against this. */ + root: string; +} + +/** + * Read the optional `sites` block from `bunny.jsonc`, walking up from cwd. + * + * Only the `sites` key is validated — a bunny.jsonc that also (or only) + * describes an app is fine, and a file without a `sites` block returns null. + * This deliberately doesn't run the full app schema, so a sites-only + * `{ "sites": { ... } }` file works without declaring an app. + */ +export function loadSiteConfig(): LoadedSiteConfig | null { + let dir = resolve(process.cwd()); + while (true) { + const path = join(dir, CONFIG_FILENAME); + if (existsSync(path)) { + const raw = parseJsonc(readFileSync(path, "utf-8")); + const sites = (raw as Record | null)?.sites; + if (sites === undefined || sites === null) return null; + + const parsed = SiteConfigSchema.safeParse(sites); + if (!parsed.success) { + throw new UserError( + `Invalid \`sites\` block in ${path}.`, + parsed.error.issues + .map((i) => `${i.path.join(".") || "sites"}: ${i.message}`) + .join("; "), + ); + } + return { config: parsed.data, root: dir }; + } + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} diff --git a/packages/cli/src/commands/sites/constants.test.ts b/packages/cli/src/commands/sites/constants.test.ts new file mode 100644 index 00000000..6459c339 --- /dev/null +++ b/packages/cli/src/commands/sites/constants.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from "bun:test"; +import { + deployPrefix, + isValidDeployId, + isValidSiteName, + parseRemoteState, + previewHostname, + previewWildcard, + type RemoteSiteState, +} from "./constants.ts"; + +const validState: RemoteSiteState = { + version: 1, + name: "my-site", + storageZoneId: 1, + pullZoneId: 2, + scriptId: 3, + routerVersion: 1, + deploys: [], +}; + +test("parseRemoteState round-trips a valid state", () => { + expect(parseRemoteState(JSON.stringify(validState))).toEqual(validState); +}); + +test("parseRemoteState defaults routerVersion for legacy state", () => { + const { routerVersion: _rv, ...legacy } = validState; + expect(parseRemoteState(JSON.stringify(legacy))?.routerVersion).toBe(0); +}); + +test("parseRemoteState rejects garbage", () => { + expect(parseRemoteState("not json")).toBeNull(); + expect(parseRemoteState("null")).toBeNull(); + expect(parseRemoteState('"a string"')).toBeNull(); + expect(parseRemoteState("{}")).toBeNull(); + // Missing a required resource ID + expect( + parseRemoteState(JSON.stringify({ ...validState, pullZoneId: undefined })), + ).toBeNull(); + // Deploys must be an array + expect( + parseRemoteState(JSON.stringify({ ...validState, deploys: {} })), + ).toBeNull(); +}); + +test("deploy path and preview host helpers", () => { + expect(deployPrefix("a1b2c3d4")).toBe("deploys/a1b2c3d4"); + expect(previewHostname("a1b2c3d4", "example.com")).toBe( + "dpl-a1b2c3d4.preview.example.com", + ); + expect(previewWildcard("example.com")).toBe("*.preview.example.com"); +}); + +test("isValidDeployId accepts git shas and content hashes", () => { + expect(isValidDeployId("a1b2c3d4")).toBe(true); + expect(isValidDeployId("0f9e8d7c6b5a4321")).toBe(true); + expect(isValidDeployId("ab")).toBe(false); // too short + expect(isValidDeployId("HAS-CAPS")).toBe(false); + expect(isValidDeployId("has/slash")).toBe(false); + expect(isValidDeployId("")).toBe(false); +}); + +test("isValidSiteName enforces zone-name rules", () => { + expect(isValidSiteName("my-site")).toBe(true); + expect(isValidSiteName("site123")).toBe(true); + expect(isValidSiteName("My-Site")).toBe(false); + expect(isValidSiteName("-leading")).toBe(false); + expect(isValidSiteName("trailing-")).toBe(false); + expect(isValidSiteName("ab")).toBe(false); // too short +}); diff --git a/packages/cli/src/commands/sites/constants.ts b/packages/cli/src/commands/sites/constants.ts new file mode 100644 index 00000000..4e9f3102 --- /dev/null +++ b/packages/cli/src/commands/sites/constants.ts @@ -0,0 +1,124 @@ +// `.bunny/site.json` is written by `bunny sites link`/`create` and resolved by sites commands. +export const SITES_MANIFEST = "site.json"; + +// Remote paths inside the site's storage zone. Everything under `_bunny/` is +// blocked by the router (403), so state and env never get served. +export const REMOTE_STATE_PATH = "_bunny/site.json"; +export const REMOTE_ENV_PATH = "_bunny/env.json"; + +// Deploys live at `deploys/{id}/...` inside the storage zone. +export const DEPLOYS_DIR = "deploys"; + +// Preview hosts are `dpl-{id}.preview.{domain}` — a namespaced wildcard that +// can't shadow user subdomains. +export const PREVIEW_LABEL = "preview"; + +// The router env var that selects the production deploy. Updating it is the +// promote/rollback lever — no code republish needed. +export const CURRENT_DEPLOY_VAR = "CURRENT_DEPLOY"; + +export const STATE_VERSION = 1; + +export const DEFAULT_KEEP_DEPLOYS = 5; + +export interface SiteManifest { + /** The site's storage zone ID — the site's identity. */ + id: number; + name?: string; +} + +export interface DeployRecord { + id: string; + createdAt: string; + source: "git" | "content"; + gitSha?: string; + dirty?: boolean; + files: number; + bytes: number; + envHash?: string; +} + +/** + * The remote site state stored at `_bunny/site.json` in the storage zone. + * It is the source of truth for what a site is (its resource triple) and + * what has been deployed; the local `.bunny/site.json` manifest is only a + * pointer to the storage zone. + */ +export interface RemoteSiteState { + version: number; + name: string; + storageZoneId: number; + pullZoneId: number; + scriptId: number; + routerVersion: number; + /** Primary custom domain (apex), when one has been attached. */ + domain?: string; + current?: string; + previous?: string; + deploys: DeployRecord[]; +} + +/** Storage-zone path prefix for a deploy, without a trailing slash. */ +export function deployPrefix(deployId: string): string { + return `${DEPLOYS_DIR}/${deployId}`; +} + +/** The preview hostname for a deploy on a custom domain. */ +export function previewHostname(deployId: string, domain: string): string { + return `dpl-${deployId}.${PREVIEW_LABEL}.${domain}`; +} + +/** The wildcard hostname that serves every deploy preview on a domain. */ +export function previewWildcard(domain: string): string { + return `*.${PREVIEW_LABEL}.${domain}`; +} + +/** 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`; +} + +// Deploy IDs are git short-shas or content hashes: lowercase hex-ish tokens. +// The router's preview-host regex and the storage path layout both rely on this. +const DEPLOY_ID_RE = /^[a-z0-9]{4,40}$/; + +export function isValidDeployId(id: string): boolean { + return DEPLOY_ID_RE.test(id); +} + +// Site names become storage zone / pull zone names (and the b-cdn.net subdomain). +const SITE_NAME_RE = /^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/; + +export function isValidSiteName(name: string): boolean { + return SITE_NAME_RE.test(name); +} + +/** + * Parse and shape-check remote state. Returns null for anything that isn't a + * state file this CLI understands — a missing field means "not a site", not + * a crash. + */ +export function parseRemoteState(raw: string): RemoteSiteState | null { + let data: unknown; + try { + data = JSON.parse(raw); + } catch { + return null; + } + if (!data || typeof data !== "object") return null; + const s = data as Record; + if ( + typeof s.version !== "number" || + typeof s.name !== "string" || + typeof s.storageZoneId !== "number" || + typeof s.pullZoneId !== "number" || + typeof s.scriptId !== "number" || + !Array.isArray(s.deploys) + ) { + return null; + } + const state = s as unknown as RemoteSiteState; + // Older state files may predate router versioning. + if (typeof state.routerVersion !== "number") state.routerVersion = 0; + return state; +} diff --git a/packages/cli/src/commands/sites/create.ts b/packages/cli/src/commands/sites/create.ts new file mode 100644 index 00000000..5ca51524 --- /dev/null +++ b/packages/cli/src/commands/sites/create.ts @@ -0,0 +1,177 @@ +import { + createComputeClient, + createCoreClient, +} from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../config/index.ts"; +import { clientOptions } from "../../core/client-options.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { UserError } from "../../core/errors.ts"; +import { formatKeyValue } from "../../core/format.ts"; +import { normalizeHostname } from "../../core/hostnames/index.ts"; +import { logger } from "../../core/logger.ts"; +import { saveManifest } from "../../core/manifest.ts"; +import { isInteractive, spinner } from "../../core/ui.ts"; +import { createSite, siteContextFromZone } from "./api.ts"; +import { + isValidSiteName, + SITES_MANIFEST, + type SiteManifest, +} from "./constants.ts"; +import { setupSiteDomain } from "./domains/index.ts"; + +interface CreateArgs { + name: string; + region?: string; + domain?: string; + link?: boolean; +} + +/** + * Create a new static site: a storage zone (files), a pull zone (CDN), and a + * middleware router script that maps hosts to deploy directories. The site's + * state lives at `_bunny/site.json` inside the storage zone. + */ +export const sitesCreateCommand = defineCommand({ + command: "create ", + describe: "Create a new static site.", + examples: [ + ["$0 sites create my-site", "Create a site served at my-site.b-cdn.net"], + [ + "$0 sites create my-site --domain example.com", + "Create and attach a custom domain", + ], + ["$0 sites create my-site --region NY", "Store files in New York"], + ], + + builder: (yargs) => + yargs + .positional("name", { + type: "string", + describe: + "Site name — becomes the storage zone, pull zone, and .b-cdn.net subdomain", + demandOption: true, + }) + .option("region", { + type: "string", + default: "DE", + describe: "Main storage region code (e.g. DE, NY, LA, SG)", + }) + .option("domain", { + type: "string", + describe: "Custom domain to attach (with *.preview. previews)", + }) + .option("link", { + type: "boolean", + describe: + "Link this directory to the new site (default: true). Use --no-link to skip.", + }), + + handler: async (args) => { + const { profile, output, verbose, apiKey } = args; + const name = args.name.toLowerCase(); + if (!isValidSiteName(name)) { + throw new UserError( + `"${args.name}" is not a valid site name.`, + "Use 3–60 lowercase letters, digits, and dashes (no leading/trailing dash).", + ); + } + + const domain = args.domain ? normalizeHostname(args.domain) : undefined; + const interactive = isInteractive(output); + + const config = resolveConfig(profile, apiKey, verbose); + const options = clientOptions(config, verbose); + const coreClient = createCoreClient(options); + const computeClient = createComputeClient(options); + + const spin = spinner(`Creating site "${name}"...`); + spin.start(); + let result: Awaited>; + try { + result = await createSite({ + coreClient, + computeClient, + name, + region: (args.region ?? "DE").toUpperCase(), + onStep: (message) => { + spin.text = message; + }, + }); + } finally { + spin.stop(); + } + + if (args.link !== false) { + saveManifest(SITES_MANIFEST, { + id: result.state.storageZoneId, + name, + }); + } + + // Attach the custom domain last — a domain failure mustn't fail the + // create; the site already exists and can be retried via `sites domains add`. + let domainError: string | undefined; + if (domain) { + const site = await siteContextFromZone(result.storageZone); + if (site) { + try { + await setupSiteDomain({ + coreClient, + site, + domain, + interactive, + verbose, + json: output === "json", + }); + } catch (err) { + domainError = err instanceof Error ? err.message : String(err); + } + } + } + + if (output === "json") { + logger.log( + JSON.stringify( + { + name, + storageZoneId: result.state.storageZoneId, + pullZoneId: result.state.pullZoneId, + scriptId: result.state.scriptId, + hostname: result.systemHostname ?? null, + domain: domain ?? null, + linked: args.link !== false, + ...(domainError ? { domainError } : {}), + }, + null, + 2, + ), + ); + if (domainError) process.exit(1); + return; + } + + logger.success(`Created site "${name}".`); + logger.log(); + logger.log( + formatKeyValue( + [ + { key: "Site", value: name }, + { key: "Storage zone", value: String(result.state.storageZoneId) }, + { key: "Pull zone", value: String(result.state.pullZoneId) }, + { key: "Router script", value: String(result.state.scriptId) }, + ...(result.systemHostname + ? [{ key: "URL", value: `https://${result.systemHostname}` }] + : []), + ], + output, + ), + ); + if (domainError) { + logger.log(); + logger.warn(`Couldn't finish setting up ${domain}: ${domainError}`); + logger.dim(` Retry later: bunny sites domains add ${domain} ${name}`); + } + logger.log(); + logger.dim(" Deploy your site: bunny sites deploy "); + }, +}); diff --git a/packages/cli/src/commands/sites/delete.ts b/packages/cli/src/commands/sites/delete.ts new file mode 100644 index 00000000..f984a44d --- /dev/null +++ b/packages/cli/src/commands/sites/delete.ts @@ -0,0 +1,147 @@ +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 { logger } from "../../core/logger.ts"; +import { loadManifest, removeManifest } from "../../core/manifest.ts"; +import { confirm, spinner } from "../../core/ui.ts"; +import { deleteSiteResources } from "./api.ts"; +import { SITES_MANIFEST, type SiteManifest } from "./constants.ts"; +import { + type SiteSelectorArgs, + selectSite, + sitePositionalBuilder, +} from "./interactive.ts"; + +interface DeleteArgs extends SiteSelectorArgs { + force?: boolean; + "keep-storage"?: boolean; +} + +/** + * Delete a site: its pull zone, router script, and (unless --keep-storage) + * the storage zone with every deploy in it. Requires typing the site name + * to confirm unless --force is passed. + */ +export const sitesDeleteCommand = defineCommand({ + command: "delete [site]", + describe: "Delete a site and its resources.", + examples: [ + ["$0 sites delete my-site", "Interactive — double confirmation"], + ["$0 sites delete my-site --force", "Skip confirmation"], + [ + "$0 sites delete my-site --keep-storage", + "Keep the storage zone (and all deploy files)", + ], + ], + + builder: (yargs) => + sitePositionalBuilder(yargs) + .option("force", { + alias: "f", + type: "boolean", + default: false, + describe: "Skip confirmation prompts", + }) + .option("keep-storage", { + type: "boolean", + default: false, + describe: "Delete the pull zone and router but keep the storage zone", + }), + + handler: async (args) => { + const { profile, output, verbose, apiKey, force } = args; + const config = resolveConfig(profile, apiKey, verbose); + const options = clientOptions(config, verbose); + const coreClient = createCoreClient(options); + const computeClient = createComputeClient(options); + + const { site } = await selectSite(coreClient, { + site: args.site, + link: false, + output, + }); + const { state } = site; + + const what = args["keep-storage"] + ? "its pull zone and router" + : "its pull zone, router, and ALL deploy files"; + const confirmed = await confirm( + `Delete site "${state.name}" (${what})? This cannot be undone.`, + { force }, + ); + if (!confirmed) { + logger.log("Cancelled."); + return; + } + + if (!force) { + const { value } = await prompts({ + type: "text", + name: "value", + message: `Type "${state.name}" to confirm:`, + }); + if (value !== state.name) { + logger.log("Cancelled."); + return; + } + } + + const spin = spinner("Deleting site resources..."); + spin.start(); + let results: Awaited>; + try { + results = await deleteSiteResources({ + coreClient, + computeClient, + state, + keepStorage: args["keep-storage"], + }); + } finally { + spin.stop(); + } + + // Only drop the local link when it pointed at this site. + const manifest = loadManifest(SITES_MANIFEST); + if (manifest.id === state.storageZoneId) { + removeManifest(SITES_MANIFEST); + } + + const failures = results.filter((r) => !r.deleted); + + if (output === "json") { + logger.log( + JSON.stringify( + { name: state.name, deleted: failures.length === 0, results }, + null, + 2, + ), + ); + if (failures.length > 0) process.exit(1); + return; + } + + for (const result of results) { + if (result.deleted) { + logger.success(`Deleted ${result.resource} ${result.id}.`); + } else { + logger.warn( + `Couldn't delete ${result.resource} ${result.id}: ${result.error}`, + ); + } + } + if (args["keep-storage"]) { + logger.info( + `Storage zone ${state.storageZoneId} was kept, including all deploy files.`, + ); + } + if (failures.length > 0) { + logger.dim(" Re-run the command to retry the failed deletions."); + process.exit(1); + } + }, +}); diff --git a/packages/cli/src/commands/sites/deploy-id.test.ts b/packages/cli/src/commands/sites/deploy-id.test.ts new file mode 100644 index 00000000..a2f578ce --- /dev/null +++ b/packages/cli/src/commands/sites/deploy-id.test.ts @@ -0,0 +1,82 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + contentHashId, + gitIdentity, + resolveDeployIdentity, +} from "./deploy-id.ts"; + +const FILES = [ + { path: "index.html", sha256: "AA11" }, + { path: "assets/app.js", sha256: "bb22" }, +]; + +test("contentHashId is order-independent and case-normalizes hashes", () => { + const a = contentHashId(FILES); + const b = contentHashId([...FILES].reverse()); + const c = contentHashId( + FILES.map((f) => ({ ...f, sha256: f.sha256.toLowerCase() })), + ); + expect(a).toBe(b); + expect(a).toBe(c); + expect(a).toMatch(/^[0-9a-f]{8}$/); +}); + +test("contentHashId changes when content or paths change", () => { + const base = contentHashId(FILES); + expect(contentHashId([{ path: "index.html", sha256: "AA12" }])).not.toBe( + base, + ); + expect( + contentHashId(FILES.map((f) => ({ ...f, path: `v2/${f.path}` }))), + ).not.toBe(base); +}); + +async function run(cwd: string, args: string[]): Promise { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "ignore", + stderr: "ignore", + }); + await proc.exited; +} + +test("gitIdentity is null outside a repo; resolveDeployIdentity falls back to content", async () => { + const dir = mkdtempSync(join(tmpdir(), "bunny-sites-")); + expect(await gitIdentity(dir)).toBeNull(); + + const identity = await resolveDeployIdentity(dir, FILES); + expect(identity.source).toBe("content"); + expect(identity.id).toBe(contentHashId(FILES)); +}); + +test("clean git repo uses the short sha; dirty tree falls back to content", async () => { + const dir = mkdtempSync(join(tmpdir(), "bunny-sites-git-")); + await run(dir, ["init", "-q"]); + await Bun.write(join(dir, "index.html"), "

hi

"); + await run(dir, ["add", "."]); + await run(dir, [ + "-c", + "user.email=test@example.com", + "-c", + "user.name=test", + "commit", + "-q", + "-m", + "init", + ]); + + const clean = await resolveDeployIdentity(dir, FILES); + expect(clean.source).toBe("git"); + expect(clean.id).toMatch(/^[0-9a-f]{8,}$/); + expect(clean.gitSha).toBe(clean.id); + + await Bun.write(join(dir, "new.txt"), "dirty"); + const dirty = await resolveDeployIdentity(dir, FILES); + expect(dirty.source).toBe("content"); + expect(dirty.dirty).toBe(true); + expect(dirty.id).toBe(contentHashId(FILES)); + expect(dirty.gitSha).toBe(clean.id); +}); diff --git a/packages/cli/src/commands/sites/deploy-id.ts b/packages/cli/src/commands/sites/deploy-id.ts new file mode 100644 index 00000000..3c059572 --- /dev/null +++ b/packages/cli/src/commands/sites/deploy-id.ts @@ -0,0 +1,81 @@ +export interface HashedFile { + /** Posix-style path relative to the deploy root. */ + path: string; + /** Lowercase or uppercase hex SHA-256 of the file contents. */ + sha256: string; +} + +export interface DeployIdentity { + id: string; + source: "git" | "content"; + gitSha?: string; + dirty?: boolean; +} + +/** + * Deterministic content hash for a set of files: a merkle-style digest over + * sorted `path + sha256` pairs, truncated to 8 hex chars. Identical content + * always yields the same ID regardless of file order. + */ +export function contentHashId(files: HashedFile[]): string { + const hasher = new Bun.CryptoHasher("sha256"); + const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path)); + for (const file of sorted) { + hasher.update(`${file.path}\0${file.sha256.toLowerCase()}\n`); + } + return hasher.digest("hex").slice(0, 8); +} + +async function git(cwd: string, args: string[]): Promise { + try { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "ignore", + stdin: "ignore", + }); + const [code, out] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + ]); + return code === 0 ? out : null; + } catch { + // git not installed + return null; + } +} + +/** The short HEAD sha and dirty-tree flag, or null when `cwd` isn't a git repo. */ +export async function gitIdentity( + cwd: string, +): Promise<{ sha: string; dirty: boolean } | null> { + const sha = await git(cwd, ["rev-parse", "--short=8", "HEAD"]); + if (!sha) return null; + const status = await git(cwd, ["status", "--porcelain"]); + return { + sha: sha.trim().toLowerCase(), + // A failed status check counts as dirty — better a content hash than a wrong sha. + dirty: status === null || status.trim().length > 0, + }; +} + +/** + * Resolve the deploy ID: the git short-sha when the tree is clean, the + * content hash otherwise (or outside a repo). Identical-hash deploys are + * no-ops, so the ID must be a pure function of what actually ships. + */ +export async function resolveDeployIdentity( + cwd: string, + files: HashedFile[], +): Promise { + const gitInfo = await gitIdentity(cwd); + if (gitInfo && !gitInfo.dirty) { + return { id: gitInfo.sha, source: "git", gitSha: gitInfo.sha }; + } + return { + id: contentHashId(files), + source: "content", + gitSha: gitInfo?.sha, + dirty: gitInfo?.dirty, + }; +} diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts new file mode 100644 index 00000000..c41c2bee --- /dev/null +++ b/packages/cli/src/commands/sites/deploy.ts @@ -0,0 +1,307 @@ +import { existsSync, statSync } from "node:fs"; +import { resolve } from "node:path"; +import { + createComputeClient, + createCoreClient, +} from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../config/index.ts"; +import { clientOptions } from "../../core/client-options.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { UserError } from "../../core/errors.ts"; +import { formatBytes } from "../../core/format.ts"; +import { logger } from "../../core/logger.ts"; +import { spinner } from "../../core/ui.ts"; +import { + promoteDeploy, + readRemoteEnv, + type SiteContext, + writeRemoteState, +} from "./api.ts"; +import { + envHash, + parseEnvAssignments, + parseEnvFile, + runBuildCommand, +} from "./build.ts"; +import { loadSiteConfig } from "./config.ts"; +import { + type DeployRecord, + deployPrefix, + previewHostname, +} from "./constants.ts"; +import { resolveDeployIdentity } from "./deploy-id.ts"; +import { + type SiteSelectorArgs, + selectSite, + siteOptionBuilder, +} from "./interactive.ts"; +import { collectFiles, hashFiles, uploadDeploy } from "./uploader.ts"; + +interface DeployArgs extends SiteSelectorArgs { + dir?: string; + build?: string; + env?: string[]; + "env-file"?: string; + promote?: boolean; + force?: boolean; +} + +/** Production and preview URLs for a deploy, derived from the site's hosts. */ +function deployUrls( + site: SiteContext, + deployId: string, + systemHost: string | undefined, +): { production?: string; preview?: string } { + const domain = site.state.domain; + const productionHost = domain ?? systemHost; + return { + production: productionHost ? `https://${productionHost}` : undefined, + preview: domain + ? `https://${previewHostname(deployId, domain)}` + : productionHost + ? `https://${productionHost}/${deployPrefix(deployId)}/` + : undefined, + }; +} + +/** + * Deploy a directory: hash → skip if unchanged → upload to `deploys/{id}/` → + * record in remote state → promote (env var + cache purge) unless + * `--no-promote`. With `--build`, runs the build command first with the + * site's remote env merged 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 and promote to production"], + ["$0 sites deploy ./dist --no-promote", "Upload without promoting"], + ["$0 sites deploy --build", "Run the configured build, then deploy"], + [ + '$0 sites deploy ./dist --build "npm run build"', + "Explicit build command", + ], + ["$0 sites deploy ./dist --site my-site", "Target a specific site"], + ], + + builder: (yargs) => + siteOptionBuilder( + yargs.positional("dir", { + type: "string", + describe: + "Directory to deploy (defaults to `sites.dir` in bunny.jsonc, then the current directory)", + }), + ) + .option("build", { + type: "string", + describe: + "Run a build first. Pass a command, or use the bare flag to run `sites.build` from bunny.jsonc", + }) + .option("env", { + type: "string", + array: true, + describe: "Build-time env override (KEY=VALUE, repeatable)", + }) + .option("env-file", { + type: "string", + describe: "Read build-time env overrides from a dotenv-style file", + }) + .option("promote", { + type: "boolean", + default: true, + describe: + "Promote the deploy to production (default: true). Use --no-promote to only upload.", + }) + .option("force", { + type: "boolean", + default: false, + describe: "Deploy even when the content is unchanged", + }), + + handler: async (args) => { + const { profile, output, verbose, apiKey } = args; + const siteConfig = loadSiteConfig(); + + const dir = resolve(args.dir ?? siteConfig?.config.dir ?? "."); + if (!existsSync(dir) || !statSync(dir).isDirectory()) { + throw new UserError(`Directory not found: ${dir}`); + } + if (args.build === undefined && (args.env?.length || args["env-file"])) { + throw new UserError( + "--env/--env-file only apply to builds.", + "Add --build to run the build with these variables.", + ); + } + + const config = resolveConfig(profile, apiKey, verbose); + const options = clientOptions(config, verbose); + const coreClient = createCoreClient(options); + const computeClient = createComputeClient(options); + + const { site, offerLink } = await selectSite(coreClient, { + site: args.site, + link: args.link, + output, + }); + const { state, connection, etag } = site; + + // Build first — the deploy ID must hash the build *output*. + let buildEnvHash: string | undefined; + if (args.build !== undefined) { + const command = args.build || siteConfig?.config.build; + if (!command) { + throw new UserError( + "No build command configured.", + 'Pass one (`--build "npm run build"`) or set `sites.build` in bunny.jsonc.', + ); + } + const overrides = { + ...(args["env-file"] + ? parseEnvFile(await Bun.file(resolve(args["env-file"])).text()) + : {}), + ...parseEnvAssignments(args.env), + }; + const remoteEnv = await readRemoteEnv(connection); + const mergedEnv = { ...remoteEnv, ...overrides }; + buildEnvHash = envHash(mergedEnv); + await runBuildCommand( + command, + siteConfig?.root ?? process.cwd(), + mergedEnv, + ); + } + + const hashSpin = spinner("Hashing files..."); + hashSpin.start(); + let files: Awaited>; + try { + files = await hashFiles(collectFiles(dir)); + } finally { + hashSpin.stop(); + } + if (files.length === 0) { + throw new UserError( + `Nothing to deploy — ${dir} has no files.`, + "Dotfiles and node_modules are excluded.", + ); + } + const totalBytes = files.reduce((sum, f) => sum + f.size, 0); + + const identity = await resolveDeployIdentity(dir, files); + + if (state.current === identity.id && !args.force) { + if (output === "json") { + logger.log( + JSON.stringify( + { site: state.name, id: identity.id, unchanged: true }, + null, + 2, + ), + ); + return; + } + logger.info( + `No changes — deploy ${identity.id} is already live. Use --force to redeploy.`, + ); + return; + } + + const uploadSpin = spinner(`Uploading ${files.length} files...`); + uploadSpin.start(); + try { + await uploadDeploy(connection, identity.id, files, { + onFileUploaded: (done, total) => { + uploadSpin.text = `Uploading ${done}/${total} files (${formatBytes(totalBytes)} total)...`; + }, + }); + } finally { + uploadSpin.stop(); + } + + // Record the deploy. A re-deployed ID keeps its slot but gets fresh metadata. + const record: DeployRecord = { + id: identity.id, + createdAt: new Date().toISOString(), + source: identity.source, + gitSha: identity.gitSha, + dirty: identity.dirty, + files: files.length, + bytes: totalBytes, + envHash: buildEnvHash, + }; + state.deploys = [ + record, + ...state.deploys.filter((d) => d.id !== record.id), + ]; + if (args.promote !== false) { + if (state.current && state.current !== identity.id) { + state.previous = state.current; + } + state.current = identity.id; + } + await writeRemoteState(connection, state, etag); + + if (args.promote !== false) { + const promoteSpin = spinner("Promoting to production..."); + promoteSpin.start(); + try { + await promoteDeploy({ + computeClient, + coreClient, + state, + deployId: identity.id, + }); + } finally { + promoteSpin.stop(); + } + } + + // The system hostname comes from the pull zone; tolerate a fetch failure. + let systemHost: string | undefined; + try { + const { data } = await coreClient.GET("/pullzone/{id}", { + params: { path: { id: state.pullZoneId } }, + }); + systemHost = + (data?.Hostnames ?? []).find((h) => h.IsSystemHostname)?.Value ?? + undefined; + } catch { + // URLs are informational only. + } + const urls = deployUrls(site, identity.id, systemHost); + + if (output === "json") { + logger.log( + JSON.stringify( + { + site: state.name, + id: identity.id, + source: identity.source, + files: files.length, + bytes: totalBytes, + promoted: args.promote !== false, + production: urls.production ?? null, + preview: urls.preview ?? null, + }, + null, + 2, + ), + ); + return; + } + + logger.success( + `Deployed ${identity.id} (${files.length} files, ${formatBytes(totalBytes)}).`, + ); + if (args.promote !== false) { + if (urls.production) logger.info(`Production: ${urls.production}`); + } else { + logger.info( + `Uploaded without promoting. Publish it with \`bunny sites deployments publish ${identity.id}\`.`, + ); + } + if (urls.preview) logger.log(` Preview: ${urls.preview}`); + + await offerLink(); + }, +}); diff --git a/packages/cli/src/commands/sites/deployments/index.ts b/packages/cli/src/commands/sites/deployments/index.ts new file mode 100644 index 00000000..cb753657 --- /dev/null +++ b/packages/cli/src/commands/sites/deployments/index.ts @@ -0,0 +1,14 @@ +import { defineNamespace } from "../../../core/define-namespace.ts"; +import { sitesDeploymentsListCommand } from "./list.ts"; +import { sitesDeploymentsPruneCommand } from "./prune.ts"; +import { sitesDeploymentsPublishCommand } from "./publish.ts"; + +export const sitesDeploymentsNamespace = defineNamespace( + "deployments", + "Manage a site's deploys — list, publish (roll back), prune.", + [ + sitesDeploymentsListCommand, + sitesDeploymentsPublishCommand, + sitesDeploymentsPruneCommand, + ], +); diff --git a/packages/cli/src/commands/sites/deployments/list.ts b/packages/cli/src/commands/sites/deployments/list.ts new file mode 100644 index 00000000..745d1833 --- /dev/null +++ b/packages/cli/src/commands/sites/deployments/list.ts @@ -0,0 +1,90 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { + formatBytes, + formatDateTime, + formatTable, +} from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { + type SiteSelectorArgs, + selectSite, + sitePositionalBuilder, +} from "../interactive.ts"; + +type ListArgs = SiteSelectorArgs; + +export const sitesDeploymentsListCommand = defineCommand({ + command: "list [site]", + aliases: ["ls"], + describe: "List a site's deploys.", + examples: [ + ["$0 sites deployments list", "List deploys for the linked site"], + ["$0 sites deployments list my-site", "List deploys for a specific site"], + ["$0 sites deployments list --output json", "JSON output"], + ], + + builder: (yargs) => sitePositionalBuilder(yargs), + + handler: async ({ site: ref, link, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const { site, offerLink } = await selectSite(client, { + site: ref, + link, + output, + }); + const { state } = site; + + if (output === "json") { + logger.log( + JSON.stringify( + { + site: state.name, + current: state.current ?? null, + previous: state.previous ?? null, + deploys: state.deploys, + }, + null, + 2, + ), + ); + return; + } + + if (state.deploys.length === 0) { + logger.info("No deploys yet."); + logger.dim(" Deploy with `bunny sites deploy `."); + await offerLink(); + return; + } + + logger.info(`Deploys for ${state.name}:`); + logger.log(); + logger.log( + formatTable( + ["ID", "Status", "Created", "Source", "Files", "Size"], + state.deploys.map((d) => [ + d.id, + d.id === state.current + ? "● Live" + : d.id === state.previous + ? "○ Previous" + : "○", + formatDateTime(d.createdAt), + d.source === "git" + ? `git ${d.gitSha ?? d.id}` + : `content${d.dirty ? " (dirty tree)" : ""}`, + String(d.files), + formatBytes(d.bytes), + ]), + output, + ), + ); + + await offerLink(); + }, +}); diff --git a/packages/cli/src/commands/sites/deployments/prune.test.ts b/packages/cli/src/commands/sites/deployments/prune.test.ts new file mode 100644 index 00000000..1587bcf9 --- /dev/null +++ b/packages/cli/src/commands/sites/deployments/prune.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import type { DeployRecord } from "../constants.ts"; +import { pruneVictims } from "./prune.ts"; + +function deploy(id: string, createdAt: string): DeployRecord { + return { id, createdAt, source: "content", files: 1, bytes: 1 }; +} + +const DEPLOYS = [ + deploy("aaa", "2026-07-01T00:00:00Z"), + deploy("bbb", "2026-07-02T00:00:00Z"), + deploy("ccc", "2026-07-03T00:00:00Z"), + deploy("ddd", "2026-07-04T00:00:00Z"), +]; + +test("pruneVictims drops everything beyond the newest N", () => { + const victims = pruneVictims(DEPLOYS, 2); + expect(victims.map((v) => v.id)).toEqual(["bbb", "aaa"]); +}); + +test("pruneVictims never touches current or previous", () => { + const victims = pruneVictims(DEPLOYS, 1, "aaa", "bbb"); + expect(victims.map((v) => v.id)).toEqual(["ccc"]); +}); + +test("pruneVictims is empty when everything fits", () => { + expect(pruneVictims(DEPLOYS, 10)).toEqual([]); + expect(pruneVictims([], 0)).toEqual([]); +}); diff --git a/packages/cli/src/commands/sites/deployments/prune.ts b/packages/cli/src/commands/sites/deployments/prune.ts new file mode 100644 index 00000000..e513b174 --- /dev/null +++ b/packages/cli/src/commands/sites/deployments/prune.ts @@ -0,0 +1,150 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { logger } from "../../../core/logger.ts"; +import { confirm, spinner } from "../../../core/ui.ts"; +import { deleteDeployFiles, writeRemoteState } from "../api.ts"; +import { DEFAULT_KEEP_DEPLOYS, type DeployRecord } from "../constants.ts"; +import { + type SiteSelectorArgs, + selectSite, + siteOptionBuilder, +} from "../interactive.ts"; + +interface PruneArgs extends SiteSelectorArgs { + keep?: number; + force?: boolean; +} + +/** Pick the deploys to delete: everything beyond the newest `keep`, never current/previous. */ +export function pruneVictims( + deploys: DeployRecord[], + keep: number, + current?: string, + previous?: string, +): DeployRecord[] { + const sorted = [...deploys].sort((a, b) => + b.createdAt.localeCompare(a.createdAt), + ); + return sorted + .slice(Math.max(0, keep)) + .filter((d) => d.id !== current && d.id !== previous); +} + +export const sitesDeploymentsPruneCommand = defineCommand({ + command: "prune", + describe: "Delete old deploys, keeping the most recent ones.", + examples: [ + [ + "$0 sites deployments prune", + `Keep the ${DEFAULT_KEEP_DEPLOYS} newest deploys`, + ], + ["$0 sites deployments prune --keep 10", "Keep the 10 newest deploys"], + ["$0 sites deployments prune --force", "Skip confirmation"], + ], + + builder: (yargs) => + siteOptionBuilder(yargs) + .option("keep", { + type: "number", + default: DEFAULT_KEEP_DEPLOYS, + describe: + "Number of recent deploys to keep (current and previous are always kept)", + }) + .option("force", { + alias: "f", + type: "boolean", + describe: "Skip the confirmation prompt", + }), + + handler: async (args) => { + const { profile, output, verbose, apiKey } = args; + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const { site } = await selectSite(client, { + site: args.site, + link: false, + output, + }); + const { state, connection, etag } = site; + + const victims = pruneVictims( + state.deploys, + args.keep ?? DEFAULT_KEEP_DEPLOYS, + state.current, + state.previous, + ); + + if (victims.length === 0) { + if (output === "json") { + logger.log(JSON.stringify({ site: state.name, pruned: [] }, null, 2)); + return; + } + logger.info("Nothing to prune."); + return; + } + + const proceed = await confirm( + `Delete ${victims.length} old deploy(s) from ${state.name} (${victims + .map((v) => v.id) + .join(", ")})?`, + { force: args.force }, + ); + if (!proceed) { + logger.log("Cancelled."); + return; + } + + const failures: Array<{ id: string; error: string }> = []; + const spin = spinner("Pruning deploys..."); + spin.start(); + try { + const pruned = new Set(); + for (const [index, victim] of victims.entries()) { + spin.text = `Pruning ${victim.id} (${index + 1}/${victims.length})...`; + try { + await deleteDeployFiles(connection, victim.id); + pruned.add(victim.id); + } catch (err) { + failures.push({ + id: victim.id, + error: err instanceof Error ? err.message : String(err), + }); + } + } + // Only forget deploys whose files are actually gone. + state.deploys = state.deploys.filter((d) => !pruned.has(d.id)); + await writeRemoteState(connection, state, etag); + } finally { + spin.stop(); + } + + const prunedIds = victims + .filter((v) => !failures.some((f) => f.id === v.id)) + .map((v) => v.id); + + if (output === "json") { + logger.log( + JSON.stringify( + { site: state.name, pruned: prunedIds, failures }, + null, + 2, + ), + ); + if (failures.length > 0) process.exit(1); + return; + } + + if (prunedIds.length > 0) { + logger.success( + `Pruned ${prunedIds.length} deploy(s): ${prunedIds.join(", ")}.`, + ); + } + for (const failure of failures) { + logger.warn(`Couldn't prune ${failure.id}: ${failure.error}`); + } + if (failures.length > 0) process.exit(1); + }, +}); diff --git a/packages/cli/src/commands/sites/deployments/publish.ts b/packages/cli/src/commands/sites/deployments/publish.ts new file mode 100644 index 00000000..ad44a951 --- /dev/null +++ b/packages/cli/src/commands/sites/deployments/publish.ts @@ -0,0 +1,160 @@ +import { + createComputeClient, + createCoreClient, +} from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { UserError } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; +import { confirm, spinner } from "../../../core/ui.ts"; +import { promoteDeploy, writeRemoteState } from "../api.ts"; +import { + type SiteSelectorArgs, + selectSite, + siteOptionBuilder, +} from "../interactive.ts"; + +interface PublishArgs extends SiteSelectorArgs { + id?: string; + previous?: boolean; + force?: boolean; +} + +/** + * Publish (promote) a past deploy as production — instant rollback. Flips the + * router's env var and purges the CDN cache; no files move. + */ +export const sitesDeploymentsPublishCommand = defineCommand({ + command: "publish [id]", + aliases: ["promote"], + describe: "Publish (roll back to) a past deploy.", + examples: [ + ["$0 sites deployments publish a1b2c3d4", "Promote a deploy by ID"], + ["$0 sites deployments publish --previous", "Instant rollback"], + ["$0 sites deployments publish a1b2c3d4 --force", "Skip confirmation"], + ], + + builder: (yargs) => + siteOptionBuilder( + yargs.positional("id", { + type: "string", + describe: "Deploy ID to publish (see `sites deployments list`)", + }), + ) + .option("previous", { + type: "boolean", + describe: "Publish the previous deploy (instant rollback)", + }) + .option("force", { + alias: "f", + type: "boolean", + describe: "Skip the confirmation prompt", + }), + + handler: async (args) => { + const { profile, output, verbose, apiKey } = args; + const config = resolveConfig(profile, apiKey, verbose); + const options = clientOptions(config, verbose); + const coreClient = createCoreClient(options); + const computeClient = createComputeClient(options); + + const { site, offerLink } = await selectSite(coreClient, { + site: args.site, + link: args.link, + output, + }); + const { state, connection, etag } = site; + + let targetId = args.id; + if (args.previous) { + if (targetId) { + throw new UserError("Pass either a deploy ID or --previous, not both."); + } + if (!state.previous) { + throw new UserError( + "No previous deploy to roll back to.", + "See `bunny sites deployments list` for available deploys.", + ); + } + targetId = state.previous; + } + if (!targetId) { + throw new UserError( + "A deploy ID is required.", + "Pass an ID from `bunny sites deployments list`, or --previous to roll back.", + ); + } + + const deploy = state.deploys.find((d) => d.id === targetId); + if (!deploy) { + throw new UserError( + `Deploy ${targetId} not found for site ${state.name}.`, + "Run `bunny sites deployments list` to see available deploys.", + ); + } + + if (state.current === targetId) { + if (output === "json") { + logger.log( + JSON.stringify( + { + site: state.name, + id: targetId, + published: false, + unchanged: true, + }, + null, + 2, + ), + ); + return; + } + logger.info(`Deploy ${targetId} is already live.`); + return; + } + + const proceed = await confirm( + `Publish deploy ${targetId} as production for ${state.name}?`, + { force: args.force }, + ); + if (!proceed) { + logger.log("Cancelled."); + return; + } + + const spin = spinner("Publishing..."); + spin.start(); + try { + await promoteDeploy({ + computeClient, + coreClient, + state, + deployId: targetId, + }); + if (state.current && state.current !== targetId) { + state.previous = state.current; + } + state.current = targetId; + await writeRemoteState(connection, state, etag); + } finally { + spin.stop(); + } + + if (output === "json") { + logger.log( + JSON.stringify( + { site: state.name, id: targetId, published: true }, + null, + 2, + ), + ); + return; + } + + logger.success(`Deploy ${targetId} is now live.`); + if (state.domain) logger.info(`Production: https://${state.domain}`); + + await offerLink(); + }, +}); diff --git a/packages/cli/src/commands/sites/domains/index.ts b/packages/cli/src/commands/sites/domains/index.ts new file mode 100644 index 00000000..62362fe6 --- /dev/null +++ b/packages/cli/src/commands/sites/domains/index.ts @@ -0,0 +1,198 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { + addHostname, + type CoreClient, + createHostnamesCommands, + enableSsl, + type ResolvedPullZone, + setupHostname, +} from "../../../core/hostnames/index.ts"; +import { logger } from "../../../core/logger.ts"; +import type { GlobalArgs } from "../../../core/types.ts"; +import { type SiteContext, writeRemoteState } from "../api.ts"; +import { PREVIEW_LABEL, previewWildcard } from "../constants.ts"; +import { selectSite } from "../interactive.ts"; + +// The hooks run in the same invocation as `resolve`, so the resolved site is +// cached here — a CLI process handles exactly one command. +let resolvedSite: SiteContext | null = null; + +async function resolveSitePullZone( + args: GlobalArgs & Record, +): Promise { + const config = resolveConfig(args.profile, args.apiKey, args.verbose); + const coreClient = createCoreClient(clientOptions(config, args.verbose)); + + const { site } = await selectSite(coreClient, { + site: args.site as string | undefined, + link: false, + output: args.output, + }); + resolvedSite = site; + + return { pullZoneId: site.state.pullZoneId, coreClient }; +} + +/** True for hostnames that are themselves preview infrastructure. */ +function isPreviewHost(hostname: string): boolean { + return ( + hostname.startsWith("*.") || + hostname.includes(`.${PREVIEW_LABEL}.`) || + hostname.startsWith(`${PREVIEW_LABEL}.`) + ); +} + +/** Persist the site's primary domain in the remote state (best-effort). */ +async function recordSiteDomain( + site: SiteContext, + domain: string | undefined, +): Promise { + try { + site.state.domain = domain; + site.etag = await writeRemoteState(site.connection, site.state, site.etag); + } catch (err) { + logger.warn( + `Couldn't update the site state: ${err instanceof Error ? err.message : err}`, + ); + } +} + +/** + * Attach the `*.preview.` wildcard that serves per-deploy previews. + * Best-effort by design: the apex is already added, and the wildcard can be + * retried by re-running `sites domains add`. + */ +export async function attachPreviewWildcard(opts: { + coreClient: CoreClient; + pullZoneId: number; + domain: string; + cnameTarget?: string; + json?: boolean; +}): Promise { + const wildcard = previewWildcard(opts.domain); + try { + const { hostnames } = await addHostname( + opts.coreClient, + opts.pullZoneId, + wildcard, + ); + 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. + if (!opts.json) { + logger.dim( + ` Preview HTTPS pending — once DNS is live: bunny sites domains ssl "${wildcard}"`, + ); + } + } + } catch (err) { + if (!opts.json) { + logger.warn( + `Couldn't add ${wildcard}: ${err instanceof Error ? err.message : err}`, + ); + logger.dim(" Previews will use /deploys// paths until it's added."); + } + } +} + +/** + * 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. + */ +export async function setupSiteDomain(opts: { + coreClient: CoreClient; + site: SiteContext; + domain: string; + interactive: boolean; + verbose: boolean; + json?: boolean; +}): Promise { + const { coreClient, site, domain } = opts; + const pullZoneId = site.state.pullZoneId; + const name = site.state.name; + + let cnameTarget: string | undefined; + if (opts.json) { + const added = await addHostname(coreClient, pullZoneId, domain); + cnameTarget = added.cnameTarget; + } else { + await setupHostname({ + coreClient, + pullZoneId, + domain, + sslHint: `bunny sites domains ssl ${domain} ${name}`, + retryHint: `bunny sites domains add ${domain} ${name}`, + forceSsl: true, + interactive: opts.interactive, + verbose: opts.verbose, + }); + } + + await attachPreviewWildcard({ + coreClient, + pullZoneId, + domain, + cnameTarget, + json: opts.json, + }); + await recordSiteDomain(site, domain); +} + +/** The `domains` namespace + hidden `hostnames` alias, ready to spread into `sites`. */ +export const sitesDomainsCommands = createHostnamesCommands({ + commandPath: "sites domains", + namespace: "domains", + describe: "Manage custom domains for a site.", + hiddenAliases: ["hostnames"], + targetPositional: { + name: "site", + describe: "Site name or storage zone ID (uses the linked site if omitted)", + type: "string", + }, + resolve: resolveSitePullZone, + onAdded: async ({ coreClient, pullZoneId, hostname, cnameTarget, args }) => { + // Adding preview infrastructure by hand shouldn't recurse into itself. + if (isPreviewHost(hostname)) return; + await attachPreviewWildcard({ + coreClient, + pullZoneId, + domain: hostname, + cnameTarget, + json: args.output === "json", + }); + if (resolvedSite && !resolvedSite.state.domain) { + await recordSiteDomain(resolvedSite, hostname); + } + }, + onRemoved: async ({ coreClient, pullZoneId, hostname }) => { + if (isPreviewHost(hostname)) return; + // Take the companion wildcard down with the apex. + try { + await coreClient.DELETE("/pullzone/{id}/removeHostname", { + params: { path: { id: pullZoneId } }, + body: { Hostname: previewWildcard(hostname) }, + }); + } catch { + // Already gone (or never added) — nothing to clean up. + } + if (resolvedSite?.state.domain === hostname) { + await recordSiteDomain(resolvedSite, undefined); + } + }, +}); diff --git a/packages/cli/src/commands/sites/env/index.ts b/packages/cli/src/commands/sites/env/index.ts new file mode 100644 index 00000000..d06a6500 --- /dev/null +++ b/packages/cli/src/commands/sites/env/index.ts @@ -0,0 +1,16 @@ +import { defineNamespace } from "../../../core/define-namespace.ts"; +import { sitesEnvListCommand } from "./list.ts"; +import { sitesEnvPullCommand } from "./pull.ts"; +import { sitesEnvRemoveCommand } from "./remove.ts"; +import { sitesEnvSetCommand } from "./set.ts"; + +export const sitesEnvNamespace = defineNamespace( + "env", + "Manage a site's build-time environment variables.", + [ + sitesEnvSetCommand, + sitesEnvListCommand, + sitesEnvRemoveCommand, + sitesEnvPullCommand, + ], +); diff --git a/packages/cli/src/commands/sites/env/list.ts b/packages/cli/src/commands/sites/env/list.ts new file mode 100644 index 00000000..9f22161b --- /dev/null +++ b/packages/cli/src/commands/sites/env/list.ts @@ -0,0 +1,89 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { formatTable, maskSecret } from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { spinner } from "../../../core/ui.ts"; +import { readRemoteEnv } from "../api.ts"; +import { + type SiteSelectorArgs, + selectSite, + siteOptionBuilder, +} from "../interactive.ts"; + +interface ListArgs extends SiteSelectorArgs { + show?: boolean; +} + +export const sitesEnvListCommand = defineCommand({ + command: "list", + aliases: ["ls"], + describe: "List a site's build-time environment variables.", + examples: [ + ["$0 sites env list", "List variables (values masked)"], + ["$0 sites env list --show", "List with values revealed"], + ], + + builder: (yargs) => + siteOptionBuilder(yargs).option("show", { + type: "boolean", + default: false, + describe: "Reveal values instead of masking them", + }), + + handler: async (args) => { + const { profile, output, verbose, apiKey } = args; + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const { site, offerLink } = await selectSite(client, { + site: args.site, + link: args.link, + output, + }); + + const spin = spinner("Fetching variables..."); + spin.start(); + let env: Record; + try { + env = await readRemoteEnv(site.connection); + } finally { + spin.stop(); + } + + const entries = Object.entries(env); + const display = (value: string) => (args.show ? value : maskSecret(value)); + + if (output === "json") { + logger.log( + JSON.stringify( + Object.fromEntries(entries.map(([k, v]) => [k, display(v)])), + null, + 2, + ), + ); + return; + } + + if (entries.length === 0) { + logger.info("No variables set."); + logger.dim(" Set one with `bunny sites env set `."); + await offerLink(); + return; + } + + logger.log( + formatTable( + ["Name", "Value"], + entries.map(([k, v]) => [k, display(v)]), + output, + ), + ); + if (!args.show) { + logger.dim(" Values are masked — pass --show to reveal them."); + } + + await offerLink(); + }, +}); diff --git a/packages/cli/src/commands/sites/env/pull.test.ts b/packages/cli/src/commands/sites/env/pull.test.ts new file mode 100644 index 00000000..5ec5031b --- /dev/null +++ b/packages/cli/src/commands/sites/env/pull.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { toDotenv } from "./pull.ts"; + +test("toDotenv sorts keys and quotes values that need it", () => { + expect( + toDotenv({ + B_URL: "https://api.example.com/v1", + A_PLAIN: "simple-value", + C_SPACED: "hello world", + D_QUOTE: 'say "hi"', + }), + ).toBe( + [ + "A_PLAIN=simple-value", + "B_URL=https://api.example.com/v1", + 'C_SPACED="hello world"', + 'D_QUOTE="say \\"hi\\""', + "", + ].join("\n"), + ); +}); + +test("toDotenv of an empty env is just a newline", () => { + expect(toDotenv({})).toBe("\n"); +}); diff --git a/packages/cli/src/commands/sites/env/pull.ts b/packages/cli/src/commands/sites/env/pull.ts new file mode 100644 index 00000000..640533d2 --- /dev/null +++ b/packages/cli/src/commands/sites/env/pull.ts @@ -0,0 +1,105 @@ +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { UserError } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; +import { spinner } from "../../../core/ui.ts"; +import { readRemoteEnv } from "../api.ts"; +import { + type SiteSelectorArgs, + selectSite, + siteOptionBuilder, +} from "../interactive.ts"; + +interface PullArgs extends SiteSelectorArgs { + file?: string; + force?: boolean; +} + +/** Serialize env pairs as dotenv lines, quoting anything that needs it. */ +export function toDotenv(env: Record): string { + return `${Object.entries(env) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => + /^[A-Za-z0-9_./:@-]*$/.test(value) + ? `${key}=${value}` + : `${key}=${JSON.stringify(value)}`, + ) + .join("\n")}\n`; +} + +export const sitesEnvPullCommand = defineCommand({ + command: "pull [file]", + describe: "Write a site's build-time env to a local dotenv file.", + examples: [ + ["$0 sites env pull", "Write to .env (refuses to overwrite)"], + ["$0 sites env pull .env.local --force", "Overwrite a specific file"], + ], + + builder: (yargs) => + siteOptionBuilder( + yargs.positional("file", { + type: "string", + describe: "Output file (default: .env)", + }), + ).option("force", { + alias: "f", + type: "boolean", + describe: "Overwrite the file if it exists", + }), + + handler: async (args) => { + const { profile, output, verbose, apiKey } = args; + const target = resolve(args.file ?? ".env"); + if (existsSync(target) && !args.force) { + throw new UserError( + `${target} already exists.`, + "Pass --force to overwrite it.", + ); + } + + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const { site, offerLink } = await selectSite(client, { + site: args.site, + link: args.link, + output, + }); + + const spin = spinner("Fetching variables..."); + spin.start(); + let env: Record; + try { + env = await readRemoteEnv(site.connection); + } finally { + spin.stop(); + } + + await Bun.write(target, toDotenv(env), { mode: 0o600 }); + + if (output === "json") { + logger.log( + JSON.stringify( + { + site: site.state.name, + file: target, + variables: Object.keys(env).length, + }, + null, + 2, + ), + ); + return; + } + + logger.success( + `Wrote ${Object.keys(env).length} variable(s) to ${target}.`, + ); + + await offerLink(); + }, +}); diff --git a/packages/cli/src/commands/sites/env/remove.ts b/packages/cli/src/commands/sites/env/remove.ts new file mode 100644 index 00000000..1b5da72a --- /dev/null +++ b/packages/cli/src/commands/sites/env/remove.ts @@ -0,0 +1,92 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { UserError } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; +import { confirm, spinner } from "../../../core/ui.ts"; +import { readRemoteEnv, writeRemoteEnv } from "../api.ts"; +import { + type SiteSelectorArgs, + selectSite, + siteOptionBuilder, +} from "../interactive.ts"; + +interface RemoveArgs extends SiteSelectorArgs { + name: string; + force?: boolean; +} + +export const sitesEnvRemoveCommand = defineCommand({ + command: "remove ", + aliases: ["rm"], + describe: "Remove a build-time environment variable from a site.", + examples: [ + ["$0 sites env remove VITE_API_URL", "Remove a variable"], + ["$0 sites env remove VITE_API_URL --force", "Skip confirmation"], + ], + + builder: (yargs) => + siteOptionBuilder( + yargs.positional("name", { + type: "string", + describe: "Variable name to remove", + demandOption: true, + }), + ).option("force", { + alias: "f", + type: "boolean", + describe: "Skip the confirmation prompt", + }), + + handler: async (args) => { + const { profile, output, verbose, apiKey } = args; + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const { site, offerLink } = await selectSite(client, { + site: args.site, + link: args.link, + output, + }); + + const env = await readRemoteEnv(site.connection); + if (!(args.name in env)) { + throw new UserError( + `Variable "${args.name}" is not set for ${site.state.name}.`, + ); + } + + const proceed = await confirm(`Remove ${args.name}?`, { + force: args.force, + }); + if (!proceed) { + logger.log("Cancelled."); + return; + } + + const spin = spinner("Removing variable..."); + spin.start(); + try { + delete env[args.name]; + await writeRemoteEnv(site.connection, env); + } finally { + spin.stop(); + } + + if (output === "json") { + logger.log( + JSON.stringify( + { site: site.state.name, name: args.name, removed: true }, + null, + 2, + ), + ); + return; + } + + logger.success(`Removed ${args.name}.`); + + await offerLink(); + }, +}); diff --git a/packages/cli/src/commands/sites/env/set.ts b/packages/cli/src/commands/sites/env/set.ts new file mode 100644 index 00000000..4a169a30 --- /dev/null +++ b/packages/cli/src/commands/sites/env/set.ts @@ -0,0 +1,108 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import prompts from "prompts"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { UserError } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; +import { spinner } from "../../../core/ui.ts"; +import { readRemoteEnv, writeRemoteEnv } from "../api.ts"; +import { + type SiteSelectorArgs, + selectSite, + siteOptionBuilder, +} from "../interactive.ts"; + +const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; + +interface SetArgs extends SiteSelectorArgs { + name?: string; + value?: string; +} + +/** + * Set a build-time variable for a site (stored at `_bunny/env.json`, which + * the router 403-blocks). These are merged into the environment of + * `sites deploy --build` — they are NOT runtime env and NOT a secret store: + * anything the build reads can end up in the shipped bundle. + */ +export const sitesEnvSetCommand = defineCommand({ + command: "set [name] [value]", + describe: "Set a build-time environment variable for a site.", + examples: [ + [ + '$0 sites env set VITE_API_URL "https://api.example.com"', + "Set a variable", + ], + ["$0 sites env set", "Interactive mode"], + ], + + builder: (yargs) => + siteOptionBuilder( + yargs + .positional("name", { type: "string", describe: "Variable name" }) + .positional("value", { type: "string", describe: "Variable value" }), + ), + + handler: async (args) => { + const { profile, output, verbose, apiKey } = args; + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const { site, offerLink } = await selectSite(client, { + site: args.site, + link: args.link, + output, + }); + + let name = args.name; + if (!name) { + const { value } = await prompts({ + type: "text", + name: "value", + message: "Variable name:", + }); + name = value; + } + if (!name) throw new UserError("Variable name is required."); + if (!ENV_NAME_RE.test(name)) { + throw new UserError( + `"${name}" is not a valid environment variable name.`, + "Names must start with a letter or underscore and contain only letters, digits, and underscores.", + ); + } + + let value = args.value; + if (value === undefined) { + const { value: prompted } = await prompts({ + type: "text", + name: "value", + message: "Variable value:", + }); + value = prompted; + } + if (value === undefined) throw new UserError("Variable value is required."); + + const spin = spinner("Saving variable..."); + spin.start(); + try { + const env = await readRemoteEnv(site.connection); + env[name] = value; + await writeRemoteEnv(site.connection, env); + } finally { + spin.stop(); + } + + if (output === "json") { + logger.log(JSON.stringify({ site: site.state.name, name }, null, 2)); + return; + } + + logger.success(`Set ${name} for ${site.state.name}.`); + logger.warn( + "Build-time env is baked into the deployed bundle — don't store runtime secrets here.", + ); + + await offerLink(); + }, +}); diff --git a/packages/cli/src/commands/sites/index.ts b/packages/cli/src/commands/sites/index.ts new file mode 100644 index 00000000..aee551ee --- /dev/null +++ b/packages/cli/src/commands/sites/index.ts @@ -0,0 +1,26 @@ +import { defineNamespace } from "../../core/define-namespace.ts"; +import { sitesCreateCommand } from "./create.ts"; +import { sitesDeleteCommand } from "./delete.ts"; +import { sitesDeployCommand } from "./deploy.ts"; +import { sitesDeploymentsNamespace } from "./deployments/index.ts"; +import { sitesDomainsCommands } from "./domains/index.ts"; +import { sitesEnvNamespace } from "./env/index.ts"; +import { sitesLinkCommand } from "./link.ts"; +import { sitesListCommand } from "./list.ts"; +import { sitesShowCommand } from "./show.ts"; +import { sitesUnlinkCommand } from "./unlink.ts"; +import { sitesUpgradeCommand } from "./upgrade.ts"; + +export const sitesNamespace = defineNamespace("sites", false, [ + sitesCreateCommand, + sitesListCommand, + sitesShowCommand, + sitesDeployCommand, + sitesDeploymentsNamespace, + ...sitesDomainsCommands, + sitesEnvNamespace, + sitesLinkCommand, + sitesUnlinkCommand, + sitesUpgradeCommand, + sitesDeleteCommand, +]); diff --git a/packages/cli/src/commands/sites/interactive.ts b/packages/cli/src/commands/sites/interactive.ts new file mode 100644 index 00000000..c60cbc49 --- /dev/null +++ b/packages/cli/src/commands/sites/interactive.ts @@ -0,0 +1,193 @@ +import prompts from "prompts"; +import type { Argv } from "yargs"; +import { UserError } from "../../core/errors.ts"; +import { logger } from "../../core/logger.ts"; +import { loadManifest, saveManifest } from "../../core/manifest.ts"; +import type { OutputFormat } from "../../core/types.ts"; +import { confirm, isInteractive, spinner } from "../../core/ui.ts"; +import { + type CoreClient, + fetchStorageZone, + resolveStorageZone, +} from "../storage/api.ts"; +import { fetchSites, type SiteContext, siteContextFromZone } from "./api.ts"; +import { loadSiteConfig } from "./config.ts"; +import { SITES_MANIFEST, type SiteManifest } from "./constants.ts"; + +const ARG_SITE_DESCRIPTION = + "Site name or storage zone ID (uses the linked site if omitted)"; +const ARG_LINK_DESCRIPTION = + "Link the directory to the picked site (use --no-link to skip the prompt)"; + +/** Args contributed by {@link siteOptionBuilder} / {@link sitePositionalBuilder}. */ +export interface SiteSelectorArgs { + site?: string; + link?: boolean; +} + +/** `--site` option + `--link`, for commands whose positionals are taken. */ +export function siteOptionBuilder( + yargs: Argv, +): Argv { + return yargs + .option("site", { type: "string", describe: ARG_SITE_DESCRIPTION }) + .option("link", { + type: "boolean", + describe: ARG_LINK_DESCRIPTION, + }) as Argv; +} + +/** Trailing `[site]` positional + `--link`. Pair with `command: "... [site]"`. */ +export function sitePositionalBuilder( + yargs: Argv, +): Argv { + return yargs + .positional("site", { type: "string", describe: ARG_SITE_DESCRIPTION }) + .option("link", { + type: "boolean", + describe: ARG_LINK_DESCRIPTION, + }) as Argv; +} + +async function contextFromRef( + client: CoreClient, + ref: string, +): Promise { + const zone = await resolveStorageZone(client, ref); + const context = await siteContextFromZone(zone); + if (!context) { + throw new UserError( + `Storage zone "${zone.Name}" is not a bunny site.`, + "Create one with `bunny sites create `.", + ); + } + return context; +} + +export interface SelectedSite { + site: SiteContext; + /** + * Offer to link the directory to the site — only when it was chosen via the + * interactive picker. A no-op otherwise, so commands can always call it + * after their own output. + */ + offerLink: () => Promise; +} + +/** + * Resolve the site a command acts on. + * + * Precedence: explicit ref (flag or positional) → `.bunny/site.json` → + * `sites.name` in bunny.jsonc → interactive picker. Non-interactive runs + * without a resolvable site fail with a hint instead of hanging on a prompt. + */ +export async function selectSite( + client: CoreClient, + args: SiteSelectorArgs & { output: OutputFormat }, +): Promise { + const noLink = async () => {}; + + if (args.site) { + const spin = spinner("Resolving site..."); + spin.start(); + try { + return { + site: await contextFromRef(client, args.site), + offerLink: noLink, + }; + } finally { + spin.stop(); + } + } + + const manifest = loadManifest(SITES_MANIFEST); + if (manifest.id) { + const spin = spinner("Loading linked site..."); + spin.start(); + try { + const zone = await fetchStorageZone(client, manifest.id); + const context = await siteContextFromZone(zone); + if (!context) { + throw new UserError( + `The linked storage zone ${manifest.id} is no longer a bunny site.`, + "Run `bunny sites unlink`, then link or create a site.", + ); + } + return { site: context, offerLink: noLink }; + } finally { + spin.stop(); + } + } + + const configured = loadSiteConfig()?.config.name; + if (configured) { + const spin = spinner(`Resolving site "${configured}" from bunny.jsonc...`); + spin.start(); + try { + return { + site: await contextFromRef(client, configured), + offerLink: noLink, + }; + } finally { + spin.stop(); + } + } + + if (!isInteractive(args.output)) { + throw new UserError( + "No site specified and no linked site found.", + "Pass a site name or run `bunny sites link`.", + ); + } + + const spin = spinner("Fetching sites..."); + spin.start(); + let sites: Awaited>; + try { + sites = await fetchSites(client); + } finally { + spin.stop(); + } + + if (sites.length === 0) { + throw new UserError( + "No sites found in your account.", + "Create one with `bunny sites create `.", + ); + } + + const { selected } = await prompts({ + type: "select", + name: "selected", + message: "Select a site:", + choices: sites.map((s) => ({ + title: `${s.state.name} (${s.state.storageZoneId})`, + value: s, + })), + }); + if (!selected) throw new UserError("A site is required."); + + const summary = selected as (typeof sites)[number]; + const context = await siteContextFromZone(summary.storageZone); + if (!context) { + throw new UserError(`Site "${summary.state.name}" could not be loaded.`); + } + + return { + site: context, + offerLink: async () => { + const shouldLink = + args.link !== undefined + ? args.link + : await confirm(`Link this directory to ${context.state.name}?`); + if (!shouldLink) return; + saveManifest(SITES_MANIFEST, { + id: context.state.storageZoneId, + name: context.state.name, + }); + logger.success( + `Linked to ${context.state.name} (${context.state.storageZoneId}).`, + ); + }, + }; +} diff --git a/packages/cli/src/commands/sites/link.ts b/packages/cli/src/commands/sites/link.ts new file mode 100644 index 00000000..d05003f9 --- /dev/null +++ b/packages/cli/src/commands/sites/link.ts @@ -0,0 +1,60 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../config/index.ts"; +import { clientOptions } from "../../core/client-options.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { logger } from "../../core/logger.ts"; +import { saveManifest } from "../../core/manifest.ts"; +import { SITES_MANIFEST, type SiteManifest } from "./constants.ts"; +import { selectSite } from "./interactive.ts"; + +interface LinkArgs { + site?: string; +} + +export const sitesLinkCommand = defineCommand({ + command: "link [site]", + describe: "Link the current directory to a site.", + examples: [ + ["$0 sites link", "Interactive selection"], + ["$0 sites link my-site", "Link by name or storage zone ID"], + ], + + builder: (yargs) => + yargs.positional("site", { + type: "string", + describe: "Site name or storage zone ID", + }), + + handler: async ({ site: ref, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + // `link: true` makes the picker's offerLink a no-op prompt-wise, but we + // save explicitly below so the ref/manifest paths also link. + const { site } = await selectSite(client, { + site: ref, + link: false, + output, + }); + + saveManifest(SITES_MANIFEST, { + id: site.state.storageZoneId, + name: site.state.name, + }); + + if (output === "json") { + logger.log( + JSON.stringify( + { id: site.state.storageZoneId, name: site.state.name }, + null, + 2, + ), + ); + return; + } + + logger.success( + `Linked to ${site.state.name} (${site.state.storageZoneId}).`, + ); + }, +}); diff --git a/packages/cli/src/commands/sites/list.ts b/packages/cli/src/commands/sites/list.ts new file mode 100644 index 00000000..c421b5c9 --- /dev/null +++ b/packages/cli/src/commands/sites/list.ts @@ -0,0 +1,75 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../config/index.ts"; +import { clientOptions } from "../../core/client-options.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { formatTable } from "../../core/format.ts"; +import { logger } from "../../core/logger.ts"; +import { spinner } from "../../core/ui.ts"; +import { fetchSites } from "./api.ts"; + +export const sitesListCommand = defineCommand({ + command: "list", + aliases: ["ls"], + describe: "List your sites.", + examples: [ + ["$0 sites list", "List sites"], + ["$0 sites list --output json", "JSON output"], + ], + + handler: async ({ profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const spin = spinner("Fetching sites..."); + spin.start(); + let sites: Awaited>; + try { + sites = await fetchSites(client); + } finally { + spin.stop(); + } + + if (output === "json") { + logger.log( + JSON.stringify( + sites.map((s) => ({ + name: s.state.name, + storageZoneId: s.state.storageZoneId, + pullZoneId: s.state.pullZoneId, + scriptId: s.state.scriptId, + domain: s.state.domain ?? null, + hostname: s.systemHostname ?? null, + current: s.state.current ?? null, + deploys: s.state.deploys.length, + })), + null, + 2, + ), + ); + return; + } + + if (sites.length === 0) { + logger.info("No sites found."); + logger.dim(" Create one with `bunny sites create `."); + return; + } + + logger.log( + formatTable( + ["Name", "URL", "Deploys", "Current"], + sites.map((s) => [ + s.state.name, + s.state.domain + ? `https://${s.state.domain}` + : s.systemHostname + ? `https://${s.systemHostname}` + : "—", + String(s.state.deploys.length), + s.state.current ?? "—", + ]), + output, + ), + ); + }, +}); diff --git a/packages/cli/src/commands/sites/router/source.ts b/packages/cli/src/commands/sites/router/source.ts new file mode 100644 index 00000000..71f71a6f --- /dev/null +++ b/packages/cli/src/commands/sites/router/source.ts @@ -0,0 +1,80 @@ +/** + * The site router — a middleware Edge Script attached to the site's pull zone. + * + * It maps incoming hosts to deploy directories inside the storage-zone origin: + * + * - apex / `.b-cdn.net` host → `/deploys/{CURRENT_DEPLOY}{path}` + * - `dpl-{id}.preview.{domain}` → `/deploys/{id}{path}` (with `X-Robots-Tag: noindex`) + * - `/deploys/{id}/...` paths → passed through (path-based previews) + * - `/_bunny/*` → 403 (site state/env are never served) + * + * Promote/rollback is just updating the `CURRENT_DEPLOY` env var — the code + * itself never changes between deploys. Bump {@link ROUTER_VERSION} whenever + * the source changes; `sites show` warns and `sites upgrade` republishes. + */ +export const ROUTER_VERSION = 1; + +// NOTE: validated by the Phase 0 spike against the live platform; the exact +// BunnySDK middleware hook names are the platform contract this encodes. +const SOURCE = `// bunny sites router v__ROUTER_VERSION__ — generated by the bunny CLI. Do not edit: +// \`bunny sites upgrade\` overwrites this script. +import * as BunnySDK from "@bunny.net/edgescript-sdk"; + +const PREVIEW_HOST = /^dpl-([a-z0-9]{4,40})\\.preview\\./i; + +const NO_DEPLOYS_PAGE = \` +No deploys yet + +

Nothing here yet 🐇

+

This site has no published deploys. Run bunny sites deploy to publish one.

+\`; + +BunnySDK.net.http + .servePullZone() + .onOriginRequest(async (ctx) => { + const url = new URL(ctx.request.url); + const path = url.pathname; + + // Internal site metadata (state, env) is never served. + if (path === "/_bunny" || path.startsWith("/_bunny/")) { + return new Response("Forbidden", { status: 403 }); + } + + // Path-based previews address a deploy directory directly. + if (path === "/deploys" || path.startsWith("/deploys/")) { + return; + } + + const preview = PREVIEW_HOST.exec(url.hostname); + const deploy = preview + ? preview[1].toLowerCase() + : process.env.CURRENT_DEPLOY || ""; + + if (!deploy) { + return new Response(NO_DEPLOYS_PAGE, { + status: 404, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } + + let target = path; + if (target.endsWith("/")) target += "index.html"; + url.pathname = "/deploys/" + deploy + target; + return new Request(url.toString(), ctx.request); + }) + .onOriginResponse(async (ctx) => { + // Previews must never be indexed. + 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(ctx.response.body, { + status: ctx.response.status, + statusText: ctx.response.statusText, + headers, + }); + }); +`; + +export function routerSource(): string { + return SOURCE.replace("__ROUTER_VERSION__", String(ROUTER_VERSION)); +} diff --git a/packages/cli/src/commands/sites/show.ts b/packages/cli/src/commands/sites/show.ts new file mode 100644 index 00000000..38029db8 --- /dev/null +++ b/packages/cli/src/commands/sites/show.ts @@ -0,0 +1,128 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../config/index.ts"; +import { clientOptions } from "../../core/client-options.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { + formatDateTime, + formatKeyValue, + formatTable, +} from "../../core/format.ts"; +import { + fetchPullZoneHostnames, + hostnameUrl, + toSafeHostname, +} from "../../core/hostnames/index.ts"; +import { logger } from "../../core/logger.ts"; +import { spinner } from "../../core/ui.ts"; +import { + type SiteSelectorArgs, + selectSite, + sitePositionalBuilder, +} from "./interactive.ts"; +import { ROUTER_VERSION } from "./router/source.ts"; + +type ShowArgs = SiteSelectorArgs; + +export const sitesShowCommand = defineCommand({ + command: "show [site]", + describe: "Show a site's resources, domains, and current deploy.", + examples: [ + ["$0 sites show", "Show the linked site"], + ["$0 sites show my-site", "Show a specific site"], + ["$0 sites show --output json", "JSON output"], + ], + + builder: (yargs) => sitePositionalBuilder(yargs), + + handler: async ({ site: ref, link, profile, output, verbose, apiKey }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const { site, offerLink } = await selectSite(client, { + site: ref, + link, + output, + }); + const { state } = site; + + const spin = spinner("Fetching hostnames..."); + spin.start(); + let hostnames: Awaited> = []; + try { + hostnames = await fetchPullZoneHostnames(client, state.pullZoneId); + } catch { + // Hostnames are informational — a fetch failure shouldn't hide the site. + } finally { + spin.stop(); + } + + const routerOutdated = state.routerVersion < ROUTER_VERSION; + + if (output === "json") { + logger.log( + JSON.stringify( + { + ...state, + routerOutdated, + hostnames: hostnames.map(toSafeHostname), + }, + null, + 2, + ), + ); + return; + } + + const current = state.deploys.find((d) => d.id === state.current); + logger.log( + formatKeyValue( + [ + { key: "Site", value: state.name }, + { key: "Storage zone", value: String(state.storageZoneId) }, + { key: "Pull zone", value: String(state.pullZoneId) }, + { key: "Router script", value: String(state.scriptId) }, + { + key: "Router version", + value: `${state.routerVersion}${routerOutdated ? ` (v${ROUTER_VERSION} available)` : ""}`, + }, + { key: "Domain", value: state.domain ?? "—" }, + { key: "Current deploy", value: state.current ?? "—" }, + { + key: "Deployed", + value: current ? formatDateTime(current.createdAt) : "—", + }, + { key: "Previous deploy", value: state.previous ?? "—" }, + { key: "Deploys", value: String(state.deploys.length) }, + ], + output, + ), + ); + + if (hostnames.length > 0) { + logger.log(); + logger.log( + formatTable( + ["Domain", "Type", "SSL"], + hostnames.map((h) => [ + hostnameUrl(h.Value ?? "", { + hasCertificate: h.HasCertificate, + forceSSL: h.ForceSSL, + }), + h.IsSystemHostname ? "System" : "Custom", + h.HasCertificate ? "Yes" : "No", + ]), + output, + ), + ); + } + + if (routerOutdated) { + logger.log(); + logger.warn( + `A newer site router (v${ROUTER_VERSION}) is available. Run \`bunny sites upgrade\` to update.`, + ); + } + + await offerLink(); + }, +}); diff --git a/packages/cli/src/commands/sites/unlink.ts b/packages/cli/src/commands/sites/unlink.ts new file mode 100644 index 00000000..c0317fad --- /dev/null +++ b/packages/cli/src/commands/sites/unlink.ts @@ -0,0 +1,28 @@ +import { defineCommand } from "../../core/define-command.ts"; +import { logger } from "../../core/logger.ts"; +import { loadManifest, removeManifest } from "../../core/manifest.ts"; +import { SITES_MANIFEST, type SiteManifest } from "./constants.ts"; + +export const sitesUnlinkCommand = defineCommand({ + command: "unlink", + describe: "Unlink the current directory from its site.", + examples: [["$0 sites unlink", "Remove the .bunny/site.json link"]], + + handler: async ({ output }) => { + const manifest = loadManifest(SITES_MANIFEST); + removeManifest(SITES_MANIFEST); + + if (output === "json") { + logger.log(JSON.stringify({ unlinked: manifest.id ?? null }, null, 2)); + return; + } + + if (manifest.id) { + logger.success( + `Unlinked from ${manifest.name ?? manifest.id}. The site itself was not touched.`, + ); + } else { + logger.info("This directory is not linked to a site."); + } + }, +}); diff --git a/packages/cli/src/commands/sites/upgrade.ts b/packages/cli/src/commands/sites/upgrade.ts new file mode 100644 index 00000000..8965748f --- /dev/null +++ b/packages/cli/src/commands/sites/upgrade.ts @@ -0,0 +1,113 @@ +import { + createComputeClient, + createCoreClient, +} from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../config/index.ts"; +import { clientOptions } from "../../core/client-options.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { logger } from "../../core/logger.ts"; +import { spinner } from "../../core/ui.ts"; +import { writeRemoteState } from "./api.ts"; +import { + type SiteSelectorArgs, + selectSite, + sitePositionalBuilder, +} from "./interactive.ts"; +import { ROUTER_VERSION, routerSource } from "./router/source.ts"; + +interface UpgradeArgs extends SiteSelectorArgs { + force?: boolean; +} + +/** + * Republish the site's router script at the CLI's current version. Deploys and + * env vars are untouched — only the router code changes. + */ +export const sitesUpgradeCommand = defineCommand({ + command: "upgrade [site]", + describe: "Upgrade a site's router script to the latest version.", + examples: [ + ["$0 sites upgrade", "Upgrade the linked site's router"], + ["$0 sites upgrade my-site --force", "Republish even when up to date"], + ], + + builder: (yargs) => + sitePositionalBuilder(yargs).option("force", { + alias: "f", + type: "boolean", + describe: "Republish the router even if it's already current", + }), + + handler: async (args) => { + const { profile, output, verbose, apiKey } = args; + const config = resolveConfig(profile, apiKey, verbose); + const options = clientOptions(config, verbose); + const coreClient = createCoreClient(options); + const computeClient = createComputeClient(options); + + const { site, offerLink } = await selectSite(coreClient, { + site: args.site, + link: args.link, + output, + }); + const { state, connection, etag } = site; + + if (state.routerVersion >= ROUTER_VERSION && !args.force) { + if (output === "json") { + logger.log( + JSON.stringify( + { + site: state.name, + routerVersion: state.routerVersion, + upgraded: false, + }, + null, + 2, + ), + ); + return; + } + logger.info( + `Router is already at v${state.routerVersion} — nothing to upgrade.`, + ); + return; + } + + const from = state.routerVersion; + const spin = spinner(`Upgrading router v${from} → v${ROUTER_VERSION}...`); + spin.start(); + try { + await computeClient.POST("/compute/script/{id}/code", { + params: { path: { id: state.scriptId } }, + body: { Code: routerSource() }, + }); + await computeClient.POST("/compute/script/{id}/publish", { + params: { path: { id: state.scriptId, uuid: null } }, + body: {}, + }); + state.routerVersion = ROUTER_VERSION; + await writeRemoteState(connection, state, etag); + } finally { + spin.stop(); + } + + if (output === "json") { + logger.log( + JSON.stringify( + { + site: state.name, + routerVersion: ROUTER_VERSION, + upgraded: true, + }, + null, + 2, + ), + ); + return; + } + + logger.success(`Router upgraded to v${ROUTER_VERSION}.`); + + await offerLink(); + }, +}); diff --git a/packages/cli/src/commands/sites/uploader.test.ts b/packages/cli/src/commands/sites/uploader.test.ts new file mode 100644 index 00000000..8c0df7a2 --- /dev/null +++ b/packages/cli/src/commands/sites/uploader.test.ts @@ -0,0 +1,100 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { StorageZone } from "../storage/files-api.ts"; +import { siteFiles } from "./api.ts"; +import { + collectFiles, + hashFiles, + shouldSkipEntry, + uploadDeploy, +} from "./uploader.ts"; + +const realUpload = siteFiles.upload; +afterEach(() => { + siteFiles.upload = realUpload; +}); + +const fakeConnection = {} as StorageZone; + +function tree(): string { + const dir = mkdtempSync(join(tmpdir(), "bunny-sites-upload-")); + Bun.write(join(dir, "index.html"), "

hi

"); + mkdirSync(join(dir, "assets")); + Bun.write(join(dir, "assets", "app.js"), "console.log(1)"); + Bun.write(join(dir, ".env"), "SECRET=1"); + mkdirSync(join(dir, ".git")); + Bun.write(join(dir, ".git", "config"), "x"); + mkdirSync(join(dir, "node_modules", "pkg"), { recursive: true }); + Bun.write(join(dir, "node_modules", "pkg", "index.js"), "x"); + return dir; +} + +test("shouldSkipEntry excludes dotfiles and node_modules", () => { + expect(shouldSkipEntry(".env")).toBe(true); + expect(shouldSkipEntry(".git")).toBe(true); + expect(shouldSkipEntry("node_modules")).toBe(true); + expect(shouldSkipEntry("index.html")).toBe(false); + expect(shouldSkipEntry("assets")).toBe(false); +}); + +test("collectFiles walks recursively, skipping excluded entries, sorted", () => { + const files = collectFiles(tree()); + expect(files.map((f) => f.path)).toEqual(["assets/app.js", "index.html"]); + expect(files[1]?.size).toBeGreaterThan(0); +}); + +test("hashFiles computes the content sha256", async () => { + const dir = tree(); + const [first] = await hashFiles( + collectFiles(dir).filter((f) => f.path === "index.html"), + ); + const expected = new Bun.CryptoHasher("sha256") + .update("

hi

") + .digest("hex"); + expect(first?.sha256).toBe(expected); +}); + +test("uploadDeploy targets deploys/{id}, sends checksums, and retries failures", async () => { + const dir = tree(); + const files = await hashFiles(collectFiles(dir)); + + const uploaded: Array<{ path: string; checksum?: string }> = []; + let failuresLeft = 1; + siteFiles.upload = async (_zone, path, _stream, options) => { + // First call fails once to exercise the retry path. + if (failuresLeft > 0) { + failuresLeft--; + throw new Error("transient"); + } + uploaded.push({ path, checksum: options?.sha256Checksum }); + }; + + await uploadDeploy(fakeConnection, "a1b2c3d4", files, { concurrency: 2 }); + + expect(uploaded.map((u) => u.path).sort()).toEqual([ + "deploys/a1b2c3d4/assets/app.js", + "deploys/a1b2c3d4/index.html", + ]); + for (const u of uploaded) { + expect(u.checksum).toMatch(/^[0-9A-F]{64}$/); + } +}); + +test("uploadDeploy rejects an empty file set", async () => { + await expect(uploadDeploy(fakeConnection, "a1b2c3d4", [])).rejects.toThrow( + "Nothing to upload", + ); +}); + +test("uploadDeploy surfaces an error after retries are exhausted", async () => { + const dir = tree(); + const files = await hashFiles(collectFiles(dir)); + siteFiles.upload = async () => { + throw new Error("permanent"); + }; + await expect(uploadDeploy(fakeConnection, "a1b2c3d4", files)).rejects.toThrow( + "permanent", + ); +}); diff --git a/packages/cli/src/commands/sites/uploader.ts b/packages/cli/src/commands/sites/uploader.ts new file mode 100644 index 00000000..a604134d --- /dev/null +++ b/packages/cli/src/commands/sites/uploader.ts @@ -0,0 +1,129 @@ +import { readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { UserError } from "../../core/errors.ts"; +import type { StorageZone } from "../storage/files-api.ts"; +import { siteFiles } from "./api.ts"; +import { deployPrefix } from "./constants.ts"; + +export interface LocalFile { + /** Posix-style path relative to the deploy root. */ + path: string; + absPath: string; + size: number; +} + +export interface HashedLocalFile extends LocalFile { + sha256: string; +} + +export const DEFAULT_UPLOAD_CONCURRENCY = 8; +const UPLOAD_ATTEMPTS = 3; + +/** Dotfiles/dirs and node_modules never ship — they're tooling, not site content. */ +export function shouldSkipEntry(name: string): boolean { + return name.startsWith(".") || name === "node_modules"; +} + +/** Recursively collect the files to deploy, sorted by path for determinism. */ +export function collectFiles(dir: string): LocalFile[] { + const files: LocalFile[] = []; + + const walk = (abs: string, rel: string) => { + for (const entry of readdirSync(abs, { withFileTypes: true })) { + if (shouldSkipEntry(entry.name)) continue; + const entryAbs = join(abs, entry.name); + const entryRel = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + walk(entryAbs, entryRel); + } else if (entry.isFile()) { + files.push({ + path: entryRel, + absPath: entryAbs, + size: statSync(entryAbs).size, + }); + } + // Sockets, FIFOs, and dangling symlinks are silently skipped. + } + }; + + walk(dir, ""); + return files.sort((a, b) => a.path.localeCompare(b.path)); +} + +/** Streaming SHA-256 for each file — feeds both the deploy ID and upload checksums. */ +export async function hashFiles( + files: LocalFile[], +): Promise { + const hashed: HashedLocalFile[] = []; + for (const file of files) { + const hasher = new Bun.CryptoHasher("sha256"); + for await (const chunk of Bun.file(file.absPath).stream()) { + hasher.update(chunk); + } + hashed.push({ ...file, sha256: hasher.digest("hex") }); + } + return hashed; +} + +async function withRetries(fn: () => Promise): Promise { + let lastErr: unknown; + for (let attempt = 0; attempt < UPLOAD_ATTEMPTS; attempt++) { + try { + return await fn(); + } catch (err) { + lastErr = err; + if (attempt < UPLOAD_ATTEMPTS - 1) { + await new Promise((r) => setTimeout(r, 250 * 2 ** attempt)); + } + } + } + throw lastErr; +} + +export interface UploadDeployOptions { + concurrency?: number; + onFileUploaded?: (done: number, total: number, file: HashedLocalFile) => void; +} + +/** + * Upload a deploy's files to `deploys/{id}/...` with bounded concurrency, + * per-file SHA-256 checksums (server-verified), and retry with backoff. + */ +export async function uploadDeploy( + connection: StorageZone, + deployId: string, + files: HashedLocalFile[], + opts?: UploadDeployOptions, +): Promise { + if (files.length === 0) { + throw new UserError("Nothing to upload — the deploy has no files."); + } + + const prefix = deployPrefix(deployId); + const concurrency = Math.max( + 1, + Math.min(opts?.concurrency ?? DEFAULT_UPLOAD_CONCURRENCY, files.length), + ); + + let next = 0; + let done = 0; + const worker = async () => { + while (true) { + const index = next++; + const file = files[index]; + if (!file) return; + await withRetries(() => + siteFiles.upload( + connection, + `${prefix}/${file.path}`, + Bun.file(file.absPath).stream(), + { sha256Checksum: file.sha256.toUpperCase() }, + ), + ); + done++; + opts?.onFileUploaded?.(done, files.length, file); + } + }; + + await Promise.all(Array.from({ length: concurrency }, worker)); +} diff --git a/packages/cli/src/core/hostnames/commands.ts b/packages/cli/src/core/hostnames/commands.ts index 9ccb3123..eb46e55c 100644 --- a/packages/cli/src/core/hostnames/commands.ts +++ b/packages/cli/src/core/hostnames/commands.ts @@ -8,6 +8,7 @@ import type { GlobalArgs } from "../types.ts"; import { confirm, isInteractive, spinner } from "../ui.ts"; import { addHostname, + type CoreClient, enableSsl, fetchPullZoneHostnames, hostnameUrl, @@ -26,6 +27,16 @@ export type HostnameResolver = ( args: GlobalArgs & Record, ) => Promise; +/** Context passed to the {@link HostnamesMountOptions.onAdded}/`onRemoved` hooks. */ +export interface HostnameHookContext { + coreClient: CoreClient; + pullZoneId: number; + hostname: string; + /** The zone's system hostname — the CNAME target (add only). */ + cnameTarget?: string; + args: GlobalArgs & Record; +} + export interface HostnamesMountOptions { /** Command breadcrumb used in examples and follow-up hints, e.g. "scripts domains". */ commandPath: string; @@ -45,6 +56,14 @@ export interface HostnamesMountOptions { describe?: string; /** Hidden namespace aliases (e.g. ["hostnames"]) — they work but stay out of help. */ hiddenAliases?: string[]; + /** + * Runs after a domain is added (before the SSL/DNS follow-up) — lets a + * resource attach companion hostnames or persist the domain. The hook must + * handle its own errors; a companion failure shouldn't fail the add. + */ + onAdded?: (ctx: HostnameHookContext) => Promise; + /** Runs after a domain is removed — the counterpart of {@link onAdded}. */ + onRemoved?: (ctx: HostnameHookContext) => Promise; } /** Echo back the targeting args the user passed so copy-paste follow-up hints keep the same scope. */ @@ -165,6 +184,16 @@ export function createHostnamesCommands( spin.stop(); + if (opts.onAdded) { + await opts.onAdded({ + coreClient, + pullZoneId, + hostname, + cnameTarget: systemHostname, + args: args as unknown as GlobalArgs & Record, + }); + } + let sslIssued = false; let sslError: string | undefined; if (requestSsl) { @@ -497,6 +526,15 @@ export function createHostnamesCommands( removeSpin.stop(); + if (opts.onRemoved) { + await opts.onRemoved({ + coreClient, + pullZoneId, + hostname, + args: args as unknown as GlobalArgs & Record, + }); + } + if (args.output === "json") { logger.log( JSON.stringify({ hostname, pullZoneId, removed: true }, null, 2), diff --git a/packages/cli/src/core/hostnames/index.ts b/packages/cli/src/core/hostnames/index.ts index fe2629fd..03af3ec0 100644 --- a/packages/cli/src/core/hostnames/index.ts +++ b/packages/cli/src/core/hostnames/index.ts @@ -21,6 +21,7 @@ export { } from "./client.ts"; export { createHostnamesCommands, + type HostnameHookContext, type HostnameResolver, type HostnamesMountOptions, } from "./commands.ts"; diff --git a/skills/bunny-cli/SKILL.md b/skills/bunny-cli/SKILL.md index 3d79a4e6..c4bb7331 100644 --- a/skills/bunny-cli/SKILL.md +++ b/skills/bunny-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: bunny-cli -description: Manage bunny.net resources from the command line (databases, DNS, Edge Scripts, authentication, and raw API requests). Use when working with bunny.net (pullzones, DNS zones/records, databases, storage, Edge Scripts, Magic Containers), invoking the `bunny` CLI, or making authenticated API calls to api.bunny.net. +description: Manage bunny.net resources from the command line (databases, DNS, Edge Scripts, static sites, authentication, and raw API requests). Use when working with bunny.net (pullzones, DNS zones/records, databases, storage, Edge Scripts, Magic Containers, static-site hosting/deploys), invoking the `bunny` CLI, or making authenticated API calls to api.bunny.net. --- # bunny.net CLI Skill @@ -46,6 +46,11 @@ bunny dns zones nameservers example.com # is the registrar delegat bunny dns records add example.com api A 198.51.100.1 bunny dns records preset google-workspace example.com # apply a preset record set bunny dns records list example.com + +# host a static site +bunny sites create my-site # provision (served at my-site.b-cdn.net) +bunny sites deploy ./dist # deploy + promote to production +bunny sites deployments publish --previous --force # instant rollback ``` ## Decision Tree @@ -56,6 +61,7 @@ Use this to route to the correct reference file: - **Database management (create, list, show, link, delete, shell, studio, regions, tokens)** -> `references/database.md` - **DNS (zones, delegation checks, records, presets, BIND import/export, DNSSEC, logging, Scriptable DNS scripts)** -> `references/dns.md` - **Edge Scripts (init, create, deploy, link, stats, deployments/rollback, env vars, custom domains)** -> `references/scripts.md` +- **Static sites (create, deploy, rollback, previews, custom domains, build-time env)** -> `references/sites.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 new file mode 100644 index 00000000..0bcd5b9e --- /dev/null +++ b/skills/bunny-cli/references/sites.md @@ -0,0 +1,151 @@ +# Static Sites Commands + +All site commands live under `bunny sites`. A site is one storage zone (files) + one pull zone (CDN) + one middleware router script, provisioned together by `sites create`. Deploys are immutable directories; promoting or rolling back flips a router env var and purges the cache — no files move, so it's instant. + +Most commands accept an optional site (a trailing `[site]` positional, or the `--site` flag on commands whose positionals are taken, like `deploy` and `env`). When omitted, the site resolves in this order: + +1. Explicit name or storage zone ID +2. `.bunny/site.json` manifest (written by `bunny sites link` or `bunny sites create`) +3. `sites.name` in `bunny.jsonc` +4. Interactive prompt (suppressed in `--output json` mode — pass a site or link the directory in CI) + +## Typical workflows + +```bash +# New site: provision, deploy, iterate +bunny sites create my-site # served at https://my-site.b-cdn.net +bunny sites deploy ./dist # uploads + promotes to production + +# Build-and-deploy in one step (build command from bunny.jsonc or the flag) +bunny sites deploy --build # runs `sites.build`, deploys `sites.dir` +bunny sites deploy ./out --build "npm run build" + +# Rollback +bunny sites deployments list # find the deploy ID (● Live marks production) +bunny sites deployments publish --previous --force # instant rollback +bunny sites deployments publish a1b2c3d4 --force # promote a specific deploy + +# Custom domain with per-deploy previews +bunny sites domains add example.com --wait # also attaches *.preview.example.com +# → production at https://example.com, previews at https://dpl-.preview.example.com +``` + +## Deploy IDs and previews + +- 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, served with `X-Robots-Tag: noindex`). +- Dotfiles and `node_modules` are never uploaded. + +--- + +## `bunny sites create` — Provision a site + +```bash +bunny sites create my-site +bunny sites create my-site --region NY +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 | +| `--link` | Link this directory (default true; `--no-link` to skip) | + +Site names become the storage zone, pull zone, and `.b-cdn.net` subdomain: 3–60 lowercase letters, digits, and dashes. Creation is idempotent — a failed create re-runs cleanly, reusing whatever was already provisioned. + +--- + +## `bunny sites deploy` — Deploy a directory + +```bash +bunny sites deploy ./dist +bunny sites deploy ./dist --no-promote # upload only; publish later +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 uses `sites.build` from bunny.jsonc) | +| `--env` | Build-time env override `KEY=VALUE` (repeatable; requires `--build`) | +| `--env-file` | Dotenv file of build-time overrides (requires `--build`) | +| `--no-promote` | Upload without pointing production at it | +| `--force` | Deploy even when content is unchanged | +| `--site` | Target site (name or storage zone ID) | + +With `--build`, the site's remote env (`sites env`) is merged with `--env`/`--env-file` overrides into the build's environment, and the env fingerprint is recorded on the deploy. + +--- + +## `bunny sites deployments` — List, publish, prune + +```bash +bunny sites deployments list +bunny sites deployments publish a1b2c3d4 # confirm prompt; --force to skip +bunny sites deployments publish --previous # instant rollback +bunny sites deployments prune --keep 10 # never prunes current/previous +``` + +`publish` (alias `promote`) flips production to a past deploy — the files are already on the CDN, so this is instant plus a cache purge. + +--- + +## `bunny sites domains` — Custom domains + +```bash +bunny sites domains add example.com --wait # wait for DNS, then issue SSL +bunny sites domains ssl example.com # issue a certificate later +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. + +--- + +## `bunny sites env` — Build-time environment variables + +```bash +bunny sites env set VITE_API_URL "https://api.example.com" +bunny sites env list # values masked; --show reveals +bunny sites env remove VITE_API_URL +bunny sites env pull .env.local --force +``` + +**These are build-time values, not a secret store** — anything the build reads can end up in the shipped bundle. They are stored inside the site's storage zone (never served) and merged into `sites deploy --build` runs. + +--- + +## `bunny sites` lifecycle and maintenance + +```bash +bunny sites list +bunny sites show # resources, domains, current deploy; warns on old router +bunny sites link my-site # .bunny/site.json +bunny sites unlink +bunny sites upgrade # republish the router at the CLI's latest version +bunny sites delete my-site # typed-name confirmation; --keep-storage keeps files +``` + +## `bunny.jsonc` integration + +An optional `sites` block configures the deploy defaults (validated on its own, so a sites-only file works without an `app` block): + +```jsonc +{ + "sites": { + "name": "my-site", // resolves the site when nothing is linked + "dir": "./dist", // default deploy directory + "build": "npm run build", // command for `deploy --build` + }, +} +``` + +## CI / agents + +- Pass `--force` on anything with a confirmation (publish, prune, remove, delete). +- Pass the site explicitly (or commit `bunny.jsonc` with `sites.name`) — the interactive picker is disabled under `--output json`. +- `--output json` on every command emits machine-readable results (deploy prints `{ id, production, preview, promoted }`). From a35d6b566b34cdf6cb42b98e12b72e2316d9ddc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 15:23:35 +0000 Subject: [PATCH 03/24] fix(sites): handle the paginated /pullzone response envelope The live API returns GET /pullzone as { Items, CurrentPage, HasMoreItems } for some queries (e.g. ?search=) even though the OpenAPI spec types the response as a plain array, so createSite crashed on .find during pull zone lookup. Route all pull zone listing through fetchPullZones, which accepts both shapes and follows HasMoreItems across pages (with an empty-page guard). /storagezone?search= genuinely returns a plain array, so the storage lookup is unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01J6CzqnCzQrV6exqx4PrF13 --- packages/cli/src/commands/sites/api.test.ts | 69 ++++++++++++++++++++- packages/cli/src/commands/sites/api.ts | 54 ++++++++++++++-- 2 files changed, 115 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index 7ad583f1..72fbd1f6 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -90,13 +90,22 @@ function fakeCoreClient(opts: { calls: Call[]; storageZones?: StorageZoneModel[]; pullZones?: Array>; + /** + * Return GET /pullzone as the live API's paginated envelope + * (`{ Items, CurrentPage, HasMoreItems }`) instead of the plain array + * the spec documents. `pageSize` splits the items across pages. + */ + pullZoneEnvelope?: boolean; + pageSize?: number; }): CoreClient { const zones = opts.storageZones ?? []; const pullZones = opts.pullZones ?? []; return { GET: async ( path: string, - options?: { params?: { path?: { id?: number }; query?: unknown } }, + options?: { + params?: { path?: { id?: number }; query?: { page?: number } }; + }, ) => { opts.calls.push({ method: "GET", path, params: options?.params }); if (path === "/storagezone") return { data: zones }; @@ -104,7 +113,20 @@ function fakeCoreClient(opts: { const zone = zones.find((z) => z.Id === options?.params?.path?.id); return { data: zone }; } - if (path === "/pullzone") return { data: pullZones }; + if (path === "/pullzone") { + if (!opts.pullZoneEnvelope) return { data: pullZones }; + const pageSize = opts.pageSize ?? Math.max(pullZones.length, 1); + const page = options?.params?.query?.page ?? 0; + const start = page * pageSize; + return { + data: { + Items: pullZones.slice(start, start + pageSize), + CurrentPage: page, + TotalItems: pullZones.length, + HasMoreItems: start + pageSize < pullZones.length, + }, + }; + } throw new Error(`unexpected GET ${path}`); }, POST: async ( @@ -393,3 +415,46 @@ test("fetchSites keeps only middleware+storage pull zones with matching state", test("siteContextFromZone is null for a zone without site state", async () => { expect(await siteContextFromZone(ZONE)).toBeNull(); }); + +// Regression: the live API returns GET /pullzone as a paginated envelope +// ({ Items, CurrentPage, HasMoreItems }) — the spec's plain array is a lie +// for some queries (e.g. ?search=). createSite crashed on `.find` here. + +test("createSite handles the paginated /pullzone envelope", async () => { + const coreClient = fakeCoreClient({ calls: [], pullZoneEnvelope: true }); + const computeClient = fakeComputeClient({ calls: [] }); + + const result = await createSite({ + coreClient, + computeClient, + name: "my-site", + region: "DE", + }); + + expect(result.state.pullZoneId).toBe(30); + expect(result.reused.pullZone).toBe(false); +}); + +test("fetchSites pages through the /pullzone envelope", async () => { + store.set(REMOTE_STATE_PATH, JSON.stringify(fakeState())); + const coreClient = fakeCoreClient({ + calls: [], + storageZones: [ZONE], + pullZones: [ + { Id: 31, Name: "not-a-site", StorageZoneId: 10 }, + { + Id: 30, + Name: "my-site", + MiddlewareScriptId: 20, + StorageZoneId: 10, + Hostnames: [{ IsSystemHostname: true, Value: "my-site.b-cdn.net" }], + }, + ], + pullZoneEnvelope: true, + pageSize: 1, // force a second page + }); + + const sites = await fetchSites(coreClient); + expect(sites).toHaveLength(1); + expect(sites[0]?.state.name).toBe("my-site"); +}); diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index b9db4b04..598f59b4 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -181,6 +181,52 @@ async function mapWithConcurrency( return results; } +const PULL_ZONE_PAGE_SIZE = 1000; + +interface PullZonePage { + Items?: PullZone[]; + CurrentPage?: number; + HasMoreItems?: boolean; +} + +/** + * List pull zones, tolerating both response shapes: the plain array the + * OpenAPI spec documents, and the `{ Items, CurrentPage, HasMoreItems }` + * envelope the live API actually returns for some queries (e.g. `search`). + */ +async function fetchPullZones( + client: CoreClient, + search?: string, +): Promise { + const all: PullZone[] = []; + let page: number | undefined; + while (true) { + const { data } = await client.GET("/pullzone", { + params: { + query: { + ...(search !== undefined ? { search } : {}), + ...(page !== undefined ? { page } : {}), + perPage: PULL_ZONE_PAGE_SIZE, + }, + }, + }); + + const raw = data as unknown; + if (Array.isArray(raw)) { + // A plain array is the complete result set. + all.push(...(raw as PullZone[])); + return all; + } + + const envelope = (raw ?? {}) as PullZonePage; + const items = envelope.Items ?? []; + all.push(...items); + // Empty page guards against a server that never clears HasMoreItems. + if (!envelope.HasMoreItems || items.length === 0) return all; + page = (envelope.CurrentPage ?? 0) + 1; + } +} + /** * Discover the account's sites. * @@ -189,8 +235,7 @@ async function mapWithConcurrency( * the candidates; only those get the per-zone state read. */ export async function fetchSites(client: CoreClient): Promise { - const { data } = await client.GET("/pullzone"); - const candidates = (data ?? []).filter( + const candidates = (await fetchPullZones(client)).filter( (pz: PullZone) => pz.MiddlewareScriptId != null && pz.StorageZoneId != null, ); @@ -238,10 +283,7 @@ async function findPullZoneByName( client: CoreClient, name: string, ): Promise { - const { data } = await client.GET("/pullzone", { - params: { query: { search: name } }, - }); - return (data ?? []).find( + return (await fetchPullZones(client, name)).find( (pz) => (pz.Name ?? "").toLowerCase() === name.toLowerCase(), ); } From b72795f5f3345f4dde1ad9cab686e9adf1be34c1 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Mon, 13 Jul 2026 21:29:45 +0100 Subject: [PATCH 04/24] continue sites --- .changeset/sites-namespace.md | 2 +- AGENTS.md | 35 ++- README.md | 5 +- docs/plans/deploy-site-action.md | 174 ++++++++++++ docs/plans/sites-github.md | 166 +++++++++++ docs/plans/sites.md | 10 +- packages/cli/src/commands/sites/api.test.ts | 45 +-- packages/cli/src/commands/sites/api.ts | 56 ++-- packages/cli/src/commands/sites/build.test.ts | 15 +- packages/cli/src/commands/sites/build.ts | 7 - .../src/commands/sites/ci/frameworks.test.ts | 111 ++++++++ .../cli/src/commands/sites/ci/frameworks.ts | 258 ++++++++++++++++++ packages/cli/src/commands/sites/ci/index.ts | 8 + packages/cli/src/commands/sites/ci/init.ts | 115 ++++++++ .../cli/src/commands/sites/ci/scaffold.ts | 196 +++++++++++++ .../src/commands/sites/ci/workflow.test.ts | 112 ++++++++ .../cli/src/commands/sites/ci/workflow.ts | 149 ++++++++++ packages/cli/src/commands/sites/constants.ts | 4 +- packages/cli/src/commands/sites/create.ts | 166 ++++++++--- packages/cli/src/commands/sites/deploy.ts | 192 +++++++------ .../cli/src/commands/sites/domains/index.ts | 1 - packages/cli/src/commands/sites/env/index.ts | 16 -- packages/cli/src/commands/sites/env/list.ts | 89 ------ .../cli/src/commands/sites/env/pull.test.ts | 25 -- packages/cli/src/commands/sites/env/pull.ts | 105 ------- packages/cli/src/commands/sites/env/remove.ts | 92 ------- packages/cli/src/commands/sites/env/set.ts | 108 -------- packages/cli/src/commands/sites/index.ts | 4 +- .../cli/src/core/hostnames/bunny-dns.test.ts | 1 + packages/cli/src/core/hostnames/bunny-dns.ts | 9 +- packages/cli/src/core/hostnames/flow.ts | 9 +- skills/bunny-cli/SKILL.md | 4 +- skills/bunny-cli/references/sites.md | 35 +-- 33 files changed, 1639 insertions(+), 685 deletions(-) create mode 100644 docs/plans/deploy-site-action.md create mode 100644 docs/plans/sites-github.md create mode 100644 packages/cli/src/commands/sites/ci/frameworks.test.ts create mode 100644 packages/cli/src/commands/sites/ci/frameworks.ts create mode 100644 packages/cli/src/commands/sites/ci/index.ts create mode 100644 packages/cli/src/commands/sites/ci/init.ts create mode 100644 packages/cli/src/commands/sites/ci/scaffold.ts create mode 100644 packages/cli/src/commands/sites/ci/workflow.test.ts create mode 100644 packages/cli/src/commands/sites/ci/workflow.ts delete mode 100644 packages/cli/src/commands/sites/env/index.ts delete mode 100644 packages/cli/src/commands/sites/env/list.ts delete mode 100644 packages/cli/src/commands/sites/env/pull.test.ts delete mode 100644 packages/cli/src/commands/sites/env/pull.ts delete mode 100644 packages/cli/src/commands/sites/env/remove.ts delete mode 100644 packages/cli/src/commands/sites/env/set.ts diff --git a/.changeset/sites-namespace.md b/.changeset/sites-namespace.md index 1154ef58..2c774ac0 100644 --- a/.changeset/sites-namespace.md +++ b/.changeset/sites-namespace.md @@ -2,4 +2,4 @@ "@bunny.net/cli": minor --- -feat(sites): new `bunny sites` namespace for static-site hosting. `sites create` provisions a storage zone + pull zone + middleware router per site; `sites deploy` uploads immutable deploys (git-sha or content-hash IDs, no-op when unchanged) and promotes by flipping the router's `CURRENT_DEPLOY` env var + purging the cache; `sites deployments list/publish/prune` cover rollback and cleanup; `sites domains` attaches custom domains plus a `*.preview.` wildcard for per-deploy preview URLs; `sites env` manages build-time variables merged into `sites deploy --build`; `sites link/unlink/show/upgrade/delete` round out the lifecycle. Site state lives at `_bunny/site.json` inside the storage zone (403-blocked by the router). The shared hostnames factory gains optional `onAdded`/`onRemoved` hooks. +feat(sites): new `bunny sites` namespace for static-site hosting. `sites create` provisions a storage zone + pull zone + middleware router per site, prompting for the name (directory-name suggestion) and a custom domain when run interactively (Bunny DNS record, nameserver guidance, DNS wait + SSL); `sites deploy` uploads immutable deploys (git-sha or content-hash IDs, no-op when unchanged) to preview URLs, and `--production`/`--prod` publishes the live site by flipping the router's `CURRENT_DEPLOY` env var + purging the cache; `sites deployments list/publish/prune` cover rollback and cleanup; `sites domains` attaches custom domains plus a `*.preview.` wildcard for per-deploy preview URLs; `sites ci init` (also offered by `sites create` on GitHub repos) scaffolds a GitHub Actions workflow with framework detection: previews on PRs, production on merges to main; `sites link/unlink/show/upgrade/delete` round out the lifecycle. Concurrent deploys merge remote state records instead of overwriting. Site state lives at `_bunny/site.json` inside the storage zone (403-blocked by the router). The shared hostnames factory gains optional `onAdded`/`onRemoved` hooks. diff --git a/AGENTS.md b/AGENTS.md index ed2d1a23..7f7ccd2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -374,11 +374,11 @@ bunny-cli/ │ │ │ ├── download.ts # Download a file to disk ( positional, --zone, --out) │ │ │ └── remove.ts # Delete a file or directory (alias: rm; positional, --zone, trailing slash = recursive) │ │ ├── sites/ # Experimental (hidden from help and landing page) — static-site hosting (storage zone + pull zone + middleware router) -│ │ │ ├── index.ts # defineNamespace("sites", ...): create/list/show/deploy/deployments/domains/env/link/unlink/upgrade/delete -│ │ │ ├── constants.ts # SITES_MANIFEST (.bunny/site.json), REMOTE_STATE_PATH/REMOTE_ENV_PATH (_bunny/*.json), RemoteSiteState/DeployRecord types, parseRemoteState (shape-checked; null = not a site), previewHostname/previewWildcard/deployPrefix helpers, deploy-ID + site-name validators +│ │ │ ├── index.ts # defineNamespace("sites", ...): create/list/show/deploy/deployments/domains/link/unlink/upgrade/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 │ │ │ ├── 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: warn + overwrite on mismatch), remote env read/write, 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), promoteDeploy (env var PUT + purgeCache POST), 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, env tolerance, createSite fresh/resume/already-exists, promote, fetchSites filtering +│ │ │ ├── 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), 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), promoteDeploy (env var PUT + purgeCache POST), 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 → .bunny/site.json → bunny.jsonc sites.name → picker (offerLink like scripts); siteOptionBuilder (--site) + sitePositionalBuilder ([site]) │ │ │ ├── config.ts # loadSiteConfig: lenient bunny.jsonc reader — validates ONLY the `sites` block (SiteConfigSchema from @bunny.net/app-config), so sites-only configs work without an `app` block │ │ │ ├── router/source.ts # ROUTER_VERSION + routerSource(): the middleware Edge Script attached to the pull zone. Host → deploy dir mapping (apex → CURRENT_DEPLOY, dpl-{id}.preview.* → that deploy), /_bunny/* → 403, /deploys/* passthrough (path previews), trailing-slash → index.html, X-Robots-Tag: noindex on preview hosts @@ -386,19 +386,19 @@ bunny-cli/ │ │ │ ├── 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 │ │ │ ├── uploader.test.ts # Walk/skip/hash tests + upload paths/checksums/retry via siteFiles swap -│ │ │ ├── build.ts # parseEnvAssignments/parseEnvFile/envHash + runBuildCommand (Bun.spawn shell, merged env, throws on non-zero exit) -│ │ │ ├── build.test.ts # Env parsing + hash stability + real build spawn success/failure -│ │ │ ├── create.ts # bunny sites create : createSite + manifest link + optional --domain via setupSiteDomain (domain failure warns, never fails the create) +│ │ │ ├── build.ts # parseEnvAssignments/parseEnvFile + 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] (prompted, directory-name suggestion): createSite + 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/SSL table + router-outdated warning -│ │ │ ├── deploy.ts # bunny sites deploy [dir]: optional build → hash → no-op if unchanged → upload deploys/{id}/ → state update → promote unless --no-promote → production/preview URLs +│ │ │ ├── deploy.ts # bunny sites deploy [dir]: optional build → hash → no-op if unchanged → upload deploys/{id}/ → state update → preview URL, or publish live with --production/--prod │ │ │ ├── link.ts # Link directory to a site (.bunny/site.json) │ │ │ ├── unlink.ts # Remove .bunny/site.json │ │ │ ├── upgrade.ts # Republish the router at ROUTER_VERSION + bump state.routerVersion │ │ │ ├── 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), scaffold.ts (git helpers, scaffoldSitesWorkflow, 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 (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 -│ │ │ └── env/ # set/list/remove/pull over _bunny/env.json (masked list unless --show; pull refuses to overwrite without --force) + pull.test.ts (toDotenv quoting) +│ │ │ └── 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 │ │ ├── registries/ │ │ │ ├── index.ts # Manual CommandModule (not defineNamespace) — default handler runs list │ │ │ ├── list.ts # List container registries @@ -1087,12 +1087,12 @@ bunny │ Show usage statistics (requests/CPU/cost totals + bar chart; defaults to last 30 days). No ID → linked script → interactive picker (offers to link; --no-link skips). JSON output skips the picker and errors. ├── sites (experimental — hidden from help and landing page) │ │ Static-site hosting: one storage zone (files) + one pull zone (CDN) + one middleware router script per site. Deploys are immutable directories (`deploys/{id}/`); promote/rollback flips the router's CURRENT_DEPLOY env var + purges the cache — no files move. 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). -│ ├── create [--region] [--domain] [--link] -│ │ Provision a site (idempotent — a failed create re-runs cleanly; each resource is looked up by name first). --domain also attaches *.preview. for per-deploy previews. +│ ├── create [name] [--region] [--domain] [--link] +│ │ Provision a site (idempotent — a failed create re-runs cleanly; each resource is looked up by name first). Interactive runs prompt for the name when omitted (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] Show resources, domains (with SSL state), current deploy; warns when a newer router is available -│ ├── deploy [dir] [--site] [--build [cmd]] [--env K=V] [--env-file] [--no-promote] [--force] -│ │ Deploy a directory: git short-sha ID when the tree is clean, content hash otherwise; identical IDs are no-ops. [dir] defaults to `sites.dir` in bunny.jsonc, then cwd (dotfiles + node_modules excluded). --build runs the command (or `sites.build`) with remote env + overrides merged. Prints production + preview URLs. +│ ├── deploy [dir] [--site] [--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). [dir] defaults to `sites.dir` in bunny.jsonc, then cwd (dotfiles + node_modules excluded). --build runs the command (or `sites.build`) in the caller's environment plus --env/--env-file overrides. --production/--prod publishes the deploy as the live site. │ ├── deployments │ │ ├── list [site] (alias: ls) List deploys (● Live / ○ Previous markers, created, source, files, size) │ │ ├── publish [id] [--previous] [--site] [--force] (alias: promote) @@ -1103,11 +1103,8 @@ bunny │ │ ├── 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) -│ ├── env Build-time env stored at `_bunny/env.json` (NOT runtime env, NOT a secret store — values are baked into build output) -│ │ ├── set [name] [value] [--site] Set a variable (prompts when omitted) -│ │ ├── list [--show] [--site] List variables (masked unless --show) -│ │ ├── remove [--site] [--force] Remove a variable (alias: rm) -│ │ └── pull [file] [--site] [--force] Write env to a dotenv file (default .env; refuses to overwrite) +│ ├── ci +│ │ └── init [--site] [--framework] [--force] Write .github/workflows/bunny-sites.yml: 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` │ ├── link [site] Link this directory to a site → .bunny/site.json │ ├── unlink Remove .bunny/site.json │ ├── upgrade [site] [--force] Republish the router script at the CLI's ROUTER_VERSION diff --git a/README.md b/README.md index c967f195..46af94a7 100644 --- a/README.md +++ b/README.md @@ -56,12 +56,13 @@ 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) -bun ny sites deploy ./dist # deploy a directory and promote it to production +bun ny sites deploy ./dist # deploy a directory to a preview URL +bun ny sites deploy ./dist --production # deploy and publish as the live site (--prod works too) bun ny sites deploy --build # run `sites.build` from bunny.jsonc, then deploy `sites.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 domains add example.com # attach a custom domain (+ *.preview.example.com for previews) -bun ny sites env set VITE_API_URL https://api.example.com # build-time env (not a secret store) +bun ny sites ci init # add a GitHub Actions workflow (preview on PRs, production on main) ``` ### Available Scripts diff --git a/docs/plans/deploy-site-action.md b/docs/plans/deploy-site-action.md new file mode 100644 index 00000000..aa65ec20 --- /dev/null +++ b/docs/plans/deploy-site-action.md @@ -0,0 +1,174 @@ +# deploy-site action: implementation plan (hand to an agent) + +Build a new `deploy-site` action in the https://github.com/BunnyWay/actions repository. It wraps the `@bunny.net/cli` `sites deploy` command so a workflow can deploy a static site to bunny.net with one `uses:` step, and it owns the PR preview comment. This plan is self-contained; the companion design doc is `docs/plans/sites-github.md` in the CLI repo. + +## What it must do + +- Run `sites deploy` against a built directory: preview by default, production when asked. +- Parse the CLI's JSON output into action outputs. +- On `pull_request` events, upsert one sticky comment on the PR with the preview URL, updated per commit. +- Never reimplement deploy logic against the bunny API: the CLI is the single deploy path. + +## Target repo conventions (observed, follow them) + +- pnpm workspace monorepo; one folder per action (`deploy-script/`, `container-update-image/`). +- node20 JavaScript actions written in TypeScript under `/src/`, bundled with ncc into `/.lib-action/index.js`; `action.yml` points `runs.main` there. +- jest for tests, eslint config per action, `README.md` + `CHANGELOG.md` per action. +- Versioning via changesets (`pnpm changeset`); releases tag both `deploy-script@0.5.0` and `deploy-script_0.5.0` styles. The underscore tag is what `uses:` references should use (no double `@`). +- Branding block in `action.yml` (orange, an icon). + +Start by copying the `deploy-script/` folder layout and its build/test wiring. + +## action.yml + +```yaml +name: Deploy Site to Bunny +author: Bunny Devs +description: Deploy a static site to bunny.net with preview URLs per deploy. + +inputs: + site: + description: Site name or storage zone ID. + required: true + directory: + description: Built output directory to deploy (e.g. dist). + required: true + api_key: + description: bunny.net API key (store it as a repository secret). + required: true + production: + description: Publish this deploy as the live site ("true"/"false", default preview only). + required: false + default: "false" + comment: + description: Upsert a sticky PR comment with the preview URL on pull_request events. + required: false + default: "true" + github_token: + description: Token for the PR comment (needs pull-requests write). + required: false + default: ${{ github.token }} + cli_version: + description: "@bunny.net/cli version range to run (pin bumped per action release)." + required: false + default: "0.10" + force: + description: Redeploy even when content is unchanged. + required: false + default: "false" + +outputs: + deploy-id: + description: The deploy ID (git short sha on clean checkouts). + preview-url: + description: Preview URL for this deploy (empty when the site has no host yet). + production-url: + description: Production URL (set when production input was true). + unchanged: + description: '"true" when the content was already deployed and nothing ran.' + +runs: + using: "node20" + main: ".lib-action/index.js" + +branding: + color: "orange" + icon: "upload-cloud" +``` + +## The CLI contract + +Invocation (spawn, do not shell-interpolate user input): + +``` +npx --yes @bunny.net/cli@ sites deploy --site [--production] [--force] --output json +``` + +- Env: pass through `process.env` plus `BUNNY_API_KEY` from the `api_key` input. Call `core.setSecret(apiKey)` first. +- The published CLI ships per-platform compiled binaries behind a Node launcher, so `npx` works on GitHub runners without installing Bun. Primary target is `ubuntu-latest`; macOS works; verify Windows binary availability before claiming support in the README (document ubuntu/macos only if not). +- JSON goes to stdout; human/progress output and errors go to stderr. Parse stdout only. Non-zero exit means the deploy failed: fail the action with the stderr tail. +- Output shapes (both must be handled): + - Deployed: `{ "site": string, "id": string, "source": "git"|"content", "files": number, "bytes": number, "promoted": boolean, "production": string|null, "preview": string|null }` + - No-op (content already deployed): `{ "site": string, "id": string, "unchanged": true, "live": boolean, "production": string|null, "preview": string|null }` +- `production`/`preview` are full URLs or null. Map null to empty-string outputs. + +## Main logic (src/main.ts) + +1. Read and validate inputs (`site`, `directory` non-empty; `directory` must exist: fail early with a clear message). +2. `core.setSecret(api_key)`; spawn the CLI via `@actions/exec` with `listeners.stdout`/`stderr` capture and `env: { ...process.env, BUNNY_API_KEY: apiKey }`. +3. On non-zero exit: `core.setFailed` with the last ~20 lines of stderr; do not attempt the comment. +4. Parse stdout JSON (tolerate leading noise by taking the substring from the first `{`; the CLI keeps stdout clean, this is belt and braces). Set the four outputs; also write a job summary line via `core.summary` (nice, cheap). +5. Comment step, only when all of: `comment` input is true, `context.eventName === "pull_request"`, and a preview URL exists. + - Marker: `` as the first line of the comment body. This exact format matters: the CLI repo's future GitHub App takes over the same marker. + - Upsert: `octokit.paginate(rest.issues.listComments, { issue_number })`, find a comment whose body starts with the marker, then `updateComment` or `createComment`. + - Body: + + ``` + + **bunny.net** deployed a preview of `my-site` + + | Deploy | Preview | Updated | + | ------ | ------- | ------- | + | `a1b2c3d4` | https://dpl-a1b2c3d4.preview.example.com | 2026-07-13 14:02 UTC | + ``` + + - Comment failures are `core.warning`, never a job failure (the deploy already succeeded). + +6. No GitHub Deployments API calls in this version (that is the planned V2; leave the module boundary so it can slot in). + +## Tests (jest, mirror deploy-script's setup) + +- JSON parsing: both output shapes, null URLs, garbage stdout fails cleanly. +- Exec wiring: mocked `@actions/exec` asserts argv (`--production` only when input true, `--force` only when true) and that `BUNNY_API_KEY` is in the env. +- Comment upsert: mocked octokit asserts create-when-absent, update-when-marker-found, and skip on non-PR events / `comment: false` / missing preview URL. +- Failure path: non-zero exit sets failed and skips the comment. + +## README.md for the action + +Include: the minimal example below, the input/output tables, the fork-PR note, and the secret setup (`gh secret set BUNNY_API_KEY`). + +```yaml +name: Deploy site +on: + push: + branches: [main] + pull_request: + +concurrency: + group: bunny-sites-${{ github.ref }} + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bun run build + - uses: BunnyWay/actions/deploy-site@deploy-site_1.0.0 + with: + site: my-site + directory: dist + production: ${{ github.event_name == 'push' }} + api_key: ${{ secrets.BUNNY_API_KEY }} +``` + +Also link to the CLI repo's framework examples and mention `bunny sites ci init` scaffolds this file. + +## Release + ordering + +1. Land the action with a changeset; release as `deploy-site@1.0.0` / tag `deploy-site_1.0.0` via the repo's existing release process. +2. Update the repo root README's action list. +3. Ordering with the CLI: the CLI's `sites ci init` scaffolds workflows referencing `BunnyWay/actions/deploy-site@deploy-site_1.0.0` (constant `DEPLOY_SITE_ACTION` in `packages/cli/src/commands/sites/ci/workflow.ts`). Publish the action before (or together with) the CLI release that ships `sites ci init`, and keep the `cli_version` default pinned to the CLI minor that includes `bunny sites` (0.10 at time of writing; bump on later releases). + +## Guardrails + +- Never log the API key; `core.setSecret` before any exec. +- Spawn with an argv array (no shell), inputs never string-concatenated into a command line. +- The action must work when the PR has no custom domain (path-based preview URL) and when the site has no hostname at all (preview null: skip the comment, still set outputs). +- Keep the action deploy-only: no purge/publish/rollback inputs in 1.0 (open question in the design doc). diff --git a/docs/plans/sites-github.md b/docs/plans/sites-github.md new file mode 100644 index 00000000..b4c62a9e --- /dev/null +++ b/docs/plans/sites-github.md @@ -0,0 +1,166 @@ +# Sites + GitHub: preview deploys on PRs, production on main (plan) + +Status: V1 CLI side (scaffolder, framework detection, state-merge hardening) is implemented on the sites branch; the action itself is planned in `docs/plans/deploy-site-action.md`. Companion to `docs/plans/sites.md`. + +## Goal + +- Open a PR (or push a new commit to one): the site is built and deployed to a preview URL. +- Merge to `main`: the site is built, deployed, and published as the live site. +- The PR gets a comment with the preview URL, updated per commit. +- No bespoke "deployments API" for the workflow to call, and no bunny-hosted service in V1. The workflow builds and runs the CLI; the CLI talks to the same storage/core/compute APIs it uses today. + +## V1: a `deploy-site` action + example workflows per framework, nothing hosted + +V1 has two deliverables and no hosted pieces: + +1. **`BunnyWay/actions/deploy-site`**: a small action in the existing [BunnyWay/actions](https://github.com/BunnyWay/actions) repo that wraps `bunny sites deploy` and owns the PR comment. +2. **Per-framework example workflows** (Jekyll, Astro, React Router, ...) that are just "build, then use the action". + +Everything the flow needs already exists in the CLI: + +- Preview is the deploy default; `--production` publishes. That maps 1:1 onto `pull_request` vs `push` to `main`. +- Deploy IDs are git short shas on clean checkouts (CI is always clean), so each commit gets a stable preview URL. +- `deploy --output json` emits `{ id, production, preview, promoted, unchanged }`, which is the action's entire contract with the CLI. +- The site is named explicitly (`site` input / `--site` flag); nothing needs to be committed to the repo and the interactive picker never enters CI. (`bunny.jsonc` is experimental and deliberately not part of this flow.) +- The published CLI ships compiled per-platform binaries behind a Node launcher, so `npx @bunny.net/cli` works on any runner with Node, no Bun install needed. Jekyll/Hugo/Ruby workflows do not have to pull in a JS toolchain beyond that. + +### The `deploy-site` action (BunnyWay/actions) + +Fits the repo's existing conventions: one folder per action, node20 JS actions bundled with ncc, changesets for versioning, tags like `deploy-site@1.0.0`, used as `BunnyWay/actions/deploy-site@`. + +The action is deliberately a thin shell around the CLI (single deploy path, nothing reimplemented against the API): it runs `npx @bunny.net/cli@ sites deploy --site [--production] --output json`, parses the JSON, and handles the GitHub-side UX with `@actions/core`/`@actions/github`, which is where that logic naturally lives (the CLI stays platform-neutral). + +```yaml +# action.yml sketch +inputs: + site: # site name or storage zone ID (required) + directory: # built output to deploy (required) + production: # "true" publishes as the live site (default: false -> preview) + api_key: # BUNNY_API_KEY (required; see deploy_key note below) + comment: # upsert a sticky PR comment with the preview URL (default: true) + github_token: # for the comment; defaults to github.token + cli_version: # @bunny.net/cli version (default: latest major pin, e.g. "1") + force: # redeploy unchanged content (default: false) +outputs: + deploy-id: + preview-url: + production-url: + unchanged: +``` + +Comment behavior: on `pull_request` events, upsert one sticky comment (marker ``) with the preview URL, deploy ID, and updated time. Posts as `github-actions[bot]` via the workflow token; needs `pull-requests: write`. `comment: false` opts out. Comment failures warn, never fail the deploy. + +The `deploy-script` action there already accepts a `deploy_key` as a scoped alternative to the account `api_key`. Sites should aim for the same shape once a scoped deploy credential exists (see security notes); the input surface leaves room for it. + +### The shared skeleton + +Every framework example is this workflow with a different build block: + +```yaml +# .github/workflows/bunny-sites.yml +name: Deploy site +on: + push: + branches: [main] + pull_request: + +# One deploy at a time per ref; a newer commit cancels the older build. +concurrency: + group: bunny-sites-${{ github.ref }} + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + # 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 + steps: + - uses: actions/checkout@v4 + + # --- framework-specific setup + build (see matrix below) --- + - uses: oven-sh/setup-bun@v2 + - run: bun install + - run: bun run build + # ----------------------------------------------------------- + + - uses: BunnyWay/actions/deploy-site@deploy-site_1.0.0 + with: + site: my-site + directory: ./dist + production: ${{ github.event_name == 'push' }} + api_key: ${{ secrets.BUNNY_API_KEY }} +``` + +Setup for a user: drop in the workflow file with their site name, `gh secret set BUNNY_API_KEY`, done. + +The docs also show the raw-CLI variant (no action) for people who want full control: the same workflow with the deploy step as `npx @bunny.net/cli sites deploy ./dist --site my-site --output json` plus their own comment step. The action is convenience, not a requirement, and the JSON contract is documented either way. + +### Framework matrix + +Frameworks mostly collapse into a toolchain plus two values (build command, output dir). The published examples are concrete per framework because that is what people search for; internally they are one template over this table: + +| Framework | Setup steps | Build | Output dir | +| -------------------------------------------- | --------------------------------------- | -------------------------- | ----------------- | +| Astro | setup-bun or setup-node | `astro build` | `dist` | +| Vite (React, Vue, Svelte, ...) | setup-bun or setup-node | `vite build` | `dist` | +| React Router (framework mode, SPA/prerender) | setup-bun or setup-node | `react-router build` | `build/client` | +| Next.js (`output: "export"`) | setup-node | `next build` | `out` | +| SvelteKit (`adapter-static`) | setup-bun or setup-node | `vite build` | `build` | +| Eleventy | setup-node | `eleventy` | `_site` | +| Docusaurus | setup-node | `docusaurus build` | `build` | +| VitePress | setup-node | `vitepress build` | `.vitepress/dist` | +| Jekyll | ruby/setup-ruby (`bundler-cache: true`) | `bundle exec jekyll build` | `_site` | +| Hugo | peaceiris/actions-hugo | `hugo --minify` | `public` | +| Plain HTML | none | none | `.` or `public` | + +Node examples use the repo's package manager (detect lockfile: bun/pnpm/yarn/npm) with the matching cache option on the setup action. Non-Node frameworks need no JS setup at all: the action's `npx` call only requires the runner's preinstalled Node. + +### Where the examples live + +1. `deploy-site/README.md` in BunnyWay/actions: the action's own docs with the skeleton (that is where `uses:` consumers land). +2. `skills/bunny-cli/references/sites.md` + this repo's README: the skeleton, the matrix, and the raw-CLI variant (the skill is what agents read). +3. A `docs/examples/github-workflows/` directory in this repo with one complete `.yml` per framework, linked from the README. Cheap to maintain because they differ only in the marked block. +4. Optionally the bunny.net docs site later; out of scope here. + +### Scaffolder (CLI work, optional and independent) + +- `sites create` (interactive, git repo with a GitHub `origin`): after the domain step, ask "Set up GitHub deployments (preview on PRs, production on main)?". +- Framework detection picks the template: `astro`/`@react-router/dev`/`next`/`vite`/`vitepress`/`@sveltejs/kit`/`@11ty/eleventy`/`@docusaurus/core` in `package.json` dependencies, `Gemfile` mentioning `jekyll`, `hugo.toml`/`config.toml` + `content/` for Hugo. Ambiguous or unknown: prompt with a select, defaulting to the plain skeleton with TODO placeholders. +- Writes `.github/workflows/bunny-sites.yml` (never overwrites without confirmation) with the site name, build block, and output dir baked in, using the `deploy-site` action. The site name comes from the site just created (`sites create`) or the resolved site (`sites ci init`); no config file involved. +- Declined, or non-interactive: print the YAML and the secret instructions instead, so nothing is gated on the prompt. +- Standalone `bunny sites ci init` for existing sites; same behavior. +- Ends with the one manual step: `gh secret set BUNNY_API_KEY` (offered as a prompted command when `gh` is installed, printed otherwise). + +Ship order within V1 is flexible: action first, examples second, scaffolder third; each is independently useful. + +### V1 security notes + +- Fork PRs skip via the `if:` guard (no secrets there). Do NOT use `pull_request_target` to work around it: it runs untrusted build scripts with access to `BUNNY_API_KEY`. +- `BUNNY_API_KEY` is the account key, which is broad for a repo secret. Per-site deploy tokens are a worthwhile platform ask (precedent: `deploy-script`'s `deploy_key` input); not a blocker. +- Concurrent deploys: `concurrency` serializes per ref. Cross-PR races are safe for files (distinct `deploys//` prefixes) but the remote-state read-modify-write can drop a deploy record (v1 lock warns and overwrites). Hardening item before advertising CI: retry-with-merge on etag mismatch in `writeRemoteState`. +- `pull_request` checkouts are the synthetic merge commit, so the deploy ID is the merge sha, not the head sha. Fine: the URL flows through the action outputs, and the preview shows what main would look like post-merge. +- The action pins the CLI to a major version (`cli_version` input) so a bad CLI release cannot silently change deploy behavior for every consumer. + +## V2: the action grows GitHub Deployments + +The `deploy-site` action (not the CLI, which stays platform-neutral) additionally creates a GitHub Deployment for the SHA (`environment: preview|production`, `transient_environment` for previews) and marks its status `success` with `environment_url`. GitHub then shows "Deployed to preview" natively in the PR timeline and environment history on the repo. Needs `deployments: write` in the workflow permissions. + +This is also the hand-off point for V3: deployments are the state channel the app subscribes to. + +## V3: the GitHub App (identity upgrade only) + +A public "bunny.net Deployments" app whose only job is reporting UX; deploys never depend on it: + +- Permissions: `pull_requests: write`, `deployments: read`, `metadata: read` (optional `checks: write` later). +- Subscribes to `deployment_status` webhooks (the deployments V2 creates), looks up PRs for the SHA, and takes over the same marker comment as `bunny.net[bot]`. Stateless; the `environment_url` in the payload carries the URL. +- The receiver is GitHub-facing only (the action never calls it) and small enough to host as a bunny Edge Script: HMAC-verify `X-Hub-Signature-256`, exchange the app private key (RS256 JWT via WebCrypto) for an installation token, two REST calls, 200. +- An app cannot replace the build step without bunny running builds on untrusted code, which stays a non-goal. + +## Open questions + +1. Action naming and versioning in BunnyWay/actions: `deploy-site` vs `sites-deploy`; follow the existing `@x.y.z` tag scheme. +2. Which frameworks make the initial examples cut (proposal: Astro, Vite, React Router, Next static export, Jekyll, Hugo, plain HTML; add the rest on demand). +3. Whether the action should also expose `purge`/`publish ` style operations later (rollback from a workflow), or stay deploy-only. diff --git a/docs/plans/sites.md b/docs/plans/sites.md index a0566215..8d6dcfcd 100644 --- a/docs/plans/sites.md +++ b/docs/plans/sites.md @@ -111,7 +111,7 @@ Work items, in dependency order: 1. **`deploy-id.ts`** — pure functions, easy unit tests: `gitShaId()` (shell out via `Bun.spawn` to `git rev-parse --short HEAD`, detect dirty tree with `git status --porcelain`), `contentHashId(files)` (sorted path+sha256 merkle → 8 hex chars). 2. **`uploader.ts`** — walk dir (v1: upload everything except dotfiles); per-file streaming SHA-256 via `Bun.CryptoHasher("sha256")` → `UploadOptions.sha256Checksum` (the `storage/file/upload.ts` pattern); 8-way concurrent `uploadFile` with retry/backoff; progress via `spinner()` from `core/ui.ts` + `progressBar()`/`formatBytes()` from `core/format.ts` (stderr, so `--output json` stays clean). 3. **Remote state read-modify-write** in `api.ts` — `downloadFile` `_bunny/site.json`, append deploy record, re-upload. Include a `stateChecksum` field checked before write (cheap optimistic lock; log-and-overwrite on mismatch in v1). Reading the storage zone for `connectStorageZone` must go through `fetchStorageZone`/`resolveStorageZone` (they return the full record with `Password`). -4. **`deploy.ts`** — resolve site (flag → manifest → picker with offer-to-link, mirroring `selectScript` in `scripts/interactive.ts`) → compute ID → short-circuit if ID equals `current` ("no changes") → upload to `/deploys/{id}/` → update state → promote (env var + `POST /pullzone/{id}/purgeCache`) unless `--no-promote` → print production + preview URLs via `hostnameUrl`. +4. **`deploy.ts`** — resolve site (flag → manifest → picker with offer-to-link, mirroring `selectScript` in `scripts/interactive.ts`) → compute ID → short-circuit if ID is already uploaded ("no changes") → upload to `/deploys/{id}/` → update state → preview URL by default; `--production`/`--prod` promotes (env var + `POST /pullzone/{id}/purgeCache`) → print production + preview URLs via `hostnameUrl`. 5. **`deployments list`** — table `["ID", "Age", "Git", "Source", "Files", "Size", "Status"]` with `● ACTIVE` marker (the `scripts/deployments/list.ts` style, `formatDateTime` for dates); `--output json` free via the shared flag. 6. **`deployments publish`** — reuse the `scripts/deployments/publish.ts` confirm/`--force` UX; update env var, purge, swap `current`/`previous` in state. `--previous` is sugar for instant rollback. @@ -141,14 +141,12 @@ Edge cases handled explicitly: - Nameservers not pointed at Bunny yet — `checkDelegation`/`expectedNameservers` from `core/dns-nameservers.ts` detect it (that's how `bunny-dns.ts` sets `match.delegated`); print NS instructions (use `detectRegistrar` from `core/registrar.ts` to name the registrar) and exit 0 with a "re-run when propagated" hint. - A preexisting conflicting record at the apex — `offerBunnyDnsRecord` already handles repointing with a prompt; surface it clearly. -## Phase 4 — Env vars + builds +## Phase 4 — Builds -**Commands:** `sites env set/list/remove/pull`, plus `deploy --build [cmd]` +**Commands:** `deploy --build [cmd]` (a remote `sites env` store was built here, then dropped: sites deploys prebuilt files, so the caller's environment is the source of truth) -- Env store at `_bunny/env.json` (already 403-blocked by the router), read/write through `downloadFile`/`uploadFile`; values echoed masked in `list` via `maskSecret()` from `core/format.ts` unless `--show`. -- `deploy --build`: merge remote env + `--env`/`--env-file` overrides → spawn build command via `Bun.spawn` (from flag or `bunny.jsonc`) with merged environment → then the normal deploy path on the output dir. Record `envHash` in the deploy entry. +- `deploy --build`: spawn the build command via `Bun.spawn` (from flag or `bunny.jsonc`) in the caller's environment plus `--env`/`--env-file` overrides → then the normal deploy path on the output dir. - **`bunny.jsonc` support:** add an optional top-level `sites` block to `BunnyAppConfigSchema` in `packages/app-config/src/schema.ts` — `sites: SitesConfigSchema.optional()` with `{ name?, dir?, build? }`, exported sub-schema + `z.infer` type, mirroring `ProbeConfigSchema` et al. Regenerate `generated/schema.json` via `packages/app-config/scripts/generate-schema.ts` (`z.toJSONSchema(..., { target: "draft-2020-12" })`). `saveConfig`'s re-keying (`$schema`, `version`, `...rest`) preserves the new key automatically; consider bumping `CURRENT_VERSION` (date-versioned). Separate changeset, minor bump for `@bunny.net/app-config`. -- Loud docs + CLI warning: build-time env is baked into the bundle; **not a secret store**. ## Phase 5 — Polish + ship diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index 72fbd1f6..568e73c0 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -5,14 +5,12 @@ import { createSite, fetchSites, promoteDeploy, - readRemoteEnv, readRemoteState, siteContextFromZone, siteFiles, writeRemoteState, } from "./api.ts"; import { - REMOTE_ENV_PATH, REMOTE_STATE_PATH, type RemoteSiteState, STATE_VERSION, @@ -227,28 +225,39 @@ test("readRemoteState is null for missing or invalid state", async () => { expect(await readRemoteState(connection)).toBeNull(); }); -test("writeRemoteState overwrites (with a warning) on an etag mismatch", async () => { +test("writeRemoteState merges concurrent deploy records on an etag mismatch", async () => { const connection = fakeConnection(); const etag = await writeRemoteState(connection, fakeState()); - // Simulate a concurrent deploy changing the remote state. - store.set(REMOTE_STATE_PATH, JSON.stringify(fakeState({ current: "zzz" }))); + // Simulate a concurrent deploy landing between our read and write. + const theirs = { + id: "zzz", + createdAt: "2026-01-02T00:00:00.000Z", + source: "git" as const, + files: 1, + bytes: 10, + }; + store.set( + REMOTE_STATE_PATH, + JSON.stringify(fakeState({ current: "zzz", deploys: [theirs] })), + ); - await writeRemoteState(connection, fakeState({ current: "aaa" }), etag); + const ours = { + id: "aaa", + createdAt: "2026-01-03T00:00:00.000Z", + source: "git" as const, + files: 1, + bytes: 10, + }; + await writeRemoteState( + connection, + fakeState({ current: "aaa", deploys: [ours] }), + etag, + ); const read = await readRemoteState(connection); + // Our promote wins, but their deploy record survives the race. expect(read?.state.current).toBe("aaa"); -}); - -test("readRemoteEnv tolerates missing and malformed files", async () => { - const connection = fakeConnection(); - expect(await readRemoteEnv(connection)).toEqual({}); - - store.set(REMOTE_ENV_PATH, "{broken"); - expect(await readRemoteEnv(connection)).toEqual({}); - - store.set(REMOTE_ENV_PATH, JSON.stringify({ A: "1", B: 2, C: "3" })); - // Non-string values are dropped, not crashed on. - expect(await readRemoteEnv(connection)).toEqual({ A: "1", C: "3" }); + expect(read?.state.deploys.map((d) => d.id)).toEqual(["aaa", "zzz"]); }); // ---- provisioning ---- diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 598f59b4..3a42117b 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -20,7 +20,6 @@ import { import { CURRENT_DEPLOY_VAR, parseRemoteState, - REMOTE_ENV_PATH, REMOTE_STATE_PATH, type RemoteSiteState, routerScriptName, @@ -96,7 +95,8 @@ export async function readRemoteState( /** * Write `_bunny/site.json`. When `expectedEtag` is given, the current remote * content is re-read first; a mismatch means someone else deployed since we - * read — v1 logs and overwrites (cheap optimistic lock, no hard failure). + * read (e.g. concurrent CI runs). Their deploy records are merged in so no + * deploy goes missing; our `current`/`previous` win (last promote wins). * Returns the new etag. */ export async function writeRemoteState( @@ -107,9 +107,21 @@ export async function writeRemoteState( if (expectedEtag) { const current = await downloadText(connection, REMOTE_STATE_PATH); if (current !== null && sha256Hex(current) !== expectedEtag) { - logger.warn( - "Remote site state changed since it was read (concurrent deploy?) — overwriting.", - ); + const remote = parseRemoteState(current); + if (remote) { + const ours = new Set(state.deploys.map((d) => d.id)); + state.deploys = [ + ...state.deploys, + ...remote.deploys.filter((d) => !ours.has(d.id)), + ].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + logger.warn( + "Remote site state changed since it was read (concurrent deploy?): merged deploy records.", + ); + } else { + logger.warn( + "Remote site state changed since it was read (concurrent deploy?): overwriting.", + ); + } } } const raw = `${JSON.stringify(state, null, 2)}\n`; @@ -119,39 +131,6 @@ export async function writeRemoteState( return sha256Hex(raw); } -/** Read `_bunny/env.json` (build-time env). Missing file → empty env. */ -export async function readRemoteEnv( - connection: StorageZone, -): Promise> { - const raw = await downloadText(connection, REMOTE_ENV_PATH); - if (raw === null) return {}; - try { - const data = JSON.parse(raw); - if (!data || typeof data !== "object" || Array.isArray(data)) return {}; - const env: Record = {}; - for (const [key, value] of Object.entries(data)) { - if (typeof value === "string") env[key] = value; - } - return env; - } catch { - return {}; - } -} - -export async function writeRemoteEnv( - connection: StorageZone, - env: Record, -): Promise { - const sorted = Object.fromEntries( - Object.entries(env).sort(([a], [b]) => a.localeCompare(b)), - ); - const raw = `${JSON.stringify(sorted, null, 2)}\n`; - await siteFiles.upload(connection, REMOTE_ENV_PATH, textStream(raw), { - sha256Checksum: sha256Hex(raw).toUpperCase(), - }); -} - -/** Build a SiteContext from a full storage zone record, or null if it isn't a site. */ export async function siteContextFromZone( zone: StorageZoneModel, ): Promise { @@ -502,7 +481,6 @@ export async function deleteSiteResources(opts: { return results; } -/** Delete a deploy's files (`deploys/{id}/`) from the storage zone. */ export async function deleteDeployFiles( connection: StorageZone, deployId: string, diff --git a/packages/cli/src/commands/sites/build.test.ts b/packages/cli/src/commands/sites/build.test.ts index fa12b33a..566107a5 100644 --- a/packages/cli/src/commands/sites/build.test.ts +++ b/packages/cli/src/commands/sites/build.test.ts @@ -2,12 +2,7 @@ import { expect, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { - envHash, - parseEnvAssignments, - parseEnvFile, - runBuildCommand, -} from "./build.ts"; +import { parseEnvAssignments, parseEnvFile, runBuildCommand } from "./build.ts"; test("parseEnvAssignments parses KEY=VALUE pairs", () => { expect(parseEnvAssignments(["A=1", "B=with=equals", "C_1="])).toEqual({ @@ -44,14 +39,6 @@ test("parseEnvFile handles comments, blanks, and quotes", () => { }); }); -test("envHash is stable across ordering and sensitive to values", () => { - const a = envHash({ A: "1", B: "2" }); - const b = envHash({ B: "2", A: "1" }); - expect(a).toBe(b); - expect(a).toMatch(/^[0-9a-f]{8}$/); - expect(envHash({ A: "1", B: "3" })).not.toBe(a); -}); - test("runBuildCommand passes env and throws on failure", async () => { const dir = mkdtempSync(join(tmpdir(), "bunny-sites-build-")); const out = join(dir, "out.txt"); diff --git a/packages/cli/src/commands/sites/build.ts b/packages/cli/src/commands/sites/build.ts index 425ad027..c96f5020 100644 --- a/packages/cli/src/commands/sites/build.ts +++ b/packages/cli/src/commands/sites/build.ts @@ -1,6 +1,5 @@ import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; -import { sha256Hex } from "./api.ts"; const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; @@ -55,12 +54,6 @@ export function parseEnvFile(content: string): Record { return env; } -/** Stable 8-hex-char fingerprint of a build env, recorded on the deploy. */ -export function envHash(env: Record): string { - const sorted = Object.entries(env).sort(([a], [b]) => a.localeCompare(b)); - return sha256Hex(JSON.stringify(sorted)).slice(0, 8); -} - /** * Run the build command in a shell with the merged environment, streaming * its output. Throws on a non-zero exit so a broken build never deploys. diff --git a/packages/cli/src/commands/sites/ci/frameworks.test.ts b/packages/cli/src/commands/sites/ci/frameworks.test.ts new file mode 100644 index 00000000..30889335 --- /dev/null +++ b/packages/cli/src/commands/sites/ci/frameworks.test.ts @@ -0,0 +1,111 @@ +import { expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { detectFramework, detectPackageManager } from "./frameworks.ts"; + +function tempRepo(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), "bunny-sites-ci-")); + for (const [name, content] of Object.entries(files)) { + writeFileSync(join(dir, name), content); + } + return dir; +} + +function pkg(deps: Record): string { + return JSON.stringify({ name: "x", dependencies: deps }); +} + +test("detectFramework picks meta-frameworks over vite", async () => { + const dir = tempRepo({ + "package.json": pkg({ vite: "^6.0.0", astro: "^5.0.0" }), + }); + expect((await detectFramework(dir))?.id).toBe("astro"); +}); + +test("detectFramework finds React Router via @react-router/dev", async () => { + const dir = tempRepo({ + "package.json": JSON.stringify({ + name: "x", + dependencies: { react: "^19.0.0" }, + devDependencies: { "@react-router/dev": "^7.0.0", vite: "^6.0.0" }, + }), + }); + const preset = await detectFramework(dir); + expect(preset?.id).toBe("react-router"); + expect(preset?.dir).toBe("build/client"); +}); + +test("detectFramework falls back to vite for plain vite apps", async () => { + const dir = tempRepo({ "package.json": pkg({ vite: "^6.0.0" }) }); + expect((await detectFramework(dir))?.id).toBe("vite"); +}); + +test("detectFramework spots Gatsby, Nuxt, and SolidStart", async () => { + const gatsby = tempRepo({ "package.json": pkg({ gatsby: "^5.0.0" }) }); + expect((await detectFramework(gatsby))?.id).toBe("gatsby"); + + const nuxt = tempRepo({ + "package.json": pkg({ nuxt: "^3.0.0", vite: "^6.0.0" }), + }); + expect((await detectFramework(nuxt))?.id).toBe("nuxt"); + + const solid = tempRepo({ + "package.json": pkg({ "@solidjs/start": "^1.0.0" }), + }); + expect((await detectFramework(solid))?.id).toBe("solidstart"); +}); + +test("detectFramework spots the Python doc SSGs", async () => { + expect( + (await detectFramework(tempRepo({ "mkdocs.yml": "site_name: x" })))?.id, + ).toBe("mkdocs"); + expect( + (await detectFramework(tempRepo({ "pelicanconf.py": "AUTHOR = 'x'" })))?.id, + ).toBe("pelican"); + expect( + (await detectFramework(tempRepo({ "conf.py": "project = 'x'" })))?.id, + ).toBe("sphinx"); +}); + +test("detectFramework distinguishes Zola from Hugo via templates/", async () => { + const zola = mkdtempSync(join(tmpdir(), "bunny-sites-ci-")); + writeFileSync(join(zola, "config.toml"), 'base_url = "https://example.com"'); + mkdirSync(join(zola, "templates")); + expect((await detectFramework(zola))?.id).toBe("zola"); + + const hugo = mkdtempSync(join(tmpdir(), "bunny-sites-ci-")); + writeFileSync(join(hugo, "config.toml"), 'title = "x"'); + mkdirSync(join(hugo, "content")); + expect((await detectFramework(hugo))?.id).toBe("hugo"); +}); + +test("detectFramework spots Jekyll from the Gemfile or _config.yml", async () => { + const gemfile = tempRepo({ Gemfile: 'gem "jekyll", "~> 4.3"' }); + expect((await detectFramework(gemfile))?.id).toBe("jekyll"); + + const config = tempRepo({ "_config.yml": "title: My Site" }); + expect((await detectFramework(config))?.id).toBe("jekyll"); +}); + +test("detectFramework spots Hugo from hugo.toml", async () => { + const dir = tempRepo({ "hugo.toml": 'title = "My Site"' }); + expect((await detectFramework(dir))?.id).toBe("hugo"); +}); + +test("detectFramework is undefined for unknown projects", async () => { + const dir = tempRepo({ "index.html": "

hi

" }); + expect(await detectFramework(dir)).toBeUndefined(); +}); + +test("detectPackageManager reads the lockfile", async () => { + expect(await detectPackageManager(tempRepo({ "bun.lock": "" }))).toBe("bun"); + expect(await detectPackageManager(tempRepo({ "bun.lockb": "" }))).toBe("bun"); + expect(await detectPackageManager(tempRepo({ "pnpm-lock.yaml": "" }))).toBe( + "pnpm", + ); + expect(await detectPackageManager(tempRepo({ "yarn.lock": "" }))).toBe( + "yarn", + ); + expect(await detectPackageManager(tempRepo({}))).toBe("npm"); +}); diff --git a/packages/cli/src/commands/sites/ci/frameworks.ts b/packages/cli/src/commands/sites/ci/frameworks.ts new file mode 100644 index 00000000..f08ea80a --- /dev/null +++ b/packages/cli/src/commands/sites/ci/frameworks.ts @@ -0,0 +1,258 @@ +import { access } from "node:fs/promises"; +import { join } from "node:path"; + +export type PackageManager = "bun" | "pnpm" | "yarn" | "npm"; + +export interface FrameworkPreset { + id: string; + label: string; + /** Directory the build writes, relative to the repo root; the deploy target. */ + dir: string; + /** Which setup/build steps the workflow needs. */ + toolchain: "js" | "ruby" | "hugo" | "python" | "zola" | "dotnet" | "none"; + /** Explicit build command; js presets run it via the package manager, others run it directly. Omit on js to run the package.json `build` script. */ + build?: string; +} + +// Static must stay last: the interactive prompt defaults to it. +export const FRAMEWORK_PRESETS: FrameworkPreset[] = [ + { id: "analog", label: "Analog", dir: "dist/analog/public", toolchain: "js" }, + // Angular's application builder emits dist//browser; adjust if yours differs. + { id: "angular", label: "Angular", dir: "dist", toolchain: "js" }, + { id: "astro", label: "Astro", dir: "dist", toolchain: "js" }, + { + id: "brunch", + label: "Brunch", + dir: "public", + toolchain: "js", + build: "brunch build --production", + }, + { id: "docusaurus", label: "Docusaurus", dir: "build", toolchain: "js" }, + { id: "elderjs", label: "Elder.js", dir: "public", toolchain: "js" }, + { id: "eleventy", label: "Eleventy", dir: "_site", toolchain: "js" }, + { id: "ember", label: "Ember", dir: "dist", toolchain: "js" }, + { id: "gatsby", label: "Gatsby", dir: "public", toolchain: "js" }, + { id: "gridsome", label: "Gridsome", dir: "dist", toolchain: "js" }, + { + id: "hexo", + label: "Hexo", + dir: "public", + toolchain: "js", + build: "hexo generate", + }, + { id: "next", label: "Next.js (static export)", dir: "out", toolchain: "js" }, + { + id: "nuxt", + label: "Nuxt (static generate)", + dir: ".output/public", + toolchain: "js", + build: "nuxi generate", + }, + { id: "preact", label: "Preact (preact-cli)", dir: "build", toolchain: "js" }, + { id: "qwik", label: "Qwik (static adapter)", dir: "dist", toolchain: "js" }, + { + id: "react", + label: "React (Create React App)", + dir: "build", + toolchain: "js", + }, + { + id: "react-router", + label: "React Router", + dir: "build/client", + toolchain: "js", + }, + { + id: "solidstart", + label: "SolidStart (static preset)", + dir: ".output/public", + toolchain: "js", + }, + { + id: "sveltekit", + label: "SvelteKit (adapter-static)", + dir: "build", + toolchain: "js", + }, + { id: "vite", label: "Vite", dir: "dist", toolchain: "js" }, + { + id: "vitepress", + label: "VitePress", + dir: ".vitepress/dist", + toolchain: "js", + }, + { id: "vue", label: "Vue (Vue CLI)", dir: "dist", toolchain: "js" }, + { + id: "jekyll", + label: "Jekyll", + dir: "_site", + toolchain: "ruby", + build: "bundle exec jekyll build", + }, + { + id: "hugo", + label: "Hugo", + dir: "public", + toolchain: "hugo", + build: "hugo --minify", + }, + { + id: "mkdocs", + label: "MkDocs", + dir: "site", + toolchain: "python", + build: "mkdocs build", + }, + { + id: "pelican", + label: "Pelican", + dir: "output", + toolchain: "python", + build: "pelican content", + }, + { + id: "sphinx", + label: "Sphinx", + dir: "_build/html", + toolchain: "python", + build: "sphinx-build -b html . _build/html", + }, + { + id: "zola", + label: "Zola", + dir: "public", + toolchain: "zola", + build: "zola build", + }, + // Blazor WebAssembly publishes to bin/Release/net/publish/wwwroot; bump the version if needed. + { + id: "blazor", + label: "Blazor WebAssembly", + dir: "bin/Release/net8.0/publish/wwwroot", + toolchain: "dotnet", + build: "dotnet publish -c Release", + }, + { + id: "static", + label: "Static HTML (no build step)", + dir: ".", + toolchain: "none", + }, +]; + +export function findPreset(id: string): FrameworkPreset | undefined { + return FRAMEWORK_PRESETS.find((p) => p.id === id); +} + +// Ordered most-specific first: meta-frameworks depend on vite, so vite goes last. +// Blazor's compiled toolchain is selectable via --framework but not auto-detected. +const JS_DETECTORS: Array<[dependency: string, presetId: string]> = [ + ["@analogjs/platform", "analog"], + ["astro", "astro"], + ["@react-router/dev", "react-router"], + ["@sveltejs/kit", "sveltekit"], + ["@solidjs/start", "solidstart"], + ["@builder.io/qwik", "qwik"], + ["gatsby", "gatsby"], + ["gridsome", "gridsome"], + ["nuxt", "nuxt"], + ["next", "next"], + ["vitepress", "vitepress"], + ["@docusaurus/core", "docusaurus"], + ["@11ty/eleventy", "eleventy"], + ["@elderjs/elderjs", "elderjs"], + ["hexo", "hexo"], + ["ember-cli", "ember"], + ["@angular/cli", "angular"], + ["@vue/cli-service", "vue"], + ["preact-cli", "preact"], + ["react-scripts", "react"], + ["brunch", "brunch"], + ["vite", "vite"], +]; + +async function readJson(path: string): Promise | null> { + try { + return (await Bun.file(path).json()) as Record; + } catch { + return null; + } +} + +async function readText(path: string): Promise { + try { + return await Bun.file(path).text(); + } catch { + return null; + } +} + +// access() handles both files and directories; Bun.file().exists() misses directories. +async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +export async function detectFramework( + root: string, +): Promise { + const pkg = await readJson(join(root, "package.json")); + if (pkg) { + const deps = { + ...(pkg.dependencies as Record | undefined), + ...(pkg.devDependencies as Record | undefined), + }; + for (const [dependency, presetId] of JS_DETECTORS) { + if (deps[dependency]) return findPreset(presetId); + } + } + + const gemfile = await readText(join(root, "Gemfile")); + if ( + gemfile?.includes("jekyll") || + (await exists(join(root, "_config.yml"))) + ) { + return findPreset("jekyll"); + } + + if (await exists(join(root, "mkdocs.yml"))) return findPreset("mkdocs"); + if (await exists(join(root, "pelicanconf.py"))) return findPreset("pelican"); + if (await exists(join(root, "conf.py"))) return findPreset("sphinx"); + + // Zola and Hugo both use config.toml; Zola's templates/ dir disambiguates. + if ( + (await exists(join(root, "config.toml"))) && + (await exists(join(root, "templates"))) + ) { + return findPreset("zola"); + } + + if ( + (await exists(join(root, "hugo.toml"))) || + (await exists(join(root, "hugo.yaml"))) || + ((await exists(join(root, "config.toml"))) && + (await exists(join(root, "content")))) + ) { + return findPreset("hugo"); + } + + return undefined; +} + +export async function detectPackageManager( + root: string, +): Promise { + if ( + (await exists(join(root, "bun.lock"))) || + (await exists(join(root, "bun.lockb"))) + ) { + return "bun"; + } + if (await exists(join(root, "pnpm-lock.yaml"))) return "pnpm"; + if (await exists(join(root, "yarn.lock"))) return "yarn"; + return "npm"; +} diff --git a/packages/cli/src/commands/sites/ci/index.ts b/packages/cli/src/commands/sites/ci/index.ts new file mode 100644 index 00000000..27edfa52 --- /dev/null +++ b/packages/cli/src/commands/sites/ci/index.ts @@ -0,0 +1,8 @@ +import { defineNamespace } from "../../../core/define-namespace.ts"; +import { sitesCiInitCommand } from "./init.ts"; + +export const sitesCiNamespace = defineNamespace( + "ci", + "Set up CI deployments for a site.", + [sitesCiInitCommand], +); diff --git a/packages/cli/src/commands/sites/ci/init.ts b/packages/cli/src/commands/sites/ci/init.ts new file mode 100644 index 00000000..ca1b0fb4 --- /dev/null +++ b/packages/cli/src/commands/sites/ci/init.ts @@ -0,0 +1,115 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { logger } from "../../../core/logger.ts"; +import { isInteractive } from "../../../core/ui.ts"; +import { + type SiteSelectorArgs, + selectSite, + siteOptionBuilder, +} from "../interactive.ts"; +import { FRAMEWORK_PRESETS } from "./frameworks.ts"; +import { + gitTopLevel, + hasGitHubOrigin, + offerGitHubSecret, + scaffoldSitesWorkflow, +} from "./scaffold.ts"; + +interface CiInitArgs extends SiteSelectorArgs { + framework?: string; + force?: boolean; +} + +/** + * Scaffold `.github/workflows/bunny-sites.yml`: previews on PRs, production + * on merges to main, via the BunnyWay/actions deploy-site action. + */ +export const sitesCiInitCommand = defineCommand({ + command: "init", + describe: "Add a GitHub Actions workflow that deploys this site.", + examples: [ + ["$0 sites ci init", "Detect the framework and write the workflow"], + ["$0 sites ci init --framework astro", "Skip detection"], + [ + "$0 sites ci init --site my-site --force", + "Overwrite an existing workflow", + ], + ], + + builder: (yargs) => + siteOptionBuilder(yargs) + .option("framework", { + type: "string", + choices: FRAMEWORK_PRESETS.map((p) => p.id), + describe: "Framework preset for the build steps (default: detected)", + }) + .option("force", { + type: "boolean", + default: false, + describe: "Overwrite an existing workflow file", + }), + + handler: async (args) => { + const { profile, output, verbose, apiKey } = args; + const interactive = isInteractive(output); + + const config = resolveConfig(profile, apiKey, verbose); + const coreClient = createCoreClient(clientOptions(config, verbose)); + const { site, offerLink } = await selectSite(coreClient, { + site: args.site, + link: args.link, + output, + }); + const name = site.state.name; + + const root = (await gitTopLevel(process.cwd())) ?? process.cwd(); + if (output !== "json" && !(await hasGitHubOrigin(root))) { + logger.dim( + " No GitHub origin remote detected; writing the workflow anyway.", + ); + } + + const result = await scaffoldSitesWorkflow({ + site: name, + root, + frameworkId: args.framework, + interactive, + force: args.force, + }); + + if (output === "json") { + logger.log( + JSON.stringify( + result && { + site: name, + path: result.path, + framework: result.preset.id, + packageManager: result.packageManager, + }, + null, + 2, + ), + ); + return; + } + + if (!result) { + logger.log("Cancelled."); + return; + } + + logger.success( + `Wrote ${result.path} (${result.preset.label}, deploys ${result.preset.dir}).`, + ); + 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.", + ); + + await offerLink(); + }, +}); diff --git a/packages/cli/src/commands/sites/ci/scaffold.ts b/packages/cli/src/commands/sites/ci/scaffold.ts new file mode 100644 index 00000000..d3c5ac80 --- /dev/null +++ b/packages/cli/src/commands/sites/ci/scaffold.ts @@ -0,0 +1,196 @@ +import { existsSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import prompts from "prompts"; +import { UserError } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; +import { confirm } from "../../../core/ui.ts"; +import { + detectFramework, + detectPackageManager, + FRAMEWORK_PRESETS, + type FrameworkPreset, + findPreset, + type PackageManager, +} from "./frameworks.ts"; +import { renderSitesWorkflow, SITES_WORKFLOW_PATH } from "./workflow.ts"; + +async function git(cwd: string, args: string[]): Promise { + try { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "ignore", + stdin: "ignore", + }); + const [code, out] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + ]); + return code === 0 ? out.trim() : null; + } catch { + return null; + } +} + +/** The repo root, or null when `cwd` isn't inside a git repository. */ +export async function gitTopLevel(cwd: string): Promise { + return git(cwd, ["rev-parse", "--show-toplevel"]); +} + +export async function hasGitHubOrigin(root: string): Promise { + const url = await git(root, ["remote", "get-url", "origin"]); + return url !== null && url.includes("github.com"); +} + +export interface ScaffoldResult { + /** Workflow path relative to the repo root. */ + path: string; + preset: FrameworkPreset; + packageManager: PackageManager; +} + +/** Resolve the framework preset: explicit id, detection, prompt, static fallback. */ +async function resolvePreset( + root: string, + frameworkId: string | undefined, + interactive: boolean, +): Promise { + if (frameworkId) { + const preset = findPreset(frameworkId); + if (!preset) { + throw new UserError( + `Unknown framework "${frameworkId}".`, + `Known frameworks: ${FRAMEWORK_PRESETS.map((p) => p.id).join(", ")}.`, + ); + } + return preset; + } + + const detected = await detectFramework(root); + if (detected) { + logger.info(`Detected ${detected.label} (deploys ${detected.dir}).`); + return detected; + } + + if (interactive) { + const { value } = await prompts({ + type: "select", + name: "value", + message: "Framework:", + choices: FRAMEWORK_PRESETS.map((p) => ({ title: p.label, value: p.id })), + initial: FRAMEWORK_PRESETS.length - 1, + }); + const preset = value ? findPreset(value) : undefined; + if (preset) return preset; + } + + const fallback = findPreset("static"); + if (!fallback) throw new UserError("Missing static framework preset."); + return fallback; +} + +/** + * Write `.github/workflows/bunny-sites.yml` for a site. Returns null when the + * user declines to overwrite an existing file; throws when non-interactive and + * the file exists without `force`. + */ +export async function scaffoldSitesWorkflow(opts: { + site: string; + root: string; + frameworkId?: string; + interactive: boolean; + force?: boolean; +}): Promise { + const preset = await resolvePreset( + opts.root, + opts.frameworkId, + opts.interactive, + ); + const packageManager = await detectPackageManager(opts.root); + const content = renderSitesWorkflow({ + site: opts.site, + preset, + packageManager, + }); + + const target = join(opts.root, SITES_WORKFLOW_PATH); + if (existsSync(target) && !opts.force) { + if (!opts.interactive) { + throw new UserError( + `${SITES_WORKFLOW_PATH} already exists.`, + "Pass --force to overwrite it.", + ); + } + if ( + !(await confirm(`Overwrite ${SITES_WORKFLOW_PATH}?`, { initial: false })) + ) { + return null; + } + } + + mkdirSync(dirname(target), { recursive: true }); + await Bun.write(target, content); + return { path: SITES_WORKFLOW_PATH, preset, packageManager }; +} + +/** Print the workflow and setup steps for users who declined the scaffold. */ +export async function printWorkflowInstructions( + site: string, + root: string, +): Promise { + const preset = (await detectFramework(root)) ?? findPreset("static"); + if (!preset) return; + const packageManager = await detectPackageManager(root); + logger.log(); + logger.log(`To deploy from GitHub later, add ${SITES_WORKFLOW_PATH}:`); + logger.log(); + logger.log(renderSitesWorkflow({ site, preset, packageManager })); + printSecretHint(); +} + +export function printSecretHint(): void { + logger.log("Then add your API key as a repository secret:"); + logger.accent(" gh secret set BUNNY_API_KEY"); + logger.dim(" (or GitHub repo Settings -> Secrets and variables -> Actions)"); +} + +/** + * Offer to add the BUNNY_API_KEY repo secret via the `gh` CLI (prompted). + * Falls back to printing the manual steps when declined or unavailable. + */ +export async function offerGitHubSecret(opts: { + apiKey: string | undefined; + root: string; + interactive: boolean; +}): Promise { + const gh = Bun.which("gh"); + if (opts.interactive && gh && opts.apiKey) { + const proceed = await confirm( + "Add the BUNNY_API_KEY secret to this GitHub repo now (runs `gh secret set`)?", + { initial: true }, + ); + if (proceed) { + const proc = Bun.spawn( + [gh, "secret", "set", "BUNNY_API_KEY", "--body", opts.apiKey], + { + cwd: opts.root, + stdout: "ignore", + stderr: "pipe", + stdin: "ignore", + }, + ); + const [code, err] = await Promise.all([ + proc.exited, + new Response(proc.stderr).text(), + ]); + if (code === 0) { + logger.success("Added the BUNNY_API_KEY secret."); + return; + } + logger.warn( + `Couldn't set the secret: ${err.trim() || `gh exited with ${code}`}`, + ); + } + } + printSecretHint(); +} diff --git a/packages/cli/src/commands/sites/ci/workflow.test.ts b/packages/cli/src/commands/sites/ci/workflow.test.ts new file mode 100644 index 00000000..42d6dc04 --- /dev/null +++ b/packages/cli/src/commands/sites/ci/workflow.test.ts @@ -0,0 +1,112 @@ +import { expect, test } from "bun:test"; +import { findPreset } from "./frameworks.ts"; +import { DEPLOY_SITE_ACTION, renderSitesWorkflow } from "./workflow.ts"; + +function preset(id: string) { + const p = findPreset(id); + if (!p) throw new Error(`missing preset ${id}`); + return p; +} + +test("astro + bun workflow builds with bun and deploys dist", () => { + const yml = renderSitesWorkflow({ + site: "my-site", + preset: preset("astro"), + packageManager: "bun", + }); + expect(yml).toContain("uses: oven-sh/setup-bun@v2"); + expect(yml).toContain("run: bun run build"); + expect(yml).toContain(`uses: ${DEPLOY_SITE_ACTION}`); + expect(yml).toContain("site: my-site"); + expect(yml).toContain("directory: dist"); + // Preview by default; production only on pushes to main. + expect(yml).toContain("production: ${{ github.event_name == 'push' }}"); + // Fork PRs are skipped, not failed. + expect(yml).toContain( + "github.event.pull_request.head.repo.full_name == github.repository", + ); +}); + +test("jekyll workflow uses ruby and deploys _site", () => { + const yml = renderSitesWorkflow({ + site: "blog", + preset: preset("jekyll"), + packageManager: "npm", + }); + expect(yml).toContain("uses: ruby/setup-ruby@v1"); + expect(yml).toContain("run: bundle exec jekyll build"); + expect(yml).toContain("directory: _site"); + expect(yml).not.toContain("setup-node"); +}); + +test("static workflow has no build step", () => { + const yml = renderSitesWorkflow({ + site: "plain", + preset: preset("static"), + packageManager: "npm", + }); + expect(yml).not.toContain("run: npm"); + expect(yml).toContain("directory: ."); +}); + +test("nuxt runs its build override via the package manager's exec runner", () => { + const yml = renderSitesWorkflow({ + site: "s", + preset: preset("nuxt"), + packageManager: "bun", + }); + expect(yml).toContain("run: bun install --frozen-lockfile"); + expect(yml).toContain("run: bunx nuxi generate"); + expect(yml).toContain("directory: .output/public"); + expect(yml).not.toContain("run: bun run build"); +}); + +test("mkdocs uses the python toolchain and deploys site", () => { + const yml = renderSitesWorkflow({ + site: "docs", + preset: preset("mkdocs"), + packageManager: "npm", + }); + expect(yml).toContain("uses: actions/setup-python@v5"); + expect(yml).toContain("run: pip install -r requirements.txt"); + expect(yml).toContain("run: mkdocs build"); + expect(yml).toContain("directory: site"); + expect(yml).not.toContain("setup-node"); +}); + +test("zola installs the zola binary and blazor uses dotnet", () => { + const zola = renderSitesWorkflow({ + site: "z", + preset: preset("zola"), + packageManager: "npm", + }); + expect(zola).toContain("tool: zola"); + expect(zola).toContain("run: zola build"); + + const blazor = renderSitesWorkflow({ + site: "b", + preset: preset("blazor"), + packageManager: "npm", + }); + expect(blazor).toContain("uses: actions/setup-dotnet@v4"); + expect(blazor).toContain("run: dotnet publish -c Release"); + expect(blazor).toContain("directory: bin/Release/net8.0/publish/wwwroot"); +}); + +test("npm and pnpm projects get the matching install steps", () => { + const npm = renderSitesWorkflow({ + site: "s", + preset: preset("vite"), + packageManager: "npm", + }); + expect(npm).toContain("run: npm ci"); + expect(npm).toContain("cache: npm"); + + const pnpm = renderSitesWorkflow({ + site: "s", + preset: preset("vite"), + packageManager: "pnpm", + }); + expect(pnpm).toContain("uses: pnpm/action-setup@v4"); + expect(pnpm).toContain("run: pnpm install --frozen-lockfile"); +}); diff --git a/packages/cli/src/commands/sites/ci/workflow.ts b/packages/cli/src/commands/sites/ci/workflow.ts new file mode 100644 index 00000000..d6436789 --- /dev/null +++ b/packages/cli/src/commands/sites/ci/workflow.ts @@ -0,0 +1,149 @@ +import type { FrameworkPreset, PackageManager } from "./frameworks.ts"; + +export const SITES_WORKFLOW_PATH = ".github/workflows/bunny-sites.yml"; + +// Bump the tag when a new major of the action ships; the action wraps the CLI. +export const DEPLOY_SITE_ACTION = + "BunnyWay/actions/deploy-site@deploy-site_1.0.0"; + +// Toolchain setup + dependency install, without the build line. +const JS_SETUP: Record = { + bun: [ + " - uses: oven-sh/setup-bun@v2", + " - run: bun install --frozen-lockfile", + ], + pnpm: [ + " - uses: pnpm/action-setup@v4", + " - uses: actions/setup-node@v4", + " with:", + ' node-version: "lts/*"', + " cache: pnpm", + " - run: pnpm install --frozen-lockfile", + ], + yarn: [ + " - uses: actions/setup-node@v4", + " with:", + ' node-version: "lts/*"', + " cache: yarn", + " - run: yarn install --frozen-lockfile", + ], + npm: [ + " - uses: actions/setup-node@v4", + " with:", + ' node-version: "lts/*"', + " cache: npm", + " - run: npm ci", + ], +}; + +// Runner for a project-local binary, used by presets that override the build command. +const PM_EXEC: Record = { + bun: "bunx", + pnpm: "pnpm exec", + yarn: "yarn", + npm: "npx", +}; + +function jsSteps(preset: FrameworkPreset, pm: PackageManager): string[] { + const build = preset.build + ? ` - run: ${PM_EXEC[pm]} ${preset.build}` + : ` - run: ${pm} run build`; + return [...JS_SETUP[pm], build]; +} + +function buildSteps( + preset: FrameworkPreset, + packageManager: PackageManager, +): string[] { + switch (preset.toolchain) { + case "js": + return jsSteps(preset, packageManager); + case "ruby": + return [ + " - uses: ruby/setup-ruby@v1", + " with:", + ' ruby-version: "3.3"', + " bundler-cache: true", + ` - run: ${preset.build}`, + " env:", + " JEKYLL_ENV: production", + ]; + case "hugo": + return [ + " - uses: peaceiris/actions-hugo@v3", + " with:", + ' hugo-version: "latest"', + " extended: true", + ` - run: ${preset.build}`, + ]; + case "python": + return [ + " - uses: actions/setup-python@v5", + " with:", + ' python-version: "3.x"', + " - run: pip install -r requirements.txt", + ` - run: ${preset.build}`, + ]; + case "zola": + return [ + " - uses: taiki-e/install-action@v2", + " with:", + " tool: zola", + ` - run: ${preset.build}`, + ]; + case "dotnet": + return [ + " - uses: actions/setup-dotnet@v4", + " with:", + ' dotnet-version: "8.0.x"', + ` - run: ${preset.build}`, + ]; + case "none": + return [" # No build step: static files deploy as-is."]; + } +} + +/** + * Render the GitHub Actions workflow: previews on PRs, production on pushes + * to main, deployed via the BunnyWay/actions deploy-site action. + */ +export function renderSitesWorkflow(opts: { + site: string; + preset: FrameworkPreset; + packageManager: PackageManager; +}): string { + const { site, preset, packageManager } = opts; + const lines = [ + "name: Deploy site", + "on:", + " push:", + " branches: [main]", + " pull_request:", + "", + "# One deploy at a time per ref; a newer commit cancels the older build.", + "concurrency:", + " group: bunny-sites-${{ github.ref }}", + " cancel-in-progress: true", + "", + "jobs:", + " deploy:", + " runs-on: ubuntu-latest", + " # 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", + " steps:", + " - uses: actions/checkout@v4", + "", + ...buildSteps(preset, packageManager), + "", + ` - uses: ${DEPLOY_SITE_ACTION}`, + " with:", + ` site: ${site}`, + ` directory: ${preset.dir}`, + " production: ${{ github.event_name == 'push' }}", + " api_key: ${{ secrets.BUNNY_API_KEY }}", + ]; + return `${lines.join("\n")}\n`; +} diff --git a/packages/cli/src/commands/sites/constants.ts b/packages/cli/src/commands/sites/constants.ts index 4e9f3102..f9cdfccb 100644 --- a/packages/cli/src/commands/sites/constants.ts +++ b/packages/cli/src/commands/sites/constants.ts @@ -2,9 +2,8 @@ export const SITES_MANIFEST = "site.json"; // Remote paths inside the site's storage zone. Everything under `_bunny/` is -// blocked by the router (403), so state and env never get served. +// blocked by the router (403), so state never gets served. export const REMOTE_STATE_PATH = "_bunny/site.json"; -export const REMOTE_ENV_PATH = "_bunny/env.json"; // Deploys live at `deploys/{id}/...` inside the storage zone. export const DEPLOYS_DIR = "deploys"; @@ -35,7 +34,6 @@ export interface DeployRecord { dirty?: boolean; files: number; bytes: number; - envHash?: string; } /** diff --git a/packages/cli/src/commands/sites/create.ts b/packages/cli/src/commands/sites/create.ts index 5ca51524..91998d87 100644 --- a/packages/cli/src/commands/sites/create.ts +++ b/packages/cli/src/commands/sites/create.ts @@ -1,7 +1,9 @@ +import { basename } from "node:path"; 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"; @@ -10,8 +12,15 @@ import { formatKeyValue } from "../../core/format.ts"; import { normalizeHostname } from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; import { saveManifest } from "../../core/manifest.ts"; -import { isInteractive, spinner } from "../../core/ui.ts"; +import { confirm, isInteractive, spinner } from "../../core/ui.ts"; import { createSite, siteContextFromZone } from "./api.ts"; +import { + gitTopLevel, + hasGitHubOrigin, + offerGitHubSecret, + printWorkflowInstructions, + scaffoldSitesWorkflow, +} from "./ci/scaffold.ts"; import { isValidSiteName, SITES_MANIFEST, @@ -20,21 +29,33 @@ import { import { setupSiteDomain } from "./domains/index.ts"; interface CreateArgs { - name: string; + name?: string; region?: string; domain?: string; link?: boolean; } +const SITE_NAME_RULES = + "Use 3–60 lowercase letters, digits, and dashes (no leading/trailing dash)."; + +function suggestSiteName(): string | undefined { + const base = basename(process.cwd()) + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return isValidSiteName(base) ? base : undefined; +} + /** * Create a new static site: a storage zone (files), a pull zone (CDN), and a * middleware router script that maps hosts to deploy directories. The site's * state lives at `_bunny/site.json` inside the storage zone. */ export const sitesCreateCommand = defineCommand({ - command: "create ", + command: "create [name]", describe: "Create a new static site.", examples: [ + ["$0 sites create", "Prompt for a name (defaults to the directory name)"], ["$0 sites create my-site", "Create a site served at my-site.b-cdn.net"], [ "$0 sites create my-site --domain example.com", @@ -48,8 +69,7 @@ export const sitesCreateCommand = defineCommand({ .positional("name", { type: "string", describe: - "Site name — becomes the storage zone, pull zone, and .b-cdn.net subdomain", - demandOption: true, + "Site name — becomes the storage zone, pull zone, and .b-cdn.net subdomain (prompted when omitted)", }) .option("region", { type: "string", @@ -58,7 +78,8 @@ export const sitesCreateCommand = defineCommand({ }) .option("domain", { type: "string", - describe: "Custom domain to attach (with *.preview. previews)", + describe: + "Custom domain to attach, with *.preview. previews (offered interactively when omitted)", }) .option("link", { type: "boolean", @@ -68,16 +89,35 @@ export const sitesCreateCommand = defineCommand({ handler: async (args) => { const { profile, output, verbose, apiKey } = args; - const name = args.name.toLowerCase(); + const interactive = isInteractive(output); + + let name = args.name?.trim().toLowerCase(); + if (!name && interactive) { + const suggestion = suggestSiteName(); + const { value } = await prompts({ + type: "text", + name: "value", + message: "Site name:", + ...(suggestion ? { initial: suggestion } : {}), + validate: (v: string) => + isValidSiteName(String(v).trim().toLowerCase()) || SITE_NAME_RULES, + }); + name = (value as string | undefined)?.trim().toLowerCase(); + } + if (!name) { + throw new UserError( + "Site name is required.", + "Pass one: bunny sites create .", + ); + } if (!isValidSiteName(name)) { throw new UserError( - `"${args.name}" is not a valid site name.`, - "Use 3–60 lowercase letters, digits, and dashes (no leading/trailing dash).", + `"${args.name ?? name}" is not a valid site name.`, + SITE_NAME_RULES, ); } const domain = args.domain ? normalizeHostname(args.domain) : undefined; - const interactive = isInteractive(output); const config = resolveConfig(profile, apiKey, verbose); const options = clientOptions(config, verbose); @@ -108,28 +148,26 @@ export const sitesCreateCommand = defineCommand({ }); } - // Attach the custom domain last — a domain failure mustn't fail the - // create; the site already exists and can be retried via `sites domains add`. - let domainError: string | undefined; - if (domain) { - const site = await siteContextFromZone(result.storageZone); - if (site) { - try { - await setupSiteDomain({ - coreClient, - site, - domain, - interactive, - verbose, - json: output === "json", - }); - } catch (err) { - domainError = err instanceof Error ? err.message : String(err); + if (output === "json") { + // --domain is attached non-interactively; a failure still reports the created site. + let domainError: string | undefined; + if (domain) { + const site = await siteContextFromZone(result.storageZone); + if (site) { + try { + await setupSiteDomain({ + coreClient, + site, + domain, + interactive: false, + verbose, + json: true, + }); + } catch (err) { + domainError = err instanceof Error ? err.message : String(err); + } } } - } - - if (output === "json") { logger.log( JSON.stringify( { @@ -166,11 +204,73 @@ export const sitesCreateCommand = defineCommand({ output, ), ); - if (domainError) { + + // Custom domain: --domain flag, or offer one interactively (mirrors `scripts create`). + let chosenDomain = domain; + if (!chosenDomain && interactive) { + logger.log(); + const { value } = await prompts({ + type: "text", + name: "value", + message: "Custom domain (leave blank to skip):", + }); + chosenDomain = normalizeHostname(value ?? "") || undefined; + } + 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(); - logger.warn(`Couldn't finish setting up ${domain}: ${domainError}`); - logger.dim(` Retry later: bunny sites domains add ${domain} ${name}`); + const site = await siteContextFromZone(result.storageZone); + if (site) { + try { + await setupSiteDomain({ + coreClient, + site, + domain: chosenDomain, + interactive, + verbose, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.warn(`Couldn't finish setting up ${chosenDomain}: ${message}`); + logger.dim( + ` Retry later: bunny sites domains add ${chosenDomain} ${name}`, + ); + } + } } + + // GitHub deployments: offer the workflow scaffold when this is a GitHub repo. + if (interactive) { + const root = await gitTopLevel(process.cwd()); + if (root && (await hasGitHubOrigin(root))) { + logger.log(); + const setup = await confirm( + "Set up GitHub deployments (preview on PRs, production on main)?", + { initial: true }, + ); + if (setup) { + const result = await scaffoldSitesWorkflow({ + site: name, + root, + interactive: true, + }); + if (result) { + logger.success( + `Wrote ${result.path} (${result.preset.label}, deploys ${result.preset.dir}).`, + ); + await offerGitHubSecret({ + apiKey: config.apiKey, + root, + interactive, + }); + } + } else { + await printWorkflowInstructions(name, root); + } + } + } + logger.log(); logger.dim(" Deploy your site: bunny sites deploy "); }, diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index c41c2bee..c400b7b2 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -11,18 +11,8 @@ import { UserError } from "../../core/errors.ts"; import { formatBytes } from "../../core/format.ts"; import { logger } from "../../core/logger.ts"; import { spinner } from "../../core/ui.ts"; -import { - promoteDeploy, - readRemoteEnv, - type SiteContext, - writeRemoteState, -} from "./api.ts"; -import { - envHash, - parseEnvAssignments, - parseEnvFile, - runBuildCommand, -} from "./build.ts"; +import { promoteDeploy, type SiteContext, writeRemoteState } from "./api.ts"; +import { parseEnvAssignments, parseEnvFile, runBuildCommand } from "./build.ts"; import { loadSiteConfig } from "./config.ts"; import { type DeployRecord, @@ -42,10 +32,28 @@ interface DeployArgs extends SiteSelectorArgs { build?: string; env?: string[]; "env-file"?: string; - promote?: boolean; + production?: boolean; force?: boolean; } +/** System hostname from the pull zone; URLs are informational, so tolerate a fetch failure. */ +async function fetchSystemHost( + coreClient: ReturnType, + pullZoneId: number, +): Promise { + try { + const { data } = await coreClient.GET("/pullzone/{id}", { + params: { path: { id: pullZoneId } }, + }); + return ( + (data?.Hostnames ?? []).find((h) => h.IsSystemHostname)?.Value ?? + undefined + ); + } catch { + return undefined; + } +} + /** Production and preview URLs for a deploy, derived from the site's hosts. */ function deployUrls( site: SiteContext, @@ -66,16 +74,20 @@ function deployUrls( /** * Deploy a directory: hash → skip if unchanged → upload to `deploys/{id}/` → - * record in remote state → promote (env var + cache purge) unless - * `--no-promote`. With `--build`, runs the build command first with the - * site's remote env merged with `--env`/`--env-file` overrides. + * record in remote state → serve at a preview URL. With `--production`, also + * publish it (env var + cache purge) as the live site. With `--build`, runs + * the build command first in the caller's environment plus `--env`/`--env-file` + * overrides. */ export const sitesDeployCommand = defineCommand({ command: "deploy [dir]", describe: "Deploy a directory to a site.", examples: [ - ["$0 sites deploy ./dist", "Deploy and promote to production"], - ["$0 sites deploy ./dist --no-promote", "Upload without promoting"], + ["$0 sites deploy ./dist", "Deploy to a preview URL"], + [ + "$0 sites deploy ./dist --production", + "Deploy and publish as the live site", + ], ["$0 sites deploy --build", "Run the configured build, then deploy"], [ '$0 sites deploy ./dist --build "npm run build"', @@ -106,11 +118,11 @@ export const sitesDeployCommand = defineCommand({ type: "string", describe: "Read build-time env overrides from a dotenv-style file", }) - .option("promote", { + .option("production", { + alias: "prod", type: "boolean", - default: true, - describe: - "Promote the deploy to production (default: true). Use --no-promote to only upload.", + default: false, + describe: "Publish the deploy as the live site (default: preview only)", }) .option("force", { type: "boolean", @@ -146,7 +158,6 @@ export const sitesDeployCommand = defineCommand({ const { state, connection, etag } = site; // Build first — the deploy ID must hash the build *output*. - let buildEnvHash: string | undefined; if (args.build !== undefined) { const command = args.build || siteConfig?.config.build; if (!command) { @@ -161,13 +172,10 @@ export const sitesDeployCommand = defineCommand({ : {}), ...parseEnvAssignments(args.env), }; - const remoteEnv = await readRemoteEnv(connection); - const mergedEnv = { ...remoteEnv, ...overrides }; - buildEnvHash = envHash(mergedEnv); await runBuildCommand( command, siteConfig?.root ?? process.cwd(), - mergedEnv, + overrides, ); } @@ -189,51 +197,76 @@ export const sitesDeployCommand = defineCommand({ const identity = await resolveDeployIdentity(dir, files); - if (state.current === identity.id && !args.force) { + const alreadyLive = state.current === identity.id; + const skipUpload = + !args.force && state.deploys.some((d) => d.id === identity.id); + + // Nothing to do: the deploy is already uploaded (and live, if --production). + if (skipUpload && (alreadyLive || !args.production)) { + const urls = deployUrls( + site, + identity.id, + await fetchSystemHost(coreClient, state.pullZoneId), + ); if (output === "json") { logger.log( JSON.stringify( - { site: state.name, id: identity.id, unchanged: true }, + { + site: state.name, + id: identity.id, + unchanged: true, + live: alreadyLive, + production: urls.production ?? null, + preview: urls.preview ?? null, + }, null, 2, ), ); return; } - logger.info( - `No changes — deploy ${identity.id} is already live. Use --force to redeploy.`, - ); + if (alreadyLive) { + logger.info( + `No changes: deploy ${identity.id} is already live. Use --force to redeploy.`, + ); + } else { + logger.info( + `No changes: deploy ${identity.id} is already uploaded. Publish it with \`bunny sites deploy --production\`.`, + ); + } + if (urls.preview) logger.log(` Preview: ${urls.preview}`); return; } - const uploadSpin = spinner(`Uploading ${files.length} files...`); - uploadSpin.start(); - try { - await uploadDeploy(connection, identity.id, files, { - onFileUploaded: (done, total) => { - uploadSpin.text = `Uploading ${done}/${total} files (${formatBytes(totalBytes)} total)...`; - }, - }); - } finally { - uploadSpin.stop(); - } + if (!skipUpload) { + const uploadSpin = spinner(`Uploading ${files.length} files...`); + uploadSpin.start(); + try { + await uploadDeploy(connection, identity.id, files, { + onFileUploaded: (done, total) => { + uploadSpin.text = `Uploading ${done}/${total} files (${formatBytes(totalBytes)} total)...`; + }, + }); + } finally { + uploadSpin.stop(); + } - // Record the deploy. A re-deployed ID keeps its slot but gets fresh metadata. - const record: DeployRecord = { - id: identity.id, - createdAt: new Date().toISOString(), - source: identity.source, - gitSha: identity.gitSha, - dirty: identity.dirty, - files: files.length, - bytes: totalBytes, - envHash: buildEnvHash, - }; - state.deploys = [ - record, - ...state.deploys.filter((d) => d.id !== record.id), - ]; - if (args.promote !== false) { + // Record the deploy. A re-deployed ID keeps its slot but gets fresh metadata. + const record: DeployRecord = { + id: identity.id, + createdAt: new Date().toISOString(), + source: identity.source, + gitSha: identity.gitSha, + dirty: identity.dirty, + files: files.length, + bytes: totalBytes, + }; + state.deploys = [ + record, + ...state.deploys.filter((d) => d.id !== record.id), + ]; + } + if (args.production) { if (state.current && state.current !== identity.id) { state.previous = state.current; } @@ -241,8 +274,8 @@ export const sitesDeployCommand = defineCommand({ } await writeRemoteState(connection, state, etag); - if (args.promote !== false) { - const promoteSpin = spinner("Promoting to production..."); + if (args.production) { + const promoteSpin = spinner("Publishing to production..."); promoteSpin.start(); try { await promoteDeploy({ @@ -256,19 +289,11 @@ export const sitesDeployCommand = defineCommand({ } } - // The system hostname comes from the pull zone; tolerate a fetch failure. - let systemHost: string | undefined; - try { - const { data } = await coreClient.GET("/pullzone/{id}", { - params: { path: { id: state.pullZoneId } }, - }); - systemHost = - (data?.Hostnames ?? []).find((h) => h.IsSystemHostname)?.Value ?? - undefined; - } catch { - // URLs are informational only. - } - const urls = deployUrls(site, identity.id, systemHost); + const urls = deployUrls( + site, + identity.id, + await fetchSystemHost(coreClient, state.pullZoneId), + ); if (output === "json") { logger.log( @@ -279,7 +304,7 @@ export const sitesDeployCommand = defineCommand({ source: identity.source, files: files.length, bytes: totalBytes, - promoted: args.promote !== false, + promoted: args.production === true, production: urls.production ?? null, preview: urls.preview ?? null, }, @@ -290,17 +315,22 @@ export const sitesDeployCommand = defineCommand({ return; } - logger.success( - `Deployed ${identity.id} (${files.length} files, ${formatBytes(totalBytes)}).`, - ); - if (args.promote !== false) { + if (skipUpload) { + logger.success(`Deploy ${identity.id} is now live.`); + } else { + logger.success( + `Deployed ${identity.id} (${files.length} files, ${formatBytes(totalBytes)}).`, + ); + } + if (args.production) { if (urls.production) logger.info(`Production: ${urls.production}`); + if (urls.preview) logger.log(` Preview: ${urls.preview}`); } else { + if (urls.preview) logger.info(`Preview: ${urls.preview}`); logger.info( - `Uploaded without promoting. Publish it with \`bunny sites deployments publish ${identity.id}\`.`, + `Publish it with \`bunny sites deploy --production\` or \`bunny sites deployments publish ${identity.id}\`.`, ); } - if (urls.preview) logger.log(` Preview: ${urls.preview}`); await offerLink(); }, diff --git a/packages/cli/src/commands/sites/domains/index.ts b/packages/cli/src/commands/sites/domains/index.ts index 62362fe6..555d1304 100644 --- a/packages/cli/src/commands/sites/domains/index.ts +++ b/packages/cli/src/commands/sites/domains/index.ts @@ -35,7 +35,6 @@ async function resolveSitePullZone( return { pullZoneId: site.state.pullZoneId, coreClient }; } -/** True for hostnames that are themselves preview infrastructure. */ function isPreviewHost(hostname: string): boolean { return ( hostname.startsWith("*.") || diff --git a/packages/cli/src/commands/sites/env/index.ts b/packages/cli/src/commands/sites/env/index.ts deleted file mode 100644 index d06a6500..00000000 --- a/packages/cli/src/commands/sites/env/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineNamespace } from "../../../core/define-namespace.ts"; -import { sitesEnvListCommand } from "./list.ts"; -import { sitesEnvPullCommand } from "./pull.ts"; -import { sitesEnvRemoveCommand } from "./remove.ts"; -import { sitesEnvSetCommand } from "./set.ts"; - -export const sitesEnvNamespace = defineNamespace( - "env", - "Manage a site's build-time environment variables.", - [ - sitesEnvSetCommand, - sitesEnvListCommand, - sitesEnvRemoveCommand, - sitesEnvPullCommand, - ], -); diff --git a/packages/cli/src/commands/sites/env/list.ts b/packages/cli/src/commands/sites/env/list.ts deleted file mode 100644 index 9f22161b..00000000 --- a/packages/cli/src/commands/sites/env/list.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { createCoreClient } from "@bunny.net/openapi-client"; -import { resolveConfig } from "../../../config/index.ts"; -import { clientOptions } from "../../../core/client-options.ts"; -import { defineCommand } from "../../../core/define-command.ts"; -import { formatTable, maskSecret } from "../../../core/format.ts"; -import { logger } from "../../../core/logger.ts"; -import { spinner } from "../../../core/ui.ts"; -import { readRemoteEnv } from "../api.ts"; -import { - type SiteSelectorArgs, - selectSite, - siteOptionBuilder, -} from "../interactive.ts"; - -interface ListArgs extends SiteSelectorArgs { - show?: boolean; -} - -export const sitesEnvListCommand = defineCommand({ - command: "list", - aliases: ["ls"], - describe: "List a site's build-time environment variables.", - examples: [ - ["$0 sites env list", "List variables (values masked)"], - ["$0 sites env list --show", "List with values revealed"], - ], - - builder: (yargs) => - siteOptionBuilder(yargs).option("show", { - type: "boolean", - default: false, - describe: "Reveal values instead of masking them", - }), - - handler: async (args) => { - const { profile, output, verbose, apiKey } = args; - const config = resolveConfig(profile, apiKey, verbose); - const client = createCoreClient(clientOptions(config, verbose)); - - const { site, offerLink } = await selectSite(client, { - site: args.site, - link: args.link, - output, - }); - - const spin = spinner("Fetching variables..."); - spin.start(); - let env: Record; - try { - env = await readRemoteEnv(site.connection); - } finally { - spin.stop(); - } - - const entries = Object.entries(env); - const display = (value: string) => (args.show ? value : maskSecret(value)); - - if (output === "json") { - logger.log( - JSON.stringify( - Object.fromEntries(entries.map(([k, v]) => [k, display(v)])), - null, - 2, - ), - ); - return; - } - - if (entries.length === 0) { - logger.info("No variables set."); - logger.dim(" Set one with `bunny sites env set `."); - await offerLink(); - return; - } - - logger.log( - formatTable( - ["Name", "Value"], - entries.map(([k, v]) => [k, display(v)]), - output, - ), - ); - if (!args.show) { - logger.dim(" Values are masked — pass --show to reveal them."); - } - - await offerLink(); - }, -}); diff --git a/packages/cli/src/commands/sites/env/pull.test.ts b/packages/cli/src/commands/sites/env/pull.test.ts deleted file mode 100644 index 5ec5031b..00000000 --- a/packages/cli/src/commands/sites/env/pull.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { expect, test } from "bun:test"; -import { toDotenv } from "./pull.ts"; - -test("toDotenv sorts keys and quotes values that need it", () => { - expect( - toDotenv({ - B_URL: "https://api.example.com/v1", - A_PLAIN: "simple-value", - C_SPACED: "hello world", - D_QUOTE: 'say "hi"', - }), - ).toBe( - [ - "A_PLAIN=simple-value", - "B_URL=https://api.example.com/v1", - 'C_SPACED="hello world"', - 'D_QUOTE="say \\"hi\\""', - "", - ].join("\n"), - ); -}); - -test("toDotenv of an empty env is just a newline", () => { - expect(toDotenv({})).toBe("\n"); -}); diff --git a/packages/cli/src/commands/sites/env/pull.ts b/packages/cli/src/commands/sites/env/pull.ts deleted file mode 100644 index 640533d2..00000000 --- a/packages/cli/src/commands/sites/env/pull.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { existsSync } from "node:fs"; -import { resolve } from "node:path"; -import { createCoreClient } from "@bunny.net/openapi-client"; -import { resolveConfig } from "../../../config/index.ts"; -import { clientOptions } from "../../../core/client-options.ts"; -import { defineCommand } from "../../../core/define-command.ts"; -import { UserError } from "../../../core/errors.ts"; -import { logger } from "../../../core/logger.ts"; -import { spinner } from "../../../core/ui.ts"; -import { readRemoteEnv } from "../api.ts"; -import { - type SiteSelectorArgs, - selectSite, - siteOptionBuilder, -} from "../interactive.ts"; - -interface PullArgs extends SiteSelectorArgs { - file?: string; - force?: boolean; -} - -/** Serialize env pairs as dotenv lines, quoting anything that needs it. */ -export function toDotenv(env: Record): string { - return `${Object.entries(env) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, value]) => - /^[A-Za-z0-9_./:@-]*$/.test(value) - ? `${key}=${value}` - : `${key}=${JSON.stringify(value)}`, - ) - .join("\n")}\n`; -} - -export const sitesEnvPullCommand = defineCommand({ - command: "pull [file]", - describe: "Write a site's build-time env to a local dotenv file.", - examples: [ - ["$0 sites env pull", "Write to .env (refuses to overwrite)"], - ["$0 sites env pull .env.local --force", "Overwrite a specific file"], - ], - - builder: (yargs) => - siteOptionBuilder( - yargs.positional("file", { - type: "string", - describe: "Output file (default: .env)", - }), - ).option("force", { - alias: "f", - type: "boolean", - describe: "Overwrite the file if it exists", - }), - - handler: async (args) => { - const { profile, output, verbose, apiKey } = args; - const target = resolve(args.file ?? ".env"); - if (existsSync(target) && !args.force) { - throw new UserError( - `${target} already exists.`, - "Pass --force to overwrite it.", - ); - } - - const config = resolveConfig(profile, apiKey, verbose); - const client = createCoreClient(clientOptions(config, verbose)); - - const { site, offerLink } = await selectSite(client, { - site: args.site, - link: args.link, - output, - }); - - const spin = spinner("Fetching variables..."); - spin.start(); - let env: Record; - try { - env = await readRemoteEnv(site.connection); - } finally { - spin.stop(); - } - - await Bun.write(target, toDotenv(env), { mode: 0o600 }); - - if (output === "json") { - logger.log( - JSON.stringify( - { - site: site.state.name, - file: target, - variables: Object.keys(env).length, - }, - null, - 2, - ), - ); - return; - } - - logger.success( - `Wrote ${Object.keys(env).length} variable(s) to ${target}.`, - ); - - await offerLink(); - }, -}); diff --git a/packages/cli/src/commands/sites/env/remove.ts b/packages/cli/src/commands/sites/env/remove.ts deleted file mode 100644 index 1b5da72a..00000000 --- a/packages/cli/src/commands/sites/env/remove.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { createCoreClient } from "@bunny.net/openapi-client"; -import { resolveConfig } from "../../../config/index.ts"; -import { clientOptions } from "../../../core/client-options.ts"; -import { defineCommand } from "../../../core/define-command.ts"; -import { UserError } from "../../../core/errors.ts"; -import { logger } from "../../../core/logger.ts"; -import { confirm, spinner } from "../../../core/ui.ts"; -import { readRemoteEnv, writeRemoteEnv } from "../api.ts"; -import { - type SiteSelectorArgs, - selectSite, - siteOptionBuilder, -} from "../interactive.ts"; - -interface RemoveArgs extends SiteSelectorArgs { - name: string; - force?: boolean; -} - -export const sitesEnvRemoveCommand = defineCommand({ - command: "remove ", - aliases: ["rm"], - describe: "Remove a build-time environment variable from a site.", - examples: [ - ["$0 sites env remove VITE_API_URL", "Remove a variable"], - ["$0 sites env remove VITE_API_URL --force", "Skip confirmation"], - ], - - builder: (yargs) => - siteOptionBuilder( - yargs.positional("name", { - type: "string", - describe: "Variable name to remove", - demandOption: true, - }), - ).option("force", { - alias: "f", - type: "boolean", - describe: "Skip the confirmation prompt", - }), - - handler: async (args) => { - const { profile, output, verbose, apiKey } = args; - const config = resolveConfig(profile, apiKey, verbose); - const client = createCoreClient(clientOptions(config, verbose)); - - const { site, offerLink } = await selectSite(client, { - site: args.site, - link: args.link, - output, - }); - - const env = await readRemoteEnv(site.connection); - if (!(args.name in env)) { - throw new UserError( - `Variable "${args.name}" is not set for ${site.state.name}.`, - ); - } - - const proceed = await confirm(`Remove ${args.name}?`, { - force: args.force, - }); - if (!proceed) { - logger.log("Cancelled."); - return; - } - - const spin = spinner("Removing variable..."); - spin.start(); - try { - delete env[args.name]; - await writeRemoteEnv(site.connection, env); - } finally { - spin.stop(); - } - - if (output === "json") { - logger.log( - JSON.stringify( - { site: site.state.name, name: args.name, removed: true }, - null, - 2, - ), - ); - return; - } - - logger.success(`Removed ${args.name}.`); - - await offerLink(); - }, -}); diff --git a/packages/cli/src/commands/sites/env/set.ts b/packages/cli/src/commands/sites/env/set.ts deleted file mode 100644 index 4a169a30..00000000 --- a/packages/cli/src/commands/sites/env/set.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { createCoreClient } from "@bunny.net/openapi-client"; -import prompts from "prompts"; -import { resolveConfig } from "../../../config/index.ts"; -import { clientOptions } from "../../../core/client-options.ts"; -import { defineCommand } from "../../../core/define-command.ts"; -import { UserError } from "../../../core/errors.ts"; -import { logger } from "../../../core/logger.ts"; -import { spinner } from "../../../core/ui.ts"; -import { readRemoteEnv, writeRemoteEnv } from "../api.ts"; -import { - type SiteSelectorArgs, - selectSite, - siteOptionBuilder, -} from "../interactive.ts"; - -const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; - -interface SetArgs extends SiteSelectorArgs { - name?: string; - value?: string; -} - -/** - * Set a build-time variable for a site (stored at `_bunny/env.json`, which - * the router 403-blocks). These are merged into the environment of - * `sites deploy --build` — they are NOT runtime env and NOT a secret store: - * anything the build reads can end up in the shipped bundle. - */ -export const sitesEnvSetCommand = defineCommand({ - command: "set [name] [value]", - describe: "Set a build-time environment variable for a site.", - examples: [ - [ - '$0 sites env set VITE_API_URL "https://api.example.com"', - "Set a variable", - ], - ["$0 sites env set", "Interactive mode"], - ], - - builder: (yargs) => - siteOptionBuilder( - yargs - .positional("name", { type: "string", describe: "Variable name" }) - .positional("value", { type: "string", describe: "Variable value" }), - ), - - handler: async (args) => { - const { profile, output, verbose, apiKey } = args; - const config = resolveConfig(profile, apiKey, verbose); - const client = createCoreClient(clientOptions(config, verbose)); - - const { site, offerLink } = await selectSite(client, { - site: args.site, - link: args.link, - output, - }); - - let name = args.name; - if (!name) { - const { value } = await prompts({ - type: "text", - name: "value", - message: "Variable name:", - }); - name = value; - } - if (!name) throw new UserError("Variable name is required."); - if (!ENV_NAME_RE.test(name)) { - throw new UserError( - `"${name}" is not a valid environment variable name.`, - "Names must start with a letter or underscore and contain only letters, digits, and underscores.", - ); - } - - let value = args.value; - if (value === undefined) { - const { value: prompted } = await prompts({ - type: "text", - name: "value", - message: "Variable value:", - }); - value = prompted; - } - if (value === undefined) throw new UserError("Variable value is required."); - - const spin = spinner("Saving variable..."); - spin.start(); - try { - const env = await readRemoteEnv(site.connection); - env[name] = value; - await writeRemoteEnv(site.connection, env); - } finally { - spin.stop(); - } - - if (output === "json") { - logger.log(JSON.stringify({ site: site.state.name, name }, null, 2)); - return; - } - - logger.success(`Set ${name} for ${site.state.name}.`); - logger.warn( - "Build-time env is baked into the deployed bundle — don't store runtime secrets here.", - ); - - await offerLink(); - }, -}); diff --git a/packages/cli/src/commands/sites/index.ts b/packages/cli/src/commands/sites/index.ts index aee551ee..a6191344 100644 --- a/packages/cli/src/commands/sites/index.ts +++ b/packages/cli/src/commands/sites/index.ts @@ -1,10 +1,10 @@ import { defineNamespace } from "../../core/define-namespace.ts"; +import { sitesCiNamespace } from "./ci/index.ts"; import { sitesCreateCommand } from "./create.ts"; import { sitesDeleteCommand } from "./delete.ts"; import { sitesDeployCommand } from "./deploy.ts"; import { sitesDeploymentsNamespace } from "./deployments/index.ts"; import { sitesDomainsCommands } from "./domains/index.ts"; -import { sitesEnvNamespace } from "./env/index.ts"; import { sitesLinkCommand } from "./link.ts"; import { sitesListCommand } from "./list.ts"; import { sitesShowCommand } from "./show.ts"; @@ -18,7 +18,7 @@ export const sitesNamespace = defineNamespace("sites", false, [ sitesDeployCommand, sitesDeploymentsNamespace, ...sitesDomainsCommands, - sitesEnvNamespace, + sitesCiNamespace, sitesLinkCommand, sitesUnlinkCommand, sitesUpgradeCommand, diff --git a/packages/cli/src/core/hostnames/bunny-dns.test.ts b/packages/cli/src/core/hostnames/bunny-dns.test.ts index 07ec7c2c..fe8ec80d 100644 --- a/packages/cli/src/core/hostnames/bunny-dns.test.ts +++ b/packages/cli/src/core/hostnames/bunny-dns.test.ts @@ -133,6 +133,7 @@ describe("offerBunnyDnsRecord", () => { recordName: "shop", existing: { Type: 0, Name: "shop", Value: "192.0.2.4" }, delegated: true, + nameservers: ["kiki.bunny.net", "coco.bunny.net"], }, }), ).rejects.toThrow(/has no ID/); diff --git a/packages/cli/src/core/hostnames/bunny-dns.ts b/packages/cli/src/core/hostnames/bunny-dns.ts index ab02d362..747a51f3 100644 --- a/packages/cli/src/core/hostnames/bunny-dns.ts +++ b/packages/cli/src/core/hostnames/bunny-dns.ts @@ -38,6 +38,8 @@ export interface BunnyDnsMatch { existing: DnsRecordModel | null; /** True when the registrar delegates to bunny's nameservers — if false, records here aren't publicly resolvable yet. */ delegated: boolean; + /** The nameservers the registrar should delegate to (custom ones when the zone has them). */ + nameservers: readonly string[]; } /** @@ -74,10 +76,8 @@ export async function findBunnyDnsZone( null; // Resolve the live registrar delegation; NameserversDetected defaults to true on a fresh zone. - const { status } = await checkDelegation( - best.Domain ?? domain, - expectedNameservers(data ?? {}), - ); + const nameservers = expectedNameservers(data ?? {}); + const { status } = await checkDelegation(best.Domain ?? domain, nameservers); return { zoneId: best.Id, @@ -85,6 +85,7 @@ export async function findBunnyDnsZone( recordName, existing, delegated: status === "bunny", + nameservers, }; } diff --git a/packages/cli/src/core/hostnames/flow.ts b/packages/cli/src/core/hostnames/flow.ts index 1f37b300..9be1c22e 100644 --- a/packages/cli/src/core/hostnames/flow.ts +++ b/packages/cli/src/core/hostnames/flow.ts @@ -308,7 +308,14 @@ export async function offerBunnyDnsThenSsl(opts: { `${match.zoneDomain} isn't delegated to bunny.net's nameservers yet.`, ); logger.dim( - " The record is set, but won't resolve (or get a certificate) until you point your registrar at bunny.net.", + " The record is set, but won't resolve (or get a certificate) until your registrar points at bunny.net.", + ); + logger.log(); + logger.log("Set these nameservers at your registrar:"); + for (const ns of match.nameservers) logger.accent(` ${ns}`); + logger.log(); + logger.dim( + `Check delegation later with:\n bunny dns zones ns ${match.zoneDomain}`, ); printSslHint(opts.sslHint); return false; diff --git a/skills/bunny-cli/SKILL.md b/skills/bunny-cli/SKILL.md index c4bb7331..ad224b74 100644 --- a/skills/bunny-cli/SKILL.md +++ b/skills/bunny-cli/SKILL.md @@ -49,7 +49,7 @@ 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 # deploy + promote to production +bunny sites deploy ./dist --production # deploy + publish as the live site bunny sites deployments publish --previous --force # instant rollback ``` @@ -61,7 +61,7 @@ Use this to route to the correct reference file: - **Database management (create, list, show, link, delete, shell, studio, regions, tokens)** -> `references/database.md` - **DNS (zones, delegation checks, records, presets, BIND import/export, DNSSEC, logging, Scriptable DNS scripts)** -> `references/dns.md` - **Edge Scripts (init, create, deploy, link, stats, deployments/rollback, env vars, custom domains)** -> `references/scripts.md` -- **Static sites (create, deploy, rollback, previews, custom domains, build-time env)** -> `references/sites.md` +- **Static sites (create, deploy, rollback, previews, custom domains)** -> `references/sites.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 0bcd5b9e..e2a23480 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -2,7 +2,7 @@ All site commands live under `bunny sites`. A site is one storage zone (files) + one pull zone (CDN) + one middleware router script, provisioned together by `sites create`. Deploys are immutable directories; promoting or rolling back flips a router env var and purges the cache — no files move, so it's instant. -Most commands accept an optional site (a trailing `[site]` positional, or the `--site` flag on commands whose positionals are taken, like `deploy` and `env`). When omitted, the site resolves in this order: +Most commands accept an optional site (a trailing `[site]` positional, or the `--site` flag on commands whose positionals are taken, like `deploy`). When omitted, the site resolves in this order: 1. Explicit name or storage zone ID 2. `.bunny/site.json` manifest (written by `bunny sites link` or `bunny sites create`) @@ -14,7 +14,8 @@ Most commands accept an optional site (a trailing `[site]` positional, or the `- ```bash # New site: provision, deploy, iterate bunny sites create my-site # served at https://my-site.b-cdn.net -bunny sites deploy ./dist # uploads + promotes to production +bunny sites deploy ./dist # uploads to a preview URL +bunny sites deploy ./dist --production # uploads + publishes as the live site # Build-and-deploy in one step (build command from bunny.jsonc or the flag) bunny sites deploy --build # runs `sites.build`, deploys `sites.dir` @@ -41,17 +42,18 @@ bunny sites domains add example.com --wait # also attaches *.preview.example.com ## `bunny sites create` — Provision a site ```bash +bunny sites create # prompts for a name (directory-name suggestion), then a custom domain bunny sites create my-site bunny sites create my-site --region NY 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 | -| `--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.`) after provisioning; interactive runs prompt for one when omitted | +| `--link` | Link this directory (default true; `--no-link` to skip) | Site names become the storage zone, pull zone, and `.b-cdn.net` subdomain: 3–60 lowercase letters, digits, and dashes. Creation is idempotent — a failed create re-runs cleanly, reusing whatever was already provisioned. @@ -60,8 +62,8 @@ Site names become the storage zone, pull zone, and `.b-cdn.net` subdomain: ## `bunny sites deploy` — Deploy a directory ```bash -bunny sites deploy ./dist -bunny sites deploy ./dist --no-promote # upload only; publish later +bunny sites deploy ./dist # preview only +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 ``` @@ -72,11 +74,11 @@ bunny sites deploy ./out --build "npm run build" --env VITE_FLAG=1 | `--build` | Run a build first (bare flag uses `sites.build` from bunny.jsonc) | | `--env` | Build-time env override `KEY=VALUE` (repeatable; requires `--build`) | | `--env-file` | Dotenv file of build-time overrides (requires `--build`) | -| `--no-promote` | Upload without pointing production at it | +| `--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) | -With `--build`, the site's remote env (`sites env`) is merged with `--env`/`--env-file` overrides into the build's environment, and the env fingerprint is recorded on the deploy. +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. --- @@ -106,16 +108,15 @@ Adding a domain also attaches `*.preview.` for per-deploy preview URLs ( --- -## `bunny sites env` — Build-time environment variables +## `bunny sites ci init` — GitHub Actions deployments ```bash -bunny sites env set VITE_API_URL "https://api.example.com" -bunny sites env list # values masked; --show reveals -bunny sites env remove VITE_API_URL -bunny sites env pull .env.local --force +bunny sites ci init # detect the framework, write .github/workflows/bunny-sites.yml +bunny sites ci init --framework astro # skip detection (astro, vite, react-router, next, sveltekit, vitepress, docusaurus, eleventy, jekyll, hugo, static) +bunny sites ci init --site my-site --force # overwrite an existing workflow ``` -**These are build-time values, not a secret store** — anything the build reads can end up in the shipped bundle. They are stored inside the site's storage zone (never served) and merged into `sites deploy --build` runs. +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. 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. --- From d68288565f2598fb99353af5e8f692537c7c0420 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Mon, 13 Jul 2026 21:30:03 +0100 Subject: [PATCH 05/24] remove old plans --- docs/plans/deploy-site-action.md | 174 ------------------------------ docs/plans/sites-github.md | 166 ----------------------------- docs/plans/sites.md | 177 ------------------------------- 3 files changed, 517 deletions(-) delete mode 100644 docs/plans/deploy-site-action.md delete mode 100644 docs/plans/sites-github.md delete mode 100644 docs/plans/sites.md diff --git a/docs/plans/deploy-site-action.md b/docs/plans/deploy-site-action.md deleted file mode 100644 index aa65ec20..00000000 --- a/docs/plans/deploy-site-action.md +++ /dev/null @@ -1,174 +0,0 @@ -# deploy-site action: implementation plan (hand to an agent) - -Build a new `deploy-site` action in the https://github.com/BunnyWay/actions repository. It wraps the `@bunny.net/cli` `sites deploy` command so a workflow can deploy a static site to bunny.net with one `uses:` step, and it owns the PR preview comment. This plan is self-contained; the companion design doc is `docs/plans/sites-github.md` in the CLI repo. - -## What it must do - -- Run `sites deploy` against a built directory: preview by default, production when asked. -- Parse the CLI's JSON output into action outputs. -- On `pull_request` events, upsert one sticky comment on the PR with the preview URL, updated per commit. -- Never reimplement deploy logic against the bunny API: the CLI is the single deploy path. - -## Target repo conventions (observed, follow them) - -- pnpm workspace monorepo; one folder per action (`deploy-script/`, `container-update-image/`). -- node20 JavaScript actions written in TypeScript under `/src/`, bundled with ncc into `/.lib-action/index.js`; `action.yml` points `runs.main` there. -- jest for tests, eslint config per action, `README.md` + `CHANGELOG.md` per action. -- Versioning via changesets (`pnpm changeset`); releases tag both `deploy-script@0.5.0` and `deploy-script_0.5.0` styles. The underscore tag is what `uses:` references should use (no double `@`). -- Branding block in `action.yml` (orange, an icon). - -Start by copying the `deploy-script/` folder layout and its build/test wiring. - -## action.yml - -```yaml -name: Deploy Site to Bunny -author: Bunny Devs -description: Deploy a static site to bunny.net with preview URLs per deploy. - -inputs: - site: - description: Site name or storage zone ID. - required: true - directory: - description: Built output directory to deploy (e.g. dist). - required: true - api_key: - description: bunny.net API key (store it as a repository secret). - required: true - production: - description: Publish this deploy as the live site ("true"/"false", default preview only). - required: false - default: "false" - comment: - description: Upsert a sticky PR comment with the preview URL on pull_request events. - required: false - default: "true" - github_token: - description: Token for the PR comment (needs pull-requests write). - required: false - default: ${{ github.token }} - cli_version: - description: "@bunny.net/cli version range to run (pin bumped per action release)." - required: false - default: "0.10" - force: - description: Redeploy even when content is unchanged. - required: false - default: "false" - -outputs: - deploy-id: - description: The deploy ID (git short sha on clean checkouts). - preview-url: - description: Preview URL for this deploy (empty when the site has no host yet). - production-url: - description: Production URL (set when production input was true). - unchanged: - description: '"true" when the content was already deployed and nothing ran.' - -runs: - using: "node20" - main: ".lib-action/index.js" - -branding: - color: "orange" - icon: "upload-cloud" -``` - -## The CLI contract - -Invocation (spawn, do not shell-interpolate user input): - -``` -npx --yes @bunny.net/cli@ sites deploy --site [--production] [--force] --output json -``` - -- Env: pass through `process.env` plus `BUNNY_API_KEY` from the `api_key` input. Call `core.setSecret(apiKey)` first. -- The published CLI ships per-platform compiled binaries behind a Node launcher, so `npx` works on GitHub runners without installing Bun. Primary target is `ubuntu-latest`; macOS works; verify Windows binary availability before claiming support in the README (document ubuntu/macos only if not). -- JSON goes to stdout; human/progress output and errors go to stderr. Parse stdout only. Non-zero exit means the deploy failed: fail the action with the stderr tail. -- Output shapes (both must be handled): - - Deployed: `{ "site": string, "id": string, "source": "git"|"content", "files": number, "bytes": number, "promoted": boolean, "production": string|null, "preview": string|null }` - - No-op (content already deployed): `{ "site": string, "id": string, "unchanged": true, "live": boolean, "production": string|null, "preview": string|null }` -- `production`/`preview` are full URLs or null. Map null to empty-string outputs. - -## Main logic (src/main.ts) - -1. Read and validate inputs (`site`, `directory` non-empty; `directory` must exist: fail early with a clear message). -2. `core.setSecret(api_key)`; spawn the CLI via `@actions/exec` with `listeners.stdout`/`stderr` capture and `env: { ...process.env, BUNNY_API_KEY: apiKey }`. -3. On non-zero exit: `core.setFailed` with the last ~20 lines of stderr; do not attempt the comment. -4. Parse stdout JSON (tolerate leading noise by taking the substring from the first `{`; the CLI keeps stdout clean, this is belt and braces). Set the four outputs; also write a job summary line via `core.summary` (nice, cheap). -5. Comment step, only when all of: `comment` input is true, `context.eventName === "pull_request"`, and a preview URL exists. - - Marker: `` as the first line of the comment body. This exact format matters: the CLI repo's future GitHub App takes over the same marker. - - Upsert: `octokit.paginate(rest.issues.listComments, { issue_number })`, find a comment whose body starts with the marker, then `updateComment` or `createComment`. - - Body: - - ``` - - **bunny.net** deployed a preview of `my-site` - - | Deploy | Preview | Updated | - | ------ | ------- | ------- | - | `a1b2c3d4` | https://dpl-a1b2c3d4.preview.example.com | 2026-07-13 14:02 UTC | - ``` - - - Comment failures are `core.warning`, never a job failure (the deploy already succeeded). - -6. No GitHub Deployments API calls in this version (that is the planned V2; leave the module boundary so it can slot in). - -## Tests (jest, mirror deploy-script's setup) - -- JSON parsing: both output shapes, null URLs, garbage stdout fails cleanly. -- Exec wiring: mocked `@actions/exec` asserts argv (`--production` only when input true, `--force` only when true) and that `BUNNY_API_KEY` is in the env. -- Comment upsert: mocked octokit asserts create-when-absent, update-when-marker-found, and skip on non-PR events / `comment: false` / missing preview URL. -- Failure path: non-zero exit sets failed and skips the comment. - -## README.md for the action - -Include: the minimal example below, the input/output tables, the fork-PR note, and the secret setup (`gh secret set BUNNY_API_KEY`). - -```yaml -name: Deploy site -on: - push: - branches: [main] - pull_request: - -concurrency: - group: bunny-sites-${{ github.ref }} - cancel-in-progress: true - -jobs: - deploy: - runs-on: ubuntu-latest - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository - permissions: - contents: read - pull-requests: write - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - - run: bun install --frozen-lockfile - - run: bun run build - - uses: BunnyWay/actions/deploy-site@deploy-site_1.0.0 - with: - site: my-site - directory: dist - production: ${{ github.event_name == 'push' }} - api_key: ${{ secrets.BUNNY_API_KEY }} -``` - -Also link to the CLI repo's framework examples and mention `bunny sites ci init` scaffolds this file. - -## Release + ordering - -1. Land the action with a changeset; release as `deploy-site@1.0.0` / tag `deploy-site_1.0.0` via the repo's existing release process. -2. Update the repo root README's action list. -3. Ordering with the CLI: the CLI's `sites ci init` scaffolds workflows referencing `BunnyWay/actions/deploy-site@deploy-site_1.0.0` (constant `DEPLOY_SITE_ACTION` in `packages/cli/src/commands/sites/ci/workflow.ts`). Publish the action before (or together with) the CLI release that ships `sites ci init`, and keep the `cli_version` default pinned to the CLI minor that includes `bunny sites` (0.10 at time of writing; bump on later releases). - -## Guardrails - -- Never log the API key; `core.setSecret` before any exec. -- Spawn with an argv array (no shell), inputs never string-concatenated into a command line. -- The action must work when the PR has no custom domain (path-based preview URL) and when the site has no hostname at all (preview null: skip the comment, still set outputs). -- Keep the action deploy-only: no purge/publish/rollback inputs in 1.0 (open question in the design doc). diff --git a/docs/plans/sites-github.md b/docs/plans/sites-github.md deleted file mode 100644 index b4c62a9e..00000000 --- a/docs/plans/sites-github.md +++ /dev/null @@ -1,166 +0,0 @@ -# Sites + GitHub: preview deploys on PRs, production on main (plan) - -Status: V1 CLI side (scaffolder, framework detection, state-merge hardening) is implemented on the sites branch; the action itself is planned in `docs/plans/deploy-site-action.md`. Companion to `docs/plans/sites.md`. - -## Goal - -- Open a PR (or push a new commit to one): the site is built and deployed to a preview URL. -- Merge to `main`: the site is built, deployed, and published as the live site. -- The PR gets a comment with the preview URL, updated per commit. -- No bespoke "deployments API" for the workflow to call, and no bunny-hosted service in V1. The workflow builds and runs the CLI; the CLI talks to the same storage/core/compute APIs it uses today. - -## V1: a `deploy-site` action + example workflows per framework, nothing hosted - -V1 has two deliverables and no hosted pieces: - -1. **`BunnyWay/actions/deploy-site`**: a small action in the existing [BunnyWay/actions](https://github.com/BunnyWay/actions) repo that wraps `bunny sites deploy` and owns the PR comment. -2. **Per-framework example workflows** (Jekyll, Astro, React Router, ...) that are just "build, then use the action". - -Everything the flow needs already exists in the CLI: - -- Preview is the deploy default; `--production` publishes. That maps 1:1 onto `pull_request` vs `push` to `main`. -- Deploy IDs are git short shas on clean checkouts (CI is always clean), so each commit gets a stable preview URL. -- `deploy --output json` emits `{ id, production, preview, promoted, unchanged }`, which is the action's entire contract with the CLI. -- The site is named explicitly (`site` input / `--site` flag); nothing needs to be committed to the repo and the interactive picker never enters CI. (`bunny.jsonc` is experimental and deliberately not part of this flow.) -- The published CLI ships compiled per-platform binaries behind a Node launcher, so `npx @bunny.net/cli` works on any runner with Node, no Bun install needed. Jekyll/Hugo/Ruby workflows do not have to pull in a JS toolchain beyond that. - -### The `deploy-site` action (BunnyWay/actions) - -Fits the repo's existing conventions: one folder per action, node20 JS actions bundled with ncc, changesets for versioning, tags like `deploy-site@1.0.0`, used as `BunnyWay/actions/deploy-site@`. - -The action is deliberately a thin shell around the CLI (single deploy path, nothing reimplemented against the API): it runs `npx @bunny.net/cli@ sites deploy --site [--production] --output json`, parses the JSON, and handles the GitHub-side UX with `@actions/core`/`@actions/github`, which is where that logic naturally lives (the CLI stays platform-neutral). - -```yaml -# action.yml sketch -inputs: - site: # site name or storage zone ID (required) - directory: # built output to deploy (required) - production: # "true" publishes as the live site (default: false -> preview) - api_key: # BUNNY_API_KEY (required; see deploy_key note below) - comment: # upsert a sticky PR comment with the preview URL (default: true) - github_token: # for the comment; defaults to github.token - cli_version: # @bunny.net/cli version (default: latest major pin, e.g. "1") - force: # redeploy unchanged content (default: false) -outputs: - deploy-id: - preview-url: - production-url: - unchanged: -``` - -Comment behavior: on `pull_request` events, upsert one sticky comment (marker ``) with the preview URL, deploy ID, and updated time. Posts as `github-actions[bot]` via the workflow token; needs `pull-requests: write`. `comment: false` opts out. Comment failures warn, never fail the deploy. - -The `deploy-script` action there already accepts a `deploy_key` as a scoped alternative to the account `api_key`. Sites should aim for the same shape once a scoped deploy credential exists (see security notes); the input surface leaves room for it. - -### The shared skeleton - -Every framework example is this workflow with a different build block: - -```yaml -# .github/workflows/bunny-sites.yml -name: Deploy site -on: - push: - branches: [main] - pull_request: - -# One deploy at a time per ref; a newer commit cancels the older build. -concurrency: - group: bunny-sites-${{ github.ref }} - cancel-in-progress: true - -jobs: - deploy: - runs-on: ubuntu-latest - # 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 - steps: - - uses: actions/checkout@v4 - - # --- framework-specific setup + build (see matrix below) --- - - uses: oven-sh/setup-bun@v2 - - run: bun install - - run: bun run build - # ----------------------------------------------------------- - - - uses: BunnyWay/actions/deploy-site@deploy-site_1.0.0 - with: - site: my-site - directory: ./dist - production: ${{ github.event_name == 'push' }} - api_key: ${{ secrets.BUNNY_API_KEY }} -``` - -Setup for a user: drop in the workflow file with their site name, `gh secret set BUNNY_API_KEY`, done. - -The docs also show the raw-CLI variant (no action) for people who want full control: the same workflow with the deploy step as `npx @bunny.net/cli sites deploy ./dist --site my-site --output json` plus their own comment step. The action is convenience, not a requirement, and the JSON contract is documented either way. - -### Framework matrix - -Frameworks mostly collapse into a toolchain plus two values (build command, output dir). The published examples are concrete per framework because that is what people search for; internally they are one template over this table: - -| Framework | Setup steps | Build | Output dir | -| -------------------------------------------- | --------------------------------------- | -------------------------- | ----------------- | -| Astro | setup-bun or setup-node | `astro build` | `dist` | -| Vite (React, Vue, Svelte, ...) | setup-bun or setup-node | `vite build` | `dist` | -| React Router (framework mode, SPA/prerender) | setup-bun or setup-node | `react-router build` | `build/client` | -| Next.js (`output: "export"`) | setup-node | `next build` | `out` | -| SvelteKit (`adapter-static`) | setup-bun or setup-node | `vite build` | `build` | -| Eleventy | setup-node | `eleventy` | `_site` | -| Docusaurus | setup-node | `docusaurus build` | `build` | -| VitePress | setup-node | `vitepress build` | `.vitepress/dist` | -| Jekyll | ruby/setup-ruby (`bundler-cache: true`) | `bundle exec jekyll build` | `_site` | -| Hugo | peaceiris/actions-hugo | `hugo --minify` | `public` | -| Plain HTML | none | none | `.` or `public` | - -Node examples use the repo's package manager (detect lockfile: bun/pnpm/yarn/npm) with the matching cache option on the setup action. Non-Node frameworks need no JS setup at all: the action's `npx` call only requires the runner's preinstalled Node. - -### Where the examples live - -1. `deploy-site/README.md` in BunnyWay/actions: the action's own docs with the skeleton (that is where `uses:` consumers land). -2. `skills/bunny-cli/references/sites.md` + this repo's README: the skeleton, the matrix, and the raw-CLI variant (the skill is what agents read). -3. A `docs/examples/github-workflows/` directory in this repo with one complete `.yml` per framework, linked from the README. Cheap to maintain because they differ only in the marked block. -4. Optionally the bunny.net docs site later; out of scope here. - -### Scaffolder (CLI work, optional and independent) - -- `sites create` (interactive, git repo with a GitHub `origin`): after the domain step, ask "Set up GitHub deployments (preview on PRs, production on main)?". -- Framework detection picks the template: `astro`/`@react-router/dev`/`next`/`vite`/`vitepress`/`@sveltejs/kit`/`@11ty/eleventy`/`@docusaurus/core` in `package.json` dependencies, `Gemfile` mentioning `jekyll`, `hugo.toml`/`config.toml` + `content/` for Hugo. Ambiguous or unknown: prompt with a select, defaulting to the plain skeleton with TODO placeholders. -- Writes `.github/workflows/bunny-sites.yml` (never overwrites without confirmation) with the site name, build block, and output dir baked in, using the `deploy-site` action. The site name comes from the site just created (`sites create`) or the resolved site (`sites ci init`); no config file involved. -- Declined, or non-interactive: print the YAML and the secret instructions instead, so nothing is gated on the prompt. -- Standalone `bunny sites ci init` for existing sites; same behavior. -- Ends with the one manual step: `gh secret set BUNNY_API_KEY` (offered as a prompted command when `gh` is installed, printed otherwise). - -Ship order within V1 is flexible: action first, examples second, scaffolder third; each is independently useful. - -### V1 security notes - -- Fork PRs skip via the `if:` guard (no secrets there). Do NOT use `pull_request_target` to work around it: it runs untrusted build scripts with access to `BUNNY_API_KEY`. -- `BUNNY_API_KEY` is the account key, which is broad for a repo secret. Per-site deploy tokens are a worthwhile platform ask (precedent: `deploy-script`'s `deploy_key` input); not a blocker. -- Concurrent deploys: `concurrency` serializes per ref. Cross-PR races are safe for files (distinct `deploys//` prefixes) but the remote-state read-modify-write can drop a deploy record (v1 lock warns and overwrites). Hardening item before advertising CI: retry-with-merge on etag mismatch in `writeRemoteState`. -- `pull_request` checkouts are the synthetic merge commit, so the deploy ID is the merge sha, not the head sha. Fine: the URL flows through the action outputs, and the preview shows what main would look like post-merge. -- The action pins the CLI to a major version (`cli_version` input) so a bad CLI release cannot silently change deploy behavior for every consumer. - -## V2: the action grows GitHub Deployments - -The `deploy-site` action (not the CLI, which stays platform-neutral) additionally creates a GitHub Deployment for the SHA (`environment: preview|production`, `transient_environment` for previews) and marks its status `success` with `environment_url`. GitHub then shows "Deployed to preview" natively in the PR timeline and environment history on the repo. Needs `deployments: write` in the workflow permissions. - -This is also the hand-off point for V3: deployments are the state channel the app subscribes to. - -## V3: the GitHub App (identity upgrade only) - -A public "bunny.net Deployments" app whose only job is reporting UX; deploys never depend on it: - -- Permissions: `pull_requests: write`, `deployments: read`, `metadata: read` (optional `checks: write` later). -- Subscribes to `deployment_status` webhooks (the deployments V2 creates), looks up PRs for the SHA, and takes over the same marker comment as `bunny.net[bot]`. Stateless; the `environment_url` in the payload carries the URL. -- The receiver is GitHub-facing only (the action never calls it) and small enough to host as a bunny Edge Script: HMAC-verify `X-Hub-Signature-256`, exchange the app private key (RS256 JWT via WebCrypto) for an installation token, two REST calls, 200. -- An app cannot replace the build step without bunny running builds on untrusted code, which stays a non-goal. - -## Open questions - -1. Action naming and versioning in BunnyWay/actions: `deploy-site` vs `sites-deploy`; follow the existing `@x.y.z` tag scheme. -2. Which frameworks make the initial examples cut (proposal: Astro, Vite, React Router, Next static export, Jekyll, Hugo, plain HTML; add the rest on demand). -3. Whether the action should also expose `purge`/`publish ` style operations later (rollback from a workflow), or stay deploy-only. diff --git a/docs/plans/sites.md b/docs/plans/sites.md deleted file mode 100644 index 8d6dcfcd..00000000 --- a/docs/plans/sites.md +++ /dev/null @@ -1,177 +0,0 @@ -# Implementation plan: `bunny sites` - -Phased plan for adding the `sites` namespace to the CLI — static-site hosting on top of a storage zone + pull zone + middleware Edge Script router, with atomic deploys, instant rollback, and per-deploy preview URLs. - -Each phase is one reviewable PR. Every path, signature, and endpoint below has been verified against the current `main`. - -## Decisions locked (from the RFC discussion) - -- **One storage zone + one pull zone + one middleware router per site.** No DNS scripting — all deploys share one origin, so there is nothing for DNS to choose between. -- **Preview scheme:** `dpl-{id}.preview.{domain}` — a namespaced wildcard that can't shadow user subdomains, and preview-wide behavior (noindex, etc.) hangs on one host branch in the router. -- **Promote verb:** `sites deployments publish` for consistency with `scripts deployments publish`; `promote` as a hidden alias. -- **Deploy IDs:** git short-sha when the tree is clean, content hash otherwise; identical-hash deploys are no-ops. -- **Site identity:** the storage zone ID is the site ID (it's the root resource; the pull zone and script hang off it). The local manifest and remote state carry the full resource triple. - -## What the repo already gives us (verified) - -| Machinery | Where | Notes | -| ------------------------ | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Command framework | `packages/cli/src/core/define-command.ts`, `define-namespace.ts` | `defineCommand({ command, describe, examples, builder, handler, preRun?, postRun? })`; `defineNamespace(command, describe \| false, subcommands, aliases?)`. Global args (`profile`, `verbose`, `output`, `apiKey`) are merged automatically; central error handling emits JSON errors when `output === "json"` and maps `UserError`/`ApiError` to exit 1, unexpected to exit 2. | -| Root registration | `packages/cli/src/cli.ts` | Two arrays: `commands` (visible) and `experimentalCommands` (registered but hidden from help/landing — `apps`, `registries`, `sandbox`, `storage` live here). `sites` starts experimental, promoted in Phase 5. | -| Middleware scripts | `commands/scripts/constants.ts` | `SCRIPT_TYPE_MIDDLEWARE: EdgeScriptTypes = 2`, typed from `components["schemas"]["EdgeScriptTypes"]` in `@bunny.net/openapi-client/generated/compute.d.ts`. `parseScriptType("middleware") → 2`. | -| Script creation | `commands/scripts/create.ts` | `createScript(opts)` → `POST /compute/script` with `{ Name, ScriptType, CreateLinkedPullZone, LinkedPullZoneName? }`. **The compute API creates the linked pull zone server-side** — see the Phase 0 attach question. | -| Env vars (promote lever) | `commands/scripts/env/set.ts` | `PUT /compute/script/{id}/variables` body `{ Name, DefaultValue }`. A single PUT, **no republish call exists or is needed** at the API level (runtime propagation latency is a Phase 0 question). `fetchEnvEntries(client, id)` in `scripts/api.ts` reads them back. | -| Publish/rollback UX | `commands/scripts/deployments/publish.ts` | `selectScript(client, { id, link, output })` (flag → `.bunny/` manifest → interactive picker with offer-to-link, from `scripts/interactive.ts`) → `confirm(msg, { force })` → POST. `deployments/list.ts` shows the `● Live` / `○ Archived` table style. | -| Storage files API | `commands/storage/files-api.ts` | `connectStorageZone(zone)` (needs the **full** zone record incl. `Password` — `resolveStorageZone`/`fetchStorageZone` in `storage/api.ts` re-fetch by ID to get it), `listFiles`, `uploadFile(zone, remotePath, stream, options?)`, `downloadFile`, `deleteFile`. **Checksums are caller-side**: `storage/file/upload.ts` computes streaming SHA-256 via `Bun.CryptoHasher("sha256")`, `.digest("hex").toUpperCase()`, passed as `UploadOptions.sha256Checksum`. | -| Storage zone creation | `commands/storage/zone/add.ts` | `POST /storagezone` body `{ Name, Region, ReplicationRegions }` on the core client. This file is also the template for create-then-domain orchestration (it composes `createPullZone` + `setupHostname`). | -| Pull zone creation | `core/hostnames/client.ts` | `createPullZone(coreClient, name, storageZoneId)` → `POST /pullzone` with `{ Name, StorageZoneId, OriginType: 2, EnableGeoZone*: true }`. Also `addHostname`, `enableSsl` (`GET /pullzone/loadFreeCertificate` + `POST /pullzone/{id}/setForceSSL`), `fetchPullZoneHostnames`, `normalizeHostname`, `hostnameUrl`, `liveHostnames`. | -| Hostname orchestration | `core/hostnames/flow.ts`, `commands.ts`, `bunny-dns.ts`, `dns.ts` | `setupHostname(opts)` is the full top-level flow (add hostname → Bunny-DNS auto-record or manual CNAME → DNS wait → SSL with retries). `createHostnamesCommands(opts)` is a **namespace factory** returning ready-made `add`/`ssl`/`list`/`remove` commands for any pull-zone-backed resource — its docstring explicitly anticipates new resource types. `findBunnyDnsZone`/`offerBunnyDnsRecord` handle PULLZONE-type DNS records; delegation checks come from `core/dns-nameservers.ts` (`checkDelegation`, `expectedNameservers`). `core/registrar.ts` is RDAP registrar _detection_ only (useful for naming the registrar in NS instructions, nothing more). | -| DNS record presets | `commands/dns/record/presets.ts` | `DnsPreset { id, title, category, params, build(ctx) → AddDnsRecordModel[] }` with `mx/txt/cname/caa` builder helpers. Note: `PRESETS` is a user-facing catalog (email/verification) — the sites apex + wildcard pair should reuse the _builder-helper pattern_ internally, not ship as a public preset. | -| Local manifests | `core/manifest.ts` | `loadManifest` / `saveManifest` (mode 0600) / `removeManifest` / `saveManifestAt` / `resolveManifestId(filename, id, resourceType)` — all keyed by filename under `.bunny/`, walking up the tree. Pattern: each resource exports a manifest filename constant (`SCRIPT_MANIFEST = "script.json"`, `STORAGE_MANIFEST = "storage.json"`). | -| Output/UI | `core/format.ts`, `core/logger.ts`, `core/ui.ts` | `formatTable(headers, rows, format)` / `formatKeyValue(entries, format)` handle `text\|table\|csv\|markdown` but **not** `json` — handlers check `output === "json"` first and `logger.log(JSON.stringify(data, null, 2))`. `logger.log` is the only stdout method (everything else is stderr, so JSON stays clean). `spinner()`, `confirm(msg, { force })`, `isInteractive(output)`, plus `formatBytes`, `progressBar`, `maskSecret` in format.ts. | -| Cache purge | `specs/core.json` | `POST /pullzone/{id}/purgeCache` exists in the spec; **nothing in the CLI calls it yet** — promote will be the first consumer. | -| Clients | `@bunny.net/openapi-client` | `createCoreClient` (storage zones, pull zones, DNS), `createComputeClient` (scripts), `createStorageClient`. Wiring: `resolveConfig(profile, apiKey, verbose)` (from `packages/cli/src/config/index.ts`) → `clientOptions(config, verbose)` → factory. | - -## Phase 0 — Spike: validate platform assumptions (no merge) - -The design rests on six assumptions. Kill or confirm each with a throwaway script/dashboard session before writing CLI code: - -1. **Attach mechanism.** How does a middleware script get onto a _storage-backed_ pull zone? The CLI's only current path is `POST /compute/script` with `CreateLinkedPullZone: true`, where the compute API creates the pull zone itself. Determine which works: - - (a) create the script with a linked pull zone, then repoint that pull zone's origin to the storage zone (`POST /pullzone/{id}` with `{ OriginType: 2, StorageZoneId }`), or - - (b) create the pull zone via `createPullZone()` first, then link the script to it (find the linking field/endpoint in `specs/core.json` / `specs/compute.json`). - The answer decides the `sites create` orchestration order in Phase 1. -2. **Per-request rewrite.** Middleware attached to the pull zone can rewrite the origin request path per-request based on the `Host` header. -3. **Env-var propagation.** Updating `CURRENT_DEPLOY` via `PUT /compute/script/{id}/variables` takes effect without republishing code (the API requires no republish — verified in code), and propagates globally in acceptable time (target: seconds). -4. **Wildcard hostnames + certs.** `*.preview.example.com` can be added via `POST /pullzone/{id}/addHostname`, and cert issuance automates when the zone is on Bunny DNS. Specifically check whether `GET /pullzone/loadFreeCertificate` (what `enableSsl` uses) handles wildcards, or whether a DNS-01 flow needs a different endpoint. -5. **Cache isolation.** Cache keys include the original request URL/host, so previews and production don't cross-contaminate, and one `POST /pullzone/{id}/purgeCache` on promote is sufficient. -6. **Upload throughput.** `uploadFile` through `storage/files-api.ts` is acceptable for a ~200-file site at 8-way concurrency. - -Exit artifact: a short findings note in the Phase 1 PR description, plus the working router script from the spike. - -**Fallback if (2) or (3) fails:** static path-based previews (`preview.domain/deploys/{id}/`) with promote via edge-rule swap — the v1 RFC design. The command surface is identical either way, so later phases are unaffected. - -## Phase 1 — Scaffolding + site lifecycle - -**Commands:** `sites create [--domain]`, `list`, `show [site]`, `link [site]`, `unlink`, `delete [--force] [--keep-storage]` - -New files under `packages/cli/src/commands/sites/`: - -``` -index.ts # export const sitesNamespace = defineNamespace("sites", false, [...]) -constants.ts # SITES_MANIFEST = "site.json" (→ .bunny/site.json) - # REMOTE_STATE_PATH = "_bunny/site.json" - # SiteManifest { id: number; name?: string; pullZoneId?: number; scriptId?: number } - # RemoteSiteState { version; siteName; storageZoneId; pullZoneId; scriptId; - # routerVersion; current?; previous?; deploys: DeployRecord[]; - # stateChecksum? } -api.ts # provisioning orchestration + remote-state read/write + fetch/resolve helpers -interactive.ts # resolveSiteInteractive — mirrors storage/interactive.ts -router/source.ts # router script source as an exported template string (from the spike) -create.ts, list.ts, show.ts, link.ts, unlink.ts, delete.ts -``` - -Registration: add `sitesNamespace` to the `experimentalCommands` array in `packages/cli/src/cli.ts` (hidden while WIP — the `storage` pattern). Promote to `commands` in Phase 5. - -**`create` orchestration** (in `api.ts`; each step looks up by name before creating, so a failed create re-runs cleanly): - -1. Create storage zone (`POST /storagezone`, core client — reuse the body shape from `storage/zone/add.ts`). -2. Create pull zone with storage origin + create middleware router from `router/source.ts` — exact order/mechanism per the Phase 0 attach answer. Script upload uses the existing `POST /compute/script/{id}/code` + `POST /compute/script/{id}/publish` pair from `scripts/deploy.ts`. -3. Set `CURRENT_DEPLOY=""` (`PUT /compute/script/{id}/variables`). -4. Write remote `_bunny/site.json` via `connectStorageZone` + `uploadFile`. -5. `saveManifest(SITES_MANIFEST, { id, name, pullZoneId, scriptId })` unless `--no-link` (mirror `scripts/create.ts`). -6. If `--domain`: invoke the Phase 3 flow. The flag ships **hidden** in Phase 1 (stub that throws `UserError` with a "coming soon" hint). - -Client wiring in every handler, per convention: - -```ts -const config = resolveConfig(profile, apiKey, verbose); -const coreClient = createCoreClient(clientOptions(config, verbose)); -const computeClient = createComputeClient(clientOptions(config, verbose)); -``` - -**Router script v1** (~30 lines, from the spike): apex/`.b-cdn.net` host → serve `/deploys/{CURRENT_DEPLOY}{path}`; `^dpl-[a-z0-9]+\.preview\.` host → that deploy's prefix; `/_bunny/*` → 403; empty `CURRENT_DEPLOY` → friendly "no deploys yet" page. - -**Command behaviors:** - -- `list` — `formatTable(["ID", "Name", "Hostname", "Deploys", "Current"], rows, output)`; `output === "json"` first; empty state via `logger.info`. -- `show [site]` — `resolveSiteInteractive` → `formatKeyValue` sections (site, resources, current deploy, hostnames via `fetchPullZoneHostnames` + `liveHostnames`). -- `link`/`unlink` — `saveManifest`/`removeManifest`, mirroring `storage/link.ts`. -- `delete` — `confirm(..., { force })`; teardown order: script (detach/delete first), pull zone, then storage zone unless `--keep-storage`. - -**Tests** (co-located `*.test.ts`, `bun:test`): provisioning-order and idempotent re-run against a hand-rolled fake client (object literal branching on path strings, cast `as unknown as CoreClient` — the pattern in `core/hostnames/bunny-dns.test.ts`, with `mock.module` + dynamic import where sibling modules need stubbing); manifest round-trip; router source snapshot. - -## Phase 2 — Deploy + deployments (the core loop) - -**Commands:** `sites deploy [dir]`, `sites deployments list`, `sites deployments publish [--previous] [--force]` (alias `promote`) - -New files: `deploy.ts`, `deploy-id.ts` (+ test), `uploader.ts` (+ test), `deployments/{index,list,publish}.ts` - -Work items, in dependency order: - -1. **`deploy-id.ts`** — pure functions, easy unit tests: `gitShaId()` (shell out via `Bun.spawn` to `git rev-parse --short HEAD`, detect dirty tree with `git status --porcelain`), `contentHashId(files)` (sorted path+sha256 merkle → 8 hex chars). -2. **`uploader.ts`** — walk dir (v1: upload everything except dotfiles); per-file streaming SHA-256 via `Bun.CryptoHasher("sha256")` → `UploadOptions.sha256Checksum` (the `storage/file/upload.ts` pattern); 8-way concurrent `uploadFile` with retry/backoff; progress via `spinner()` from `core/ui.ts` + `progressBar()`/`formatBytes()` from `core/format.ts` (stderr, so `--output json` stays clean). -3. **Remote state read-modify-write** in `api.ts` — `downloadFile` `_bunny/site.json`, append deploy record, re-upload. Include a `stateChecksum` field checked before write (cheap optimistic lock; log-and-overwrite on mismatch in v1). Reading the storage zone for `connectStorageZone` must go through `fetchStorageZone`/`resolveStorageZone` (they return the full record with `Password`). -4. **`deploy.ts`** — resolve site (flag → manifest → picker with offer-to-link, mirroring `selectScript` in `scripts/interactive.ts`) → compute ID → short-circuit if ID is already uploaded ("no changes") → upload to `/deploys/{id}/` → update state → preview URL by default; `--production`/`--prod` promotes (env var + `POST /pullzone/{id}/purgeCache`) → print production + preview URLs via `hostnameUrl`. -5. **`deployments list`** — table `["ID", "Age", "Git", "Source", "Files", "Size", "Status"]` with `● ACTIVE` marker (the `scripts/deployments/list.ts` style, `formatDateTime` for dates); `--output json` free via the shared flag. -6. **`deployments publish`** — reuse the `scripts/deployments/publish.ts` confirm/`--force` UX; update env var, purge, swap `current`/`previous` in state. `--previous` is sugar for instant rollback. - -End state: the full Pages loop works against `.b-cdn.net`, with subdomain-preview infrastructure already live (previews resolve once a domain exists; path previews `/deploys/{id}/` work immediately). - -## Phase 3 — Domains - -**Commands:** `sites domains add `, `list`, `remove [--purge-dns]` - -Mount `createHostnamesCommands()` from `core/hostnames/commands.ts` — it exists for exactly this ("any resource backed by a pull zone") and returns ready-made `add`/`ssl`/`list`/`remove` commands given a `resolve: (args) => Promise`: - -```ts -createHostnamesCommands({ - commandPath: "sites domains", - namespace: "domains", - resolve: resolveSitePullZone, // site arg/manifest/picker → { pullZoneId, coreClient } - hiddenAliases: ["hostnames"], -}); -``` - -Sites-specific extension on top of the factory's `add` (wrap it or add a hook): after the apex succeeds, also attach `*.preview.` via `addHostname`, create the DNS pair on the owning Bunny DNS zone (apex as PULLZONE-type record via `findBunnyDnsZone`/`offerBunnyDnsRecord`; wildcard record alongside — model the pair with the `mx/txt/cname`-style builder helpers from `dns/record/presets.ts`, kept internal to sites rather than added to the public `PRESETS` catalog), then trigger cert issuance and poll with a spinner until valid (`offerDnsWaitAndSsl` already does poll-then-issue with retries; wildcard-cert specifics per the Phase 0 answer). - -Also: un-hide `create --domain` and wire it to this flow (`storage/zone/add.ts` and `setupCustomDomain` in `scripts/create.ts` are the composition templates). `show` gains a domains section with cert status. - -Edge cases handled explicitly: - -- Nameservers not pointed at Bunny yet — `checkDelegation`/`expectedNameservers` from `core/dns-nameservers.ts` detect it (that's how `bunny-dns.ts` sets `match.delegated`); print NS instructions (use `detectRegistrar` from `core/registrar.ts` to name the registrar) and exit 0 with a "re-run when propagated" hint. -- A preexisting conflicting record at the apex — `offerBunnyDnsRecord` already handles repointing with a prompt; surface it clearly. - -## Phase 4 — Builds - -**Commands:** `deploy --build [cmd]` (a remote `sites env` store was built here, then dropped: sites deploys prebuilt files, so the caller's environment is the source of truth) - -- `deploy --build`: spawn the build command via `Bun.spawn` (from flag or `bunny.jsonc`) in the caller's environment plus `--env`/`--env-file` overrides → then the normal deploy path on the output dir. -- **`bunny.jsonc` support:** add an optional top-level `sites` block to `BunnyAppConfigSchema` in `packages/app-config/src/schema.ts` — `sites: SitesConfigSchema.optional()` with `{ name?, dir?, build? }`, exported sub-schema + `z.infer` type, mirroring `ProbeConfigSchema` et al. Regenerate `generated/schema.json` via `packages/app-config/scripts/generate-schema.ts` (`z.toJSONSchema(..., { target: "draft-2020-12" })`). `saveConfig`'s re-keying (`$schema`, `version`, `...rest`) preserves the new key automatically; consider bumping `CURRENT_VERSION` (date-versioned). Separate changeset, minor bump for `@bunny.net/app-config`. - -## Phase 5 — Polish + ship - -- `deployments prune [--keep N]` (never prunes `current`/`previous`). -- `X-Robots-Tag: noindex` on preview hosts in the router; router version handling — store `routerVersion` in remote state; `sites show` warns when an upgrade is available; `sites upgrade` republishes the router source. -- Promote `sitesNamespace` from `experimentalCommands` to `commands` in `cli.ts` with a real `describe`. -- Update `skills/bunny-cli/` — Quick Start mention in `SKILL.md` plus a new `references/sites.md` (matching the existing per-area reference docs: `api.md`, `auth.md`, `database.md`, `dns.md`, `scripts.md`). This is the tie-in to the sandbox project: the app-builder supervisor just runs `bunny sites deploy ./dist --build`. -- Docs PR to `BunnyWay/documentation` (Mintlify page under CDN or a new Sites section) with the deploy→rollback quickstart. -- Release: changesets (minor `@bunny.net/cli`, minor `@bunny.net/app-config`; the `fixed` group in `.changeset/config.json` propagates the CLI bump to the platform binaries automatically), `bun run typecheck`, `bun run lint`, `bun test`. - -## Cross-cutting conventions checklist (applies to every phase) - -Per `AGENTS.md` "Conventions for Adding New Commands" and `CLAUDE.md`: - -- `defineCommand()`/`defineNamespace()` for everything; register in `cli.ts`. -- Flag equivalents for every interactive prompt; prompts gated on `isInteractive(output)` and skipped under `--force`. -- `--output json` handled first in every handler (`logger.log(JSON.stringify(data, null, 2)); return;`); all status/progress output through `logger.*` (stderr) or `spinner()` so JSON stays parseable. -- `UserError(message, hint?)` for expected failures; never raw `throw new Error` for user-facing conditions. -- `resolveConfig(profile, apiKey, verbose)` + `clientOptions(config, verbose)` for all clients; import clients from `@bunny.net/openapi-client` and generated types from `@bunny.net/openapi-client/generated/.d.ts` (`core.d.ts` for storage/pull zones/DNS, `compute.d.ts` for scripts). Prefer `Pick` over inline primitives. -- Co-located `*.test.ts` with `bun:test`; fake clients as path-branching object literals; `mock.module` for sibling stubs. -- Update `README.md` (command examples) and `AGENTS.md` (Command Reference tree + file listing + manifest section) in the same PR as the code. -- One changeset per PR. - -## Sequencing and effort - -Phase 0 is a day of poking the platform and decides everything — do it first, alone. Phases 1–2 are the meat (roughly a week together) and ship a usable feature on their own. Phase 3 is mostly composition of `core/hostnames/` (1–2 days if the wildcard-cert API cooperates — `createHostnamesCommands` removes most of the surface work). Phases 4–5 are independent and can trail. - -Branch/PR naming: `feat/sites-scaffolding`, `feat/sites-deploy`, `feat/sites-domains`, `feat/sites-env`, `feat/sites-polish`, each with its own changeset. From c1c6ff8b6e60a071e0e948bde75ddba91a5ba951 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Mon, 13 Jul 2026 22:36:27 +0100 Subject: [PATCH 06/24] apply code suggestions --- packages/cli/src/commands/sites/api.test.ts | 44 ++++++++++++ packages/cli/src/commands/sites/api.ts | 67 +++++++++++++------ packages/cli/src/commands/sites/build.test.ts | 17 +++++ packages/cli/src/commands/sites/build.ts | 39 ++++++++--- .../cli/src/commands/sites/ci/scaffold.ts | 2 +- .../src/commands/sites/ci/workflow.test.ts | 1 + .../cli/src/commands/sites/ci/workflow.ts | 1 + packages/cli/src/commands/sites/constants.ts | 2 + packages/cli/src/commands/sites/delete.ts | 3 +- .../cli/src/commands/sites/deploy-id.test.ts | 4 +- packages/cli/src/commands/sites/deploy-id.ts | 26 ++++--- packages/cli/src/commands/sites/deploy.ts | 41 +++++++----- .../cli/src/commands/sites/uploader.test.ts | 15 +++++ packages/cli/src/commands/sites/uploader.ts | 46 ++++++++++--- 14 files changed, 243 insertions(+), 65 deletions(-) diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index 568e73c0..72473908 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -3,6 +3,7 @@ import type { CoreClient, StorageZoneModel } from "../storage/api.ts"; import { type ComputeClient, createSite, + deleteSiteResources, fetchSites, promoteDeploy, readRemoteState, @@ -225,6 +226,25 @@ test("readRemoteState is null for missing or invalid state", async () => { expect(await readRemoteState(connection)).toBeNull(); }); +test("readRemoteState rethrows transient read errors (no fail-open)", async () => { + const connection = fakeConnection(); + siteFiles.download = async () => { + throw new Error("Timeout connecting to storage"); + }; + await expect(readRemoteState(connection)).rejects.toThrow("Timeout"); +}); + +test("writeRemoteState refuses to overwrite an unparseable conflict", async () => { + const connection = fakeConnection(); + const etag = await writeRemoteState(connection, fakeState()); + + // A concurrent writer left the state unparseable between our read and write. + store.set(REMOTE_STATE_PATH, "garbage"); + await expect( + writeRemoteState(connection, fakeState({ current: "aaa" }), etag), + ).rejects.toThrow("no longer parseable"); +}); + test("writeRemoteState merges concurrent deploy records on an etag mismatch", async () => { const connection = fakeConnection(); const etag = await writeRemoteState(connection, fakeState()); @@ -425,6 +445,30 @@ test("siteContextFromZone is null for a zone without site state", async () => { expect(await siteContextFromZone(ZONE)).toBeNull(); }); +// ---- teardown ---- + +test("deleteSiteResources removes the site marker when keeping storage", async () => { + store.set(REMOTE_STATE_PATH, JSON.stringify(fakeState())); + store.set("deploys/aaa/index.html", "

hi

"); + const coreClient = fakeCoreClient({ calls: [] }); + const computeClient = fakeComputeClient({ calls: [] }); + + const results = await deleteSiteResources({ + coreClient, + computeClient, + state: fakeState(), + keepStorage: true, + connection: fakeConnection(), + }); + + // The storage zone was never deleted… + expect(results.some((r) => r.resource === "storage zone")).toBe(false); + // …but its site marker is gone, so list/link/show can't rediscover it. + expect(store.has(REMOTE_STATE_PATH)).toBe(false); + // Deploy files are untouched. + expect(store.has("deploys/aaa/index.html")).toBe(true); +}); + // Regression: the live API returns GET /pullzone as a paginated envelope // ({ Items, CurrentPage, HasMoreItems }) — the spec's plain array is a lie // for some queries (e.g. ?search=). createSite crashed on `.find` here. diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 3a42117b..23c7e1e3 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -63,6 +63,15 @@ export function sha256Hex(text: string): string { return hasher.digest("hex"); } +/** + * The storage SDK throws a plain Error whose message encodes the HTTP status + * (`File not found: ...` for a 404). Only a genuine 404 means "no file"; a + * 401/timeout/5xx must propagate so ownership checks never fail open. + */ +function isNotFoundError(err: unknown): boolean { + return err instanceof Error && /not found/i.test(err.message); +} + async function downloadText( connection: StorageZone, path: string, @@ -70,10 +79,11 @@ async function downloadText( try { const { stream } = await siteFiles.download(connection, path); return await new Response(stream).text(); - } catch { - // Missing file and transient errors both read as "no data" — callers - // that need to distinguish should not be writing based on this alone. - return null; + } catch (err) { + // A missing file reads as "no data"; transient/permission errors must + // propagate so a caller can't clobber a live site's state on a bad read. + if (isNotFoundError(err)) return null; + throw err; } } @@ -95,9 +105,10 @@ export async function readRemoteState( /** * Write `_bunny/site.json`. When `expectedEtag` is given, the current remote * content is re-read first; a mismatch means someone else deployed since we - * read (e.g. concurrent CI runs). Their deploy records are merged in so no - * deploy goes missing; our `current`/`previous` win (last promote wins). - * Returns the new etag. + * read (e.g. concurrent CI runs). If the concurrent state is parseable, their + * deploy records are merged in so no deploy goes missing and our + * `current`/`previous` win (last promote wins). If it's unparseable we abort + * rather than blindly overwrite unknown state. Returns the new etag. */ export async function writeRemoteState( connection: StorageZone, @@ -108,20 +119,20 @@ export async function writeRemoteState( const current = await downloadText(connection, REMOTE_STATE_PATH); if (current !== null && sha256Hex(current) !== expectedEtag) { const remote = parseRemoteState(current); - if (remote) { - const ours = new Set(state.deploys.map((d) => d.id)); - state.deploys = [ - ...state.deploys, - ...remote.deploys.filter((d) => !ours.has(d.id)), - ].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); - logger.warn( - "Remote site state changed since it was read (concurrent deploy?): merged deploy records.", - ); - } else { - logger.warn( - "Remote site state changed since it was read (concurrent deploy?): overwriting.", + if (!remote) { + throw new UserError( + "Remote site state changed since it was read and is no longer parseable.", + "Another process may be writing it. Re-run the command.", ); } + const ours = new Set(state.deploys.map((d) => d.id)); + state.deploys = [ + ...state.deploys, + ...remote.deploys.filter((d) => !ours.has(d.id)), + ].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + logger.warn( + "Remote site state changed since it was read (concurrent deploy?): merged deploy records.", + ); } } const raw = `${JSON.stringify(state, null, 2)}\n`; @@ -438,6 +449,8 @@ export async function deleteSiteResources(opts: { computeClient: ComputeClient; state: RemoteSiteState; keepStorage?: boolean; + /** The storage connection — needed to tombstone the site marker with --keep-storage. */ + connection?: StorageZone; }): Promise { const { coreClient, computeClient, state } = opts; const results: TeardownResult[] = []; @@ -470,7 +483,21 @@ export async function deleteSiteResources(opts: { params: { path: { id: state.scriptId } }, }), ); - if (!opts.keepStorage) { + if (opts.keepStorage) { + // The zone survives, so remove its site marker — otherwise list/link/show + // rediscover a "site" whose pull zone and router are already gone. + if (opts.connection) { + try { + await siteFiles.remove(opts.connection, REMOTE_STATE_PATH); + } catch (err) { + logger.warn( + `Kept the storage zone but couldn't remove its site marker (${REMOTE_STATE_PATH}): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + } else { await attempt("storage zone", state.storageZoneId, () => coreClient.DELETE("/storagezone/{id}", { params: { path: { id: state.storageZoneId } }, diff --git a/packages/cli/src/commands/sites/build.test.ts b/packages/cli/src/commands/sites/build.test.ts index 566107a5..2b3234e7 100644 --- a/packages/cli/src/commands/sites/build.test.ts +++ b/packages/cli/src/commands/sites/build.test.ts @@ -39,6 +39,23 @@ test("parseEnvFile handles comments, blanks, and quotes", () => { }); }); +test("parseEnvFile strips inline comments and unescapes inner quotes", () => { + const env = parseEnvFile( + [ + "INLINE=value # trailing comment", + "HASHINVALUE=a#b", + 'ESCAPED="value with \\"quotes\\""', + 'HASHINQUOTES="a # b"', + ].join("\n"), + ); + expect(env).toEqual({ + INLINE: "value", + HASHINVALUE: "a#b", + ESCAPED: 'value with "quotes"', + HASHINQUOTES: "a # b", + }); +}); + test("runBuildCommand passes env and throws on failure", async () => { const dir = mkdtempSync(join(tmpdir(), "bunny-sites-build-")); const out = join(dir, "out.txt"); diff --git a/packages/cli/src/commands/sites/build.ts b/packages/cli/src/commands/sites/build.ts index c96f5020..3cbb8890 100644 --- a/packages/cli/src/commands/sites/build.ts +++ b/packages/cli/src/commands/sites/build.ts @@ -32,6 +32,36 @@ export function parseEnvAssignments( return env; } +/** + * Turn a dotenv value token into its string. Double-quoted values read to the + * closing quote, unescaping `\"`/`\\` (a `#` inside stays literal); single + * quotes are taken verbatim; unquoted values drop a whitespace-delimited inline + * `# comment`. + */ +function parseEnvValue(raw: string): string { + const value = raw.trim(); + if (value.startsWith('"')) { + let out = ""; + for (let i = 1; i < value.length; i++) { + const ch = value[i]; + if (ch === "\\" && i + 1 < value.length) { + out += value[++i]; + } else if (ch === '"') { + return out; + } else { + out += ch; + } + } + return out; // unterminated quote — take what we have + } + if (value.startsWith("'")) { + const end = value.indexOf("'", 1); + return end === -1 ? value.slice(1) : value.slice(1, end); + } + const comment = value.search(/\s#/); + return (comment === -1 ? value : value.slice(0, comment)).trimEnd(); +} + /** Parse a dotenv-style file: KEY=VALUE lines, `#` comments, optional quotes. */ export function parseEnvFile(content: string): Record { const env: Record = {}; @@ -42,14 +72,7 @@ export function parseEnvFile(content: string): Record { if (eq <= 0) continue; const name = line.slice(0, eq).trim(); if (!ENV_NAME_RE.test(name)) continue; - let value = line.slice(eq + 1).trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - env[name] = value; + env[name] = parseEnvValue(line.slice(eq + 1)); } return env; } diff --git a/packages/cli/src/commands/sites/ci/scaffold.ts b/packages/cli/src/commands/sites/ci/scaffold.ts index d3c5ac80..092eae87 100644 --- a/packages/cli/src/commands/sites/ci/scaffold.ts +++ b/packages/cli/src/commands/sites/ci/scaffold.ts @@ -39,7 +39,7 @@ export async function gitTopLevel(cwd: string): Promise { export async function hasGitHubOrigin(root: string): Promise { const url = await git(root, ["remote", "get-url", "origin"]); - return url !== null && url.includes("github.com"); + return url?.includes("github.com") ?? false; } export interface ScaffoldResult { diff --git a/packages/cli/src/commands/sites/ci/workflow.test.ts b/packages/cli/src/commands/sites/ci/workflow.test.ts index 42d6dc04..d952f4e2 100644 --- a/packages/cli/src/commands/sites/ci/workflow.test.ts +++ b/packages/cli/src/commands/sites/ci/workflow.test.ts @@ -1,3 +1,4 @@ +// biome-ignore-all lint/suspicious/noTemplateCurlyInString: asserts on GitHub Actions ${{ }} expressions in the generated workflow import { expect, test } from "bun:test"; import { findPreset } from "./frameworks.ts"; import { DEPLOY_SITE_ACTION, renderSitesWorkflow } from "./workflow.ts"; diff --git a/packages/cli/src/commands/sites/ci/workflow.ts b/packages/cli/src/commands/sites/ci/workflow.ts index d6436789..88094eda 100644 --- a/packages/cli/src/commands/sites/ci/workflow.ts +++ b/packages/cli/src/commands/sites/ci/workflow.ts @@ -1,3 +1,4 @@ +// biome-ignore-all lint/suspicious/noTemplateCurlyInString: emits GitHub Actions ${{ }} expressions, not JS template strings import type { FrameworkPreset, PackageManager } from "./frameworks.ts"; export const SITES_WORKFLOW_PATH = ".github/workflows/bunny-sites.yml"; diff --git a/packages/cli/src/commands/sites/constants.ts b/packages/cli/src/commands/sites/constants.ts index f9cdfccb..14c822f0 100644 --- a/packages/cli/src/commands/sites/constants.ts +++ b/packages/cli/src/commands/sites/constants.ts @@ -32,6 +32,8 @@ export interface DeployRecord { source: "git" | "content"; gitSha?: string; dirty?: boolean; + /** Hash of the deployed bytes; the no-op check keys on this. Absent on pre-v1 records. */ + contentHash?: string; files: number; bytes: number; } diff --git a/packages/cli/src/commands/sites/delete.ts b/packages/cli/src/commands/sites/delete.ts index f984a44d..5088d279 100644 --- a/packages/cli/src/commands/sites/delete.ts +++ b/packages/cli/src/commands/sites/delete.ts @@ -100,6 +100,7 @@ export const sitesDeleteCommand = defineCommand({ computeClient, state, keepStorage: args["keep-storage"], + connection: site.connection, }); } finally { spin.stop(); @@ -136,7 +137,7 @@ export const sitesDeleteCommand = defineCommand({ } if (args["keep-storage"]) { logger.info( - `Storage zone ${state.storageZoneId} was kept, including all deploy files.`, + `Storage zone ${state.storageZoneId} was kept (including all deploy files) but is no longer registered as a site.`, ); } if (failures.length > 0) { diff --git a/packages/cli/src/commands/sites/deploy-id.test.ts b/packages/cli/src/commands/sites/deploy-id.test.ts index a2f578ce..76d53b2e 100644 --- a/packages/cli/src/commands/sites/deploy-id.test.ts +++ b/packages/cli/src/commands/sites/deploy-id.test.ts @@ -21,7 +21,7 @@ test("contentHashId is order-independent and case-normalizes hashes", () => { ); expect(a).toBe(b); expect(a).toBe(c); - expect(a).toMatch(/^[0-9a-f]{8}$/); + expect(a).toMatch(/^[0-9a-f]{12}$/); }); test("contentHashId changes when content or paths change", () => { @@ -72,6 +72,8 @@ test("clean git repo uses the short sha; dirty tree falls back to content", asyn expect(clean.source).toBe("git"); expect(clean.id).toMatch(/^[0-9a-f]{8,}$/); expect(clean.gitSha).toBe(clean.id); + // The content hash is always carried, even when the display id is the git sha. + expect(clean.contentHash).toBe(contentHashId(FILES)); await Bun.write(join(dir, "new.txt"), "dirty"); const dirty = await resolveDeployIdentity(dir, FILES); diff --git a/packages/cli/src/commands/sites/deploy-id.ts b/packages/cli/src/commands/sites/deploy-id.ts index 3c059572..501eac53 100644 --- a/packages/cli/src/commands/sites/deploy-id.ts +++ b/packages/cli/src/commands/sites/deploy-id.ts @@ -10,12 +10,19 @@ export interface DeployIdentity { source: "git" | "content"; gitSha?: string; dirty?: boolean; + /** + * Hash of the deployed bytes, always computed. The no-op check keys on this + * (not `id`), so a rebuilt `dist/` at the same git sha with different output + * isn't wrongly skipped as unchanged. + */ + contentHash: string; } /** * Deterministic content hash for a set of files: a merkle-style digest over - * sorted `path + sha256` pairs, truncated to 8 hex chars. Identical content - * always yields the same ID regardless of file order. + * sorted `path + sha256` pairs, truncated to 12 hex chars (48 bits — enough + * headroom that collisions across a site's deploys stay negligible). Identical + * content always yields the same hash regardless of file order. */ export function contentHashId(files: HashedFile[]): string { const hasher = new Bun.CryptoHasher("sha256"); @@ -23,7 +30,7 @@ export function contentHashId(files: HashedFile[]): string { for (const file of sorted) { hasher.update(`${file.path}\0${file.sha256.toLowerCase()}\n`); } - return hasher.digest("hex").slice(0, 8); + return hasher.digest("hex").slice(0, 12); } async function git(cwd: string, args: string[]): Promise { @@ -60,22 +67,25 @@ export async function gitIdentity( } /** - * Resolve the deploy ID: the git short-sha when the tree is clean, the - * content hash otherwise (or outside a repo). Identical-hash deploys are - * no-ops, so the ID must be a pure function of what actually ships. + * Resolve the deploy identity: the display `id` is the git short-sha when the + * tree is clean, the content hash otherwise (or outside a repo). `contentHash` + * is always the hash of what actually ships and drives the no-op check — the + * git sha alone can't tell a rebuilt `dist/` from an unchanged one. */ export async function resolveDeployIdentity( cwd: string, files: HashedFile[], ): Promise { + const contentHash = contentHashId(files); const gitInfo = await gitIdentity(cwd); if (gitInfo && !gitInfo.dirty) { - return { id: gitInfo.sha, source: "git", gitSha: gitInfo.sha }; + return { id: gitInfo.sha, source: "git", gitSha: gitInfo.sha, contentHash }; } return { - id: contentHashId(files), + id: contentHash, source: "content", gitSha: gitInfo?.sha, dirty: gitInfo?.dirty, + contentHash, }; } diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index c400b7b2..5ef2928b 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -197,15 +197,21 @@ export const sitesDeployCommand = defineCommand({ const identity = await resolveDeployIdentity(dir, files); - const alreadyLive = state.current === identity.id; - const skipUpload = - !args.force && state.deploys.some((d) => d.id === identity.id); + // The no-op check keys on content, not the display id: a rebuilt `dist/` + // at the same git sha ships different bytes and must not be skipped. + const existing = args.force + ? undefined + : state.deploys.find((d) => d.contentHash === identity.contentHash); + const skipUpload = existing !== undefined; + // A skipped deploy reuses the already-uploaded deploy's id — that's where its files live. + const deployId = existing?.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)) { const urls = deployUrls( site, - identity.id, + deployId, await fetchSystemHost(coreClient, state.pullZoneId), ); if (output === "json") { @@ -213,7 +219,7 @@ export const sitesDeployCommand = defineCommand({ JSON.stringify( { site: state.name, - id: identity.id, + id: deployId, unchanged: true, live: alreadyLive, production: urls.production ?? null, @@ -227,11 +233,11 @@ export const sitesDeployCommand = defineCommand({ } if (alreadyLive) { logger.info( - `No changes: deploy ${identity.id} is already live. Use --force to redeploy.`, + `No changes: deploy ${deployId} is already live. Use --force to redeploy.`, ); } else { logger.info( - `No changes: deploy ${identity.id} is already uploaded. Publish it with \`bunny sites deploy --production\`.`, + `No changes: deploy ${deployId} is already uploaded. Publish it with \`bunny sites deploy --production\`.`, ); } if (urls.preview) logger.log(` Preview: ${urls.preview}`); @@ -242,7 +248,7 @@ export const sitesDeployCommand = defineCommand({ const uploadSpin = spinner(`Uploading ${files.length} files...`); uploadSpin.start(); try { - await uploadDeploy(connection, identity.id, files, { + await uploadDeploy(connection, deployId, files, { onFileUploaded: (done, total) => { uploadSpin.text = `Uploading ${done}/${total} files (${formatBytes(totalBytes)} total)...`; }, @@ -253,11 +259,12 @@ export const sitesDeployCommand = defineCommand({ // Record the deploy. A re-deployed ID keeps its slot but gets fresh metadata. const record: DeployRecord = { - id: identity.id, + id: deployId, createdAt: new Date().toISOString(), source: identity.source, gitSha: identity.gitSha, dirty: identity.dirty, + contentHash: identity.contentHash, files: files.length, bytes: totalBytes, }; @@ -267,10 +274,10 @@ export const sitesDeployCommand = defineCommand({ ]; } if (args.production) { - if (state.current && state.current !== identity.id) { + if (state.current && state.current !== deployId) { state.previous = state.current; } - state.current = identity.id; + state.current = deployId; } await writeRemoteState(connection, state, etag); @@ -282,7 +289,7 @@ export const sitesDeployCommand = defineCommand({ computeClient, coreClient, state, - deployId: identity.id, + deployId, }); } finally { promoteSpin.stop(); @@ -291,7 +298,7 @@ export const sitesDeployCommand = defineCommand({ const urls = deployUrls( site, - identity.id, + deployId, await fetchSystemHost(coreClient, state.pullZoneId), ); @@ -300,7 +307,7 @@ export const sitesDeployCommand = defineCommand({ JSON.stringify( { site: state.name, - id: identity.id, + id: deployId, source: identity.source, files: files.length, bytes: totalBytes, @@ -316,10 +323,10 @@ export const sitesDeployCommand = defineCommand({ } if (skipUpload) { - logger.success(`Deploy ${identity.id} is now live.`); + logger.success(`Deploy ${deployId} is now live.`); } else { logger.success( - `Deployed ${identity.id} (${files.length} files, ${formatBytes(totalBytes)}).`, + `Deployed ${deployId} (${files.length} files, ${formatBytes(totalBytes)}).`, ); } if (args.production) { @@ -328,7 +335,7 @@ export const sitesDeployCommand = defineCommand({ } else { if (urls.preview) logger.info(`Preview: ${urls.preview}`); logger.info( - `Publish it with \`bunny sites deploy --production\` or \`bunny sites deployments publish ${identity.id}\`.`, + `Publish it with \`bunny sites deploy --production\` or \`bunny sites deployments publish ${deployId}\`.`, ); } diff --git a/packages/cli/src/commands/sites/uploader.test.ts b/packages/cli/src/commands/sites/uploader.test.ts index 8c0df7a2..8d716896 100644 --- a/packages/cli/src/commands/sites/uploader.test.ts +++ b/packages/cli/src/commands/sites/uploader.test.ts @@ -37,6 +37,8 @@ test("shouldSkipEntry excludes dotfiles and node_modules", () => { expect(shouldSkipEntry("node_modules")).toBe(true); expect(shouldSkipEntry("index.html")).toBe(false); expect(shouldSkipEntry("assets")).toBe(false); + // Web-visible standards dirs must ship despite the leading dot. + expect(shouldSkipEntry(".well-known")).toBe(false); }); test("collectFiles walks recursively, skipping excluded entries, sorted", () => { @@ -45,6 +47,19 @@ test("collectFiles walks recursively, skipping excluded entries, sorted", () => expect(files[1]?.size).toBeGreaterThan(0); }); +test("collectFiles keeps .well-known content", () => { + const dir = mkdtempSync(join(tmpdir(), "bunny-sites-wk-")); + Bun.write(join(dir, "index.html"), "

hi

"); + mkdirSync(join(dir, ".well-known")); + Bun.write(join(dir, ".well-known", "security.txt"), "Contact: mailto:x@y"); + + const files = collectFiles(dir); + expect(files.map((f) => f.path)).toEqual([ + ".well-known/security.txt", + "index.html", + ]); +}); + test("hashFiles computes the content sha256", async () => { const dir = tree(); const [first] = await hashFiles( diff --git a/packages/cli/src/commands/sites/uploader.ts b/packages/cli/src/commands/sites/uploader.ts index a604134d..72092a60 100644 --- a/packages/cli/src/commands/sites/uploader.ts +++ b/packages/cli/src/commands/sites/uploader.ts @@ -19,8 +19,15 @@ export interface HashedLocalFile extends LocalFile { export const DEFAULT_UPLOAD_CONCURRENCY = 8; const UPLOAD_ATTEMPTS = 3; -/** Dotfiles/dirs and node_modules never ship — they're tooling, not site content. */ +// Dot-directories that carry web-visible content the site must serve. +const ALLOWED_DOT_ENTRIES = new Set([".well-known"]); + +/** + * Dotfiles/dirs and node_modules never ship — they're tooling, not site + * content — except standards dirs like `.well-known` that must be served. + */ export function shouldSkipEntry(name: string): boolean { + if (ALLOWED_DOT_ENTRIES.has(name)) return false; return name.startsWith(".") || name === "node_modules"; } @@ -50,18 +57,39 @@ export function collectFiles(dir: string): LocalFile[] { return files.sort((a, b) => a.path.localeCompare(b.path)); } -/** Streaming SHA-256 for each file — feeds both the deploy ID and upload checksums. */ +async function hashFile(file: LocalFile): Promise { + const hasher = new Bun.CryptoHasher("sha256"); + for await (const chunk of Bun.file(file.absPath).stream()) { + hasher.update(chunk); + } + return { ...file, sha256: hasher.digest("hex") }; +} + +/** + * Streaming SHA-256 for each file — feeds both the deploy ID and upload + * checksums. Bounded concurrency matches the upload step so large deploys + * don't hash one file at a time. + */ export async function hashFiles( files: LocalFile[], ): Promise { - const hashed: HashedLocalFile[] = []; - for (const file of files) { - const hasher = new Bun.CryptoHasher("sha256"); - for await (const chunk of Bun.file(file.absPath).stream()) { - hasher.update(chunk); + const hashed: HashedLocalFile[] = new Array(files.length); + const concurrency = Math.max( + 1, + Math.min(DEFAULT_UPLOAD_CONCURRENCY, files.length), + ); + + let next = 0; + const worker = async () => { + while (true) { + const index = next++; + const file = files[index]; + if (!file) return; + hashed[index] = await hashFile(file); } - hashed.push({ ...file, sha256: hasher.digest("hex") }); - } + }; + + await Promise.all(Array.from({ length: concurrency }, worker)); return hashed; } From 3e2b65a435610cafcb025e0a934b66e6259fe672 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 14 Jul 2026 12:21:38 +0100 Subject: [PATCH 07/24] apply code suggestions --- AGENTS.md | 20 ++- README.md | 3 + packages/cli/src/commands/sites/api.test.ts | 104 ++++++++++- packages/cli/src/commands/sites/api.ts | 164 ++++++++++++++++-- packages/cli/src/commands/sites/build.test.ts | 55 +++++- packages/cli/src/commands/sites/build.ts | 50 ++++++ .../src/commands/sites/ci/frameworks.test.ts | 28 ++- .../cli/src/commands/sites/ci/frameworks.ts | 20 +++ packages/cli/src/commands/sites/deploy.ts | 80 +++++---- packages/cli/src/commands/sites/index.ts | 8 +- packages/cli/src/commands/sites/open.test.ts | 59 +++++++ packages/cli/src/commands/sites/open.ts | 96 ++++++++++ .../cli/src/commands/sites/router/source.ts | 4 +- packages/cli/src/commands/sites/show.ts | 5 +- packages/cli/src/commands/sites/ssl.ts | 86 +++++++++ .../sites/{upgrade.ts => upgrade-router.ts} | 11 +- packages/cli/src/core/hostnames/client.ts | 18 +- packages/cli/src/core/hostnames/index.ts | 1 + 18 files changed, 742 insertions(+), 70 deletions(-) create mode 100644 packages/cli/src/commands/sites/open.test.ts create mode 100644 packages/cli/src/commands/sites/open.ts create mode 100644 packages/cli/src/commands/sites/ssl.ts rename packages/cli/src/commands/sites/{upgrade.ts => upgrade-router.ts} (91%) diff --git a/AGENTS.md b/AGENTS.md index 7f7ccd2e..a1ec4a08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -374,7 +374,7 @@ bunny-cli/ │ │ │ ├── download.ts # Download a file to disk ( positional, --zone, --out) │ │ │ └── remove.ts # Delete a file or directory (alias: rm; positional, --zone, trailing slash = recursive) │ │ ├── sites/ # Experimental (hidden from help and landing page) — static-site hosting (storage zone + pull zone + middleware router) -│ │ │ ├── index.ts # defineNamespace("sites", ...): create/list/show/deploy/deployments/domains/link/unlink/upgrade/delete +│ │ │ ├── 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 │ │ │ ├── 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), 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), promoteDeploy (env var PUT + purgeCache POST), deleteSiteResources (pull zone → script → storage zone, best-effort), deleteDeployFiles @@ -388,13 +388,15 @@ bunny-cli/ │ │ │ ├── uploader.test.ts # Walk/skip/hash tests + upload paths/checksums/retry via siteFiles swap │ │ │ ├── build.ts # parseEnvAssignments/parseEnvFile + 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] (prompted, directory-name suggestion): createSite + manifest link + custom domain via setupSiteDomain (--domain flag, offered interactively when omitted; domain failure warns, never fails the create) +│ │ │ ├── create.ts # bunny sites create [name] (prompted, directory-name suggestion): createSite (also forces HTTPS on the .b-cdn.net 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/SSL table + router-outdated warning -│ │ │ ├── deploy.ts # bunny sites deploy [dir]: optional build → hash → no-op if unchanged → upload deploys/{id}/ → state update → preview URL, or publish live with --production/--prod +│ │ │ ├── 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 .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]: build (explicit --build, or an interactively-offered detected/configured build) → hash → no-op if unchanged → upload deploys/{id}/ → state update → preview URL, or publish live with --production/--prod │ │ │ ├── link.ts # Link directory to a site (.bunny/site.json) │ │ │ ├── unlink.ts # Remove .bunny/site.json -│ │ │ ├── upgrade.ts # Republish the router at ROUTER_VERSION + bump state.routerVersion +│ │ │ ├── upgrade-router.ts # Republish the router at ROUTER_VERSION + bump state.routerVersion │ │ │ ├── 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), scaffold.ts (git helpers, scaffoldSitesWorkflow, 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 (pruneVictims never drops current/previous) + prune.test.ts @@ -1090,9 +1092,10 @@ bunny │ ├── create [name] [--region] [--domain] [--link] │ │ Provision a site (idempotent — a failed create re-runs cleanly; each resource is looked up by name first). Interactive runs prompt for the name when omitted (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] Show resources, domains (with SSL state), current deploy; warns when a newer router is available +│ ├── show [site] 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] [--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). [dir] defaults to `sites.dir` in bunny.jsonc, then cwd (dotfiles + node_modules excluded). --build runs the command (or `sites.build`) in the caller's environment plus --env/--env-file overrides. --production/--prod publishes the deploy as the live site. +│ │ 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). [dir] defaults to `sites.dir` in bunny.jsonc, then cwd (dotfiles + node_modules excluded). --build runs the command (or `sites.build`) 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. │ ├── deployments │ │ ├── list [site] (alias: ls) List deploys (● Live / ○ Previous markers, created, source, files, size) │ │ ├── publish [id] [--previous] [--site] [--force] (alias: promote) @@ -1103,11 +1106,12 @@ bunny │ │ ├── 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) +│ ├── ssl [site] [--no-force-ssl] Toggle Force HTTPS on the .b-cdn.net system host (no cert issued; custom domains use `sites domains ssl`) │ ├── ci │ │ └── init [--site] [--framework] [--force] Write .github/workflows/bunny-sites.yml: 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` │ ├── link [site] Link this directory to a site → .bunny/site.json │ ├── unlink Remove .bunny/site.json -│ ├── upgrade [site] [--force] Republish the router script at the CLI's ROUTER_VERSION +│ ├── upgrade-router [site] [--force] Republish the router script at the CLI's ROUTER_VERSION │ └── delete [site] [--force] [--keep-storage] Delete pull zone → router → storage zone (typed-name confirmation; best-effort so re-runs finish a partial delete) ├── docs Open bunny.net documentation in browser ├── open [--print] Open bunny.net dashboard in browser (or print URL) diff --git a/README.md b/README.md index 46af94a7..1bda3071 100644 --- a/README.md +++ b/README.md @@ -56,12 +56,15 @@ 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) +bun ny sites deploy # detect the framework and offer to run its build, then deploy the output bun ny sites deploy ./dist # deploy a directory to a preview URL bun ny sites deploy ./dist --production # deploy and publish as the live site (--prod works too) bun ny sites deploy --build # run `sites.build` from bunny.jsonc, then deploy `sites.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 domains add example.com # attach a custom domain (+ *.preview.example.com for previews) +bun ny sites ssl --no-force-ssl # stop forcing HTTPS on the .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) ``` diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index 72473908..37e187ec 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeEach, expect, test } from "bun:test"; +import { ApiError } from "../../core/errors.ts"; import type { CoreClient, StorageZoneModel } from "../storage/api.ts"; import { type ComputeClient, @@ -6,6 +7,7 @@ import { deleteSiteResources, fetchSites, promoteDeploy, + promoteVerification, readRemoteState, siteContextFromZone, siteFiles, @@ -22,9 +24,13 @@ import { ROUTER_VERSION } from "./router/source.ts"; const store = new Map(); const original = { ...siteFiles }; +const originalVerification = { ...promoteVerification }; beforeEach(() => { store.clear(); + // Promote probes the CDN and sleeps between attempts; keep tests offline and fast. + promoteVerification.wait = async () => {}; + promoteVerification.probe = async () => 200; siteFiles.connect = (zone) => ({ zoneName: zone.Name }) as unknown as ReturnType< typeof siteFiles.connect @@ -50,6 +56,7 @@ beforeEach(() => { afterAll(() => { Object.assign(siteFiles, original); + Object.assign(promoteVerification, originalVerification); }); // ---- fake clients: object literals branching on the path string ---- @@ -96,6 +103,8 @@ function fakeCoreClient(opts: { */ pullZoneEnvelope?: boolean; pageSize?: number; + /** Throw this from POST /storagezone or POST /pullzone (a taken name). */ + createError?: { path: "/storagezone" | "/pullzone"; error: ApiError }; }): CoreClient { const zones = opts.storageZones ?? []; const pullZones = opts.pullZones ?? []; @@ -112,6 +121,15 @@ function fakeCoreClient(opts: { const zone = zones.find((z) => z.Id === options?.params?.path?.id); return { data: zone }; } + if (path === "/pullzone/{id}") { + const pz = pullZones.find((p) => p.Id === options?.params?.path?.id); + return { + data: pz ?? { + Id: options?.params?.path?.id, + Hostnames: [{ IsSystemHostname: true, Value: "my-site.b-cdn.net" }], + }, + }; + } if (path === "/pullzone") { if (!opts.pullZoneEnvelope) return { data: pullZones }; const pageSize = opts.pageSize ?? Math.max(pullZones.length, 1); @@ -138,6 +156,9 @@ function fakeCoreClient(opts: { params: options?.params as Record, body: options?.body, }); + if (opts.createError && path === opts.createError.path) { + throw opts.createError.error; + } if (path === "/storagezone") { const zone = { ...ZONE, @@ -157,6 +178,7 @@ function fakeCoreClient(opts: { return { data: pz }; } if (path === "/pullzone/{id}") return { data: {} }; + if (path === "/pullzone/{id}/setForceSSL") return { data: undefined }; if (path === "/pullzone/{id}/purgeCache") return { data: undefined }; throw new Error(`unexpected POST ${path}`); }, @@ -322,6 +344,15 @@ test("createSite provisions storage zone → router → pull zone → state", as ); expect(attach?.body).toEqual({ MiddlewareScriptId: 20 }); + // The system host redirects HTTP → HTTPS out of the box. + const forceSsl = coreCalls.find( + (c) => c.method === "POST" && c.path === "/pullzone/{id}/setForceSSL", + ); + expect(forceSsl?.body).toEqual({ + Hostname: "my-site.b-cdn.net", + ForceSSL: true, + }); + // Remote state marks the zone as a site. const written = await readRemoteState(fakeConnection()); expect(written?.state).toMatchObject({ @@ -388,6 +419,43 @@ test("createSite refuses to re-provision an existing site", async () => { ).rejects.toThrow('Site "my-site" already exists.'); }); +test("createSite reports a globally-taken storage zone name clearly", async () => { + // The name is free on this account (findStorageZoneByName sees nothing) but + // taken globally, so the create POST comes back 409. + const coreClient = fakeCoreClient({ + calls: [], + createError: { + path: "/storagezone", + error: new ApiError( + "Conflict. The resource already exists or is in use.", + 409, + ), + }, + }); + const computeClient = fakeComputeClient({ calls: [] }); + + await expect( + createSite({ coreClient, computeClient, name: "my-site", region: "DE" }), + ).rejects.toThrow('The storage zone name "my-site" is already taken.'); +}); + +test("createSite reports a globally-taken pull zone name clearly", async () => { + // Storage zone and router provision fine; the pull zone name is taken (a 400 + // whose message says so on this endpoint). + const coreClient = fakeCoreClient({ + calls: [], + createError: { + path: "/pullzone", + error: new ApiError("The name is already taken.", 400), + }, + }); + const computeClient = fakeComputeClient({ calls: [] }); + + await expect( + createSite({ coreClient, computeClient, name: "my-site", region: "DE" }), + ).rejects.toThrow('The pull zone name "my-site" is already taken.'); +}); + // ---- promote ---- test("promoteDeploy sets CURRENT_DEPLOY and purges the pull zone cache", async () => { @@ -408,8 +476,40 @@ test("promoteDeploy sets CURRENT_DEPLOY and purges the pull zone cache", async ( Name: "CURRENT_DEPLOY", DefaultValue: "a1b2c3d4", }); - const purge = coreCalls.find((c) => c.path === "/pullzone/{id}/purgeCache"); - expect(purge?.params).toEqual({ path: { id: 30 } }); + // Purged twice: once immediately, once after the edge picks up the new deploy. + const purges = coreCalls.filter( + (c) => c.path === "/pullzone/{id}/purgeCache", + ); + expect(purges).toHaveLength(2); + expect(purges[0]?.params).toEqual({ path: { id: 30 } }); +}); + +test("promoteDeploy waits for the edge to serve a deploy before the final purge", async () => { + const coreCalls: Call[] = []; + const coreClient = fakeCoreClient({ calls: coreCalls }); + const computeClient = fakeComputeClient({ calls: [] }); + + // The edge returns the 404 placeholder until CURRENT_DEPLOY propagates. + const statuses = [404, 404, 200]; + const probed: string[] = []; + promoteVerification.probe = async (url) => { + probed.push(url); + return statuses.shift() ?? 200; + }; + + await promoteDeploy({ + computeClient, + coreClient, + state: fakeState(), + deployId: "a1b2c3d4", + }); + + // Kept probing past the placeholder, then purged a second time. + expect(probed.length).toBe(3); + expect(probed[0]).toContain("my-site.b-cdn.net"); + expect( + coreCalls.filter((c) => c.path === "/pullzone/{id}/purgeCache"), + ).toHaveLength(2); }); // ---- discovery ---- diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 23c7e1e3..31566785 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -1,7 +1,7 @@ import type { createComputeClient } from "@bunny.net/openapi-client"; import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; -import { UserError } from "../../core/errors.ts"; -import { createPullZone } from "../../core/hostnames/index.ts"; +import { ApiError, UserError } from "../../core/errors.ts"; +import { createPullZone, setForceSsl } from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; import { fetchScripts } from "../scripts/api.ts"; import { SCRIPT_TYPE_MIDDLEWARE } from "../scripts/constants.ts"; @@ -278,6 +278,20 @@ async function findPullZoneByName( ); } +/** + * A create failed because the globally-unique name is already taken (usually by + * another account, so the pre-create by-name lookup couldn't see it). Reported + * as a 409, or on some endpoints a 400 whose message says the name is in use. + */ +function isNameTaken(err: unknown): boolean { + if (!(err instanceof ApiError)) return false; + if (err.status === 409) return true; + return ( + err.status === 400 && + /already (exists|taken|in use)|not available|is taken/i.test(err.message) + ); +} + export interface CreateSiteOptions { coreClient: CoreClient; computeClient: ComputeClient; @@ -321,9 +335,20 @@ export async function createSite( } reused.storageZone = true; } else { - const { data } = await coreClient.POST("/storagezone", { - body: { Name: name, Region: region, ReplicationRegions: null }, - }); + let data: StorageZoneModel | undefined; + try { + ({ data } = await coreClient.POST("/storagezone", { + body: { Name: name, Region: region, ReplicationRegions: null }, + })); + } catch (err) { + if (isNameTaken(err)) { + throw new UserError( + `The storage zone name "${name}" is already taken.`, + "Storage zone names are global across bunny.net. Choose a different site name.", + ); + } + throw err; + } if (!data?.Id) { throw new UserError(`Failed to create storage zone "${name}".`); } @@ -378,7 +403,17 @@ export async function createSite( if (pullZone) { reused.pullZone = true; } else { - pullZone = await createPullZone(coreClient, name, storageZoneId); + try { + pullZone = await createPullZone(coreClient, name, storageZoneId); + } catch (err) { + if (isNameTaken(err)) { + throw new UserError( + `The pull zone name "${name}" is already taken.`, + "Pull zone names are global across bunny.net. Choose a different site name.", + ); + } + throw err; + } } if (pullZone.Id == null) { throw new UserError(`Pull zone "${name}" has no ID.`); @@ -388,6 +423,21 @@ export async function createSite( body: { MiddlewareScriptId: scriptId }, }); + // Force HTTPS on the .b-cdn.net system host: it already carries bunny's + // wildcard cert, so this just redirects HTTP. Best-effort — never fail create. + const systemHostname = (pullZone.Hostnames ?? []).find( + (h) => h.IsSystemHostname, + )?.Value; + if (systemHostname) { + try { + await setForceSsl(coreClient, pullZone.Id, systemHostname, true); + } catch (err) { + logger.warn( + `Couldn't force HTTPS on ${systemHostname}: ${err instanceof Error ? err.message : err}`, + ); + } + } + // 4. Remote state — from here on the zone identifies as a site. step("Writing site state..."); const state: RemoteSiteState = { @@ -412,9 +462,98 @@ export async function createSite( }; } +/** The pull zone's system hostname (`*.b-cdn.net`), or undefined on any failure. */ +export async function fetchSystemHostname( + coreClient: CoreClient, + pullZoneId: number, +): Promise { + try { + const { data } = await coreClient.GET("/pullzone/{id}", { + params: { path: { id: pullZoneId } }, + }); + return ( + (data?.Hostnames ?? []).find((h) => h.IsSystemHostname)?.Value ?? + undefined + ); + } catch { + return undefined; + } +} + +// Promote timing. `CURRENT_DEPLOY` is accepted by the control plane instantly +// but reaches the edge nodes asynchronously, so we confirm the edge is serving +// a deploy before the follow-up purge, then settle briefly so the value has +// landed everywhere. +const PROBE_TIMEOUT_MS = 4000; +const PROPAGATION_DEADLINE_MS = 20_000; +const PROPAGATION_INTERVAL_MS = 1500; +const SETTLE_FLOOR_MS = 2500; + +/** + * The CDN-probe seam. Tests swap these so promote runs without real network or + * real timers. + */ +export const promoteVerification = { + /** Probe the live site through the CDN; resolves to the HTTP status code. */ + probe: async (url: string): Promise => { + const res = await fetch(url, { + cache: "no-store", + redirect: "manual", + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }); + return res.status; + }, + wait: (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)), +}; + +/** + * Wait until the edge serves a real deploy for this site. The router returns + * the "no deploys yet" placeholder as a 404 while `CURRENT_DEPLOY` is unset or + * unpropagated, so any non-404 means a deploy is being served. Best-effort: if + * the system hostname can't be resolved we skip probing and just settle. + */ +async function waitForEdgePropagation( + coreClient: CoreClient, + state: RemoteSiteState, + deployId: string, +): Promise { + const host = await fetchSystemHostname(coreClient, state.pullZoneId); + const start = Date.now(); + if (host) { + const deadline = start + PROPAGATION_DEADLINE_MS; + let attempt = 0; + while (Date.now() < deadline) { + try { + // A unique query per attempt keeps each probe out of the CDN cache so a + // stale placeholder can't mask a propagated deploy. + const status = await promoteVerification.probe( + `https://${host}/?__bunny_promote=${deployId}-${attempt++}`, + ); + if (status !== 404) break; + } catch { + // Edge briefly unreachable (DNS/warmup) — keep trying until the deadline. + } + await promoteVerification.wait(PROPAGATION_INTERVAL_MS); + } + } + // Give the env var a moment to reach every node before the follow-up purge, + // so re-promotes don't re-cache the outgoing deploy's assets. + const elapsed = Date.now() - start; + if (elapsed < SETTLE_FLOOR_MS) { + await promoteVerification.wait(SETTLE_FLOOR_MS - elapsed); + } +} + /** * Point production at a deploy: update the router's `CURRENT_DEPLOY` env var * (takes effect without republishing code) and purge the pull zone cache. + * + * The env var reaches the edge asynchronously, so a single purge races it: a + * request during the propagation window re-caches the old deploy (or, on a + * first deploy, the 404 placeholder — the "styles broken until it settles" + * failure). We purge, wait for the edge to serve the deploy, then purge again + * so nothing stale survives. */ export async function promoteDeploy(opts: { computeClient: ComputeClient; @@ -422,14 +561,19 @@ export async function promoteDeploy(opts: { state: RemoteSiteState; deployId: string; }): Promise { + const purge = () => + opts.coreClient.POST("/pullzone/{id}/purgeCache", { + params: { path: { id: opts.state.pullZoneId } }, + body: {}, + }); + await opts.computeClient.PUT("/compute/script/{id}/variables", { params: { path: { id: opts.state.scriptId } }, body: { Name: CURRENT_DEPLOY_VAR, DefaultValue: opts.deployId }, }); - await opts.coreClient.POST("/pullzone/{id}/purgeCache", { - params: { path: { id: opts.state.pullZoneId } }, - body: {}, - }); + await purge(); + await waitForEdgePropagation(opts.coreClient, opts.state, opts.deployId); + await purge(); } export interface TeardownResult { diff --git a/packages/cli/src/commands/sites/build.test.ts b/packages/cli/src/commands/sites/build.test.ts index 2b3234e7..5118ddb0 100644 --- a/packages/cli/src/commands/sites/build.test.ts +++ b/packages/cli/src/commands/sites/build.test.ts @@ -1,8 +1,21 @@ import { expect, test } from "bun:test"; -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { parseEnvAssignments, parseEnvFile, runBuildCommand } from "./build.ts"; +import { + parseEnvAssignments, + parseEnvFile, + resolveAutoBuild, + runBuildCommand, +} from "./build.ts"; + +function tempRepo(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), "bunny-sites-auto-")); + for (const [name, content] of Object.entries(files)) { + writeFileSync(join(dir, name), content); + } + return dir; +} test("parseEnvAssignments parses KEY=VALUE pairs", () => { expect(parseEnvAssignments(["A=1", "B=with=equals", "C_1="])).toEqual({ @@ -56,6 +69,44 @@ test("parseEnvFile strips inline comments and unescapes inner quotes", () => { }); }); +test("resolveAutoBuild infers a detected framework's build and output dir", async () => { + const dir = tempRepo({ + "package.json": JSON.stringify({ + name: "x", + dependencies: { vite: "^6.0.0" }, + }), + "pnpm-lock.yaml": "", + }); + expect(await resolveAutoBuild(dir)).toEqual({ + command: "pnpm run build", + label: "Vite", + dir: "dist", + }); +}); + +test("resolveAutoBuild falls back to a package.json build script", async () => { + const dir = tempRepo({ + "package.json": JSON.stringify({ + name: "x", + scripts: { build: "tsc" }, + }), + }); + expect(await resolveAutoBuild(dir)).toEqual({ + command: "npm run build", + label: "a package.json build script", + }); +}); + +test("resolveAutoBuild returns null when nothing is detected", async () => { + const dir = tempRepo({ "index.html": "

hi

" }); + expect(await resolveAutoBuild(dir)).toBeNull(); + + const noBuildScript = tempRepo({ + "package.json": JSON.stringify({ name: "x", scripts: { test: "echo" } }), + }); + expect(await resolveAutoBuild(noBuildScript)).toBeNull(); +}); + test("runBuildCommand passes env and throws on failure", async () => { const dir = mkdtempSync(join(tmpdir(), "bunny-sites-build-")); const out = join(dir, "out.txt"); diff --git a/packages/cli/src/commands/sites/build.ts b/packages/cli/src/commands/sites/build.ts index 3cbb8890..55b23525 100644 --- a/packages/cli/src/commands/sites/build.ts +++ b/packages/cli/src/commands/sites/build.ts @@ -1,5 +1,11 @@ +import { join } from "node:path"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; +import { + detectFramework, + detectPackageManager, + presetBuildCommand, +} from "./ci/frameworks.ts"; const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; @@ -77,6 +83,50 @@ export function parseEnvFile(content: string): Record { return env; } +export interface AutoBuild { + /** Shell command to run. */ + command: string; + /** Human label for the prompt: the framework name, or a generic hint. */ + label: string; + /** Build output dir relative to the repo root, when the framework fixes one. */ + dir?: string; +} + +/** + * Infer a build to offer before a deploy: the detected framework's build + * command, else a `build` script in package.json. Returns null when nothing is + * detected or the framework is static (no build step). + */ +export async function resolveAutoBuild( + root: string, +): Promise { + const preset = await detectFramework(root); + const pm = await detectPackageManager(root); + if (preset) { + const command = presetBuildCommand(preset, pm); + return command ? { command, label: preset.label, dir: preset.dir } : null; + } + const pkg = await readPackageJson(root); + const scripts = pkg?.scripts as Record | undefined; + if (scripts?.build) { + return { command: `${pm} run build`, label: "a package.json build script" }; + } + return null; +} + +async function readPackageJson( + root: string, +): Promise | null> { + try { + return (await Bun.file(join(root, "package.json")).json()) as Record< + string, + unknown + >; + } catch { + return null; + } +} + /** * Run the build command in a shell with the merged environment, streaming * its output. Throws on a non-zero exit so a broken build never deploys. diff --git a/packages/cli/src/commands/sites/ci/frameworks.test.ts b/packages/cli/src/commands/sites/ci/frameworks.test.ts index 30889335..2d9c5549 100644 --- a/packages/cli/src/commands/sites/ci/frameworks.test.ts +++ b/packages/cli/src/commands/sites/ci/frameworks.test.ts @@ -2,7 +2,12 @@ import { expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { detectFramework, detectPackageManager } from "./frameworks.ts"; +import { + detectFramework, + detectPackageManager, + findPreset, + presetBuildCommand, +} from "./frameworks.ts"; function tempRepo(files: Record): string { const dir = mkdtempSync(join(tmpdir(), "bunny-sites-ci-")); @@ -98,6 +103,27 @@ test("detectFramework is undefined for unknown projects", async () => { expect(await detectFramework(dir)).toBeUndefined(); }); +test("presetBuildCommand runs the package.json build for plain js presets", () => { + const vite = findPreset("vite"); + expect(presetBuildCommand(vite as never, "pnpm")).toBe("pnpm run build"); +}); + +test("presetBuildCommand runs an explicit js build via the package runner", () => { + const nuxt = findPreset("nuxt"); + expect(presetBuildCommand(nuxt as never, "bun")).toBe("bunx nuxi generate"); + expect(presetBuildCommand(nuxt as never, "npm")).toBe("npx nuxi generate"); +}); + +test("presetBuildCommand runs non-js builds directly", () => { + const hugo = findPreset("hugo"); + expect(presetBuildCommand(hugo as never, "npm")).toBe("hugo --minify"); +}); + +test("presetBuildCommand returns null for the static preset", () => { + const staticPreset = findPreset("static"); + expect(presetBuildCommand(staticPreset as never, "npm")).toBeNull(); +}); + test("detectPackageManager reads the lockfile", async () => { expect(await detectPackageManager(tempRepo({ "bun.lock": "" }))).toBe("bun"); expect(await detectPackageManager(tempRepo({ "bun.lockb": "" }))).toBe("bun"); diff --git a/packages/cli/src/commands/sites/ci/frameworks.ts b/packages/cli/src/commands/sites/ci/frameworks.ts index f08ea80a..0b2b29ef 100644 --- a/packages/cli/src/commands/sites/ci/frameworks.ts +++ b/packages/cli/src/commands/sites/ci/frameworks.ts @@ -144,6 +144,26 @@ export function findPreset(id: string): FrameworkPreset | undefined { return FRAMEWORK_PRESETS.find((p) => p.id === id); } +// Runner for a project-local binary, mirroring the CI workflow's PM_EXEC. +const PM_EXEC: Record = { + bun: "bunx", + pnpm: "pnpm exec", + yarn: "yarn", + npm: "npx", +}; + +/** The build command to run locally for a preset, or null for the static (no-build) preset. */ +export function presetBuildCommand( + preset: FrameworkPreset, + pm: PackageManager, +): string | null { + if (preset.toolchain === "none") return null; + if (preset.toolchain === "js") { + return preset.build ? `${PM_EXEC[pm]} ${preset.build}` : `${pm} run build`; + } + return preset.build ?? null; +} + // Ordered most-specific first: meta-frameworks depend on vite, so vite goes last. // Blazor's compiled toolchain is selectable via --framework but not auto-detected. const JS_DETECTORS: Array<[dependency: string, presetId: string]> = [ diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 5ef2928b..8ba681c3 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -10,9 +10,19 @@ import { defineCommand } from "../../core/define-command.ts"; import { UserError } from "../../core/errors.ts"; import { formatBytes } from "../../core/format.ts"; import { logger } from "../../core/logger.ts"; -import { spinner } from "../../core/ui.ts"; -import { promoteDeploy, type SiteContext, writeRemoteState } from "./api.ts"; -import { parseEnvAssignments, parseEnvFile, runBuildCommand } from "./build.ts"; +import { confirm, isInteractive, spinner } from "../../core/ui.ts"; +import { + fetchSystemHostname, + promoteDeploy, + type SiteContext, + writeRemoteState, +} from "./api.ts"; +import { + parseEnvAssignments, + parseEnvFile, + resolveAutoBuild, + runBuildCommand, +} from "./build.ts"; import { loadSiteConfig } from "./config.ts"; import { type DeployRecord, @@ -36,24 +46,6 @@ interface DeployArgs extends SiteSelectorArgs { force?: boolean; } -/** System hostname from the pull zone; URLs are informational, so tolerate a fetch failure. */ -async function fetchSystemHost( - coreClient: ReturnType, - pullZoneId: number, -): Promise { - try { - const { data } = await coreClient.GET("/pullzone/{id}", { - params: { path: { id: pullZoneId } }, - }); - return ( - (data?.Hostnames ?? []).find((h) => h.IsSystemHostname)?.Value ?? - undefined - ); - } catch { - return undefined; - } -} - /** Production and preview URLs for a deploy, derived from the site's hosts. */ function deployUrls( site: SiteContext, @@ -133,11 +125,9 @@ export const sitesDeployCommand = defineCommand({ handler: async (args) => { const { profile, output, verbose, apiKey } = args; const siteConfig = loadSiteConfig(); + const root = siteConfig?.root ?? process.cwd(); + const explicitDir = args.dir ?? siteConfig?.config.dir; - const dir = resolve(args.dir ?? siteConfig?.config.dir ?? "."); - if (!existsSync(dir) || !statSync(dir).isDirectory()) { - throw new UserError(`Directory not found: ${dir}`); - } if (args.build === undefined && (args.env?.length || args["env-file"])) { throw new UserError( "--env/--env-file only apply to builds.", @@ -157,7 +147,10 @@ export const sitesDeployCommand = defineCommand({ }); const { state, connection, etag } = site; - // Build first — the deploy ID must hash the build *output*. + // Build before hashing — the deploy ID must key on the build *output*, and a + // first build may be what creates the deploy directory. When no build is + // detected, `autoDir` stays undefined and the explicit/default dir is used. + let autoDir: string | undefined; if (args.build !== undefined) { const command = args.build || siteConfig?.config.build; if (!command) { @@ -172,11 +165,32 @@ export const sitesDeployCommand = defineCommand({ : {}), ...parseEnvAssignments(args.env), }; - await runBuildCommand( - command, - siteConfig?.root ?? process.cwd(), - overrides, - ); + await runBuildCommand(command, root, overrides); + } else if (isInteractive(output)) { + // No --build: offer to run the configured build, else a detected one. + const configured = siteConfig?.config.build; + const auto = configured + ? { command: configured, label: "the configured build" } + : await resolveAutoBuild(root); + if (auto) { + // A detected framework fixes its output dir; target it unless one was + // given, whether or not the build runs (they may have built already). + if (explicitDir === undefined && "dir" in auto) autoDir = auto.dir; + const prompt = configured + ? `Run ${auto.label} (\`${auto.command}\`) before deploying?` + : `Detected ${auto.label}. Run \`${auto.command}\` before deploying?`; + if (await confirm(prompt, { initial: true })) { + await runBuildCommand(auto.command, root, {}); + } + } + } + + const dir = resolve(explicitDir ?? autoDir ?? "."); + if (autoDir && explicitDir === undefined) { + logger.info(`Deploying detected output directory: ${autoDir}`); + } + if (!existsSync(dir) || !statSync(dir).isDirectory()) { + throw new UserError(`Directory not found: ${dir}`); } const hashSpin = spinner("Hashing files..."); @@ -212,7 +226,7 @@ export const sitesDeployCommand = defineCommand({ const urls = deployUrls( site, deployId, - await fetchSystemHost(coreClient, state.pullZoneId), + await fetchSystemHostname(coreClient, state.pullZoneId), ); if (output === "json") { logger.log( @@ -299,7 +313,7 @@ export const sitesDeployCommand = defineCommand({ const urls = deployUrls( site, deployId, - await fetchSystemHost(coreClient, state.pullZoneId), + await fetchSystemHostname(coreClient, state.pullZoneId), ); if (output === "json") { diff --git a/packages/cli/src/commands/sites/index.ts b/packages/cli/src/commands/sites/index.ts index a6191344..7143f0ea 100644 --- a/packages/cli/src/commands/sites/index.ts +++ b/packages/cli/src/commands/sites/index.ts @@ -7,20 +7,24 @@ import { sitesDeploymentsNamespace } from "./deployments/index.ts"; import { sitesDomainsCommands } from "./domains/index.ts"; import { sitesLinkCommand } from "./link.ts"; import { sitesListCommand } from "./list.ts"; +import { sitesOpenCommand } from "./open.ts"; import { sitesShowCommand } from "./show.ts"; +import { sitesSslCommand } from "./ssl.ts"; import { sitesUnlinkCommand } from "./unlink.ts"; -import { sitesUpgradeCommand } from "./upgrade.ts"; +import { sitesUpgradeRouterCommand } from "./upgrade-router.ts"; export const sitesNamespace = defineNamespace("sites", false, [ sitesCreateCommand, sitesListCommand, sitesShowCommand, + sitesOpenCommand, sitesDeployCommand, sitesDeploymentsNamespace, ...sitesDomainsCommands, + sitesSslCommand, sitesCiNamespace, sitesLinkCommand, sitesUnlinkCommand, - sitesUpgradeCommand, + sitesUpgradeRouterCommand, sitesDeleteCommand, ]); diff --git a/packages/cli/src/commands/sites/open.test.ts b/packages/cli/src/commands/sites/open.test.ts new file mode 100644 index 00000000..cb112b6c --- /dev/null +++ b/packages/cli/src/commands/sites/open.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from "bun:test"; +import type { Hostname } from "../../core/hostnames/index.ts"; +import { type RemoteSiteState, STATE_VERSION } from "./constants.ts"; +import { siteLiveUrl } from "./open.ts"; +import { ROUTER_VERSION } from "./router/source.ts"; + +function state(overrides?: Partial): RemoteSiteState { + return { + version: STATE_VERSION, + name: "my-site", + storageZoneId: 10, + pullZoneId: 30, + scriptId: 20, + routerVersion: ROUTER_VERSION, + deploys: [], + ...overrides, + }; +} + +const SYSTEM: Hostname = { + Value: "my-site.b-cdn.net", + IsSystemHostname: true, + HasCertificate: true, +} as Hostname; + +const CUSTOM: Hostname = { + Value: "example.com", + IsSystemHostname: false, + HasCertificate: true, + ForceSSL: true, +} as Hostname; + +test("siteLiveUrl falls back to the system host when no custom domain", () => { + expect(siteLiveUrl(state(), [SYSTEM])).toBe("https://my-site.b-cdn.net"); +}); + +test("siteLiveUrl prefers the recorded custom domain", () => { + expect(siteLiveUrl(state({ domain: "example.com" }), [SYSTEM, CUSTOM])).toBe( + "https://example.com", + ); +}); + +test("siteLiveUrl uses the system host when the recorded domain isn't on the zone", () => { + // The domain was recorded but its hostname was later removed from the zone. + expect(siteLiveUrl(state({ domain: "gone.example.com" }), [SYSTEM])).toBe( + "https://my-site.b-cdn.net", + ); +}); + +test("siteLiveUrl serves the custom domain over http until its cert lands", () => { + const pending = { ...CUSTOM, HasCertificate: false, ForceSSL: false }; + expect(siteLiveUrl(state({ domain: "example.com" }), [SYSTEM, pending])).toBe( + "http://example.com", + ); +}); + +test("siteLiveUrl is undefined when the zone has no hostnames", () => { + expect(siteLiveUrl(state(), [])).toBeUndefined(); +}); diff --git a/packages/cli/src/commands/sites/open.ts b/packages/cli/src/commands/sites/open.ts new file mode 100644 index 00000000..ec2ce8d1 --- /dev/null +++ b/packages/cli/src/commands/sites/open.ts @@ -0,0 +1,96 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../config/index.ts"; +import { clientOptions } from "../../core/client-options.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { UserError } from "../../core/errors.ts"; +import { + fetchPullZoneHostnames, + type Hostname, + hostnameUrl, + liveHostnames, +} from "../../core/hostnames/index.ts"; +import { logger } from "../../core/logger.ts"; +import { openBrowser } from "../../core/ui.ts"; +import type { RemoteSiteState } from "./constants.ts"; +import { + type SiteSelectorArgs, + selectSite, + sitePositionalBuilder, +} from "./interactive.ts"; + +interface OpenArgs extends SiteSelectorArgs { + print?: boolean; +} + +/** The URL to open: the recorded custom domain when it's live, else the system host. */ +export function siteLiveUrl( + state: RemoteSiteState, + hostnames: Hostname[], +): string | undefined { + if (state.domain) { + const custom = hostnames.find( + (h) => (h.Value ?? "").toLowerCase() === state.domain?.toLowerCase(), + ); + if (custom?.Value) { + return hostnameUrl(custom.Value, { + hasCertificate: custom.HasCertificate, + forceSSL: custom.ForceSSL, + }); + } + } + return liveHostnames(hostnames).primary; +} + +export const sitesOpenCommand = defineCommand({ + command: "open [site]", + describe: "Open a site's live URL in the browser.", + examples: [ + ["$0 sites open", "Open the linked site"], + ["$0 sites open my-site", "Open a specific site"], + ["$0 sites open --print", "Print the URL instead of opening it"], + ], + + builder: (yargs) => + sitePositionalBuilder(yargs).option("print", { + type: "boolean", + default: false, + describe: "Print the URL instead of opening it in the browser", + }), + + handler: async ({ + site: ref, + link, + print, + profile, + output, + verbose, + apiKey, + }) => { + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const { site } = await selectSite(client, { site: ref, link, output }); + const { state } = site; + + const hostnames = await fetchPullZoneHostnames(client, state.pullZoneId); + const url = siteLiveUrl(state, hostnames); + if (!url) { + throw new UserError( + `Site "${state.name}" has no reachable hostname yet.`, + "Add a domain with `bunny sites domains add`, or deploy first.", + ); + } + + if (output === "json") { + logger.log(JSON.stringify({ url }, null, 2)); + return; + } + if (print) { + logger.log(url); + return; + } + + logger.info(`Opening ${url}`); + openBrowser(url); + }, +}); diff --git a/packages/cli/src/commands/sites/router/source.ts b/packages/cli/src/commands/sites/router/source.ts index 71f71a6f..6711053f 100644 --- a/packages/cli/src/commands/sites/router/source.ts +++ b/packages/cli/src/commands/sites/router/source.ts @@ -10,14 +10,14 @@ * * Promote/rollback is just updating the `CURRENT_DEPLOY` env var — the code * itself never changes between deploys. Bump {@link ROUTER_VERSION} whenever - * the source changes; `sites show` warns and `sites upgrade` republishes. + * the source changes; `sites show` warns and `sites upgrade-router` republishes. */ export const ROUTER_VERSION = 1; // NOTE: validated by the Phase 0 spike against the live platform; the exact // BunnySDK middleware hook names are the platform contract this encodes. const SOURCE = `// bunny sites router v__ROUTER_VERSION__ — generated by the bunny CLI. Do not edit: -// \`bunny sites upgrade\` overwrites this script. +// \`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; diff --git a/packages/cli/src/commands/sites/show.ts b/packages/cli/src/commands/sites/show.ts index 38029db8..2dc815db 100644 --- a/packages/cli/src/commands/sites/show.ts +++ b/packages/cli/src/commands/sites/show.ts @@ -102,7 +102,7 @@ export const sitesShowCommand = defineCommand({ logger.log(); logger.log( formatTable( - ["Domain", "Type", "SSL"], + ["Domain", "Type", "SSL", "Force SSL"], hostnames.map((h) => [ hostnameUrl(h.Value ?? "", { hasCertificate: h.HasCertificate, @@ -110,6 +110,7 @@ export const sitesShowCommand = defineCommand({ }), h.IsSystemHostname ? "System" : "Custom", h.HasCertificate ? "Yes" : "No", + h.ForceSSL ? "Yes" : "No", ]), output, ), @@ -119,7 +120,7 @@ export const sitesShowCommand = defineCommand({ if (routerOutdated) { logger.log(); logger.warn( - `A newer site router (v${ROUTER_VERSION}) is available. Run \`bunny sites upgrade\` to update.`, + `A newer site router (v${ROUTER_VERSION}) is available. Run \`bunny sites upgrade-router\` to update.`, ); } diff --git a/packages/cli/src/commands/sites/ssl.ts b/packages/cli/src/commands/sites/ssl.ts new file mode 100644 index 00000000..d32ee4a9 --- /dev/null +++ b/packages/cli/src/commands/sites/ssl.ts @@ -0,0 +1,86 @@ +import { createCoreClient } from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../config/index.ts"; +import { clientOptions } from "../../core/client-options.ts"; +import { defineCommand } from "../../core/define-command.ts"; +import { UserError } from "../../core/errors.ts"; +import { + fetchPullZoneHostnames, + setForceSsl, +} from "../../core/hostnames/index.ts"; +import { logger } from "../../core/logger.ts"; +import { spinner } from "../../core/ui.ts"; +import { + type SiteSelectorArgs, + selectSite, + sitePositionalBuilder, +} from "./interactive.ts"; + +interface SslArgs extends SiteSelectorArgs { + "force-ssl"?: boolean; +} + +/** + * Toggle Force SSL (HTTP→HTTPS redirect) on the site's `.b-cdn.net` + * system host. It always carries bunny's wildcard certificate, so no cert is + * issued here. Custom domains are managed via `sites domains ssl`. + */ +export const sitesSslCommand = defineCommand({ + command: "ssl [site]", + describe: "Force HTTPS on a site's .b-cdn.net address.", + examples: [ + ["$0 sites ssl", "Force HTTP→HTTPS on the linked site's system host"], + [ + "$0 sites ssl my-site --no-force-ssl", + "Allow plain HTTP on the system host", + ], + ], + + builder: (yargs) => + sitePositionalBuilder(yargs).option("force-ssl", { + type: "boolean", + default: true, + describe: + "Force HTTP→HTTPS on the system host (default: true). Use --no-force-ssl to allow HTTP.", + }), + + handler: async (args) => { + const { site: ref, link, profile, output, verbose, apiKey } = args; + const force = args["force-ssl"] !== false; + + const config = resolveConfig(profile, apiKey, verbose); + const client = createCoreClient(clientOptions(config, verbose)); + + const { site } = await selectSite(client, { site: ref, link, output }); + const { state } = site; + + const spin = spinner("Updating Force SSL..."); + spin.start(); + let systemHost: string | null | undefined; + try { + const hostnames = await fetchPullZoneHostnames(client, state.pullZoneId); + systemHost = hostnames.find((h) => h.IsSystemHostname)?.Value; + if (!systemHost) { + throw new UserError( + `Couldn't find the system hostname for "${state.name}".`, + ); + } + await setForceSsl(client, state.pullZoneId, systemHost, force); + } finally { + spin.stop(); + } + + if (output === "json") { + logger.log( + JSON.stringify({ hostname: systemHost, forceSSL: force }, null, 2), + ); + return; + } + + logger.success( + force + ? `${systemHost} now redirects HTTP → HTTPS.` + : `${systemHost} now serves plain HTTP too.`, + ); + logger.dim(" Custom domains: bunny sites domains ssl "); + }, +}); diff --git a/packages/cli/src/commands/sites/upgrade.ts b/packages/cli/src/commands/sites/upgrade-router.ts similarity index 91% rename from packages/cli/src/commands/sites/upgrade.ts rename to packages/cli/src/commands/sites/upgrade-router.ts index 8965748f..22d5ffd7 100644 --- a/packages/cli/src/commands/sites/upgrade.ts +++ b/packages/cli/src/commands/sites/upgrade-router.ts @@ -23,12 +23,15 @@ interface UpgradeArgs extends SiteSelectorArgs { * Republish the site's router script at the CLI's current version. Deploys and * env vars are untouched — only the router code changes. */ -export const sitesUpgradeCommand = defineCommand({ - command: "upgrade [site]", +export const sitesUpgradeRouterCommand = defineCommand({ + command: "upgrade-router [site]", describe: "Upgrade a site's router script to the latest version.", examples: [ - ["$0 sites upgrade", "Upgrade the linked site's router"], - ["$0 sites upgrade my-site --force", "Republish even when up to date"], + ["$0 sites upgrade-router", "Upgrade the linked site's router"], + [ + "$0 sites upgrade-router my-site --force", + "Republish even when up to date", + ], ], builder: (yargs) => diff --git a/packages/cli/src/core/hostnames/client.ts b/packages/cli/src/core/hostnames/client.ts index e1f20198..d63e01d6 100644 --- a/packages/cli/src/core/hostnames/client.ts +++ b/packages/cli/src/core/hostnames/client.ts @@ -151,6 +151,19 @@ export async function addHostname( return { hostnames, cnameTarget }; } +/** Set a hostname's Force SSL (HTTP→HTTPS redirect) state; assumes the cert is already in place. */ +export async function setForceSsl( + client: CoreClient, + pullZoneId: number, + hostname: string, + forceSSL: boolean, +): Promise { + await client.POST("/pullzone/{id}/setForceSSL", { + params: { path: { id: pullZoneId } }, + body: { Hostname: hostname, ForceSSL: forceSSL }, + }); +} + /** Issue a free SSL certificate for a hostname on a pull zone, then set its Force SSL state. */ export async function enableSsl( client: CoreClient, @@ -177,8 +190,5 @@ export async function enableSsl( params: { query: { hostname } }, }); // Always set Force SSL to the requested value so --no-force-ssl can also turn it off. - await client.POST("/pullzone/{id}/setForceSSL", { - params: { path: { id: pullZoneId } }, - body: { Hostname: hostname, ForceSSL: forceSSL }, - }); + await setForceSsl(client, pullZoneId, hostname, forceSSL); } diff --git a/packages/cli/src/core/hostnames/index.ts b/packages/cli/src/core/hostnames/index.ts index 03af3ec0..e2156c6d 100644 --- a/packages/cli/src/core/hostnames/index.ts +++ b/packages/cli/src/core/hostnames/index.ts @@ -17,6 +17,7 @@ export { normalizeHostname, type ResolvedPullZone, type SafeHostname, + setForceSsl, toSafeHostname, } from "./client.ts"; export { From 5c0588a55e3be546304e39c315ea22293e722a40 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 14 Jul 2026 12:41:13 +0100 Subject: [PATCH 08/24] apply code suggestions --- .../src/commands/sites/ci/workflow.test.ts | 25 +++++++++++++------ .../cli/src/commands/sites/ci/workflow.ts | 5 ++-- .../cli/src/commands/sites/constants.test.ts | 7 ++++++ packages/cli/src/commands/sites/constants.ts | 3 +++ .../src/commands/sites/deployments/prune.ts | 11 +++++++- 5 files changed, 41 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/commands/sites/ci/workflow.test.ts b/packages/cli/src/commands/sites/ci/workflow.test.ts index d952f4e2..470a2794 100644 --- a/packages/cli/src/commands/sites/ci/workflow.test.ts +++ b/packages/cli/src/commands/sites/ci/workflow.test.ts @@ -18,8 +18,8 @@ test("astro + bun workflow builds with bun and deploys dist", () => { expect(yml).toContain("uses: oven-sh/setup-bun@v2"); expect(yml).toContain("run: bun run build"); expect(yml).toContain(`uses: ${DEPLOY_SITE_ACTION}`); - expect(yml).toContain("site: my-site"); - expect(yml).toContain("directory: dist"); + expect(yml).toContain('site: "my-site"'); + expect(yml).toContain('directory: "dist"'); // Preview by default; production only on pushes to main. expect(yml).toContain("production: ${{ github.event_name == 'push' }}"); // Fork PRs are skipped, not failed. @@ -36,7 +36,7 @@ test("jekyll workflow uses ruby and deploys _site", () => { }); expect(yml).toContain("uses: ruby/setup-ruby@v1"); expect(yml).toContain("run: bundle exec jekyll build"); - expect(yml).toContain("directory: _site"); + expect(yml).toContain('directory: "_site"'); expect(yml).not.toContain("setup-node"); }); @@ -47,7 +47,7 @@ test("static workflow has no build step", () => { packageManager: "npm", }); expect(yml).not.toContain("run: npm"); - expect(yml).toContain("directory: ."); + expect(yml).toContain('directory: "."'); }); test("nuxt runs its build override via the package manager's exec runner", () => { @@ -58,7 +58,7 @@ test("nuxt runs its build override via the package manager's exec runner", () => }); expect(yml).toContain("run: bun install --frozen-lockfile"); expect(yml).toContain("run: bunx nuxi generate"); - expect(yml).toContain("directory: .output/public"); + expect(yml).toContain('directory: ".output/public"'); expect(yml).not.toContain("run: bun run build"); }); @@ -71,7 +71,7 @@ test("mkdocs uses the python toolchain and deploys site", () => { expect(yml).toContain("uses: actions/setup-python@v5"); expect(yml).toContain("run: pip install -r requirements.txt"); expect(yml).toContain("run: mkdocs build"); - expect(yml).toContain("directory: site"); + expect(yml).toContain('directory: "site"'); expect(yml).not.toContain("setup-node"); }); @@ -91,7 +91,18 @@ test("zola installs the zola binary and blazor uses dotnet", () => { }); expect(blazor).toContain("uses: actions/setup-dotnet@v4"); expect(blazor).toContain("run: dotnet publish -c Release"); - expect(blazor).toContain("directory: bin/Release/net8.0/publish/wwwroot"); + expect(blazor).toContain('directory: "bin/Release/net8.0/publish/wwwroot"'); +}); + +test("interpolated site name is a quoted, inert YAML scalar", () => { + const yml = renderSitesWorkflow({ + site: "evil\n run: rm -rf /", + preset: preset("static"), + packageManager: "npm", + }); + // The newline is encoded inside the quoted scalar, never a new YAML line. + expect(yml).toContain('site: "evil\\n run: rm -rf /"'); + expect(yml).not.toContain("\n run: rm -rf /\n"); }); test("npm and pnpm projects get the matching install steps", () => { diff --git a/packages/cli/src/commands/sites/ci/workflow.ts b/packages/cli/src/commands/sites/ci/workflow.ts index 88094eda..13290383 100644 --- a/packages/cli/src/commands/sites/ci/workflow.ts +++ b/packages/cli/src/commands/sites/ci/workflow.ts @@ -141,8 +141,9 @@ export function renderSitesWorkflow(opts: { "", ` - uses: ${DEPLOY_SITE_ACTION}`, " with:", - ` site: ${site}`, - ` directory: ${preset.dir}`, + // Quote the interpolated values so they're always inert YAML scalars. + ` site: ${JSON.stringify(site)}`, + ` directory: ${JSON.stringify(preset.dir)}`, " production: ${{ github.event_name == 'push' }}", " api_key: ${{ secrets.BUNNY_API_KEY }}", ]; diff --git a/packages/cli/src/commands/sites/constants.test.ts b/packages/cli/src/commands/sites/constants.test.ts index 6459c339..edcf7984 100644 --- a/packages/cli/src/commands/sites/constants.test.ts +++ b/packages/cli/src/commands/sites/constants.test.ts @@ -41,6 +41,13 @@ test("parseRemoteState rejects garbage", () => { expect( parseRemoteState(JSON.stringify({ ...validState, deploys: {} })), ).toBeNull(); + // A tampered name that isn't a legal zone name is rejected outright, so it + // can't reach storage paths or generated CI YAML. + expect( + parseRemoteState( + JSON.stringify({ ...validState, name: "evil\n run: rm -rf /" }), + ), + ).toBeNull(); }); test("deploy path and preview host helpers", () => { diff --git a/packages/cli/src/commands/sites/constants.ts b/packages/cli/src/commands/sites/constants.ts index 14c822f0..44e1bf15 100644 --- a/packages/cli/src/commands/sites/constants.ts +++ b/packages/cli/src/commands/sites/constants.ts @@ -110,6 +110,9 @@ export function parseRemoteState(raw: string): RemoteSiteState | null { if ( typeof s.version !== "number" || typeof s.name !== "string" || + // A tampered/corrupt name would flow into storage paths and generated CI + // YAML unquoted; reject state whose name isn't a legal zone name. + !isValidSiteName(s.name) || typeof s.storageZoneId !== "number" || typeof s.pullZoneId !== "number" || typeof s.scriptId !== "number" || diff --git a/packages/cli/src/commands/sites/deployments/prune.ts b/packages/cli/src/commands/sites/deployments/prune.ts index e513b174..4c3bbdd9 100644 --- a/packages/cli/src/commands/sites/deployments/prune.ts +++ b/packages/cli/src/commands/sites/deployments/prune.ts @@ -5,7 +5,11 @@ import { defineCommand } from "../../../core/define-command.ts"; import { logger } from "../../../core/logger.ts"; import { confirm, spinner } from "../../../core/ui.ts"; import { deleteDeployFiles, writeRemoteState } from "../api.ts"; -import { DEFAULT_KEEP_DEPLOYS, type DeployRecord } from "../constants.ts"; +import { + DEFAULT_KEEP_DEPLOYS, + type DeployRecord, + isValidDeployId, +} from "../constants.ts"; import { type SiteSelectorArgs, selectSite, @@ -105,6 +109,11 @@ export const sitesDeploymentsPruneCommand = defineCommand({ for (const [index, victim] of victims.entries()) { spin.text = `Pruning ${victim.id} (${index + 1}/${victims.length})...`; try { + // Never interpolate an unvalidated ID into a storage path. + if (!isValidDeployId(victim.id)) { + failures.push({ id: victim.id, error: "Invalid deploy ID." }); + continue; + } await deleteDeployFiles(connection, victim.id); pruned.add(victim.id); } catch (err) { From b07594265f1fc8c710119ec5d417c7c1d53f00be Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 14 Jul 2026 18:46:43 +0100 Subject: [PATCH 09/24] provision deployment tweaks --- AGENTS.md | 17 +-- README.md | 5 +- packages/cli/src/commands/sites/api.test.ts | 73 +++++++++--- packages/cli/src/commands/sites/api.ts | 4 +- .../cli/src/commands/sites/constants.test.ts | 6 - packages/cli/src/commands/sites/constants.ts | 21 +++- packages/cli/src/commands/sites/create.ts | 46 +------- packages/cli/src/commands/sites/deploy.ts | 21 +++- .../commands/sites/deployments/prune.test.ts | 3 +- .../src/commands/sites/deployments/prune.ts | 17 +-- .../cli/src/commands/sites/interactive.ts | 30 ++++- packages/cli/src/commands/sites/open.test.ts | 2 - .../cli/src/commands/sites/provision.test.ts | 36 ++++++ packages/cli/src/commands/sites/provision.ts | 110 ++++++++++++++++++ .../src/commands/sites/router/source.test.ts | 36 ++++++ .../cli/src/commands/sites/router/source.ts | 102 +++++++++++++--- packages/cli/src/commands/sites/show.ts | 15 --- .../cli/src/commands/sites/upgrade-router.ts | 65 ++--------- 18 files changed, 415 insertions(+), 194 deletions(-) create mode 100644 packages/cli/src/commands/sites/provision.test.ts create mode 100644 packages/cli/src/commands/sites/provision.ts create mode 100644 packages/cli/src/commands/sites/router/source.test.ts diff --git a/AGENTS.md b/AGENTS.md index a1ec4a08..a68eca43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -379,24 +379,25 @@ bunny-cli/ │ │ │ ├── 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), 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), promoteDeploy (env var PUT + purgeCache POST), 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 → .bunny/site.json → bunny.jsonc sites.name → picker (offerLink like scripts); siteOptionBuilder (--site) + sitePositionalBuilder ([site]) +│ │ │ ├── interactive.ts # selectSite: explicit ref → .bunny/site.json → bunny.jsonc sites.name → picker (offerLink like scripts); optional offerCreate (deploy only) adds a new-vs-existing prompt, and creates straight away when the account has no sites; siteOptionBuilder (--site) + sitePositionalBuilder ([site]) +│ │ │ ├── provision.ts # promptSiteName (normalize/validate, directory-name suggestion; shared with create.ts) + createLinkedSite (createSite + manifest link → SiteContext, skipping create's domain/CI prompts) for the deploy picker's new-site branch │ │ │ ├── config.ts # loadSiteConfig: lenient bunny.jsonc reader — validates ONLY the `sites` block (SiteConfigSchema from @bunny.net/app-config), so sites-only configs work without an `app` block -│ │ │ ├── router/source.ts # ROUTER_VERSION + routerSource(): the middleware Edge Script attached to the pull zone. Host → deploy dir mapping (apex → CURRENT_DEPLOY, dpl-{id}.preview.* → that deploy), /_bunny/* → 403, /deploys/* passthrough (path previews), trailing-slash → index.html, 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, /deploys/{id}/ passthrough (path preview) flagged with x-bunny-preview header, /_bunny/* → 403, trailing-slash → index.html. 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 │ │ │ ├── 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 │ │ │ ├── uploader.test.ts # Walk/skip/hash tests + upload paths/checksums/retry via siteFiles swap │ │ │ ├── build.ts # parseEnvAssignments/parseEnvFile + 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] (prompted, directory-name suggestion): createSite (also forces HTTPS on the .b-cdn.net system host, best-effort) + manifest link + custom domain via setupSiteDomain (--domain flag, offered interactively when omitted; domain failure warns, never fails the create) +│ │ │ ├── create.ts # bunny sites create [name] (prompted, 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 │ │ │ ├── ssl.ts # bunny sites ssl [site]: toggle Force HTTPS on the .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]: build (explicit --build, or an interactively-offered detected/configured build) → hash → no-op if unchanged → upload deploys/{id}/ → state update → preview URL, 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 (explicit --build, or an interactively-offered detected/configured build) → hash → no-op if unchanged → upload deploys/{id}/ → state update → immutable preview URL (custom-domain dpl-{id}.preview.* when a domain exists, else the .b-cdn.net/deploys/{id}/ path — rendered correctly by the router's HTMLRewriter), or publish live with --production/--prod │ │ │ ├── link.ts # Link directory to a site (.bunny/site.json) │ │ │ ├── unlink.ts # Remove .bunny/site.json -│ │ │ ├── upgrade-router.ts # Republish the router at ROUTER_VERSION + bump state.routerVersion +│ │ │ ├── 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), scaffold.ts (git helpers, scaffoldSitesWorkflow, 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 (pruneVictims never drops current/previous) + prune.test.ts @@ -1088,14 +1089,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 (experimental — hidden from help and landing page) -│ │ Static-site hosting: one storage zone (files) + one pull zone (CDN) + one middleware router script per site. Deploys are immutable directories (`deploys/{id}/`); promote/rollback flips the router's CURRENT_DEPLOY env var + purges the cache — no files move. 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). +│ │ Static-site hosting: one storage zone (files) + one pull zone (CDN) + one middleware router script per site. 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 `.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). │ ├── create [name] [--region] [--domain] [--link] │ │ Provision a site (idempotent — a failed create re-runs cleanly; each resource is looked up by name first). Interactive runs prompt for the name when omitted (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] 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] [--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). [dir] defaults to `sites.dir` in bunny.jsonc, then cwd (dotfiles + node_modules excluded). --build runs the command (or `sites.build`) 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 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 `.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. [dir] defaults to `sites.dir` in bunny.jsonc, then cwd (dotfiles + node_modules excluded). --build runs the command (or `sites.build`) 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. │ ├── deployments │ │ ├── list [site] (alias: ls) List deploys (● Live / ○ Previous markers, created, source, files, size) │ │ ├── publish [id] [--previous] [--site] [--force] (alias: promote) @@ -1111,7 +1112,7 @@ bunny │ │ └── init [--site] [--framework] [--force] Write .github/workflows/bunny-sites.yml: 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` │ ├── link [site] Link this directory to a site → .bunny/site.json │ ├── unlink Remove .bunny/site.json -│ ├── upgrade-router [site] [--force] Republish the router script at the CLI's ROUTER_VERSION +│ ├── upgrade-router [site] Republish the site's router script with the CLI's current source │ └── delete [site] [--force] [--keep-storage] Delete pull zone → router → storage zone (typed-name confirmation; best-effort so re-runs finish a partial delete) ├── docs Open bunny.net documentation in browser ├── open [--print] Open bunny.net dashboard in browser (or print URL) diff --git a/README.md b/README.md index 1bda3071..9f6e11e7 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,9 @@ 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) -bun ny sites deploy # detect the framework and offer to run its build, then deploy the output -bun ny sites deploy ./dist # deploy a directory to a preview URL +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 (.b-cdn.net/deploys//); the router's HTMLRewriter keeps root-absolute assets working +bun ny sites deployments prune # delete old deploys (keeps the newest 5, never current/previous) bun ny sites deploy ./dist --production # deploy and publish as the live site (--prod works too) bun ny sites deploy --build # run `sites.build` from bunny.jsonc, then deploy `sites.dir` bun ny sites deployments list # list deploys with the live one marked diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index 37e187ec..3802606e 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -18,7 +18,6 @@ import { type RemoteSiteState, STATE_VERSION, } from "./constants.ts"; -import { ROUTER_VERSION } from "./router/source.ts"; // ---- in-memory storage-file store (replaces the storage SDK) ---- @@ -86,7 +85,6 @@ function fakeState(overrides?: Partial): RemoteSiteState { storageZoneId: 10, pullZoneId: 30, scriptId: 20, - routerVersion: ROUTER_VERSION, deploys: [], ...overrides, }; @@ -108,6 +106,7 @@ function fakeCoreClient(opts: { }): CoreClient { const zones = opts.storageZones ?? []; const pullZones = opts.pullZones ?? []; + let nextPullZoneId = 30; return { GET: async ( path: string, @@ -169,10 +168,11 @@ function fakeCoreClient(opts: { return { data: zone }; } if (path === "/pullzone") { + const name = (options?.body as { Name: string }).Name; const pz = { - Id: 30, - Name: (options?.body as { Name: string }).Name, - Hostnames: [{ IsSystemHostname: true, Value: "my-site.b-cdn.net" }], + Id: nextPullZoneId++, + Name: name, + Hostnames: [{ IsSystemHostname: true, Value: `${name}.b-cdn.net` }], }; pullZones.push(pz); return { data: pz }; @@ -198,6 +198,7 @@ function fakeComputeClient(opts: { scripts?: Array<{ Id: number; Name: string }>; }): ComputeClient { const scripts = opts.scripts ?? []; + let nextScriptId = 20; return { GET: async (path: string) => { opts.calls.push({ method: "GET", path }); @@ -208,7 +209,7 @@ function fakeComputeClient(opts: { opts.calls.push({ method: "POST", path, body: options?.body }); if (path === "/compute/script") { const script = { - Id: 20, + Id: nextScriptId++, Name: (options?.body as { Name: string }).Name, }; scripts.push(script); @@ -216,12 +217,24 @@ function fakeComputeClient(opts: { } return { data: {} }; }, - PUT: async (path: string, options?: { body?: unknown }) => { - opts.calls.push({ method: "PUT", path, body: options?.body }); + PUT: async ( + path: string, + options?: { params?: unknown; body?: unknown }, + ) => { + opts.calls.push({ + method: "PUT", + path, + params: options?.params as Record, + body: options?.body, + }); return { data: {} }; }, - DELETE: async (path: string) => { - opts.calls.push({ method: "DELETE", path }); + DELETE: async (path: string, options?: { params?: unknown }) => { + opts.calls.push({ + method: "DELETE", + path, + params: options?.params as Record, + }); return { data: undefined }; }, } as unknown as ComputeClient; @@ -334,11 +347,15 @@ test("createSite provisions storage zone → router → pull zone → state", as ); expect(envSet?.body).toEqual({ Name: "CURRENT_DEPLOY", DefaultValue: "" }); - // The pull zone is created from the storage zone and the router is attached. - const pzCreate = coreCalls.find( + // Exactly one pull zone (production) is created; the router is attached. + const pzCreates = coreCalls.filter( (c) => c.method === "POST" && c.path === "/pullzone", ); - expect(pzCreate?.body).toMatchObject({ Name: "my-site", StorageZoneId: 10 }); + expect(pzCreates).toHaveLength(1); + expect(pzCreates[0]?.body).toMatchObject({ + Name: "my-site", + StorageZoneId: 10, + }); const attach = coreCalls.find( (c) => c.method === "POST" && c.path === "/pullzone/{id}", ); @@ -353,6 +370,11 @@ test("createSite provisions storage zone → router → pull zone → state", as ForceSSL: true, }); + // Exactly one middleware script (the router) is created. + expect(computePaths.filter((p) => p === "POST /compute/script")).toHaveLength( + 1, + ); + // Remote state marks the zone as a site. const written = await readRemoteState(fakeConnection()); expect(written?.state).toMatchObject({ @@ -360,7 +382,6 @@ test("createSite provisions storage zone → router → pull zone → state", as storageZoneId: 10, pullZoneId: 30, scriptId: 20, - routerVersion: ROUTER_VERSION, }); }); @@ -569,6 +590,30 @@ test("deleteSiteResources removes the site marker when keeping storage", async ( expect(store.has("deploys/aaa/index.html")).toBe(true); }); +test("deleteSiteResources deletes the pull zone, router, and storage zone", async () => { + const coreCalls: Call[] = []; + const computeCalls: Call[] = []; + const coreClient = fakeCoreClient({ calls: coreCalls }); + const computeClient = fakeComputeClient({ calls: computeCalls }); + + const results = await deleteSiteResources({ + coreClient, + computeClient, + state: fakeState(), + }); + + const deletedPullZoneIds = coreCalls + .filter((c) => c.method === "DELETE" && c.path === "/pullzone/{id}") + .map((c) => (c.params as { path: { id: number } }).path.id); + expect(deletedPullZoneIds).toEqual([30]); + const deletedScriptIds = computeCalls + .filter((c) => c.method === "DELETE" && c.path === "/compute/script/{id}") + .map((c) => (c.params as { path: { id: number } }).path.id); + expect(deletedScriptIds).toEqual([20]); + // Pull zone + router script + storage zone. + expect(results.filter((r) => r.deleted)).toHaveLength(3); +}); + // Regression: the live API returns GET /pullzone as a paginated envelope // ({ Items, CurrentPage, HasMoreItems }) — the spec's plain array is a lie // for some queries (e.g. ?search=). createSite crashed on `.find` here. diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 31566785..21bb3818 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -25,7 +25,7 @@ import { routerScriptName, STATE_VERSION, } from "./constants.ts"; -import { ROUTER_VERSION, routerSource } from "./router/source.ts"; +import { routerSource } from "./router/source.ts"; export type ComputeClient = ReturnType; type PullZone = components["schemas"]["PullZoneModel"]; @@ -446,7 +446,6 @@ export async function createSite( storageZoneId, pullZoneId: pullZone.Id, scriptId, - routerVersion: ROUTER_VERSION, deploys: [], }; const connection = siteFiles.connect(storageZone); @@ -617,6 +616,7 @@ export async function deleteSiteResources(opts: { } }; + // The pull zone references both the script and the storage zone, so it goes first. await attempt("pull zone", state.pullZoneId, () => coreClient.DELETE("/pullzone/{id}", { params: { path: { id: state.pullZoneId } }, diff --git a/packages/cli/src/commands/sites/constants.test.ts b/packages/cli/src/commands/sites/constants.test.ts index edcf7984..600263bd 100644 --- a/packages/cli/src/commands/sites/constants.test.ts +++ b/packages/cli/src/commands/sites/constants.test.ts @@ -15,7 +15,6 @@ const validState: RemoteSiteState = { storageZoneId: 1, pullZoneId: 2, scriptId: 3, - routerVersion: 1, deploys: [], }; @@ -23,11 +22,6 @@ test("parseRemoteState round-trips a valid state", () => { expect(parseRemoteState(JSON.stringify(validState))).toEqual(validState); }); -test("parseRemoteState defaults routerVersion for legacy state", () => { - const { routerVersion: _rv, ...legacy } = validState; - expect(parseRemoteState(JSON.stringify(legacy))?.routerVersion).toBe(0); -}); - test("parseRemoteState rejects garbage", () => { expect(parseRemoteState("not json")).toBeNull(); expect(parseRemoteState("null")).toBeNull(); diff --git a/packages/cli/src/commands/sites/constants.ts b/packages/cli/src/commands/sites/constants.ts index 44e1bf15..63973702 100644 --- a/packages/cli/src/commands/sites/constants.ts +++ b/packages/cli/src/commands/sites/constants.ts @@ -50,7 +50,6 @@ export interface RemoteSiteState { storageZoneId: number; pullZoneId: number; scriptId: number; - routerVersion: number; /** Primary custom domain (apex), when one has been attached. */ domain?: string; current?: string; @@ -63,6 +62,21 @@ export function deployPrefix(deployId: string): string { return `${DEPLOYS_DIR}/${deployId}`; } +/** Pick the deploys beyond the newest `keep`, never the current or previous. */ +export function pruneVictims( + deploys: DeployRecord[], + keep: number, + current?: string, + previous?: string, +): DeployRecord[] { + const sorted = [...deploys].sort((a, b) => + b.createdAt.localeCompare(a.createdAt), + ); + return sorted + .slice(Math.max(0, keep)) + .filter((d) => d.id !== current && d.id !== previous); +} + /** The preview hostname for a deploy on a custom domain. */ export function previewHostname(deployId: string, domain: string): string { return `dpl-${deployId}.${PREVIEW_LABEL}.${domain}`; @@ -120,8 +134,5 @@ export function parseRemoteState(raw: string): RemoteSiteState | null { ) { return null; } - const state = s as unknown as RemoteSiteState; - // Older state files may predate router versioning. - if (typeof state.routerVersion !== "number") state.routerVersion = 0; - return state; + return s as unknown as RemoteSiteState; } diff --git a/packages/cli/src/commands/sites/create.ts b/packages/cli/src/commands/sites/create.ts index 91998d87..392c2568 100644 --- a/packages/cli/src/commands/sites/create.ts +++ b/packages/cli/src/commands/sites/create.ts @@ -1,4 +1,3 @@ -import { basename } from "node:path"; import { createComputeClient, createCoreClient, @@ -7,7 +6,6 @@ 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 { UserError } from "../../core/errors.ts"; import { formatKeyValue } from "../../core/format.ts"; import { normalizeHostname } from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; @@ -21,12 +19,9 @@ import { printWorkflowInstructions, scaffoldSitesWorkflow, } from "./ci/scaffold.ts"; -import { - isValidSiteName, - SITES_MANIFEST, - type SiteManifest, -} from "./constants.ts"; +import { SITES_MANIFEST, type SiteManifest } from "./constants.ts"; import { setupSiteDomain } from "./domains/index.ts"; +import { promptSiteName } from "./provision.ts"; interface CreateArgs { name?: string; @@ -35,17 +30,6 @@ interface CreateArgs { link?: boolean; } -const SITE_NAME_RULES = - "Use 3–60 lowercase letters, digits, and dashes (no leading/trailing dash)."; - -function suggestSiteName(): string | undefined { - const base = basename(process.cwd()) - .toLowerCase() - .replace(/[^a-z0-9-]+/g, "-") - .replace(/^-+|-+$/g, ""); - return isValidSiteName(base) ? base : undefined; -} - /** * Create a new static site: a storage zone (files), a pull zone (CDN), and a * middleware router script that maps hosts to deploy directories. The site's @@ -91,31 +75,7 @@ export const sitesCreateCommand = defineCommand({ const { profile, output, verbose, apiKey } = args; const interactive = isInteractive(output); - let name = args.name?.trim().toLowerCase(); - if (!name && interactive) { - const suggestion = suggestSiteName(); - const { value } = await prompts({ - type: "text", - name: "value", - message: "Site name:", - ...(suggestion ? { initial: suggestion } : {}), - validate: (v: string) => - isValidSiteName(String(v).trim().toLowerCase()) || SITE_NAME_RULES, - }); - name = (value as string | undefined)?.trim().toLowerCase(); - } - if (!name) { - throw new UserError( - "Site name is required.", - "Pass one: bunny sites create .", - ); - } - if (!isValidSiteName(name)) { - throw new UserError( - `"${args.name ?? name}" is not a valid site name.`, - SITE_NAME_RULES, - ); - } + const name = await promptSiteName(args.name, interactive); const domain = args.domain ? normalizeHostname(args.domain) : undefined; diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 8ba681c3..7e77e8f2 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -35,6 +35,7 @@ import { selectSite, siteOptionBuilder, } from "./interactive.ts"; +import { createLinkedSite, promptSiteName } from "./provision.ts"; import { collectFiles, hashFiles, uploadDeploy } from "./uploader.ts"; interface DeployArgs extends SiteSelectorArgs { @@ -46,7 +47,14 @@ interface DeployArgs extends SiteSelectorArgs { force?: boolean; } -/** Production and preview URLs for a deploy, derived from the site's hosts. */ +/** + * Production and preview URLs for a deploy, derived from the site's hosts. + * + * With a custom domain the preview is an isolated per-deploy subdomain + * (`dpl-{id}.preview.{domain}`); otherwise it's the immutable `/deploys/{id}/` + * path on the system host, which the router's HTMLRewriter makes render + * correctly even for root-absolute asset URLs. + */ function deployUrls( site: SiteContext, deployId: string, @@ -144,12 +152,14 @@ export const sitesDeployCommand = defineCommand({ site: args.site, link: args.link, output, + offerCreate: async () => { + const name = await promptSiteName(undefined, true); + return createLinkedSite({ coreClient, computeClient, name }); + }, }); const { state, connection, etag } = site; - // Build before hashing — the deploy ID must key on the build *output*, and a - // first build may be what creates the deploy directory. When no build is - // detected, `autoDir` stays undefined and the explicit/default dir is used. + // Build before hashing so the deploy ID keys on the output (and a first build can create the dir). let autoDir: string | undefined; if (args.build !== undefined) { const command = args.build || siteConfig?.config.build; @@ -173,8 +183,7 @@ export const sitesDeployCommand = defineCommand({ ? { command: configured, label: "the configured build" } : await resolveAutoBuild(root); if (auto) { - // A detected framework fixes its output dir; target it unless one was - // given, whether or not the build runs (they may have built already). + // Target the framework's output dir unless one was given (whether or not the build runs). if (explicitDir === undefined && "dir" in auto) autoDir = auto.dir; const prompt = configured ? `Run ${auto.label} (\`${auto.command}\`) before deploying?` diff --git a/packages/cli/src/commands/sites/deployments/prune.test.ts b/packages/cli/src/commands/sites/deployments/prune.test.ts index 1587bcf9..ae196be3 100644 --- a/packages/cli/src/commands/sites/deployments/prune.test.ts +++ b/packages/cli/src/commands/sites/deployments/prune.test.ts @@ -1,6 +1,5 @@ import { expect, test } from "bun:test"; -import type { DeployRecord } from "../constants.ts"; -import { pruneVictims } from "./prune.ts"; +import { type DeployRecord, pruneVictims } from "../constants.ts"; function deploy(id: string, createdAt: string): DeployRecord { return { id, createdAt, source: "content", files: 1, bytes: 1 }; diff --git a/packages/cli/src/commands/sites/deployments/prune.ts b/packages/cli/src/commands/sites/deployments/prune.ts index 4c3bbdd9..93cb1581 100644 --- a/packages/cli/src/commands/sites/deployments/prune.ts +++ b/packages/cli/src/commands/sites/deployments/prune.ts @@ -7,8 +7,8 @@ import { confirm, spinner } from "../../../core/ui.ts"; import { deleteDeployFiles, writeRemoteState } from "../api.ts"; import { DEFAULT_KEEP_DEPLOYS, - type DeployRecord, isValidDeployId, + pruneVictims, } from "../constants.ts"; import { type SiteSelectorArgs, @@ -21,21 +21,6 @@ interface PruneArgs extends SiteSelectorArgs { force?: boolean; } -/** Pick the deploys to delete: everything beyond the newest `keep`, never current/previous. */ -export function pruneVictims( - deploys: DeployRecord[], - keep: number, - current?: string, - previous?: string, -): DeployRecord[] { - const sorted = [...deploys].sort((a, b) => - b.createdAt.localeCompare(a.createdAt), - ); - return sorted - .slice(Math.max(0, keep)) - .filter((d) => d.id !== current && d.id !== previous); -} - export const sitesDeploymentsPruneCommand = defineCommand({ command: "prune", describe: "Delete old deploys, keeping the most recent ones.", diff --git a/packages/cli/src/commands/sites/interactive.ts b/packages/cli/src/commands/sites/interactive.ts index c60cbc49..59f7ad9a 100644 --- a/packages/cli/src/commands/sites/interactive.ts +++ b/packages/cli/src/commands/sites/interactive.ts @@ -80,10 +80,17 @@ export interface SelectedSite { * Precedence: explicit ref (flag or positional) → `.bunny/site.json` → * `sites.name` in bunny.jsonc → interactive picker. Non-interactive runs * without a resolvable site fail with a hint instead of hanging on a prompt. + * + * `offerCreate` (deploy only) adds a "new site" branch to the picker: it runs + * when the account has no sites, or when the user picks "a new site" over the + * existing list. It returns a ready, already-linked context. */ export async function selectSite( client: CoreClient, - args: SiteSelectorArgs & { output: OutputFormat }, + args: SiteSelectorArgs & { + output: OutputFormat; + offerCreate?: () => Promise; + }, ): Promise { const noLink = async () => {}; @@ -149,7 +156,26 @@ export async function selectSite( spin.stop(); } - if (sites.length === 0) { + // Deploy offers to create a site here; other commands only pick an existing one. + if (args.offerCreate) { + if (sites.length === 0) { + return { site: await args.offerCreate(), offerLink: noLink }; + } + const { mode } = await prompts({ + type: "select", + name: "mode", + message: "Deploy to:", + choices: [ + { title: "A new site", value: "new" }, + { title: "An existing site", value: "existing" }, + ], + initial: 0, + }); + if (!mode) throw new UserError("A site is required."); + if (mode === "new") { + return { site: await args.offerCreate(), offerLink: noLink }; + } + } else if (sites.length === 0) { throw new UserError( "No sites found in your account.", "Create one with `bunny sites create `.", diff --git a/packages/cli/src/commands/sites/open.test.ts b/packages/cli/src/commands/sites/open.test.ts index cb112b6c..09db539c 100644 --- a/packages/cli/src/commands/sites/open.test.ts +++ b/packages/cli/src/commands/sites/open.test.ts @@ -2,7 +2,6 @@ import { expect, test } from "bun:test"; import type { Hostname } from "../../core/hostnames/index.ts"; import { type RemoteSiteState, STATE_VERSION } from "./constants.ts"; import { siteLiveUrl } from "./open.ts"; -import { ROUTER_VERSION } from "./router/source.ts"; function state(overrides?: Partial): RemoteSiteState { return { @@ -11,7 +10,6 @@ function state(overrides?: Partial): RemoteSiteState { storageZoneId: 10, pullZoneId: 30, scriptId: 20, - routerVersion: ROUTER_VERSION, deploys: [], ...overrides, }; diff --git a/packages/cli/src/commands/sites/provision.test.ts b/packages/cli/src/commands/sites/provision.test.ts new file mode 100644 index 00000000..2c4c3626 --- /dev/null +++ b/packages/cli/src/commands/sites/provision.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "bun:test"; +import prompts from "prompts"; +import { promptSiteName, suggestSiteName } from "./provision.ts"; + +test("promptSiteName normalizes and validates a passed name", async () => { + expect(await promptSiteName("My-Site", false)).toBe("my-site"); + expect(await promptSiteName(" Blog ", false)).toBe("blog"); +}); + +test("promptSiteName rejects a missing name when non-interactive", async () => { + await expect(promptSiteName(undefined, false)).rejects.toThrow( + "Site name is required", + ); +}); + +test("promptSiteName rejects an invalid name", async () => { + await expect(promptSiteName("Not A Name!", false)).rejects.toThrow( + "not a valid site name", + ); + await expect(promptSiteName("-leading", false)).rejects.toThrow( + "not a valid site name", + ); +}); + +test("promptSiteName prompts when no name is passed and normalizes the answer", async () => { + prompts.inject(["Prompted-Name"]); + expect(await promptSiteName(undefined, true)).toBe("prompted-name"); +}); + +test("suggestSiteName returns a slug or undefined, never an invalid name", () => { + const suggestion = suggestSiteName(); + // Depending on the cwd it may be undefined; when present it must be usable. + if (suggestion !== undefined) { + expect(suggestion).toMatch(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/); + } +}); diff --git a/packages/cli/src/commands/sites/provision.ts b/packages/cli/src/commands/sites/provision.ts new file mode 100644 index 00000000..31f2e058 --- /dev/null +++ b/packages/cli/src/commands/sites/provision.ts @@ -0,0 +1,110 @@ +import { basename } from "node:path"; +import prompts from "prompts"; +import { UserError } from "../../core/errors.ts"; +import { logger } from "../../core/logger.ts"; +import { saveManifest } from "../../core/manifest.ts"; +import { spinner } from "../../core/ui.ts"; +import type { CoreClient } from "../storage/api.ts"; +import { + type ComputeClient, + createSite, + type SiteContext, + siteContextFromZone, +} from "./api.ts"; +import { + isValidSiteName, + SITES_MANIFEST, + type SiteManifest, +} from "./constants.ts"; + +export const SITE_NAME_RULES = + "Use 3–60 lowercase letters, digits, and dashes (no leading/trailing dash)."; + +/** Best-effort site name from the current directory, or undefined if it can't be one. */ +export function suggestSiteName(): string | undefined { + const base = basename(process.cwd()) + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return isValidSiteName(base) ? base : undefined; +} + +/** + * Resolve a site name: normalize a passed one, else prompt (defaulting to the + * directory name). Throws with `missingHint` when no name is available and we + * can't prompt. + */ +export async function promptSiteName( + nameArg: string | undefined, + interactive: boolean, + missingHint = "Pass one: bunny sites create .", +): Promise { + let name = nameArg?.trim().toLowerCase(); + if (!name && interactive) { + const suggestion = suggestSiteName(); + const { value } = await prompts({ + type: "text", + name: "value", + message: "Site name:", + ...(suggestion ? { initial: suggestion } : {}), + validate: (v: string) => + isValidSiteName(String(v).trim().toLowerCase()) || SITE_NAME_RULES, + }); + name = (value as string | undefined)?.trim().toLowerCase(); + } + if (!name) { + throw new UserError("Site name is required.", missingHint); + } + if (!isValidSiteName(name)) { + throw new UserError( + `"${nameArg ?? name}" is not a valid site name.`, + SITE_NAME_RULES, + ); + } + return name; +} + +/** + * Create a new site and link this directory to it, returning the ready context. + * The provisioning-only path behind the deploy picker's "new site" branch: it + * skips the custom-domain and CI prompts that `sites create` layers on top. + */ +export async function createLinkedSite(opts: { + coreClient: CoreClient; + computeClient: ComputeClient; + name: string; + region?: string; +}): Promise { + const spin = spinner(`Creating site "${opts.name}"...`); + spin.start(); + let result: Awaited>; + try { + result = await createSite({ + coreClient: opts.coreClient, + computeClient: opts.computeClient, + name: opts.name, + region: (opts.region ?? "DE").toUpperCase(), + onStep: (message) => { + spin.text = message; + }, + }); + } finally { + spin.stop(); + } + + saveManifest(SITES_MANIFEST, { + id: result.state.storageZoneId, + name: opts.name, + }); + + const context = await siteContextFromZone(result.storageZone); + if (!context) { + throw new UserError( + `Created site "${opts.name}" but couldn't load its state.`, + "Re-run the command, or `bunny sites link` to retry.", + ); + } + + logger.success(`Created site "${opts.name}".`); + return context; +} diff --git a/packages/cli/src/commands/sites/router/source.test.ts b/packages/cli/src/commands/sites/router/source.test.ts new file mode 100644 index 00000000..08965b37 --- /dev/null +++ b/packages/cli/src/commands/sites/router/source.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "bun:test"; +import { routerSource } from "./source.ts"; + +test("routerSource wires up the preview machinery", () => { + const src = routerSource(); + expect(src).toContain("bunny sites router"); + // Serves the promoted deploy at the apex and per-deploy path previews. + expect(src).toContain("process.env.CURRENT_DEPLOY"); + expect(src).toContain('url.pathname = "/deploys/" + deploy + target;'); + // Flags previews so the response phase rewrites their HTML (and never production's). + expect(src).toContain('const PREVIEW_HEADER = "x-bunny-preview";'); + expect(src).toContain("new HTMLRewriter()"); + expect(src).toContain("X-Robots-Tag"); +}); + +// Mirrors the router's withDeploy() so a regression in the rewrite rule is caught here. +function withDeploy(id: string, value: string): string { + if (!value.startsWith("/") || value.startsWith("//")) return value; + if (value.startsWith("/deploys/")) return value; + return `/deploys/${id}${value}`; +} + +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 6711053f..4661d1a1 100644 --- a/packages/cli/src/commands/sites/router/source.ts +++ b/packages/cli/src/commands/sites/router/source.ts @@ -3,24 +3,34 @@ * * It maps incoming hosts to deploy directories inside the storage-zone origin: * - * - apex / `.b-cdn.net` host → `/deploys/{CURRENT_DEPLOY}{path}` - * - `dpl-{id}.preview.{domain}` → `/deploys/{id}{path}` (with `X-Robots-Tag: noindex`) - * - `/deploys/{id}/...` paths → passed through (path-based previews) + * - apex / `.b-cdn.net` host → `/deploys/{CURRENT_DEPLOY}{path}` (production) + * - `dpl-{id}.preview.{domain}` → `/deploys/{id}{path}` (custom-domain preview, root-served) + * - `/deploys/{id}/...` paths → passed through (immutable per-deploy preview URL) * - `/_bunny/*` → 403 (site state/env are never served) * - * Promote/rollback is just updating the `CURRENT_DEPLOY` env var — the code - * itself never changes between deploys. Bump {@link ROUTER_VERSION} whenever - * the source changes; `sites show` warns and `sites upgrade-router` republishes. + * A path preview (`/deploys/{id}/`) serves a build under a subpath, so its + * root-absolute asset URLs (`/assets/x.css`) would otherwise escape the prefix + * and 404. The response phase runs such HTML through {@link HTMLRewriter} to + * rewrite those refs to `/deploys/{id}/…` — the fix for Jekyll/most SSGs without + * a custom domain, on a single pull zone (each deploy's assets get a unique URL, + * so the CDN caches them separately). The request phase flags previews with an + * `x-bunny-preview` header so production HTML (served at the apex) is never + * rewritten — that would leak deploy ids into production URLs and churn its cache + * on every promote. + * + * Promote/rollback is just a `CURRENT_DEPLOY` env write — the code never changes + * between deploys. `sites upgrade-router` republishes this source onto a site. */ -export const ROUTER_VERSION = 1; // NOTE: validated by the Phase 0 spike against the live platform; the exact -// BunnySDK middleware hook names are the platform contract this encodes. -const SOURCE = `// bunny sites router v__ROUTER_VERSION__ — generated by the bunny CLI. Do not edit: +// BunnySDK middleware hook + HTMLRewriter names are the platform contract. +const SOURCE = `// 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 DEPLOY_PATH = /^\\/deploys\\/([a-z0-9]{4,40})\\//i; const NO_DEPLOYS_PAGE = \` No deploys yet @@ -29,6 +39,40 @@ const NO_DEPLOYS_PAGE = \`

This site has no published deploys. Run bunny sites deploy to publish one.

\`; +// 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) => { @@ -40,9 +84,13 @@ BunnySDK.net.http return new Response("Forbidden", { status: 403 }); } - // Path-based previews address a deploy directory directly. + // Path preview: flag the request so the response phase rewrites its HTML for this deploy. if (path === "/deploys" || path.startsWith("/deploys/")) { - return; + const match = DEPLOY_PATH.exec(path); + if (!match) return; + const headers = new Headers(ctx.request.headers); + headers.set(PREVIEW_HEADER, match[1].toLowerCase()); + return new Request(ctx.request, { headers }); } const preview = PREVIEW_HOST.exec(url.hostname); @@ -63,18 +111,38 @@ BunnySDK.net.http return new Request(url.toString(), ctx.request); }) .onOriginResponse(async (ctx) => { + const previewId = ctx.request.headers.get(PREVIEW_HEADER); + const isCustomPreview = PREVIEW_HOST.test(new URL(ctx.request.url).hostname); + if (!previewId && !isCustomPreview) return; + + let response = ctx.response; + + // 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. - if (!PREVIEW_HOST.test(new URL(ctx.request.url).hostname)) return; - const headers = new Headers(ctx.response.headers); + const headers = new Headers(response.headers); headers.set("X-Robots-Tag", "noindex"); - return new Response(ctx.response.body, { - status: ctx.response.status, - statusText: ctx.response.statusText, + return new Response(response.body, { + status: response.status, + statusText: response.statusText, headers, }); }); `; +/** The site's middleware router script. */ export function routerSource(): string { - return SOURCE.replace("__ROUTER_VERSION__", String(ROUTER_VERSION)); + return SOURCE; } diff --git a/packages/cli/src/commands/sites/show.ts b/packages/cli/src/commands/sites/show.ts index 2dc815db..f5eb7a23 100644 --- a/packages/cli/src/commands/sites/show.ts +++ b/packages/cli/src/commands/sites/show.ts @@ -19,7 +19,6 @@ import { selectSite, sitePositionalBuilder, } from "./interactive.ts"; -import { ROUTER_VERSION } from "./router/source.ts"; type ShowArgs = SiteSelectorArgs; @@ -56,14 +55,11 @@ export const sitesShowCommand = defineCommand({ spin.stop(); } - const routerOutdated = state.routerVersion < ROUTER_VERSION; - if (output === "json") { logger.log( JSON.stringify( { ...state, - routerOutdated, hostnames: hostnames.map(toSafeHostname), }, null, @@ -81,10 +77,6 @@ export const sitesShowCommand = defineCommand({ { key: "Storage zone", value: String(state.storageZoneId) }, { key: "Pull zone", value: String(state.pullZoneId) }, { key: "Router script", value: String(state.scriptId) }, - { - key: "Router version", - value: `${state.routerVersion}${routerOutdated ? ` (v${ROUTER_VERSION} available)` : ""}`, - }, { key: "Domain", value: state.domain ?? "—" }, { key: "Current deploy", value: state.current ?? "—" }, { @@ -117,13 +109,6 @@ export const sitesShowCommand = defineCommand({ ); } - if (routerOutdated) { - logger.log(); - logger.warn( - `A newer site router (v${ROUTER_VERSION}) is available. Run \`bunny sites upgrade-router\` to update.`, - ); - } - await offerLink(); }, }); diff --git a/packages/cli/src/commands/sites/upgrade-router.ts b/packages/cli/src/commands/sites/upgrade-router.ts index 22d5ffd7..b4f5e64b 100644 --- a/packages/cli/src/commands/sites/upgrade-router.ts +++ b/packages/cli/src/commands/sites/upgrade-router.ts @@ -7,39 +7,28 @@ import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; import { logger } from "../../core/logger.ts"; import { spinner } from "../../core/ui.ts"; -import { writeRemoteState } from "./api.ts"; import { type SiteSelectorArgs, selectSite, sitePositionalBuilder, } from "./interactive.ts"; -import { ROUTER_VERSION, routerSource } from "./router/source.ts"; +import { routerSource } from "./router/source.ts"; -interface UpgradeArgs extends SiteSelectorArgs { - force?: boolean; -} +type UpgradeArgs = SiteSelectorArgs; /** - * Republish the site's router script at the CLI's current version. Deploys and + * Republish the site's router script with the CLI's current source. Deploys and * env vars are untouched — only the router code changes. */ export const sitesUpgradeRouterCommand = defineCommand({ command: "upgrade-router [site]", - describe: "Upgrade a site's router script to the latest version.", + describe: "Republish a site's router script with the latest version.", examples: [ - ["$0 sites upgrade-router", "Upgrade the linked site's router"], - [ - "$0 sites upgrade-router my-site --force", - "Republish even when up to date", - ], + ["$0 sites upgrade-router", "Republish the linked site's router"], + ["$0 sites upgrade-router my-site", "Republish a specific site's router"], ], - builder: (yargs) => - sitePositionalBuilder(yargs).option("force", { - alias: "f", - type: "boolean", - describe: "Republish the router even if it's already current", - }), + builder: (yargs) => sitePositionalBuilder(yargs), handler: async (args) => { const { profile, output, verbose, apiKey } = args; @@ -53,31 +42,9 @@ export const sitesUpgradeRouterCommand = defineCommand({ link: args.link, output, }); - const { state, connection, etag } = site; + const { state } = site; - if (state.routerVersion >= ROUTER_VERSION && !args.force) { - if (output === "json") { - logger.log( - JSON.stringify( - { - site: state.name, - routerVersion: state.routerVersion, - upgraded: false, - }, - null, - 2, - ), - ); - return; - } - logger.info( - `Router is already at v${state.routerVersion} — nothing to upgrade.`, - ); - return; - } - - const from = state.routerVersion; - const spin = spinner(`Upgrading router v${from} → v${ROUTER_VERSION}...`); + const spin = spinner("Republishing router..."); spin.start(); try { await computeClient.POST("/compute/script/{id}/code", { @@ -88,28 +55,18 @@ export const sitesUpgradeRouterCommand = defineCommand({ params: { path: { id: state.scriptId, uuid: null } }, body: {}, }); - state.routerVersion = ROUTER_VERSION; - await writeRemoteState(connection, state, etag); } finally { spin.stop(); } if (output === "json") { logger.log( - JSON.stringify( - { - site: state.name, - routerVersion: ROUTER_VERSION, - upgraded: true, - }, - null, - 2, - ), + JSON.stringify({ site: state.name, republished: true }, null, 2), ); return; } - logger.success(`Router upgraded to v${ROUTER_VERSION}.`); + logger.success("Router republished."); await offerLink(); }, From c59573b4013cbc665529b5654497fcb129a90cf7 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 14 Jul 2026 18:49:48 +0100 Subject: [PATCH 10/24] update docs --- README.md | 2 +- skills/bunny-cli/references/sites.md | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9f6e11e7..3ea57196 100644 --- a/README.md +++ b/README.md @@ -58,11 +58,11 @@ bun ny dns records preset bluesky example.com --param did=did:plc:abc123 # app bun ny sites create my-site # provision a static site (storage zone + pull zone + edge router) 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 (.b-cdn.net/deploys//); the router's HTMLRewriter keeps root-absolute assets working -bun ny sites deployments prune # delete old deploys (keeps the newest 5, never current/previous) bun ny sites deploy ./dist --production # deploy and publish as the live site (--prod works too) bun ny sites deploy --build # run `sites.build` from bunny.jsonc, then deploy `sites.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 ssl --no-force-ssl # stop forcing HTTPS on the .b-cdn.net system host bun ny sites open # open the site's live URL in the browser diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index e2a23480..a88851ae 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -34,7 +34,7 @@ bunny sites domains add example.com --wait # also attaches *.preview.example.com ## Deploy IDs and previews - 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, served with `X-Robots-Tag: noindex`). +- 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. --- @@ -80,6 +80,11 @@ bunny sites deploy ./out --build "npm run build" --env VITE_FLAG=1 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`): + +- **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. + --- ## `bunny sites deployments` — List, publish, prune @@ -124,10 +129,12 @@ Writes a workflow that deploys previews on pull requests and publishes to produc ```bash bunny sites list -bunny sites show # resources, domains, current deploy; warns on old router +bunny sites show # resources, domains, current deploy +bunny sites open # open the live URL in the browser (--print emits it) +bunny sites ssl --no-force-ssl # toggle Force HTTPS on the .b-cdn.net system host bunny sites link my-site # .bunny/site.json bunny sites unlink -bunny sites upgrade # republish the router at the CLI's latest version +bunny sites upgrade-router # republish the router with the CLI's current source bunny sites delete my-site # typed-name confirmation; --keep-storage keeps files ``` From 271bbd4140c78574251ffcd48b0b06e9dad54798 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 14 Jul 2026 19:34:10 +0100 Subject: [PATCH 11/24] code fixes --- packages/cli/src/commands/sites/deploy.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 7e77e8f2..56b2505f 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -157,9 +157,10 @@ export const sitesDeployCommand = defineCommand({ return createLinkedSite({ coreClient, computeClient, name }); }, }); - const { state, connection, etag } = site; + const { state, connection } = site; + + let etag = site.etag; - // Build before hashing so the deploy ID keys on the output (and a first build can create the dir). let autoDir: string | undefined; if (args.build !== undefined) { const command = args.build || siteConfig?.config.build; @@ -295,14 +296,8 @@ export const sitesDeployCommand = defineCommand({ record, ...state.deploys.filter((d) => d.id !== record.id), ]; + etag = await writeRemoteState(connection, state, etag); } - if (args.production) { - if (state.current && state.current !== deployId) { - state.previous = state.current; - } - state.current = deployId; - } - await writeRemoteState(connection, state, etag); if (args.production) { const promoteSpin = spinner("Publishing to production..."); @@ -314,6 +309,11 @@ export const sitesDeployCommand = defineCommand({ state, deployId, }); + if (state.current && state.current !== deployId) { + state.previous = state.current; + } + state.current = deployId; + etag = await writeRemoteState(connection, state, etag); } finally { promoteSpin.stop(); } From f1e3d219c664e512cbb85fe2bfc45d5f1d3bb22c Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Wed, 15 Jul 2026 09:16:29 +0100 Subject: [PATCH 12/24] refactor the poc --- AGENTS.md | 24 +-- packages/app-config/src/schema.ts | 6 +- packages/cli/src/commands/sandbox/env-args.ts | 72 +-------- packages/cli/src/commands/sites/api.test.ts | 9 +- packages/cli/src/commands/sites/api.ts | 139 ++++-------------- packages/cli/src/commands/sites/build.test.ts | 61 +------- packages/cli/src/commands/sites/build.ts | 87 +---------- .../cli/src/commands/sites/ci/frameworks.ts | 7 +- packages/cli/src/commands/sites/ci/init.ts | 5 +- .../cli/src/commands/sites/ci/scaffold.ts | 34 +---- .../cli/src/commands/sites/ci/workflow.ts | 25 +--- packages/cli/src/commands/sites/config.ts | 11 +- packages/cli/src/commands/sites/constants.ts | 40 +++-- packages/cli/src/commands/sites/create.ts | 121 +++++++-------- packages/cli/src/commands/sites/delete.ts | 23 +-- packages/cli/src/commands/sites/deploy-id.ts | 51 ++----- packages/cli/src/commands/sites/deploy.ts | 80 +++------- .../src/commands/sites/deployments/index.ts | 2 +- .../src/commands/sites/deployments/prune.ts | 10 +- .../src/commands/sites/deployments/publish.ts | 21 +-- .../cli/src/commands/sites/domains/index.ts | 19 +-- .../cli/src/commands/sites/interactive.ts | 89 ++++------- packages/cli/src/commands/sites/link.ts | 3 +- packages/cli/src/commands/sites/list.ts | 17 +-- packages/cli/src/commands/sites/provision.ts | 29 +--- .../src/commands/sites/router/source.test.ts | 4 +- .../cli/src/commands/sites/router/source.ts | 34 +---- packages/cli/src/commands/sites/show.ts | 24 ++- packages/cli/src/commands/sites/ssl.ts | 24 +-- .../cli/src/commands/sites/upgrade-router.ts | 17 +-- packages/cli/src/commands/sites/uploader.ts | 59 ++------ packages/cli/src/core/concurrency.ts | 19 +++ packages/cli/src/core/dns-nameservers.ts | 17 +-- packages/cli/src/core/env.test.ts | 55 +++++++ packages/cli/src/core/env.ts | 78 ++++++++++ packages/cli/src/core/git.ts | 21 +++ packages/cli/src/core/hostnames/commands.ts | 6 +- packages/cli/src/core/ui.ts | 14 ++ skills/bunny-cli/references/sites.md | 24 +-- 39 files changed, 493 insertions(+), 888 deletions(-) create mode 100644 packages/cli/src/core/concurrency.ts create mode 100644 packages/cli/src/core/env.test.ts create mode 100644 packages/cli/src/core/env.ts create mode 100644 packages/cli/src/core/git.ts diff --git a/AGENTS.md b/AGENTS.md index a68eca43..4b4f0f25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,7 @@ This is a Bun workspace monorepo with six packages: - **`@bunny.net/app-config`** (`packages/app-config/`) — Shared app configuration schemas (Zod), inferred types, JSON Schema generation, and API conversion functions. Used by the CLI and potentially other tools. - **`@bunny.net/database-shell`** (`packages/database-shell/`) — Standalone interactive SQL shell for libSQL databases. Framework-agnostic REPL, dot-commands, formatting, masking, and history. Also usable as a standalone CLI (binary: `bsql`). - **`@bunny.net/scriptable-dns-types`** (`packages/scriptable-dns-types/`): Ambient TypeScript declarations for the Scriptable DNS runtime globals (`ARecord`, `Monitoring`, `RoutingEngine`, etc.). Types-only, no runtime code: the DNS runtime can't `import`, so these power editor autocomplete and an optional typecheck step. Scaffolded into projects by `bunny dns scripts init`; intended to also feed the dashboard editor. Publishable to npm. -- **`@bunny.net/sandbox`** (`packages/sandbox/`) — Standalone sandbox SDK. Code-first DX (`Sandbox.create`, `writeFiles`, `runCommand`, `exposePort`, `setEnv`/`getEnv`/`unsetEnv`) over Magic Containers provisioning plus an `ssh2` SSH/SFTP transport. Env vars can be baked in at `create` (persisted), passed per-command via `runCommand({ env })` (temporary), or persisted after creation via `setEnv`. Zero CLI dependencies. +- **`@bunny.net/sandbox`** (`packages/sandbox/`): Standalone sandbox SDK. Code-first DX (`Sandbox.create`, `writeFiles`, `runCommand`, `exposePort`, `setEnv`/`getEnv`/`unsetEnv`) over Magic Containers provisioning plus an `ssh2` SSH/SFTP transport. Env vars can be baked in at `create` (persisted), passed per-command via `runCommand({ env })` (temporary), or persisted after creation via `setEnv`. Zero CLI dependencies. - **`@bunny.net/cli`** (`packages/cli/`) — The CLI. Depends on `@bunny.net/openapi-client`, `@bunny.net/app-config`, `@bunny.net/database-shell`, `@bunny.net/scriptable-dns-types`, and `@bunny.net/sandbox`. ``` @@ -373,16 +373,16 @@ bunny-cli/ │ │ │ ├── upload.ts # Upload a local file ( positional, --zone, --to, --checksum streams a SHA256, --content-type) │ │ │ ├── download.ts # Download a file to disk ( positional, --zone, --out) │ │ │ └── remove.ts # Delete a file or directory (alias: rm; positional, --zone, trailing slash = recursive) -│ │ ├── sites/ # Experimental (hidden from help and landing page) — static-site hosting (storage zone + pull zone + middleware router) +│ │ ├── sites/ # Experimental (hidden from help and landing page): static-site hosting (storage zone + pull zone + middleware router) │ │ │ ├── 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 │ │ │ ├── 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), 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), 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), 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), promoteDeploy (env var PUT + purgeCache POST), 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 → .bunny/site.json → bunny.jsonc sites.name → picker (offerLink like scripts); optional offerCreate (deploy only) adds a new-vs-existing prompt, and creates straight away when the account has no sites; siteOptionBuilder (--site) + sitePositionalBuilder ([site]) │ │ │ ├── provision.ts # promptSiteName (normalize/validate, directory-name suggestion; shared with create.ts) + createLinkedSite (createSite + manifest link → SiteContext, skipping create's domain/CI prompts) for the deploy picker's new-site branch -│ │ │ ├── config.ts # loadSiteConfig: lenient bunny.jsonc reader — validates ONLY the `sites` block (SiteConfigSchema from @bunny.net/app-config), so sites-only configs work without an `app` block -│ │ │ ├── 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, trailing-slash → index.html. 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 +│ │ │ ├── config.ts # loadSiteConfig: lenient bunny.jsonc reader; validates ONLY the `sites` block (SiteConfigSchema from @bunny.net/app-config), so sites-only configs work without an `app` block +│ │ │ ├── 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, trailing-slash → index.html. 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 │ │ │ ├── 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 @@ -394,7 +394,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 .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 (explicit --build, or an interactively-offered detected/configured build) → hash → no-op if unchanged → upload deploys/{id}/ → state update → immutable preview URL (custom-domain dpl-{id}.preview.* when a domain exists, else the .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 (explicit --build, or an interactively-offered detected/configured build) → hash → no-op if unchanged → upload deploys/{id}/ → state update → immutable preview URL (custom-domain dpl-{id}.preview.* when a domain exists, else the .b-cdn.net/deploys/{id}/ path; rendered correctly by the router's HTMLRewriter), or publish live with --production/--prod │ │ │ ├── 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) @@ -1088,10 +1088,10 @@ bunny │ ├── show [id] Show Edge Script details (uses linked script if omitted) │ └── stats [id] [--from] [--to] [--hourly] [--link] │ Show usage statistics (requests/CPU/cost totals + bar chart; defaults to last 30 days). No ID → linked script → interactive picker (offers to link; --no-link skips). JSON output skips the picker and errors. -├── sites (experimental — hidden from help and landing page) -│ │ Static-site hosting: one storage zone (files) + one pull zone (CDN) + one middleware router script per site. 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 `.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). +├── sites (experimental; hidden from help and landing page) +│ │ Static-site hosting: one storage zone (files) + one pull zone (CDN) + one middleware router script per site. 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 `.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). │ ├── create [name] [--region] [--domain] [--link] -│ │ Provision a site (idempotent — a failed create re-runs cleanly; each resource is looked up by name first). Interactive runs prompt for the name when omitted (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). +│ │ Provision a site (idempotent; a failed create re-runs cleanly; each resource is looked up by name first). Interactive runs prompt for the name when omitted (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] 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 @@ -1100,9 +1100,9 @@ bunny │ ├── deployments │ │ ├── list [site] (alias: ls) List deploys (● Live / ○ Previous markers, created, source, files, size) │ │ ├── publish [id] [--previous] [--site] [--force] (alias: promote) -│ │ │ Promote a past deploy — instant rollback (--previous = the previous deploy) +│ │ │ Promote a past deploy; instant rollback (--previous = the previous deploy) │ │ └── prune [--keep N] [--site] [--force] Delete old deploys (never current/previous; default keeps 5) -│ ├── domains (hidden alias: hostnames) — mounts core/hostnames createHostnamesCommands with a sites resolver +│ ├── 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) │ │ ├── ssl [site] Issue a free SSL certificate │ │ ├── list [site] (alias: ls) List domains @@ -1312,7 +1312,7 @@ Commands that operate on a specific remote resource (e.g. a script, an app) can ### How it works - **`.bunny/script.json`** (gitignored) — links the current directory to a remote Edge Script. -- **`.bunny/site.json`** (gitignored) — links the current directory to a site (the site's storage zone ID). Written by `bunny sites link`/`create`; the site's own state (resource triple, deploys, current/previous) lives remotely at `_bunny/site.json` inside the storage zone, so the local manifest is only a pointer. +- **`.bunny/site.json`** (gitignored): links the current directory to a site (the site's storage zone ID). Written by `bunny sites link`/`create`; the site's own state (resource triple, deploys, current/previous) lives remotely at `_bunny/site.json` inside the storage zone, so the local manifest is only a pointer. - The manifest is machine-managed: written by `bunny scripts link`, read by other script commands. - `resolveManifestId()` in `packages/cli/src/core/manifest.ts` handles the resolution: explicit ID flag → manifest file → error with hint. - `findRoot()` walks up the directory tree to find `.bunny/`, so it works from subdirectories. diff --git a/packages/app-config/src/schema.ts b/packages/app-config/src/schema.ts index b215f0a2..34ceecae 100644 --- a/packages/app-config/src/schema.ts +++ b/packages/app-config/src/schema.ts @@ -67,11 +67,7 @@ export const RegionsConfigSchema = z.union([ }), ]); -/** - * Static-site config (`bunny sites`). All fields are optional: `name` links - * the directory to a site, `dir` is the deploy root, and `build` is the - * command `bunny sites deploy --build` runs before uploading. - */ +// Static-site config (`bunny sites`), all optional: `name` links the directory to a site, `dir` is the deploy root, `build` is the command `sites deploy --build` runs. export const SiteConfigSchema = z.object({ name: z.string().optional(), dir: z.string().optional(), diff --git a/packages/cli/src/commands/sandbox/env-args.ts b/packages/cli/src/commands/sandbox/env-args.ts index 76f17a69..cab5c95d 100644 --- a/packages/cli/src/commands/sandbox/env-args.ts +++ b/packages/cli/src/commands/sandbox/env-args.ts @@ -1,7 +1,7 @@ import type { Argv } from "yargs"; -import { UserError } from "../../core/errors.ts"; -const ENV_KEY_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; +// The env parsing lives in core/env.ts; re-exported here so sandbox keeps its import path. +export { collectEnv, parseDotenv, splitPair } from "../../core/env.ts"; /** Add the shared `--env`/`--env-file` options to a command builder. * Pass `{ shortAlias: false }` on commands that forward arbitrary argv so that @@ -28,71 +28,3 @@ export interface EnvOptionArgs { env?: string[]; envFile?: string; } - -/** - * Merge env vars from a `--env-file` (loaded first) and `KEY=VALUE` entries - * (which override the file). Both key and value are validated. - */ -export async function collectEnv( - entries: string[] = [], - envFile?: string, -): Promise> { - const env: Record = {}; - if (envFile) { - const file = Bun.file(envFile); - if (!(await file.exists())) { - throw new UserError(`Env file not found: ${envFile}`); - } - Object.assign(env, parseDotenv(await file.text())); - } - for (const entry of entries) { - const [key, value] = splitPair(entry); - env[key] = value; - } - return env; -} - -/** Split a `KEY=VALUE` string on the first `=`, validating the key. */ -export function splitPair(entry: string): [string, string] { - const eq = entry.indexOf("="); - if (eq === -1) { - throw new UserError(`Invalid env entry "${entry}". Expected KEY=VALUE.`); - } - const key = entry.slice(0, eq); - assertValidKey(key); - return [key, entry.slice(eq + 1)]; -} - -/** Minimal dotenv parser: KEY=VALUE lines, `#` comments, optional quotes. */ -export function parseDotenv(text: string): Record { - const env: Record = {}; - for (const rawLine of text.split("\n")) { - const line = rawLine.trim(); - if (!line || line.startsWith("#")) continue; - const body = line.startsWith("export ") ? line.slice(7).trim() : line; - const eq = body.indexOf("="); - if (eq === -1) continue; - const key = body.slice(0, eq).trim(); - assertValidKey(key); - let value = body.slice(eq + 1).trim(); - const quote = value[0]; - if ( - value.length >= 2 && - (quote === '"' || quote === "'") && - value.endsWith(quote) - ) { - value = value.slice(1, -1); - } else { - const commentIdx = value.indexOf(" #"); - if (commentIdx !== -1) value = value.slice(0, commentIdx).trim(); - } - env[key] = value; - } - return env; -} - -function assertValidKey(key: string): void { - if (!ENV_KEY_PATTERN.test(key)) { - throw new UserError(`Invalid environment variable name: "${key}"`); - } -} diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index 3802606e..ea4fb122 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -37,11 +37,12 @@ beforeEach(() => { siteFiles.download = async (_zone, path) => { const content = store.get(path); if (content === undefined) throw new Error("404 Not Found"); + // Bun's Blob stream is web-standard; cast to the SDK's exact download return type. return { stream: new Blob([content]).stream(), response: new Response(content), length: content.length, - }; + } as Awaited>; }; siteFiles.upload = async (_zone, path, stream) => { store.set(path, await new Response(stream).text()); @@ -388,7 +389,7 @@ test("createSite provisions storage zone → router → pull zone → state", as test("createSite re-run reuses existing resources and converges", async () => { const coreCalls: Call[] = []; const computeCalls: Call[] = []; - // Everything already exists — but no remote state (a half-finished create). + // Everything already exists; but no remote state (a half-finished create). const coreClient = fakeCoreClient({ calls: coreCalls, storageZones: [ZONE], @@ -549,7 +550,7 @@ test("fetchSites keeps only middleware+storage pull zones with matching state", StorageZoneId: 10, Hostnames: [{ IsSystemHostname: true, Value: "my-site.b-cdn.net" }], }, - // Plain storage pull zone — no middleware, never fetched. + // Plain storage pull zone; no middleware, never fetched. { Id: 31, Name: "not-a-site", StorageZoneId: 10 }, // Middleware pull zone whose state points elsewhere. { Id: 32, Name: "other", MiddlewareScriptId: 9, StorageZoneId: 10 }, @@ -615,7 +616,7 @@ test("deleteSiteResources deletes the pull zone, router, and storage zone", asyn }); // Regression: the live API returns GET /pullzone as a paginated envelope -// ({ Items, CurrentPage, HasMoreItems }) — the spec's plain array is a lie +// ({ Items, CurrentPage, HasMoreItems }); the spec's plain array is a lie // for some queries (e.g. ?search=). createSite crashed on `.find` here. test("createSite handles the paginated /pullzone envelope", async () => { diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 21bb3818..77af0033 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -1,5 +1,6 @@ import type { createComputeClient } from "@bunny.net/openapi-client"; import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; +import { mapWithConcurrency } from "../../core/concurrency.ts"; import { ApiError, UserError } from "../../core/errors.ts"; import { createPullZone, setForceSsl } from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; @@ -19,6 +20,7 @@ import { } from "../storage/files-api.ts"; import { CURRENT_DEPLOY_VAR, + deployPrefix, parseRemoteState, REMOTE_STATE_PATH, type RemoteSiteState, @@ -30,11 +32,7 @@ import { routerSource } from "./router/source.ts"; export type ComputeClient = ReturnType; type PullZone = components["schemas"]["PullZoneModel"]; -/** - * The storage-file IO seam. Everything sites reads/writes in a storage zone - * goes through here so tests can swap the entries for an in-memory store - * (bun's `mock.module` leaks across test files; this doesn't). - */ +// Storage-file IO seam; tests swap these for an in-memory store (bun's `mock.module` leaks across files, this doesn't). export const siteFiles = { connect: connectStorageZone, download: downloadFile, @@ -45,7 +43,7 @@ export const siteFiles = { /** Everything a sites command needs once the site is resolved. */ export interface SiteContext { state: RemoteSiteState; - /** Checksum of the state as read — the optimistic lock for writes. */ + /** Checksum of the state as read; the optimistic lock for writes. */ etag: string; storageZone: StorageZoneModel; connection: StorageZone; @@ -63,11 +61,7 @@ export function sha256Hex(text: string): string { return hasher.digest("hex"); } -/** - * The storage SDK throws a plain Error whose message encodes the HTTP status - * (`File not found: ...` for a 404). Only a genuine 404 means "no file"; a - * 401/timeout/5xx must propagate so ownership checks never fail open. - */ +// Only a genuine 404 means "no file"; 401/timeout/5xx must propagate so ownership checks never fail open. function isNotFoundError(err: unknown): boolean { return err instanceof Error && /not found/i.test(err.message); } @@ -80,8 +74,7 @@ async function downloadText( const { stream } = await siteFiles.download(connection, path); return await new Response(stream).text(); } catch (err) { - // A missing file reads as "no data"; transient/permission errors must - // propagate so a caller can't clobber a live site's state on a bad read. + // A missing file reads as "no data"; other errors propagate so a bad read can't clobber live state. if (isNotFoundError(err)) return null; throw err; } @@ -102,14 +95,7 @@ export async function readRemoteState( return { state, etag: sha256Hex(raw) }; } -/** - * Write `_bunny/site.json`. When `expectedEtag` is given, the current remote - * content is re-read first; a mismatch means someone else deployed since we - * read (e.g. concurrent CI runs). If the concurrent state is parseable, their - * deploy records are merged in so no deploy goes missing and our - * `current`/`previous` win (last promote wins). If it's unparseable we abort - * rather than blindly overwrite unknown state. Returns the new etag. - */ +// Write `_bunny/site.json` (returns the new etag). On an `expectedEtag` mismatch a parseable concurrent state has its deploys merged in (last promote wins); an unparseable one aborts rather than overwrite. export async function writeRemoteState( connection: StorageZone, state: RemoteSiteState, @@ -151,26 +137,6 @@ export async function siteContextFromZone( return { ...remote, storageZone: zone, connection }; } -async function mapWithConcurrency( - items: T[], - concurrency: number, - fn: (item: T) => Promise, -): Promise { - const results: R[] = new Array(items.length); - let next = 0; - const worker = async () => { - while (true) { - const index = next++; - if (index >= items.length) return; - results[index] = await fn(items[index] as T); - } - }; - await Promise.all( - Array.from({ length: Math.min(concurrency, items.length) }, worker), - ); - return results; -} - const PULL_ZONE_PAGE_SIZE = 1000; interface PullZonePage { @@ -179,11 +145,7 @@ interface PullZonePage { HasMoreItems?: boolean; } -/** - * List pull zones, tolerating both response shapes: the plain array the - * OpenAPI spec documents, and the `{ Items, CurrentPage, HasMoreItems }` - * envelope the live API actually returns for some queries (e.g. `search`). - */ +// List pull zones, tolerating both response shapes: the plain array the spec documents, and the `{ Items, CurrentPage, HasMoreItems }` envelope the live API returns for some queries. async function fetchPullZones( client: CoreClient, search?: string, @@ -217,13 +179,7 @@ async function fetchPullZones( } } -/** - * Discover the account's sites. - * - * A site is a storage-backed pull zone with a middleware script whose storage - * zone carries a matching `_bunny/site.json`. One pull zone listing narrows - * the candidates; only those get the per-zone state read. - */ +// Discover sites: a pull zone listing narrows to storage+middleware candidates, only those get the per-zone `_bunny/site.json` read. export async function fetchSites(client: CoreClient): Promise { const candidates = (await fetchPullZones(client)).filter( (pz: PullZone) => pz.MiddlewareScriptId != null && pz.StorageZoneId != null, @@ -278,11 +234,7 @@ async function findPullZoneByName( ); } -/** - * A create failed because the globally-unique name is already taken (usually by - * another account, so the pre-create by-name lookup couldn't see it). Reported - * as a 409, or on some endpoints a 400 whose message says the name is in use. - */ +// The globally-unique name is taken (often by another account, so the pre-create lookup missed it): a 409, or a 400 whose message says so. function isNameTaken(err: unknown): boolean { if (!(err instanceof ApiError)) return false; if (err.status === 409) return true; @@ -297,7 +249,7 @@ export interface CreateSiteOptions { computeClient: ComputeClient; name: string; region: string; - /** Progress callback — drives the spinner text. */ + /** Progress callback; drives the spinner text. */ onStep?: (message: string) => void; } @@ -308,13 +260,7 @@ export interface CreateSiteResult { reused: { storageZone: boolean; script: boolean; pullZone: boolean }; } -/** - * Provision a site: storage zone → router script (middleware) → storage-backed - * pull zone with the router attached → remote state. Every step looks up the - * resource by name before creating it, so a half-finished create re-runs - * cleanly. A storage zone that already carries site state is an existing site - * and is never re-provisioned. - */ +// Provision a site (storage zone -> router script -> pull zone + router -> state); each step looks up by name first so a half-finished create re-runs cleanly, and a zone already carrying state is never re-provisioned. export async function createSite( opts: CreateSiteOptions, ): Promise { @@ -322,7 +268,7 @@ export async function createSite( const step = opts.onStep ?? (() => {}); const reused = { storageZone: false, script: false, pullZone: false }; - // 1. Storage zone — the site's identity. + // 1. Storage zone; the site's identity. step("Creating storage zone..."); let storageZone = await findStorageZoneByName(coreClient, name); if (storageZone) { @@ -360,8 +306,7 @@ export async function createSite( throw new UserError(`Storage zone "${name}" has no ID.`); } - // 2. Router script (middleware). Code upload + publish + env var are all - // idempotent PUTs/POSTs, so they always run — a resumed create converges. + // 2. Router script (middleware); code/publish/env-var are idempotent, so they always run and a resumed create converges. step("Creating router script..."); const scriptName = routerScriptName(name); let scriptId = (await fetchScripts(computeClient)).find( @@ -386,7 +331,7 @@ export async function createSite( step("Publishing router..."); await computeClient.POST("/compute/script/{id}/code", { params: { path: { id: scriptId } }, - body: { Code: routerSource() }, + body: { Code: routerSource }, }); await computeClient.POST("/compute/script/{id}/publish", { params: { path: { id: scriptId, uuid: null } }, @@ -423,8 +368,7 @@ export async function createSite( body: { MiddlewareScriptId: scriptId }, }); - // Force HTTPS on the .b-cdn.net system host: it already carries bunny's - // wildcard cert, so this just redirects HTTP. Best-effort — never fail create. + // Force HTTPS on the .b-cdn.net system host (already on bunny's wildcard cert, so this just redirects HTTP); best-effort. const systemHostname = (pullZone.Hostnames ?? []).find( (h) => h.IsSystemHostname, )?.Value; @@ -438,7 +382,7 @@ export async function createSite( } } - // 4. Remote state — from here on the zone identifies as a site. + // 4. Remote state; from here on the zone identifies as a site. step("Writing site state..."); const state: RemoteSiteState = { version: STATE_VERSION, @@ -479,19 +423,13 @@ export async function fetchSystemHostname( } } -// Promote timing. `CURRENT_DEPLOY` is accepted by the control plane instantly -// but reaches the edge nodes asynchronously, so we confirm the edge is serving -// a deploy before the follow-up purge, then settle briefly so the value has -// landed everywhere. +// 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; const PROPAGATION_INTERVAL_MS = 1500; const SETTLE_FLOOR_MS = 2500; -/** - * The CDN-probe seam. Tests swap these so promote runs without real network or - * real timers. - */ +// 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 => { @@ -506,12 +444,7 @@ export const promoteVerification = { new Promise((resolve) => setTimeout(resolve, ms)), }; -/** - * Wait until the edge serves a real deploy for this site. The router returns - * the "no deploys yet" placeholder as a 404 while `CURRENT_DEPLOY` is unset or - * unpropagated, so any non-404 means a deploy is being served. Best-effort: if - * the system hostname can't be resolved we skip probing and just settle. - */ +// Wait until the edge serves a real deploy: the router's "no deploys yet" 404 means CURRENT_DEPLOY is unset/unpropagated, so any non-404 means it landed; best-effort (skip probing when the host can't be resolved). async function waitForEdgePropagation( coreClient: CoreClient, state: RemoteSiteState, @@ -524,36 +457,25 @@ async function waitForEdgePropagation( let attempt = 0; while (Date.now() < deadline) { try { - // A unique query per attempt keeps each probe out of the CDN cache so a - // stale placeholder can't mask a propagated deploy. + // A unique query per attempt keeps each probe out of the CDN cache so a stale placeholder can't mask a propagated deploy. const status = await promoteVerification.probe( `https://${host}/?__bunny_promote=${deployId}-${attempt++}`, ); if (status !== 404) break; } catch { - // Edge briefly unreachable (DNS/warmup) — keep trying until the deadline. + // Edge briefly unreachable (DNS/warmup); keep trying until the deadline. } await promoteVerification.wait(PROPAGATION_INTERVAL_MS); } } - // Give the env var a moment to reach every node before the follow-up purge, - // so re-promotes don't re-cache the outgoing deploy's assets. + // Let the env var reach every node before the follow-up purge, so re-promotes don't re-cache the outgoing deploy's assets. const elapsed = Date.now() - start; if (elapsed < SETTLE_FLOOR_MS) { await promoteVerification.wait(SETTLE_FLOOR_MS - elapsed); } } -/** - * Point production at a deploy: update the router's `CURRENT_DEPLOY` env var - * (takes effect without republishing code) and purge the pull zone cache. - * - * The env var reaches the edge asynchronously, so a single purge races it: a - * request during the propagation window re-caches the old deploy (or, on a - * first deploy, the 404 placeholder — the "styles broken until it settles" - * failure). We purge, wait for the edge to serve the deploy, then purge again - * so nothing stale survives. - */ +// Point production at a deploy: set CURRENT_DEPLOY (no republish) and purge. Since the env var propagates async, we purge, wait for the edge to serve it, then purge again so nothing stale survives. export async function promoteDeploy(opts: { computeClient: ComputeClient; coreClient: CoreClient; @@ -582,17 +504,13 @@ export interface TeardownResult { error?: string; } -/** - * Tear down a site's resources. Order matters: the pull zone references both - * the script and the storage zone, so it goes first. Each step is best-effort - * so a partially-deleted site can be re-deleted. - */ +// Tear down a site's resources; the pull zone references the script and storage zone so it goes first, and each step is best-effort so a partial delete can be re-run. export async function deleteSiteResources(opts: { coreClient: CoreClient; computeClient: ComputeClient; state: RemoteSiteState; keepStorage?: boolean; - /** The storage connection — needed to tombstone the site marker with --keep-storage. */ + /** The storage connection; needed to tombstone the site marker with --keep-storage. */ connection?: StorageZone; }): Promise { const { coreClient, computeClient, state } = opts; @@ -628,8 +546,7 @@ export async function deleteSiteResources(opts: { }), ); if (opts.keepStorage) { - // The zone survives, so remove its site marker — otherwise list/link/show - // rediscover a "site" whose pull zone and router are already gone. + // The zone survives, so remove its site marker, else list/link/show rediscover a "site" whose pull zone and router are gone. if (opts.connection) { try { await siteFiles.remove(opts.connection, REMOTE_STATE_PATH); @@ -656,5 +573,5 @@ export async function deleteDeployFiles( connection: StorageZone, deployId: string, ): Promise { - await siteFiles.remove(connection, `deploys/${deployId}/`); + await siteFiles.remove(connection, `${deployPrefix(deployId)}/`); } diff --git a/packages/cli/src/commands/sites/build.test.ts b/packages/cli/src/commands/sites/build.test.ts index 5118ddb0..4dc31fd8 100644 --- a/packages/cli/src/commands/sites/build.test.ts +++ b/packages/cli/src/commands/sites/build.test.ts @@ -2,12 +2,7 @@ import { expect, test } from "bun:test"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { - parseEnvAssignments, - parseEnvFile, - resolveAutoBuild, - runBuildCommand, -} from "./build.ts"; +import { resolveAutoBuild, runBuildCommand } from "./build.ts"; function tempRepo(files: Record): string { const dir = mkdtempSync(join(tmpdir(), "bunny-sites-auto-")); @@ -17,58 +12,6 @@ function tempRepo(files: Record): string { return dir; } -test("parseEnvAssignments parses KEY=VALUE pairs", () => { - expect(parseEnvAssignments(["A=1", "B=with=equals", "C_1="])).toEqual({ - A: "1", - B: "with=equals", - C_1: "", - }); - expect(parseEnvAssignments(undefined)).toEqual({}); -}); - -test("parseEnvAssignments rejects malformed entries", () => { - expect(() => parseEnvAssignments(["NOEQUALS"])).toThrow("Invalid --env"); - expect(() => parseEnvAssignments(["=value"])).toThrow("Invalid --env"); - expect(() => parseEnvAssignments(["1BAD=x"])).toThrow("not a valid"); -}); - -test("parseEnvFile handles comments, blanks, and quotes", () => { - const env = parseEnvFile( - [ - "# comment", - "", - "PLAIN=value", - 'QUOTED="hello world"', - "SINGLE='single'", - " SPACED = spaced-out ", - "not a var line", - ].join("\n"), - ); - expect(env).toEqual({ - PLAIN: "value", - QUOTED: "hello world", - SINGLE: "single", - SPACED: "spaced-out", - }); -}); - -test("parseEnvFile strips inline comments and unescapes inner quotes", () => { - const env = parseEnvFile( - [ - "INLINE=value # trailing comment", - "HASHINVALUE=a#b", - 'ESCAPED="value with \\"quotes\\""', - 'HASHINQUOTES="a # b"', - ].join("\n"), - ); - expect(env).toEqual({ - INLINE: "value", - HASHINVALUE: "a#b", - ESCAPED: 'value with "quotes"', - HASHINQUOTES: "a # b", - }); -}); - test("resolveAutoBuild infers a detected framework's build and output dir", async () => { const dir = tempRepo({ "package.json": JSON.stringify({ @@ -111,7 +54,7 @@ test("runBuildCommand passes env and throws on failure", async () => { const dir = mkdtempSync(join(tmpdir(), "bunny-sites-build-")); const out = join(dir, "out.txt"); - await runBuildCommand(`echo -n "$MY_VAR" > ${JSON.stringify(out)}`, dir, { + await runBuildCommand(`printf %s "$MY_VAR" > ${JSON.stringify(out)}`, dir, { MY_VAR: "built", }); expect(await Bun.file(out).text()).toBe("built"); diff --git a/packages/cli/src/commands/sites/build.ts b/packages/cli/src/commands/sites/build.ts index 55b23525..4952edf1 100644 --- a/packages/cli/src/commands/sites/build.ts +++ b/packages/cli/src/commands/sites/build.ts @@ -7,82 +7,6 @@ import { presetBuildCommand, } from "./ci/frameworks.ts"; -const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; - -function assertEnvName(name: string): void { - if (!ENV_NAME_RE.test(name)) { - throw new UserError( - `"${name}" is not a valid environment variable name.`, - "Names must start with a letter or underscore and contain only letters, digits, and underscores.", - ); - } -} - -/** Parse repeated `--env KEY=VALUE` flags. */ -export function parseEnvAssignments( - entries: string[] | undefined, -): Record { - const env: Record = {}; - for (const entry of entries ?? []) { - const eq = entry.indexOf("="); - if (eq <= 0) { - throw new UserError( - `Invalid --env value "${entry}".`, - "Use --env KEY=VALUE.", - ); - } - const name = entry.slice(0, eq); - assertEnvName(name); - env[name] = entry.slice(eq + 1); - } - return env; -} - -/** - * Turn a dotenv value token into its string. Double-quoted values read to the - * closing quote, unescaping `\"`/`\\` (a `#` inside stays literal); single - * quotes are taken verbatim; unquoted values drop a whitespace-delimited inline - * `# comment`. - */ -function parseEnvValue(raw: string): string { - const value = raw.trim(); - if (value.startsWith('"')) { - let out = ""; - for (let i = 1; i < value.length; i++) { - const ch = value[i]; - if (ch === "\\" && i + 1 < value.length) { - out += value[++i]; - } else if (ch === '"') { - return out; - } else { - out += ch; - } - } - return out; // unterminated quote — take what we have - } - if (value.startsWith("'")) { - const end = value.indexOf("'", 1); - return end === -1 ? value.slice(1) : value.slice(1, end); - } - const comment = value.search(/\s#/); - return (comment === -1 ? value : value.slice(0, comment)).trimEnd(); -} - -/** Parse a dotenv-style file: KEY=VALUE lines, `#` comments, optional quotes. */ -export function parseEnvFile(content: string): Record { - const env: Record = {}; - for (const rawLine of content.split("\n")) { - const line = rawLine.trim(); - if (!line || line.startsWith("#")) continue; - const eq = line.indexOf("="); - if (eq <= 0) continue; - const name = line.slice(0, eq).trim(); - if (!ENV_NAME_RE.test(name)) continue; - env[name] = parseEnvValue(line.slice(eq + 1)); - } - return env; -} - export interface AutoBuild { /** Shell command to run. */ command: string; @@ -92,11 +16,7 @@ export interface AutoBuild { dir?: string; } -/** - * Infer a build to offer before a deploy: the detected framework's build - * command, else a `build` script in package.json. Returns null when nothing is - * detected or the framework is static (no build step). - */ +// Infer a build to offer before a deploy: the detected framework's command, else a package.json `build` script; null when nothing is detected or the framework is static. export async function resolveAutoBuild( root: string, ): Promise { @@ -127,10 +47,7 @@ async function readPackageJson( } } -/** - * Run the build command in a shell with the merged environment, streaming - * its output. Throws on a non-zero exit so a broken build never deploys. - */ +// Run the build command in a shell with the merged environment, streaming output; throws on non-zero exit so a broken build never deploys. export async function runBuildCommand( command: string, cwd: string, diff --git a/packages/cli/src/commands/sites/ci/frameworks.ts b/packages/cli/src/commands/sites/ci/frameworks.ts index 0b2b29ef..ae27d012 100644 --- a/packages/cli/src/commands/sites/ci/frameworks.ts +++ b/packages/cli/src/commands/sites/ci/frameworks.ts @@ -144,8 +144,8 @@ export function findPreset(id: string): FrameworkPreset | undefined { return FRAMEWORK_PRESETS.find((p) => p.id === id); } -// Runner for a project-local binary, mirroring the CI workflow's PM_EXEC. -const PM_EXEC: Record = { +// Runner for a project-local binary; shared by the local build and the emitted CI workflow. +export const PM_EXEC: Record = { bun: "bunx", pnpm: "pnpm exec", yarn: "yarn", @@ -164,8 +164,7 @@ export function presetBuildCommand( return preset.build ?? null; } -// Ordered most-specific first: meta-frameworks depend on vite, so vite goes last. -// Blazor's compiled toolchain is selectable via --framework but not auto-detected. +// Ordered most-specific first (meta-frameworks depend on vite, so vite goes last); Blazor is selectable via --framework but not auto-detected. const JS_DETECTORS: Array<[dependency: string, presetId: string]> = [ ["@analogjs/platform", "analog"], ["astro", "astro"], diff --git a/packages/cli/src/commands/sites/ci/init.ts b/packages/cli/src/commands/sites/ci/init.ts index ca1b0fb4..131afcde 100644 --- a/packages/cli/src/commands/sites/ci/init.ts +++ b/packages/cli/src/commands/sites/ci/init.ts @@ -22,10 +22,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`: previews on PRs, production on merges to main, via the BunnyWay/actions deploy-site action. export const sitesCiInitCommand = defineCommand({ command: "init", describe: "Add a GitHub Actions workflow that deploys this site.", diff --git a/packages/cli/src/commands/sites/ci/scaffold.ts b/packages/cli/src/commands/sites/ci/scaffold.ts index 092eae87..60e8a60e 100644 --- a/packages/cli/src/commands/sites/ci/scaffold.ts +++ b/packages/cli/src/commands/sites/ci/scaffold.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; import prompts from "prompts"; import { UserError } from "../../../core/errors.ts"; +import { runGit } from "../../../core/git.ts"; import { logger } from "../../../core/logger.ts"; import { confirm } from "../../../core/ui.ts"; import { @@ -14,31 +15,13 @@ import { } from "./frameworks.ts"; import { renderSitesWorkflow, SITES_WORKFLOW_PATH } from "./workflow.ts"; -async function git(cwd: string, args: string[]): Promise { - try { - const proc = Bun.spawn(["git", ...args], { - cwd, - stdout: "pipe", - stderr: "ignore", - stdin: "ignore", - }); - const [code, out] = await Promise.all([ - proc.exited, - new Response(proc.stdout).text(), - ]); - return code === 0 ? out.trim() : null; - } catch { - return null; - } -} - /** The repo root, or null when `cwd` isn't inside a git repository. */ export async function gitTopLevel(cwd: string): Promise { - return git(cwd, ["rev-parse", "--show-toplevel"]); + return runGit(cwd, ["rev-parse", "--show-toplevel"]); } export async function hasGitHubOrigin(root: string): Promise { - const url = await git(root, ["remote", "get-url", "origin"]); + const url = await runGit(root, ["remote", "get-url", "origin"]); return url?.includes("github.com") ?? false; } @@ -89,11 +72,7 @@ async function resolvePreset( return fallback; } -/** - * Write `.github/workflows/bunny-sites.yml` for a site. Returns null when the - * user declines to overwrite an existing file; throws when non-interactive and - * the file exists without `force`. - */ +// Write `.github/workflows/bunny-sites.yml`; returns null when the user declines to overwrite an existing file, throws when non-interactive and it exists without `force`. export async function scaffoldSitesWorkflow(opts: { site: string; root: string; @@ -154,10 +133,7 @@ export function printSecretHint(): void { logger.dim(" (or GitHub repo Settings -> Secrets and variables -> Actions)"); } -/** - * Offer to add the BUNNY_API_KEY repo secret via the `gh` CLI (prompted). - * Falls back to printing the manual steps when declined or unavailable. - */ +// Offer to add the BUNNY_API_KEY repo secret via the `gh` CLI (prompted); falls back to printing the manual steps when declined or unavailable. export async function offerGitHubSecret(opts: { apiKey: string | undefined; root: string; diff --git a/packages/cli/src/commands/sites/ci/workflow.ts b/packages/cli/src/commands/sites/ci/workflow.ts index 13290383..0b296211 100644 --- a/packages/cli/src/commands/sites/ci/workflow.ts +++ b/packages/cli/src/commands/sites/ci/workflow.ts @@ -1,5 +1,9 @@ // biome-ignore-all lint/suspicious/noTemplateCurlyInString: emits GitHub Actions ${{ }} expressions, not JS template strings -import type { FrameworkPreset, PackageManager } from "./frameworks.ts"; +import { + type FrameworkPreset, + type PackageManager, + presetBuildCommand, +} from "./frameworks.ts"; export const SITES_WORKFLOW_PATH = ".github/workflows/bunny-sites.yml"; @@ -37,19 +41,9 @@ const JS_SETUP: Record = { ], }; -// Runner for a project-local binary, used by presets that override the build command. -const PM_EXEC: Record = { - bun: "bunx", - pnpm: "pnpm exec", - yarn: "yarn", - npm: "npx", -}; - function jsSteps(preset: FrameworkPreset, pm: PackageManager): string[] { - const build = preset.build - ? ` - run: ${PM_EXEC[pm]} ${preset.build}` - : ` - run: ${pm} run build`; - return [...JS_SETUP[pm], build]; + const build = presetBuildCommand(preset, pm) ?? `${pm} run build`; + return [...JS_SETUP[pm], ` - run: ${build}`]; } function buildSteps( @@ -104,10 +98,7 @@ function buildSteps( } } -/** - * Render the GitHub Actions workflow: previews on PRs, production on pushes - * to main, deployed via the BunnyWay/actions deploy-site action. - */ +// Render the GitHub Actions workflow: previews on PRs, production on pushes to main, via the BunnyWay/actions deploy-site action. export function renderSitesWorkflow(opts: { site: string; preset: FrameworkPreset; diff --git a/packages/cli/src/commands/sites/config.ts b/packages/cli/src/commands/sites/config.ts index 8ed269c3..e1addd80 100644 --- a/packages/cli/src/commands/sites/config.ts +++ b/packages/cli/src/commands/sites/config.ts @@ -8,18 +8,11 @@ const CONFIG_FILENAME = "bunny.jsonc"; export interface LoadedSiteConfig { config: SiteConfig; - /** Directory containing bunny.jsonc — `sites.dir` resolves against this. */ + /** Directory containing bunny.jsonc; `sites.dir` resolves against this. */ root: string; } -/** - * Read the optional `sites` block from `bunny.jsonc`, walking up from cwd. - * - * Only the `sites` key is validated — a bunny.jsonc that also (or only) - * describes an app is fine, and a file without a `sites` block returns null. - * This deliberately doesn't run the full app schema, so a sites-only - * `{ "sites": { ... } }` file works without declaring an app. - */ +// Read the optional `sites` block from `bunny.jsonc` (walking up from cwd); only that block is validated, so a sites-only file works without an `app` block and a file with no `sites` block returns null. export function loadSiteConfig(): LoadedSiteConfig | null { let dir = resolve(process.cwd()); while (true) { diff --git a/packages/cli/src/commands/sites/constants.ts b/packages/cli/src/commands/sites/constants.ts index 63973702..7cacdeb4 100644 --- a/packages/cli/src/commands/sites/constants.ts +++ b/packages/cli/src/commands/sites/constants.ts @@ -1,19 +1,16 @@ // `.bunny/site.json` is written by `bunny sites link`/`create` and resolved by sites commands. export const SITES_MANIFEST = "site.json"; -// Remote paths inside the site's storage zone. Everything under `_bunny/` is -// blocked by the router (403), so state never gets served. +// Site state path; everything under `_bunny/` is router-blocked (403) so state is never served. export const REMOTE_STATE_PATH = "_bunny/site.json"; // Deploys live at `deploys/{id}/...` inside the storage zone. export const DEPLOYS_DIR = "deploys"; -// Preview hosts are `dpl-{id}.preview.{domain}` — a namespaced wildcard that -// can't shadow user subdomains. +// Preview hosts are `dpl-{id}.preview.{domain}`; a namespaced wildcard that can't shadow user subdomains. export const PREVIEW_LABEL = "preview"; -// The router env var that selects the production deploy. Updating it is the -// promote/rollback lever — no code republish needed. +// Router env var selecting the production deploy; updating it is the promote/rollback lever (no republish). export const CURRENT_DEPLOY_VAR = "CURRENT_DEPLOY"; export const STATE_VERSION = 1; @@ -21,7 +18,7 @@ export const STATE_VERSION = 1; export const DEFAULT_KEEP_DEPLOYS = 5; export interface SiteManifest { - /** The site's storage zone ID — the site's identity. */ + /** The site's storage zone ID; the site's identity. */ id: number; name?: string; } @@ -38,12 +35,7 @@ export interface DeployRecord { bytes: number; } -/** - * The remote site state stored at `_bunny/site.json` in the storage zone. - * It is the source of truth for what a site is (its resource triple) and - * what has been deployed; the local `.bunny/site.json` manifest is only a - * pointer to the storage zone. - */ +// Source of truth (at `_bunny/site.json`) for a site's resource triple and deploys; `.bunny/site.json` is just a local pointer to it. export interface RemoteSiteState { version: number; name: string; @@ -62,6 +54,14 @@ export function deployPrefix(deployId: string): string { return `${DEPLOYS_DIR}/${deployId}`; } +/** Point production at `deployId`, remembering the outgoing deploy as previous. */ +export function markCurrent(state: RemoteSiteState, deployId: string): void { + if (state.current && state.current !== deployId) { + state.previous = state.current; + } + state.current = deployId; +} + /** Pick the deploys beyond the newest `keep`, never the current or previous. */ export function pruneVictims( deploys: DeployRecord[], @@ -87,13 +87,12 @@ export function previewWildcard(domain: string): string { return `*.${PREVIEW_LABEL}.${domain}`; } -/** Router script name for a site — namespaced so `sites create` can find it on re-run. */ +/** 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`; } -// Deploy IDs are git short-shas or content hashes: lowercase hex-ish tokens. -// The router's preview-host regex and the storage path layout both rely on this. +// Deploy IDs are git short-shas or content hashes (lowercase hex-ish); the router regex and storage paths rely on this. const DEPLOY_ID_RE = /^[a-z0-9]{4,40}$/; export function isValidDeployId(id: string): boolean { @@ -107,11 +106,7 @@ export function isValidSiteName(name: string): boolean { return SITE_NAME_RE.test(name); } -/** - * Parse and shape-check remote state. Returns null for anything that isn't a - * state file this CLI understands — a missing field means "not a site", not - * a crash. - */ +// Parse and shape-check remote state; returns null (not a crash) for anything that isn't a state file this CLI understands. export function parseRemoteState(raw: string): RemoteSiteState | null { let data: unknown; try { @@ -124,8 +119,7 @@ export function parseRemoteState(raw: string): RemoteSiteState | null { if ( typeof s.version !== "number" || typeof s.name !== "string" || - // A tampered/corrupt name would flow into storage paths and generated CI - // YAML unquoted; reject state whose name isn't a legal zone name. + // Reject an illegal name: it would flow unquoted into storage paths and generated CI YAML. !isValidSiteName(s.name) || typeof s.storageZoneId !== "number" || typeof s.pullZoneId !== "number" || diff --git a/packages/cli/src/commands/sites/create.ts b/packages/cli/src/commands/sites/create.ts index 392c2568..2664c695 100644 --- a/packages/cli/src/commands/sites/create.ts +++ b/packages/cli/src/commands/sites/create.ts @@ -10,7 +10,8 @@ import { formatKeyValue } from "../../core/format.ts"; import { normalizeHostname } from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; import { saveManifest } from "../../core/manifest.ts"; -import { confirm, isInteractive, spinner } from "../../core/ui.ts"; +import { confirm, isInteractive, withSpinner } from "../../core/ui.ts"; +import type { CoreClient, StorageZoneModel } from "../storage/api.ts"; import { createSite, siteContextFromZone } from "./api.ts"; import { gitTopLevel, @@ -30,11 +31,33 @@ interface CreateArgs { link?: boolean; } -/** - * Create a new static site: a storage zone (files), a pull zone (CDN), and a - * middleware router script that maps hosts to deploy directories. The site's - * state lives at `_bunny/site.json` inside the storage zone. - */ +// Attach a custom domain to a just-created site; returns an error message on failure (never throws). +async function attachDomainToCreatedSite(opts: { + coreClient: CoreClient; + storageZone: StorageZoneModel; + domain: string; + interactive: boolean; + verbose: boolean; + json?: boolean; +}): Promise { + const site = await siteContextFromZone(opts.storageZone); + if (!site) return undefined; + try { + await setupSiteDomain({ + coreClient: opts.coreClient, + site, + domain: opts.domain, + interactive: opts.interactive, + verbose: opts.verbose, + json: opts.json, + }); + return undefined; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } +} + +// Create a static site: a storage zone (files), a pull zone (CDN), and a middleware router script mapping hosts to deploy dirs; state lives at `_bunny/site.json` in the storage zone. export const sitesCreateCommand = defineCommand({ command: "create [name]", describe: "Create a new static site.", @@ -53,7 +76,7 @@ export const sitesCreateCommand = defineCommand({ .positional("name", { type: "string", describe: - "Site name — becomes the storage zone, pull zone, and .b-cdn.net subdomain (prompted when omitted)", + "Site name; becomes the storage zone, pull zone, and .b-cdn.net subdomain (prompted when omitted)", }) .option("region", { type: "string", @@ -84,11 +107,8 @@ export const sitesCreateCommand = defineCommand({ const coreClient = createCoreClient(options); const computeClient = createComputeClient(options); - const spin = spinner(`Creating site "${name}"...`); - spin.start(); - let result: Awaited>; - try { - result = await createSite({ + const result = await withSpinner(`Creating site "${name}"...`, (spin) => + createSite({ coreClient, computeClient, name, @@ -96,10 +116,8 @@ export const sitesCreateCommand = defineCommand({ onStep: (message) => { spin.text = message; }, - }); - } finally { - spin.stop(); - } + }), + ); if (args.link !== false) { saveManifest(SITES_MANIFEST, { @@ -109,25 +127,17 @@ export const sitesCreateCommand = defineCommand({ } if (output === "json") { - // --domain is attached non-interactively; a failure still reports the created site. - let domainError: string | undefined; - if (domain) { - const site = await siteContextFromZone(result.storageZone); - if (site) { - try { - await setupSiteDomain({ - coreClient, - site, - domain, - interactive: false, - verbose, - json: true, - }); - } catch (err) { - domainError = err instanceof Error ? err.message : String(err); - } - } - } + // --domain is attached non-interactively; a failure is reported but doesn't fail the create. + const domainError = domain + ? await attachDomainToCreatedSite({ + coreClient, + storageZone: result.storageZone, + domain, + interactive: false, + verbose, + json: true, + }) + : undefined; logger.log( JSON.stringify( { @@ -144,7 +154,6 @@ export const sitesCreateCommand = defineCommand({ 2, ), ); - if (domainError) process.exit(1); return; } @@ -177,26 +186,22 @@ export const sitesCreateCommand = defineCommand({ chosenDomain = normalizeHostname(value ?? "") || undefined; } if (chosenDomain) { - // A domain failure mustn't fail the create; the site already exists - // and the domain can be retried via `sites domains add`. + // 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 site = await siteContextFromZone(result.storageZone); - if (site) { - try { - await setupSiteDomain({ - coreClient, - site, - domain: chosenDomain, - interactive, - verbose, - }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - logger.warn(`Couldn't finish setting up ${chosenDomain}: ${message}`); - logger.dim( - ` Retry later: bunny sites domains add ${chosenDomain} ${name}`, - ); - } + const domainError = await attachDomainToCreatedSite({ + coreClient, + storageZone: result.storageZone, + domain: chosenDomain, + interactive, + verbose, + }); + if (domainError) { + logger.warn( + `Couldn't finish setting up ${chosenDomain}: ${domainError}`, + ); + logger.dim( + ` Retry later: bunny sites domains add ${chosenDomain} ${name}`, + ); } } @@ -210,14 +215,14 @@ export const sitesCreateCommand = defineCommand({ { initial: true }, ); if (setup) { - const result = await scaffoldSitesWorkflow({ + const scaffold = await scaffoldSitesWorkflow({ site: name, root, interactive: true, }); - if (result) { + if (scaffold) { logger.success( - `Wrote ${result.path} (${result.preset.label}, deploys ${result.preset.dir}).`, + `Wrote ${scaffold.path} (${scaffold.preset.label}, deploys ${scaffold.preset.dir}).`, ); await offerGitHubSecret({ apiKey: config.apiKey, diff --git a/packages/cli/src/commands/sites/delete.ts b/packages/cli/src/commands/sites/delete.ts index 5088d279..14d06142 100644 --- a/packages/cli/src/commands/sites/delete.ts +++ b/packages/cli/src/commands/sites/delete.ts @@ -8,7 +8,7 @@ import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; import { logger } from "../../core/logger.ts"; import { loadManifest, removeManifest } from "../../core/manifest.ts"; -import { confirm, spinner } from "../../core/ui.ts"; +import { confirm, withSpinner } from "../../core/ui.ts"; import { deleteSiteResources } from "./api.ts"; import { SITES_MANIFEST, type SiteManifest } from "./constants.ts"; import { @@ -22,16 +22,12 @@ interface DeleteArgs extends SiteSelectorArgs { "keep-storage"?: boolean; } -/** - * Delete a site: its pull zone, router script, and (unless --keep-storage) - * the storage zone with every deploy in it. Requires typing the site name - * to confirm unless --force is passed. - */ +// Delete a site: its pull zone, router script, and (unless --keep-storage) the storage zone with every deploy; requires typing the name to confirm unless --force. export const sitesDeleteCommand = defineCommand({ command: "delete [site]", describe: "Delete a site and its resources.", examples: [ - ["$0 sites delete my-site", "Interactive — double confirmation"], + ["$0 sites delete my-site", "Interactive; double confirmation"], ["$0 sites delete my-site --force", "Skip confirmation"], [ "$0 sites delete my-site --keep-storage", @@ -91,20 +87,15 @@ export const sitesDeleteCommand = defineCommand({ } } - const spin = spinner("Deleting site resources..."); - spin.start(); - let results: Awaited>; - try { - results = await deleteSiteResources({ + const results = await withSpinner("Deleting site resources...", () => + deleteSiteResources({ coreClient, computeClient, state, keepStorage: args["keep-storage"], connection: site.connection, - }); - } finally { - spin.stop(); - } + }), + ); // Only drop the local link when it pointed at this site. const manifest = loadManifest(SITES_MANIFEST); diff --git a/packages/cli/src/commands/sites/deploy-id.ts b/packages/cli/src/commands/sites/deploy-id.ts index 501eac53..b810348d 100644 --- a/packages/cli/src/commands/sites/deploy-id.ts +++ b/packages/cli/src/commands/sites/deploy-id.ts @@ -1,3 +1,5 @@ +import { runGit } from "../../core/git.ts"; + export interface HashedFile { /** Posix-style path relative to the deploy root. */ path: string; @@ -10,20 +12,11 @@ export interface DeployIdentity { source: "git" | "content"; gitSha?: string; dirty?: boolean; - /** - * Hash of the deployed bytes, always computed. The no-op check keys on this - * (not `id`), so a rebuilt `dist/` at the same git sha with different output - * isn't wrongly skipped as unchanged. - */ + // Hash of the deployed bytes; the no-op check keys on this (not `id`), so a rebuilt `dist/` at the same git sha isn't wrongly skipped. contentHash: string; } -/** - * Deterministic content hash for a set of files: a merkle-style digest over - * sorted `path + sha256` pairs, truncated to 12 hex chars (48 bits — enough - * headroom that collisions across a site's deploys stay negligible). Identical - * content always yields the same hash regardless of file order. - */ +// Deterministic content hash: a digest over sorted `path + sha256` pairs, truncated to 12 hex chars (48 bits); same content yields the same hash regardless of file order. export function contentHashId(files: HashedFile[]): string { const hasher = new Bun.CryptoHasher("sha256"); const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path)); @@ -33,45 +26,21 @@ export function contentHashId(files: HashedFile[]): string { return hasher.digest("hex").slice(0, 12); } -async function git(cwd: string, args: string[]): Promise { - try { - const proc = Bun.spawn(["git", ...args], { - cwd, - stdout: "pipe", - stderr: "ignore", - stdin: "ignore", - }); - const [code, out] = await Promise.all([ - proc.exited, - new Response(proc.stdout).text(), - ]); - return code === 0 ? out : null; - } catch { - // git not installed - return null; - } -} - /** The short HEAD sha and dirty-tree flag, or null when `cwd` isn't a git repo. */ export async function gitIdentity( cwd: string, ): Promise<{ sha: string; dirty: boolean } | null> { - const sha = await git(cwd, ["rev-parse", "--short=8", "HEAD"]); + const sha = await runGit(cwd, ["rev-parse", "--short=8", "HEAD"]); if (!sha) return null; - const status = await git(cwd, ["status", "--porcelain"]); + const status = await runGit(cwd, ["status", "--porcelain"]); return { - sha: sha.trim().toLowerCase(), - // A failed status check counts as dirty — better a content hash than a wrong sha. - dirty: status === null || status.trim().length > 0, + sha: sha.toLowerCase(), + // A failed status check counts as dirty: better a content hash than a wrong sha. + dirty: status === null || status.length > 0, }; } -/** - * Resolve the deploy identity: the display `id` is the git short-sha when the - * tree is clean, the content hash otherwise (or outside a repo). `contentHash` - * is always the hash of what actually ships and drives the no-op check — the - * git sha alone can't tell a rebuilt `dist/` from an unchanged one. - */ +// Resolve the deploy identity: display `id` is the git short-sha on a clean tree, else the content hash; `contentHash` always hashes what ships and drives the no-op check. export async function resolveDeployIdentity( cwd: string, files: HashedFile[], diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 56b2505f..e19e90c2 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -7,26 +7,23 @@ import { 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 { formatBytes } from "../../core/format.ts"; import { logger } from "../../core/logger.ts"; -import { confirm, isInteractive, spinner } from "../../core/ui.ts"; +import { confirm, isInteractive, withSpinner } from "../../core/ui.ts"; import { fetchSystemHostname, promoteDeploy, type SiteContext, writeRemoteState, } from "./api.ts"; -import { - parseEnvAssignments, - parseEnvFile, - resolveAutoBuild, - runBuildCommand, -} from "./build.ts"; +import { resolveAutoBuild, runBuildCommand } from "./build.ts"; import { loadSiteConfig } from "./config.ts"; import { type DeployRecord, deployPrefix, + markCurrent, previewHostname, } from "./constants.ts"; import { resolveDeployIdentity } from "./deploy-id.ts"; @@ -47,14 +44,7 @@ interface DeployArgs extends SiteSelectorArgs { force?: boolean; } -/** - * Production and preview URLs for a deploy, derived from the site's hosts. - * - * With a custom domain the preview is an isolated per-deploy subdomain - * (`dpl-{id}.preview.{domain}`); otherwise it's the immutable `/deploys/{id}/` - * path on the system host, which the router's HTMLRewriter makes render - * correctly even for root-absolute asset URLs. - */ +// 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. function deployUrls( site: SiteContext, deployId: string, @@ -72,13 +62,7 @@ function deployUrls( }; } -/** - * Deploy a directory: hash → skip if unchanged → upload to `deploys/{id}/` → - * record in remote state → serve at a preview URL. With `--production`, also - * publish it (env var + cache purge) as the live site. With `--build`, runs - * the build command first in the caller's environment plus `--env`/`--env-file` - * overrides. - */ +// 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. export const sitesDeployCommand = defineCommand({ command: "deploy [dir]", describe: "Deploy a directory to a site.", @@ -170,12 +154,7 @@ export const sitesDeployCommand = defineCommand({ 'Pass one (`--build "npm run build"`) or set `sites.build` in bunny.jsonc.', ); } - const overrides = { - ...(args["env-file"] - ? parseEnvFile(await Bun.file(resolve(args["env-file"])).text()) - : {}), - ...parseEnvAssignments(args.env), - }; + const overrides = await collectEnv(args.env, args["env-file"]); await runBuildCommand(command, root, overrides); } else if (isInteractive(output)) { // No --build: offer to run the configured build, else a detected one. @@ -203,17 +182,12 @@ export const sitesDeployCommand = defineCommand({ throw new UserError(`Directory not found: ${dir}`); } - const hashSpin = spinner("Hashing files..."); - hashSpin.start(); - let files: Awaited>; - try { - files = await hashFiles(collectFiles(dir)); - } finally { - hashSpin.stop(); - } + const files = await withSpinner("Hashing files...", () => + hashFiles(collectFiles(dir)), + ); if (files.length === 0) { throw new UserError( - `Nothing to deploy — ${dir} has no files.`, + `Nothing to deploy; ${dir} has no files.`, "Dotfiles and node_modules are excluded.", ); } @@ -221,13 +195,12 @@ export const sitesDeployCommand = defineCommand({ const identity = await resolveDeployIdentity(dir, files); - // The no-op check keys on content, not the display id: a rebuilt `dist/` - // at the same git sha ships different bytes and must not be skipped. + // The no-op check keys on content, not the display id, so a rebuilt `dist/` at the same git sha isn't wrongly skipped. const existing = args.force ? undefined : state.deploys.find((d) => d.contentHash === identity.contentHash); const skipUpload = existing !== undefined; - // A skipped deploy reuses the already-uploaded deploy's id — that's where its files live. + // A skipped deploy reuses the already-uploaded deploy's id; that's where its files live. const deployId = existing?.id ?? identity.id; const alreadyLive = state.current === deployId; @@ -269,17 +242,13 @@ export const sitesDeployCommand = defineCommand({ } if (!skipUpload) { - const uploadSpin = spinner(`Uploading ${files.length} files...`); - uploadSpin.start(); - try { - await uploadDeploy(connection, deployId, files, { + await withSpinner(`Uploading ${files.length} files...`, (spin) => + uploadDeploy(connection, deployId, files, { onFileUploaded: (done, total) => { - uploadSpin.text = `Uploading ${done}/${total} files (${formatBytes(totalBytes)} total)...`; + spin.text = `Uploading ${done}/${total} files (${formatBytes(totalBytes)} total)...`; }, - }); - } finally { - uploadSpin.stop(); - } + }), + ); // Record the deploy. A re-deployed ID keeps its slot but gets fresh metadata. const record: DeployRecord = { @@ -300,23 +269,16 @@ export const sitesDeployCommand = defineCommand({ } if (args.production) { - const promoteSpin = spinner("Publishing to production..."); - promoteSpin.start(); - try { + await withSpinner("Publishing to production...", async () => { await promoteDeploy({ computeClient, coreClient, state, deployId, }); - if (state.current && state.current !== deployId) { - state.previous = state.current; - } - state.current = deployId; + markCurrent(state, deployId); etag = await writeRemoteState(connection, state, etag); - } finally { - promoteSpin.stop(); - } + }); } const urls = deployUrls( diff --git a/packages/cli/src/commands/sites/deployments/index.ts b/packages/cli/src/commands/sites/deployments/index.ts index cb753657..235599c3 100644 --- a/packages/cli/src/commands/sites/deployments/index.ts +++ b/packages/cli/src/commands/sites/deployments/index.ts @@ -5,7 +5,7 @@ import { sitesDeploymentsPublishCommand } from "./publish.ts"; export const sitesDeploymentsNamespace = defineNamespace( "deployments", - "Manage a site's deploys — list, publish (roll back), prune.", + "Manage a site's deploys; list, publish (roll back), prune.", [ sitesDeploymentsListCommand, sitesDeploymentsPublishCommand, diff --git a/packages/cli/src/commands/sites/deployments/prune.ts b/packages/cli/src/commands/sites/deployments/prune.ts index 93cb1581..a2afd710 100644 --- a/packages/cli/src/commands/sites/deployments/prune.ts +++ b/packages/cli/src/commands/sites/deployments/prune.ts @@ -3,7 +3,7 @@ import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { logger } from "../../../core/logger.ts"; -import { confirm, spinner } from "../../../core/ui.ts"; +import { confirm, withSpinner } from "../../../core/ui.ts"; import { deleteDeployFiles, writeRemoteState } from "../api.ts"; import { DEFAULT_KEEP_DEPLOYS, @@ -87,9 +87,7 @@ export const sitesDeploymentsPruneCommand = defineCommand({ } const failures: Array<{ id: string; error: string }> = []; - const spin = spinner("Pruning deploys..."); - spin.start(); - try { + await withSpinner("Pruning deploys...", async (spin) => { const pruned = new Set(); for (const [index, victim] of victims.entries()) { spin.text = `Pruning ${victim.id} (${index + 1}/${victims.length})...`; @@ -111,9 +109,7 @@ export const sitesDeploymentsPruneCommand = defineCommand({ // Only forget deploys whose files are actually gone. state.deploys = state.deploys.filter((d) => !pruned.has(d.id)); await writeRemoteState(connection, state, etag); - } finally { - spin.stop(); - } + }); const prunedIds = victims .filter((v) => !failures.some((f) => f.id === v.id)) diff --git a/packages/cli/src/commands/sites/deployments/publish.ts b/packages/cli/src/commands/sites/deployments/publish.ts index ad44a951..ad21271e 100644 --- a/packages/cli/src/commands/sites/deployments/publish.ts +++ b/packages/cli/src/commands/sites/deployments/publish.ts @@ -7,8 +7,9 @@ import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; -import { confirm, spinner } from "../../../core/ui.ts"; +import { confirm, withSpinner } from "../../../core/ui.ts"; import { promoteDeploy, writeRemoteState } from "../api.ts"; +import { markCurrent } from "../constants.ts"; import { type SiteSelectorArgs, selectSite, @@ -21,10 +22,7 @@ interface PublishArgs extends SiteSelectorArgs { force?: boolean; } -/** - * Publish (promote) a past deploy as production — instant rollback. Flips the - * router's env var and purges the CDN cache; no files move. - */ +// Publish (promote) a past deploy as production: flips the router's env var and purges the cache, no files move (instant rollback). export const sitesDeploymentsPublishCommand = defineCommand({ command: "publish [id]", aliases: ["promote"], @@ -123,23 +121,16 @@ export const sitesDeploymentsPublishCommand = defineCommand({ return; } - const spin = spinner("Publishing..."); - spin.start(); - try { + await withSpinner("Publishing...", async () => { await promoteDeploy({ computeClient, coreClient, state, deployId: targetId, }); - if (state.current && state.current !== targetId) { - state.previous = state.current; - } - state.current = targetId; + markCurrent(state, targetId); await writeRemoteState(connection, state, etag); - } finally { - spin.stop(); - } + }); if (output === "json") { logger.log( diff --git a/packages/cli/src/commands/sites/domains/index.ts b/packages/cli/src/commands/sites/domains/index.ts index 555d1304..37019b37 100644 --- a/packages/cli/src/commands/sites/domains/index.ts +++ b/packages/cli/src/commands/sites/domains/index.ts @@ -15,8 +15,7 @@ import { type SiteContext, writeRemoteState } from "../api.ts"; import { PREVIEW_LABEL, previewWildcard } from "../constants.ts"; import { selectSite } from "../interactive.ts"; -// The hooks run in the same invocation as `resolve`, so the resolved site is -// cached here — a CLI process handles exactly one command. +// The hooks run in the same invocation as `resolve`, so the resolved site is cached here (a CLI process handles exactly one command). let resolvedSite: SiteContext | null = null; async function resolveSitePullZone( @@ -58,11 +57,7 @@ async function recordSiteDomain( } } -/** - * Attach the `*.preview.` wildcard that serves per-deploy previews. - * Best-effort by design: the apex is already added, and the wildcard can be - * retried by re-running `sites domains add`. - */ +// 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`. export async function attachPreviewWildcard(opts: { coreClient: CoreClient; pullZoneId: number; @@ -95,7 +90,7 @@ export async function attachPreviewWildcard(opts: { // 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}"`, + ` Preview HTTPS pending; once DNS is live: bunny sites domains ssl "${wildcard}"`, ); } } @@ -109,11 +104,7 @@ export async function attachPreviewWildcard(opts: { } } -/** - * 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. - */ +// 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. export async function setupSiteDomain(opts: { coreClient: CoreClient; site: SiteContext; @@ -188,7 +179,7 @@ export const sitesDomainsCommands = createHostnamesCommands({ body: { Hostname: previewWildcard(hostname) }, }); } catch { - // Already gone (or never added) — nothing to clean up. + // Already gone (or never added); nothing to clean up. } if (resolvedSite?.state.domain === hostname) { await recordSiteDomain(resolvedSite, undefined); diff --git a/packages/cli/src/commands/sites/interactive.ts b/packages/cli/src/commands/sites/interactive.ts index 59f7ad9a..8d09d791 100644 --- a/packages/cli/src/commands/sites/interactive.ts +++ b/packages/cli/src/commands/sites/interactive.ts @@ -4,7 +4,7 @@ import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; import { loadManifest, saveManifest } from "../../core/manifest.ts"; import type { OutputFormat } from "../../core/types.ts"; -import { confirm, isInteractive, spinner } from "../../core/ui.ts"; +import { confirm, isInteractive, withSpinner } from "../../core/ui.ts"; import { type CoreClient, fetchStorageZone, @@ -66,25 +66,11 @@ async function contextFromRef( export interface SelectedSite { site: SiteContext; - /** - * Offer to link the directory to the site — only when it was chosen via the - * interactive picker. A no-op otherwise, so commands can always call it - * after their own output. - */ + // Offer to link the directory to the site (only when chosen via the interactive picker); a no-op otherwise, so commands can always call it. offerLink: () => Promise; } -/** - * Resolve the site a command acts on. - * - * Precedence: explicit ref (flag or positional) → `.bunny/site.json` → - * `sites.name` in bunny.jsonc → interactive picker. Non-interactive runs - * without a resolvable site fail with a hint instead of hanging on a prompt. - * - * `offerCreate` (deploy only) adds a "new site" branch to the picker: it runs - * when the account has no sites, or when the user picks "a new site" over the - * existing list. It returns a ready, already-linked context. - */ +// Resolve the site a command acts on, in precedence order: explicit ref, `.bunny/site.json`, `sites.name` in bunny.jsonc, interactive picker (non-interactive runs fail with a hint instead of hanging). `offerCreate` (deploy only) adds a "new site" branch returning a ready, already-linked context. export async function selectSite( client: CoreClient, args: SiteSelectorArgs & { @@ -95,49 +81,39 @@ export async function selectSite( const noLink = async () => {}; if (args.site) { - const spin = spinner("Resolving site..."); - spin.start(); - try { - return { - site: await contextFromRef(client, args.site), - offerLink: noLink, - }; - } finally { - spin.stop(); - } + const ref = args.site; + return { + site: await withSpinner("Resolving site...", () => + contextFromRef(client, ref), + ), + offerLink: noLink, + }; } const manifest = loadManifest(SITES_MANIFEST); if (manifest.id) { - const spin = spinner("Loading linked site..."); - spin.start(); - try { - const zone = await fetchStorageZone(client, manifest.id); - const context = await siteContextFromZone(zone); - if (!context) { - throw new UserError( - `The linked storage zone ${manifest.id} is no longer a bunny site.`, - "Run `bunny sites unlink`, then link or create a site.", - ); - } - return { site: context, offerLink: noLink }; - } finally { - spin.stop(); + const id = manifest.id; + const context = await withSpinner("Loading linked site...", async () => + siteContextFromZone(await fetchStorageZone(client, id)), + ); + if (!context) { + throw new UserError( + `The linked storage zone ${id} is no longer a bunny site.`, + "Run `bunny sites unlink`, then link or create a site.", + ); } + return { site: context, offerLink: noLink }; } const configured = loadSiteConfig()?.config.name; if (configured) { - const spin = spinner(`Resolving site "${configured}" from bunny.jsonc...`); - spin.start(); - try { - return { - site: await contextFromRef(client, configured), - offerLink: noLink, - }; - } finally { - spin.stop(); - } + return { + site: await withSpinner( + `Resolving site "${configured}" from bunny.jsonc...`, + () => contextFromRef(client, configured), + ), + offerLink: noLink, + }; } if (!isInteractive(args.output)) { @@ -147,14 +123,9 @@ export async function selectSite( ); } - const spin = spinner("Fetching sites..."); - spin.start(); - let sites: Awaited>; - try { - sites = await fetchSites(client); - } finally { - spin.stop(); - } + const sites = await withSpinner("Fetching sites...", () => + fetchSites(client), + ); // Deploy offers to create a site here; other commands only pick an existing one. if (args.offerCreate) { diff --git a/packages/cli/src/commands/sites/link.ts b/packages/cli/src/commands/sites/link.ts index d05003f9..13616290 100644 --- a/packages/cli/src/commands/sites/link.ts +++ b/packages/cli/src/commands/sites/link.ts @@ -29,8 +29,7 @@ export const sitesLinkCommand = defineCommand({ const config = resolveConfig(profile, apiKey, verbose); const client = createCoreClient(clientOptions(config, verbose)); - // `link: true` makes the picker's offerLink a no-op prompt-wise, but we - // save explicitly below so the ref/manifest paths also link. + // link always saves the manifest itself below, so the picker's offerLink is irrelevant here. const { site } = await selectSite(client, { site: ref, link: false, diff --git a/packages/cli/src/commands/sites/list.ts b/packages/cli/src/commands/sites/list.ts index c421b5c9..714d5b08 100644 --- a/packages/cli/src/commands/sites/list.ts +++ b/packages/cli/src/commands/sites/list.ts @@ -4,7 +4,7 @@ import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; import { formatTable } from "../../core/format.ts"; import { logger } from "../../core/logger.ts"; -import { spinner } from "../../core/ui.ts"; +import { withSpinner } from "../../core/ui.ts"; import { fetchSites } from "./api.ts"; export const sitesListCommand = defineCommand({ @@ -20,14 +20,9 @@ export const sitesListCommand = defineCommand({ const config = resolveConfig(profile, apiKey, verbose); const client = createCoreClient(clientOptions(config, verbose)); - const spin = spinner("Fetching sites..."); - spin.start(); - let sites: Awaited>; - try { - sites = await fetchSites(client); - } finally { - spin.stop(); - } + const sites = await withSpinner("Fetching sites...", () => + fetchSites(client), + ); if (output === "json") { logger.log( @@ -64,9 +59,9 @@ export const sitesListCommand = defineCommand({ ? `https://${s.state.domain}` : s.systemHostname ? `https://${s.systemHostname}` - : "—", + : "-", String(s.state.deploys.length), - s.state.current ?? "—", + s.state.current ?? "-", ]), output, ), diff --git a/packages/cli/src/commands/sites/provision.ts b/packages/cli/src/commands/sites/provision.ts index 31f2e058..f1d250fa 100644 --- a/packages/cli/src/commands/sites/provision.ts +++ b/packages/cli/src/commands/sites/provision.ts @@ -3,7 +3,7 @@ import prompts from "prompts"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; import { saveManifest } from "../../core/manifest.ts"; -import { spinner } from "../../core/ui.ts"; +import { withSpinner } from "../../core/ui.ts"; import type { CoreClient } from "../storage/api.ts"; import { type ComputeClient, @@ -18,7 +18,7 @@ import { } from "./constants.ts"; export const SITE_NAME_RULES = - "Use 3–60 lowercase letters, digits, and dashes (no leading/trailing dash)."; + "Use 3-60 lowercase letters, digits, and dashes (no leading/trailing dash)."; /** Best-effort site name from the current directory, or undefined if it can't be one. */ export function suggestSiteName(): string | undefined { @@ -29,11 +29,7 @@ export function suggestSiteName(): string | undefined { return isValidSiteName(base) ? base : undefined; } -/** - * Resolve a site name: normalize a passed one, else prompt (defaulting to the - * directory name). Throws with `missingHint` when no name is available and we - * can't prompt. - */ +// Resolve a site name: normalize a passed one, else prompt (defaulting to the directory name); throws with `missingHint` when none is available and we can't prompt. export async function promptSiteName( nameArg: string | undefined, interactive: boolean, @@ -64,22 +60,15 @@ export async function promptSiteName( return name; } -/** - * Create a new site and link this directory to it, returning the ready context. - * The provisioning-only path behind the deploy picker's "new site" branch: it - * skips the custom-domain and CI prompts that `sites create` layers on top. - */ +// Create a site and link this directory to it (the provisioning-only path behind the deploy picker's "new site" branch); skips the custom-domain and CI prompts `sites create` adds. export async function createLinkedSite(opts: { coreClient: CoreClient; computeClient: ComputeClient; name: string; region?: string; }): Promise { - const spin = spinner(`Creating site "${opts.name}"...`); - spin.start(); - let result: Awaited>; - try { - result = await createSite({ + const result = await withSpinner(`Creating site "${opts.name}"...`, (spin) => + createSite({ coreClient: opts.coreClient, computeClient: opts.computeClient, name: opts.name, @@ -87,10 +76,8 @@ export async function createLinkedSite(opts: { onStep: (message) => { spin.text = message; }, - }); - } finally { - spin.stop(); - } + }), + ); saveManifest(SITES_MANIFEST, { id: result.state.storageZoneId, diff --git a/packages/cli/src/commands/sites/router/source.test.ts b/packages/cli/src/commands/sites/router/source.test.ts index 08965b37..261f6e0c 100644 --- a/packages/cli/src/commands/sites/router/source.test.ts +++ b/packages/cli/src/commands/sites/router/source.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "bun:test"; import { routerSource } from "./source.ts"; test("routerSource wires up the preview machinery", () => { - const src = routerSource(); + const src = routerSource; expect(src).toContain("bunny sites router"); // Serves the promoted deploy at the apex and per-deploy path previews. expect(src).toContain("process.env.CURRENT_DEPLOY"); @@ -24,7 +24,7 @@ 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). + // 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( diff --git a/packages/cli/src/commands/sites/router/source.ts b/packages/cli/src/commands/sites/router/source.ts index 4661d1a1..dfa15d36 100644 --- a/packages/cli/src/commands/sites/router/source.ts +++ b/packages/cli/src/commands/sites/router/source.ts @@ -1,30 +1,5 @@ -/** - * The site router — a middleware Edge Script attached to the site's pull zone. - * - * It maps incoming hosts to deploy directories inside the storage-zone origin: - * - * - apex / `.b-cdn.net` host → `/deploys/{CURRENT_DEPLOY}{path}` (production) - * - `dpl-{id}.preview.{domain}` → `/deploys/{id}{path}` (custom-domain preview, root-served) - * - `/deploys/{id}/...` paths → passed through (immutable per-deploy preview URL) - * - `/_bunny/*` → 403 (site state/env are never served) - * - * A path preview (`/deploys/{id}/`) serves a build under a subpath, so its - * root-absolute asset URLs (`/assets/x.css`) would otherwise escape the prefix - * and 404. The response phase runs such HTML through {@link HTMLRewriter} to - * rewrite those refs to `/deploys/{id}/…` — the fix for Jekyll/most SSGs without - * a custom domain, on a single pull zone (each deploy's assets get a unique URL, - * so the CDN caches them separately). The request phase flags previews with an - * `x-bunny-preview` header so production HTML (served at the apex) is never - * rewritten — that would leak deploy ids into production URLs and churn its cache - * on every promote. - * - * Promote/rollback is just a `CURRENT_DEPLOY` env write — the code never changes - * between deploys. `sites upgrade-router` republishes this source onto a site. - */ - -// NOTE: validated by the Phase 0 spike against the live platform; the exact -// BunnySDK middleware hook + HTMLRewriter names are the platform contract. -const SOURCE = `// bunny sites router — generated by the bunny CLI. Do not edit: +// 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. +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"; @@ -141,8 +116,3 @@ BunnySDK.net.http }); }); `; - -/** The site's middleware router script. */ -export function routerSource(): string { - return SOURCE; -} diff --git a/packages/cli/src/commands/sites/show.ts b/packages/cli/src/commands/sites/show.ts index f5eb7a23..ac7d842b 100644 --- a/packages/cli/src/commands/sites/show.ts +++ b/packages/cli/src/commands/sites/show.ts @@ -13,7 +13,7 @@ import { toSafeHostname, } from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; -import { spinner } from "../../core/ui.ts"; +import { withSpinner } from "../../core/ui.ts"; import { type SiteSelectorArgs, selectSite, @@ -44,16 +44,10 @@ export const sitesShowCommand = defineCommand({ }); const { state } = site; - const spin = spinner("Fetching hostnames..."); - spin.start(); - let hostnames: Awaited> = []; - try { - hostnames = await fetchPullZoneHostnames(client, state.pullZoneId); - } catch { - // Hostnames are informational — a fetch failure shouldn't hide the site. - } finally { - spin.stop(); - } + // Hostnames are informational; a fetch failure shouldn't hide the site. + const hostnames = await withSpinner("Fetching hostnames...", () => + fetchPullZoneHostnames(client, state.pullZoneId).catch(() => []), + ); if (output === "json") { logger.log( @@ -77,13 +71,13 @@ export const sitesShowCommand = defineCommand({ { key: "Storage zone", value: String(state.storageZoneId) }, { key: "Pull zone", value: String(state.pullZoneId) }, { key: "Router script", value: String(state.scriptId) }, - { key: "Domain", value: state.domain ?? "—" }, - { key: "Current deploy", value: state.current ?? "—" }, + { key: "Domain", value: state.domain ?? "-" }, + { key: "Current deploy", value: state.current ?? "-" }, { key: "Deployed", - value: current ? formatDateTime(current.createdAt) : "—", + value: current ? formatDateTime(current.createdAt) : "-", }, - { key: "Previous deploy", value: state.previous ?? "—" }, + { key: "Previous deploy", value: state.previous ?? "-" }, { key: "Deploys", value: String(state.deploys.length) }, ], output, diff --git a/packages/cli/src/commands/sites/ssl.ts b/packages/cli/src/commands/sites/ssl.ts index d32ee4a9..06871ce4 100644 --- a/packages/cli/src/commands/sites/ssl.ts +++ b/packages/cli/src/commands/sites/ssl.ts @@ -8,7 +8,7 @@ import { setForceSsl, } from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; -import { spinner } from "../../core/ui.ts"; +import { withSpinner } from "../../core/ui.ts"; import { type SiteSelectorArgs, selectSite, @@ -19,11 +19,7 @@ interface SslArgs extends SiteSelectorArgs { "force-ssl"?: boolean; } -/** - * Toggle Force SSL (HTTP→HTTPS redirect) on the site's `.b-cdn.net` - * system host. It always carries bunny's wildcard certificate, so no cert is - * issued here. Custom domains are managed via `sites domains ssl`. - */ +// Toggle Force SSL (HTTP->HTTPS) on the site's `.b-cdn.net` system host (always on bunny's wildcard cert, so no cert is issued); custom domains use `sites domains ssl`. export const sitesSslCommand = defineCommand({ command: "ssl [site]", describe: "Force HTTPS on a site's .b-cdn.net address.", @@ -53,21 +49,17 @@ export const sitesSslCommand = defineCommand({ const { site } = await selectSite(client, { site: ref, link, output }); const { state } = site; - const spin = spinner("Updating Force SSL..."); - spin.start(); - let systemHost: string | null | undefined; - try { + const systemHost = await withSpinner("Updating Force SSL...", async () => { const hostnames = await fetchPullZoneHostnames(client, state.pullZoneId); - systemHost = hostnames.find((h) => h.IsSystemHostname)?.Value; - if (!systemHost) { + const host = hostnames.find((h) => h.IsSystemHostname)?.Value; + if (!host) { throw new UserError( `Couldn't find the system hostname for "${state.name}".`, ); } - await setForceSsl(client, state.pullZoneId, systemHost, force); - } finally { - spin.stop(); - } + await setForceSsl(client, state.pullZoneId, host, force); + return host; + }); if (output === "json") { logger.log( diff --git a/packages/cli/src/commands/sites/upgrade-router.ts b/packages/cli/src/commands/sites/upgrade-router.ts index b4f5e64b..f6ea990f 100644 --- a/packages/cli/src/commands/sites/upgrade-router.ts +++ b/packages/cli/src/commands/sites/upgrade-router.ts @@ -6,7 +6,7 @@ import { resolveConfig } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; import { logger } from "../../core/logger.ts"; -import { spinner } from "../../core/ui.ts"; +import { withSpinner } from "../../core/ui.ts"; import { type SiteSelectorArgs, selectSite, @@ -16,10 +16,7 @@ import { routerSource } from "./router/source.ts"; type UpgradeArgs = SiteSelectorArgs; -/** - * Republish the site's router script with the CLI's current source. Deploys and - * env vars are untouched — only the router code changes. - */ +// Republish the site's router script with the CLI's current source; deploys and env vars are untouched, only the router code changes. export const sitesUpgradeRouterCommand = defineCommand({ command: "upgrade-router [site]", describe: "Republish a site's router script with the latest version.", @@ -44,20 +41,16 @@ export const sitesUpgradeRouterCommand = defineCommand({ }); const { state } = site; - const spin = spinner("Republishing router..."); - spin.start(); - try { + await withSpinner("Republishing router...", async () => { await computeClient.POST("/compute/script/{id}/code", { params: { path: { id: state.scriptId } }, - body: { Code: routerSource() }, + body: { Code: routerSource }, }); await computeClient.POST("/compute/script/{id}/publish", { params: { path: { id: state.scriptId, uuid: null } }, body: {}, }); - } finally { - spin.stop(); - } + }); if (output === "json") { logger.log( diff --git a/packages/cli/src/commands/sites/uploader.ts b/packages/cli/src/commands/sites/uploader.ts index 72092a60..152f633f 100644 --- a/packages/cli/src/commands/sites/uploader.ts +++ b/packages/cli/src/commands/sites/uploader.ts @@ -1,5 +1,6 @@ import { readdirSync, statSync } from "node:fs"; import { join } from "node:path"; +import { mapWithConcurrency } from "../../core/concurrency.ts"; import { UserError } from "../../core/errors.ts"; import type { StorageZone } from "../storage/files-api.ts"; import { siteFiles } from "./api.ts"; @@ -22,10 +23,7 @@ const UPLOAD_ATTEMPTS = 3; // Dot-directories that carry web-visible content the site must serve. const ALLOWED_DOT_ENTRIES = new Set([".well-known"]); -/** - * Dotfiles/dirs and node_modules never ship — they're tooling, not site - * content — except standards dirs like `.well-known` that must be served. - */ +// Dotfiles/dirs and node_modules never ship (tooling, not content), except standards dirs like `.well-known` that must be served. export function shouldSkipEntry(name: string): boolean { if (ALLOWED_DOT_ENTRIES.has(name)) return false; return name.startsWith(".") || name === "node_modules"; @@ -65,32 +63,11 @@ async function hashFile(file: LocalFile): Promise { return { ...file, sha256: hasher.digest("hex") }; } -/** - * Streaming SHA-256 for each file — feeds both the deploy ID and upload - * checksums. Bounded concurrency matches the upload step so large deploys - * don't hash one file at a time. - */ +// Streaming SHA-256 per file feeds both the deploy ID and upload checksums; concurrency matches the upload step. export async function hashFiles( files: LocalFile[], ): Promise { - const hashed: HashedLocalFile[] = new Array(files.length); - const concurrency = Math.max( - 1, - Math.min(DEFAULT_UPLOAD_CONCURRENCY, files.length), - ); - - let next = 0; - const worker = async () => { - while (true) { - const index = next++; - const file = files[index]; - if (!file) return; - hashed[index] = await hashFile(file); - } - }; - - await Promise.all(Array.from({ length: concurrency }, worker)); - return hashed; + return mapWithConcurrency(files, DEFAULT_UPLOAD_CONCURRENCY, hashFile); } async function withRetries(fn: () => Promise): Promise { @@ -113,10 +90,7 @@ export interface UploadDeployOptions { onFileUploaded?: (done: number, total: number, file: HashedLocalFile) => void; } -/** - * Upload a deploy's files to `deploys/{id}/...` with bounded concurrency, - * per-file SHA-256 checksums (server-verified), and retry with backoff. - */ +// Upload a deploy's files to `deploys/{id}/...` with bounded concurrency, server-verified per-file SHA-256 checksums, and retry with backoff. export async function uploadDeploy( connection: StorageZone, deployId: string, @@ -124,22 +98,15 @@ export async function uploadDeploy( opts?: UploadDeployOptions, ): Promise { if (files.length === 0) { - throw new UserError("Nothing to upload — the deploy has no files."); + throw new UserError("Nothing to upload; the deploy has no files."); } const prefix = deployPrefix(deployId); - const concurrency = Math.max( - 1, - Math.min(opts?.concurrency ?? DEFAULT_UPLOAD_CONCURRENCY, files.length), - ); - - let next = 0; let done = 0; - const worker = async () => { - while (true) { - const index = next++; - const file = files[index]; - if (!file) return; + await mapWithConcurrency( + files, + opts?.concurrency ?? DEFAULT_UPLOAD_CONCURRENCY, + async (file) => { await withRetries(() => siteFiles.upload( connection, @@ -150,8 +117,6 @@ export async function uploadDeploy( ); done++; opts?.onFileUploaded?.(done, files.length, file); - } - }; - - await Promise.all(Array.from({ length: concurrency }, worker)); + }, + ); } diff --git a/packages/cli/src/core/concurrency.ts b/packages/cli/src/core/concurrency.ts new file mode 100644 index 00000000..80f44ada --- /dev/null +++ b/packages/cli/src/core/concurrency.ts @@ -0,0 +1,19 @@ +/** Map over `items` with at most `concurrency` promises in flight, preserving input order. */ +export async function mapWithConcurrency( + items: T[], + concurrency: number, + fn: (item: T, index: number) => Promise, +): Promise { + const results = new Array(items.length); + let next = 0; + const worker = async () => { + while (true) { + const index = next++; + if (index >= items.length) return; + results[index] = await fn(items[index] as T, index); + } + }; + const lanes = Math.min(concurrency, items.length) || 1; + await Promise.all(Array.from({ length: lanes }, worker)); + return results; +} diff --git a/packages/cli/src/core/dns-nameservers.ts b/packages/cli/src/core/dns-nameservers.ts index effe0879..2cda3239 100644 --- a/packages/cli/src/core/dns-nameservers.ts +++ b/packages/cli/src/core/dns-nameservers.ts @@ -1,5 +1,6 @@ import dgram from "node:dgram"; import { promises as dns } from "node:dns"; +import { mapWithConcurrency } from "./concurrency.ts"; /** bunny.net delegates every zone to the same two anycast nameservers. */ export const BUNNY_NAMESERVERS = ["kiki.bunny.net", "coco.bunny.net"] as const; @@ -214,17 +215,7 @@ export async function checkDelegations( items: { domain: string; expected?: readonly string[] }[], concurrency = 10, ): Promise { - const results = new Array(items.length); - let next = 0; - async function worker() { - while (next < items.length) { - const index = next++; - const item = items[index]; - if (!item) continue; - results[index] = await checkDelegation(item.domain, item.expected); - } - } - const lanes = Math.min(concurrency, items.length) || 1; - await Promise.all(Array.from({ length: lanes }, worker)); - return results; + return mapWithConcurrency(items, concurrency, (item) => + checkDelegation(item.domain, item.expected), + ); } diff --git a/packages/cli/src/core/env.test.ts b/packages/cli/src/core/env.test.ts new file mode 100644 index 00000000..6fe485a1 --- /dev/null +++ b/packages/cli/src/core/env.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import { parseDotenv, splitPair } from "./env.ts"; + +describe("splitPair", () => { + test("splits on the first = and validates the key", () => { + expect(splitPair("B=with=equals")).toEqual(["B", "with=equals"]); + expect(splitPair("C_1=")).toEqual(["C_1", ""]); + expect(() => splitPair("NOEQUALS")).toThrow("Expected KEY=VALUE"); + expect(() => splitPair("=value")).toThrow("Invalid environment variable"); + expect(() => splitPair("1BAD=x")).toThrow("Invalid environment variable"); + }); +}); + +describe("parseDotenv", () => { + test("handles comments, blanks, export, and quotes", () => { + expect( + parseDotenv( + [ + "# comment", + "", + "PLAIN=value", + "export EXPORTED=x", + 'QUOTED="hello world"', + "SINGLE='single'", + " SPACED = spaced-out ", + "not a var line", + ].join("\n"), + ), + ).toEqual({ + PLAIN: "value", + EXPORTED: "x", + QUOTED: "hello world", + SINGLE: "single", + SPACED: "spaced-out", + }); + }); + + test("strips inline comments and unescapes inner quotes", () => { + expect( + parseDotenv( + [ + "INLINE=value # trailing comment", + "HASHINVALUE=a#b", + 'ESCAPED="value with \\"quotes\\""', + 'HASHINQUOTES="a # b"', + ].join("\n"), + ), + ).toEqual({ + INLINE: "value", + HASHINVALUE: "a#b", + ESCAPED: 'value with "quotes"', + HASHINQUOTES: "a # b", + }); + }); +}); diff --git a/packages/cli/src/core/env.ts b/packages/cli/src/core/env.ts new file mode 100644 index 00000000..b93393cd --- /dev/null +++ b/packages/cli/src/core/env.ts @@ -0,0 +1,78 @@ +import { UserError } from "./errors.ts"; + +const ENV_KEY_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +/** Throw when `key` isn't a legal environment variable name. */ +export function assertEnvKey(key: string): void { + if (!ENV_KEY_PATTERN.test(key)) { + throw new UserError(`Invalid environment variable name: "${key}"`); + } +} + +/** Split a `KEY=VALUE` string on the first `=`, validating the key. */ +export function splitPair(entry: string): [string, string] { + const eq = entry.indexOf("="); + if (eq === -1) { + throw new UserError(`Invalid env entry "${entry}". Expected KEY=VALUE.`); + } + const key = entry.slice(0, eq); + assertEnvKey(key); + return [key, entry.slice(eq + 1)]; +} + +// Double-quoted values read to the closing quote, unescaping `\"`/`\\` (a `#` inside stays literal); single quotes are verbatim; unquoted values drop a whitespace-delimited inline `# comment`. +function parseValue(raw: string): string { + const value = raw.trim(); + if (value.startsWith('"')) { + let out = ""; + for (let i = 1; i < value.length; i++) { + const ch = value[i]; + if (ch === "\\" && i + 1 < value.length) out += value[++i]; + else if (ch === '"') return out; + else out += ch; + } + return out; // unterminated quote: take what we have + } + if (value.startsWith("'")) { + const end = value.indexOf("'", 1); + return end === -1 ? value.slice(1) : value.slice(1, end); + } + const comment = value.search(/\s#/); + return (comment === -1 ? value : value.slice(0, comment)).trimEnd(); +} + +/** Parse a dotenv file: KEY=VALUE lines, `#` comments, optional `export`, optional quotes; invalid lines are skipped. */ +export function parseDotenv(text: string): Record { + const env: Record = {}; + for (const rawLine of text.split("\n")) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const body = line.startsWith("export ") ? line.slice(7).trim() : line; + const eq = body.indexOf("="); + if (eq === -1) continue; + const key = body.slice(0, eq).trim(); + if (!ENV_KEY_PATTERN.test(key)) continue; + env[key] = parseValue(body.slice(eq + 1)); + } + return env; +} + +/** Merge env vars from a dotenv file (loaded first) and `KEY=VALUE` entries (which override the file). */ +export async function collectEnv( + entries: string[] = [], + envFile?: string, +): Promise> { + const env: Record = {}; + if (envFile) { + const file = Bun.file(envFile); + if (!(await file.exists())) { + throw new UserError(`Env file not found: ${envFile}`); + } + Object.assign(env, parseDotenv(await file.text())); + } + for (const entry of entries) { + const [key, value] = splitPair(entry); + env[key] = value; + } + return env; +} diff --git a/packages/cli/src/core/git.ts b/packages/cli/src/core/git.ts new file mode 100644 index 00000000..b8f870e5 --- /dev/null +++ b/packages/cli/src/core/git.ts @@ -0,0 +1,21 @@ +/** Run `git` in `cwd`, returning trimmed stdout, or null when git fails or isn't installed. */ +export async function runGit( + cwd: string, + args: string[], +): Promise { + try { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "ignore", + stdin: "ignore", + }); + const [code, out] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + ]); + return code === 0 ? out.trim() : null; + } catch { + return null; + } +} diff --git a/packages/cli/src/core/hostnames/commands.ts b/packages/cli/src/core/hostnames/commands.ts index eb46e55c..5e6b85b1 100644 --- a/packages/cli/src/core/hostnames/commands.ts +++ b/packages/cli/src/core/hostnames/commands.ts @@ -32,7 +32,7 @@ export interface HostnameHookContext { coreClient: CoreClient; pullZoneId: number; hostname: string; - /** The zone's system hostname — the CNAME target (add only). */ + /** The zone's system hostname: the CNAME target (add only). */ cnameTarget?: string; args: GlobalArgs & Record; } @@ -57,12 +57,12 @@ export interface HostnamesMountOptions { /** Hidden namespace aliases (e.g. ["hostnames"]) — they work but stay out of help. */ hiddenAliases?: string[]; /** - * Runs after a domain is added (before the SSL/DNS follow-up) — lets a + * Runs after a domain is added (before the SSL/DNS follow-up); lets a * resource attach companion hostnames or persist the domain. The hook must * handle its own errors; a companion failure shouldn't fail the add. */ onAdded?: (ctx: HostnameHookContext) => Promise; - /** Runs after a domain is removed — the counterpart of {@link onAdded}. */ + /** Runs after a domain is removed; the counterpart of {@link onAdded}. */ onRemoved?: (ctx: HostnameHookContext) => Promise; } diff --git a/packages/cli/src/core/ui.ts b/packages/cli/src/core/ui.ts index 6ec8bde0..73f0299b 100644 --- a/packages/cli/src/core/ui.ts +++ b/packages/cli/src/core/ui.ts @@ -50,6 +50,20 @@ export function spinner(text: string) { return ora({ text, isSilent: !process.stdout.isTTY }); } +/** Run `fn` under a started spinner, stopping it whatever happens; `fn` may update `spin.text`. */ +export async function withSpinner( + text: string, + fn: (spin: ReturnType) => Promise, +): Promise { + const spin = spinner(text); + spin.start(); + try { + return await fn(spin); + } finally { + spin.stop(); + } +} + /** Open a URL in the user's default browser. */ export function openBrowser(url: string) { const cmds: Record = { diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index a88851ae..0f28fed2 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -1,13 +1,13 @@ # Static Sites Commands -All site commands live under `bunny sites`. A site is one storage zone (files) + one pull zone (CDN) + one middleware router script, provisioned together by `sites create`. Deploys are immutable directories; promoting or rolling back flips a router env var and purges the cache — no files move, so it's instant. +All site commands live under `bunny sites`. A site is one storage zone (files) + one pull zone (CDN) + one middleware router script, provisioned together by `sites create`. Deploys are immutable directories; promoting or rolling back flips a router env var and purges the cache; no files move, so it's instant. Most commands accept an optional site (a trailing `[site]` positional, or the `--site` flag on commands whose positionals are taken, like `deploy`). When omitted, the site resolves in this order: 1. Explicit name or storage zone ID 2. `.bunny/site.json` manifest (written by `bunny sites link` or `bunny sites create`) 3. `sites.name` in `bunny.jsonc` -4. Interactive prompt (suppressed in `--output json` mode — pass a site or link the directory in CI) +4. Interactive prompt (suppressed in `--output json` mode; pass a site or link the directory in CI) ## Typical workflows @@ -39,7 +39,7 @@ bunny sites domains add example.com --wait # also attaches *.preview.example.com --- -## `bunny sites create` — Provision a site +## `bunny sites create`; Provision a site ```bash bunny sites create # prompts for a name (directory-name suggestion), then a custom domain @@ -55,11 +55,11 @@ bunny sites create my-site --no-link # don't write .bunny/site.json | `--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) | -Site names become the storage zone, pull zone, and `.b-cdn.net` subdomain: 3–60 lowercase letters, digits, and dashes. Creation is idempotent — a failed create re-runs cleanly, reusing whatever was already provisioned. +Site names become the storage zone, pull zone, and `.b-cdn.net` subdomain: 3-60 lowercase letters, digits, and dashes. Creation is idempotent; a failed create re-runs cleanly, reusing whatever was already provisioned. --- -## `bunny sites deploy` — Deploy a directory +## `bunny sites deploy`; Deploy a directory ```bash bunny sites deploy ./dist # preview only @@ -78,7 +78,7 @@ bunny sites deploy ./out --build "npm run build" --env VITE_FLAG=1 | `--force` | Deploy even when content is unchanged | | `--site` | Target site (name or storage zone ID) | -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. +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`): @@ -87,7 +87,7 @@ Interactive `deploy` adds two conveniences (both skipped under `--output json`): --- -## `bunny sites deployments` — List, publish, prune +## `bunny sites deployments`; List, publish, prune ```bash bunny sites deployments list @@ -96,11 +96,11 @@ bunny sites deployments publish --previous # instant rollback bunny sites deployments prune --keep 10 # never prunes current/previous ``` -`publish` (alias `promote`) flips production to a past deploy — the files are already on the CDN, so this is instant plus a cache purge. +`publish` (alias `promote`) flips production to a past deploy; the files are already on the CDN, so this is instant plus a cache purge. --- -## `bunny sites domains` — Custom domains +## `bunny sites domains`; Custom domains ```bash bunny sites domains add example.com --wait # wait for DNS, then issue SSL @@ -109,11 +109,11 @@ 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.` 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. --- -## `bunny sites ci init` — GitHub Actions deployments +## `bunny sites ci init`; GitHub Actions deployments ```bash bunny sites ci init # detect the framework, write .github/workflows/bunny-sites.yml @@ -155,5 +155,5 @@ An optional `sites` block configures the deploy defaults (validated on its own, ## CI / agents - Pass `--force` on anything with a confirmation (publish, prune, remove, delete). -- Pass the site explicitly (or commit `bunny.jsonc` with `sites.name`) — the interactive picker is disabled under `--output json`. +- Pass the site explicitly (or commit `bunny.jsonc` with `sites.name`); the interactive picker is disabled under `--output json`. - `--output json` on every command emits machine-readable results (deploy prints `{ id, production, preview, promoted }`). From ff7821d61f9dbef12a06c85494dc617cae0948b5 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Wed, 15 Jul 2026 09:52:47 +0100 Subject: [PATCH 13/24] refactorings --- AGENTS.md | 10 ++--- packages/cli/src/commands/db/delete.ts | 27 +++--------- packages/cli/src/commands/scripts/delete.ts | 25 +++-------- packages/cli/src/commands/sites/api.ts | 44 +++++++------------ packages/cli/src/commands/sites/build.ts | 15 +------ .../cli/src/commands/sites/ci/frameworks.ts | 11 +++-- packages/cli/src/commands/sites/create.ts | 26 +++++------ packages/cli/src/commands/sites/delete.ts | 24 +++------- packages/cli/src/commands/sites/deploy.ts | 6 +-- .../src/commands/sites/deployments/prune.ts | 6 +-- .../cli/src/commands/sites/domains/index.ts | 9 ++-- packages/cli/src/commands/sites/provision.ts | 18 ++++++-- packages/cli/src/commands/sites/ssl.ts | 3 +- packages/cli/src/commands/storage/zone/add.ts | 3 +- packages/cli/src/core/errors.ts | 4 ++ packages/cli/src/core/hostnames/client.ts | 14 ++++-- packages/cli/src/core/hostnames/index.ts | 1 + packages/cli/src/core/ui.ts | 13 ++++++ 18 files changed, 112 insertions(+), 147 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4b4f0f25..4c337af2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -178,12 +178,12 @@ bunny-cli/ │ │ ├── define-namespace.ts # Namespace/group factory for subcommand trees │ │ ├── dns-nameservers.ts # BUNNY_NAMESERVERS + expectedNameservers(zone) + checkDelegation()/checkDelegations(): reads the parent zone's NS referral (raw UDP query of the registry, not the recursive answer a child host could spoof; falls back to dns.resolveNs when the referral is unreadable), matches the full expected set both ways, ground truth over bunny's NameserversDetected flag which defaults true on a fresh zone; checkDelegations is bounded-concurrency for the zone list │ │ ├── dns-record-types.ts # Canonical DNS record-type name⇄integer map (RECORD_TYPES) + RECORD_TYPE_META (dashboard taxonomy: short label, friendly name, Standard/Bunny group) + recordTypeLabel() (bunny's canonical labels: PZ/RDR/SCR/Flatten for the bunny-specific types) + recordTypeFromLabel() (parses canonical labels and enum-key names); shared by commands/dns + core/hostnames -│ │ ├── errors.ts # Re-exports UserError/ApiError from @bunny.net/openapi-client + ConfigError +│ │ ├── errors.ts # Re-exports UserError/ApiError from @bunny.net/openapi-client + ConfigError + errorMessage() │ │ ├── format.ts # Shared table/key-value rendering (text, table, csv, markdown) │ │ ├── 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) + Hostname/ResolvedPullZone types +│ │ │ ├── client.ts # hostnameUrl(), normalizeHostname(), addHostname(), fetchPullZoneHostnames(), enableSsl(), createPullZone() (storage-zone origin), systemHostname() + Hostname/ResolvedPullZone types │ │ │ ├── client.test.ts # Tests for hostnameUrl() scheme logic │ │ │ ├── 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 @@ -198,7 +198,7 @@ bunny-cli/ │ │ ├── stats.ts # Shared stats rendering: sumChart(), renderBarChart(), formatBucketLabel() (UTC date labels), BAR_WIDTH (used by dns/zone/stats + scripts/stats) │ │ ├── stats.test.ts # Tests for stats helpers │ │ ├── types.ts # GlobalArgs, OutputFormat, and shared type definitions -│ │ ├── ui.ts # readPassword(), confirm(), spinner() wrappers +│ │ ├── ui.ts # readPassword(), confirm(), confirmTyped(), spinner() wrappers │ │ └── version.ts # VERSION constant from package.json │ │ │ ├── config/ @@ -380,14 +380,14 @@ bunny-cli/ │ │ │ ├── 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), 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), promoteDeploy (env var PUT + purgeCache POST), 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 → .bunny/site.json → bunny.jsonc sites.name → picker (offerLink like scripts); optional offerCreate (deploy only) adds a new-vs-existing prompt, and creates straight away when the account has no sites; siteOptionBuilder (--site) + sitePositionalBuilder ([site]) -│ │ │ ├── provision.ts # promptSiteName (normalize/validate, directory-name suggestion; shared with create.ts) + createLinkedSite (createSite + manifest link → SiteContext, skipping create's domain/CI prompts) for the deploy picker's new-site branch +│ │ │ ├── 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: lenient bunny.jsonc reader; validates ONLY the `sites` block (SiteConfigSchema from @bunny.net/app-config), so sites-only configs work without an `app` block │ │ │ ├── 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, trailing-slash → index.html. 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 │ │ │ ├── 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 │ │ │ ├── uploader.test.ts # Walk/skip/hash tests + upload paths/checksums/retry via siteFiles swap -│ │ │ ├── build.ts # parseEnvAssignments/parseEnvFile + runBuildCommand (Bun.spawn shell, caller env + overrides, throws on non-zero exit) +│ │ │ ├── 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] (prompted, 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) diff --git a/packages/cli/src/commands/db/delete.ts b/packages/cli/src/commands/db/delete.ts index e1f2db6c..c6b90cbf 100644 --- a/packages/cli/src/commands/db/delete.ts +++ b/packages/cli/src/commands/db/delete.ts @@ -1,11 +1,10 @@ import { createDbClient } 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 { logger } from "../../core/logger.ts"; import { loadManifest, removeManifest } from "../../core/manifest.ts"; -import { confirm, spinner } from "../../core/ui.ts"; +import { confirm, confirmTyped, spinner } from "../../core/ui.ts"; import { readEnvValue, removeEnvValue } from "../../utils/env-file.ts"; import { fetchDatabase } from "./api.ts"; import { @@ -105,31 +104,17 @@ export const dbDeleteCommand = defineCommand({ ); } - // First confirmation - const confirmed = await confirm( - `Delete database "${db.name}" (${databaseId})? This cannot be undone.`, - { force }, - ); + const confirmed = + (await confirm( + `Delete database "${db.name}" (${databaseId})? This cannot be undone.`, + { force }, + )) && (await confirmTyped(db.name, { force })); if (!confirmed) { logger.log("Cancelled."); return; } - // Second confirmation: type the database name - if (!force) { - const { value } = await prompts({ - type: "text", - name: "value", - message: `Type "${db.name}" to confirm:`, - }); - - if (value !== db.name) { - logger.log("Cancelled."); - return; - } - } - const deleteSpin = spinner("Deleting database..."); deleteSpin.start(); diff --git a/packages/cli/src/commands/scripts/delete.ts b/packages/cli/src/commands/scripts/delete.ts index 127c627b..dddfa309 100644 --- a/packages/cli/src/commands/scripts/delete.ts +++ b/packages/cli/src/commands/scripts/delete.ts @@ -1,12 +1,11 @@ import { createComputeClient } from "@bunny.net/openapi-client"; import type { components } from "@bunny.net/openapi-client/generated/compute.d.ts"; -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 { logger } from "../../core/logger.ts"; import { resolveManifestId } from "../../core/manifest.ts"; -import { confirm, spinner } from "../../core/ui.ts"; +import { confirm, confirmTyped, spinner } from "../../core/ui.ts"; import { fetchScript } from "./api.ts"; import { SCRIPT_MANIFEST } from "./constants.ts"; @@ -89,29 +88,17 @@ export const scriptsDeleteCommand = defineCommand({ fetchSpin.stop(); - const confirmed = await confirm( - `Delete Edge Script "${script.Name}" (${id})? This cannot be undone.`, - { force }, - ); + const confirmed = + (await confirm( + `Delete Edge Script "${script.Name}" (${id})? This cannot be undone.`, + { force }, + )) && (await confirmTyped(script.Name ?? "", { force })); if (!confirmed) { logger.log("Cancelled."); return; } - if (!force) { - const { value } = await prompts({ - type: "text", - name: "value", - message: `Type "${script.Name}" to confirm:`, - }); - - if (value !== script.Name) { - logger.log("Cancelled."); - return; - } - } - const deleteSpin = spinner("Deleting Edge Script..."); deleteSpin.start(); diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 77af0033..477f0fc0 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -1,8 +1,12 @@ import type { createComputeClient } from "@bunny.net/openapi-client"; import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; import { mapWithConcurrency } from "../../core/concurrency.ts"; -import { ApiError, UserError } from "../../core/errors.ts"; -import { createPullZone, setForceSsl } from "../../core/hostnames/index.ts"; +import { ApiError, errorMessage, UserError } from "../../core/errors.ts"; +import { + createPullZone, + setForceSsl, + systemHostname, +} from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; import { fetchScripts } from "../scripts/api.ts"; import { SCRIPT_TYPE_MIDDLEWARE } from "../scripts/constants.ts"; @@ -74,7 +78,6 @@ async function downloadText( const { stream } = await siteFiles.download(connection, path); return await new Response(stream).text(); } catch (err) { - // A missing file reads as "no data"; other errors propagate so a bad read can't clobber live state. if (isNotFoundError(err)) return null; throw err; } @@ -196,9 +199,7 @@ export async function fetchSites(client: CoreClient): Promise { return { state: context.state, storageZone: zone, - systemHostname: - (pz.Hostnames ?? []).find((h) => h.IsSystemHostname)?.Value ?? - undefined, + systemHostname: systemHostname(pz.Hostnames), }; } catch { return null; @@ -369,15 +370,13 @@ export async function createSite( }); // Force HTTPS on the .b-cdn.net system host (already on bunny's wildcard cert, so this just redirects HTTP); best-effort. - const systemHostname = (pullZone.Hostnames ?? []).find( - (h) => h.IsSystemHostname, - )?.Value; - if (systemHostname) { + const systemHost = systemHostname(pullZone.Hostnames); + if (systemHost) { try { - await setForceSsl(coreClient, pullZone.Id, systemHostname, true); + await setForceSsl(coreClient, pullZone.Id, systemHost, true); } catch (err) { logger.warn( - `Couldn't force HTTPS on ${systemHostname}: ${err instanceof Error ? err.message : err}`, + `Couldn't force HTTPS on ${systemHost}: ${errorMessage(err)}`, ); } } @@ -398,9 +397,7 @@ export async function createSite( return { state, storageZone, - systemHostname: - (pullZone.Hostnames ?? []).find((h) => h.IsSystemHostname)?.Value ?? - undefined, + systemHostname: systemHostname(pullZone.Hostnames), reused, }; } @@ -414,10 +411,7 @@ export async function fetchSystemHostname( const { data } = await coreClient.GET("/pullzone/{id}", { params: { path: { id: pullZoneId } }, }); - return ( - (data?.Hostnames ?? []).find((h) => h.IsSystemHostname)?.Value ?? - undefined - ); + return systemHostname(data?.Hostnames); } catch { return undefined; } @@ -525,16 +519,10 @@ export async function deleteSiteResources(opts: { await fn(); results.push({ resource, id, deleted: true }); } catch (err) { - results.push({ - resource, - id, - deleted: false, - error: err instanceof Error ? err.message : String(err), - }); + results.push({ resource, id, deleted: false, error: errorMessage(err) }); } }; - // The pull zone references both the script and the storage zone, so it goes first. await attempt("pull zone", state.pullZoneId, () => coreClient.DELETE("/pullzone/{id}", { params: { path: { id: state.pullZoneId } }, @@ -552,9 +540,7 @@ export async function deleteSiteResources(opts: { await siteFiles.remove(opts.connection, REMOTE_STATE_PATH); } catch (err) { logger.warn( - `Kept the storage zone but couldn't remove its site marker (${REMOTE_STATE_PATH}): ${ - err instanceof Error ? err.message : String(err) - }`, + `Kept the storage zone but couldn't remove its site marker (${REMOTE_STATE_PATH}): ${errorMessage(err)}`, ); } } diff --git a/packages/cli/src/commands/sites/build.ts b/packages/cli/src/commands/sites/build.ts index 4952edf1..3c1fb4eb 100644 --- a/packages/cli/src/commands/sites/build.ts +++ b/packages/cli/src/commands/sites/build.ts @@ -1,10 +1,10 @@ -import { join } from "node:path"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; import { detectFramework, detectPackageManager, presetBuildCommand, + readPackageJson, } from "./ci/frameworks.ts"; export interface AutoBuild { @@ -34,19 +34,6 @@ export async function resolveAutoBuild( return null; } -async function readPackageJson( - root: string, -): Promise | null> { - try { - return (await Bun.file(join(root, "package.json")).json()) as Record< - string, - unknown - >; - } catch { - return null; - } -} - // Run the build command in a shell with the merged environment, streaming output; throws on non-zero exit so a broken build never deploys. export async function runBuildCommand( command: string, diff --git a/packages/cli/src/commands/sites/ci/frameworks.ts b/packages/cli/src/commands/sites/ci/frameworks.ts index ae27d012..ea509e44 100644 --- a/packages/cli/src/commands/sites/ci/frameworks.ts +++ b/packages/cli/src/commands/sites/ci/frameworks.ts @@ -190,9 +190,14 @@ const JS_DETECTORS: Array<[dependency: string, presetId: string]> = [ ["vite", "vite"], ]; -async function readJson(path: string): Promise | null> { +export async function readPackageJson( + root: string, +): Promise | null> { try { - return (await Bun.file(path).json()) as Record; + return (await Bun.file(join(root, "package.json")).json()) as Record< + string, + unknown + >; } catch { return null; } @@ -219,7 +224,7 @@ async function exists(path: string): Promise { export async function detectFramework( root: string, ): Promise { - const pkg = await readJson(join(root, "package.json")); + const pkg = await readPackageJson(root); if (pkg) { const deps = { ...(pkg.dependencies as Record | undefined), diff --git a/packages/cli/src/commands/sites/create.ts b/packages/cli/src/commands/sites/create.ts index 2664c695..81955cd4 100644 --- a/packages/cli/src/commands/sites/create.ts +++ b/packages/cli/src/commands/sites/create.ts @@ -6,13 +6,14 @@ 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 { errorMessage } from "../../core/errors.ts"; import { formatKeyValue } from "../../core/format.ts"; import { normalizeHostname } from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; import { saveManifest } from "../../core/manifest.ts"; -import { confirm, isInteractive, withSpinner } from "../../core/ui.ts"; +import { confirm, isInteractive } from "../../core/ui.ts"; import type { CoreClient, StorageZoneModel } from "../storage/api.ts"; -import { createSite, siteContextFromZone } from "./api.ts"; +import { siteContextFromZone } from "./api.ts"; import { gitTopLevel, hasGitHubOrigin, @@ -22,7 +23,7 @@ import { } from "./ci/scaffold.ts"; import { SITES_MANIFEST, type SiteManifest } from "./constants.ts"; import { setupSiteDomain } from "./domains/index.ts"; -import { promptSiteName } from "./provision.ts"; +import { createSiteWithProgress, promptSiteName } from "./provision.ts"; interface CreateArgs { name?: string; @@ -53,7 +54,7 @@ async function attachDomainToCreatedSite(opts: { }); return undefined; } catch (err) { - return err instanceof Error ? err.message : String(err); + return errorMessage(err); } } @@ -107,17 +108,12 @@ export const sitesCreateCommand = defineCommand({ const coreClient = createCoreClient(options); const computeClient = createComputeClient(options); - const result = await withSpinner(`Creating site "${name}"...`, (spin) => - createSite({ - coreClient, - computeClient, - name, - region: (args.region ?? "DE").toUpperCase(), - onStep: (message) => { - spin.text = message; - }, - }), - ); + const result = await createSiteWithProgress({ + coreClient, + computeClient, + name, + region: args.region, + }); if (args.link !== false) { saveManifest(SITES_MANIFEST, { diff --git a/packages/cli/src/commands/sites/delete.ts b/packages/cli/src/commands/sites/delete.ts index 14d06142..ac42e96a 100644 --- a/packages/cli/src/commands/sites/delete.ts +++ b/packages/cli/src/commands/sites/delete.ts @@ -2,13 +2,12 @@ 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 { logger } from "../../core/logger.ts"; import { loadManifest, removeManifest } from "../../core/manifest.ts"; -import { confirm, withSpinner } from "../../core/ui.ts"; +import { confirm, confirmTyped, withSpinner } from "../../core/ui.ts"; import { deleteSiteResources } from "./api.ts"; import { SITES_MANIFEST, type SiteManifest } from "./constants.ts"; import { @@ -66,27 +65,16 @@ export const sitesDeleteCommand = defineCommand({ const what = args["keep-storage"] ? "its pull zone and router" : "its pull zone, router, and ALL deploy files"; - const confirmed = await confirm( - `Delete site "${state.name}" (${what})? This cannot be undone.`, - { force }, - ); + const confirmed = + (await confirm( + `Delete site "${state.name}" (${what})? This cannot be undone.`, + { force }, + )) && (await confirmTyped(state.name, { force })); if (!confirmed) { logger.log("Cancelled."); return; } - if (!force) { - const { value } = await prompts({ - type: "text", - name: "value", - message: `Type "${state.name}" to confirm:`, - }); - if (value !== state.name) { - logger.log("Cancelled."); - return; - } - } - const results = await withSpinner("Deleting site resources...", () => deleteSiteResources({ coreClient, diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index e19e90c2..4e379b33 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -196,12 +196,12 @@ export const sitesDeployCommand = defineCommand({ const identity = await resolveDeployIdentity(dir, files); // The no-op check keys on content, not the display id, so a rebuilt `dist/` at the same git sha isn't wrongly skipped. - const existing = args.force + const alreadyUploaded = args.force ? undefined : state.deploys.find((d) => d.contentHash === identity.contentHash); - const skipUpload = existing !== undefined; + const skipUpload = alreadyUploaded !== undefined; // A skipped deploy reuses the already-uploaded deploy's id; that's where its files live. - const deployId = existing?.id ?? identity.id; + const deployId = alreadyUploaded?.id ?? identity.id; const alreadyLive = state.current === deployId; // Nothing to do: the deploy is already uploaded (and live, if --production). diff --git a/packages/cli/src/commands/sites/deployments/prune.ts b/packages/cli/src/commands/sites/deployments/prune.ts index a2afd710..e5b74299 100644 --- a/packages/cli/src/commands/sites/deployments/prune.ts +++ b/packages/cli/src/commands/sites/deployments/prune.ts @@ -2,6 +2,7 @@ import { createCoreClient } from "@bunny.net/openapi-client"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; +import { errorMessage } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; import { confirm, withSpinner } from "../../../core/ui.ts"; import { deleteDeployFiles, writeRemoteState } from "../api.ts"; @@ -100,10 +101,7 @@ export const sitesDeploymentsPruneCommand = defineCommand({ await deleteDeployFiles(connection, victim.id); pruned.add(victim.id); } catch (err) { - failures.push({ - id: victim.id, - error: err instanceof Error ? err.message : String(err), - }); + failures.push({ id: victim.id, error: errorMessage(err) }); } } // Only forget deploys whose files are actually gone. diff --git a/packages/cli/src/commands/sites/domains/index.ts b/packages/cli/src/commands/sites/domains/index.ts index 37019b37..fb704b64 100644 --- a/packages/cli/src/commands/sites/domains/index.ts +++ b/packages/cli/src/commands/sites/domains/index.ts @@ -1,6 +1,7 @@ import { createCoreClient } from "@bunny.net/openapi-client"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; +import { errorMessage } from "../../../core/errors.ts"; import { addHostname, type CoreClient, @@ -51,9 +52,7 @@ async function recordSiteDomain( site.state.domain = domain; site.etag = await writeRemoteState(site.connection, site.state, site.etag); } catch (err) { - logger.warn( - `Couldn't update the site state: ${err instanceof Error ? err.message : err}`, - ); + logger.warn(`Couldn't update the site state: ${errorMessage(err)}`); } } @@ -96,9 +95,7 @@ export async function attachPreviewWildcard(opts: { } } catch (err) { if (!opts.json) { - logger.warn( - `Couldn't add ${wildcard}: ${err instanceof Error ? err.message : err}`, - ); + logger.warn(`Couldn't add ${wildcard}: ${errorMessage(err)}`); logger.dim(" Previews will use /deploys// paths until it's added."); } } diff --git a/packages/cli/src/commands/sites/provision.ts b/packages/cli/src/commands/sites/provision.ts index f1d250fa..ddcf5c05 100644 --- a/packages/cli/src/commands/sites/provision.ts +++ b/packages/cli/src/commands/sites/provision.ts @@ -7,6 +7,7 @@ import { withSpinner } from "../../core/ui.ts"; import type { CoreClient } from "../storage/api.ts"; import { type ComputeClient, + type CreateSiteResult, createSite, type SiteContext, siteContextFromZone, @@ -60,14 +61,14 @@ export async function promptSiteName( return name; } -// Create a site and link this directory to it (the provisioning-only path behind the deploy picker's "new site" branch); skips the custom-domain and CI prompts `sites create` adds. -export async function createLinkedSite(opts: { +/** Run {@link createSite} under a spinner whose text tracks each provisioning step. */ +export async function createSiteWithProgress(opts: { coreClient: CoreClient; computeClient: ComputeClient; name: string; region?: string; -}): Promise { - const result = await withSpinner(`Creating site "${opts.name}"...`, (spin) => +}): Promise { + return withSpinner(`Creating site "${opts.name}"...`, (spin) => createSite({ coreClient: opts.coreClient, computeClient: opts.computeClient, @@ -78,6 +79,15 @@ export async function createLinkedSite(opts: { }, }), ); +} + +export async function createLinkedSite(opts: { + coreClient: CoreClient; + computeClient: ComputeClient; + name: string; + region?: string; +}): Promise { + const result = await createSiteWithProgress(opts); saveManifest(SITES_MANIFEST, { id: result.state.storageZoneId, diff --git a/packages/cli/src/commands/sites/ssl.ts b/packages/cli/src/commands/sites/ssl.ts index 06871ce4..0b6dcd80 100644 --- a/packages/cli/src/commands/sites/ssl.ts +++ b/packages/cli/src/commands/sites/ssl.ts @@ -6,6 +6,7 @@ import { UserError } from "../../core/errors.ts"; import { fetchPullZoneHostnames, setForceSsl, + systemHostname, } from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; import { withSpinner } from "../../core/ui.ts"; @@ -51,7 +52,7 @@ export const sitesSslCommand = defineCommand({ const systemHost = await withSpinner("Updating Force SSL...", async () => { const hostnames = await fetchPullZoneHostnames(client, state.pullZoneId); - const host = hostnames.find((h) => h.IsSystemHostname)?.Value; + const host = systemHostname(hostnames); if (!host) { throw new UserError( `Couldn't find the system hostname for "${state.name}".`, diff --git a/packages/cli/src/commands/storage/zone/add.ts b/packages/cli/src/commands/storage/zone/add.ts index 52db80ca..ae0b181e 100644 --- a/packages/cli/src/commands/storage/zone/add.ts +++ b/packages/cli/src/commands/storage/zone/add.ts @@ -9,6 +9,7 @@ import { createPullZone, normalizeHostname, setupHostname, + systemHostname, } from "../../../core/hostnames/index.ts"; import { logger } from "../../../core/logger.ts"; import { confirm, isInteractive, spinner } from "../../../core/ui.ts"; @@ -211,7 +212,7 @@ export const storageZoneAddCommand = defineCommand({ } finally { pzSpin.stop(); } - const host = (pz?.Hostnames ?? []).find((h) => h.IsSystemHostname)?.Value; + const host = systemHostname(pz?.Hostnames); pullZoneResult = { id: pz?.Id, name: pz?.Name, diff --git a/packages/cli/src/core/errors.ts b/packages/cli/src/core/errors.ts index 8f95f921..43a0e69e 100644 --- a/packages/cli/src/core/errors.ts +++ b/packages/cli/src/core/errors.ts @@ -12,3 +12,7 @@ export class ConfigError extends UserError { this.name = "ConfigError"; } } + +export function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/packages/cli/src/core/hostnames/client.ts b/packages/cli/src/core/hostnames/client.ts index d63e01d6..c75315f6 100644 --- a/packages/cli/src/core/hostnames/client.ts +++ b/packages/cli/src/core/hostnames/client.ts @@ -91,7 +91,15 @@ export async function fetchHostnamesForZones( return results.flat(); } -/** Pick the system-preferred live URL plus any custom-domain URLs from a hostname list. */ +export function systemHostname( + hostnames: + | Array> + | null + | undefined, +): string | undefined { + return hostnames?.find((h) => h.IsSystemHostname)?.Value ?? undefined; +} + export function liveHostnames(hostnames: Hostname[]): { primary?: string; customs: string[]; @@ -145,9 +153,7 @@ export async function addHostname( body: { Hostname: hostname }, }); const hostnames = await fetchPullZoneHostnames(client, pullZoneId); - const cnameTarget = hostnames - .find((h) => h.IsSystemHostname) - ?.Value?.replace(/^https?:\/\//i, ""); + const cnameTarget = systemHostname(hostnames)?.replace(/^https?:\/\//i, ""); return { hostnames, cnameTarget }; } diff --git a/packages/cli/src/core/hostnames/index.ts b/packages/cli/src/core/hostnames/index.ts index e2156c6d..90f414ad 100644 --- a/packages/cli/src/core/hostnames/index.ts +++ b/packages/cli/src/core/hostnames/index.ts @@ -18,6 +18,7 @@ export { type ResolvedPullZone, type SafeHostname, setForceSsl, + systemHostname, toSafeHostname, } from "./client.ts"; export { diff --git a/packages/cli/src/core/ui.ts b/packages/cli/src/core/ui.ts index 73f0299b..d2e7534e 100644 --- a/packages/cli/src/core/ui.ts +++ b/packages/cli/src/core/ui.ts @@ -37,6 +37,19 @@ export async function confirm( return confirmed ?? false; } +export async function confirmTyped( + expected: string, + opts?: { force?: boolean }, +): Promise { + if (opts?.force) return true; + const { value } = await prompts({ + type: "text", + name: "value", + message: `Type "${expected}" to confirm:`, + }); + return value === expected; +} + export function isInteractive(output?: string): boolean { return ( output !== "json" && From 343b1aa8a0a230ad6af62325627aca1cb16ca169 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Wed, 15 Jul 2026 10:36:58 +0100 Subject: [PATCH 14/24] fix(sites): pass gh secret via stdin, strip client x-bunny-preview header; sites changesets to patch --- .changeset/app-config-sites-block.md | 2 +- .changeset/sites-namespace.md | 2 +- AGENTS.md | 2 +- packages/cli/src/commands/sites/ci/scaffold.ts | 16 +++++++--------- .../cli/src/commands/sites/router/source.test.ts | 2 ++ packages/cli/src/commands/sites/router/source.ts | 10 ++++++---- 6 files changed, 18 insertions(+), 16 deletions(-) diff --git a/.changeset/app-config-sites-block.md b/.changeset/app-config-sites-block.md index 89053161..493966e0 100644 --- a/.changeset/app-config-sites-block.md +++ b/.changeset/app-config-sites-block.md @@ -1,5 +1,5 @@ --- -"@bunny.net/app-config": minor +"@bunny.net/app-config": patch --- feat: optional top-level `sites` block (`name`, `dir`, `build`) in the bunny.jsonc schema, consumed by `bunny sites deploy` for the default deploy directory and build command. Exported as `SiteConfigSchema`/`SiteConfig`; the generated JSON Schema includes the new block. diff --git a/.changeset/sites-namespace.md b/.changeset/sites-namespace.md index 2c774ac0..1ad4883a 100644 --- a/.changeset/sites-namespace.md +++ b/.changeset/sites-namespace.md @@ -1,5 +1,5 @@ --- -"@bunny.net/cli": minor +"@bunny.net/cli": patch --- feat(sites): new `bunny sites` namespace for static-site hosting. `sites create` provisions a storage zone + pull zone + middleware router per site, prompting for the name (directory-name suggestion) and a custom domain when run interactively (Bunny DNS record, nameserver guidance, DNS wait + SSL); `sites deploy` uploads immutable deploys (git-sha or content-hash IDs, no-op when unchanged) to preview URLs, and `--production`/`--prod` publishes the live site by flipping the router's `CURRENT_DEPLOY` env var + purging the cache; `sites deployments list/publish/prune` cover rollback and cleanup; `sites domains` attaches custom domains plus a `*.preview.` wildcard for per-deploy preview URLs; `sites ci init` (also offered by `sites create` on GitHub repos) scaffolds a GitHub Actions workflow with framework detection: previews on PRs, production on merges to main; `sites link/unlink/show/upgrade/delete` round out the lifecycle. Concurrent deploys merge remote state records instead of overwriting. Site state lives at `_bunny/site.json` inside the storage zone (403-blocked by the router). The shared hostnames factory gains optional `onAdded`/`onRemoved` hooks. diff --git a/AGENTS.md b/AGENTS.md index 097e78c9..50ebcbdf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -382,7 +382,7 @@ bunny-cli/ │ │ │ ├── interactive.ts # selectSite: explicit ref → .bunny/site.json → bunny.jsonc sites.name → picker (offerLink like scripts); optional offerCreate (deploy only) adds a new-vs-existing prompt, and creates straight away when the account has no sites; siteOptionBuilder (--site) + sitePositionalBuilder ([site]) │ │ │ ├── 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: lenient bunny.jsonc reader; validates ONLY the `sites` block (SiteConfigSchema from @bunny.net/app-config), so sites-only configs work without an `app` block -│ │ │ ├── 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, trailing-slash → index.html. 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, /deploys/{id}/ passthrough (path preview) flagged with x-bunny-preview header, /_bunny/* → 403 (client-sent x-bunny-preview headers are stripped; the flag is router-internal), trailing-slash → index.html. 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 │ │ │ ├── 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 diff --git a/packages/cli/src/commands/sites/ci/scaffold.ts b/packages/cli/src/commands/sites/ci/scaffold.ts index 60e8a60e..766acbd2 100644 --- a/packages/cli/src/commands/sites/ci/scaffold.ts +++ b/packages/cli/src/commands/sites/ci/scaffold.ts @@ -146,15 +146,13 @@ export async function offerGitHubSecret(opts: { { initial: true }, ); if (proceed) { - const proc = Bun.spawn( - [gh, "secret", "set", "BUNNY_API_KEY", "--body", opts.apiKey], - { - cwd: opts.root, - stdout: "ignore", - stderr: "pipe", - stdin: "ignore", - }, - ); + // The key goes via stdin, never argv: process arguments are visible in `ps`. + const proc = Bun.spawn([gh, "secret", "set", "BUNNY_API_KEY"], { + cwd: opts.root, + stdout: "ignore", + stderr: "pipe", + stdin: new Blob([opts.apiKey]), + }); const [code, err] = await Promise.all([ proc.exited, new Response(proc.stderr).text(), diff --git a/packages/cli/src/commands/sites/router/source.test.ts b/packages/cli/src/commands/sites/router/source.test.ts index 261f6e0c..8a3ea1e2 100644 --- a/packages/cli/src/commands/sites/router/source.test.ts +++ b/packages/cli/src/commands/sites/router/source.test.ts @@ -9,6 +9,8 @@ test("routerSource wires up the preview machinery", () => { expect(src).toContain('url.pathname = "/deploys/" + deploy + target;'); // Flags previews so the response phase rewrites their HTML (and never production's). expect(src).toContain('const PREVIEW_HEADER = "x-bunny-preview";'); + // Client-sent preview flags must be stripped, or they'd poison cached production HTML. + expect(src).toContain("headers.delete(PREVIEW_HEADER);"); expect(src).toContain("new HTMLRewriter()"); expect(src).toContain("X-Robots-Tag"); }); diff --git a/packages/cli/src/commands/sites/router/source.ts b/packages/cli/src/commands/sites/router/source.ts index dfa15d36..aa6a920a 100644 --- a/packages/cli/src/commands/sites/router/source.ts +++ b/packages/cli/src/commands/sites/router/source.ts @@ -59,12 +59,14 @@ BunnySDK.net.http return new Response("Forbidden", { status: 403 }); } + // The preview flag is router-internal: a client-sent one is stripped, or it would poison cached production HTML with preview rewrites. + const headers = new Headers(ctx.request.headers); + headers.delete(PREVIEW_HEADER); + // Path preview: flag the request so the response phase rewrites its HTML for this deploy. if (path === "/deploys" || path.startsWith("/deploys/")) { const match = DEPLOY_PATH.exec(path); - if (!match) return; - const headers = new Headers(ctx.request.headers); - headers.set(PREVIEW_HEADER, match[1].toLowerCase()); + if (match) headers.set(PREVIEW_HEADER, match[1].toLowerCase()); return new Request(ctx.request, { headers }); } @@ -83,7 +85,7 @@ BunnySDK.net.http let target = path; if (target.endsWith("/")) target += "index.html"; url.pathname = "/deploys/" + deploy + target; - return new Request(url.toString(), ctx.request); + return new Request(new Request(url.toString(), ctx.request), { headers }); }) .onOriginResponse(async (ctx) => { const previewId = ctx.request.headers.get(PREVIEW_HEADER); From 6d1de462545daaf97ea32e2cbe3d7b010dea4f29 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Wed, 15 Jul 2026 11:08:03 +0100 Subject: [PATCH 15/24] fix(sites): expand trailing-slash URLs to index.html for path previews too --- packages/cli/src/commands/sites/router/source.test.ts | 4 +++- packages/cli/src/commands/sites/router/source.ts | 10 +++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/sites/router/source.test.ts b/packages/cli/src/commands/sites/router/source.test.ts index 8a3ea1e2..6751ca88 100644 --- a/packages/cli/src/commands/sites/router/source.test.ts +++ b/packages/cli/src/commands/sites/router/source.test.ts @@ -6,7 +6,9 @@ test("routerSource wires up the preview machinery", () => { expect(src).toContain("bunny sites router"); // Serves the promoted deploy at the apex and per-deploy path previews. expect(src).toContain("process.env.CURRENT_DEPLOY"); - expect(src).toContain('url.pathname = "/deploys/" + deploy + target;'); + expect(src).toContain('url.pathname = "/deploys/" + deploy + path;'); + // Directory URLs expand to index.html before any branching, so path previews get it too. + 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";'); // Client-sent preview flags must be stripped, or they'd poison cached production HTML. diff --git a/packages/cli/src/commands/sites/router/source.ts b/packages/cli/src/commands/sites/router/source.ts index aa6a920a..8436540c 100644 --- a/packages/cli/src/commands/sites/router/source.ts +++ b/packages/cli/src/commands/sites/router/source.ts @@ -52,6 +52,8 @@ 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. + if (url.pathname.endsWith("/")) url.pathname += "index.html"; const path = url.pathname; // Internal site metadata (state, env) is never served. @@ -63,11 +65,11 @@ BunnySDK.net.http const headers = new Headers(ctx.request.headers); headers.delete(PREVIEW_HEADER); - // Path preview: flag the request so the response phase rewrites its HTML for this deploy. + // 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(ctx.request, { headers }); + return new Request(new Request(url.toString(), ctx.request), { headers }); } const preview = PREVIEW_HOST.exec(url.hostname); @@ -82,9 +84,7 @@ BunnySDK.net.http }); } - let target = path; - if (target.endsWith("/")) target += "index.html"; - url.pathname = "/deploys/" + deploy + target; + url.pathname = "/deploys/" + deploy + path; return new Request(new Request(url.toString(), ctx.request), { headers }); }) .onOriginResponse(async (ctx) => { From 2defb06b4027e19b1381db1b1ccac7ddd9611f4a Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Wed, 15 Jul 2026 12:58:12 +0100 Subject: [PATCH 16/24] fmt --- packages/cli/src/commands/sites/router/source.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/sites/router/source.test.ts b/packages/cli/src/commands/sites/router/source.test.ts index 6751ca88..0920bb75 100644 --- a/packages/cli/src/commands/sites/router/source.test.ts +++ b/packages/cli/src/commands/sites/router/source.test.ts @@ -8,7 +8,9 @@ test("routerSource wires up the preview machinery", () => { expect(src).toContain("process.env.CURRENT_DEPLOY"); expect(src).toContain('url.pathname = "/deploys/" + deploy + path;'); // Directory URLs expand to index.html before any branching, so path previews get it too. - expect(src).toContain('if (url.pathname.endsWith("/")) url.pathname += "index.html";'); + 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";'); // Client-sent preview flags must be stripped, or they'd poison cached production HTML. From fe8b5579e143dc11bfa83ae9f34780619b73f087 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Wed, 15 Jul 2026 15:04:38 +0100 Subject: [PATCH 17/24] fix(sites): reconcile current/previous pointers on concurrent state writes --- AGENTS.md | 2 +- packages/cli/src/commands/sites/api.test.ts | 38 ++++++++++++++++++- packages/cli/src/commands/sites/api.ts | 17 ++++++++- packages/cli/src/commands/sites/deploy.ts | 4 +- .../src/commands/sites/deployments/publish.ts | 4 +- 5 files changed, 60 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 50ebcbdf..37fa6b5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -377,7 +377,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 │ │ │ ├── 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), 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), 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), promoteDeploy (env var PUT + purgeCache POST), 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 → .bunny/site.json → bunny.jsonc sites.name → picker (offerLink like scripts); optional offerCreate (deploy only) adds a new-vs-existing prompt, and creates straight away when the account has no sites; siteOptionBuilder (--site) + sitePositionalBuilder ([site]) │ │ │ ├── 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 diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index ea4fb122..523fe6b4 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -309,10 +309,46 @@ test("writeRemoteState merges concurrent deploy records on an etag mismatch", as connection, fakeState({ current: "aaa", deploys: [ours] }), etag, + { promotedTo: "aaa" }, ); const read = await readRemoteState(connection); - // Our promote wins, but their deploy record survives the race. + // Our promote wins, their deploy record survives, and their promote becomes the rollback target. expect(read?.state.current).toBe("aaa"); + expect(read?.state.previous).toBe("zzz"); + expect(read?.state.deploys.map((d) => d.id)).toEqual(["aaa", "zzz"]); +}); + +test("a non-promoting write adopts the concurrent writer's current/previous", async () => { + const connection = fakeConnection(); + const etag = await writeRemoteState(connection, fakeState()); + + // A concurrent promote lands between our read and write. + const theirs = { + id: "zzz", + createdAt: "2026-01-02T00:00:00.000Z", + source: "git" as const, + files: 1, + bytes: 10, + }; + store.set( + REMOTE_STATE_PATH, + JSON.stringify( + fakeState({ current: "zzz", previous: "yyy", deploys: [theirs] }), + ), + ); + + const ours = { + id: "aaa", + createdAt: "2026-01-03T00:00:00.000Z", + source: "git" as const, + files: 1, + bytes: 10, + }; + // A preview-only deploy: its stale in-memory pointers must not reverse the promote. + await writeRemoteState(connection, fakeState({ deploys: [ours] }), etag); + const read = await readRemoteState(connection); + expect(read?.state.current).toBe("zzz"); + expect(read?.state.previous).toBe("yyy"); expect(read?.state.deploys.map((d) => d.id)).toEqual(["aaa", "zzz"]); }); diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 477f0fc0..7d23859a 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -98,11 +98,15 @@ export async function readRemoteState( return { state, etag: sha256Hex(raw) }; } -// Write `_bunny/site.json` (returns the new etag). On an `expectedEtag` mismatch a parseable concurrent state has its deploys merged in (last promote wins); an unparseable one aborts rather than overwrite. +// Write `_bunny/site.json` (returns the new etag). On an `expectedEtag` mismatch a parseable concurrent state is reconciled: deploy records merge, and the current/previous pointers follow `promotedTo` (last promote wins; a non-promoting writer adopts the concurrent pointers rather than clobber them with its stale read). An unparseable conflict aborts rather than overwrite. export async function writeRemoteState( connection: StorageZone, state: RemoteSiteState, expectedEtag?: string, + opts?: { + /** Deploy this writer just promoted to production; omit when the write doesn't change `current`. */ + promotedTo?: string; + }, ): Promise { if (expectedEtag) { const current = await downloadText(connection, REMOTE_STATE_PATH); @@ -119,6 +123,17 @@ export async function writeRemoteState( ...state.deploys, ...remote.deploys.filter((d) => !ours.has(d.id)), ].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + if (opts?.promotedTo) { + // Our promote wins (it set CURRENT_DEPLOY last), and the concurrent writer's production deploy becomes the rollback target. + state.current = opts.promotedTo; + state.previous = + remote.current && remote.current !== opts.promotedTo + ? remote.current + : remote.previous; + } else { + state.current = remote.current; + state.previous = remote.previous; + } logger.warn( "Remote site state changed since it was read (concurrent deploy?): merged deploy records.", ); diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 4e379b33..5c49de40 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -277,7 +277,9 @@ export const sitesDeployCommand = defineCommand({ deployId, }); markCurrent(state, deployId); - etag = await writeRemoteState(connection, state, etag); + etag = await writeRemoteState(connection, state, etag, { + promotedTo: deployId, + }); }); } diff --git a/packages/cli/src/commands/sites/deployments/publish.ts b/packages/cli/src/commands/sites/deployments/publish.ts index ad21271e..de50eedd 100644 --- a/packages/cli/src/commands/sites/deployments/publish.ts +++ b/packages/cli/src/commands/sites/deployments/publish.ts @@ -129,7 +129,9 @@ export const sitesDeploymentsPublishCommand = defineCommand({ deployId: targetId, }); markCurrent(state, targetId); - await writeRemoteState(connection, state, etag); + await writeRemoteState(connection, state, etag, { + promotedTo: targetId, + }); }); if (output === "json") { From a067ab77942a41824717ca2a6b8d702519e9fae4 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Wed, 15 Jul 2026 15:27:46 +0100 Subject: [PATCH 18/24] fix(sites): infer the framework output dir for --build deploys without a dir --- AGENTS.md | 2 +- README.md | 2 +- packages/cli/src/commands/sites/deploy.ts | 7 ++++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 37fa6b5b..a8fcd66b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -394,7 +394,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 .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 (explicit --build, or an interactively-offered detected/configured build) → hash → no-op if unchanged → upload deploys/{id}/ → state update → immutable preview URL (custom-domain dpl-{id}.preview.* when a domain exists, else the .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 (explicit --build, or an interactively-offered detected/configured build; 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 .b-cdn.net/deploys/{id}/ path; rendered correctly by the router's HTMLRewriter), or publish live with --production/--prod │ │ │ ├── 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/README.md b/README.md index 3ea57196..40f78969 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ bun ny sites create my-site # provision a static site (storage z 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 (.b-cdn.net/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 --build # run `sites.build` from bunny.jsonc, then deploy `sites.dir` +bun ny sites deploy --build # run `sites.build` from bunny.jsonc, then deploy `sites.dir` (or the detected framework's 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) diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 5c49de40..a8350e94 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -19,6 +19,7 @@ import { writeRemoteState, } from "./api.ts"; import { resolveAutoBuild, runBuildCommand } from "./build.ts"; +import { detectFramework } from "./ci/frameworks.ts"; import { loadSiteConfig } from "./config.ts"; import { type DeployRecord, @@ -85,7 +86,7 @@ export const sitesDeployCommand = defineCommand({ yargs.positional("dir", { type: "string", describe: - "Directory to deploy (defaults to `sites.dir` in bunny.jsonc, then the current directory)", + "Directory to deploy (defaults to `sites.dir` in bunny.jsonc, then the detected framework's output dir when building, then the current directory)", }), ) .option("build", { @@ -154,6 +155,10 @@ export const sitesDeployCommand = defineCommand({ 'Pass one (`--build "npm run build"`) or set `sites.build` in bunny.jsonc.', ); } + // No dir given: target the detected framework's output dir, not the repo root the build ran in. + if (explicitDir === undefined) { + autoDir = (await detectFramework(root))?.dir; + } const overrides = await collectEnv(args.env, args["env-file"]); await runBuildCommand(command, root, overrides); } else if (isInteractive(output)) { From 0f6be4f714f229c05e53e3dd01ce449fe6408ad5 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 16 Jul 2026 14:46:48 +0100 Subject: [PATCH 19/24] prefixes --- .changeset/sites-namespace.md | 4 +- AGENTS.md | 16 +-- README.md | 6 +- packages/cli/src/commands/sites/api.test.ts | 136 ++++++++++++++++-- packages/cli/src/commands/sites/api.ts | 108 +++++++++----- .../cli/src/commands/sites/constants.test.ts | 17 +++ packages/cli/src/commands/sites/constants.ts | 32 ++++- packages/cli/src/commands/sites/create.ts | 7 +- .../cli/src/commands/sites/interactive.ts | 34 ++++- packages/cli/src/commands/sites/provision.ts | 2 +- skills/bunny-cli/references/sites.md | 6 +- 11 files changed, 289 insertions(+), 79 deletions(-) diff --git a/.changeset/sites-namespace.md b/.changeset/sites-namespace.md index 1ad4883a..10ee47f1 100644 --- a/.changeset/sites-namespace.md +++ b/.changeset/sites-namespace.md @@ -1,5 +1,5 @@ --- -"@bunny.net/cli": patch +"@bunny.net/cli": minor --- -feat(sites): new `bunny sites` namespace for static-site hosting. `sites create` provisions a storage zone + pull zone + middleware router per site, prompting for the name (directory-name suggestion) and a custom domain when run interactively (Bunny DNS record, nameserver guidance, DNS wait + SSL); `sites deploy` uploads immutable deploys (git-sha or content-hash IDs, no-op when unchanged) to preview URLs, and `--production`/`--prod` publishes the live site by flipping the router's `CURRENT_DEPLOY` env var + purging the cache; `sites deployments list/publish/prune` cover rollback and cleanup; `sites domains` attaches custom domains plus a `*.preview.` wildcard for per-deploy preview URLs; `sites ci init` (also offered by `sites create` on GitHub repos) scaffolds a GitHub Actions workflow with framework detection: previews on PRs, production on merges to main; `sites link/unlink/show/upgrade/delete` round out the lifecycle. Concurrent deploys merge remote state records instead of overwriting. Site state lives at `_bunny/site.json` inside the storage zone (403-blocked by the router). The shared hostnames factory gains optional `onAdded`/`onRemoved` hooks. +feat(sites): new `bunny sites` namespace for static-site hosting. `sites create` provisions a storage zone + pull zone + middleware router per site (zones are named `sites--` so globally-taken names can't block the create; commands take the clean site name), prompting for the name (directory-name suggestion) and a custom domain when run interactively (Bunny DNS record, nameserver guidance, DNS wait + SSL); `sites deploy` uploads immutable deploys (git-sha or content-hash IDs, no-op when unchanged) to preview URLs, and `--production`/`--prod` publishes the live site by flipping the router's `CURRENT_DEPLOY` env var + purging the cache; `sites deployments list/publish/prune` cover rollback and cleanup; `sites domains` attaches custom domains plus a `*.preview.` wildcard for per-deploy preview URLs; `sites ci init` (also offered by `sites create` on GitHub repos) scaffolds a GitHub Actions workflow with framework detection: previews on PRs, production on merges to main; `sites link/unlink/show/upgrade/delete` round out the lifecycle. Concurrent deploys merge remote state records instead of overwriting. Site state lives at `_bunny/site.json` inside the storage zone (403-blocked by the router). The shared hostnames factory gains optional `onAdded`/`onRemoved` hooks. diff --git a/AGENTS.md b/AGENTS.md index a8fcd66b..897cf4ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -375,11 +375,11 @@ bunny-cli/ │ │ │ └── remove.ts # Delete a file or directory (alias: rm; positional, --zone, trailing slash = recursive) │ │ ├── sites/ # Experimental (hidden from help and landing page): static-site hosting (storage zone + pull zone + middleware router) │ │ │ ├── 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 +│ │ │ ├── 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), 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), 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 → .bunny/site.json → bunny.jsonc sites.name → picker (offerLink like scripts); optional offerCreate (deploy only) adds a new-vs-existing prompt, and creates straight away when the account has no sites; siteOptionBuilder (--site) + sitePositionalBuilder ([site]) +│ │ │ ├── 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); optional offerCreate (deploy only) adds a new-vs-existing prompt, and creates straight away when the account has no sites; siteOptionBuilder (--site) + sitePositionalBuilder ([site]) │ │ │ ├── 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: lenient bunny.jsonc reader; validates ONLY the `sites` block (SiteConfigSchema from @bunny.net/app-config), so sites-only configs work without an `app` block │ │ │ ├── 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 headers are stripped; the flag is router-internal), trailing-slash → index.html. 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 @@ -393,8 +393,8 @@ bunny-cli/ │ │ │ ├── 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 -│ │ │ ├── ssl.ts # bunny sites ssl [site]: toggle Force HTTPS on the .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 (explicit --build, or an interactively-offered detected/configured build; 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 .b-cdn.net/deploys/{id}/ path; rendered correctly by the router's HTMLRewriter), or publish live with --production/--prod +│ │ │ ├── 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 (explicit --build, or an interactively-offered detected/configured build; 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 │ │ │ ├── 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) @@ -1093,14 +1093,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 (experimental; hidden from help and landing page) -│ │ Static-site hosting: one storage zone (files) + one pull zone (CDN) + one middleware router script per site. 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 `.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). +│ │ 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). │ ├── create [name] [--region] [--domain] [--link] │ │ Provision a site (idempotent; a failed create re-runs cleanly; each resource is looked up by name first). Interactive runs prompt for the name when omitted (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] 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] [--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 `.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. [dir] defaults to `sites.dir` in bunny.jsonc, then cwd (dotfiles + node_modules excluded). --build runs the command (or `sites.build`) 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 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. [dir] defaults to `sites.dir` in bunny.jsonc, then cwd (dotfiles + node_modules excluded). --build runs the command (or `sites.build`) 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. │ ├── deployments │ │ ├── list [site] (alias: ls) List deploys (● Live / ○ Previous markers, created, source, files, size) │ │ ├── publish [id] [--previous] [--site] [--force] (alias: promote) @@ -1111,7 +1111,7 @@ bunny │ │ ├── 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) -│ ├── ssl [site] [--no-force-ssl] Toggle Force HTTPS on the .b-cdn.net system host (no cert issued; custom domains use `sites domains ssl`) +│ ├── 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] [--framework] [--force] Write .github/workflows/bunny-sites.yml: 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` │ ├── link [site] Link this directory to a site → .bunny/site.json diff --git a/README.md b/README.md index 40f78969..7186afcd 100644 --- a/README.md +++ b/README.md @@ -55,16 +55,16 @@ bun ny dns records scan example.com # scan for the domain's existing rec bun ny dns records preset list # list DNS record presets (email providers, verification, security) bun ny dns records preset google-workspace example.com # apply a preset record set bun ny dns records preset bluesky example.com --param did=did:plc:abc123 # apply a preset non-interactively -bun ny sites create my-site # provision a static site (storage zone + pull zone + edge router) +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 (.b-cdn.net/deploys//); the router's HTMLRewriter keeps root-absolute assets working +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 --build # run `sites.build` from bunny.jsonc, then deploy `sites.dir` (or the detected framework's 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 ssl --no-force-ssl # stop forcing HTTPS on the .b-cdn.net system host +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) ``` diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index 523fe6b4..c0d5f52b 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -102,8 +102,12 @@ function fakeCoreClient(opts: { */ pullZoneEnvelope?: boolean; pageSize?: number; - /** Throw this from POST /storagezone or POST /pullzone (a taken name). */ - createError?: { path: "/storagezone" | "/pullzone"; error: ApiError }; + /** Throw this from POST /storagezone or POST /pullzone (a taken name); `times` bounds how often (default: always). */ + createError?: { + path: "/storagezone" | "/pullzone"; + error: ApiError; + times?: number; + }; }): CoreClient { const zones = opts.storageZones ?? []; const pullZones = opts.pullZones ?? []; @@ -157,7 +161,13 @@ function fakeCoreClient(opts: { body: options?.body, }); if (opts.createError && path === opts.createError.path) { - throw opts.createError.error; + if ( + opts.createError.times === undefined || + opts.createError.times > 0 + ) { + if (opts.createError.times !== undefined) opts.createError.times--; + throw opts.createError.error; + } } if (path === "/storagezone") { const zone = { @@ -372,13 +382,24 @@ test("createSite provisions storage zone → router → pull zone → state", as script: false, pullZone: false, }); - expect(result.systemHostname).toBe("my-site.b-cdn.net"); + + // Zone names are globally unique, so both carry a shared random suffix. + const zoneCreate = coreCalls.find( + (c) => c.method === "POST" && c.path === "/storagezone", + ); + const zoneName = (zoneCreate?.body as { Name: string }).Name; + expect(zoneName).toMatch(/^sites-my-site-[a-z0-9]{6}$/); + expect(result.systemHostname).toBe(`${zoneName}.b-cdn.net`); // The router script is uploaded, published, and gets CURRENT_DEPLOY="". const computePaths = computeCalls.map((c) => `${c.method} ${c.path}`); expect(computePaths).toContain("POST /compute/script"); expect(computePaths).toContain("POST /compute/script/{id}/code"); expect(computePaths).toContain("POST /compute/script/{id}/publish"); + const scriptCreate = computeCalls.find( + (c) => c.path === "/compute/script" && c.method === "POST", + ); + expect(scriptCreate?.body).toMatchObject({ Name: `${zoneName}-router` }); const envSet = computeCalls.find( (c) => c.path === "/compute/script/{id}/variables", ); @@ -390,7 +411,7 @@ test("createSite provisions storage zone → router → pull zone → state", as ); expect(pzCreates).toHaveLength(1); expect(pzCreates[0]?.body).toMatchObject({ - Name: "my-site", + Name: zoneName, StorageZoneId: 10, }); const attach = coreCalls.find( @@ -403,7 +424,7 @@ test("createSite provisions storage zone → router → pull zone → state", as (c) => c.method === "POST" && c.path === "/pullzone/{id}/setForceSSL", ); expect(forceSsl?.body).toEqual({ - Hostname: "my-site.b-cdn.net", + Hostname: `${zoneName}.b-cdn.net`, ForceSSL: true, }); @@ -467,6 +488,43 @@ test("createSite re-run reuses existing resources and converges", async () => { expect(await readRemoteState(fakeConnection())).not.toBeNull(); }); +test("createSite resumes a half-created suffixed site", async () => { + const coreCalls: Call[] = []; + const computeCalls: Call[] = []; + const suffixed = { ...ZONE, Name: "sites-my-site-abc123" }; + const coreClient = fakeCoreClient({ + calls: coreCalls, + storageZones: [suffixed], + pullZones: [ + { + Id: 30, + Name: "sites-my-site-abc123", + StorageZoneId: 10, + Hostnames: [], + }, + ], + }); + const computeClient = fakeComputeClient({ + calls: computeCalls, + scripts: [{ Id: 20, Name: "sites-my-site-abc123-router" }], + }); + + const result = await createSite({ + coreClient, + computeClient, + name: "my-site", + region: "DE", + }); + + expect(result.reused).toEqual({ + storageZone: true, + script: true, + pullZone: true, + }); + // The site keeps its clean display name; only the zones carry the suffix. + expect(result.state.name).toBe("my-site"); +}); + test("createSite refuses to re-provision an existing site", async () => { store.set(REMOTE_STATE_PATH, JSON.stringify(fakeState())); const coreClient = fakeCoreClient({ calls: [], storageZones: [ZONE] }); @@ -477,11 +535,23 @@ test("createSite refuses to re-provision an existing site", async () => { ).rejects.toThrow('Site "my-site" already exists.'); }); -test("createSite reports a globally-taken storage zone name clearly", async () => { - // The name is free on this account (findStorageZoneByName sees nothing) but - // taken globally, so the create POST comes back 409. +test("createSite refuses to re-provision an existing suffixed site", async () => { + store.set(REMOTE_STATE_PATH, JSON.stringify(fakeState())); + const suffixed = { ...ZONE, Name: "sites-my-site-abc123" }; + const coreClient = fakeCoreClient({ calls: [], storageZones: [suffixed] }); + const computeClient = fakeComputeClient({ calls: [] }); + + await expect( + createSite({ coreClient, computeClient, name: "my-site", region: "DE" }), + ).rejects.toThrow('Site "my-site" already exists.'); +}); + +test("createSite gives up after every storage zone suffix collides", async () => { + // Every suffixed candidate comes back 409 (in practice: the API rejecting the + // name for another reason the taken-name check matches). + const coreCalls: Call[] = []; const coreClient = fakeCoreClient({ - calls: [], + calls: coreCalls, createError: { path: "/storagezone", error: new ApiError( @@ -494,12 +564,46 @@ test("createSite reports a globally-taken storage zone name clearly", async () = await expect( createSite({ coreClient, computeClient, name: "my-site", region: "DE" }), - ).rejects.toThrow('The storage zone name "my-site" is already taken.'); + ).rejects.toThrow( + 'Couldn\'t find an available storage zone name for "my-site".', + ); + // Each attempt used a fresh suffixed candidate. + const attempts = coreCalls + .filter((c) => c.method === "POST" && c.path === "/storagezone") + .map((c) => (c.body as { Name: string }).Name); + expect(attempts).toHaveLength(3); + for (const name of attempts) + expect(name).toMatch(/^sites-my-site-[a-z0-9]{6}$/); }); -test("createSite reports a globally-taken pull zone name clearly", async () => { - // Storage zone and router provision fine; the pull zone name is taken (a 400 - // whose message says so on this endpoint). +test("createSite retries the pull zone with a fresh suffix when the name is taken", async () => { + const coreCalls: Call[] = []; + const coreClient = fakeCoreClient({ + calls: coreCalls, + createError: { + path: "/pullzone", + error: new ApiError("The name is already taken.", 400), + times: 1, + }, + }); + const computeClient = fakeComputeClient({ calls: [] }); + + const result = await createSite({ + coreClient, + computeClient, + name: "my-site", + region: "DE", + }); + + const attempts = coreCalls + .filter((c) => c.method === "POST" && c.path === "/pullzone") + .map((c) => (c.body as { Name: string }).Name); + expect(attempts).toHaveLength(2); + expect(attempts[1]).toMatch(/^sites-my-site-[a-z0-9]{6}$/); + expect(result.reused.pullZone).toBe(false); +}); + +test("createSite gives up after every pull zone suffix collides", async () => { const coreClient = fakeCoreClient({ calls: [], createError: { @@ -511,7 +615,9 @@ test("createSite reports a globally-taken pull zone name clearly", async () => { await expect( createSite({ coreClient, computeClient, name: "my-site", region: "DE" }), - ).rejects.toThrow('The pull zone name "my-site" is already taken.'); + ).rejects.toThrow( + 'Couldn\'t find an available pull zone name for "my-site".', + ); }); // ---- promote ---- diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 7d23859a..35f45560 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -30,6 +30,8 @@ import { type RemoteSiteState, routerScriptName, STATE_VERSION, + siteResourcePattern, + suffixedResourceName, } from "./constants.ts"; import { routerSource } from "./router/source.ts"; @@ -227,26 +229,36 @@ export async function fetchSites(client: CoreClient): Promise { .sort((a, b) => a.state.name.localeCompare(b.state.name)); } -async function findStorageZoneByName( +// Account storage zones whose name is `{name}-{suffix}` (or bare `{name}` from pre-suffix CLIs), re-fetched by ID because search results may omit the zone password. +async function findSiteStorageZones( client: CoreClient, name: string, -): Promise { +): Promise { const { data } = await client.GET("/storagezone", { params: { query: { search: name } }, }); - const match = (data ?? []).find( - (zone) => (zone.Name ?? "").toLowerCase() === name.toLowerCase(), + const pattern = siteResourcePattern(name); + const matches = (data ?? []).filter((zone) => pattern.test(zone.Name ?? "")); + return Promise.all( + matches + .filter((zone) => zone.Id != null) + .map((zone) => fetchStorageZone(client, zone.Id as number)), ); - // Re-fetch by ID: search results may omit the zone password. - return match?.Id ? fetchStorageZone(client, match.Id) : undefined; } -async function findPullZoneByName( +// The site's pull zone on a resumed create: prefer the one already pointing at the storage zone, else a bare legacy `{name}` zone. +async function findSitePullZone( client: CoreClient, name: string, + storageZoneId: number, ): Promise { - return (await fetchPullZones(client, name)).find( - (pz) => (pz.Name ?? "").toLowerCase() === name.toLowerCase(), + const pattern = siteResourcePattern(name); + const candidates = (await fetchPullZones(client, name)).filter((pz) => + pattern.test(pz.Name ?? ""), + ); + return ( + candidates.find((pz) => pz.StorageZoneId === storageZoneId) ?? + candidates.find((pz) => (pz.Name ?? "").toLowerCase() === name) ); } @@ -285,37 +297,44 @@ export async function createSite( const reused = { storageZone: false, script: false, pullZone: false }; // 1. Storage zone; the site's identity. + // A stateless name-pattern match is a half-finished create to resume; one carrying this site's state already is the site. step("Creating storage zone..."); - let storageZone = await findStorageZoneByName(coreClient, name); - if (storageZone) { - const existing = await siteContextFromZone(storageZone); - if (existing) { + let storageZone: StorageZoneModel | undefined; + for (const zone of await findSiteStorageZones(coreClient, name)) { + const existing = await siteContextFromZone(zone); + if (existing?.state.name === name) { throw new UserError( `Site "${name}" already exists.`, `Run \`bunny sites link ${name}\` to use it from this directory.`, ); } + if (!existing && !storageZone) storageZone = zone; + } + if (storageZone) { reused.storageZone = true; } else { - let data: StorageZoneModel | undefined; - try { - ({ data } = await coreClient.POST("/storagezone", { - body: { Name: name, Region: region, ReplicationRegions: null }, - })); - } catch (err) { - if (isNameTaken(err)) { - throw new UserError( - `The storage zone name "${name}" is already taken.`, - "Storage zone names are global across bunny.net. Choose a different site name.", - ); + // The suffix keeps the globally-unique name from colliding with other accounts; retry fresh suffixes on the off chance one still does. + for (let attempt = 0; !storageZone && attempt < 3; attempt++) { + const zoneName = suffixedResourceName(name); + try { + const { data } = await coreClient.POST("/storagezone", { + body: { Name: zoneName, Region: region, ReplicationRegions: null }, + }); + if (!data?.Id) { + throw new UserError(`Failed to create storage zone "${zoneName}".`); + } + // Re-fetch for the full record (including the zone password). + storageZone = await fetchStorageZone(coreClient, data.Id); + } catch (err) { + if (!isNameTaken(err)) throw err; } - throw err; } - if (!data?.Id) { - throw new UserError(`Failed to create storage zone "${name}".`); + if (!storageZone) { + throw new UserError( + `Couldn't find an available storage zone name for "${name}".`, + "Re-run the command, or choose a different site name.", + ); } - // Re-fetch for the full record (including the zone password). - storageZone = await fetchStorageZone(coreClient, data.Id); } const storageZoneId = storageZone.Id; if (storageZoneId == null) { @@ -323,8 +342,10 @@ export async function createSite( } // 2. Router script (middleware); code/publish/env-var are idempotent, so they always run and a resumed create converges. + // Named after the zone so a resume finds it. step("Creating router script..."); - const scriptName = routerScriptName(name); + const resourceName = storageZone.Name ?? name; + const scriptName = routerScriptName(resourceName); let scriptId = (await fetchScripts(computeClient)).find( (s) => s.Name === scriptName, )?.Id; @@ -359,21 +380,30 @@ export async function createSite( }); // 3. Pull zone with the storage origin, router attached. + // Namd like the storage zone; a fresh suffix on collision keeps the create moving(nothing keys on the names matching). step("Creating pull zone..."); - let pullZone = await findPullZoneByName(coreClient, name); + let pullZone = await findSitePullZone(coreClient, name, storageZoneId); if (pullZone) { reused.pullZone = true; } else { - try { - pullZone = await createPullZone(coreClient, name, storageZoneId); - } catch (err) { - if (isNameTaken(err)) { - throw new UserError( - `The pull zone name "${name}" is already taken.`, - "Pull zone names are global across bunny.net. Choose a different site name.", + let pullZoneName = resourceName; + for (let attempt = 0; !pullZone && attempt < 3; attempt++) { + try { + pullZone = await createPullZone( + coreClient, + pullZoneName, + storageZoneId, ); + } catch (err) { + if (!isNameTaken(err)) throw err; + pullZoneName = suffixedResourceName(name); } - throw err; + } + if (!pullZone) { + throw new UserError( + `Couldn't find an available pull zone name for "${name}".`, + "Re-run the command, or choose a different site name.", + ); } } if (pullZone.Id == null) { diff --git a/packages/cli/src/commands/sites/constants.test.ts b/packages/cli/src/commands/sites/constants.test.ts index 600263bd..c28c26fc 100644 --- a/packages/cli/src/commands/sites/constants.test.ts +++ b/packages/cli/src/commands/sites/constants.test.ts @@ -7,6 +7,8 @@ import { previewHostname, previewWildcard, type RemoteSiteState, + siteResourcePattern, + suffixedResourceName, } from "./constants.ts"; const validState: RemoteSiteState = { @@ -68,4 +70,19 @@ test("isValidSiteName enforces zone-name rules", () => { expect(isValidSiteName("-leading")).toBe(false); expect(isValidSiteName("trailing-")).toBe(false); expect(isValidSiteName("ab")).toBe(false); // too short + expect(isValidSiteName("a".repeat(47))).toBe(true); + expect(isValidSiteName("a".repeat(48))).toBe(false); +}); + +test("suffixed resource names round-trip through the site pattern", () => { + const zoneName = suffixedResourceName("my-site"); + expect(zoneName).toMatch(/^sites-my-site-[a-z0-9]{6}$/); + const pattern = siteResourcePattern("my-site"); + expect(pattern.test(zoneName)).toBe(true); + expect(pattern.test("my-site")).toBe(true); // bare pre-suffix zone + expect(pattern.test("my-site-abcdef")).toBe(false); // suffix without the prefix + expect(pattern.test("sites-my-site")).toBe(false); // prefix without a suffix + expect(pattern.test("sites-my-site-abcdef1")).toBe(false); // suffix too long + expect(pattern.test("sites-my-site2-abcdef")).toBe(false); // different site + expect(siteResourcePattern("other").test(zoneName)).toBe(false); }); diff --git a/packages/cli/src/commands/sites/constants.ts b/packages/cli/src/commands/sites/constants.ts index 7cacdeb4..8c4c6392 100644 --- a/packages/cli/src/commands/sites/constants.ts +++ b/packages/cli/src/commands/sites/constants.ts @@ -99,13 +99,41 @@ export function isValidDeployId(id: string): boolean { return DEPLOY_ID_RE.test(id); } -// Site names become storage zone / pull zone names (and the b-cdn.net subdomain). -const SITE_NAME_RE = /^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/; +// Site names become `sites-{name}-{suffix}` zone names; 3-47 chars keeps those within zone-name limits. +const SITE_NAME_RE = /^[a-z0-9][a-z0-9-]{1,45}[a-z0-9]$/; export function isValidSiteName(name: string): boolean { return SITE_NAME_RE.test(name); } +// Marks the zones as sites-managed in the dashboard; discovery doesn't key on it (that's pull zone shape + state). +export const RESOURCE_PREFIX = "sites-"; + +const RESOURCE_SUFFIX_LENGTH = 6; +const RESOURCE_SUFFIX_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789"; + +// Zone names are global across bunny.net, so the random suffix keeps creates from colliding with other accounts' zones. +export function randomResourceSuffix(): string { + const bytes = crypto.getRandomValues(new Uint8Array(RESOURCE_SUFFIX_LENGTH)); + return Array.from( + bytes, + (b) => RESOURCE_SUFFIX_CHARS[b % RESOURCE_SUFFIX_CHARS.length], + ).join(""); +} + +/** A site's storage/pull zone name: `sites-{name}-{suffix}`. */ +export function suffixedResourceName(siteName: string): string { + return `${RESOURCE_PREFIX}${siteName}-${randomResourceSuffix()}`; +} + +/** Matches a site's zone names: `sites-{name}-{suffix}`, or bare `{name}` from pre-suffix CLIs. */ +export function siteResourcePattern(siteName: string): RegExp { + return new RegExp( + `^(${RESOURCE_PREFIX}${siteName}-[a-z0-9]{${RESOURCE_SUFFIX_LENGTH}}|${siteName})$`, + "i", + ); +} + // Parse and shape-check remote state; returns null (not a crash) for anything that isn't a state file this CLI understands. export function parseRemoteState(raw: string): RemoteSiteState | null { let data: unknown; diff --git a/packages/cli/src/commands/sites/create.ts b/packages/cli/src/commands/sites/create.ts index 81955cd4..2c441603 100644 --- a/packages/cli/src/commands/sites/create.ts +++ b/packages/cli/src/commands/sites/create.ts @@ -64,7 +64,10 @@ export const sitesCreateCommand = defineCommand({ describe: "Create a new static site.", examples: [ ["$0 sites create", "Prompt for a name (defaults to the directory name)"], - ["$0 sites create my-site", "Create a site served at my-site.b-cdn.net"], + [ + "$0 sites create my-site", + "Create a site served at sites-my-site-.b-cdn.net", + ], [ "$0 sites create my-site --domain example.com", "Create and attach a custom domain", @@ -77,7 +80,7 @@ export const sitesCreateCommand = defineCommand({ .positional("name", { type: "string", describe: - "Site name; becomes the storage zone, pull zone, and .b-cdn.net subdomain (prompted when omitted)", + "Site name; the storage zone, pull zone, and b-cdn.net subdomain become sites--xxxxxx (prompted when omitted)", }) .option("region", { type: "string", diff --git a/packages/cli/src/commands/sites/interactive.ts b/packages/cli/src/commands/sites/interactive.ts index 8d09d791..22df5a79 100644 --- a/packages/cli/src/commands/sites/interactive.ts +++ b/packages/cli/src/commands/sites/interactive.ts @@ -49,19 +49,45 @@ export function sitePositionalBuilder( }) as Argv; } +// Resolve a ref to a site: a storage zone ID/name directly, else by site name (zone names carry a random suffix, so the site name usually isn't one). async function contextFromRef( client: CoreClient, ref: string, ): Promise { - const zone = await resolveStorageZone(client, ref); - const context = await siteContextFromZone(zone); - if (!context) { + let zone: Awaited> | undefined; + try { + zone = await resolveStorageZone(client, ref); + } catch { + zone = undefined; + } + if (zone) { + const context = await siteContextFromZone(zone); + if (context) return context; + } + + const matches = (await fetchSites(client)).filter( + (s) => s.state.name.toLowerCase() === ref.toLowerCase(), + ); + if (matches.length > 1) { + throw new UserError( + `Multiple sites are named "${ref}".`, + "Pass the storage zone ID instead (see `bunny sites list`).", + ); + } + const summary = matches[0]; + const context = summary && (await siteContextFromZone(summary.storageZone)); + if (context) return context; + + if (zone) { throw new UserError( `Storage zone "${zone.Name}" is not a bunny site.`, "Create one with `bunny sites create `.", ); } - return context; + throw new UserError( + `No site found for "${ref}".`, + "Run `bunny sites list` to see your sites, or create one with `bunny sites create `.", + ); } export interface SelectedSite { diff --git a/packages/cli/src/commands/sites/provision.ts b/packages/cli/src/commands/sites/provision.ts index ddcf5c05..fa44569c 100644 --- a/packages/cli/src/commands/sites/provision.ts +++ b/packages/cli/src/commands/sites/provision.ts @@ -19,7 +19,7 @@ import { } from "./constants.ts"; export const SITE_NAME_RULES = - "Use 3-60 lowercase letters, digits, and dashes (no leading/trailing dash)."; + "Use 3-47 lowercase letters, digits, and dashes (no leading/trailing dash)."; /** Best-effort site name from the current directory, or undefined if it can't be one. */ export function suggestSiteName(): string | undefined { diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index 0f28fed2..90898300 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -13,7 +13,7 @@ Most commands accept an optional site (a trailing `[site]` positional, or the `- ```bash # New site: provision, deploy, iterate -bunny sites create my-site # served at https://my-site.b-cdn.net +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 @@ -55,7 +55,7 @@ bunny sites create my-site --no-link # don't write .bunny/site.json | `--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) | -Site names become the storage zone, pull zone, and `.b-cdn.net` subdomain: 3-60 lowercase letters, digits, and dashes. Creation is idempotent; a failed create re-runs cleanly, reusing whatever was already provisioned. +Site names are 3-47 lowercase letters, digits, and dashes. The storage zone, pull zone, and b-cdn.net subdomain become `sites--xxxxxx` (a `sites-` prefix marking them in the dashboard, plus a shared random suffix since zone names are global across bunny.net); commands still take the clean site name. Creation is idempotent; a failed create re-runs cleanly, reusing whatever was already provisioned. --- @@ -131,7 +131,7 @@ Writes a workflow that deploys previews on pull requests and publishes to produc bunny sites list bunny sites show # resources, domains, current deploy bunny sites open # open the live URL in the browser (--print emits it) -bunny sites ssl --no-force-ssl # toggle Force HTTPS on the .b-cdn.net system host +bunny sites ssl --no-force-ssl # toggle Force HTTPS on the site's b-cdn.net system host bunny sites link my-site # .bunny/site.json bunny sites unlink bunny sites upgrade-router # republish the router with the CLI's current source From 8b1d7eb62ffc81a03268c815198da0b2c79f0a1c Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Sun, 19 Jul 2026 13:53:57 +0100 Subject: [PATCH 20/24] config update --- .changeset/app-config-sites-block.md | 5 -- .changeset/config-package-sites.md | 5 ++ .github/workflows/release.yml | 39 +++++++++ .prettierignore | 2 +- AGENTS.md | 40 ++++++--- CLAUDE.md | 4 +- README.md | 24 +++--- bun.lock | 50 +++++------ examples/sites/README.md | 60 ++++++++++++++ examples/sites/app-and-site/bunny.jsonc | 27 ++++++ examples/sites/astro/bunny.jsonc | 10 +++ examples/sites/hugo/bunny.jsonc | 10 +++ examples/sites/nextjs-static/bunny.jsonc | 10 +++ examples/sites/static-html/bunny.jsonc | 9 ++ examples/sites/vite/bunny.jsonc | 12 +++ packages/app-config/package.json | 18 ---- packages/cli/package.json | 2 +- packages/cli/src/commands/apps/APPS.md | 2 +- .../src/commands/apps/compose/translate.ts | 4 +- packages/cli/src/commands/apps/config.test.ts | 35 ++++++++ packages/cli/src/commands/apps/config.ts | 83 ++++++++----------- packages/cli/src/commands/apps/deploy.ts | 2 +- .../cli/src/commands/apps/env/resolve.test.ts | 2 +- packages/cli/src/commands/apps/push.ts | 2 +- packages/cli/src/commands/sites/config.ts | 45 ++++------ packages/cli/src/core/bunny-config.ts | 46 ++++++++++ packages/cli/src/core/jsonc.test.ts | 64 ++++++++++++++ packages/cli/src/core/jsonc.ts | 69 +++++++++++++++ packages/{app-config => config}/CHANGELOG.md | 2 +- packages/{app-config => config}/README.md | 37 +++++---- .../generated/schema.json | 2 +- packages/config/package.json | 43 ++++++++++ .../scripts/generate-schema.ts | 4 +- .../src/convert.test.ts | 0 .../{app-config => config}/src/convert.ts | 0 packages/{app-config => config}/src/index.ts | 4 + .../src/parse-image-ref.test.ts | 0 .../src/parse-image-ref.ts | 0 .../{app-config => config}/src/schema.test.ts | 0 packages/{app-config => config}/src/schema.ts | 27 ++++-- packages/config/tsconfig.build.json | 13 +++ packages/{app-config => config}/tsconfig.json | 0 templates/apps/django-with-redis-0.jsonc | 2 +- templates/apps/fastapi-with-redis-0.jsonc | 2 +- templates/apps/flask-0.jsonc | 2 +- templates/apps/flask-with-redis-0.jsonc | 2 +- templates/apps/go-api-0.jsonc | 2 +- templates/apps/go-api-with-redis-0.jsonc | 2 +- templates/apps/hono-api-0.jsonc | 2 +- templates/apps/hono-api-with-duckdb-0.jsonc | 2 +- templates/apps/laravel-with-mariadb-0.jsonc | 2 +- templates/apps/minecraft-0.jsonc | 2 +- templates/apps/n8n-with-psql-0.jsonc | 2 +- templates/apps/nextjs-0.jsonc | 2 +- templates/apps/nextjs-with-psql-0.jsonc | 2 +- templates/apps/nuxt-0.jsonc | 2 +- templates/apps/rails-0.jsonc | 2 +- templates/apps/umami-with-psql-0.jsonc | 2 +- templates/apps/vite-react-nginx-0.jsonc | 2 +- templates/apps/wordpress-0.jsonc | 2 +- tsconfig.json | 5 +- 61 files changed, 649 insertions(+), 205 deletions(-) delete mode 100644 .changeset/app-config-sites-block.md create mode 100644 .changeset/config-package-sites.md create mode 100644 examples/sites/README.md create mode 100644 examples/sites/app-and-site/bunny.jsonc create mode 100644 examples/sites/astro/bunny.jsonc create mode 100644 examples/sites/hugo/bunny.jsonc create mode 100644 examples/sites/nextjs-static/bunny.jsonc create mode 100644 examples/sites/static-html/bunny.jsonc create mode 100644 examples/sites/vite/bunny.jsonc delete mode 100644 packages/app-config/package.json create mode 100644 packages/cli/src/core/bunny-config.ts create mode 100644 packages/cli/src/core/jsonc.test.ts create mode 100644 packages/cli/src/core/jsonc.ts rename packages/{app-config => config}/CHANGELOG.md (97%) rename packages/{app-config => config}/README.md (57%) rename packages/{app-config => config}/generated/schema.json (99%) create mode 100644 packages/config/package.json rename packages/{app-config => config}/scripts/generate-schema.ts (67%) rename packages/{app-config => config}/src/convert.test.ts (100%) rename packages/{app-config => config}/src/convert.ts (100%) rename packages/{app-config => config}/src/index.ts (89%) rename packages/{app-config => config}/src/parse-image-ref.test.ts (100%) rename packages/{app-config => config}/src/parse-image-ref.ts (100%) rename packages/{app-config => config}/src/schema.test.ts (100%) rename packages/{app-config => config}/src/schema.ts (79%) create mode 100644 packages/config/tsconfig.build.json rename packages/{app-config => config}/tsconfig.json (100%) diff --git a/.changeset/app-config-sites-block.md b/.changeset/app-config-sites-block.md deleted file mode 100644 index 493966e0..00000000 --- a/.changeset/app-config-sites-block.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bunny.net/app-config": patch ---- - -feat: optional top-level `sites` block (`name`, `dir`, `build`) in the bunny.jsonc schema, consumed by `bunny sites deploy` for the default deploy directory and build command. Exported as `SiteConfigSchema`/`SiteConfig`; the generated JSON Schema includes the new block. diff --git a/.changeset/config-package-sites.md b/.changeset/config-package-sites.md new file mode 100644 index 00000000..671472ae --- /dev/null +++ b/.changeset/config-package-sites.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/config": patch +--- + +rename `@bunny.net/app-config` to `@bunny.net/config` and add a top-level `sites` block (`name`, `dir`, `build`) to the bunny.jsonc schema; the root `BunnyConfigSchema` makes `app` and `sites` optional so app-only, sites-only, and combined files all validate against the generated JSON Schema diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d3ca3e1..694657f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,6 +37,7 @@ jobs: if: needs.changesets.outputs.hasChangesets == 'false' outputs: cli-version: ${{ steps.check-cli.outputs.version }} + config-version: ${{ steps.check-config.outputs.version }} database-shell-version: ${{ steps.check-database-shell.outputs.version }} openapi-client-version: ${{ steps.check-openapi-client.outputs.version }} sandbox-version: ${{ steps.check-sandbox.outputs.version }} @@ -54,6 +55,17 @@ jobs: else echo "No version change: $VERSION" fi + - name: Check config version + id: check-config + run: | + VERSION=$(node -p "require('./packages/config/package.json').version") + PUBLISHED=$(npm view @bunny.net/config version 2>/dev/null || echo "0.0.0") + if [ "$VERSION" != "$PUBLISHED" ]; then + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "New version detected: $VERSION (published: $PUBLISHED)" + else + echo "No version change: $VERSION" + fi - name: Check sandbox version id: check-sandbox run: | @@ -467,6 +479,33 @@ jobs: env: NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} + publish-config: + name: Publish config + runs-on: ubuntu-latest + needs: version + if: needs.version.outputs.config-version + steps: + - uses: actions/checkout@v5 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.11" + - run: bun install + + # Config's build type-checks against openapi-client's package exports (dist/), so build it first. + - name: Build openapi-client + run: bun run --filter @bunny.net/openapi-client build + + - name: Build package + run: bun run --filter @bunny.net/config build + + # bun publish (not npm) rewrites the workspace:* dependency on @bunny.net/openapi-client to a real version. + - name: Publish @bunny.net/config + run: | + cd packages/config + bun publish --access public + env: + NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} + publish-scriptable-dns-types: name: Publish scriptable-dns-types runs-on: ubuntu-latest diff --git a/.prettierignore b/.prettierignore index b9d1d2a4..4d2b5eb1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -12,7 +12,7 @@ bsql # Generated artifacts packages/openapi-client/src/generated packages/openapi-client/specs -packages/app-config/generated +packages/config/generated # Changesets keeps its own format .changeset/*.md diff --git a/AGENTS.md b/AGENTS.md index 897cf4ac..214e1af6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,11 +72,11 @@ Bun replaces the entire Node.js toolchain. There are no separate tools for trans This is a Bun workspace monorepo with six packages: - **`@bunny.net/openapi-client`** (`packages/openapi-client/`) — Standalone, type-safe OpenAPI client for bunny.net, generated from OpenAPI specs. Zero CLI dependencies. Publishable to npm. -- **`@bunny.net/app-config`** (`packages/app-config/`) — Shared app configuration schemas (Zod), inferred types, JSON Schema generation, and API conversion functions. Used by the CLI and potentially other tools. +- **`@bunny.net/config`** (`packages/config/`) — Shared `bunny.jsonc` schemas (Zod), inferred types, JSON Schema generation, and API conversion functions. The root `BunnyConfigSchema` has optional `app` (Magic Containers) and `sites` (static sites) blocks; `BunnyAppConfigSchema` narrows it to require `app`. Used by the CLI and potentially other tools. - **`@bunny.net/database-shell`** (`packages/database-shell/`) — Standalone interactive SQL shell for libSQL databases. Framework-agnostic REPL, dot-commands, formatting, masking, and history. Also usable as a standalone CLI (binary: `bsql`). - **`@bunny.net/scriptable-dns-types`** (`packages/scriptable-dns-types/`): Ambient TypeScript declarations for the Scriptable DNS runtime globals (`ARecord`, `Monitoring`, `RoutingEngine`, etc.). Types-only, no runtime code: the DNS runtime can't `import`, so these power editor autocomplete and an optional typecheck step. Scaffolded into projects by `bunny dns scripts init`; intended to also feed the dashboard editor. Publishable to npm. - **`@bunny.net/sandbox`** (`packages/sandbox/`) — Standalone sandbox SDK. Code-first DX (`Sandbox.create`, `writeFiles`, `runCommand`, `exposePort`, `setEnv`/`getEnv`/`unsetEnv`, `listFiles`/`deleteFile`/`rename`/`exists`) over Magic Containers provisioning plus an `ssh2` SSH/SFTP transport. Blocking `runCommand` accepts `timeout` (rejects with `CommandTimeoutError` carrying partial output), `signal` for cancellation, and `onStdout`/`onStderr` callbacks for live output. Env vars can be baked in at `create` (persisted), passed per-command via `runCommand({ env })` (temporary), or persisted after creation via `setEnv`. The handle implements `Symbol.dispose`/`Symbol.asyncDispose` so `using`/`await using` release the SSH connection (without deleting the sandbox). Zero CLI dependencies. -- **`@bunny.net/cli`** (`packages/cli/`) — The CLI. Depends on `@bunny.net/openapi-client`, `@bunny.net/app-config`, `@bunny.net/database-shell`, `@bunny.net/scriptable-dns-types`, and `@bunny.net/sandbox`. +- **`@bunny.net/cli`** (`packages/cli/`) — The CLI. Depends on `@bunny.net/openapi-client`, `@bunny.net/config`, `@bunny.net/database-shell`, `@bunny.net/scriptable-dns-types`, and `@bunny.net/sandbox`. ``` bunny-cli/ @@ -119,7 +119,7 @@ bunny-cli/ │ │ ├── storage.d.ts │ │ └── stream.d.ts │ │ -│ ├── app-config/ # @bunny.net/app-config package +│ ├── config/ # @bunny.net/config package │ │ ├── package.json │ │ ├── tsconfig.json │ │ ├── scripts/ @@ -128,7 +128,7 @@ bunny-cli/ │ │ │ └── schema.json # JSON Schema for bunny.jsonc (committed) │ │ └── src/ │ │ ├── index.ts # Barrel export: schemas, types, conversion functions -│ │ ├── schema.ts # Zod schemas + inferred types (BunnyAppConfig, etc.) +│ │ ├── schema.ts # Zod schemas + inferred types: BunnyConfigSchema (root; optional app + sites), AppConfigSchema, SiteConfigSchema, BunnyAppConfigSchema (app required) │ │ ├── convert.ts # API ↔ config conversion (apiToConfig, configToAddRequest, configToPatchRequest) │ │ └── parse-image-ref.ts # Docker image reference parser (parseImageRef) │ │ @@ -191,6 +191,9 @@ bunny-cli/ │ │ │ ├── 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 +│ │ ├── 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) │ │ ├── logger.ts # Chalk-based structured logger │ │ ├── manifest.ts # .bunny/ context file resolution (load, save, resolveManifestId) │ │ ├── registrar.ts # detectRegistrar()/parseRegistrar(): best-effort registrar name via RDAP (used by dns zones add to name the registrar in next-steps) @@ -211,7 +214,7 @@ bunny-cli/ │ │ │ ├── APPS.md # Apps documentation (while experimental) │ │ │ ├── index.ts # defineNamespace("apps", false) — hidden, registers all app commands │ │ │ ├── constants.ts # Status label maps + APP_MANIFEST filename + AppManifest interface (consumed via core/manifest.ts) -│ │ │ ├── config.ts # bunny.jsonc file I/O (saveConfig strips transient `image`/`registry`/`app.id` via stripTransientFields), re-exports from @bunny.net/app-config; provides resolveAppId, resolveContainerId, resolveContainerRegistry +│ │ │ ├── config.ts # bunny.jsonc app I/O over core/bunny-config.ts + core/jsonc.ts (loadConfig requires an `app` block; saveConfig strips transient `image`/`registry`/`app.id` via stripTransientFields and edits existing files surgically), re-exports from @bunny.net/config; provides resolveAppId, resolveContainerId, resolveContainerRegistry │ │ │ ├── docker.ts # Docker + registry helpers (build, push, dockerLogin, ensureRegistryLogin, dockerHasCredentials, ghDockerLogin, generateTag, promptRegistry, resolveRegistryForImage, getConfigSuggestions, imageHostname, parseDockerfileExposedPorts/readDockerfileExposedPorts, findDockerfiles/isDockerfileName/defaultContainerNameFromDockerfile/assignContainerNamesToDockerfiles for monorepo Dockerfile discovery) │ │ │ ├── suggestions.ts # Shared endpoint/env-var suggestion prompting (confirmEndpointSuggestions, endpointRequestToConfig, promptSuggestedEnv, filterNewEndpointSuggestions, filterNewEnvSuggestions) - used by walkthrough.ts and deploy.ts (post-push) │ │ │ ├── init.ts # Scaffold bunny.jsonc (detects Dockerfile, prompts for registry) @@ -381,7 +384,7 @@ bunny-cli/ │ │ │ ├── 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); optional offerCreate (deploy only) adds a new-vs-existing prompt, and creates straight away when the account has no sites; siteOptionBuilder (--site) + sitePositionalBuilder ([site]) │ │ │ ├── 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: lenient bunny.jsonc reader; validates ONLY the `sites` block (SiteConfigSchema from @bunny.net/app-config), so sites-only configs work without an `app` block +│ │ │ ├── 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 headers are stripped; the flag is router-internal), trailing-slash → index.html. 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 │ │ │ ├── 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) @@ -470,7 +473,7 @@ bunny-cli/ ### Conventions -- **Monorepo with Bun workspaces.** `packages/openapi-client/` is the standalone API client SDK; `packages/app-config/` provides shared Zod schemas, types, and API conversion functions for `bunny.jsonc`; `packages/database-shell/` is the standalone SQL shell engine; `packages/sandbox/` is the standalone sandbox SDK (provisioning + SSH transport); `packages/cli/` is the CLI. +- **Monorepo with Bun workspaces.** `packages/openapi-client/` is the standalone API client SDK; `packages/config/` provides shared Zod schemas, types, and API conversion functions for `bunny.jsonc`; `packages/database-shell/` is the standalone SQL shell engine; `packages/sandbox/` is the standalone sandbox SDK (provisioning + SSH transport); `packages/cli/` is the CLI. - **API clients use `ClientOptions`** — an options object with `apiKey`, `baseUrl`, `verbose`, `userAgent`, and `onDebug`. The CLI provides a `clientOptions(config, verbose)` helper to build this from `ResolvedConfig`. - **One command per file.** Each file in `commands/` exports a single command or namespace. - **Commands are grouped by domain** in subdirectories (`config/`, `db/`, `scripts/`). @@ -921,13 +924,24 @@ Its `package.json` `exports`/`main`/`types` point at `dist/`, so npm consumers g ### Publishing `@bunny.net/sandbox` -`@bunny.net/sandbox` follows the same compiled-library pattern as `@bunny.net/openapi-client`: `exports`/`main`/`types` point at `dist/`, in-repo tooling resolves it from source via the root `tsconfig.json` `paths` mapping, and `scripts/build.ts` (via `tsconfig.build.json`) emits JS + declarations with the same `.ts` → `.js` specifier rewriting. +`@bunny.net/sandbox` follows the same compiled-library pattern as `@bunny.net/openapi-client`: `exports`/`main`/`types` point at `dist/`, in-repo tooling resolves it from source via the root `tsconfig.json` `paths` mapping, and `tsconfig.build.json` (plain `tsc` with `rewriteRelativeImportExtensions`, no `scripts/build.ts`) emits JS + declarations. Unlike openapi-client it has no declaration transformer, so emitted `.d.ts` keep their `.ts` import specifiers, which TypeScript resolves to the sibling `.d.ts` at the consumer. Two differences from openapi-client: - Sandbox depends on `@bunny.net/openapi-client` with `workspace:*`, so the `publish-sandbox` job in `release.yml` uses `bun publish` (not `npm publish`) — bun rewrites `workspace:*` to the local package version in the published tarball; npm would ship the unresolvable `workspace:*` spec verbatim. `bun publish` authenticates via the `NPM_CONFIG_TOKEN` env var. - Its `tsconfig.build.json` overrides `paths` to `{}` so openapi-client resolves via its package `exports` (`dist/`) instead of source — otherwise openapi-client's sources would enter the program and violate `rootDir`. The publish job therefore builds openapi-client before building sandbox. +### Publishing `@bunny.net/config` + +`@bunny.net/config` follows the same compiled-library pattern as `@bunny.net/sandbox`: `exports`/`main`/`types` point at `dist/`, in-repo tooling resolves it from source via the root `tsconfig.json` `paths` mapping, and `tsconfig.build.json` (`rewriteRelativeImportExtensions`, plain `tsc`, `paths` overridden to `{}`) emits JS + declarations. Like sandbox, it depends on `@bunny.net/openapi-client` with `workspace:*`, so the `publish-config` job in `release.yml` builds openapi-client first, then builds config, then runs `bun publish` (which rewrites `workspace:*` to a real version; authenticates via `NPM_CONFIG_TOKEN`). + +Two config-specific notes: + +- Its `build` script runs `generate:schema` before `tsc`, so the published `generated/schema.json` (shipped via `files`, not `dist`) always matches the Zod schemas. The `./schema.json` subpath export and the `$schema` reference written into `bunny.jsonc` both resolve to that file. +- `tsconfig.build.json` sets `include: ["src"]` so the package-root `scripts/generate-schema.ts` stays out of the compiled program (it would otherwise violate `rootDir: src`). + +The CLI bundles config at compile time (`bun build --compile`), so the CLI itself does not consume the published package — publishing exists for external tools that read or write `bunny.jsonc`. + ### CI Tests and type-checking run on every pull request via `.github/workflows/ci.yml` (`bun run typecheck` and `bun test`). @@ -1379,11 +1393,11 @@ The `.bunny/` manifest and `bunny.jsonc` serve different purposes: | Committed | No (gitignored) | Yes | | Shared | No (per-developer) | Yes (team-wide) | -`bunny.jsonc` supports a `$schema` property for editor autocompletion, pointing to the JSON Schema generated by `@bunny.net/app-config`: +`bunny.jsonc` supports a `$schema` property for editor autocompletion, pointing to the JSON Schema generated by `@bunny.net/config`: ```jsonc { - "$schema": "./node_modules/@bunny.net/app-config/generated/schema.json", + "$schema": "./node_modules/@bunny.net/config/generated/schema.json", "version": "2026-05-11", "app": { "name": "my-app", @@ -1395,9 +1409,9 @@ The `.bunny/` manifest and `bunny.jsonc` serve different purposes: } ``` -`version` is an ISO date string. The CLI requires it on load — if a config is missing `version`, `loadConfig` throws a `UserError` with a hint to regenerate via `bunny apps pull`. There is no migration runner yet; when the first breaking shape change ships, that PR introduces one alongside its transform. +`version` is an ISO date string. The apps flow requires it on load — if a config is missing `version`, `loadConfig` throws a `UserError` with a hint to regenerate via `bunny apps pull`. (The sites flow is lenient: a sites-only file needs neither `version` nor an `app` block.) There is no migration runner yet; when the first breaking shape change ships, that PR introduces one alongside its transform. -Schemas and types are defined in `@bunny.net/app-config` using Zod. The CLI's `config.ts` handles file I/O (parsing JSONC, validating with Zod, writing with `$schema` + `version` injection) and resolution helpers (`resolveAppId`, `resolveContainerId`). +Schemas and types are defined in `@bunny.net/config` using Zod. `core/bunny-config.ts` owns `bunny.jsonc` discovery + raw read (shared by the apps and sites flows). The apps `config.ts` layers validation, resolution helpers (`resolveAppId`, `resolveContainerId`), and writes: a new file is serialized fresh with `$schema` + `version` first, while an existing file is edited surgically via `core/jsonc.ts` (`syncJsonc`) so comments, key order, and a sibling `sites` block survive. **Persistence model.** Three layers, with strict roles: @@ -1409,7 +1423,7 @@ To keep these consistent and stop file churn on every deploy, `saveConfig` (`app `resolveAppId` and `resolveContainerRegistry` in `apps/config.ts` read from the manifest first, then fall back to legacy `app.id` / `container.registry` in `bunny.jsonc` with a one-time deprecation warning. Existing pre-manifest configs continue to work; the next save naturally migrates them by stripping the legacy fields once the manifest carries the same data. -In-memory mutations during a deploy run (`targetContainer.image = imageRef`) are still required so `configToAddRequest` / `configToPatchRequest` can read the ref within the same run - they just don't make it to disk. Registry IDs for the API body are supplied via the new `RegistryMap` argument to `configToAddRequest` / `configToPatchRequest` (`@bunny.net/app-config`), sourced from the manifest at the call site. +In-memory mutations during a deploy run (`targetContainer.image = imageRef`) are still required so `configToAddRequest` / `configToPatchRequest` can read the ref within the same run - they just don't make it to disk. Registry IDs for the API body are supplied via the new `RegistryMap` argument to `configToAddRequest` / `configToPatchRequest` (`@bunny.net/config`), sourced from the manifest at the call site. --- diff --git a/CLAUDE.md b/CLAUDE.md index 581a29c0..2e655808 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ test("example", () => { This is a Bun workspace monorepo with five packages: - `packages/openapi-client/` (`@bunny.net/openapi-client`) — standalone, type-safe OpenAPI client, zero CLI deps -- `packages/app-config/` (`@bunny.net/app-config`) — shared Zod schemas, types, and JSON Schema for `bunny.jsonc` +- `packages/config/` (`@bunny.net/config`) — shared Zod schemas, types, and JSON Schema for `bunny.jsonc` - `packages/database-shell/` (`@bunny.net/database-shell`) — standalone SQL shell engine (REPL, formatting, masking) - `packages/sandbox/` (`@bunny.net/sandbox`) — standalone sandbox SDK (create, file buffering, command exec, port exposure) over Magic Containers + SSH - `packages/cli/` (`@bunny.net/cli`) — the CLI, depends on the other four @@ -47,7 +47,7 @@ This is a Bun workspace monorepo with five packages: - Import API clients from `@bunny.net/openapi-client`, not relative paths. Import generated types from `@bunny.net/openapi-client/generated/.d.ts`. - Use `clientOptions(config, verbose)` from `packages/cli/src/core/client-options.ts` when creating API clients in command handlers. - Database commands use v2 API endpoints (`/v2/databases/...`). -- Apps (Magic Containers) commands use `bunny.jsonc` as the single source of truth. App ID is stored in the config (no separate manifest file). Use `resolveAppId()` and `resolveContainerId()` from `packages/cli/src/commands/apps/config.ts`. Types and conversion functions come from `@bunny.net/app-config`. +- Apps (Magic Containers) commands use `bunny.jsonc` as the single source of truth. App ID is stored in the config (no separate manifest file). Use `resolveAppId()` and `resolveContainerId()` from `packages/cli/src/commands/apps/config.ts`. Types and conversion functions come from `@bunny.net/config`. - Prefer generated schema types over inline primitives. Use `Pick` instead of `{ field1: string; field2: number }`. Only fall back to `string`, `any`, or `number` when no generated type exists. ## Documentation diff --git a/README.md b/README.md index 7186afcd..75ceb440 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,17 @@ Monorepo for the [bunny.net](https://bunny.net) CLI and supporting packages. ## Packages -| Package | Name | Description | -| ------------------------------------------------------------------------ | ------------------------------------ | ------------------------------------------------------------ | -| [`packages/cli/`](packages/cli/) | `@bunny.net/cli` | Command-line interface for bunny.net | -| [`packages/openapi-client/`](packages/openapi-client/) | `@bunny.net/openapi-client` | Standalone, type-safe OpenAPI client for bunny.net | -| [`packages/sandbox/`](packages/sandbox/) | `@bunny.net/sandbox` | Standalone sandbox SDK over Magic Containers and SSH | -| [`packages/app-config/`](packages/app-config/) | `@bunny.net/app-config` | Shared Zod schemas, types, and JSON Schema for `bunny.jsonc` | -| [`packages/database-shell/`](packages/database-shell/) | `@bunny.net/database-shell` | Standalone interactive SQL shell for libSQL databases | -| [`packages/database-openapi/`](packages/database-openapi/) | `@bunny.net/database-openapi` | Generate OpenAPI 3.0 specs from a database schema | -| [`packages/database-rest/`](packages/database-rest/) | `@bunny.net/database-rest` | PostgREST-like REST API handler (database-agnostic) | -| [`packages/database-adapter-libsql/`](packages/database-adapter-libsql/) | `@bunny.net/database-adapter-libsql` | Bunny Database adapter for database-rest | -| [`packages/scriptable-dns-types/`](packages/scriptable-dns-types/) | `@bunny.net/scriptable-dns-types` | Ambient TypeScript types for the Scriptable DNS runtime | +| Package | Name | Description | +| ------------------------------------------------------------------------ | ------------------------------------ | -------------------------------------------------------------------------- | +| [`packages/cli/`](packages/cli/) | `@bunny.net/cli` | Command-line interface for bunny.net | +| [`packages/openapi-client/`](packages/openapi-client/) | `@bunny.net/openapi-client` | Standalone, type-safe OpenAPI client for bunny.net | +| [`packages/sandbox/`](packages/sandbox/) | `@bunny.net/sandbox` | Standalone sandbox SDK over Magic Containers and SSH | +| [`packages/config/`](packages/config/) | `@bunny.net/config` | Shared Zod schemas, types, and JSON Schema for `bunny.jsonc` (app + sites) | +| [`packages/database-shell/`](packages/database-shell/) | `@bunny.net/database-shell` | Standalone interactive SQL shell for libSQL databases | +| [`packages/database-openapi/`](packages/database-openapi/) | `@bunny.net/database-openapi` | Generate OpenAPI 3.0 specs from a database schema | +| [`packages/database-rest/`](packages/database-rest/) | `@bunny.net/database-rest` | PostgREST-like REST API handler (database-agnostic) | +| [`packages/database-adapter-libsql/`](packages/database-adapter-libsql/) | `@bunny.net/database-adapter-libsql` | Bunny Database adapter for database-rest | +| [`packages/scriptable-dns-types/`](packages/scriptable-dns-types/) | `@bunny.net/scriptable-dns-types` | Ambient TypeScript types for the Scriptable DNS runtime | See each package's README for usage and API documentation. @@ -69,6 +69,8 @@ bun ny sites open # open the site's live URL in the br bun ny sites ci init # add a GitHub Actions workflow (preview on PRs, 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`. See [`examples/sites/`](examples/sites/) for ready-to-copy configs (Vite, Astro, Next.js static export, Hugo, plain HTML, and a combined app + site file). + ### Available Scripts ```bash diff --git a/bun.lock b/bun.lock index 9a3c8f32..4bbee803 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "bun-ny-cli", @@ -15,22 +14,14 @@ "typescript": "^5", }, }, - "packages/app-config": { - "name": "@bunny.net/app-config", - "version": "0.1.2", - "dependencies": { - "@bunny.net/openapi-client": "workspace:*", - "zod": "^4.3.6", - }, - }, "packages/cli": { "name": "@bunny.net/cli", - "version": "0.8.1", + "version": "0.9.1", "bin": { "bunny": "./bin/bunny.cjs", }, "devDependencies": { - "@bunny.net/app-config": "workspace:*", + "@bunny.net/config": "workspace:*", "@bunny.net/database-shell": "workspace:*", "@bunny.net/database-studio": "workspace:*", "@bunny.net/openapi-client": "workspace:*", @@ -49,32 +40,43 @@ "zod": "^4.3.6", }, "optionalDependencies": { - "@bunny.net/cli-darwin-arm64": "0.8.1", - "@bunny.net/cli-darwin-x64": "0.8.1", - "@bunny.net/cli-linux-arm64": "0.8.1", - "@bunny.net/cli-linux-x64": "0.8.1", - "@bunny.net/cli-windows-x64": "0.8.1", + "@bunny.net/cli-darwin-arm64": "0.9.1", + "@bunny.net/cli-darwin-x64": "0.9.1", + "@bunny.net/cli-linux-arm64": "0.9.1", + "@bunny.net/cli-linux-x64": "0.9.1", + "@bunny.net/cli-windows-x64": "0.9.1", }, }, "packages/cli-darwin-arm64": { "name": "@bunny.net/cli-darwin-arm64", - "version": "0.8.1", + "version": "0.9.1", }, "packages/cli-darwin-x64": { "name": "@bunny.net/cli-darwin-x64", - "version": "0.8.1", + "version": "0.9.1", }, "packages/cli-linux-arm64": { "name": "@bunny.net/cli-linux-arm64", - "version": "0.8.1", + "version": "0.9.1", }, "packages/cli-linux-x64": { "name": "@bunny.net/cli-linux-x64", - "version": "0.8.1", + "version": "0.9.1", }, "packages/cli-windows-x64": { "name": "@bunny.net/cli-windows-x64", - "version": "0.8.1", + "version": "0.9.1", + }, + "packages/config": { + "name": "@bunny.net/config", + "version": "0.1.2", + "dependencies": { + "@bunny.net/openapi-client": "workspace:*", + "zod": "^4.3.6", + }, + "devDependencies": { + "typescript": "^5", + }, }, "packages/database-adapter-libsql": { "name": "@bunny.net/database-adapter-libsql", @@ -186,7 +188,7 @@ }, "packages/sandbox": { "name": "@bunny.net/sandbox", - "version": "0.2.1", + "version": "0.3.0", "dependencies": { "@bunny.net/openapi-client": "workspace:*", "@types/ssh2": "^1.15.0", @@ -260,8 +262,6 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6uxpR9hvaglANkZemeSiN/FhYgkGasrEGn267eXIWvjrjJ2LhDlk251IhjVJq6MXzkV2/bcXwLwSroLyPtqRZg=="], - "@bunny.net/app-config": ["@bunny.net/app-config@workspace:packages/app-config"], - "@bunny.net/cli": ["@bunny.net/cli@workspace:packages/cli"], "@bunny.net/cli-darwin-arm64": ["@bunny.net/cli-darwin-arm64@workspace:packages/cli-darwin-arm64"], @@ -274,6 +274,8 @@ "@bunny.net/cli-windows-x64": ["@bunny.net/cli-windows-x64@workspace:packages/cli-windows-x64"], + "@bunny.net/config": ["@bunny.net/config@workspace:packages/config"], + "@bunny.net/database-adapter-libsql": ["@bunny.net/database-adapter-libsql@workspace:packages/database-adapter-libsql"], "@bunny.net/database-openapi": ["@bunny.net/database-openapi@workspace:packages/database-openapi"], diff --git a/examples/sites/README.md b/examples/sites/README.md new file mode 100644 index 00000000..8890351e --- /dev/null +++ b/examples/sites/README.md @@ -0,0 +1,60 @@ +# Sites config examples + +Drop-in `bunny.jsonc` files for `bunny sites`. Each one preconfigures the +`sites` block so a deploy needs no flags: the CLI reads the site to target, the +build command to run, and the directory to upload straight from the file. + +## The `sites` block + +| Field | Purpose | Required | +| ------- | --------------------------------------------------------------------------------------------------- | -------- | +| `name` | Links the directory to a site (created with `bunny sites create `), so deploys skip `--site`. | No | +| `build` | The command `bunny sites deploy --build` runs before uploading. | No | +| `dir` | The directory that gets uploaded. Defaults to the detected framework's output dir, then the cwd. | No | + +Every field is optional, and the whole block is optional. A file with only a +`sites` block validates fine: `app` is not required. (`$schema` points at the +JSON Schema for editor autocompletion; it resolves once `@bunny.net/cli` is +installed.) + +## What "preconfigured" buys you + +With `name`, `build`, and `dir` set, the entire deploy is one command: + +```bash +bun ny sites deploy --build # runs `build`, uploads `dir`, to a preview URL +bun ny sites deploy --build --prod # same, published as the live site +``` + +No `--site`, no build command, no directory argument. Without the config you'd +type them each time: + +```bash +bun ny sites deploy dist --build "npm run build" --site acme-app --prod +``` + +## Examples + +| Directory | Framework | `build` | `dir` | +| ----------------------------------- | ----------------------- | --------------- | -------- | +| [`vite/`](./vite) | Vite | `npm run build` | `dist` | +| [`astro/`](./astro) | Astro | `npm run build` | `dist` | +| [`nextjs-static/`](./nextjs-static) | Next.js (static export) | `npm run build` | `out` | +| [`hugo/`](./hugo) | Hugo | `hugo --minify` | `public` | +| [`static-html/`](./static-html) | Plain HTML (no build) | - | `.` | +| [`app-and-site/`](./app-and-site) | Magic Containers + site | `npm run build` | `dist` | + +`app-and-site/` shows both blocks in one file: `bunny apps deploy` reads `app`, +`bunny sites deploy` reads `sites`, and each ignores the other. + +## Typical setup + +```bash +bun ny sites create acme-app # provision the site + set `sites.name` +# ...author bunny.jsonc from one of these examples... +bun ny sites deploy --build --prod +``` + +For other frameworks, set `dir` to the framework's output folder (Gatsby +`public`, SvelteKit `build`, Eleventy `_site`, ...); the CLI also detects most of +these automatically when `dir` is omitted. diff --git a/examples/sites/app-and-site/bunny.jsonc b/examples/sites/app-and-site/bunny.jsonc new file mode 100644 index 00000000..5a87c2a2 --- /dev/null +++ b/examples/sites/app-and-site/bunny.jsonc @@ -0,0 +1,27 @@ +{ + "$schema": "./node_modules/@bunny.net/config/generated/schema.json", + "version": "2026-05-11", + // One bunny.jsonc can hold both a Magic Containers `app` and a static `sites` block. + // `bunny apps deploy` reads `app`; `bunny sites deploy` reads `sites`. Each ignores the other. + "app": { + "name": "acme-api", + "regions": ["DE"], + "containers": { + "api": { + "image": "ghcr.io/acme/api:latest", + "endpoints": [ + { + "type": "cdn", + "ssl": true, + "ports": [{ "public": 443, "container": 8080 }] + } + ] + } + } + }, + "sites": { + "name": "acme-www", + "build": "npm run build", + "dir": "dist" + } +} diff --git a/examples/sites/astro/bunny.jsonc b/examples/sites/astro/bunny.jsonc new file mode 100644 index 00000000..5bbec36c --- /dev/null +++ b/examples/sites/astro/bunny.jsonc @@ -0,0 +1,10 @@ +{ + "$schema": "./node_modules/@bunny.net/config/generated/schema.json", + "version": "2026-05-11", + "sites": { + "name": "acme-blog", + "build": "npm run build", + // Astro's default static output. + "dir": "dist" + } +} diff --git a/examples/sites/hugo/bunny.jsonc b/examples/sites/hugo/bunny.jsonc new file mode 100644 index 00000000..f57521ca --- /dev/null +++ b/examples/sites/hugo/bunny.jsonc @@ -0,0 +1,10 @@ +{ + "$schema": "./node_modules/@bunny.net/config/generated/schema.json", + "version": "2026-05-11", + "sites": { + "name": "acme-docs", + // Hugo builds directly (no package manager). + "build": "hugo --minify", + "dir": "public" + } +} diff --git a/examples/sites/nextjs-static/bunny.jsonc b/examples/sites/nextjs-static/bunny.jsonc new file mode 100644 index 00000000..219409f3 --- /dev/null +++ b/examples/sites/nextjs-static/bunny.jsonc @@ -0,0 +1,10 @@ +{ + "$schema": "./node_modules/@bunny.net/config/generated/schema.json", + "version": "2026-05-11", + "sites": { + "name": "acme-landing", + // Requires `output: "export"` in next.config.js so the build emits static HTML. + "build": "npm run build", + "dir": "out" + } +} diff --git a/examples/sites/static-html/bunny.jsonc b/examples/sites/static-html/bunny.jsonc new file mode 100644 index 00000000..1a7e7651 --- /dev/null +++ b/examples/sites/static-html/bunny.jsonc @@ -0,0 +1,9 @@ +{ + "$schema": "./node_modules/@bunny.net/config/generated/schema.json", + "version": "2026-05-11", + "sites": { + "name": "acme-status", + // No build step: plain HTML lives in this directory, so `dir` is the project root. + "dir": "." + } +} diff --git a/examples/sites/vite/bunny.jsonc b/examples/sites/vite/bunny.jsonc new file mode 100644 index 00000000..24522fda --- /dev/null +++ b/examples/sites/vite/bunny.jsonc @@ -0,0 +1,12 @@ +{ + "$schema": "./node_modules/@bunny.net/config/generated/schema.json", + "version": "2026-05-11", + "sites": { + // Links this directory to a site created with `bunny sites create acme-app`, so deploys need no --site flag. + "name": "acme-app", + // `bunny sites deploy --build` runs this before uploading. + "build": "npm run build", + // Vite writes to dist/; the deploy root. + "dir": "dist" + } +} diff --git a/packages/app-config/package.json b/packages/app-config/package.json deleted file mode 100644 index 91e974d2..00000000 --- a/packages/app-config/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@bunny.net/app-config", - "version": "0.1.2", - "private": true, - "type": "module", - "module": "src/index.ts", - "exports": { - ".": "./src/index.ts", - "./schema.json": "./generated/schema.json" - }, - "scripts": { - "generate:schema": "bun run scripts/generate-schema.ts" - }, - "dependencies": { - "@bunny.net/openapi-client": "workspace:*", - "zod": "^4.3.6" - } -} diff --git a/packages/cli/package.json b/packages/cli/package.json index 31fe53de..db634b7d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -11,7 +11,7 @@ ], "devDependencies": { "@bunny.net/openapi-client": "workspace:*", - "@bunny.net/app-config": "workspace:*", + "@bunny.net/config": "workspace:*", "@bunny.net/storage-sdk": "^0.3.1", "@bunny.net/database-shell": "workspace:*", "@bunny.net/sandbox": "workspace:*", diff --git a/packages/cli/src/commands/apps/APPS.md b/packages/cli/src/commands/apps/APPS.md index c073da8b..89febe5f 100644 --- a/packages/cli/src/commands/apps/APPS.md +++ b/packages/cli/src/commands/apps/APPS.md @@ -293,7 +293,7 @@ A single-container app: ```jsonc { - "$schema": "./node_modules/@bunny.net/app-config/generated/schema.json", + "$schema": "./node_modules/@bunny.net/config/generated/schema.json", "version": "2026-05-11", "app": { "id": "app_xxx", // written by the CLI on first deploy diff --git a/packages/cli/src/commands/apps/compose/translate.ts b/packages/cli/src/commands/apps/compose/translate.ts index 452a4488..d9c4a9f8 100644 --- a/packages/cli/src/commands/apps/compose/translate.ts +++ b/packages/cli/src/commands/apps/compose/translate.ts @@ -6,8 +6,8 @@ import type { EndpointConfig, ProbeConfig, VolumeConfig, -} from "@bunny.net/app-config"; -import { CURRENT_VERSION } from "@bunny.net/app-config"; +} from "@bunny.net/config"; +import { CURRENT_VERSION } from "@bunny.net/config"; import { UserError } from "../../../core/errors.ts"; import { parseDotenv } from "../env/parse.ts"; import { parsePortMapping } from "./ports.ts"; diff --git a/packages/cli/src/commands/apps/config.test.ts b/packages/cli/src/commands/apps/config.test.ts index 7aaa8cb0..3cf297d2 100644 --- a/packages/cli/src/commands/apps/config.test.ts +++ b/packages/cli/src/commands/apps/config.test.ts @@ -156,6 +156,41 @@ test("saveConfig strips id/registry/transient-image on disk", () => { expect(parsed.app.id).toBeUndefined(); }); +test("saveConfig preserves comments and a sibling sites block on an existing file", () => { + const path = join(tempDir(), "mixed.jsonc"); + writeFileSync( + path, + `{ + // my deploy config + "version": "2026-05-11", + "app": { + "name": "demo", + "id": "app_legacy", + "containers": { + "api": { "dockerfile": "Dockerfile", "registry": "7545" } + } + }, + "sites": { "dir": "dist" } +} +`, + ); + + saveConfig(loadConfig(path), path); + + const text = readFileSync(path, "utf-8"); + expect(text).toContain("// my deploy config"); + + const parsed = JSON.parse(text.replace(/\/\/.*$/gm, "")) as Record< + string, + any + >; + // Transient fields stripped, the hand-authored sites block survives. + expect(parsed.app.id).toBeUndefined(); + expect(parsed.app.containers.api.registry).toBeUndefined(); + expect(parsed.app.containers.api.dockerfile).toBe("Dockerfile"); + expect(parsed.sites).toEqual({ dir: "dist" }); +}); + test("load → save → reload preserves intent fields", () => { const path = join(tempDir(), "rt.jsonc"); writeFileSync( diff --git a/packages/cli/src/commands/apps/config.ts b/packages/cli/src/commands/apps/config.ts index 0baa3bba..90cb4bb0 100644 --- a/packages/cli/src/commands/apps/config.ts +++ b/packages/cli/src/commands/apps/config.ts @@ -1,26 +1,30 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { - type BunnyAppConfig, - BunnyAppConfigSchema, -} from "@bunny.net/app-config"; +import { join } from "node:path"; +import { type BunnyAppConfig, BunnyAppConfigSchema } from "@bunny.net/config"; import type { components } from "@bunny.net/openapi-client/generated/magic-containers.d.ts"; -import { parse as parseJsonc } from "jsonc-parser"; +import { + CONFIG_FILENAME, + configExists, + configPath, + readBunnyConfig, +} from "../../core/bunny-config.ts"; import { UserError } from "../../core/errors.ts"; +import { syncJsonc } from "../../core/jsonc.ts"; import { logger } from "../../core/logger.ts"; import { loadManifest } from "../../core/manifest.ts"; import { APP_MANIFEST, type AppManifest } from "./constants.ts"; type Application = components["schemas"]["Application"]; -const CONFIG_FILENAME = "bunny.jsonc"; +// The `$schema` reference written into `bunny.jsonc`; resolves in a consumer's node_modules for editor validation. +const SCHEMA_REF = "./node_modules/@bunny.net/config/generated/schema.json"; // Re-export types and conversion functions for convenience export type { BunnyAppConfig, ContainerConfig, RegionsConfig, -} from "@bunny.net/app-config"; +} from "@bunny.net/config"; export { apiToConfig, CURRENT_VERSION, @@ -28,44 +32,35 @@ export { configToPatchRequest, normalizeRegions, parseImageRef, -} from "@bunny.net/app-config"; - -function findConfigRoot(): string { - let dir = resolve(process.cwd()); - - while (true) { - if (existsSync(join(dir, CONFIG_FILENAME))) return dir; - const parent = dirname(dir); - if (parent === dir) return process.cwd(); - dir = parent; - } -} +} from "@bunny.net/config"; +// `bunny.jsonc` discovery lives in core so apps and sites share one walk-up. +export { configExists }; /** * Load and parse the app config. * * When `explicitPath` is given (e.g. from `--config `), that file * is loaded verbatim. Otherwise we walk up from cwd looking for - * `bunny.jsonc`. + * `bunny.jsonc`. The `app` block is required here (see `BunnyAppConfigSchema`); + * a sites-only file is read via `sites/config.ts` instead. */ export function loadConfig(explicitPath?: string): BunnyAppConfig { - const jsoncPath = explicitPath ?? join(findConfigRoot(), CONFIG_FILENAME); - - if (!existsSync(jsoncPath)) { + const found = readBunnyConfig(explicitPath); + if (!found) { throw new UserError( - `No config file found at ${jsoncPath}.`, + `No config file found at ${configPath(explicitPath)}.`, "Run `bunny apps init` first, or pass --config .", ); } - const raw = parseJsonc(readFileSync(jsoncPath, "utf-8")); - if (raw && typeof raw === "object" && !("version" in raw)) { + const { data, path } = found; + if (data && typeof data === "object" && !("version" in data)) { throw new UserError( - `${jsoncPath} is missing the \`version\` field.`, + `${path} is missing the \`version\` field.`, "Run `bunny apps pull` to regenerate it from the remote app.", ); } - return BunnyAppConfigSchema.parse(raw); + return BunnyAppConfigSchema.parse(data); } /** @@ -116,32 +111,24 @@ export function stripTransientFields(data: BunnyAppConfig): BunnyAppConfig { * Transient fields (see {@link stripTransientFields}) are removed before * write - callers can freely mutate the in-memory `image` field during a * deploy without polluting the on-disk config. + * + * An existing file is edited surgically (see {@link syncJsonc}), so comments, + * key order, and any sibling blocks (such as `sites`) are preserved. A new + * file is serialized fresh, starting with $schema → version → app. */ export function saveConfig(data: BunnyAppConfig, explicitPath?: string): void { const path = explicitPath ?? join(process.cwd(), CONFIG_FILENAME); const cleaned = stripTransientFields(data); - // Re-key the object so the file always starts with $schema → version → app. + // Re-key so a freshly written file starts with $schema → version → app. const { $schema: _schema, version, ...rest } = cleaned; - const output = { - $schema: "./node_modules/@bunny.net/app-config/generated/schema.json", - version, - ...rest, - }; - - writeFileSync(path, `${JSON.stringify(output, null, 2)}\n`); -} + const output = { $schema: SCHEMA_REF, version, ...rest }; -/** - * Check whether an app config exists. - * - * When `explicitPath` is given we check that exact file; otherwise we - * walk up from cwd looking for `bunny.jsonc`. - */ -export function configExists(explicitPath?: string): boolean { - if (explicitPath) return existsSync(explicitPath); - const root = findConfigRoot(); - return existsSync(join(root, CONFIG_FILENAME)); + if (existsSync(path)) { + writeFileSync(path, syncJsonc(readFileSync(path, "utf-8"), output)); + } else { + writeFileSync(path, `${JSON.stringify(output, null, 2)}\n`); + } } /** diff --git a/packages/cli/src/commands/apps/deploy.ts b/packages/cli/src/commands/apps/deploy.ts index ab50533a..830ea3a4 100644 --- a/packages/cli/src/commands/apps/deploy.ts +++ b/packages/cli/src/commands/apps/deploy.ts @@ -1,6 +1,6 @@ import { existsSync } from "node:fs"; import { dirname, isAbsolute, resolve } from "node:path"; -import type { RegistryMap } from "@bunny.net/app-config"; +import type { RegistryMap } from "@bunny.net/config"; import { createMcClient } from "@bunny.net/openapi-client"; import { resolveConfig } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; diff --git a/packages/cli/src/commands/apps/env/resolve.test.ts b/packages/cli/src/commands/apps/env/resolve.test.ts index e06397de..639721d6 100644 --- a/packages/cli/src/commands/apps/env/resolve.test.ts +++ b/packages/cli/src/commands/apps/env/resolve.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { writeFileSync } from "node:fs"; import { join } from "node:path"; -import { CURRENT_VERSION } from "@bunny.net/app-config"; +import { CURRENT_VERSION } from "@bunny.net/config"; import { useTempDir } from "../../../test-utils/temp-dir.ts"; import type { BunnyAppConfig } from "../config.ts"; import { resolveContainerEnv } from "./resolve.ts"; diff --git a/packages/cli/src/commands/apps/push.ts b/packages/cli/src/commands/apps/push.ts index a76a5ff7..b491b2d5 100644 --- a/packages/cli/src/commands/apps/push.ts +++ b/packages/cli/src/commands/apps/push.ts @@ -1,4 +1,4 @@ -import type { RegistryMap } from "@bunny.net/app-config"; +import type { RegistryMap } from "@bunny.net/config"; import { createMcClient } from "@bunny.net/openapi-client"; import { resolveConfig } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; diff --git a/packages/cli/src/commands/sites/config.ts b/packages/cli/src/commands/sites/config.ts index e1addd80..ba6506e4 100644 --- a/packages/cli/src/commands/sites/config.ts +++ b/packages/cli/src/commands/sites/config.ts @@ -1,40 +1,29 @@ -import { existsSync, readFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { type SiteConfig, SiteConfigSchema } from "@bunny.net/app-config"; -import { parse as parseJsonc } from "jsonc-parser"; +import { type SiteConfig, SiteConfigSchema } from "@bunny.net/config"; +import { readBunnyConfig } from "../../core/bunny-config.ts"; import { UserError } from "../../core/errors.ts"; -const CONFIG_FILENAME = "bunny.jsonc"; - export interface LoadedSiteConfig { config: SiteConfig; /** Directory containing bunny.jsonc; `sites.dir` resolves against this. */ root: string; } -// Read the optional `sites` block from `bunny.jsonc` (walking up from cwd); only that block is validated, so a sites-only file works without an `app` block and a file with no `sites` block returns null. +// Read the optional `sites` block from `bunny.jsonc` (walking up from cwd); only that block is validated, so a sites-only file works without an `app` block (or even a `version`) and a file with no `sites` block returns null. export function loadSiteConfig(): LoadedSiteConfig | null { - let dir = resolve(process.cwd()); - while (true) { - const path = join(dir, CONFIG_FILENAME); - if (existsSync(path)) { - const raw = parseJsonc(readFileSync(path, "utf-8")); - const sites = (raw as Record | null)?.sites; - if (sites === undefined || sites === null) return null; + const found = readBunnyConfig(); + if (!found) return null; + + const sites = (found.data as Record | null)?.sites; + if (sites === undefined || sites === null) return null; - const parsed = SiteConfigSchema.safeParse(sites); - if (!parsed.success) { - throw new UserError( - `Invalid \`sites\` block in ${path}.`, - parsed.error.issues - .map((i) => `${i.path.join(".") || "sites"}: ${i.message}`) - .join("; "), - ); - } - return { config: parsed.data, root: dir }; - } - const parent = dirname(dir); - if (parent === dir) return null; - dir = parent; + const parsed = SiteConfigSchema.safeParse(sites); + if (!parsed.success) { + throw new UserError( + `Invalid \`sites\` block in ${found.path}.`, + parsed.error.issues + .map((i) => `${i.path.join(".") || "sites"}: ${i.message}`) + .join("; "), + ); } + return { config: parsed.data, root: found.root }; } diff --git a/packages/cli/src/core/bunny-config.ts b/packages/cli/src/core/bunny-config.ts new file mode 100644 index 00000000..2a67e710 --- /dev/null +++ b/packages/cli/src/core/bunny-config.ts @@ -0,0 +1,46 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { parse as parseJsonc } from "jsonc-parser"; + +// The single `bunny.jsonc` resource file; holds the optional `app` and `sites` blocks (see `@bunny.net/config`). +export const CONFIG_FILENAME = "bunny.jsonc"; + +// Walk up from cwd to the directory holding `bunny.jsonc`, or null when none exists. +export function findConfigRoot(): string | null { + let dir = resolve(process.cwd()); + while (true) { + if (existsSync(join(dir, CONFIG_FILENAME))) return dir; + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +// The path a command reads: an explicit `--config` path, the nearest ancestor `bunny.jsonc`, or cwd's `bunny.jsonc` as the default target. +export function configPath(explicitPath?: string): string { + if (explicitPath) return explicitPath; + return join(findConfigRoot() ?? process.cwd(), CONFIG_FILENAME); +} + +// Whether a `bunny.jsonc` exists at the explicit path, or anywhere up the tree. +export function configExists(explicitPath?: string): boolean { + if (explicitPath) return existsSync(explicitPath); + return findConfigRoot() !== null; +} + +export interface RawBunnyConfig { + /** Parsed JSONC value; validate this against the schema you need. */ + data: unknown; + /** The `bunny.jsonc` path that was read. */ + path: string; + /** Directory containing `bunny.jsonc`; relative paths resolve against this. */ + root: string; +} + +// Locate and parse `bunny.jsonc` (walking up from cwd unless an explicit path is given); returns null when the file doesn't exist. Callers apply their own schema validation. +export function readBunnyConfig(explicitPath?: string): RawBunnyConfig | null { + const path = configPath(explicitPath); + if (!existsSync(path)) return null; + const data = parseJsonc(readFileSync(path, "utf-8")); + return { data, path, root: dirname(path) }; +} diff --git a/packages/cli/src/core/jsonc.test.ts b/packages/cli/src/core/jsonc.test.ts new file mode 100644 index 00000000..70b1862e --- /dev/null +++ b/packages/cli/src/core/jsonc.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +import { parse as parseJsonc } from "jsonc-parser"; +import { syncJsonc } from "./jsonc.ts"; + +describe("syncJsonc", () => { + test("preserves comments on untouched keys", () => { + const text = `{ + // the app to deploy + "version": "2026-05-11", + "app": { + "name": "demo" // display name + } +}`; + const out = syncJsonc(text, { + version: "2026-05-11", + app: { name: "demo" }, + }); + expect(out).toContain("// the app to deploy"); + expect(out).toContain("// display name"); + }); + + test("updates only the changed leaf, keeping surrounding comments", () => { + const text = `{ + // keep me + "version": "2026-05-11", + "app": { "name": "old" } +}`; + const out = syncJsonc(text, { + version: "2026-05-11", + app: { name: "new" }, + }); + expect(out).toContain("// keep me"); + expect(parseJsonc(out).app.name).toBe("new"); + }); + + test("appends new keys and removes absent ones", () => { + const text = `{ "version": "2026-05-11", "app": { "name": "x", "id": "app_1" } }`; + const out = syncJsonc(text, { + version: "2026-05-11", + app: { name: "x" }, + sites: { dir: "dist" }, + }); + const parsed = parseJsonc(out); + expect(parsed.app.id).toBeUndefined(); + expect(parsed.sites).toEqual({ dir: "dist" }); + }); + + test("leaves an already-matching object byte-identical", () => { + const text = `{ + "version": "2026-05-11", + "app": { "name": "x" } +}`; + const out = syncJsonc(text, { + version: "2026-05-11", + app: { name: "x" }, + }); + expect(out).toBe(`${text}\n`); + }); + + test("serializes fresh when the input is not a JSON object", () => { + const out = syncJsonc("", { version: "2026-05-11" }); + expect(parseJsonc(out)).toEqual({ version: "2026-05-11" }); + }); +}); diff --git a/packages/cli/src/core/jsonc.ts b/packages/cli/src/core/jsonc.ts new file mode 100644 index 00000000..3a18a716 --- /dev/null +++ b/packages/cli/src/core/jsonc.ts @@ -0,0 +1,69 @@ +import { + applyEdits, + type FormattingOptions, + modify, + parse as parseJsonc, +} from "jsonc-parser"; + +const FORMATTING: FormattingOptions = { tabSize: 2, insertSpaces: true }; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// Arrays and primitives are compared whole; both are JSON-safe here so serialization order is stable. +function equalLeaf(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +// Return `text` with the value at `path` reconciled to `desired`, recursing into objects so comments and formatting on untouched keys survive. Each edit is applied before the next is computed so offsets stay valid. +function reconcile( + text: string, + path: (string | number)[], + existing: unknown, + desired: unknown, +): string { + if (isPlainObject(desired) && isPlainObject(existing)) { + let out = text; + for (const [key, value] of Object.entries(desired)) { + out = reconcile(out, [...path, key], existing[key], value); + } + for (const key of Object.keys(existing)) { + if (!(key in desired)) { + out = applyEdits( + out, + modify(out, [...path, key], undefined, { + formattingOptions: FORMATTING, + }), + ); + } + } + return out; + } + + if (equalLeaf(existing, desired)) return text; + return applyEdits( + text, + modify(text, path, desired, { formattingOptions: FORMATTING }), + ); +} + +/** + * Surgically edit JSONC `text` so it matches `desired`, preserving comments, + * key order, and formatting on everything that didn't change. New keys are + * appended; keys absent from `desired` are removed. + * + * Falls back to a fresh serialization when `text` isn't a JSON object (empty + * file, array, or garbage) so callers always get a valid result. + */ +export function syncJsonc( + text: string, + desired: Record, +): string { + const existing = parseJsonc(text); + if (!isPlainObject(existing)) { + return `${JSON.stringify(desired, null, 2)}\n`; + } + const merged = reconcile(text, [], existing, desired); + return merged.endsWith("\n") ? merged : `${merged}\n`; +} diff --git a/packages/app-config/CHANGELOG.md b/packages/config/CHANGELOG.md similarity index 97% rename from packages/app-config/CHANGELOG.md rename to packages/config/CHANGELOG.md index 7df6b877..2fca2d32 100644 --- a/packages/app-config/CHANGELOG.md +++ b/packages/config/CHANGELOG.md @@ -1,4 +1,4 @@ -# @bunny.net/app-config +# @bunny.net/config ## 0.1.2 diff --git a/packages/app-config/README.md b/packages/config/README.md similarity index 57% rename from packages/app-config/README.md rename to packages/config/README.md index de6b6611..ea338039 100644 --- a/packages/app-config/README.md +++ b/packages/config/README.md @@ -1,11 +1,11 @@ -# @bunny.net/app-config +# @bunny.net/config -Shared Zod schemas, inferred types, JSON Schema, and API conversion functions for `bunny.jsonc` app configuration files. +Shared Zod schemas, inferred types, JSON Schema, and API conversion functions for `bunny.jsonc`, the single bunny.net project config file. It holds an optional `app` block (Magic Containers) and an optional `sites` block (static sites); both are optional, so app-only, sites-only, and combined files all validate. ## Installation ```bash -bun add @bunny.net/app-config +bun add @bunny.net/config ``` Requires `@bunny.net/openapi-client` as a peer dependency (for API type definitions used by conversion functions). @@ -14,7 +14,7 @@ Requires `@bunny.net/openapi-client` as a peer dependency (for API type definiti ```jsonc { - "$schema": "./node_modules/@bunny.net/app-config/generated/schema.json", + "$schema": "./node_modules/@bunny.net/config/generated/schema.json", "app": { "name": "my-app", "scaling": { "min": 1, "max": 3 }, @@ -44,19 +44,24 @@ The `$schema` property enables editor autocompletion and validation. Zod schemas define the config structure. Types are inferred from schemas (single source of truth). -| Schema | Type | Description | -| ----------------------- | ----------------- | ----------------------------------- | -| `BunnyAppConfigSchema` | `BunnyAppConfig` | Root config (app + containers) | -| `ContainerConfigSchema` | `ContainerConfig` | Container: image, env, probes, etc. | -| `EndpointConfigSchema` | `EndpointConfig` | CDN or Anycast endpoint | -| `VolumeConfigSchema` | `VolumeConfig` | Persistent volume mount | -| `ProbeConfigSchema` | `ProbeConfig` | Health check probe (http/tcp/grpc) | +| Schema | Type | Description | +| ----------------------- | ----------------- | ------------------------------------------------------ | +| `BunnyConfigSchema` | `BunnyConfig` | The whole file; `app` and `sites` both optional | +| `AppConfigSchema` | `AppConfig` | The `app` block: name, scaling, regions, containers | +| `SiteConfigSchema` | `SiteConfig` | The `sites` block: `name`, `dir`, `build` | +| `BunnyAppConfigSchema` | `BunnyAppConfig` | `BunnyConfigSchema` narrowed to require an `app` block | +| `ContainerConfigSchema` | `ContainerConfig` | Container: image, env, probes, etc. | +| `EndpointConfigSchema` | `EndpointConfig` | CDN or Anycast endpoint | +| `VolumeConfigSchema` | `VolumeConfig` | Persistent volume mount | +| `ProbeConfigSchema` | `ProbeConfig` | Health check probe (http/tcp/grpc) | + +`BunnyConfigSchema` is the permissive root the generated JSON Schema is built from. `BunnyAppConfigSchema` is the same shape with `app` required, used by the apps commands and the API conversion functions. ```typescript -import { BunnyAppConfigSchema, type BunnyAppConfig } from "@bunny.net/app-config"; +import { BunnyConfigSchema, type BunnyConfig } from "@bunny.net/config"; -// Validate unknown data -const config = BunnyAppConfigSchema.parse(data); +// Validate a whole bunny.jsonc (app-only, sites-only, or combined) +const config = BunnyConfigSchema.parse(data); ``` ## API Conversion Functions @@ -64,7 +69,7 @@ const config = BunnyAppConfigSchema.parse(data); Convert between local config format and the Magic Containers API: ```typescript -import { apiToConfig, configToAddRequest, configToPatchRequest } from "@bunny.net/app-config"; +import { apiToConfig, configToAddRequest, configToPatchRequest } from "@bunny.net/config"; // API response -> local config const config = apiToConfig(apiResponse); @@ -79,7 +84,7 @@ const patchRequest = configToPatchRequest(config, existingApp); ## Utilities ```typescript -import { parseImageRef } from "@bunny.net/app-config"; +import { parseImageRef } from "@bunny.net/config"; const { imageName, imageNamespace, imageTag } = parseImageRef( "registry.example.com/myorg/api:v1.2", diff --git a/packages/app-config/generated/schema.json b/packages/config/generated/schema.json similarity index 99% rename from packages/app-config/generated/schema.json rename to packages/config/generated/schema.json index c635d4c5..aecc5d0c 100644 --- a/packages/app-config/generated/schema.json +++ b/packages/config/generated/schema.json @@ -227,6 +227,6 @@ "additionalProperties": false } }, - "required": ["version", "app"], + "required": ["version"], "additionalProperties": false } diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 00000000..3db35926 --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,43 @@ +{ + "name": "@bunny.net/config", + "version": "0.1.2", + "description": "Shared Zod schemas, types, JSON Schema, and API conversion for the bunny.jsonc config file.", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "generate:schema": "bun run scripts/generate-schema.ts", + "build": "rm -rf dist && bun run generate:schema && tsc -p tsconfig.build.json" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./schema.json": "./generated/schema.json" + }, + "files": [ + "dist", + "generated", + "README.md" + ], + "keywords": [ + "bunny", + "config", + "bunny.jsonc", + "schema", + "zod" + ], + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@bunny.net/openapi-client": "workspace:*", + "zod": "^4.3.6" + }, + "devDependencies": { + "typescript": "^5" + } +} diff --git a/packages/app-config/scripts/generate-schema.ts b/packages/config/scripts/generate-schema.ts similarity index 67% rename from packages/app-config/scripts/generate-schema.ts rename to packages/config/scripts/generate-schema.ts index a6722dc7..9665ee9e 100644 --- a/packages/app-config/scripts/generate-schema.ts +++ b/packages/config/scripts/generate-schema.ts @@ -1,7 +1,7 @@ import { z } from "zod"; -import { BunnyAppConfigSchema } from "../src/schema.ts"; +import { BunnyConfigSchema } from "../src/schema.ts"; -const jsonSchema = z.toJSONSchema(BunnyAppConfigSchema, { +const jsonSchema = z.toJSONSchema(BunnyConfigSchema, { target: "draft-2020-12", }); diff --git a/packages/app-config/src/convert.test.ts b/packages/config/src/convert.test.ts similarity index 100% rename from packages/app-config/src/convert.test.ts rename to packages/config/src/convert.test.ts diff --git a/packages/app-config/src/convert.ts b/packages/config/src/convert.ts similarity index 100% rename from packages/app-config/src/convert.ts rename to packages/config/src/convert.ts diff --git a/packages/app-config/src/index.ts b/packages/config/src/index.ts similarity index 89% rename from packages/app-config/src/index.ts rename to packages/config/src/index.ts index 0cffb31a..8e9f321f 100644 --- a/packages/app-config/src/index.ts +++ b/packages/config/src/index.ts @@ -11,7 +11,9 @@ export { export { parseImageRef } from "./parse-image-ref.ts"; // Types export type { + AppConfig, BunnyAppConfig, + BunnyConfig, ContainerConfig, EndpointConfig, ProbeConfig, @@ -20,7 +22,9 @@ export type { VolumeConfig, } from "./schema.ts"; export { + AppConfigSchema, BunnyAppConfigSchema, + BunnyConfigSchema, ContainerConfigSchema, CURRENT_VERSION, EndpointConfigSchema, diff --git a/packages/app-config/src/parse-image-ref.test.ts b/packages/config/src/parse-image-ref.test.ts similarity index 100% rename from packages/app-config/src/parse-image-ref.test.ts rename to packages/config/src/parse-image-ref.test.ts diff --git a/packages/app-config/src/parse-image-ref.ts b/packages/config/src/parse-image-ref.ts similarity index 100% rename from packages/app-config/src/parse-image-ref.ts rename to packages/config/src/parse-image-ref.ts diff --git a/packages/app-config/src/schema.test.ts b/packages/config/src/schema.test.ts similarity index 100% rename from packages/app-config/src/schema.test.ts rename to packages/config/src/schema.test.ts diff --git a/packages/app-config/src/schema.ts b/packages/config/src/schema.ts similarity index 79% rename from packages/app-config/src/schema.ts rename to packages/config/src/schema.ts index 34ceecae..ace9a760 100644 --- a/packages/app-config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -74,19 +74,30 @@ export const SiteConfigSchema = z.object({ build: z.string().optional(), }); -export const BunnyAppConfigSchema = z.object({ +// The `app` block: Magic Containers deploy intent (name, scaling, regions, containers). +export const AppConfigSchema = z.object({ + id: z.string().optional(), + name: z.string(), + scaling: z.object({ min: z.number(), max: z.number() }).optional(), + regions: RegionsConfigSchema.optional(), + containers: z.record(z.string(), ContainerConfigSchema), +}); + +// The whole `bunny.jsonc` file; every resource block is optional so app-only, sites-only, and combined files all validate. +export const BunnyConfigSchema = z.object({ $schema: z.string().optional(), version: VersionSchema, - app: z.object({ - id: z.string().optional(), - name: z.string(), - scaling: z.object({ min: z.number(), max: z.number() }).optional(), - regions: RegionsConfigSchema.optional(), - containers: z.record(z.string(), ContainerConfigSchema), - }), + app: AppConfigSchema.optional(), sites: SiteConfigSchema.optional(), }); +// `BunnyConfigSchema` narrowed to require the `app` block; used by the apps commands and API conversion. +export const BunnyAppConfigSchema = BunnyConfigSchema.extend({ + app: AppConfigSchema, +}); + +export type BunnyConfig = z.infer; +export type AppConfig = z.infer; export type BunnyAppConfig = z.infer; export type SiteConfig = z.infer; export type ContainerConfig = z.infer; diff --git a/packages/config/tsconfig.build.json b/packages/config/tsconfig.build.json new file mode 100644 index 00000000..5ccb64e1 --- /dev/null +++ b/packages/config/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "rewriteRelativeImportExtensions": true, + "outDir": "dist", + "rootDir": "src", + "paths": {} + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/app-config/tsconfig.json b/packages/config/tsconfig.json similarity index 100% rename from packages/app-config/tsconfig.json rename to packages/config/tsconfig.json diff --git a/templates/apps/django-with-redis-0.jsonc b/templates/apps/django-with-redis-0.jsonc index c743fb8f..6342b56e 100644 --- a/templates/apps/django-with-redis-0.jsonc +++ b/templates/apps/django-with-redis-0.jsonc @@ -1,5 +1,5 @@ { - "$schema": "../../packages/app-config/generated/schema.json", + "$schema": "../../packages/config/generated/schema.json", "version": "2026-05-12", "app": { "name": "Django with Redis", diff --git a/templates/apps/fastapi-with-redis-0.jsonc b/templates/apps/fastapi-with-redis-0.jsonc index fd3d09e7..86d9ecb6 100644 --- a/templates/apps/fastapi-with-redis-0.jsonc +++ b/templates/apps/fastapi-with-redis-0.jsonc @@ -1,5 +1,5 @@ { - "$schema": "../../packages/app-config/generated/schema.json", + "$schema": "../../packages/config/generated/schema.json", "version": "2026-05-12", "app": { "name": "FastAPI KV with Redis", diff --git a/templates/apps/flask-0.jsonc b/templates/apps/flask-0.jsonc index 00c3118f..c0d70b38 100644 --- a/templates/apps/flask-0.jsonc +++ b/templates/apps/flask-0.jsonc @@ -1,5 +1,5 @@ { - "$schema": "../../packages/app-config/generated/schema.json", + "$schema": "../../packages/config/generated/schema.json", "version": "2026-05-12", "app": { "name": "Flask", diff --git a/templates/apps/flask-with-redis-0.jsonc b/templates/apps/flask-with-redis-0.jsonc index b6b4f981..ac03c5dd 100644 --- a/templates/apps/flask-with-redis-0.jsonc +++ b/templates/apps/flask-with-redis-0.jsonc @@ -1,5 +1,5 @@ { - "$schema": "../../packages/app-config/generated/schema.json", + "$schema": "../../packages/config/generated/schema.json", "version": "2026-05-12", "app": { "name": "Flask with Redis", diff --git a/templates/apps/go-api-0.jsonc b/templates/apps/go-api-0.jsonc index e628cd0a..e2b34f6e 100644 --- a/templates/apps/go-api-0.jsonc +++ b/templates/apps/go-api-0.jsonc @@ -1,5 +1,5 @@ { - "$schema": "../../packages/app-config/generated/schema.json", + "$schema": "../../packages/config/generated/schema.json", "version": "2026-05-12", "app": { "name": "Go API", diff --git a/templates/apps/go-api-with-redis-0.jsonc b/templates/apps/go-api-with-redis-0.jsonc index a9e103c5..ab78571a 100644 --- a/templates/apps/go-api-with-redis-0.jsonc +++ b/templates/apps/go-api-with-redis-0.jsonc @@ -1,5 +1,5 @@ { - "$schema": "../../packages/app-config/generated/schema.json", + "$schema": "../../packages/config/generated/schema.json", "version": "2026-05-12", "app": { "name": "Go API with Redis", diff --git a/templates/apps/hono-api-0.jsonc b/templates/apps/hono-api-0.jsonc index 7a844877..4a3a4758 100644 --- a/templates/apps/hono-api-0.jsonc +++ b/templates/apps/hono-api-0.jsonc @@ -1,5 +1,5 @@ { - "$schema": "../../packages/app-config/generated/schema.json", + "$schema": "../../packages/config/generated/schema.json", "version": "2026-05-12", "app": { "name": "Hono API", diff --git a/templates/apps/hono-api-with-duckdb-0.jsonc b/templates/apps/hono-api-with-duckdb-0.jsonc index 65f9bd7c..d085e0e5 100644 --- a/templates/apps/hono-api-with-duckdb-0.jsonc +++ b/templates/apps/hono-api-with-duckdb-0.jsonc @@ -1,5 +1,5 @@ { - "$schema": "../../packages/app-config/generated/schema.json", + "$schema": "../../packages/config/generated/schema.json", "version": "2026-05-12", "app": { "name": "Hono API with DuckDB", diff --git a/templates/apps/laravel-with-mariadb-0.jsonc b/templates/apps/laravel-with-mariadb-0.jsonc index fdcf0240..f66ecf8f 100644 --- a/templates/apps/laravel-with-mariadb-0.jsonc +++ b/templates/apps/laravel-with-mariadb-0.jsonc @@ -1,5 +1,5 @@ { - "$schema": "../../packages/app-config/generated/schema.json", + "$schema": "../../packages/config/generated/schema.json", "version": "2026-05-12", "app": { "name": "Laravel with MariaDB", diff --git a/templates/apps/minecraft-0.jsonc b/templates/apps/minecraft-0.jsonc index 0f83c4f5..d50ae1ff 100644 --- a/templates/apps/minecraft-0.jsonc +++ b/templates/apps/minecraft-0.jsonc @@ -1,5 +1,5 @@ { - "$schema": "../../packages/app-config/generated/schema.json", + "$schema": "../../packages/config/generated/schema.json", "version": "2026-05-12", "app": { "name": "Minecraft", diff --git a/templates/apps/n8n-with-psql-0.jsonc b/templates/apps/n8n-with-psql-0.jsonc index 91b05f5e..fb4b35d7 100644 --- a/templates/apps/n8n-with-psql-0.jsonc +++ b/templates/apps/n8n-with-psql-0.jsonc @@ -1,5 +1,5 @@ { - "$schema": "../../packages/app-config/generated/schema.json", + "$schema": "../../packages/config/generated/schema.json", "version": "2026-05-12", "app": { "name": "n8n", diff --git a/templates/apps/nextjs-0.jsonc b/templates/apps/nextjs-0.jsonc index 4f5987c2..77b4a9cb 100644 --- a/templates/apps/nextjs-0.jsonc +++ b/templates/apps/nextjs-0.jsonc @@ -1,5 +1,5 @@ { - "$schema": "../../packages/app-config/generated/schema.json", + "$schema": "../../packages/config/generated/schema.json", "version": "2026-05-12", "app": { "name": "Next.js", diff --git a/templates/apps/nextjs-with-psql-0.jsonc b/templates/apps/nextjs-with-psql-0.jsonc index 448f74c8..86e7b63a 100644 --- a/templates/apps/nextjs-with-psql-0.jsonc +++ b/templates/apps/nextjs-with-psql-0.jsonc @@ -1,5 +1,5 @@ { - "$schema": "../../packages/app-config/generated/schema.json", + "$schema": "../../packages/config/generated/schema.json", "version": "2026-05-12", "app": { "name": "Next.js with PostgreSQL", diff --git a/templates/apps/nuxt-0.jsonc b/templates/apps/nuxt-0.jsonc index 4d6463d1..74eeb549 100644 --- a/templates/apps/nuxt-0.jsonc +++ b/templates/apps/nuxt-0.jsonc @@ -1,5 +1,5 @@ { - "$schema": "../../packages/app-config/generated/schema.json", + "$schema": "../../packages/config/generated/schema.json", "version": "2026-05-12", "app": { "name": "Nuxt", diff --git a/templates/apps/rails-0.jsonc b/templates/apps/rails-0.jsonc index e2ede97f..a17e4c6c 100644 --- a/templates/apps/rails-0.jsonc +++ b/templates/apps/rails-0.jsonc @@ -1,5 +1,5 @@ { - "$schema": "../../packages/app-config/generated/schema.json", + "$schema": "../../packages/config/generated/schema.json", "version": "2026-05-12", "app": { "name": "Rails App", diff --git a/templates/apps/umami-with-psql-0.jsonc b/templates/apps/umami-with-psql-0.jsonc index f61a6c62..811851da 100644 --- a/templates/apps/umami-with-psql-0.jsonc +++ b/templates/apps/umami-with-psql-0.jsonc @@ -1,5 +1,5 @@ { - "$schema": "../../packages/app-config/generated/schema.json", + "$schema": "../../packages/config/generated/schema.json", "version": "2026-05-12", "app": { "name": "Umami Analytics", diff --git a/templates/apps/vite-react-nginx-0.jsonc b/templates/apps/vite-react-nginx-0.jsonc index bf94b3b7..d30e1e65 100644 --- a/templates/apps/vite-react-nginx-0.jsonc +++ b/templates/apps/vite-react-nginx-0.jsonc @@ -1,5 +1,5 @@ { - "$schema": "../../packages/app-config/generated/schema.json", + "$schema": "../../packages/config/generated/schema.json", "version": "2026-05-12", "app": { "name": "Vite + React (Nginx)", diff --git a/templates/apps/wordpress-0.jsonc b/templates/apps/wordpress-0.jsonc index 1639b4ed..b298f770 100644 --- a/templates/apps/wordpress-0.jsonc +++ b/templates/apps/wordpress-0.jsonc @@ -1,5 +1,5 @@ { - "$schema": "../../packages/app-config/generated/schema.json", + "$schema": "../../packages/config/generated/schema.json", "version": "2026-05-12", "app": { "name": "WordPress", diff --git a/tsconfig.json b/tsconfig.json index e48ca6a2..fdabf03f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,13 +14,14 @@ "verbatimModuleSyntax": true, "noEmit": true, - // Resolve @bunny.net/openapi-client and @bunny.net/sandbox from source in-repo (no prebuild); npm consumers use their package exports. See AGENTS.md "Publishing". + // Resolve @bunny.net/openapi-client, @bunny.net/sandbox, and @bunny.net/config from source in-repo (no prebuild); npm consumers use their package exports. See AGENTS.md "Publishing". "paths": { "@bunny.net/openapi-client": ["./packages/openapi-client/src/index.ts"], "@bunny.net/openapi-client/generated/*": [ "./packages/openapi-client/src/generated/*" ], - "@bunny.net/sandbox": ["./packages/sandbox/src/index.ts"] + "@bunny.net/sandbox": ["./packages/sandbox/src/index.ts"], + "@bunny.net/config": ["./packages/config/src/index.ts"] }, // Best practices From 8f9516490c634d0e6557a7365ffd1087de0ca1f6 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Sun, 19 Jul 2026 17:11:33 +0100 Subject: [PATCH 21/24] update config --- AGENTS.md | 2 +- examples/sites/vite/bunny.jsonc | 3 --- packages/cli/src/commands/apps/config.test.ts | 1 - packages/cli/src/commands/apps/config.ts | 9 +++---- packages/cli/src/commands/sites/config.ts | 2 +- .../cli/src/commands/sites/deploy.test.ts | 25 +++++++++++++++++++ packages/cli/src/commands/sites/deploy.ts | 18 ++++++++++++- packages/cli/src/core/bunny-config.ts | 3 +-- packages/cli/src/core/jsonc.ts | 12 ++------- packages/config/src/schema.ts | 2 +- 10 files changed, 52 insertions(+), 25 deletions(-) create mode 100644 packages/cli/src/commands/sites/deploy.test.ts diff --git a/AGENTS.md b/AGENTS.md index 214e1af6..8c53650d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1114,7 +1114,7 @@ bunny │ ├── show [site] 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] [--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. [dir] defaults to `sites.dir` in bunny.jsonc, then cwd (dotfiles + node_modules excluded). --build runs the command (or `sites.build`) 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 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`) 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. │ ├── deployments │ │ ├── list [site] (alias: ls) List deploys (● Live / ○ Previous markers, created, source, files, size) │ │ ├── publish [id] [--previous] [--site] [--force] (alias: promote) diff --git a/examples/sites/vite/bunny.jsonc b/examples/sites/vite/bunny.jsonc index 24522fda..71019a50 100644 --- a/examples/sites/vite/bunny.jsonc +++ b/examples/sites/vite/bunny.jsonc @@ -2,11 +2,8 @@ "$schema": "./node_modules/@bunny.net/config/generated/schema.json", "version": "2026-05-11", "sites": { - // Links this directory to a site created with `bunny sites create acme-app`, so deploys need no --site flag. "name": "acme-app", - // `bunny sites deploy --build` runs this before uploading. "build": "npm run build", - // Vite writes to dist/; the deploy root. "dir": "dist" } } diff --git a/packages/cli/src/commands/apps/config.test.ts b/packages/cli/src/commands/apps/config.test.ts index 3cf297d2..9064ab04 100644 --- a/packages/cli/src/commands/apps/config.test.ts +++ b/packages/cli/src/commands/apps/config.test.ts @@ -184,7 +184,6 @@ test("saveConfig preserves comments and a sibling sites block on an existing fil string, any >; - // Transient fields stripped, the hand-authored sites block survives. expect(parsed.app.id).toBeUndefined(); expect(parsed.app.containers.api.registry).toBeUndefined(); expect(parsed.app.containers.api.dockerfile).toBe("Dockerfile"); diff --git a/packages/cli/src/commands/apps/config.ts b/packages/cli/src/commands/apps/config.ts index 90cb4bb0..b9dc6ffe 100644 --- a/packages/cli/src/commands/apps/config.ts +++ b/packages/cli/src/commands/apps/config.ts @@ -41,8 +41,8 @@ export { configExists }; * * When `explicitPath` is given (e.g. from `--config `), that file * is loaded verbatim. Otherwise we walk up from cwd looking for - * `bunny.jsonc`. The `app` block is required here (see `BunnyAppConfigSchema`); - * a sites-only file is read via `sites/config.ts` instead. + * `bunny.jsonc`. The `app` block is required here; a sites-only file is + * read via `sites/config.ts` instead. */ export function loadConfig(explicitPath?: string): BunnyAppConfig { const found = readBunnyConfig(explicitPath); @@ -112,9 +112,8 @@ export function stripTransientFields(data: BunnyAppConfig): BunnyAppConfig { * write - callers can freely mutate the in-memory `image` field during a * deploy without polluting the on-disk config. * - * An existing file is edited surgically (see {@link syncJsonc}), so comments, - * key order, and any sibling blocks (such as `sites`) are preserved. A new - * file is serialized fresh, starting with $schema → version → app. + * An existing file is edited surgically (see {@link syncJsonc}) to preserve + * comments and a sibling `sites` block; a new file is serialized fresh. */ export function saveConfig(data: BunnyAppConfig, explicitPath?: string): void { const path = explicitPath ?? join(process.cwd(), CONFIG_FILENAME); diff --git a/packages/cli/src/commands/sites/config.ts b/packages/cli/src/commands/sites/config.ts index ba6506e4..ad9a41e0 100644 --- a/packages/cli/src/commands/sites/config.ts +++ b/packages/cli/src/commands/sites/config.ts @@ -8,7 +8,7 @@ export interface LoadedSiteConfig { root: string; } -// Read the optional `sites` block from `bunny.jsonc` (walking up from cwd); only that block is validated, so a sites-only file works without an `app` block (or even a `version`) and a file with no `sites` block returns null. +// Read the `sites` block from `bunny.jsonc`, validating only that block so a sites-only file needs no `app` (or `version`); returns null when no file or no `sites` block. export function loadSiteConfig(): LoadedSiteConfig | null { const found = readBunnyConfig(); if (!found) return null; diff --git a/packages/cli/src/commands/sites/deploy.test.ts b/packages/cli/src/commands/sites/deploy.test.ts new file mode 100644 index 00000000..459a6210 --- /dev/null +++ b/packages/cli/src/commands/sites/deploy.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { resolveDeployDir } from "./deploy.ts"; + +const ROOT = "/project/root"; + +test("a CLI path arg is cwd-relative and wins over the configured dir", () => { + expect(resolveDeployDir("out", "dist", undefined, ROOT)).toBe(resolve("out")); +}); + +test("`sites.dir` resolves against the bunny.jsonc root, not cwd", () => { + expect(resolveDeployDir(undefined, "dist", undefined, ROOT)).toBe( + `${ROOT}/dist`, + ); +}); + +test("the detected framework output dir resolves against the root where the build ran", () => { + expect(resolveDeployDir(undefined, undefined, "public", ROOT)).toBe( + `${ROOT}/public`, + ); +}); + +test("nothing specified falls back to the root, not cwd", () => { + expect(resolveDeployDir(undefined, undefined, undefined, ROOT)).toBe(ROOT); +}); diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index a8350e94..f0d475ce 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -63,6 +63,17 @@ function deployUrls( }; } +// A CLI path arg is cwd-relative; `sites.dir` and the detected output dir are relative to the bunny.jsonc root, where the build runs. +export function resolveDeployDir( + argDir: string | undefined, + configDir: string | undefined, + autoDir: string | undefined, + root: string, +): string { + if (argDir !== undefined) return resolve(argDir); + 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. export const sitesDeployCommand = defineCommand({ command: "deploy [dir]", @@ -179,7 +190,12 @@ export const sitesDeployCommand = defineCommand({ } } - const dir = resolve(explicitDir ?? autoDir ?? "."); + const dir = resolveDeployDir( + args.dir, + siteConfig?.config.dir, + autoDir, + root, + ); if (autoDir && explicitDir === undefined) { logger.info(`Deploying detected output directory: ${autoDir}`); } diff --git a/packages/cli/src/core/bunny-config.ts b/packages/cli/src/core/bunny-config.ts index 2a67e710..5fce7364 100644 --- a/packages/cli/src/core/bunny-config.ts +++ b/packages/cli/src/core/bunny-config.ts @@ -2,7 +2,6 @@ import { existsSync, readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { parse as parseJsonc } from "jsonc-parser"; -// The single `bunny.jsonc` resource file; holds the optional `app` and `sites` blocks (see `@bunny.net/config`). export const CONFIG_FILENAME = "bunny.jsonc"; // Walk up from cwd to the directory holding `bunny.jsonc`, or null when none exists. @@ -37,7 +36,7 @@ export interface RawBunnyConfig { root: string; } -// Locate and parse `bunny.jsonc` (walking up from cwd unless an explicit path is given); returns null when the file doesn't exist. Callers apply their own schema validation. +// Locate and parse `bunny.jsonc` (walking up from cwd unless a path is given), or null when absent. Callers validate the shape they need. export function readBunnyConfig(explicitPath?: string): RawBunnyConfig | null { const path = configPath(explicitPath); if (!existsSync(path)) return null; diff --git a/packages/cli/src/core/jsonc.ts b/packages/cli/src/core/jsonc.ts index 3a18a716..3b1b4bd2 100644 --- a/packages/cli/src/core/jsonc.ts +++ b/packages/cli/src/core/jsonc.ts @@ -11,12 +11,11 @@ function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -// Arrays and primitives are compared whole; both are JSON-safe here so serialization order is stable. function equalLeaf(a: unknown, b: unknown): boolean { return JSON.stringify(a) === JSON.stringify(b); } -// Return `text` with the value at `path` reconciled to `desired`, recursing into objects so comments and formatting on untouched keys survive. Each edit is applied before the next is computed so offsets stay valid. +// Reconcile the value at `path` to `desired`, recursing into objects so comments and formatting on unchanged keys survive. function reconcile( text: string, path: (string | number)[], @@ -48,14 +47,7 @@ function reconcile( ); } -/** - * Surgically edit JSONC `text` so it matches `desired`, preserving comments, - * key order, and formatting on everything that didn't change. New keys are - * appended; keys absent from `desired` are removed. - * - * Falls back to a fresh serialization when `text` isn't a JSON object (empty - * file, array, or garbage) so callers always get a valid result. - */ +// Edit JSONC `text` to match `desired`, preserving comments and formatting on unchanged keys; fresh-serializes when `text` isn't a JSON object. export function syncJsonc( text: string, desired: Record, diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index ace9a760..4d2fe7ba 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -74,7 +74,7 @@ export const SiteConfigSchema = z.object({ build: z.string().optional(), }); -// The `app` block: Magic Containers deploy intent (name, scaling, regions, containers). +// The `app` block: Magic Containers deploy intent. export const AppConfigSchema = z.object({ id: z.string().optional(), name: z.string(), From b79951ed6efd0c27791724d30286d455278493d2 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Sun, 19 Jul 2026 19:00:54 +0100 Subject: [PATCH 22/24] set config state --- .github/workflows/release.yml | 39 ----------------------------- AGENTS.md | 11 +------- bun.lock | 3 --- packages/config/package.json | 35 ++++---------------------- packages/config/tsconfig.build.json | 13 ---------- tsconfig.json | 5 ++-- 6 files changed, 8 insertions(+), 98 deletions(-) delete mode 100644 packages/config/tsconfig.build.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 694657f3..0d3ca3e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,6 @@ jobs: if: needs.changesets.outputs.hasChangesets == 'false' outputs: cli-version: ${{ steps.check-cli.outputs.version }} - config-version: ${{ steps.check-config.outputs.version }} database-shell-version: ${{ steps.check-database-shell.outputs.version }} openapi-client-version: ${{ steps.check-openapi-client.outputs.version }} sandbox-version: ${{ steps.check-sandbox.outputs.version }} @@ -55,17 +54,6 @@ jobs: else echo "No version change: $VERSION" fi - - name: Check config version - id: check-config - run: | - VERSION=$(node -p "require('./packages/config/package.json').version") - PUBLISHED=$(npm view @bunny.net/config version 2>/dev/null || echo "0.0.0") - if [ "$VERSION" != "$PUBLISHED" ]; then - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "New version detected: $VERSION (published: $PUBLISHED)" - else - echo "No version change: $VERSION" - fi - name: Check sandbox version id: check-sandbox run: | @@ -479,33 +467,6 @@ jobs: env: NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} - publish-config: - name: Publish config - runs-on: ubuntu-latest - needs: version - if: needs.version.outputs.config-version - steps: - - uses: actions/checkout@v5 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: "1.3.11" - - run: bun install - - # Config's build type-checks against openapi-client's package exports (dist/), so build it first. - - name: Build openapi-client - run: bun run --filter @bunny.net/openapi-client build - - - name: Build package - run: bun run --filter @bunny.net/config build - - # bun publish (not npm) rewrites the workspace:* dependency on @bunny.net/openapi-client to a real version. - - name: Publish @bunny.net/config - run: | - cd packages/config - bun publish --access public - env: - NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} - publish-scriptable-dns-types: name: Publish scriptable-dns-types runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 8c53650d..b1931c6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -931,16 +931,7 @@ Two differences from openapi-client: - Sandbox depends on `@bunny.net/openapi-client` with `workspace:*`, so the `publish-sandbox` job in `release.yml` uses `bun publish` (not `npm publish`) — bun rewrites `workspace:*` to the local package version in the published tarball; npm would ship the unresolvable `workspace:*` spec verbatim. `bun publish` authenticates via the `NPM_CONFIG_TOKEN` env var. - Its `tsconfig.build.json` overrides `paths` to `{}` so openapi-client resolves via its package `exports` (`dist/`) instead of source — otherwise openapi-client's sources would enter the program and violate `rootDir`. The publish job therefore builds openapi-client before building sandbox. -### Publishing `@bunny.net/config` - -`@bunny.net/config` follows the same compiled-library pattern as `@bunny.net/sandbox`: `exports`/`main`/`types` point at `dist/`, in-repo tooling resolves it from source via the root `tsconfig.json` `paths` mapping, and `tsconfig.build.json` (`rewriteRelativeImportExtensions`, plain `tsc`, `paths` overridden to `{}`) emits JS + declarations. Like sandbox, it depends on `@bunny.net/openapi-client` with `workspace:*`, so the `publish-config` job in `release.yml` builds openapi-client first, then builds config, then runs `bun publish` (which rewrites `workspace:*` to a real version; authenticates via `NPM_CONFIG_TOKEN`). - -Two config-specific notes: - -- Its `build` script runs `generate:schema` before `tsc`, so the published `generated/schema.json` (shipped via `files`, not `dist`) always matches the Zod schemas. The `./schema.json` subpath export and the `$schema` reference written into `bunny.jsonc` both resolve to that file. -- `tsconfig.build.json` sets `include: ["src"]` so the package-root `scripts/generate-schema.ts` stays out of the compiled program (it would otherwise violate `rootDir: src`). - -The CLI bundles config at compile time (`bun build --compile`), so the CLI itself does not consume the published package — publishing exists for external tools that read or write `bunny.jsonc`. +`@bunny.net/config` is a private workspace package (not published); the CLI consumes it from source via the workspace symlink. ### CI diff --git a/bun.lock b/bun.lock index 4bbee803..35aa0369 100644 --- a/bun.lock +++ b/bun.lock @@ -74,9 +74,6 @@ "@bunny.net/openapi-client": "workspace:*", "zod": "^4.3.6", }, - "devDependencies": { - "typescript": "^5", - }, }, "packages/database-adapter-libsql": { "name": "@bunny.net/database-adapter-libsql", diff --git a/packages/config/package.json b/packages/config/package.json index 3db35926..17014103 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,43 +1,18 @@ { "name": "@bunny.net/config", "version": "0.1.2", - "description": "Shared Zod schemas, types, JSON Schema, and API conversion for the bunny.jsonc config file.", - "license": "MIT", + "private": true, "type": "module", - "main": "./dist/index.js", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", - "scripts": { - "generate:schema": "bun run scripts/generate-schema.ts", - "build": "rm -rf dist && bun run generate:schema && tsc -p tsconfig.build.json" - }, + "module": "src/index.ts", "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, + ".": "./src/index.ts", "./schema.json": "./generated/schema.json" }, - "files": [ - "dist", - "generated", - "README.md" - ], - "keywords": [ - "bunny", - "config", - "bunny.jsonc", - "schema", - "zod" - ], - "publishConfig": { - "access": "public" + "scripts": { + "generate:schema": "bun run scripts/generate-schema.ts" }, "dependencies": { "@bunny.net/openapi-client": "workspace:*", "zod": "^4.3.6" - }, - "devDependencies": { - "typescript": "^5" } } diff --git a/packages/config/tsconfig.build.json b/packages/config/tsconfig.build.json deleted file mode 100644 index 5ccb64e1..00000000 --- a/packages/config/tsconfig.build.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "noEmit": false, - "declaration": true, - "rewriteRelativeImportExtensions": true, - "outDir": "dist", - "rootDir": "src", - "paths": {} - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/tsconfig.json b/tsconfig.json index fdabf03f..e48ca6a2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,14 +14,13 @@ "verbatimModuleSyntax": true, "noEmit": true, - // Resolve @bunny.net/openapi-client, @bunny.net/sandbox, and @bunny.net/config from source in-repo (no prebuild); npm consumers use their package exports. See AGENTS.md "Publishing". + // Resolve @bunny.net/openapi-client and @bunny.net/sandbox from source in-repo (no prebuild); npm consumers use their package exports. See AGENTS.md "Publishing". "paths": { "@bunny.net/openapi-client": ["./packages/openapi-client/src/index.ts"], "@bunny.net/openapi-client/generated/*": [ "./packages/openapi-client/src/generated/*" ], - "@bunny.net/sandbox": ["./packages/sandbox/src/index.ts"], - "@bunny.net/config": ["./packages/config/src/index.ts"] + "@bunny.net/sandbox": ["./packages/sandbox/src/index.ts"] }, // Best practices From de2e1b35f8b136ce248cbeb58d0e988dcccefe43 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Mon, 20 Jul 2026 06:57:29 +0100 Subject: [PATCH 23/24] remote filter state --- packages/cli/src/commands/sites/api.test.ts | 32 +++++++++++++++++++ packages/cli/src/commands/sites/api.ts | 7 ++-- .../src/commands/sites/deployments/prune.ts | 4 ++- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index c0d5f52b..d0440346 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -362,6 +362,38 @@ test("a non-promoting write adopts the concurrent writer's current/previous", as expect(read?.state.deploys.map((d) => d.id)).toEqual(["aaa", "zzz"]); }); +test("writeRemoteState does not resurrect intentionally removed deploys on a prune/deploy race", async () => { + const connection = fakeConnection(); + const kept = { + id: "keep", + createdAt: "2026-01-03T00:00:00.000Z", + source: "content" as const, + files: 1, + bytes: 10, + }; + const victim = { ...kept, id: "old", createdAt: "2026-01-01T00:00:00.000Z" }; + const etag = await writeRemoteState( + connection, + fakeState({ deploys: [kept, victim] }), + ); + + // A racing deploy re-writes the pre-prune state (still holding the victim) and adds its own record. + const fresh = { ...kept, id: "new", createdAt: "2026-01-04T00:00:00.000Z" }; + store.set( + REMOTE_STATE_PATH, + JSON.stringify(fakeState({ deploys: [fresh, kept, victim] })), + ); + + // Prune deleted `old`'s files and dropped it from state; it reports the removal. + await writeRemoteState(connection, fakeState({ deploys: [kept] }), etag, { + removedIds: ["old"], + }); + + const read = await readRemoteState(connection); + // The racing deploy's record survives; the pruned one is not restored to point at deleted files. + expect(read?.state.deploys.map((d) => d.id).sort()).toEqual(["keep", "new"]); +}); + // ---- provisioning ---- test("createSite provisions storage zone → router → pull zone → state", async () => { diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 35f45560..fb26cf09 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -100,7 +100,7 @@ export async function readRemoteState( return { state, etag: sha256Hex(raw) }; } -// Write `_bunny/site.json` (returns the new etag). On an `expectedEtag` mismatch a parseable concurrent state is reconciled: deploy records merge, and the current/previous pointers follow `promotedTo` (last promote wins; a non-promoting writer adopts the concurrent pointers rather than clobber them with its stale read). An unparseable conflict aborts rather than overwrite. +// Write `_bunny/site.json` (returns the new etag). On an `expectedEtag` mismatch a parseable concurrent state is reconciled: deploy records merge (minus any `removedIds` this writer intentionally deleted, so a prune racing a deploy doesn't resurrect pruned records), and the current/previous pointers follow `promotedTo` (last promote wins; a non-promoting writer adopts the concurrent pointers rather than clobber them with its stale read). An unparseable conflict aborts rather than overwrite. export async function writeRemoteState( connection: StorageZone, state: RemoteSiteState, @@ -108,6 +108,8 @@ export async function writeRemoteState( opts?: { /** Deploy this writer just promoted to production; omit when the write doesn't change `current`. */ promotedTo?: string; + /** Deploy IDs this writer intentionally removed (e.g. prune); the conflict merge must not resurrect them from concurrent state. */ + removedIds?: readonly string[]; }, ): Promise { if (expectedEtag) { @@ -121,9 +123,10 @@ export async function writeRemoteState( ); } const ours = new Set(state.deploys.map((d) => d.id)); + const removed = new Set(opts?.removedIds ?? []); state.deploys = [ ...state.deploys, - ...remote.deploys.filter((d) => !ours.has(d.id)), + ...remote.deploys.filter((d) => !ours.has(d.id) && !removed.has(d.id)), ].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); if (opts?.promotedTo) { // Our promote wins (it set CURRENT_DEPLOY last), and the concurrent writer's production deploy becomes the rollback target. diff --git a/packages/cli/src/commands/sites/deployments/prune.ts b/packages/cli/src/commands/sites/deployments/prune.ts index e5b74299..d28e92b8 100644 --- a/packages/cli/src/commands/sites/deployments/prune.ts +++ b/packages/cli/src/commands/sites/deployments/prune.ts @@ -106,7 +106,9 @@ export const sitesDeploymentsPruneCommand = defineCommand({ } // Only forget deploys whose files are actually gone. state.deploys = state.deploys.filter((d) => !pruned.has(d.id)); - await writeRemoteState(connection, state, etag); + await writeRemoteState(connection, state, etag, { + removedIds: [...pruned], + }); }); const prunedIds = victims From 64c877f8a85f09ce8e0eb6193e730ede4401c3a4 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Mon, 20 Jul 2026 12:21:33 +0100 Subject: [PATCH 24/24] fix build flag --- AGENTS.md | 4 +- README.md | 2 +- packages/cli/src/commands/sites/build.test.ts | 43 ++++++++++++++++++- packages/cli/src/commands/sites/build.ts | 28 +++++++++++- packages/cli/src/commands/sites/deploy.ts | 36 +++++++++------- skills/bunny-cli/references/sites.md | 2 +- 6 files changed, 94 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b1931c6c..4281cc88 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -397,7 +397,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 (explicit --build, or an interactively-offered detected/configured build; 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 → 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 │ │ │ ├── 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,7 +1105,7 @@ bunny │ ├── show [site] 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] [--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`) 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 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. │ ├── deployments │ │ ├── list [site] (alias: ls) List deploys (● Live / ○ Previous markers, created, source, files, size) │ │ ├── publish [id] [--previous] [--site] [--force] (alias: promote) diff --git a/README.md b/README.md index 75ceb440..52386bab 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ bun ny sites create my-site # provision a static site (storage z 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 --build # run `sites.build` from bunny.jsonc, then deploy `sites.dir` (or the detected framework's output dir) +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) diff --git a/packages/cli/src/commands/sites/build.test.ts b/packages/cli/src/commands/sites/build.test.ts index 4dc31fd8..7da7bcff 100644 --- a/packages/cli/src/commands/sites/build.test.ts +++ b/packages/cli/src/commands/sites/build.test.ts @@ -2,7 +2,11 @@ import { expect, test } from "bun:test"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { resolveAutoBuild, runBuildCommand } from "./build.ts"; +import { + resolveAutoBuild, + resolveRequestedBuild, + runBuildCommand, +} from "./build.ts"; function tempRepo(files: Record): string { const dir = mkdtempSync(join(tmpdir(), "bunny-sites-auto-")); @@ -50,6 +54,43 @@ test("resolveAutoBuild returns null when nothing is detected", async () => { expect(await resolveAutoBuild(noBuildScript)).toBeNull(); }); +test("resolveRequestedBuild prefers the flag command, keeping the detected dir", async () => { + const dir = tempRepo({ + "package.json": JSON.stringify({ + name: "x", + dependencies: { vite: "^6.0.0" }, + }), + }); + expect( + await resolveRequestedBuild("make site", "npm run build", dir), + ).toEqual({ command: "make site", dir: "dist" }); +}); + +test("resolveRequestedBuild falls back to the configured, then detected build", async () => { + const dir = tempRepo({ + "package.json": JSON.stringify({ + name: "x", + dependencies: { astro: "^5.0.0" }, + }), + }); + expect(await resolveRequestedBuild("", "npm run build", dir)).toEqual({ + command: "npm run build", + dir: "dist", + }); + expect(await resolveRequestedBuild("", undefined, dir)).toEqual({ + command: "npm run build", + label: "Astro", + dir: "dist", + }); +}); + +test("resolveRequestedBuild throws when nothing is configured or detected", async () => { + const dir = tempRepo({ "index.html": "

hi

" }); + await expect(resolveRequestedBuild("", undefined, dir)).rejects.toThrow( + "No build command configured and none detected.", + ); +}); + test("runBuildCommand passes env and throws on failure", async () => { const dir = mkdtempSync(join(tmpdir(), "bunny-sites-build-")); const out = join(dir, "out.txt"); diff --git a/packages/cli/src/commands/sites/build.ts b/packages/cli/src/commands/sites/build.ts index 3c1fb4eb..2ce3dc31 100644 --- a/packages/cli/src/commands/sites/build.ts +++ b/packages/cli/src/commands/sites/build.ts @@ -34,7 +34,33 @@ export async function resolveAutoBuild( return null; } -// Run the build command in a shell with the merged environment, streaming output; throws on non-zero exit so a broken build never deploys. +export interface RequestedBuild { + command: string; + /** Framework label when the command came from detection, for the log line. */ + label?: string; + /** Build output dir relative to the repo root, when the framework fixes one. */ + dir?: string; +} + +export async function resolveRequestedBuild( + flag: string, + configured: string | undefined, + root: string, +): Promise { + const explicit = flag || configured; + if (explicit) { + return { command: explicit, dir: (await detectFramework(root))?.dir }; + } + const auto = await resolveAutoBuild(root); + if (!auto) { + throw new UserError( + "No build command configured and none detected.", + 'Pass one (`--build "npm run build"`) or set `sites.build` in bunny.jsonc.', + ); + } + return auto; +} + export async function runBuildCommand( command: string, cwd: string, diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index f0d475ce..8eb40b71 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -18,8 +18,12 @@ import { type SiteContext, writeRemoteState, } from "./api.ts"; -import { resolveAutoBuild, runBuildCommand } from "./build.ts"; -import { detectFramework } from "./ci/frameworks.ts"; +import { + type RequestedBuild, + resolveAutoBuild, + resolveRequestedBuild, + runBuildCommand, +} from "./build.ts"; import { loadSiteConfig } from "./config.ts"; import { type DeployRecord, @@ -103,7 +107,7 @@ export const sitesDeployCommand = defineCommand({ .option("build", { type: "string", describe: - "Run a build first. Pass a command, or use the bare flag to run `sites.build` from bunny.jsonc", + "Run a build first. Pass a command, or use the bare flag to run `sites.build` from bunny.jsonc (else the detected framework's build)", }) .option("env", { type: "string", @@ -139,6 +143,15 @@ export const sitesDeployCommand = defineCommand({ ); } + let requestedBuild: RequestedBuild | undefined; + if (args.build !== undefined) { + requestedBuild = await resolveRequestedBuild( + args.build, + siteConfig?.config.build, + root, + ); + } + const config = resolveConfig(profile, apiKey, verbose); const options = clientOptions(config, verbose); const coreClient = createCoreClient(options); @@ -158,20 +171,13 @@ export const sitesDeployCommand = defineCommand({ let etag = site.etag; let autoDir: string | undefined; - if (args.build !== undefined) { - const command = args.build || siteConfig?.config.build; - if (!command) { - throw new UserError( - "No build command configured.", - 'Pass one (`--build "npm run build"`) or set `sites.build` in bunny.jsonc.', - ); - } + if (requestedBuild) { + if (requestedBuild.label) + logger.info(`Detected ${requestedBuild.label}.`); // No dir given: target the detected framework's output dir, not the repo root the build ran in. - if (explicitDir === undefined) { - autoDir = (await detectFramework(root))?.dir; - } + if (explicitDir === undefined) autoDir = requestedBuild.dir; const overrides = await collectEnv(args.env, args["env-file"]); - await runBuildCommand(command, root, overrides); + await runBuildCommand(requestedBuild.command, root, overrides); } else if (isInteractive(output)) { // No --build: offer to run the configured build, else a detected one. const configured = siteConfig?.config.build; diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index 90898300..40e02838 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -71,7 +71,7 @@ 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 uses `sites.build` from bunny.jsonc) | +| `--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) |