diff --git a/.changeset/sites-launch-fixes.md b/.changeset/sites-launch-fixes.md new file mode 100644 index 00000000..65ebe4a5 --- /dev/null +++ b/.changeset/sites-launch-fixes.md @@ -0,0 +1,6 @@ +--- +"@bunny.net/cli": patch +"@bunny.net/framework-detector": patch +--- + +`bunny sites ci init` workflows deploy again (deploy-site 0.1.1, pinned to this CLI's version) and install correctly in pnpm, lockfile-less, monorepo and Python projects; deploys handle changed build output at the same commit, symlinks, and nested `bunny.jsonc`, and failed uploads, creates and deletes explain how to recover diff --git a/README.md b/README.md index 91a5275d..9da1354c 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ bun ny stream transcribe 1a2b3c4d-... --languages en,de # paid: transcribe the bun ny stream smart 1a2b3c4d-... --title --chapters # paid: generate a title and chapters from the transcript (offers to transcribe first if the video has no captions) ``` -Every deploy is published as the live site. Deploys are immutable under their own ID, so `bun ny sites deployments publish` rolls back to any earlier one without re-uploading. Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`) so a deploy needs no flags: `bun ny sites deploy --build`. `bun ny sites ci init` writes the same `build` and `dir` into the generated workflow. See [`examples/sites/`](examples/sites/) for ready-to-copy configs (Vite, Astro, Next.js static export, Hugo, plain HTML, and a combined app + site file). +Every deploy is published as the live site. Deploys are immutable under their own ID, so `bun ny sites deployments publish` rolls back to any earlier one without re-uploading. Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`, `spa`) so a deploy needs no flags: `bun ny sites deploy --build`. `bun ny sites ci init` writes the same `build` and `dir` into the generated workflow. See [`examples/sites/`](examples/sites/) for ready-to-copy configs (Vite, Astro, Next.js static export, Hugo, plain HTML, and a combined app + site file). ### Available scripts diff --git a/examples/sites/README.md b/examples/sites/README.md index 698b6b7a..5984b9a4 100644 --- a/examples/sites/README.md +++ b/examples/sites/README.md @@ -23,7 +23,6 @@ With `name`, `build`, and `dir` set, the entire deploy is one command: ```bash bun ny sites deploy --build # runs `build`, uploads `dir`, publishes it live -bun ny sites deploy --build # same, published as the live site ``` No `--site`, no build command, no directory argument. Without the config you'd diff --git a/packages/cli/README.md b/packages/cli/README.md index a57b3560..2b275dda 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1035,15 +1035,16 @@ bunny scripts docs Host static sites on bunny.net. Each site is two resources provisioned and wired together for you: a **storage zone** holding the files and a **pull zone** serving them over the CDN, with edge rules that route requests to the deploy that should answer them. Zones are named `sites--` (the prefix groups them in the dashboard; the suffix is because zone names are global across bunny.net) while commands take the clean site name. -Deploys are immutable: every `sites deploy` uploads to its own `deploys//` directory and then goes live. Publishing retargets the pull zone's rewrite rule and purges the cache, so going live and rolling back to any earlier deploy are instant and move no files. HTML is served with `max-age=0` so browsers pick up new deploys immediately, while static assets get a one-day browser cache. Deploy IDs are the git short SHA when the working tree is clean and a content hash otherwise, which makes redeploying identical content a no-op. +Deploys are immutable: every `sites deploy` uploads to its own `deploys//` directory and then goes live. Publishing retargets the pull zone's rewrite rule and purges the cache, so going live and rolling back to any earlier deploy are instant and move no files. HTML is served with `max-age=0` so browsers pick up new deploys immediately, while static assets get a one-day browser cache. Deploy IDs are the git short SHA when the working tree is clean and a content hash otherwise (also when that SHA is already deployed with different bytes, say after changing a build env var), which makes redeploying identical content a no-op. -Commands take the site as an optional positional (`[site]`), except `deploy`, `ci init`, and `deployments publish`, which use `--site`. Either accepts the site name or its storage zone ID. When omitted, the site resolves from the directory's linked site (`.bunny/site.json`, written by `sites link` or by `create`/`deploy`), then `sites.name` in `bunny.jsonc`, then an interactive picker that offers to link. Non-interactive runs (`--output json`, no TTY, or `--force` on a destructive command) error instead of prompting. +Commands take the site as an optional positional (`[site]`), except `deploy`, `ci init`, `deployments publish`, and `deployments delete`, which use `--site`. Either accepts the site name or its storage zone ID. When omitted, the site resolves from the directory's linked site (`.bunny/site.json`, written by `sites link` or by `create`/`deploy`), then `sites.name` in `bunny.jsonc`, then an interactive picker that offers to link. Non-interactive runs (`--output json`, no TTY, or `--force` on a destructive command) error instead of prompting. ```bash # Provision a site bunny sites create # interactive: prompts for a name (directory-name suggestion) bunny sites create my-site # served at sites-my-site-.b-cdn.net bunny sites create my-site --region NY # store the files in New York (default: DE) +bunny sites create my-site --tier ssd # Edge (SSD) storage tier; DE only, fixed at creation bunny sites create my-site --domain example.com # also attach a custom production domain bunny sites create my-site --from-zone my-zone # import an existing storage zone + pull zone, keeping its hostnames @@ -1055,11 +1056,12 @@ bunny sites deploy --build "npm run build" --env API_URL=https://api.example.com bunny sites deploy ./dist --site my-site --force # target a site explicitly; redeploy unchanged content bunny sites deploy ./catalog --deploy-id 20260827-1433-r42 # your own release ID instead of the git sha / content hash -# Deploys: list, publish (roll back), prune +# Deploys: list, publish (roll back), prune, delete bunny sites deployments list # ● Live / ○ Previous markers, created, source, files, size bunny sites deployments publish a1b2c3d4 # promote a past deploy (alias: promote) bunny sites deployments publish --previous # instant rollback bunny sites deployments prune --keep 10 # delete old deploys (default keeps 5; never live/previous) +bunny sites deployments delete a1b2c3d4 --force # delete one deploy (never the live or rollback deploy) # Custom production domains bunny sites domains list @@ -1076,7 +1078,7 @@ bunny sites open --print bunny sites ssl --no-force-ssl # CI, linking, maintenance -bunny sites ci init # GitHub Actions: push to main goes live +bunny sites ci init # GitHub Actions: push to the default branch goes live bunny sites ci init --framework astro bunny sites link my-site bunny sites unlink @@ -1085,30 +1087,32 @@ bunny sites delete my-site --keep-storage # typed-name confirmation; **Client-side routing and 404s.** Single-page apps serve `index.html` for extensionless paths that aren't files (a refresh on `/products/42` returns the app with a 200), while missing assets still 404. This is on automatically when the detected framework is a single-page one (Vite, Create React App, React Router, Angular, Vue CLI, Ember, Preact, Blazor WebAssembly) and the output has a root `index.html` and no `404.html` (a built not-found page wins over the framework heuristic); set `sites.spa` to `true` or `false` in `bunny.jsonc`, or pass `--spa`/`--no-spa` on the deploy, to decide yourself. When neither applies but the output looks client-routed (a single root `index.html` plus scripts), an interactive deploy asks once and saves the answer to `sites.spa`; the flags answer it in scripts. Otherwise a root `404.html` in the output (as Astro, Eleventy, Hugo, and most static generators emit) becomes the site's not-found page, served with a 404 status. The choice is recorded per deploy and follows rollbacks; `sites show` prints the live one. -Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`, `spa`) and a deploy needs no arguments: `bunny sites deploy --build`. `sites ci init` reads the same block, so the generated workflow builds and deploys exactly what the local command does; without it, the framework is detected from `package.json` deps, a Blazor WebAssembly `.csproj`, `Gemfile`, or a `hugo`/`python`/`zola` config file, with the lockfile picking the package manager. `sites create` offers to scaffold the workflow on GitHub repos. +Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`, `spa`) and a deploy needs no arguments: `bunny sites deploy --build`. `deploy` reads the nearest `bunny.jsonc` up from the working directory, else the one above the deploy directory, so a run from a monorepo root still picks up a nested project's config. `sites ci init` reads the same block, so the generated workflow builds and deploys exactly what the local command does; without it, the framework is detected from `package.json` deps, a Blazor WebAssembly `.csproj`, `Gemfile`, or a `hugo`/`python`/`zola` config file, with the lockfile picking the package manager. `sites create` offers to scaffold the workflow on GitHub repos. **Importing an existing zone.** `sites create --from-zone ` turns a storage zone you already serve through a pull zone (for example one deployed by a community storage action) into a site, so custom hostnames, certificates, and DNS stay as they are. The import only writes the site state and the edge rules that block `/_bunny/` and `/deploys/`; nothing else visitors see changes until the first `sites deploy`, which adds the rest of the site's rules and cache settings and goes live on the existing hostnames (a wildcard hostname never becomes the production domain). Files already at the zone root stay in storage but stop being served after that deploy; delete them yourself once you're happy. The edge blocks `_bunny/` and `deploys/` at any depth, so a nested path such as `docs/_bunny/` or `assets/deploys/` stops being served at import. The import refuses a zone that already has a root `deploys` or `_bunny` entry, a pull zone running an edge script, or anything but exactly one storage-backed pull zone; `--region`, `--tier` and `--domain` don't apply. Other edge rules on the pull zone are left in place. `sites delete` treats an imported site like any other and deletes both zones with everything in them. Every deploy publishes: the files land in an immutable `deploys//` directory and the rewrite rule is pointed at it, so `deployments publish` rolls back to any earlier deploy by moving that pointer, with no files moving and nothing re-uploaded. The ID is the git short-sha when the tree is clean, a content hash otherwise, or whatever `--deploy-id` supplies (letters, digits, `-`, `_`, `.`; 4-64 chars; case-sensitive): a custom ID never aliases onto another deploy's content, and reusing one for different content asks before replacing (`--force` skips the prompt); a replacement clears the old files first, so nothing stale survives. The live deploy and the rollback target are never replaced in place; deploy those under a new ID. Content is root-served, so absolute asset paths work as-is. Direct `/deploys//` URLs are blocked at the edge. Site state lives at `_bunny/site.json` inside the storage zone (also blocked at the edge); `.bunny/site.json` is only a local pointer, so a fresh clone can `sites link` and pick up where the last machine left off. -| Flag | Commands | Description | -| -------------------------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| `--region`, `--domain` | `create` | Main storage region code (default `DE`); custom production domain to attach | -| `--from-zone` | `create` | Import an existing storage zone (name or ID) and its pull zone instead of creating them | -| `--site` | `deploy`, `ci init`, `deployments publish` | Site name or storage zone ID (defaults to the linked site) | -| `--build [cmd]`, `--env`, `--env-file` | `deploy` | Build before deploying (bare flag uses the configured or detected build); build-time env overrides | -| `--force` | `deploy` | Deploy even when the content is unchanged, and replace an existing `--deploy-id` without asking | -| `--deploy-id` | `deploy` | Identify the deploy yourself (release tag, catalog ID); case-sensitive, used exactly as given | -| `--spa`, `--no-spa` | `deploy` | Serve `index.html` for client-side routes, or the 404 page; beats `sites.spa` and framework detection, skips the prompt | -| `--previous` | `deployments publish` | Publish the previous deploy (instant rollback) | -| `--keep` | `deployments prune` | Number of recent deploys to keep (default 5; live and previous are always kept) | -| `--ssl`, `--wait`, `--force-ssl` | `domains add` | Issue SSL now; wait up to 10 minutes for DNS then issue it; `--no-force-ssl` keeps HTTP working | -| `--force-ssl` | `ssl` | Force HTTP→HTTPS on the system host; `--no-force-ssl` allows plain HTTP | -| `--framework` | `ci init` | Framework preset for the workflow's build steps (default: detected) | -| `--print` | `open` | Print the URL instead of opening a browser | -| `--link` | `create`, `deploy`, `show`, `ci init`, `deployments` | Link the directory to the site; `--no-link` never links | -| `--keep-storage` | `delete` | Delete the pull zone but keep the storage zone and its deploy files | -| `--force`, `-f` | `create --from-zone`, `deployments publish`, `prune`, `domains remove`, `delete` | Skip the confirmation prompts | +| Flag | Commands | Description | +| -------------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `--region`, `--domain` | `create` | Main storage region code (default `DE`); custom production domain to attach | +| `--tier` | `create` | Storage tier, `hdd` or `ssd` (`ssd` is `DE` only); fixed once the site exists | +| `--from-zone` | `create` | Import an existing storage zone (name or ID) and its pull zone instead of creating them | +| `--site` | `deploy`, `ci init`, `deployments publish`, `deployments delete` | Site name or storage zone ID (defaults to the linked site) | +| `--build [cmd]`, `--env`, `--env-file` | `deploy` | Build before deploying (bare flag uses the configured or detected build); build-time env overrides | +| `--force` | `deploy` | Deploy even when the content is unchanged, and replace an existing `--deploy-id` without asking | +| `--deploy-id` | `deploy` | Identify the deploy yourself (release tag, catalog ID); case-sensitive, used exactly as given | +| `--spa`, `--no-spa` | `deploy` | Serve `index.html` for client-side routes, or the 404 page; beats `sites.spa` and framework detection, skips the prompt | +| `--previous` | `deployments publish` | Publish the previous deploy (instant rollback) | +| `--keep` | `deployments prune` | Number of recent deploys to keep (default 5; live and previous are always kept) | +| `--ssl`, `--wait`, `--force-ssl` | `domains add` | Issue SSL now; wait up to 10 minutes for DNS then issue it; `--no-force-ssl` keeps HTTP working | +| `--force-ssl` | `ssl`, `domains ssl` | Force HTTP→HTTPS on the system host; `--no-force-ssl` allows plain HTTP | +| `--framework` | `ci init` | Framework preset for the workflow's build steps (default: detected) | +| `--force` | `ci init` | Overwrite an existing workflow file | +| `--print` | `open` | Print the URL instead of opening a browser | +| `--link` | `create`, `deploy`, `show`, `ci init`, `deployments list`, `deployments publish` | Link the directory to the site; `--no-link` never links | +| `--keep-storage` | `delete` | Delete the pull zone but keep the storage zone and its deploy files | +| `--force`, `-f` | `create --from-zone`, `deployments publish`/`prune`/`delete`, `domains remove`, `delete` | Skip the confirmation prompts | ### `bunny stream` diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 1b599de6..ca27ec3c 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -257,11 +257,15 @@ async function fetchPullZones( } // Discover sites: every storage-backed pull zone gets the per-zone `_bunny/site.json` read (concurrency-capped). A candidate is only a site when the state names it as the site's own pull zone, so another zone pointed at the same storage origin is never mistaken for one. -export async function fetchSites(client: CoreClient): Promise { +export async function fetchSites( + client: CoreClient, + opts: { strict?: boolean } = {}, +): Promise { const candidates = (await fetchPullZones(client)).filter( (pz: PullZone) => pz.StorageZoneId != null, ); + let unreadable = 0; const summaries = await mapWithConcurrency( candidates, 8, @@ -272,10 +276,17 @@ export async function fetchSites(client: CoreClient): Promise { if (!context || context.state.pullZoneId !== pz.Id) return null; return { ...context, systemHostname: systemHostname(pz.Hostnames) }; } catch { + unreadable++; return null; } }, ); + if (unreadable > 0) { + const message = `Couldn't read ${unreadable} storage zone${unreadable === 1 ? "" : "s"}, so some sites may be missing`; + // A uniqueness check can't trust a partial list. + if (opts.strict) throw new UserError(`${message}.`, "Re-run to retry."); + logger.warn(`${message}; re-run to retry.`); + } return summaries .filter((s): s is SiteSummary => s !== null) @@ -520,6 +531,14 @@ export async function createSite( } reused.storageZone = true; } else { + // An imported site keeps its original zone names, so the name-pattern scan above can't see it. + const sites = await fetchSites(coreClient, { strict: true }); + if (sites.some((s) => s.state.name === name)) { + throw new UserError( + `Site "${name}" already exists.`, + `Run \`bunny sites link ${name}\` to use it from this directory.`, + ); + } // 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); @@ -717,7 +736,7 @@ export async function planSiteImport(opts: { ); } - const taken = (await fetchSites(coreClient)).some( + const taken = (await fetchSites(coreClient, { strict: true })).some( (site) => site.state.name === name, ); if (taken) { @@ -814,21 +833,23 @@ export const promoteVerification = { new Promise((resolve) => setTimeout(resolve, ms)), }; -// Wait until the edge serves the promoted deploy, identified by the rewrite rule's response header. +// Wait until the edge serves the promoted deploy, identified by the rewrite rule's response header; false when the deadline passed unconfirmed. async function waitForEdgePropagation( host: string, deployId: string, -): Promise { +): Promise { const start = Date.now(); const deadline = start + PROPAGATION_DEADLINE_MS; let attempt = 0; - while (Date.now() < deadline) { + let confirmed = false; + while (!confirmed && Date.now() < deadline) { try { // A unique query per attempt keeps each probe out of the CDN cache so a stale entry can't mask a propagated rule. const { deploy } = await promoteVerification.probe( `https://${host}/?__bunny_promote=${deployId}-${attempt++}`, ); - if (deploy === deployId) break; + confirmed = deploy === deployId; + if (confirmed) break; } catch { // Edge briefly unreachable (DNS/warmup); keep trying until the deadline. } @@ -839,8 +860,12 @@ async function waitForEdgePropagation( if (elapsed < SETTLE_FLOOR_MS) { await promoteVerification.wait(SETTLE_FLOOR_MS - elapsed); } + return confirmed; } +export const UNCONFIRMED_PUBLISH_WARNING = + "The edge hasn't confirmed the new deploy yet; it can take a minute to show everywhere."; + async function ensureNotFoundSettings( coreClient: CoreClient, storageZone: StorageZoneModel, @@ -864,7 +889,7 @@ export async function promoteDeploy(opts: { coreClient: CoreClient; state: RemoteSiteState; deployId: string; -}): Promise { +}): Promise { const { coreClient, state, deployId } = opts; const purge = () => coreClient.POST("/pullzone/{id}/purgeCache", { @@ -894,8 +919,9 @@ export async function promoteDeploy(opts: { }); await ensureNotFoundSettings(coreClient, storageZone, state, deployId); await purge(); - await waitForEdgePropagation(host, deployId); + const confirmed = await waitForEdgePropagation(host, deployId); await purge(); + return confirmed; } // Refuse to replace version-1 state that changed since it was read. `writeRemoteState`'s own conflict merge can't cover this: it reconciles against the current format, and the file being replaced is the older one, so it would abort as unparseable rather than merge. @@ -935,6 +961,8 @@ export interface MigrateResult { detachedScriptId: number | null; deletedScriptId: number | null; scriptError?: string; + /** False when the edge didn't confirm the republished deploy in time. */ + confirmed: boolean; } export async function migrateSite(opts: { coreClient: CoreClient; @@ -968,9 +996,14 @@ export async function migrateSite(opts: { }); await applySiteCacheSettings(coreClient, state.pullZoneId); + let confirmed = true; if (state.current) { step("Publishing the current deploy..."); - await promoteDeploy({ coreClient, state, deployId: state.current }); + confirmed = await promoteDeploy({ + coreClient, + state, + deployId: state.current, + }); } // Committed only once every fallible remote step is done: while the file still reads as version 1, a failed run above is fully resumable. @@ -997,7 +1030,7 @@ export async function migrateSite(opts: { } } - return { state, detachedScriptId, deletedScriptId, scriptError }; + return { state, detachedScriptId, deletedScriptId, scriptError, confirmed }; } interface TeardownResult { @@ -1031,11 +1064,18 @@ export async function deleteSiteResources(opts: { } }; - await attempt("pull zone", state.pullZoneId, () => - coreClient.DELETE("/pullzone/{id}", { - params: { path: { id: state.pullZoneId } }, - }), - ); + await attempt("pull zone", state.pullZoneId, async () => { + try { + await coreClient.DELETE("/pullzone/{id}", { + params: { path: { id: state.pullZoneId } }, + }); + } catch (err) { + // Already gone (a re-run after a partial delete) is the goal. + if (!(err instanceof ApiError && err.status === 404)) throw err; + } + }); + // The storage zone's site marker is the only way back to a pull zone that failed to delete, so keep both for the re-run. + if (!results.every((r) => r.deleted)) return results; if (opts.keepStorage) { // The zone survives, so remove its site marker, else list/link/show rediscover a "site" whose pull zone is gone. But only once everything else deleted: the marker is what makes a re-run able to find and retry the failures. if (opts.connection && results.every((r) => r.deleted)) { diff --git a/packages/cli/src/commands/sites/build.ts b/packages/cli/src/commands/sites/build.ts index 1e83183e..486c9569 100644 --- a/packages/cli/src/commands/sites/build.ts +++ b/packages/cli/src/commands/sites/build.ts @@ -75,7 +75,8 @@ export async function runBuildCommand( cwd, env: { ...process.env, ...env }, stdin: "ignore", - stdout: "inherit", + // Build logs go to stderr so `--output json` keeps stdout to the JSON payload. + stdout: 2, stderr: "inherit", }); const code = await proc.exited; diff --git a/packages/cli/src/commands/sites/ci/init.ts b/packages/cli/src/commands/sites/ci/init.ts index 56b50aa7..a2cd24a1 100644 --- a/packages/cli/src/commands/sites/ci/init.ts +++ b/packages/cli/src/commands/sites/ci/init.ts @@ -24,7 +24,7 @@ interface CiInitArgs extends SiteSelectorArgs { force?: boolean; } -// Scaffold `.github/workflows/bunny-sites.yml` via the BunnyWay/actions deploy-site action: pushes to main go live, and `workflow_dispatch` redeploys on demand. +// Scaffold `.github/workflows/bunny-sites.yml` via the BunnyWay/actions deploy-site action: pushes to the default branch go live, and `workflow_dispatch` redeploys on demand. export const sitesCiInitCommand = defineCommand({ command: "init", describe: "Add a GitHub Actions workflow that deploys this site.", @@ -111,7 +111,7 @@ export const sitesCiInitCommand = defineCommand({ logger.log(); await offerGitHubSecret({ apiKey: config.apiKey, root, interactive }); logger.log(); - logger.dim(" Push to main on GitHub and the site goes live."); + logger.dim(` Push to ${result.branch} on GitHub and the site goes live.`); await offerLink(); }, diff --git a/packages/cli/src/commands/sites/ci/scaffold.test.ts b/packages/cli/src/commands/sites/ci/scaffold.test.ts index 38bef452..c468a89f 100644 --- a/packages/cli/src/commands/sites/ci/scaffold.test.ts +++ b/packages/cli/src/commands/sites/ci/scaffold.test.ts @@ -1,5 +1,8 @@ import { expect, test } from "bun:test"; -import { projectPrefix, remoteHost } from "./scaffold.ts"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { jsInstallSettings, projectPrefix, remoteHost } from "./scaffold.ts"; test("projectPrefix is empty at the workflow root and the POSIX offset when nested", () => { expect(projectPrefix("/repo", "/repo")).toBe(""); @@ -30,3 +33,25 @@ test("remoteHost does not confuse lookalike hosts for github.com", () => { ); expect(remoteHost("https://notgithub.com/a/b")).toBe("notgithub.com"); }); + +test("a nested workspace member without its own lockfile installs with the monorepo root's", async () => { + const root = mkdtempSync(join(tmpdir(), "sites-scaffold-")); + const web = join(root, "apps", "web"); + mkdirSync(web, { recursive: true }); + writeFileSync(join(root, "package.json"), "{}"); + writeFileSync(join(root, "pnpm-lock.yaml"), "lockfileVersion: '6.0'\n"); + // Not a workspace yet: a standalone nested app can't use the root lockfile. + expect((await jsInstallSettings(root, web)).lockfile).toBe(false); + writeFileSync(join(root, "pnpm-workspace.yaml"), "packages: [apps/*]\n"); + expect(await jsInstallSettings(root, web)).toEqual({ + packageManager: "pnpm", + lockfile: true, + pnpmVersion: "8", + }); + // A root `packageManager` pin is what pnpm/action-setup reads, so no explicit version. + writeFileSync( + join(root, "package.json"), + '{ "packageManager": "pnpm@9.15.0" }', + ); + expect((await jsInstallSettings(root, web)).pnpmVersion).toBeUndefined(); +}); diff --git a/packages/cli/src/commands/sites/ci/scaffold.ts b/packages/cli/src/commands/sites/ci/scaffold.ts index 9538db93..0a78bb8e 100644 --- a/packages/cli/src/commands/sites/ci/scaffold.ts +++ b/packages/cli/src/commands/sites/ci/scaffold.ts @@ -4,6 +4,7 @@ import { UserError } from "@/core/errors.ts"; import { runGit } from "@/core/git.ts"; import { logger } from "@/core/logger.ts"; import { confirm, prompts } from "@/core/ui.ts"; +import { VERSION } from "@/core/version.ts"; import { detectFramework, detectPackageManager, @@ -24,6 +25,20 @@ export async function gitTopLevel(cwd: string): Promise { return runGit(cwd, ["rev-parse", "--show-toplevel"]); } +/** The remote's default branch from `origin/HEAD`; a repo without one was made locally, so its current branch is the best guess, then main. */ +export async function defaultBranch(root: string): Promise { + const ref = await runGit(root, [ + "symbolic-ref", + "--short", + "refs/remotes/origin/HEAD", + ]); + if (ref) return ref.replace(/^origin\//, ""); + return (await runGit(root, ["branch", "--show-current"])) || "main"; +} + +// The action runs this CLI's minor line, so CI deploys sites the same way the local command does. +const CLI_VERSION_RANGE = VERSION.split(".").slice(0, 2).join("."); + /** The host of a git remote URL, handling both scp-style (`git@host:path`) and URL forms; null when neither parses. */ export function remoteHost(url: string): string | null { const scp = url.match(/^(?:[^@/]+@)?([^/:]+):(?!\/)/); @@ -49,6 +64,8 @@ interface ScaffoldResult { packageManager: PackageManager; /** Directory the workflow deploys, relative to the repo root: `sites.dir` when configured, else the preset's, prefixed when the project sits below the root. */ dir: string; + /** The branch whose pushes go live. */ + branch: string; } // Lockfiles that decide the package manager; a nested project may carry its own, in which case setup-node needs to be pointed at it. @@ -60,6 +77,63 @@ const LOCKFILES = [ "package-lock.json", ]; +interface JsInstallSettings { + packageManager: PackageManager; + lockfile: boolean; + pnpmVersion?: string; +} + +// A nested workspace member without a lockfile of its own installs from the monorepo root's; a standalone nested app does not. +export async function jsInstallSettings( + root: string, + projectRoot: string, +): Promise { + const rootIsWorkspace = + existsSync(join(root, "pnpm-workspace.yaml")) || + (await readPackageJson(root))?.workspaces !== undefined; + const candidates = + projectRoot === root || rootIsWorkspace + ? [projectRoot, root] + : [projectRoot]; + const lockDir = candidates.find((dir) => + LOCKFILES.some((name) => existsSync(join(dir, name))), + ); + const packageManager = await detectPackageManager(lockDir ?? projectRoot); + return { + packageManager, + lockfile: lockDir !== undefined, + pnpmVersion: + packageManager === "pnpm" + ? await pnpmVersion(root, projectRoot, lockDir) + : undefined, + }; +} + +// pnpm/action-setup reads only the root package.json's `packageManager`; without one it needs an explicit version, taken from the project's own field or the lockfile format. +async function pnpmVersion( + root: string, + projectRoot: string, + lockDir: string | undefined, +): Promise { + const pinned = (dir: string) => + readPackageJson(dir).then((pkg) => { + const field = pkg?.packageManager; + return typeof field === "string" && field.startsWith("pnpm@") + ? field.slice("pnpm@".length).split("+")[0] + : undefined; + }); + if (await pinned(root)) return undefined; + const own = await pinned(projectRoot); + if (own) return own; + const lock = lockDir + ? await Bun.file(join(lockDir, "pnpm-lock.yaml")) + .text() + .catch(() => "") + : ""; + const format = lock.match(/^lockfileVersion:\s*['"]?(\d+)/m)?.[1]; + return format === "5" ? "7" : format === "6" ? "8" : "10"; +} + // The git top level and the bunny.jsonc directory can reach the same place by different paths (macOS /tmp -> /private/tmp), which would read as "outside the repo". function realOrSelf(path: string): string { try { @@ -190,16 +264,24 @@ export async function scaffoldSitesWorkflow(opts: { opts.interactive, settings.dir, ); - const packageManager = await detectPackageManager(settings.projectRoot); + const { packageManager, lockfile, pnpmVersion } = await jsInstallSettings( + opts.root, + settings.projectRoot, + ); + const branch = await defaultBranch(opts.root); const content = renderSitesWorkflow({ site: opts.site, preset, packageManager, + lockfile, + pnpmVersion, dir: settings.dir, build: settings.build, workingDirectory: settings.prefix || undefined, cacheDependencyPath: settings.cacheDependencyPath, installDeps: await needsJsInstall(preset, settings), + branch, + cliVersion: CLI_VERSION_RANGE, }); const target = join(opts.root, SITES_WORKFLOW_PATH); @@ -227,6 +309,7 @@ export async function scaffoldSitesWorkflow(opts: { preset, packageManager, dir: workflowPath(settings.prefix, settings.dir ?? preset.dir), + branch, }; } @@ -245,7 +328,10 @@ export async function printWorkflowInstructions( const preset = (await detectFramework(settings.projectRoot)) ?? findPreset("static"); if (!preset) return; - const packageManager = await detectPackageManager(settings.projectRoot); + const { packageManager, lockfile, pnpmVersion } = await jsInstallSettings( + root, + settings.projectRoot, + ); logger.log(); logger.log(`To deploy from GitHub later, add ${SITES_WORKFLOW_PATH}:`); logger.log(); @@ -254,11 +340,15 @@ export async function printWorkflowInstructions( site, preset, packageManager, + lockfile, + pnpmVersion, dir: settings.dir, build: settings.build, workingDirectory: settings.prefix || undefined, cacheDependencyPath: settings.cacheDependencyPath, installDeps: await needsJsInstall(preset, settings), + branch: await defaultBranch(root), + cliVersion: CLI_VERSION_RANGE, }), ); printSecretHint(); diff --git a/packages/cli/src/commands/sites/ci/workflow.test.ts b/packages/cli/src/commands/sites/ci/workflow.test.ts index e55707a7..c21d6139 100644 --- a/packages/cli/src/commands/sites/ci/workflow.test.ts +++ b/packages/cli/src/commands/sites/ci/workflow.test.ts @@ -70,7 +70,9 @@ test("mkdocs uses the python toolchain and deploys site", () => { packageManager: "npm", }); expect(yml).toContain("uses: actions/setup-python@v7"); - expect(yml).toContain("run: pip install -r requirements.txt"); + expect(yml).toContain( + "then pip install -r requirements.txt; else pip install 'mkdocs'; fi", + ); expect(yml).toContain("run: mkdocs build"); expect(yml).toContain('directory: "site"'); expect(yml).not.toContain("setup-node"); @@ -187,4 +189,24 @@ test("npm and pnpm projects get the matching install steps", () => { }); expect(pnpm).toContain("uses: pnpm/action-setup@v6"); expect(pnpm).toContain("run: pnpm install --frozen-lockfile"); + + // No lockfile: setup-node's cache and `npm ci` would both fail on the runner. + const unlocked = renderSitesWorkflow({ + site: "s", + preset: preset("vite"), + packageManager: "npm", + lockfile: false, + }); + expect(unlocked).toContain("run: npm install"); + expect(unlocked).not.toContain("cache:"); + + const pinned = renderSitesWorkflow({ + site: "s", + preset: preset("vite"), + packageManager: "pnpm", + pnpmVersion: "10", + }); + expect(pinned).toContain( + 'uses: pnpm/action-setup@v6\n with:\n version: "10"', + ); }); diff --git a/packages/cli/src/commands/sites/config.test.ts b/packages/cli/src/commands/sites/config.test.ts index ec5004a7..d283b46b 100644 --- a/packages/cli/src/commands/sites/config.test.ts +++ b/packages/cli/src/commands/sites/config.test.ts @@ -1,9 +1,9 @@ import { expect, test } from "bun:test"; -import { mkdtempSync, readFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { parse as parseJsonc } from "jsonc-parser"; -import { saveSiteConfig } from "./config.ts"; +import { loadSiteConfig, saveSiteConfig } from "./config.ts"; test("saveSiteConfig edits the sites block in place, keeping comments and siblings", async () => { const path = join( @@ -29,3 +29,13 @@ test("saveSiteConfig edits the sites block in place, keeping comments and siblin sites: { name: "my-site", spa: false }, }); }); + +test("loadSiteConfig(from) finds the bunny.jsonc above a nested deploy directory", async () => { + const project = join(mkdtempSync(join(tmpdir(), "sites-config-")), "web"); + mkdirSync(join(project, "dist"), { recursive: true }); + await Bun.write(join(project, "bunny.jsonc"), `{ "sites": { "spa": true } }`); + expect(loadSiteConfig(join(project, "dist"))).toEqual({ + config: { spa: true }, + root: project, + }); +}); diff --git a/packages/cli/src/commands/sites/config.ts b/packages/cli/src/commands/sites/config.ts index 67d0a39f..0fbdd1d3 100644 --- a/packages/cli/src/commands/sites/config.ts +++ b/packages/cli/src/commands/sites/config.ts @@ -1,11 +1,14 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { CURRENT_VERSION, type SiteConfig, SiteConfigSchema, } from "@bunny.net/config"; import { + CONFIG_FILENAME, configPath, + findConfigRoot, readBunnyConfig, SCHEMA_REF, } from "@/core/bunny-config.ts"; @@ -19,8 +22,12 @@ interface LoadedSiteConfig { } // 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(); +export function loadSiteConfig(from?: string): LoadedSiteConfig | null { + const fromRoot = from ? findConfigRoot(from) : undefined; + if (fromRoot === null) return null; + const found = readBunnyConfig( + fromRoot ? join(fromRoot, CONFIG_FILENAME) : undefined, + ); if (!found) return null; const sites = (found.data as Record | null)?.sites; diff --git a/packages/cli/src/commands/sites/delete.ts b/packages/cli/src/commands/sites/delete.ts index c6c54606..1f5b8d3e 100644 --- a/packages/cli/src/commands/sites/delete.ts +++ b/packages/cli/src/commands/sites/delete.ts @@ -91,14 +91,14 @@ export const sitesDeleteCommand = defineCommand({ }), ); - // Only drop the local link when it pointed at this site. + const failures = results.filter((r) => !r.deleted); + + // Only drop the local link when it pointed at this site, and keep it while a re-run still has work to do. const manifest = loadManifest(SITES_MANIFEST); - if (manifest.id === state.storageZoneId) { + if (failures.length === 0 && manifest.id === state.storageZoneId) { removeManifest(SITES_MANIFEST); } - const failures = results.filter((r) => !r.deleted); - if (output === "json") { logger.log( JSON.stringify( @@ -126,7 +126,9 @@ export const sitesDeleteCommand = defineCommand({ ); } if (failures.length > 0) { - logger.dim(" Re-run the command to retry the failed deletions."); + logger.dim( + ` Re-run \`bunny sites delete ${state.storageZoneId}\` to retry the failed deletions.`, + ); process.exit(1); } }, diff --git a/packages/cli/src/commands/sites/deploy.test.ts b/packages/cli/src/commands/sites/deploy.test.ts index 5a3da1dd..f74f2d9b 100644 --- a/packages/cli/src/commands/sites/deploy.test.ts +++ b/packages/cli/src/commands/sites/deploy.test.ts @@ -135,16 +135,27 @@ test("replacing the live or rollback deploy's content is refused, even with --fo reason: "rollback", }); - // Same git sha over different bytes lands on the same ID without --deploy-id. - const gitLive = deploy("aaaa1111", "hash1", "git"); + // Same git sha over different bytes falls back to the content hash instead of colliding with the live deploy. expect( resolveDeployTarget({ - deploys: [gitLive], + deploys: [deploy("aaaa1111", "hash1", "git")], identity: identity("aaaa1111", "hash2", "git"), force: false, current: "aaaa1111", - }).conflict, - ).toEqual({ record: gitLive, reason: "live" }); + }), + ).toEqual({ deployId: "hash2", skipUpload: false }); + // ...unless another deploy already holds the hash as its ID, which the fallback must not quietly replace. + expect( + resolveDeployTarget({ + deploys: [ + deploy("aaaa1111", "hash1", "git"), + deploy("hash2", "other", "custom"), + ], + identity: identity("aaaa1111", "hash2", "git"), + force: false, + current: "aaaa1111", + }).deployId, + ).toBe("aaaa1111"); }); // --force's "redeploy unchanged content" path: every write is byte-identical, so in-place is safe. diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 96293a4e..99300ea2 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -1,7 +1,8 @@ import { existsSync, statSync } from "node:fs"; -import { resolve } from "node:path"; +import { join, resolve } from "node:path"; import { createCoreClient } from "@bunny.net/openapi-client"; import { resolveConfig } from "@/config/index.ts"; +import { CONFIG_FILENAME } from "@/core/bunny-config.ts"; import { clientOptions } from "@/core/client-options.ts"; import { defineCommand } from "@/core/define-command.ts"; import { collectEnv } from "@/core/env.ts"; @@ -21,6 +22,7 @@ import { fetchSystemHostname, promoteDeploy, rereadRemoteState, + UNCONFIRMED_PUBLISH_WARNING, writeRemoteState, } from "./api.ts"; import { @@ -143,8 +145,17 @@ export function resolveDeployTarget(opts: { ? d.id === customId && d.contentHash === identity.contentHash : d.contentHash === identity.contentHash, ); + // A rebuild at an already-deployed git sha with different bytes (new env, non-deterministic build) lands under its content hash instead. + const heldElsewhere = (id: string) => + deploys.some((d) => d.id === id && d.contentHash !== identity.contentHash); + // Only when the hash ID is free: falling back onto another deploy's ID would quietly replace it. + const shaTaken = + identity.source === "git" && + heldElsewhere(identity.id) && + !heldElsewhere(identity.contentHash); // A skipped deploy reuses the already-uploaded deploy's id; that's where its files live. - const deployId = alreadyUploaded?.id ?? identity.id; + const deployId = + alreadyUploaded?.id ?? (shaTaken ? identity.contentHash : identity.id); const skipUpload = alreadyUploaded !== undefined; if (customId && !skipUpload) { @@ -262,7 +273,9 @@ export const sitesDeployCommand = defineCommand({ handler: async (args) => { const { profile, output, verbose, apiKey } = args; - const siteConfig = loadSiteConfig(); + // CI deploys a nested project's output from the repo root, so fall back to the bunny.jsonc above the deploy directory. + const siteConfig = + loadSiteConfig() ?? (args.dir ? loadSiteConfig(args.dir) : null); const root = siteConfig?.root ?? process.cwd(); const explicitDir = args.dir ?? siteConfig?.config.dir; @@ -291,6 +304,7 @@ export const sitesDeployCommand = defineCommand({ site: args.site, link: args.link, output, + configName: siteConfig?.config.name, offerCreate: async () => { const name = await promptSiteName(undefined, true); return createLinkedSite({ coreClient, name }); @@ -369,7 +383,10 @@ export const sitesDeployCommand = defineCommand({ "This looks like a single-page app. Serve index.html for client-side routes so deep links survive a refresh?", { initial: true, optional: true }, ); - const savedTo = saveSiteConfig({ spa: configuredSpa }); + const savedTo = saveSiteConfig( + { spa: configuredSpa }, + siteConfig ? join(siteConfig.root, CONFIG_FILENAME) : undefined, + ); logger.dim(` Saved sites.spa: ${configuredSpa} to ${savedTo}`); } const notFound = resolveNotFoundMode(paths, { @@ -460,9 +477,10 @@ export const sitesDeployCommand = defineCommand({ const production = productionUrl(state, systemHost); if (skipUpload && alreadyLive) { - await withSpinner("Checking routing...", () => + const confirmed = await withSpinner("Checking routing...", () => promoteDeploy({ coreClient, state, deployId }), ); + if (!confirmed) logger.warn(UNCONFIRMED_PUBLISH_WARNING); if (output === "json") { logger.log( JSON.stringify( @@ -541,12 +559,14 @@ export const sitesDeployCommand = defineCommand({ etag = await writeRemoteState(connection, state, etag); } + let confirmed = true; await withSpinner("Publishing to production...", async () => { - await promoteDeploy({ coreClient, state, deployId }); + confirmed = await promoteDeploy({ coreClient, state, deployId }); markCurrent(state, deployId); etag = await writeRemoteState(connection, state, etag, { promotedTo: deployId, }); + if (!confirmed) logger.warn(UNCONFIRMED_PUBLISH_WARNING); }); if (output === "json") { diff --git a/packages/cli/src/commands/sites/deployments/publish.ts b/packages/cli/src/commands/sites/deployments/publish.ts index eacaf4b8..14ade2df 100644 --- a/packages/cli/src/commands/sites/deployments/publish.ts +++ b/packages/cli/src/commands/sites/deployments/publish.ts @@ -2,6 +2,7 @@ import { createCoreClient } from "@bunny.net/openapi-client"; import { promoteDeploy, rereadRemoteState, + UNCONFIRMED_PUBLISH_WARNING, writeRemoteState, } from "@/commands/sites/api.ts"; import { findDeploy, markCurrent } from "@/commands/sites/constants.ts"; @@ -132,6 +133,7 @@ export const sitesDeploymentsPublishCommand = defineCommand({ return; } + let confirmed = true; await withSpinner("Publishing...", async () => { // Revalidate on fresh state right before promoting: the confirmation window is long enough for a concurrent replace to have dropped this deploy's record and started rewriting its files. const { state: latest, etag: latestEtag } = await rereadRemoteState( @@ -144,12 +146,17 @@ export const sitesDeploymentsPublishCommand = defineCommand({ "Run `bunny sites deployments list` and retry.", ); } - await promoteDeploy({ coreClient, state: latest, deployId: targetId }); + confirmed = await promoteDeploy({ + coreClient, + state: latest, + deployId: targetId, + }); markCurrent(latest, targetId); await writeRemoteState(connection, latest, latestEtag, { promotedTo: targetId, }); }); + if (!confirmed) logger.warn(UNCONFIRMED_PUBLISH_WARNING); if (output === "json") { logger.log( diff --git a/packages/cli/src/commands/sites/interactive.ts b/packages/cli/src/commands/sites/interactive.ts index 80bbb0a2..c920979b 100644 --- a/packages/cli/src/commands/sites/interactive.ts +++ b/packages/cli/src/commands/sites/interactive.ts @@ -122,6 +122,10 @@ export async function selectSite( output: OutputFormat; force?: boolean; offerCreate?: () => Promise; + /** Go straight to the picker, ignoring the linked site and bunny.jsonc (`sites link` switching sites). */ + pick?: boolean; + /** `sites.name` from a bunny.jsonc the caller already found (deploy's nested-project fallback); else read from cwd. */ + configName?: string; }, ): Promise { const noLink = async () => {}; @@ -140,7 +144,7 @@ export async function selectSite( } const manifest = loadManifest(SITES_MANIFEST); - if (manifest.id) { + if (manifest.id && !args.pick) { const id = manifest.id; const context = await withSpinner("Loading linked site...", async () => siteContextFromZone(await fetchStorageZone(client, id)), @@ -154,7 +158,9 @@ export async function selectSite( return { site: context, offerLink: noLink }; } - const configured = loadSiteConfig()?.config.name; + const configured = args.pick + ? undefined + : (args.configName ?? loadSiteConfig()?.config.name); if (configured) { const site = await withSpinner( `Resolving site "${configured}" from bunny.jsonc...`, diff --git a/packages/cli/src/commands/sites/link.ts b/packages/cli/src/commands/sites/link.ts index c4e52202..e6740718 100644 --- a/packages/cli/src/commands/sites/link.ts +++ b/packages/cli/src/commands/sites/link.ts @@ -3,6 +3,7 @@ import { resolveConfig } from "@/config/index.ts"; import { clientOptions } from "@/core/client-options.ts"; import { defineCommand } from "@/core/define-command.ts"; import { logger } from "@/core/logger.ts"; +import { isInteractive } from "@/core/ui.ts"; import { saveSiteLink, selectSite } from "./interactive.ts"; interface LinkArgs { @@ -32,6 +33,7 @@ export const sitesLinkCommand = defineCommand({ site: ref, link: false, output, + pick: isInteractive(output), }); saveSiteLink(site.state); diff --git a/packages/cli/src/commands/sites/migrate.ts b/packages/cli/src/commands/sites/migrate.ts index 04f06500..7eda9d52 100644 --- a/packages/cli/src/commands/sites/migrate.ts +++ b/packages/cli/src/commands/sites/migrate.ts @@ -20,6 +20,7 @@ import { fetchSystemHostname, migrateSite, siteFiles, + UNCONFIRMED_PUBLISH_WARNING, type ZoneState, } from "./api.ts"; import { @@ -126,6 +127,7 @@ export const sitesMigrateCommand = defineCommand({ ); logger.success(`Migrated "${name}" to the edge-rule architecture.`); + if (!result.confirmed) logger.warn(UNCONFIRMED_PUBLISH_WARNING); if (result.scriptError) { logger.warn( `Couldn't delete edge script ${result.detachedScriptId}: ${result.scriptError}`, diff --git a/packages/cli/src/commands/sites/open.ts b/packages/cli/src/commands/sites/open.ts index 76b39b83..b11509fa 100644 --- a/packages/cli/src/commands/sites/open.ts +++ b/packages/cli/src/commands/sites/open.ts @@ -83,6 +83,11 @@ export const sitesOpenCommand = defineCommand({ return; } + if (!state.current) { + logger.dim( + " Nothing is published yet, so this URL serves a 404: run `bunny sites deploy`.", + ); + } logger.info(`Opening ${url}`); openBrowser(url); }, diff --git a/packages/cli/src/commands/sites/provision.ts b/packages/cli/src/commands/sites/provision.ts index 94b68731..71d553a0 100644 --- a/packages/cli/src/commands/sites/provision.ts +++ b/packages/cli/src/commands/sites/provision.ts @@ -4,7 +4,7 @@ import { SSD_PRIMARY_REGION, type ZoneTierChoice, } from "@/commands/storage/constants.ts"; -import { UserError } from "@/core/errors.ts"; +import { ApiError, UserError } from "@/core/errors.ts"; import { logger } from "@/core/logger.ts"; import { prompts, withSpinner } from "@/core/ui.ts"; import { type CreateSiteResult, createSite, type SiteContext } from "./api.ts"; @@ -89,7 +89,13 @@ export async function createSiteWithProgress(opts: { spin.text = message; }, }), - ); + ).catch((err) => { + // A create that fails partway leaves zones a re-run picks up; the hint says so without hiding the API error's status or the credential hint. + if (err instanceof ApiError && !err.hint) { + err.hint = `Re-run \`bunny sites create ${opts.name}\` to resume where it stopped.`; + } + throw err; + }); } export async function createLinkedSite(opts: { diff --git a/packages/cli/src/commands/sites/uploader.test.ts b/packages/cli/src/commands/sites/uploader.test.ts index 2dc1e15b..80303dcc 100644 --- a/packages/cli/src/commands/sites/uploader.test.ts +++ b/packages/cli/src/commands/sites/uploader.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync } from "node:fs"; +import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { StorageZone } from "@/commands/storage/files-api.ts"; @@ -37,6 +37,26 @@ test("collectFiles skips dotfiles and node_modules but keeps .well-known, sorted ]); }); +test("collectFiles follows symlinked files and dirs without looping", () => { + const dir = mkdtempSync(join(tmpdir(), "bunny-sites-links-")); + mkdirSync(join(dir, "shared")); + writeFileSync(join(dir, "shared", "logo.svg"), ""); + symlinkSync(join(dir, "shared"), join(dir, "img")); + symlinkSync(join(dir, "shared", "logo.svg"), join(dir, "favicon.svg")); + symlinkSync(dir, join(dir, "shared", "loop")); + // Links out of the deploy dir, or onto an excluded dotfile inside it, never ship. + const outside = mkdtempSync(join(tmpdir(), "bunny-sites-secret-")); + writeFileSync(join(outside, ".env"), "SECRET=1"); + symlinkSync(join(outside, ".env"), join(dir, "config.txt")); + writeFileSync(join(dir, ".env"), "SECRET=1"); + symlinkSync(join(dir, ".env"), join(dir, "env.txt")); + expect(collectFiles(dir).map((f) => f.path)).toEqual([ + "favicon.svg", + "img/logo.svg", + "shared/logo.svg", + ]); +}); + test("uploadDeploy targets deploys/{id}, sends checksums, and retries failures", async () => { const dir = tree(); const files = await hashFiles(collectFiles(dir)); diff --git a/packages/cli/src/commands/sites/uploader.ts b/packages/cli/src/commands/sites/uploader.ts index 1fcae100..54e14d4e 100644 --- a/packages/cli/src/commands/sites/uploader.ts +++ b/packages/cli/src/commands/sites/uploader.ts @@ -1,7 +1,9 @@ -import { readdirSync, statSync } from "node:fs"; -import { join } from "node:path"; +import { readdirSync, realpathSync, statSync } from "node:fs"; +import { isAbsolute, join, relative, sep } from "node:path"; import type { StorageZone } from "@/commands/storage/files-api.ts"; import { mapWithConcurrency } from "@/core/concurrency.ts"; +import { errorMessage, UserError } from "@/core/errors.ts"; +import { logger } from "@/core/logger.ts"; import { siteFiles } from "./api.ts"; import { deployPrefix } from "./constants.ts"; @@ -31,15 +33,47 @@ function shouldSkipEntry(name: string): boolean { /** Recursively collect the files to deploy, sorted by path for determinism. */ export function collectFiles(dir: string): LocalFile[] { const files: LocalFile[] = []; + // Real paths of the directories on the current walk, so a symlink back up the tree can't loop. + const ancestors = new Set(); + const rootReal = realpathSync(dir); + + // A link may only resolve to deployable content inside the deploy dir, so `config -> ../.env` can't publish a private file. + const linkStaysInside = (entryAbs: string): boolean => { + let target: string; + try { + target = realpathSync(entryAbs); + } catch { + return true; // Dangling: statSync below finds nothing and it's skipped. + } + const rel = relative(rootReal, target); + return ( + !rel.startsWith("..") && + !isAbsolute(rel) && + !rel.split(sep).some((part) => part && shouldSkipEntry(part)) + ); + }; const walk = (abs: string, rel: string) => { + const real = realpathSync(abs); + if (ancestors.has(real)) return; + ancestors.add(real); 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()) { + if (entry.isSymbolicLink() && !linkStaysInside(entryAbs)) { + logger.warn( + `Skipped ${entryRel}: it links outside the deploy directory or to an excluded path.`, + ); + continue; + } + // statSync follows symlinks, so linked files and dirs ship as their targets. + const stat = entry.isSymbolicLink() + ? statSync(entryAbs, { throwIfNoEntry: false }) + : entry; + if (stat?.isDirectory()) { walk(entryAbs, entryRel); - } else if (entry.isFile()) { + } else if (stat?.isFile()) { files.push({ path: entryRel, absPath: entryAbs, @@ -48,6 +82,7 @@ export function collectFiles(dir: string): LocalFile[] { } // Sockets, FIFOs, and dangling symlinks are silently skipped. } + ancestors.delete(real); }; walk(dir, ""); @@ -105,7 +140,12 @@ export async function uploadDeploy( Bun.file(file.absPath).stream(), { sha256Checksum: file.sha256.toUpperCase() }, ), - ); + ).catch((err) => { + throw new UserError( + `Uploading ${file.path} failed: ${errorMessage(err)}`, + "Re-run the deploy; nothing goes live until every file is uploaded.", + ); + }); done++; opts?.onFileUploaded?.(done, files.length, file); }); diff --git a/packages/cli/src/core/bunny-config.ts b/packages/cli/src/core/bunny-config.ts index 4664b0ac..e5224b2c 100644 --- a/packages/cli/src/core/bunny-config.ts +++ b/packages/cli/src/core/bunny-config.ts @@ -8,9 +8,9 @@ export const CONFIG_FILENAME = "bunny.jsonc"; export const SCHEMA_REF = "./node_modules/@bunny.net/config/generated/schema.json"; -// 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()); +// Walk up from `from` (default cwd) to the directory holding `bunny.jsonc`, or null when none exists. +export function findConfigRoot(from = process.cwd()): string | null { + let dir = resolve(from); while (true) { if (existsSync(join(dir, CONFIG_FILENAME))) return dir; const parent = dirname(dir); diff --git a/packages/framework-detector/src/presets.ts b/packages/framework-detector/src/presets.ts index b130b0c9..25ac1b13 100644 --- a/packages/framework-detector/src/presets.ts +++ b/packages/framework-detector/src/presets.ts @@ -15,6 +15,8 @@ export interface FrameworkPreset { spa?: boolean; /** SDK channel for the dotnet toolchain's setup step, e.g. `9.0.x`. */ dotnetVersion?: string; + /** pip requirement the python toolchain installs when the project has no requirements.txt; defaults to the preset id. */ + pipPackage?: string; } // Static must stay last: the interactive prompt defaults to it. @@ -162,6 +164,7 @@ export const FRAMEWORK_PRESETS: FrameworkPreset[] = [ dir: "output", toolchain: "python", build: "pelican content", + pipPackage: "pelican[markdown]", }, { id: "sphinx", diff --git a/packages/framework-detector/src/workflow.ts b/packages/framework-detector/src/workflow.ts index 2d182a1e..dda935d5 100644 --- a/packages/framework-detector/src/workflow.ts +++ b/packages/framework-detector/src/workflow.ts @@ -9,51 +9,64 @@ 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_0.1.0"; + "BunnyWay/actions/deploy-site@deploy-site_0.1.1"; + +interface JsInstall { + /** Lockfile path for setup-node's cache when it isn't at the checkout root. */ + cacheDependencyPath?: string; + /** False when the project has no lockfile: no dependency cache, and a plain install instead of a frozen one. */ + lockfile?: boolean; + /** pnpm version for pnpm/action-setup, needed when the root package.json has no `packageManager` field. */ + pnpmVersion?: string; +} // Toolchain setup + dependency install, without the build line. setup-node looks for the lockfile at the checkout root, so a nested project passes its own path. -function jsSetup( - pm: PackageManager, - cacheDependencyPath: string | undefined, -): string[] { +function jsSetup(pm: PackageManager, install: JsInstall): string[] { + const { cacheDependencyPath } = install; + const frozen = install.lockfile !== false; const cachePath = cacheDependencyPath ? [ ` cache-dependency-path: ${JSON.stringify(cacheDependencyPath)}`, ] : []; + const cache = (name: string) => + frozen ? [` cache: ${name}`, ...cachePath] : []; switch (pm) { case "bun": return [ " - uses: oven-sh/setup-bun@v2", - " - run: bun install --frozen-lockfile", + ` - run: bun install${frozen ? " --frozen-lockfile" : ""}`, ]; case "pnpm": return [ " - uses: pnpm/action-setup@v6", + ...(install.pnpmVersion + ? [ + " with:", + ` version: ${JSON.stringify(install.pnpmVersion)}`, + ] + : []), " - uses: actions/setup-node@v7", " with:", ' node-version: "lts/*"', - " cache: pnpm", - ...cachePath, - " - run: pnpm install --frozen-lockfile", + ...cache("pnpm"), + ` - run: pnpm install${frozen ? " --frozen-lockfile" : ""}`, ]; case "yarn": return [ " - uses: actions/setup-node@v7", " with:", ' node-version: "lts/*"', - " cache: yarn", - ...cachePath, - " - run: yarn install --frozen-lockfile", + ...cache("yarn"), + ` - run: yarn install${frozen ? " --frozen-lockfile" : ""}`, ]; case "npm": return [ " - uses: actions/setup-node@v7", " with:", ' node-version: "lts/*"', - " cache: npm", - ...cachePath, - " - run: npm ci", + ...cache("npm"), + ` - run: ${frozen ? "npm ci" : "npm install"}`, ]; } } @@ -68,10 +81,10 @@ function jsSteps( preset: FrameworkPreset, pm: PackageManager, build: string | undefined, - cacheDependencyPath: string | undefined, + install: JsInstall, ): string[] { return [ - ...jsSetup(pm, cacheDependencyPath), + ...jsSetup(pm, install), runStep(build, presetBuildCommand(preset, pm) ?? `${pm} run build`), ]; } @@ -80,12 +93,12 @@ function buildSteps( preset: FrameworkPreset, packageManager: PackageManager, build: string | undefined, - cacheDependencyPath: string | undefined, + install: JsInstall, installDeps: boolean | undefined, ): string[] { switch (preset.toolchain) { case "js": - return jsSteps(preset, packageManager, build, cacheDependencyPath); + return jsSteps(preset, packageManager, build, install); case "ruby": return [ " - uses: ruby/setup-ruby@v1", @@ -109,7 +122,8 @@ function buildSteps( " - uses: actions/setup-python@v7", " with:", ' python-version: "3.x"', - " - run: pip install -r requirements.txt", + // A project without requirements.txt still needs the generator itself. + ` - run: ${JSON.stringify(`if [ -f requirements.txt ]; then pip install -r requirements.txt; else pip install '${preset.pipPackage ?? preset.id}'; fi`)}`, runStep(build, preset.build), ]; case "zola": @@ -130,7 +144,7 @@ function buildSteps( if (!build) return [" # No build step: static files deploy as-is."]; // An unrecognized bundler lands on the static preset; a configured build in a JS project still needs its dependencies on the runner. return installDeps - ? [...jsSetup(packageManager, cacheDependencyPath), runStep(build)] + ? [...jsSetup(packageManager, install), runStep(build)] : [runStep(build)]; } } @@ -153,6 +167,12 @@ export function renderSitesWorkflow(opts: { installDeps?: boolean; /** The branch that goes live on push; main unless the repository's default branch differs. */ branch?: string; + /** The `@bunny.net/cli` version range the action runs; the action's own default when omitted. */ + cliVersion?: string; + /** False when the project has no lockfile; see JsInstall. */ + lockfile?: boolean; + /** pnpm version to install when the root package.json doesn't pin one. */ + pnpmVersion?: string; }): string { const { site, preset, packageManager, workingDirectory } = opts; // Every `run` step builds from the project directory; `uses` inputs stay workflow-root-relative, so the deploy directory carries the prefix instead. @@ -190,7 +210,11 @@ export function renderSitesWorkflow(opts: { preset, packageManager, opts.build, - opts.cacheDependencyPath, + { + cacheDependencyPath: opts.cacheDependencyPath, + lockfile: opts.lockfile, + pnpmVersion: opts.pnpmVersion, + }, opts.installDeps, ), "", @@ -200,6 +224,9 @@ export function renderSitesWorkflow(opts: { ` site: ${JSON.stringify(site)}`, ` directory: ${JSON.stringify(workflowPath(workingDirectory, opts.dir ?? preset.dir))}`, " api_key: ${{ secrets.BUNNYNET_API_KEY }}", + ...(opts.cliVersion + ? [` cli_version: ${JSON.stringify(opts.cliVersion)}`] + : []), ]; return `${lines.join("\n")}\n`; } diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index 8b907231..840a02d5 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -39,21 +39,21 @@ This is the rule that shapes every other command here: - Deploys stay immutable under their own ID, so `deployments publish ` rolls back to any earlier one by retargeting the edge rule; no files move and nothing is re-uploaded. - Custom domains are vanity hostnames on the site's pull zone; without one the site serves at `https://sites--.b-cdn.net`. -Content is root-served, so root-absolute assets work as-is. Single-page apps get `index.html` for extensionless misses when the detected framework is client-routed (Vite, CRA, React Router, Angular, Vue CLI, Ember, Preact) and the output has no root `404.html`; `sites.spa` in `bunny.jsonc` or `--spa`/`--no-spa` on the deploy decides explicitly, and otherwise a root `404.html` is the not-found page. The mode is recorded per deploy and follows rollbacks. Deploys are not individually addressable: `/deploys//` URLs are internal to the storage layout and are not publicly served. To review a change before it goes live, build and serve it locally, or deploy it to a separate site. +Content is root-served, so root-absolute assets work as-is. Single-page apps get `index.html` for extensionless misses when the detected framework is client-routed (Vite, CRA, React Router, Angular, Vue CLI, Ember, Preact, Blazor WebAssembly) and the output has no root `404.html`; `sites.spa` in `bunny.jsonc` or `--spa`/`--no-spa` on the deploy decides explicitly, and otherwise a root `404.html` is the not-found page. The mode is recorded per deploy and follows rollbacks. Deploys are not individually addressable: `/deploys//` URLs are internal to the storage layout and are not publicly served. To review a change before it goes live, build and serve it locally, or deploy it to a separate site. ## Deploy IDs -- The deploy ID is the **git short-sha** when the working tree is clean, otherwise a 12-char **content hash**. Re-deploying identical content is a no-op (`--force` overrides). +- The deploy ID is the **git short-sha** when the working tree is clean, otherwise a 12-char **content hash**; a clean tree whose sha is already deployed with different bytes (a changed build env, a non-deterministic build) also falls back to the content hash. Re-deploying identical content is a no-op (`--force` overrides). - `--deploy-id ` sets the ID yourself, so a deploy can carry the same identifier as whatever produced it (a release tag, a catalog build, a timestamped artifact) and `deployments list` needs no cross-referencing. The ID is used **exactly as given**, case included: it exists to match your identifier, and it never appears in a client-facing URL (the edge rule builds the origin path from it server-side). IDs become storage paths, so they take letters, digits and `-`, `_` or `.`, 4 to 64 characters, starting and ending alphanumeric: `20260827-1433-r42`, `Catalog_V3`, `v1.2.3`. - Deploy IDs are therefore **case-sensitive**. `publish`/`delete` match exactly and suggest a case variant when one exists, and deploying an ID that differs from an existing one only in case is refused (not even with `--force`), since two storage paths differing only by case are indistinguishable to anything that folds case. - An explicit ID is an assertion about identity, so it is never aliased onto an earlier deploy that happens to share content: each release keeps its own ID and rollback target even when the bytes are unchanged. - Reusing an ID for **different** content asks before replacing, because rolling back to that ID would then serve the new files instead of the originals (`--force` skips the prompt for CI). A replacement clears the old files first, so nothing stale survives. The **live deploy and the rollback target are never replaceable in place** (not even with `--force`): that would empty and rewrite the files being served. Deploy under a new ID, or publish another deploy first. - The git sha is still recorded alongside a custom ID when the deploy came from a repo, so provenance is not lost; `deployments list` shows it as `custom (git abc12345)`. -- Dotfiles and `node_modules` are never uploaded. +- Dotfiles and `node_modules` are never uploaded, except `.well-known/`. Symlinked files and directories upload as their targets when those resolve inside the deploy directory; a link pointing outside it (or onto an excluded dotfile) is skipped with a warning. --- -## `bunny sites create`; Provision a site +## `bunny sites create`: Provision a site ```bash bunny sites create # uses `sites.name` from bunny.jsonc, else prompts (directory-name suggestion), then a custom domain @@ -64,18 +64,20 @@ 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`) | -| `--tier` | Storage tier: `hdd` (Standard) or `ssd` (Edge, always `DE`); create-time only | -| `--domain` | Attach a custom production domain after provisioning; interactive runs prompt for one when omitted | -| `--link` | Link this directory (default true; `--no-link` to skip) | +| Flag | Description | +| --------------- | --------------------------------------------------------------------------------------------------- | +| `--region` | Main storage region code (default `DE`) | +| `--tier` | Storage tier: `hdd` (Standard) or `ssd` (Edge, always `DE`); create-time only | +| `--domain` | Attach a custom production domain after provisioning; interactive runs prompt for one when omitted | +| `--link` | Link this directory (default true; `--no-link` to skip) | +| `--from-zone` | Import an existing storage zone (name or ID) and its pull zone as the site instead of creating them | +| `--force`, `-f` | Skip the import confirmation (only with `--from-zone`) | -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. +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 (the error says so), reusing whatever was already provisioned. A name already used by any site, including an imported one, is refused. --- -## `bunny sites deploy`; Deploy a directory +## `bunny sites deploy`: Deploy a directory ```bash bunny sites deploy ./dist # deploy and publish as the live site @@ -83,15 +85,17 @@ bunny sites deploy --build # run `sites.build` from bunny.jsonc 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`) | -| `--force` | Deploy even when content is unchanged | -| `--site` | Target site (name or storage zone ID) | -| `--link` | Link this directory to the deployed site (`--no-link` never links) | +| Flag | Description | +| ------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `[dir]` | Directory to deploy (default: `sites.dir` in bunny.jsonc, then the detected framework's output dir when building, 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`) | +| `--force` | Deploy even when content is unchanged | +| `--site` | Target site (name or storage zone ID) | +| `--link` | Link this directory to the deployed site (`--no-link` never links) | +| `--deploy-id` | Your own deploy ID (release tag, catalog build); see Deploy IDs | +| `--spa`, `--no-spa` | Serve `index.html` for client-side routes, or the 404 page; beats `sites.spa` and detection | 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. Redeploying content that is already uploaded skips the upload and just republishes it; when it is already live, the deploy is a no-op unless you pass `--force`. @@ -103,14 +107,14 @@ Interactive `deploy` adds two conveniences (both skipped under `--output json`): --- -## `bunny sites deployments`; List, publish, prune, delete +## `bunny sites deployments`: List, publish, prune, delete ```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 -bunny sites deployments prune my-site # or --site my-site +bunny sites deployments prune my-site # target a site other than the linked one bunny sites deployments delete a1b2c3d4 --force # delete one deploy ``` @@ -120,7 +124,7 @@ bunny sites deployments delete a1b2c3d4 --force # delete one deploy --- -## `bunny sites domains`; Custom domains +## `bunny sites domains`: Custom domains ```bash bunny sites domains add example.com --wait # wait for DNS, then issue SSL @@ -133,7 +137,7 @@ A custom domain is the site's production URL and nothing more. The first added d --- -## `bunny sites ci init`; GitHub Actions deployments +## `bunny sites ci init`: GitHub Actions deployments ```bash bunny sites ci init # detect the framework, write .github/workflows/bunny-sites.yml @@ -141,7 +145,7 @@ bunny sites ci init --framework astro # skip detection (astro, vite, react bunny sites ci init --site my-site --force # overwrite an existing workflow ``` -Writes a workflow using the `BunnyWay/actions/deploy-site` action with the site name baked in: pushes to `main` go live, plus `workflow_dispatch` for on-demand redeploys. Deploys serialize and are never cancelled in flight, since cancelling mid-upload would leave a half-written deploy directory behind. `sites.dir` and `sites.build` from `bunny.jsonc` override the preset's deploy directory and build command, so CI builds and deploys exactly what a local `sites deploy` does. The workflow is written at the git root; when `bunny.jsonc` lives below it (a monorepo package), the job gets `defaults.run.working-directory` and the deploy directory is prefixed, so those paths still mean what they do locally. Framework detection reads `package.json` dependencies, `Gemfile`, or Hugo config; the lockfile picks the package manager for the install steps. The job requests `contents: read` and `deployments: write`, so the run is recorded in the repository's Environments. After writing, the CLI offers to run `gh secret set BUNNYNET_API_KEY` (or prints the manual steps). `sites create` offers the same scaffold on GitHub repos; declining prints the workflow instead. +Writes a workflow using the `BunnyWay/actions/deploy-site` action with the site name baked in: pushes to the repository's default branch go live, plus `workflow_dispatch` for on-demand redeploys. Deploys serialize and are never cancelled in flight, since cancelling mid-upload would leave a half-written deploy directory behind. `sites.dir` and `sites.build` from `bunny.jsonc` override the preset's deploy directory and build command, so CI builds and deploys exactly what a local `sites deploy` does. The workflow is written at the git root; when `bunny.jsonc` lives below it (a monorepo package), the job gets `defaults.run.working-directory` and the deploy directory is prefixed, so those paths still mean what they do locally; `sites deploy` itself falls back to the `bunny.jsonc` above the deploy directory when none is found from the working directory, so the nested project's `spa` and framework detection still apply. Framework detection reads `package.json` dependencies, a Blazor WebAssembly `.csproj`, `Gemfile`, or a Hugo, Python, or Zola config; the lockfile picks the package manager for the install steps (a nested project without one uses the repo root's; with no lockfile at all the workflow does a plain, uncached install), and Python sites install `requirements.txt` when present, else the generator itself. The job requests `contents: read` and `deployments: write`, so the run is recorded in the repository's Environments. The workflow pins the action's `cli_version` to the generating CLI's minor line, so CI deploys the same way. After writing, the CLI offers to store the `BUNNYNET_API_KEY` repository secret (or prints the manual steps). `sites create` offers the same scaffold on GitHub repos; declining prints the workflow instead. --- @@ -167,13 +171,14 @@ An optional `sites` block configures the deploy defaults (validated on its own, "name": "my-site", // resolves the site when nothing is linked "dir": "./dist", // default deploy directory "build": "npm run build", // command for `deploy --build` + "spa": true, // serve index.html for client-side routes (omit to detect) }, } ``` ## CI / agents -- Pass `--force` on anything with a confirmation (publish, prune, remove, delete); without a TTY they error with a hint rather than waiting on a prompt. +- Pass `--force` on anything with a confirmation (publish, prune, remove, delete, `create --from-zone`); without a TTY they error with a hint rather than waiting on a prompt. - Pass the site explicitly (or commit `bunny.jsonc` with `sites.name`); the interactive picker is disabled under `--output json` and by `--force`, so `sites delete --force` with nothing linked errors instead of prompting. -- `--output json` on every command emits machine-readable results. `deploy` prints `{ id, production, unchanged, live }`, where `production` is `null` on a site whose hostname couldn't be read. +- `--output json` on every command emits machine-readable results. `deploy` prints `{ site, id, source, files, bytes, production, unchanged, live }`, where `production` is `null` on a site whose hostname couldn't be read. - The first-deploy custom-domain prompt never runs under `--output json` or without a TTY, so CI deploys are unaffected.