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/.changeset/sites-namespace.md b/.changeset/sites-namespace.md new file mode 100644 index 00000000..10ee47f1 --- /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 (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/.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 df768227..7d088b91 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) │ │ @@ -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 @@ -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) @@ -198,7 +201,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/ @@ -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) @@ -374,6 +377,35 @@ 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/link/unlink/upgrade-router/delete +│ │ │ ├── constants.ts # SITES_MANIFEST (.bunny/site.json), REMOTE_STATE_PATH (_bunny/site.json), RemoteSiteState/DeployRecord types, parseRemoteState (shape-checked; null = not a site), previewHostname/previewWildcard/deployPrefix helpers, deploy-ID + site-name validators (3-47 chars), suffixedResourceName/siteResourcePattern (zone names are `sites-{name}-{random 6}`: the prefix marks them in the dashboard, the suffix dodges the global zone namespace; the pattern also matches bare pre-suffix names) +│ │ │ ├── constants.test.ts # parseRemoteState round-trip/rejection + helper tests +│ │ │ ├── api.ts # siteFiles IO seam (connect/download/upload/remove; swap in tests instead of mock.module), remote state read/write (sha256 etag optimistic lock: concurrent deploy records merge on mismatch, ours win per id; current/previous follow promotedTo, so last promote wins and non-promoting writers adopt the concurrent pointers), siteContextFromZone, fetchSites (pull zone listing → middleware+storage candidates → per-zone state verification), createSite (idempotent provisioning: storage zone → router script code+publish+CURRENT_DEPLOY → pull zone + MiddlewareScriptId attach → state; both zones share a random name suffix so globally-taken names can't block the create, retrying fresh suffixes on collision; resume adopts a stateless name-pattern zone, and state.name keeps the clean site name), promoteDeploy (env var PUT + purgeCache POST), deleteSiteResources (pull zone → script → storage zone, best-effort), deleteDeployFiles +│ │ │ ├── api.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: 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) +│ │ │ ├── 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 # 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) +│ │ │ ├── show.ts # Site details + hostname table (SSL cert + Force SSL columns) + router-outdated warning +│ │ │ ├── open.ts # bunny sites open [site]: open the live URL (recorded custom domain when live, else system host) in the browser; --print emits it, siteLiveUrl is the pure resolver +│ │ │ ├── ssl.ts # bunny sites ssl [site]: toggle Force HTTPS on the site's b-cdn.net system host via setForceSsl (no cert issued; --no-force-ssl allows HTTP); custom domains use `sites domains ssl` +│ │ │ ├── deploy.ts # bunny sites deploy [dir]: resolve site (picker offers to create a new site when none is linked) → build (--build resolves flag command → `sites.build` → detected build, failing before any site is created; no --build offers a detected/configured build interactively; without a dir arg, --build deploys the detected framework's output dir) → hash → no-op if unchanged → upload deploys/{id}/ → state update → immutable preview URL (custom-domain dpl-{id}.preview.* when a domain exists, else the sites--.b-cdn.net/deploys/{id}/ path; rendered correctly by the router's HTMLRewriter), or publish live with --production/--prod +│ │ │ ├── link.ts # Link directory to a site (.bunny/site.json) +│ │ │ ├── unlink.ts # Remove .bunny/site.json +│ │ │ ├── upgrade-router.ts # Republish the site's router script with the CLI's current source (pushes router improvements to an existing site) +│ │ │ ├── delete.ts # Delete a site (typed-name confirm; --keep-storage; drops .bunny/site.json if it pointed here) +│ │ │ ├── ci/ # frameworks.ts (preset table of ~30 frameworks across js/ruby/hugo/python/zola/dotnet toolchains + detection: package.json deps/Gemfile/python+zola config files + lockfile pm), workflow.ts (renderSitesWorkflow -> .github/workflows/bunny-sites.yml using BunnyWay/actions/deploy-site), 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 │ │ ├── registries/ │ │ │ ├── index.ts # Manual CommandModule (not defineNamespace) — default handler runs list │ │ │ ├── list.ts # List container registries @@ -442,7 +474,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/`). @@ -893,13 +925,15 @@ 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. +`@bunny.net/config` is a private workspace package (not published); the CLI consumes it from source via the workspace symlink. + ### CI Tests and type-checking run on every pull request via `.github/workflows/ci.yml` (`bun run typecheck` and `bun test`). @@ -1065,6 +1099,32 @@ 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. 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 `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) +│ │ │ 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) +│ ├── 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 +│ ├── unlink Remove .bunny/site.json +│ ├── 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) ├── --profile, -p Profile to use (default: "default") @@ -1263,6 +1323,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. @@ -1325,11 +1386,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", @@ -1341,9 +1402,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: @@ -1355,7 +1416,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 3c4f1b6d..79f9be2f 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. @@ -56,8 +56,22 @@ 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; zones are named sites-my-site-, served at sites-my-site-.b-cdn.net) +bun ny sites deploy # no linked site? offers to create one or pick an existing; detects the framework, offers to build, then deploys +bun ny sites deploy ./dist # deploy to an immutable preview URL (the site's b-cdn.net host + /deploys//); the router's HTMLRewriter keeps root-absolute assets working +bun ny sites deploy ./dist --production # deploy and publish as the live site (--prod works too) +bun ny sites deploy --build # run `sites.build` from bunny.jsonc (else the detected framework's build), then deploy `sites.dir` (or the detected output dir) +bun ny sites deployments list # list deploys with the live one marked +bun ny sites deployments publish --previous # instant rollback to the previous deploy +bun ny sites deployments prune # delete old deploys (keeps the newest 5, never current/previous) +bun ny sites domains add example.com # attach a custom domain (+ *.preview.example.com for previews) +bun ny sites 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) ``` +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..35aa0369 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,40 @@ "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", + }, }, "packages/database-adapter-libsql": { "name": "@bunny.net/database-adapter-libsql", @@ -186,7 +185,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 +259,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 +271,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..71019a50 --- /dev/null +++ b/examples/sites/vite/bunny.jsonc @@ -0,0 +1,9 @@ +{ + "$schema": "./node_modules/@bunny.net/config/generated/schema.json", + "version": "2026-05-11", + "sites": { + "name": "acme-app", + "build": "npm run build", + "dir": "dist" + } +} diff --git a/packages/cli/package.json b/packages/cli/package.json index b9a15a9c..3a6a928f 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/cli.ts b/packages/cli/src/cli.ts index d1c3af7a..ef88d718 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 commands: CommandModule[] = [ const experimentalCommands: CommandModule[] = [ appsNamespace, registriesNamespace, + sitesNamespace, storageNamespace, ]; 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..9064ab04 100644 --- a/packages/cli/src/commands/apps/config.test.ts +++ b/packages/cli/src/commands/apps/config.test.ts @@ -156,6 +156,40 @@ 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 + >; + 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..b9dc6ffe 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; 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,23 @@ 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}) 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); 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/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/sandbox/env-args.ts b/packages/cli/src/commands/sandbox/env-args.ts index f87161de..ebcb3a2e 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 @@ -30,71 +30,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/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.test.ts b/packages/cli/src/commands/sites/api.test.ts new file mode 100644 index 00000000..d0440346 --- /dev/null +++ b/packages/cli/src/commands/sites/api.test.ts @@ -0,0 +1,833 @@ +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, + createSite, + deleteSiteResources, + fetchSites, + promoteDeploy, + promoteVerification, + readRemoteState, + siteContextFromZone, + siteFiles, + writeRemoteState, +} from "./api.ts"; +import { + REMOTE_STATE_PATH, + type RemoteSiteState, + STATE_VERSION, +} from "./constants.ts"; + +// ---- in-memory storage-file store (replaces the storage SDK) ---- + +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 + >; + 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()); + }; + siteFiles.remove = async (_zone, path) => { + for (const key of [...store.keys()]) { + if (key.startsWith(path)) store.delete(key); + } + }; +}); + +afterAll(() => { + Object.assign(siteFiles, original); + Object.assign(promoteVerification, originalVerification); +}); + +// ---- 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, + deploys: [], + ...overrides, + }; +} + +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; + /** 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 ?? []; + let nextPullZoneId = 30; + return { + GET: async ( + path: string, + options?: { + params?: { path?: { id?: number }; query?: { page?: number } }; + }, + ) => { + 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/{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); + 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 ( + path: string, + options?: { params?: unknown; body?: unknown }, + ) => { + opts.calls.push({ + method: "POST", + path, + params: options?.params as Record, + body: options?.body, + }); + if (opts.createError && path === opts.createError.path) { + 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 = { + ...ZONE, + Id: 10, + Name: (options?.body as { Name: string }).Name, + }; + zones.push(zone); + return { data: zone }; + } + if (path === "/pullzone") { + const name = (options?.body as { Name: string }).Name; + const pz = { + Id: nextPullZoneId++, + Name: name, + Hostnames: [{ IsSystemHostname: true, Value: `${name}.b-cdn.net` }], + }; + pullZones.push(pz); + 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}`); + }, + 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 ?? []; + let nextScriptId = 20; + 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: nextScriptId++, + Name: (options?.body as { Name: string }).Name, + }; + scripts.push(script); + return { data: script }; + } + return { data: {} }; + }, + 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, options?: { params?: unknown }) => { + opts.calls.push({ + method: "DELETE", + path, + params: options?.params as Record, + }); + 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("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()); + + // 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] })), + ); + + 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, + { promotedTo: "aaa" }, + ); + const read = await readRemoteState(connection); + // 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"]); +}); + +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 () => { + 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, + }); + + // 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", + ); + expect(envSet?.body).toEqual({ Name: "CURRENT_DEPLOY", DefaultValue: "" }); + + // Exactly one pull zone (production) is created; the router is attached. + const pzCreates = coreCalls.filter( + (c) => c.method === "POST" && c.path === "/pullzone", + ); + expect(pzCreates).toHaveLength(1); + expect(pzCreates[0]?.body).toMatchObject({ + Name: zoneName, + StorageZoneId: 10, + }); + const attach = coreCalls.find( + (c) => c.method === "POST" && c.path === "/pullzone/{id}", + ); + 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: `${zoneName}.b-cdn.net`, + 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({ + name: "my-site", + storageZoneId: 10, + pullZoneId: 30, + scriptId: 20, + }); +}); + +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 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] }); + const computeClient = fakeComputeClient({ calls: [] }); + + await expect( + createSite({ coreClient, computeClient, name: "my-site", region: "DE" }), + ).rejects.toThrow('Site "my-site" already exists.'); +}); + +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: coreCalls, + 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( + '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 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: { + 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( + 'Couldn\'t find an available pull zone name for "my-site".', + ); +}); + +// ---- 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", + }); + // 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 ---- + +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(); +}); + +// ---- 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); +}); + +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. + +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 new file mode 100644 index 00000000..fb26cf09 --- /dev/null +++ b/packages/cli/src/commands/sites/api.ts @@ -0,0 +1,611 @@ +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, 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"; +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, + deployPrefix, + parseRemoteState, + REMOTE_STATE_PATH, + type RemoteSiteState, + routerScriptName, + STATE_VERSION, + siteResourcePattern, + suffixedResourceName, +} from "./constants.ts"; +import { routerSource } from "./router/source.ts"; + +export type ComputeClient = ReturnType; +type PullZone = components["schemas"]["PullZoneModel"]; + +// 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, + 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"); +} + +// 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); +} + +async function downloadText( + connection: StorageZone, + path: string, +): Promise { + try { + const { stream } = await siteFiles.download(connection, path); + return await new Response(stream).text(); + } catch (err) { + if (isNotFoundError(err)) return null; + throw err; + } +} + +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` (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, + expectedEtag?: string, + 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) { + const current = await downloadText(connection, REMOTE_STATE_PATH); + if (current !== null && sha256Hex(current) !== expectedEtag) { + const remote = parseRemoteState(current); + 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)); + const removed = new Set(opts?.removedIds ?? []); + state.deploys = [ + ...state.deploys, + ...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. + 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.", + ); + } + } + const raw = `${JSON.stringify(state, null, 2)}\n`; + await siteFiles.upload(connection, REMOTE_STATE_PATH, textStream(raw), { + sha256Checksum: sha256Hex(raw).toUpperCase(), + }); + return sha256Hex(raw); +} + +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 }; +} + +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 spec documents, and the `{ Items, CurrentPage, HasMoreItems }` envelope the live API returns for some queries. +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 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, + ); + + 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: systemHostname(pz.Hostnames), + }; + } catch { + return null; + } + }, + ); + + return summaries + .filter((s): s is SiteSummary => s !== null) + .sort((a, b) => a.state.name.localeCompare(b.state.name)); +} + +// 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 { + const { data } = await client.GET("/storagezone", { + params: { query: { search: name } }, + }); + 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)), + ); +} + +// 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 { + 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) + ); +} + +// 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; + return ( + err.status === 400 && + /already (exists|taken|in use)|not available|is taken/i.test(err.message) + ); +} + +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 -> 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 { + 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. + // 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: 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 { + // 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; + } + } + 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.", + ); + } + } + const storageZoneId = storageZone.Id; + if (storageZoneId == null) { + throw new UserError(`Storage zone "${name}" has no ID.`); + } + + // 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 resourceName = storageZone.Name ?? name; + const scriptName = routerScriptName(resourceName); + 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. + // 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 findSitePullZone(coreClient, name, storageZoneId); + if (pullZone) { + reused.pullZone = true; + } else { + 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); + } + } + 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) { + throw new UserError(`Pull zone "${name}" has no ID.`); + } + await coreClient.POST("/pullzone/{id}", { + params: { path: { id: pullZone.Id } }, + body: { MiddlewareScriptId: scriptId }, + }); + + // Force HTTPS on the .b-cdn.net system host (already on bunny's wildcard cert, so this just redirects HTTP); best-effort. + const systemHost = systemHostname(pullZone.Hostnames); + if (systemHost) { + try { + await setForceSsl(coreClient, pullZone.Id, systemHost, true); + } catch (err) { + logger.warn( + `Couldn't force HTTPS on ${systemHost}: ${errorMessage(err)}`, + ); + } + } + + // 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, + deploys: [], + }; + const connection = siteFiles.connect(storageZone); + await writeRemoteState(connection, state); + + return { + state, + storageZone, + systemHostname: systemHostname(pullZone.Hostnames), + reused, + }; +} + +/** 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 systemHostname(data?.Hostnames); + } catch { + return undefined; + } +} + +// 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; + +// 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 => { + 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: 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, + 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); + } + } + // 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: 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; + 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 purge(); + await waitForEdgePropagation(opts.coreClient, opts.state, opts.deployId); + await purge(); +} + +export interface TeardownResult { + resource: "pull zone" | "router script" | "storage zone"; + id: number; + deleted: boolean; + error?: string; +} + +// 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. */ + connection?: StorageZone; +}): 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: errorMessage(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) { + // 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); + } catch (err) { + logger.warn( + `Kept the storage zone but couldn't remove its site marker (${REMOTE_STATE_PATH}): ${errorMessage(err)}`, + ); + } + } + } else { + await attempt("storage zone", state.storageZoneId, () => + coreClient.DELETE("/storagezone/{id}", { + params: { path: { id: state.storageZoneId } }, + }), + ); + } + + return results; +} + +export async function deleteDeployFiles( + connection: StorageZone, + deployId: string, +): Promise { + 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 new file mode 100644 index 00000000..7da7bcff --- /dev/null +++ b/packages/cli/src/commands/sites/build.test.ts @@ -0,0 +1,106 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + resolveAutoBuild, + resolveRequestedBuild, + 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("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("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"); + + await runBuildCommand(`printf %s "$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..2ce3dc31 --- /dev/null +++ b/packages/cli/src/commands/sites/build.ts @@ -0,0 +1,88 @@ +import { UserError } from "../../core/errors.ts"; +import { logger } from "../../core/logger.ts"; +import { + detectFramework, + detectPackageManager, + presetBuildCommand, + readPackageJson, +} from "./ci/frameworks.ts"; + +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 command, else a package.json `build` script; null when nothing is detected or the framework is static. +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; +} + +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, + 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/ci/frameworks.test.ts b/packages/cli/src/commands/sites/ci/frameworks.test.ts new file mode 100644 index 00000000..2d9c5549 --- /dev/null +++ b/packages/cli/src/commands/sites/ci/frameworks.test.ts @@ -0,0 +1,137 @@ +import { expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + detectFramework, + detectPackageManager, + findPreset, + presetBuildCommand, +} 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("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"); + 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..ea509e44 --- /dev/null +++ b/packages/cli/src/commands/sites/ci/frameworks.ts @@ -0,0 +1,282 @@ +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); +} + +// 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", + 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 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"], +]; + +export async function readPackageJson( + root: string, +): Promise | null> { + try { + return (await Bun.file(join(root, "package.json")).json()) as Record< + string, + unknown + >; + } 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 readPackageJson(root); + 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..131afcde --- /dev/null +++ b/packages/cli/src/commands/sites/ci/init.ts @@ -0,0 +1,112 @@ +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..766acbd2 --- /dev/null +++ b/packages/cli/src/commands/sites/ci/scaffold.ts @@ -0,0 +1,170 @@ +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 { + detectFramework, + detectPackageManager, + FRAMEWORK_PRESETS, + type FrameworkPreset, + findPreset, + type PackageManager, +} from "./frameworks.ts"; +import { renderSitesWorkflow, SITES_WORKFLOW_PATH } from "./workflow.ts"; + +/** The repo root, or null when `cwd` isn't inside a git repository. */ +export async function gitTopLevel(cwd: string): Promise { + return runGit(cwd, ["rev-parse", "--show-toplevel"]); +} + +export async function hasGitHubOrigin(root: string): Promise { + const url = await runGit(root, ["remote", "get-url", "origin"]); + return url?.includes("github.com") ?? false; +} + +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`; 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; + 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) { + // 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(), + ]); + 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..470a2794 --- /dev/null +++ b/packages/cli/src/commands/sites/ci/workflow.test.ts @@ -0,0 +1,124 @@ +// 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"; + +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("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", () => { + 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..0b296211 --- /dev/null +++ b/packages/cli/src/commands/sites/ci/workflow.ts @@ -0,0 +1,142 @@ +// biome-ignore-all lint/suspicious/noTemplateCurlyInString: emits GitHub Actions ${{ }} expressions, not JS template strings +import { + type FrameworkPreset, + type PackageManager, + presetBuildCommand, +} 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", + ], +}; + +function jsSteps(preset: FrameworkPreset, pm: PackageManager): string[] { + const build = presetBuildCommand(preset, pm) ?? `${pm} run build`; + return [...JS_SETUP[pm], ` - run: ${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, 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:", + // 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 }}", + ]; + return `${lines.join("\n")}\n`; +} diff --git a/packages/cli/src/commands/sites/config.ts b/packages/cli/src/commands/sites/config.ts new file mode 100644 index 00000000..ad9a41e0 --- /dev/null +++ b/packages/cli/src/commands/sites/config.ts @@ -0,0 +1,29 @@ +import { type SiteConfig, SiteConfigSchema } from "@bunny.net/config"; +import { readBunnyConfig } from "../../core/bunny-config.ts"; +import { UserError } from "../../core/errors.ts"; + +export interface LoadedSiteConfig { + config: SiteConfig; + /** Directory containing bunny.jsonc; `sites.dir` resolves against this. */ + root: string; +} + +// 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; + + 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 ${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/commands/sites/constants.test.ts b/packages/cli/src/commands/sites/constants.test.ts new file mode 100644 index 00000000..c28c26fc --- /dev/null +++ b/packages/cli/src/commands/sites/constants.test.ts @@ -0,0 +1,88 @@ +import { expect, test } from "bun:test"; +import { + deployPrefix, + isValidDeployId, + isValidSiteName, + parseRemoteState, + previewHostname, + previewWildcard, + type RemoteSiteState, + siteResourcePattern, + suffixedResourceName, +} from "./constants.ts"; + +const validState: RemoteSiteState = { + version: 1, + name: "my-site", + storageZoneId: 1, + pullZoneId: 2, + scriptId: 3, + deploys: [], +}; + +test("parseRemoteState round-trips a valid state", () => { + expect(parseRemoteState(JSON.stringify(validState))).toEqual(validState); +}); + +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(); + // 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", () => { + 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 + 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 new file mode 100644 index 00000000..8c4c6392 --- /dev/null +++ b/packages/cli/src/commands/sites/constants.ts @@ -0,0 +1,160 @@ +// `.bunny/site.json` is written by `bunny sites link`/`create` and resolved by sites commands. +export const SITES_MANIFEST = "site.json"; + +// 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. +export const PREVIEW_LABEL = "preview"; + +// 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; + +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; + /** Hash of the deployed bytes; the no-op check keys on this. Absent on pre-v1 records. */ + contentHash?: string; + files: number; + bytes: number; +} + +// Source of truth (at `_bunny/site.json`) for a site's resource triple and deploys; `.bunny/site.json` is just a local pointer to it. +export interface RemoteSiteState { + version: number; + name: string; + storageZoneId: number; + pullZoneId: number; + scriptId: 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}`; +} + +/** 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[], + 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}`; +} + +/** 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); the router regex and storage paths 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 `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; + 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" || + // 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" || + typeof s.scriptId !== "number" || + !Array.isArray(s.deploys) + ) { + return null; + } + return s as unknown as RemoteSiteState; +} diff --git a/packages/cli/src/commands/sites/create.ts b/packages/cli/src/commands/sites/create.ts new file mode 100644 index 00000000..2c441603 --- /dev/null +++ b/packages/cli/src/commands/sites/create.ts @@ -0,0 +1,241 @@ +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 { 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 } from "../../core/ui.ts"; +import type { CoreClient, StorageZoneModel } from "../storage/api.ts"; +import { siteContextFromZone } from "./api.ts"; +import { + gitTopLevel, + hasGitHubOrigin, + offerGitHubSecret, + printWorkflowInstructions, + scaffoldSitesWorkflow, +} from "./ci/scaffold.ts"; +import { SITES_MANIFEST, type SiteManifest } from "./constants.ts"; +import { setupSiteDomain } from "./domains/index.ts"; +import { createSiteWithProgress, promptSiteName } from "./provision.ts"; + +interface CreateArgs { + name?: string; + region?: string; + domain?: string; + link?: boolean; +} + +// 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 errorMessage(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.", + examples: [ + ["$0 sites create", "Prompt for a name (defaults to the directory name)"], + [ + "$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", + ], + ["$0 sites create my-site --region NY", "Store files in New York"], + ], + + builder: (yargs) => + yargs + .positional("name", { + type: "string", + describe: + "Site name; the storage zone, pull zone, and b-cdn.net subdomain become sites--xxxxxx (prompted when omitted)", + }) + .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 (offered interactively when omitted)", + }) + .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 interactive = isInteractive(output); + + const name = await promptSiteName(args.name, interactive); + + const domain = args.domain ? normalizeHostname(args.domain) : undefined; + + const config = resolveConfig(profile, apiKey, verbose); + const options = clientOptions(config, verbose); + const coreClient = createCoreClient(options); + const computeClient = createComputeClient(options); + + const result = await createSiteWithProgress({ + coreClient, + computeClient, + name, + region: args.region, + }); + + if (args.link !== false) { + saveManifest(SITES_MANIFEST, { + id: result.state.storageZoneId, + name, + }); + } + + if (output === "json") { + // --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( + { + 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, + ), + ); + 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, + ), + ); + + // 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(); + 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}`, + ); + } + } + + // 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 scaffold = await scaffoldSitesWorkflow({ + site: name, + root, + interactive: true, + }); + if (scaffold) { + logger.success( + `Wrote ${scaffold.path} (${scaffold.preset.label}, deploys ${scaffold.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/delete.ts b/packages/cli/src/commands/sites/delete.ts new file mode 100644 index 00000000..ac42e96a --- /dev/null +++ b/packages/cli/src/commands/sites/delete.ts @@ -0,0 +1,127 @@ +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 { loadManifest, removeManifest } from "../../core/manifest.ts"; +import { confirm, confirmTyped, withSpinner } 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; 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 --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 }, + )) && (await confirmTyped(state.name, { force })); + if (!confirmed) { + logger.log("Cancelled."); + return; + } + + const results = await withSpinner("Deleting site resources...", () => + deleteSiteResources({ + coreClient, + computeClient, + state, + keepStorage: args["keep-storage"], + connection: site.connection, + }), + ); + + // 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) but is no longer registered as a site.`, + ); + } + 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..76d53b2e --- /dev/null +++ b/packages/cli/src/commands/sites/deploy-id.test.ts @@ -0,0 +1,84 @@ +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]{12}$/); +}); + +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); + // 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); + 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..b810348d --- /dev/null +++ b/packages/cli/src/commands/sites/deploy-id.ts @@ -0,0 +1,60 @@ +import { runGit } from "../../core/git.ts"; + +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; + // 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: 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)); + for (const file of sorted) { + hasher.update(`${file.path}\0${file.sha256.toLowerCase()}\n`); + } + return hasher.digest("hex").slice(0, 12); +} + +/** 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 runGit(cwd, ["rev-parse", "--short=8", "HEAD"]); + if (!sha) return null; + const status = await runGit(cwd, ["status", "--porcelain"]); + return { + 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: 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[], +): Promise { + const contentHash = contentHashId(files); + const gitInfo = await gitIdentity(cwd); + if (gitInfo && !gitInfo.dirty) { + return { id: gitInfo.sha, source: "git", gitSha: gitInfo.sha, contentHash }; + } + return { + id: contentHash, + source: "content", + gitSha: gitInfo?.sha, + dirty: gitInfo?.dirty, + contentHash, + }; +} 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 new file mode 100644 index 00000000..8eb40b71 --- /dev/null +++ b/packages/cli/src/commands/sites/deploy.ts @@ -0,0 +1,358 @@ +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 { 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, withSpinner } from "../../core/ui.ts"; +import { + fetchSystemHostname, + promoteDeploy, + type SiteContext, + writeRemoteState, +} from "./api.ts"; +import { + type RequestedBuild, + resolveAutoBuild, + resolveRequestedBuild, + runBuildCommand, +} from "./build.ts"; +import { loadSiteConfig } from "./config.ts"; +import { + type DeployRecord, + deployPrefix, + markCurrent, + previewHostname, +} from "./constants.ts"; +import { resolveDeployIdentity } from "./deploy-id.ts"; +import { + type SiteSelectorArgs, + selectSite, + siteOptionBuilder, +} from "./interactive.ts"; +import { createLinkedSite, promptSiteName } from "./provision.ts"; +import { collectFiles, hashFiles, uploadDeploy } from "./uploader.ts"; + +interface DeployArgs extends SiteSelectorArgs { + dir?: string; + build?: string; + env?: string[]; + "env-file"?: string; + production?: boolean; + force?: boolean; +} + +// Production and preview URLs for a deploy: with a custom domain the preview is `dpl-{id}.preview.{domain}`, else the `/deploys/{id}/` path the router's HTMLRewriter renders correctly. +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, + }; +} + +// 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]", + describe: "Deploy a directory to a site.", + examples: [ + ["$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"', + "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 detected framework's output dir when building, 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 (else the detected framework's build)", + }) + .option("env", { + type: "string", + array: true, + describe: "Build-time env override (KEY=VALUE, repeatable)", + }) + .option("env-file", { + type: "string", + describe: "Read build-time env overrides from a dotenv-style file", + }) + .option("production", { + alias: "prod", + type: "boolean", + default: false, + describe: "Publish the deploy as the live site (default: preview only)", + }) + .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 root = siteConfig?.root ?? process.cwd(); + const explicitDir = args.dir ?? siteConfig?.config.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.", + ); + } + + 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); + const computeClient = createComputeClient(options); + + const { site, offerLink } = await selectSite(coreClient, { + site: args.site, + link: args.link, + output, + offerCreate: async () => { + const name = await promptSiteName(undefined, true); + return createLinkedSite({ coreClient, computeClient, name }); + }, + }); + const { state, connection } = site; + + let etag = site.etag; + + let autoDir: string | undefined; + if (requestedBuild) { + if (requestedBuild.label) + logger.info(`Detected ${requestedBuild.label}.`); + // No dir given: target the detected framework's output dir, not the repo root the build ran in. + if (explicitDir === undefined) autoDir = requestedBuild.dir; + const overrides = await collectEnv(args.env, args["env-file"]); + await runBuildCommand(requestedBuild.command, root, overrides); + } else if (isInteractive(output)) { + // No --build: offer to run the configured build, else a detected one. + const configured = siteConfig?.config.build; + const auto = configured + ? { command: configured, label: "the configured build" } + : await resolveAutoBuild(root); + if (auto) { + // Target the framework's output dir unless one was given (whether or not the build runs). + if (explicitDir === undefined && "dir" in auto) autoDir = auto.dir; + const prompt = configured + ? `Run ${auto.label} (\`${auto.command}\`) before deploying?` + : `Detected ${auto.label}. Run \`${auto.command}\` before deploying?`; + if (await confirm(prompt, { initial: true })) { + await runBuildCommand(auto.command, root, {}); + } + } + } + + const dir = resolveDeployDir( + args.dir, + siteConfig?.config.dir, + autoDir, + root, + ); + 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 files = await withSpinner("Hashing files...", () => + hashFiles(collectFiles(dir)), + ); + 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); + + // 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 alreadyUploaded = args.force + ? undefined + : state.deploys.find((d) => d.contentHash === identity.contentHash); + const skipUpload = alreadyUploaded !== undefined; + // A skipped deploy reuses the already-uploaded deploy's id; that's where its files live. + const deployId = alreadyUploaded?.id ?? identity.id; + const alreadyLive = state.current === deployId; + + // Nothing to do: the deploy is already uploaded (and live, if --production). + if (skipUpload && (alreadyLive || !args.production)) { + const urls = deployUrls( + site, + deployId, + await fetchSystemHostname(coreClient, state.pullZoneId), + ); + if (output === "json") { + logger.log( + JSON.stringify( + { + site: state.name, + id: deployId, + unchanged: true, + live: alreadyLive, + production: urls.production ?? null, + preview: urls.preview ?? null, + }, + null, + 2, + ), + ); + return; + } + if (alreadyLive) { + logger.info( + `No changes: deploy ${deployId} is already live. Use --force to redeploy.`, + ); + } else { + logger.info( + `No changes: deploy ${deployId} is already uploaded. Publish it with \`bunny sites deploy --production\`.`, + ); + } + if (urls.preview) logger.log(` Preview: ${urls.preview}`); + return; + } + + if (!skipUpload) { + await withSpinner(`Uploading ${files.length} files...`, (spin) => + uploadDeploy(connection, deployId, files, { + onFileUploaded: (done, total) => { + spin.text = `Uploading ${done}/${total} files (${formatBytes(totalBytes)} total)...`; + }, + }), + ); + + // Record the deploy. A re-deployed ID keeps its slot but gets fresh metadata. + const record: DeployRecord = { + id: deployId, + createdAt: new Date().toISOString(), + source: identity.source, + gitSha: identity.gitSha, + dirty: identity.dirty, + contentHash: identity.contentHash, + files: files.length, + bytes: totalBytes, + }; + state.deploys = [ + record, + ...state.deploys.filter((d) => d.id !== record.id), + ]; + etag = await writeRemoteState(connection, state, etag); + } + + if (args.production) { + await withSpinner("Publishing to production...", async () => { + await promoteDeploy({ + computeClient, + coreClient, + state, + deployId, + }); + markCurrent(state, deployId); + etag = await writeRemoteState(connection, state, etag, { + promotedTo: deployId, + }); + }); + } + + const urls = deployUrls( + site, + deployId, + await fetchSystemHostname(coreClient, state.pullZoneId), + ); + + if (output === "json") { + logger.log( + JSON.stringify( + { + site: state.name, + id: deployId, + source: identity.source, + files: files.length, + bytes: totalBytes, + promoted: args.production === true, + production: urls.production ?? null, + preview: urls.preview ?? null, + }, + null, + 2, + ), + ); + return; + } + + if (skipUpload) { + logger.success(`Deploy ${deployId} is now live.`); + } else { + logger.success( + `Deployed ${deployId} (${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( + `Publish it with \`bunny sites deploy --production\` or \`bunny sites deployments publish ${deployId}\`.`, + ); + } + + 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..235599c3 --- /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..ae196be3 --- /dev/null +++ b/packages/cli/src/commands/sites/deployments/prune.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test"; +import { type DeployRecord, pruneVictims } from "../constants.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..d28e92b8 --- /dev/null +++ b/packages/cli/src/commands/sites/deployments/prune.ts @@ -0,0 +1,140 @@ +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"; +import { + DEFAULT_KEEP_DEPLOYS, + isValidDeployId, + pruneVictims, +} from "../constants.ts"; +import { + type SiteSelectorArgs, + selectSite, + siteOptionBuilder, +} from "../interactive.ts"; + +interface PruneArgs extends SiteSelectorArgs { + keep?: number; + force?: boolean; +} + +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 }> = []; + 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})...`; + 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) { + failures.push({ id: victim.id, error: errorMessage(err) }); + } + } + // Only forget deploys whose files are actually gone. + state.deploys = state.deploys.filter((d) => !pruned.has(d.id)); + await writeRemoteState(connection, state, etag, { + removedIds: [...pruned], + }); + }); + + 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..de50eedd --- /dev/null +++ b/packages/cli/src/commands/sites/deployments/publish.ts @@ -0,0 +1,153 @@ +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, withSpinner } from "../../../core/ui.ts"; +import { promoteDeploy, writeRemoteState } from "../api.ts"; +import { markCurrent } from "../constants.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: flips the router's env var and purges the cache, no files move (instant rollback). +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; + } + + await withSpinner("Publishing...", async () => { + await promoteDeploy({ + computeClient, + coreClient, + state, + deployId: targetId, + }); + markCurrent(state, targetId); + await writeRemoteState(connection, state, etag, { + promotedTo: targetId, + }); + }); + + 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..fb704b64 --- /dev/null +++ b/packages/cli/src/commands/sites/domains/index.ts @@ -0,0 +1,185 @@ +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, + 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 }; +} + +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: ${errorMessage(err)}`); + } +} + +// Attach the `*.preview.` wildcard that serves per-deploy previews; best-effort, since the apex is already added and this can be retried via `sites domains add`. +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}: ${errorMessage(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/index.ts b/packages/cli/src/commands/sites/index.ts new file mode 100644 index 00000000..7143f0ea --- /dev/null +++ b/packages/cli/src/commands/sites/index.ts @@ -0,0 +1,30 @@ +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 { 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 { sitesUpgradeRouterCommand } from "./upgrade-router.ts"; + +export const sitesNamespace = defineNamespace("sites", false, [ + sitesCreateCommand, + sitesListCommand, + sitesShowCommand, + sitesOpenCommand, + sitesDeployCommand, + sitesDeploymentsNamespace, + ...sitesDomainsCommands, + sitesSslCommand, + sitesCiNamespace, + sitesLinkCommand, + sitesUnlinkCommand, + sitesUpgradeRouterCommand, + 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..22df5a79 --- /dev/null +++ b/packages/cli/src/commands/sites/interactive.ts @@ -0,0 +1,216 @@ +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, withSpinner } 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; +} + +// 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 { + 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 `.", + ); + } + 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 { + site: SiteContext; + // 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, 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 & { + output: OutputFormat; + offerCreate?: () => Promise; + }, +): Promise { + const noLink = async () => {}; + + if (args.site) { + const ref = args.site; + return { + site: await withSpinner("Resolving site...", () => + contextFromRef(client, ref), + ), + offerLink: noLink, + }; + } + + const manifest = loadManifest(SITES_MANIFEST); + if (manifest.id) { + 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) { + return { + site: await withSpinner( + `Resolving site "${configured}" from bunny.jsonc...`, + () => contextFromRef(client, configured), + ), + offerLink: noLink, + }; + } + + 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 sites = await withSpinner("Fetching sites...", () => + fetchSites(client), + ); + + // 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 `.", + ); + } + + 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..13616290 --- /dev/null +++ b/packages/cli/src/commands/sites/link.ts @@ -0,0 +1,59 @@ +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 always saves the manifest itself below, so the picker's offerLink is irrelevant here. + 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..714d5b08 --- /dev/null +++ b/packages/cli/src/commands/sites/list.ts @@ -0,0 +1,70 @@ +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 { withSpinner } 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 sites = await withSpinner("Fetching sites...", () => + fetchSites(client), + ); + + 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/open.test.ts b/packages/cli/src/commands/sites/open.test.ts new file mode 100644 index 00000000..09db539c --- /dev/null +++ b/packages/cli/src/commands/sites/open.test.ts @@ -0,0 +1,57 @@ +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"; + +function state(overrides?: Partial): RemoteSiteState { + return { + version: STATE_VERSION, + name: "my-site", + storageZoneId: 10, + pullZoneId: 30, + scriptId: 20, + 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/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..fa44569c --- /dev/null +++ b/packages/cli/src/commands/sites/provision.ts @@ -0,0 +1,107 @@ +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 { withSpinner } from "../../core/ui.ts"; +import type { CoreClient } from "../storage/api.ts"; +import { + type ComputeClient, + type CreateSiteResult, + createSite, + type SiteContext, + siteContextFromZone, +} from "./api.ts"; +import { + isValidSiteName, + SITES_MANIFEST, + type SiteManifest, +} from "./constants.ts"; + +export const SITE_NAME_RULES = + "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 { + 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 none 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; +} + +/** 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 { + return withSpinner(`Creating site "${opts.name}"...`, (spin) => + createSite({ + coreClient: opts.coreClient, + computeClient: opts.computeClient, + name: opts.name, + region: (opts.region ?? "DE").toUpperCase(), + onStep: (message) => { + spin.text = message; + }, + }), + ); +} + +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, + 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..0920bb75 --- /dev/null +++ b/packages/cli/src/commands/sites/router/source.test.ts @@ -0,0 +1,42 @@ +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 + 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. + expect(src).toContain("headers.delete(PREVIEW_HEADER);"); + 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 new file mode 100644 index 00000000..8436540c --- /dev/null +++ b/packages/cli/src/commands/sites/router/source.ts @@ -0,0 +1,120 @@ +// 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"; + +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 + +

Nothing here yet 🐇

+

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) => { + 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. + if (path === "/_bunny" || path.startsWith("/_bunny/")) { + 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: serve the deploy dir directly, flagging the request so the response phase rewrites its HTML. + if (path === "/deploys" || path.startsWith("/deploys/")) { + const match = DEPLOY_PATH.exec(path); + if (match) headers.set(PREVIEW_HEADER, match[1].toLowerCase()); + return new Request(new Request(url.toString(), ctx.request), { headers }); + } + + const preview = PREVIEW_HOST.exec(url.hostname); + const deploy = preview + ? preview[1].toLowerCase() + : process.env.CURRENT_DEPLOY || ""; + + if (!deploy) { + return new Response(NO_DEPLOYS_PAGE, { + status: 404, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } + + url.pathname = "/deploys/" + deploy + path; + return new Request(new Request(url.toString(), ctx.request), { headers }); + }) + .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. + const headers = new Headers(response.headers); + headers.set("X-Robots-Tag", "noindex"); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + }); +`; diff --git a/packages/cli/src/commands/sites/show.ts b/packages/cli/src/commands/sites/show.ts new file mode 100644 index 00000000..ac7d842b --- /dev/null +++ b/packages/cli/src/commands/sites/show.ts @@ -0,0 +1,108 @@ +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 { withSpinner } from "../../core/ui.ts"; +import { + type SiteSelectorArgs, + selectSite, + sitePositionalBuilder, +} from "./interactive.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; + + // 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( + JSON.stringify( + { + ...state, + 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: "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", "Force SSL"], + hostnames.map((h) => [ + hostnameUrl(h.Value ?? "", { + hasCertificate: h.HasCertificate, + forceSSL: h.ForceSSL, + }), + h.IsSystemHostname ? "System" : "Custom", + h.HasCertificate ? "Yes" : "No", + h.ForceSSL ? "Yes" : "No", + ]), + output, + ), + ); + } + + await offerLink(); + }, +}); diff --git a/packages/cli/src/commands/sites/ssl.ts b/packages/cli/src/commands/sites/ssl.ts new file mode 100644 index 00000000..0b6dcd80 --- /dev/null +++ b/packages/cli/src/commands/sites/ssl.ts @@ -0,0 +1,79 @@ +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, + systemHostname, +} from "../../core/hostnames/index.ts"; +import { logger } from "../../core/logger.ts"; +import { withSpinner } from "../../core/ui.ts"; +import { + type SiteSelectorArgs, + selectSite, + sitePositionalBuilder, +} from "./interactive.ts"; + +interface SslArgs extends SiteSelectorArgs { + "force-ssl"?: boolean; +} + +// 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.", + 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 systemHost = await withSpinner("Updating Force SSL...", async () => { + const hostnames = await fetchPullZoneHostnames(client, state.pullZoneId); + const host = systemHostname(hostnames); + if (!host) { + throw new UserError( + `Couldn't find the system hostname for "${state.name}".`, + ); + } + await setForceSsl(client, state.pullZoneId, host, force); + return host; + }); + + 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/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-router.ts b/packages/cli/src/commands/sites/upgrade-router.ts new file mode 100644 index 00000000..f6ea990f --- /dev/null +++ b/packages/cli/src/commands/sites/upgrade-router.ts @@ -0,0 +1,66 @@ +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 { withSpinner } from "../../core/ui.ts"; +import { + type SiteSelectorArgs, + selectSite, + sitePositionalBuilder, +} from "./interactive.ts"; +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. +export const sitesUpgradeRouterCommand = defineCommand({ + command: "upgrade-router [site]", + describe: "Republish a site's router script with the latest version.", + examples: [ + ["$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), + + 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 } = site; + + await withSpinner("Republishing router...", async () => { + 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: {}, + }); + }); + + if (output === "json") { + logger.log( + JSON.stringify({ site: state.name, republished: true }, null, 2), + ); + return; + } + + logger.success("Router republished."); + + 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..8d716896 --- /dev/null +++ b/packages/cli/src/commands/sites/uploader.test.ts @@ -0,0 +1,115 @@ +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); + // Web-visible standards dirs must ship despite the leading dot. + expect(shouldSkipEntry(".well-known")).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("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( + 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..152f633f --- /dev/null +++ b/packages/cli/src/commands/sites/uploader.ts @@ -0,0 +1,122 @@ +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"; +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; + +// 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 (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"; +} + +/** 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)); +} + +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 per file feeds both the deploy ID and upload checksums; concurrency matches the upload step. +export async function hashFiles( + files: LocalFile[], +): Promise { + return mapWithConcurrency(files, DEFAULT_UPLOAD_CONCURRENCY, hashFile); +} + +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, server-verified per-file SHA-256 checksums, 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); + let done = 0; + await mapWithConcurrency( + files, + opts?.concurrency ?? DEFAULT_UPLOAD_CONCURRENCY, + async (file) => { + await withRetries(() => + siteFiles.upload( + connection, + `${prefix}/${file.path}`, + Bun.file(file.absPath).stream(), + { sha256Checksum: file.sha256.toUpperCase() }, + ), + ); + done++; + opts?.onFileUploaded?.(done, files.length, file); + }, + ); +} diff --git a/packages/cli/src/commands/storage/zone/add.ts b/packages/cli/src/commands/storage/zone/add.ts index 83feb61c..fb552d1e 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"; @@ -212,7 +213,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/bunny-config.ts b/packages/cli/src/core/bunny-config.ts new file mode 100644 index 00000000..5fce7364 --- /dev/null +++ b/packages/cli/src/core/bunny-config.ts @@ -0,0 +1,45 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { parse as parseJsonc } from "jsonc-parser"; + +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 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; + const data = parseJsonc(readFileSync(path, "utf-8")); + return { data, path, root: dirname(path) }; +} 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/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/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/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/client.ts b/packages/cli/src/core/hostnames/client.ts index e1f20198..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,12 +153,23 @@ 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 }; } +/** 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 +196,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/commands.ts b/packages/cli/src/core/hostnames/commands.ts index 9ccb3123..5e6b85b1 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/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/packages/cli/src/core/hostnames/index.ts b/packages/cli/src/core/hostnames/index.ts index fe2629fd..90f414ad 100644 --- a/packages/cli/src/core/hostnames/index.ts +++ b/packages/cli/src/core/hostnames/index.ts @@ -17,10 +17,13 @@ export { normalizeHostname, type ResolvedPullZone, type SafeHostname, + setForceSsl, + systemHostname, toSafeHostname, } from "./client.ts"; export { createHostnamesCommands, + type HostnameHookContext, type HostnameResolver, type HostnamesMountOptions, } from "./commands.ts"; 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..3b1b4bd2 --- /dev/null +++ b/packages/cli/src/core/jsonc.ts @@ -0,0 +1,61 @@ +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); +} + +function equalLeaf(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +// 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)[], + 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 }), + ); +} + +// 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, +): 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/cli/src/core/ui.ts b/packages/cli/src/core/ui.ts index 6ec8bde0..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" && @@ -50,6 +63,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/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 95% rename from packages/app-config/generated/schema.json rename to packages/config/generated/schema.json index 2a0e4e45..aecc5d0c 100644 --- a/packages/app-config/generated/schema.json +++ b/packages/config/generated/schema.json @@ -210,8 +210,23 @@ }, "required": ["name", "containers"], "additionalProperties": false + }, + "sites": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "dir": { + "type": "string" + }, + "build": { + "type": "string" + } + }, + "additionalProperties": false } }, - "required": ["version", "app"], + "required": ["version"], "additionalProperties": false } diff --git a/packages/app-config/package.json b/packages/config/package.json similarity index 91% rename from packages/app-config/package.json rename to packages/config/package.json index 91e974d2..17014103 100644 --- a/packages/app-config/package.json +++ b/packages/config/package.json @@ -1,5 +1,5 @@ { - "name": "@bunny.net/app-config", + "name": "@bunny.net/config", "version": "0.1.2", "private": true, "type": "module", 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 84% rename from packages/app-config/src/index.ts rename to packages/config/src/index.ts index d06704a7..8e9f321f 100644 --- a/packages/app-config/src/index.ts +++ b/packages/config/src/index.ts @@ -11,20 +11,26 @@ export { export { parseImageRef } from "./parse-image-ref.ts"; // Types export type { + AppConfig, BunnyAppConfig, + BunnyConfig, ContainerConfig, EndpointConfig, ProbeConfig, RegionsConfig, + SiteConfig, VolumeConfig, } from "./schema.ts"; export { + AppConfigSchema, BunnyAppConfigSchema, + BunnyConfigSchema, ContainerConfigSchema, CURRENT_VERSION, EndpointConfigSchema, normalizeRegions, ProbeConfigSchema, RegionsConfigSchema, + SiteConfigSchema, VolumeConfigSchema, } from "./schema.ts"; 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 70% rename from packages/app-config/src/schema.ts rename to packages/config/src/schema.ts index 7c7421df..4d2fe7ba 100644 --- a/packages/app-config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -67,19 +67,39 @@ export const RegionsConfigSchema = z.union([ }), ]); -export const BunnyAppConfigSchema = z.object({ +// 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(), + build: z.string().optional(), +}); + +// The `app` block: Magic Containers deploy intent. +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; export type EndpointConfig = z.infer; export type VolumeConfig = z.infer; 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/skills/bunny-cli/SKILL.md b/skills/bunny-cli/SKILL.md index 367a755a..a5483b06 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, sandboxes, authentication, and raw API requests). Use when working with bunny.net (pullzones, DNS zones/records, databases, storage, Edge Scripts, Magic Containers, cloud sandboxes), 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, sandboxes, 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, cloud sandboxes), invoking the `bunny` CLI, or making authenticated API calls to api.bunny.net. --- # bunny.net CLI Skill @@ -51,6 +51,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 --production # deploy + publish as the live site +bunny sites deployments publish --previous --force # instant rollback ``` ## Decision Tree @@ -61,6 +66,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)** -> `references/sites.md` - **Sandboxes (create, exec, ssh, cp, files, public URLs, persistent env vars, Claude Code auth)** -> `references/sandbox.md` - **Make raw API requests** -> `references/api.md` - **CLI doesn't have a command for it** -> use `bunny api` as a fallback (see `references/api.md`) diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md new file mode 100644 index 00000000..40e02838 --- /dev/null +++ b/skills/bunny-cli/references/sites.md @@ -0,0 +1,159 @@ +# 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`). 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://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 + +# 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). 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. + +--- + +## `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; interactive runs prompt for one when omitted | +| `--link` | Link this directory (default true; `--no-link` to skip) | + +Site names are 3-47 lowercase letters, digits, and dashes. The storage zone, pull zone, and b-cdn.net subdomain become `sites--xxxxxx` (a `sites-` prefix marking them in the dashboard, plus a shared random suffix since zone names are global across bunny.net); commands still take the clean site name. Creation is idempotent; a failed create re-runs cleanly, reusing whatever was already provisioned. + +--- + +## `bunny sites deploy`; Deploy a directory + +```bash +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 +``` + +| Flag | Description | +| -------------- | -------------------------------------------------------------------- | +| `[dir]` | Directory to deploy (default: `sites.dir` in bunny.jsonc, then cwd) | +| `--build` | Run a build first (bare flag: `sites.build`, else a detected build) | +| `--env` | Build-time env override `KEY=VALUE` (repeatable; requires `--build`) | +| `--env-file` | Dotenv file of build-time overrides (requires `--build`) | +| `--production` | Publish as the live site (alias `--prod`; default is preview only) | +| `--force` | Deploy even when content is unchanged | +| `--site` | Target site (name or storage zone ID) | + +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 + +```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 ci init`; GitHub Actions deployments + +```bash +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 +``` + +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. + +--- + +## `bunny sites` lifecycle and maintenance + +```bash +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 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 +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 }`). 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",