From eeb6b96d1c89c44317c87b61dc6ea3f1206ba4bb Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 28 Jul 2026 10:25:55 +0100 Subject: [PATCH 01/11] fix(sites): error instead of hanging when a confirmation has no TTY `confirm()` blocks forever when stdin isn't a TTY, so `sites delete`, `sites deployments publish`, `sites deployments prune` and the shared `domains remove` would hang in CI, and print the prompt to stdout ahead of the `--output json` payload. Add `requireConfirmable()` next to `isInteractive()` and call it before each of those confirmations, so an unattended run fails fast with a `--force` hint. --- .changeset/sites-confirm-non-interactive.md | 5 +++++ AGENTS.md | 15 ++++++++++----- packages/cli/src/commands/sites/delete.ts | 12 +++++++++++- .../cli/src/commands/sites/deployments/prune.ts | 7 ++++++- .../src/commands/sites/deployments/publish.ts | 7 ++++++- packages/cli/src/core/hostnames/commands.ts | 7 ++++++- packages/cli/src/core/ui.test.ts | 16 ++++++++++++++++ packages/cli/src/core/ui.ts | 10 ++++++++++ skills/bunny-cli/references/sites.md | 2 +- 9 files changed, 71 insertions(+), 10 deletions(-) create mode 100644 .changeset/sites-confirm-non-interactive.md create mode 100644 packages/cli/src/core/ui.test.ts diff --git a/.changeset/sites-confirm-non-interactive.md b/.changeset/sites-confirm-non-interactive.md new file mode 100644 index 0000000..d1bebdb --- /dev/null +++ b/.changeset/sites-confirm-non-interactive.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": patch +--- + +fix(sites): `delete`, `deployments publish/prune` and `domains remove` now error with a `--force` hint when there's no TTY to answer their confirmation, instead of hanging on a prompt (and writing it to stdout ahead of `--output json`) diff --git a/AGENTS.md b/AGENTS.md index 7d088b9..fa5f9df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -190,7 +190,7 @@ bunny-cli/ │ │ │ ├── flow.ts # offerDnsWaitAndSsl(): poll DNS + opportunistically attempt SSL issuance (~30s) since bunny's resolvers decide validation; printSslHint(). dnsAlreadyLive skips the poll (Bunny DNS record already live). offerBunnyDnsThenSsl() takes an optional onBunnyDnsZone(zone) callback fired when the hostname is on Bunny DNS (lets the command layer link the directory). setupHostname(): resource-agnostic add-hostname -> DNS -> SSL orchestration (caller supplies sslHint/retryHint); used by scripts setupCustomDomain and storage zone add │ │ │ ├── bunny-dns.ts # findBunnyDnsZone()/offerBunnyDnsRecord(): detect a hostname inside an account Bunny DNS zone, then add/repoint a PullZone record (always confirmed) so SSL can issue immediately │ │ │ ├── bunny-dns.test.ts # Tests for longest-suffix zone matching + record-name derivation with a fake core client -│ │ │ └── commands.ts # createHostnamesCommands(): add/ssl/list/remove factory parameterized by a pull-zone resolver +│ │ │ └── commands.ts # createHostnamesCommands(): add/ssl/list/remove factory parameterized by a pull-zone resolver; remove guards its confirmation with requireConfirmable (unattended runs need --force) │ │ ├── bunny-config.ts # Shared bunny.jsonc discovery + raw read (findConfigRoot, configPath, configExists, readBunnyConfig); used by apps/ and sites/ config.ts │ │ ├── jsonc.ts # syncJsonc(): surgical JSONC editing that preserves comments, key order, and sibling blocks │ │ ├── jsonc.test.ts # Tests for syncJsonc (comment/formatting preservation, key add/remove) @@ -201,7 +201,8 @@ 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(), confirmTyped(), spinner() wrappers +│ │ ├── ui.ts # readPassword(), confirm(), confirmTyped(), requireConfirmable() (unattended runs must pass --force instead of hanging on a prompt), spinner() wrappers +│ │ ├── ui.test.ts # Tests for requireConfirmable (no-TTY guard, --force bypass) │ │ └── version.ts # VERSION constant from package.json │ │ │ ├── config/ @@ -717,6 +718,10 @@ Masked password input using `prompts` with `type: "password"`. Used for API key Confirmation prompt using `prompts` with `type: "confirm"`. If `opts.force` is `true`, returns `true` immediately without prompting. This maps to the `--force` flag pattern used in `auth login` and `auth logout`. +### `requireConfirmable(output, { force, message, hint })` + +Guard called immediately before a `confirm()`/`confirmTyped()` that gates a destructive action. Returns silently with `force`, or when `isInteractive(output)`; otherwise throws a `UserError` with `hint`. Without it an unattended run (CI, `--output json`, no TTY) blocks forever on a prompt nobody can answer, and the prompt lands on stdout ahead of the JSON payload. Used by `sites delete`, `sites deployments publish/prune`, and the shared `domains remove`. + ### `spinner(text: string): ora.Ora` Creates an `ora` spinner. Automatically silenced in non-TTY environments (`isSilent: !process.stdout.isTTY`). @@ -1111,8 +1116,8 @@ bunny │ ├── deployments │ │ ├── list [site] (alias: ls) List deploys (● Live / ○ Previous markers, created, source, files, size) │ │ ├── publish [id] [--previous] [--site] [--force] (alias: promote) -│ │ │ Promote a past deploy; instant rollback (--previous = the previous deploy) -│ │ └── prune [--keep N] [--site] [--force] Delete old deploys (never current/previous; default keeps 5) +│ │ │ Promote a past deploy; instant rollback (--previous = the previous deploy). Unattended runs need --force (the confirmation is guarded by requireConfirmable) +│ │ └── prune [--keep N] [--site] [--force] Delete old deploys (never current/previous; default keeps 5). Unattended runs need --force; "nothing to prune" still succeeds without it │ ├── domains (hidden alias: hostnames); mounts core/hostnames createHostnamesCommands with a sites resolver │ │ ├── add [site] [--ssl] [--wait] [--no-force-ssl] Add a domain; also attaches *.preview. + records the domain in site state (onAdded hook) │ │ ├── ssl [site] Issue a free SSL certificate @@ -1124,7 +1129,7 @@ bunny │ ├── 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) +│ └── delete [site] [--force] [--keep-storage] Delete pull zone → router → storage zone (typed-name confirmation, so unattended runs need --force; 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") diff --git a/packages/cli/src/commands/sites/delete.ts b/packages/cli/src/commands/sites/delete.ts index ac42e96..00dd6eb 100644 --- a/packages/cli/src/commands/sites/delete.ts +++ b/packages/cli/src/commands/sites/delete.ts @@ -7,7 +7,12 @@ 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 { + confirm, + confirmTyped, + requireConfirmable, + withSpinner, +} from "../../core/ui.ts"; import { deleteSiteResources } from "./api.ts"; import { SITES_MANIFEST, type SiteManifest } from "./constants.ts"; import { @@ -65,6 +70,11 @@ export const sitesDeleteCommand = defineCommand({ const what = args["keep-storage"] ? "its pull zone and router" : "its pull zone, router, and ALL deploy files"; + requireConfirmable(output, { + force, + message: `Deleting "${state.name}" needs a confirmation prompt.`, + hint: "Re-run with --force to delete non-interactively.", + }); const confirmed = (await confirm( `Delete site "${state.name}" (${what})? This cannot be undone.`, diff --git a/packages/cli/src/commands/sites/deployments/prune.ts b/packages/cli/src/commands/sites/deployments/prune.ts index d28e92b..27e057a 100644 --- a/packages/cli/src/commands/sites/deployments/prune.ts +++ b/packages/cli/src/commands/sites/deployments/prune.ts @@ -4,7 +4,7 @@ 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 { confirm, requireConfirmable, withSpinner } from "../../../core/ui.ts"; import { deleteDeployFiles, writeRemoteState } from "../api.ts"; import { DEFAULT_KEEP_DEPLOYS, @@ -76,6 +76,11 @@ export const sitesDeploymentsPruneCommand = defineCommand({ return; } + requireConfirmable(output, { + force: args.force, + message: `Pruning ${victims.length} deploy(s) needs a confirmation prompt.`, + hint: "Re-run with --force to prune non-interactively.", + }); const proceed = await confirm( `Delete ${victims.length} old deploy(s) from ${state.name} (${victims .map((v) => v.id) diff --git a/packages/cli/src/commands/sites/deployments/publish.ts b/packages/cli/src/commands/sites/deployments/publish.ts index de50eed..9a436c0 100644 --- a/packages/cli/src/commands/sites/deployments/publish.ts +++ b/packages/cli/src/commands/sites/deployments/publish.ts @@ -7,7 +7,7 @@ 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 { confirm, requireConfirmable, withSpinner } from "../../../core/ui.ts"; import { promoteDeploy, writeRemoteState } from "../api.ts"; import { markCurrent } from "../constants.ts"; import { @@ -112,6 +112,11 @@ export const sitesDeploymentsPublishCommand = defineCommand({ return; } + requireConfirmable(output, { + force: args.force, + message: `Publishing ${targetId} needs a confirmation prompt.`, + hint: "Re-run with --force to publish non-interactively.", + }); const proceed = await confirm( `Publish deploy ${targetId} as production for ${state.name}?`, { force: args.force }, diff --git a/packages/cli/src/core/hostnames/commands.ts b/packages/cli/src/core/hostnames/commands.ts index 5e6b85b..7ceae74 100644 --- a/packages/cli/src/core/hostnames/commands.ts +++ b/packages/cli/src/core/hostnames/commands.ts @@ -5,7 +5,7 @@ import { UserError } from "../errors.ts"; import { formatTable } from "../format.ts"; import { logger } from "../logger.ts"; import type { GlobalArgs } from "../types.ts"; -import { confirm, isInteractive, spinner } from "../ui.ts"; +import { confirm, isInteractive, requireConfirmable, spinner } from "../ui.ts"; import { addHostname, type CoreClient, @@ -508,6 +508,11 @@ export function createHostnamesCommands( ); } + requireConfirmable(args.output, { + force: args.force, + message: `Removing ${hostname} needs a confirmation prompt.`, + hint: "Re-run with --force to remove it non-interactively.", + }); const confirmed = await confirm(`Remove ${hostname}?`, { force: args.force, }); diff --git a/packages/cli/src/core/ui.test.ts b/packages/cli/src/core/ui.test.ts new file mode 100644 index 0000000..926a8c0 --- /dev/null +++ b/packages/cli/src/core/ui.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "bun:test"; +import { requireConfirmable } from "./ui.ts"; + +const OPTS = { message: "Needs a prompt.", hint: "Re-run with --force." }; + +// `bun test` runs without a TTY, so every call here takes the unattended path. +test("requireConfirmable throws when there's no TTY to answer the prompt", () => { + expect(() => requireConfirmable("text", OPTS)).toThrow("Needs a prompt."); + expect(() => requireConfirmable("json", OPTS)).toThrow("Needs a prompt."); +}); + +test("requireConfirmable passes with --force", () => { + expect(() => + requireConfirmable("json", { ...OPTS, force: true }), + ).not.toThrow(); +}); diff --git a/packages/cli/src/core/ui.ts b/packages/cli/src/core/ui.ts index d2e7534..cabd746 100644 --- a/packages/cli/src/core/ui.ts +++ b/packages/cli/src/core/ui.ts @@ -1,5 +1,6 @@ import ora from "ora"; import prompts from "prompts"; +import { UserError } from "./errors.ts"; /** * Masked password input. Returns an empty string if the user cancels. @@ -58,6 +59,15 @@ export function isInteractive(output?: string): boolean { ); } +// Guard a confirmation there's nobody to answer: an unguarded prompt blocks forever in CI and lands on stdout ahead of `--output json`, so unattended runs must pass --force. +export function requireConfirmable( + output: string | undefined, + opts: { force?: boolean; message: string; hint: string }, +): void { + if (opts.force || isInteractive(output)) return; + throw new UserError(opts.message, opts.hint); +} + /** Creates an ora spinner. Automatically silenced in non-TTY environments. */ export function spinner(text: string) { return ora({ text, isSilent: !process.stdout.isTTY }); diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index 40e0283..75071d6 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -154,6 +154,6 @@ An optional `sites` block configures the deploy defaults (validated on its own, ## CI / agents -- Pass `--force` on anything with a confirmation (publish, prune, remove, delete). +- Pass `--force` on anything with a confirmation (publish, prune, remove, delete); without a TTY they error with a hint rather than waiting on a prompt. - Pass the site explicitly (or commit `bunny.jsonc` with `sites.name`); the interactive picker is disabled under `--output json`. - `--output json` on every command emits machine-readable results (deploy prints `{ id, production, preview, promoted }`). From 36996678afb3ada86b3cb8d3a991995586460ab0 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 28 Jul 2026 10:27:26 +0100 Subject: [PATCH 02/11] fix(sites): --force opts out of the site picker on destructive commands `--force` already skips the confirmation, so `sites delete --force` with nothing linked would open the picker and delete whichever site was highlighted, unprompted. Thread `force` into `selectSite` from delete, deployments publish/prune and the domains resolver (only `remove` defines the flag) so those runs error with the same hint a non-interactive run gets. `deploy --force` means "redeploy unchanged content" and still gets the picker. --- .changeset/sites-force-picker.md | 5 +++++ AGENTS.md | 4 ++-- packages/cli/src/commands/sites/delete.ts | 1 + packages/cli/src/commands/sites/deploy.ts | 1 + packages/cli/src/commands/sites/deployments/prune.ts | 1 + packages/cli/src/commands/sites/deployments/publish.ts | 1 + packages/cli/src/commands/sites/domains/index.ts | 2 ++ packages/cli/src/commands/sites/interactive.ts | 6 ++++-- skills/bunny-cli/references/sites.md | 4 ++-- 9 files changed, 19 insertions(+), 6 deletions(-) create mode 100644 .changeset/sites-force-picker.md diff --git a/.changeset/sites-force-picker.md b/.changeset/sites-force-picker.md new file mode 100644 index 0000000..a169e4c --- /dev/null +++ b/.changeset/sites-force-picker.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": patch +--- + +fix(sites): `--force` on `delete`, `deployments publish/prune` and `domains remove` now errors without an explicit or linked site instead of opening the picker, so a highlighted site can't be acted on with the confirmation already skipped diff --git a/AGENTS.md b/AGENTS.md index fa5f9df..51cc183 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -384,7 +384,7 @@ bunny-cli/ │ │ │ ├── constants.test.ts # parseRemoteState round-trip/rejection + helper tests │ │ │ ├── api.ts # siteFiles IO seam (connect/download/upload/remove; swap in tests instead of mock.module), remote state read/write (sha256 etag optimistic lock: concurrent deploy records merge on mismatch, ours win per id; 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]) +│ │ │ ├── interactive.ts # selectSite: explicit ref (storage zone ID/name, falling back to a state.name match since zone names carry a suffix) → .bunny/site.json → bunny.jsonc sites.name → picker (offerLink like scripts); `force` errors instead of opening the picker (destructive commands pass their --force, which also skips the confirmation, so a picked site would be acted on unprompted; deploy's --force means "redeploy unchanged content" and is not passed); optional offerCreate (deploy only) adds a new-vs-existing prompt, and creates straight away when the account has no sites; siteOptionBuilder (--site) + sitePositionalBuilder ([site]) │ │ │ ├── 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 @@ -1105,7 +1105,7 @@ bunny │ └── stats [id] [--from] [--to] [--hourly] [--link] │ Show usage statistics (requests/CPU/cost totals + bar chart; defaults to last 30 days). No ID → linked script → interactive picker (offers to link; --no-link skips). JSON output skips the picker and errors. ├── sites (experimental; hidden from help and landing page) -│ │ 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). +│ │ Static-site hosting: one storage zone (files) + one pull zone (CDN) + one middleware router script per site. Zone names are `sites-{name}-{random suffix}` (prefixed for dashboard grouping; suffixed because zone names are global across bunny.net); the site keeps its clean name in state. Deploys are immutable directories (`deploys/{id}/`); promote/rollback flips the router's CURRENT_DEPLOY env var + purges the cache; no files move. A deploy's immutable preview URL is `sites--.b-cdn.net/deploys/{id}/`; the router's HTMLRewriter rewrites root-absolute asset URLs in that path-preview HTML to `/deploys/{id}/…` so Jekyll/most SSGs render on a single pull zone (each deploy's assets get a unique cache key). Custom domains add isolated per-deploy subdomains (`dpl-{id}.preview.{domain}`, root-served, no rewriting). Site state lives at `_bunny/site.json` in the storage zone (403-blocked by the router); `.bunny/site.json` is the local pointer. Site resolution everywhere: explicit ref → .bunny/site.json → `sites.name` in bunny.jsonc → interactive picker (offers to link). The picker is skipped, with an error, under `--output json`/no TTY and on destructive commands run with `--force`. │ ├── 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) diff --git a/packages/cli/src/commands/sites/delete.ts b/packages/cli/src/commands/sites/delete.ts index 00dd6eb..0f2dc33 100644 --- a/packages/cli/src/commands/sites/delete.ts +++ b/packages/cli/src/commands/sites/delete.ts @@ -64,6 +64,7 @@ export const sitesDeleteCommand = defineCommand({ site: args.site, link: false, output, + force, }); const { state } = site; diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 8eb40b7..98a8eee 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -157,6 +157,7 @@ export const sitesDeployCommand = defineCommand({ const coreClient = createCoreClient(options); const computeClient = createComputeClient(options); + // No `force` here: deploy's --force only redeploys unchanged content, so the picker stays. const { site, offerLink } = await selectSite(coreClient, { site: args.site, link: args.link, diff --git a/packages/cli/src/commands/sites/deployments/prune.ts b/packages/cli/src/commands/sites/deployments/prune.ts index 27e057a..9b676a7 100644 --- a/packages/cli/src/commands/sites/deployments/prune.ts +++ b/packages/cli/src/commands/sites/deployments/prune.ts @@ -57,6 +57,7 @@ export const sitesDeploymentsPruneCommand = defineCommand({ site: args.site, link: false, output, + force: args.force, }); const { state, connection, etag } = site; diff --git a/packages/cli/src/commands/sites/deployments/publish.ts b/packages/cli/src/commands/sites/deployments/publish.ts index 9a436c0..12e40de 100644 --- a/packages/cli/src/commands/sites/deployments/publish.ts +++ b/packages/cli/src/commands/sites/deployments/publish.ts @@ -61,6 +61,7 @@ export const sitesDeploymentsPublishCommand = defineCommand({ site: args.site, link: args.link, output, + force: args.force, }); const { state, connection, etag } = site; diff --git a/packages/cli/src/commands/sites/domains/index.ts b/packages/cli/src/commands/sites/domains/index.ts index fb704b6..5503895 100644 --- a/packages/cli/src/commands/sites/domains/index.ts +++ b/packages/cli/src/commands/sites/domains/index.ts @@ -25,10 +25,12 @@ async function resolveSitePullZone( const config = resolveConfig(args.profile, args.apiKey, args.verbose); const coreClient = createCoreClient(clientOptions(config, args.verbose)); + // Only `remove` defines --force, so add/ssl/list keep the picker. const { site } = await selectSite(coreClient, { site: args.site as string | undefined, link: false, output: args.output, + force: args.force === true, }); resolvedSite = site; diff --git a/packages/cli/src/commands/sites/interactive.ts b/packages/cli/src/commands/sites/interactive.ts index 22df5a7..8af9cf6 100644 --- a/packages/cli/src/commands/sites/interactive.ts +++ b/packages/cli/src/commands/sites/interactive.ts @@ -96,11 +96,12 @@ export interface SelectedSite { 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. +// 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. Destructive commands pass their `--force` to opt out of the picker. export async function selectSite( client: CoreClient, args: SiteSelectorArgs & { output: OutputFormat; + force?: boolean; offerCreate?: () => Promise; }, ): Promise { @@ -142,7 +143,8 @@ export async function selectSite( }; } - if (!isInteractive(args.output)) { + // `--force` skips the confirmation too, so picking a site from a list would act on it unprompted. + if (args.force || !isInteractive(args.output)) { throw new UserError( "No site specified and no linked site found.", "Pass a site name or run `bunny sites link`.", diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index 75071d6..016d565 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -7,7 +7,7 @@ Most commands accept an optional site (a trailing `[site]` positional, or the `- 1. Explicit name or storage zone ID 2. `.bunny/site.json` manifest (written by `bunny sites link` or `bunny sites create`) 3. `sites.name` in `bunny.jsonc` -4. Interactive prompt (suppressed in `--output json` mode; pass a site or link the directory in CI) +4. Interactive prompt (suppressed in `--output json` mode, and on destructive commands run with `--force`; pass a site or link the directory in CI) ## Typical workflows @@ -155,5 +155,5 @@ An optional `sites` block configures the deploy defaults (validated on its own, ## CI / agents - Pass `--force` on anything with a confirmation (publish, prune, remove, delete); 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`. +- Pass the site explicitly (or commit `bunny.jsonc` with `sites.name`); the interactive picker is disabled under `--output json` and by `--force`, so `sites delete --force` with nothing linked errors instead of prompting. - `--output json` on every command emits machine-readable results (deploy prints `{ id, production, preview, promoted }`). From 994a533cbfa7191d247e8dea2bcf69df12638aad Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 28 Jul 2026 10:28:59 +0100 Subject: [PATCH 03/11] fix(sites): validate deployments prune --keep before pruning yargs coerces `--keep abc` to NaN, and NaN passes straight through pruneVictims' `Math.max(0, keep)` into `slice(NaN)`, which behaves like `slice(0)`: every deploy but current and previous gets deleted. Negative counts did the same. Validate the count in the handler via resolveKeepCount so it fails before any state is touched. --- .changeset/sites-prune-keep-validation.md | 5 +++++ AGENTS.md | 4 ++-- .../commands/sites/deployments/prune.test.ts | 20 ++++++++++++++++++- .../src/commands/sites/deployments/prune.ts | 18 +++++++++++++++-- 4 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 .changeset/sites-prune-keep-validation.md diff --git a/.changeset/sites-prune-keep-validation.md b/.changeset/sites-prune-keep-validation.md new file mode 100644 index 0000000..a675038 --- /dev/null +++ b/.changeset/sites-prune-keep-validation.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": patch +--- + +fix(sites): `deployments prune --keep` now rejects non-integer and negative counts up front; `--keep abc` reached the pruner as NaN and deleted every deploy except the live and previous ones diff --git a/AGENTS.md b/AGENTS.md index 51cc183..d3fa705 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -405,7 +405,7 @@ bunny-cli/ │ │ │ ├── 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 +│ │ │ ├── deployments/ # list (● Live/○ Previous), publish [id]|--previous (alias promote; confirm + promote + current/previous swap), prune --keep N (resolveKeepCount validates the count first; pruneVictims never drops current/previous) + prune.test.ts │ │ │ └── domains/index.ts # Mounts core/hostnames createHostnamesCommands as "sites domains" with onAdded/onRemoved hooks: apex add also attaches *.preview. (attachPreviewWildcard, best-effort SSL) + records state.domain; remove takes the wildcard down too. setupSiteDomain composes setupHostname + wildcard for create --domain │ │ ├── registries/ │ │ │ ├── index.ts # Manual CommandModule (not defineNamespace) — default handler runs list @@ -1117,7 +1117,7 @@ bunny │ │ ├── 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). Unattended runs need --force (the confirmation is guarded by requireConfirmable) -│ │ └── prune [--keep N] [--site] [--force] Delete old deploys (never current/previous; default keeps 5). Unattended runs need --force; "nothing to prune" still succeeds without it +│ │ └── prune [--keep N] [--site] [--force] Delete old deploys (never current/previous; default keeps 5). --keep is validated as a whole number >= 0 before anything runs (resolveKeepCount: yargs turns `--keep abc` into NaN, which would otherwise slip through pruneVictims and prune everything). Unattended runs need --force; "nothing to prune" still succeeds without it │ ├── domains (hidden alias: hostnames); mounts core/hostnames createHostnamesCommands with a sites resolver │ │ ├── add [site] [--ssl] [--wait] [--no-force-ssl] Add a domain; also attaches *.preview. + records the domain in site state (onAdded hook) │ │ ├── ssl [site] Issue a free SSL certificate diff --git a/packages/cli/src/commands/sites/deployments/prune.test.ts b/packages/cli/src/commands/sites/deployments/prune.test.ts index ae196be..ed74c18 100644 --- a/packages/cli/src/commands/sites/deployments/prune.test.ts +++ b/packages/cli/src/commands/sites/deployments/prune.test.ts @@ -1,5 +1,10 @@ import { expect, test } from "bun:test"; -import { type DeployRecord, pruneVictims } from "../constants.ts"; +import { + DEFAULT_KEEP_DEPLOYS, + type DeployRecord, + pruneVictims, +} from "../constants.ts"; +import { resolveKeepCount } from "./prune.ts"; function deploy(id: string, createdAt: string): DeployRecord { return { id, createdAt, source: "content", files: 1, bytes: 1 }; @@ -26,3 +31,16 @@ test("pruneVictims is empty when everything fits", () => { expect(pruneVictims(DEPLOYS, 10)).toEqual([]); expect(pruneVictims([], 0)).toEqual([]); }); + +test("resolveKeepCount defaults and passes whole counts through", () => { + expect(resolveKeepCount(undefined)).toBe(DEFAULT_KEEP_DEPLOYS); + expect(resolveKeepCount(0)).toBe(0); + expect(resolveKeepCount(10)).toBe(10); +}); + +// `--keep abc` arrives as NaN; unchecked it would prune every deploy but current/previous. +test("resolveKeepCount rejects NaN, negatives, and fractions", () => { + expect(() => resolveKeepCount(Number.NaN)).toThrow("--keep must be"); + expect(() => resolveKeepCount(-1)).toThrow("--keep must be"); + expect(() => resolveKeepCount(2.5)).toThrow("--keep must be"); +}); diff --git a/packages/cli/src/commands/sites/deployments/prune.ts b/packages/cli/src/commands/sites/deployments/prune.ts index 9b676a7..dcd48ee 100644 --- a/packages/cli/src/commands/sites/deployments/prune.ts +++ b/packages/cli/src/commands/sites/deployments/prune.ts @@ -2,7 +2,7 @@ import { createCoreClient } from "@bunny.net/openapi-client"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; -import { errorMessage } from "../../../core/errors.ts"; +import { errorMessage, UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; import { confirm, requireConfirmable, withSpinner } from "../../../core/ui.ts"; import { deleteDeployFiles, writeRemoteState } from "../api.ts"; @@ -22,6 +22,18 @@ interface PruneArgs extends SiteSelectorArgs { force?: boolean; } +// yargs hands us NaN for `--keep abc`, which passes pruneVictims' `Math.max(0, keep)` untouched and marks every deploy but current/previous for deletion; the same goes for a negative count. +export function resolveKeepCount(keep: number | undefined): number { + const value = keep ?? DEFAULT_KEEP_DEPLOYS; + if (!Number.isInteger(value) || value < 0) { + throw new UserError( + "--keep must be a whole number of deploys to keep, 0 or more.", + `Omit it to keep the newest ${DEFAULT_KEEP_DEPLOYS}.`, + ); + } + return value; +} + export const sitesDeploymentsPruneCommand = defineCommand({ command: "prune", describe: "Delete old deploys, keeping the most recent ones.", @@ -50,6 +62,8 @@ export const sitesDeploymentsPruneCommand = defineCommand({ handler: async (args) => { const { profile, output, verbose, apiKey } = args; + const keep = resolveKeepCount(args.keep); + const config = resolveConfig(profile, apiKey, verbose); const client = createCoreClient(clientOptions(config, verbose)); @@ -63,7 +77,7 @@ export const sitesDeploymentsPruneCommand = defineCommand({ const victims = pruneVictims( state.deploys, - args.keep ?? DEFAULT_KEEP_DEPLOYS, + keep, state.current, state.previous, ); From bc35c92d05a16b61ce3e9b1347ef800288f381c4 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 28 Jul 2026 10:34:07 +0100 Subject: [PATCH 04/11] fix(sites): scope --link to the commands that can link `sitePositionalBuilder`/`siteOptionBuilder` bundled `--link` with the site target, so `open`, `ssl`, `delete` and `deployments prune` advertised a flag none of them acts on: they never call `offerLink`, and delete/prune hardcode `link: false`. Split the flag into its own `siteLinkOption` and mount it only where offerLink runs. An explicit `--link` was also dropped whenever the site came from `--site` or `bunny.jsonc`, so `deploy --site my-site --link` never wrote the manifest. Those paths now link on request; the picker keeps prompting unless the flag already decided it. --- .changeset/sites-link-flag-scope.md | 5 ++ AGENTS.md | 14 ++-- packages/cli/src/commands/sites/ci/init.ts | 3 +- packages/cli/src/commands/sites/deploy.ts | 68 ++++++++-------- .../src/commands/sites/deployments/list.ts | 3 +- .../src/commands/sites/deployments/publish.ts | 33 ++++---- .../cli/src/commands/sites/interactive.ts | 79 ++++++++++--------- packages/cli/src/commands/sites/open.ts | 12 +-- packages/cli/src/commands/sites/show.ts | 3 +- packages/cli/src/commands/sites/ssl.ts | 4 +- .../cli/src/commands/sites/upgrade-router.ts | 3 +- skills/bunny-cli/references/sites.md | 3 + 12 files changed, 123 insertions(+), 107 deletions(-) create mode 100644 .changeset/sites-link-flag-scope.md diff --git a/.changeset/sites-link-flag-scope.md b/.changeset/sites-link-flag-scope.md new file mode 100644 index 0000000..ff9d5a3 --- /dev/null +++ b/.changeset/sites-link-flag-scope.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": patch +--- + +fix(sites): `--link` is now only accepted by the commands that can act on it (`deploy`, `show`, `deployments list/publish`, `upgrade-router`, `ci init`), where it also links a site resolved from `--site` or `bunny.jsonc`; `open`, `ssl`, `delete` and `deployments prune` no longer advertise a flag they ignored diff --git a/AGENTS.md b/AGENTS.md index d3fa705..db02485 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -384,7 +384,7 @@ bunny-cli/ │ │ │ ├── constants.test.ts # parseRemoteState round-trip/rejection + helper tests │ │ │ ├── api.ts # siteFiles IO seam (connect/download/upload/remove; swap in tests instead of mock.module), remote state read/write (sha256 etag optimistic lock: concurrent deploy records merge on mismatch, ours win per id; 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); `force` errors instead of opening the picker (destructive commands pass their --force, which also skips the confirmation, so a picked site would be acted on unprompted; deploy's --force means "redeploy unchanged content" and is not passed); optional offerCreate (deploy only) adds a new-vs-existing prompt, and creates straight away when the account has no sites; siteOptionBuilder (--site) + sitePositionalBuilder ([site]) +│ │ │ ├── interactive.ts # selectSite: explicit ref (storage zone ID/name, falling back to a state.name match since zone names carry a suffix) → .bunny/site.json → bunny.jsonc sites.name → picker (offerLink like scripts); `force` errors instead of opening the picker (destructive commands pass their --force, which also skips the confirmation, so a picked site would be acted on unprompted; deploy's --force means "redeploy unchanged content" and is not passed); optional offerCreate (deploy only) adds a new-vs-existing prompt, and creates straight away when the account has no sites; siteOptionBuilder (--site) + sitePositionalBuilder ([site]) + siteLinkOption (--link, mounted only by the commands that call offerLink); an explicit --link links whatever site was resolved (ref or bunny.jsonc included), while the picker keeps prompting unless --link/--no-link decided it │ │ │ ├── provision.ts # promptSiteName (normalize/validate, directory-name suggestion) + createSiteWithProgress (createSite under a step-tracking spinner; shared with create.ts) + createLinkedSite (create + manifest link → SiteContext, skipping create's domain/CI prompts) for the deploy picker's new-site branch │ │ │ ├── config.ts # loadSiteConfig: reads bunny.jsonc via core/bunny-config.ts and validates ONLY the `sites` block (SiteConfigSchema from @bunny.net/config), so sites-only configs work without an `app` block or `version` │ │ │ ├── router/source.ts # routerSource: the middleware Edge Script (one script per site; no version tracking; upgrade-router just republishes the latest). apex → CURRENT_DEPLOY, dpl-{id}.preview.{domain} → that deploy, /deploys/{id}/ passthrough (path preview) flagged with x-bunny-preview header, /_bunny/* → 403 (client-sent x-bunny-preview 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 @@ -1109,13 +1109,13 @@ bunny │ ├── create [name] [--region] [--domain] [--link] │ │ Provision a site (idempotent; a failed create re-runs cleanly; each resource is looked up by name first). Interactive runs prompt for the name when omitted (directory-name suggestion). --domain also attaches *.preview. for per-deploy previews; when omitted, interactive runs offer to add one (Bunny DNS record with confirmation, nameserver guidance when undelegated, DNS wait + SSL). GitHub repos then get an offer to scaffold the deploy workflow (declining prints it instead). │ ├── list (alias: ls) List sites (middleware+storage pull zones with matching remote state) -│ ├── show [site] Show resources, domains (with SSL + Force SSL state), current deploy; warns when a newer router is available +│ ├── show [site] [--link] Show resources, domains (with SSL + Force SSL state), current deploy; warns when a newer router is available │ ├── open [site] [--print] Open the live URL (recorded custom domain when live, else system host) in the browser; --print emits it -│ ├── deploy [dir] [--site] [--build [cmd]] [--env K=V] [--env-file] [--production/--prod] [--force] +│ ├── deploy [dir] [--site] [--link] [--build [cmd]] [--env K=V] [--env-file] [--production/--prod] [--force] │ │ Deploy a directory to a preview URL: git short-sha ID when the tree is clean, content hash otherwise; identical IDs are no-ops (an already-uploaded ID with --production skips the upload and just publishes). The immutable preview URL is `sites--.b-cdn.net/deploys/{id}/` (custom-domain `dpl-{id}.preview.*` when a domain exists), rendered correctly by the router's HTMLRewriter. The target site resolves via selectSite (--site → linked → bunny.jsonc → picker); when nothing is linked, the interactive picker offers to create a new site (or, with no sites yet, goes straight to create) and links it. A [dir] arg is cwd-relative; without it the target is `sites.dir` (or the detected output dir), resolved against the bunny.jsonc directory where the build runs, else that directory (dotfiles + node_modules excluded). --build runs the command (or `sites.build`, else the detected build; resolved before any site is created so a missing command can't leave an orphan site) in the caller's environment plus --env/--env-file overrides. Without --build, an interactive run offers to run the configured `sites.build`, else a detected build (the CI framework preset's command, else a package.json `build` script); confirming builds first and, when no dir was given, deploys the framework's output dir. --production/--prod publishes the deploy as the live site. │ ├── deployments -│ │ ├── list [site] (alias: ls) List deploys (● Live / ○ Previous markers, created, source, files, size) -│ │ ├── publish [id] [--previous] [--site] [--force] (alias: promote) +│ │ ├── list [site] [--link] (alias: ls) List deploys (● Live / ○ Previous markers, created, source, files, size) +│ │ ├── publish [id] [--previous] [--site] [--link] [--force] (alias: promote) │ │ │ Promote a past deploy; instant rollback (--previous = the previous deploy). Unattended runs need --force (the confirmation is guarded by requireConfirmable) │ │ └── prune [--keep N] [--site] [--force] Delete old deploys (never current/previous; default keeps 5). --keep is validated as a whole number >= 0 before anything runs (resolveKeepCount: yargs turns `--keep abc` into NaN, which would otherwise slip through pruneVictims and prune everything). Unattended runs need --force; "nothing to prune" still succeeds without it │ ├── domains (hidden alias: hostnames); mounts core/hostnames createHostnamesCommands with a sites resolver @@ -1125,10 +1125,10 @@ bunny │ │ └── remove [site] [--force] Remove a domain (also removes its *.preview wildcard, onRemoved hook) │ ├── ssl [site] [--no-force-ssl] Toggle Force HTTPS on the site's b-cdn.net system host (no cert issued; custom domains use `sites domains ssl`) │ ├── ci -│ │ └── init [--site] [--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` +│ │ └── init [--site] [--link] [--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 +│ ├── upgrade-router [site] [--link] 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, so unattended runs need --force; best-effort so re-runs finish a partial delete) ├── docs Open bunny.net documentation in browser ├── open [--print] Open bunny.net dashboard in browser (or print URL) diff --git a/packages/cli/src/commands/sites/ci/init.ts b/packages/cli/src/commands/sites/ci/init.ts index 131afcd..b1ed314 100644 --- a/packages/cli/src/commands/sites/ci/init.ts +++ b/packages/cli/src/commands/sites/ci/init.ts @@ -7,6 +7,7 @@ import { isInteractive } from "../../../core/ui.ts"; import { type SiteSelectorArgs, selectSite, + siteLinkOption, siteOptionBuilder, } from "../interactive.ts"; import { FRAMEWORK_PRESETS } from "./frameworks.ts"; @@ -36,7 +37,7 @@ export const sitesCiInitCommand = defineCommand({ ], builder: (yargs) => - siteOptionBuilder(yargs) + siteLinkOption(siteOptionBuilder(yargs)) .option("framework", { type: "string", choices: FRAMEWORK_PRESETS.map((p) => p.id), diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 98a8eee..dd23877 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -35,6 +35,7 @@ import { resolveDeployIdentity } from "./deploy-id.ts"; import { type SiteSelectorArgs, selectSite, + siteLinkOption, siteOptionBuilder, } from "./interactive.ts"; import { createLinkedSite, promptSiteName } from "./provision.ts"; @@ -97,38 +98,41 @@ export const sitesDeployCommand = defineCommand({ ], 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", - }), + siteLinkOption( + 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; diff --git a/packages/cli/src/commands/sites/deployments/list.ts b/packages/cli/src/commands/sites/deployments/list.ts index 745d183..e1dfc5b 100644 --- a/packages/cli/src/commands/sites/deployments/list.ts +++ b/packages/cli/src/commands/sites/deployments/list.ts @@ -11,6 +11,7 @@ import { logger } from "../../../core/logger.ts"; import { type SiteSelectorArgs, selectSite, + siteLinkOption, sitePositionalBuilder, } from "../interactive.ts"; @@ -26,7 +27,7 @@ export const sitesDeploymentsListCommand = defineCommand({ ["$0 sites deployments list --output json", "JSON output"], ], - builder: (yargs) => sitePositionalBuilder(yargs), + builder: (yargs) => siteLinkOption(sitePositionalBuilder(yargs)), handler: async ({ site: ref, link, profile, output, verbose, apiKey }) => { const config = resolveConfig(profile, apiKey, verbose); diff --git a/packages/cli/src/commands/sites/deployments/publish.ts b/packages/cli/src/commands/sites/deployments/publish.ts index 12e40de..a15477a 100644 --- a/packages/cli/src/commands/sites/deployments/publish.ts +++ b/packages/cli/src/commands/sites/deployments/publish.ts @@ -13,6 +13,7 @@ import { markCurrent } from "../constants.ts"; import { type SiteSelectorArgs, selectSite, + siteLinkOption, siteOptionBuilder, } from "../interactive.ts"; @@ -34,21 +35,23 @@ export const sitesDeploymentsPublishCommand = defineCommand({ ], 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", - }), + siteLinkOption( + 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; diff --git a/packages/cli/src/commands/sites/interactive.ts b/packages/cli/src/commands/sites/interactive.ts index 8af9cf6..07db7ee 100644 --- a/packages/cli/src/commands/sites/interactive.ts +++ b/packages/cli/src/commands/sites/interactive.ts @@ -17,7 +17,7 @@ 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)"; + "Link this directory to the site (prompted when picking one; --no-link never links)"; /** Args contributed by {@link siteOptionBuilder} / {@link sitePositionalBuilder}. */ export interface SiteSelectorArgs { @@ -25,28 +25,32 @@ export interface SiteSelectorArgs { link?: boolean; } -/** `--site` option + `--link`, for commands whose positionals are taken. */ +/** `--site` option, 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; + return yargs.option("site", { + type: "string", + describe: ARG_SITE_DESCRIPTION, + }) as Argv; } -/** Trailing `[site]` positional + `--link`. Pair with `command: "... [site]"`. */ +/** Trailing `[site]` positional. 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; + return yargs.positional("site", { + type: "string", + describe: ARG_SITE_DESCRIPTION, + }) as Argv; +} + +/** `--link`. Only for commands that call {@link SelectedSite.offerLink}; elsewhere the flag would parse and do nothing. */ +export function siteLinkOption(yargs: Argv): Argv { + return yargs.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). @@ -92,10 +96,18 @@ async function contextFromRef( 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. + // Link the directory to the site: prompted when it came from the picker, silent when `--link` asked for it, a no-op otherwise, so commands can always call it. offerLink: () => Promise; } +function linkDirectory(site: SiteContext): void { + saveManifest(SITES_MANIFEST, { + id: site.state.storageZoneId, + name: site.state.name, + }); + logger.success(`Linked to ${site.state.name} (${site.state.storageZoneId}).`); +} + // 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. Destructive commands pass their `--force` to opt out of the picker. export async function selectSite( client: CoreClient, @@ -106,15 +118,17 @@ export async function selectSite( }, ): Promise { const noLink = async () => {}; + // A site resolved from a ref or from bunny.jsonc isn't prompted about, but an explicit `--link` still asks for it to be linked. + const linkIfRequested = (site: SiteContext) => async () => { + if (args.link === true) linkDirectory(site); + }; if (args.site) { const ref = args.site; - return { - site: await withSpinner("Resolving site...", () => - contextFromRef(client, ref), - ), - offerLink: noLink, - }; + const site = await withSpinner("Resolving site...", () => + contextFromRef(client, ref), + ); + return { site, offerLink: linkIfRequested(site) }; } const manifest = loadManifest(SITES_MANIFEST); @@ -134,13 +148,11 @@ export async function selectSite( const configured = loadSiteConfig()?.config.name; if (configured) { - return { - site: await withSpinner( - `Resolving site "${configured}" from bunny.jsonc...`, - () => contextFromRef(client, configured), - ), - offerLink: noLink, - }; + const site = await withSpinner( + `Resolving site "${configured}" from bunny.jsonc...`, + () => contextFromRef(client, configured), + ); + return { site, offerLink: linkIfRequested(site) }; } // `--force` skips the confirmation too, so picking a site from a list would act on it unprompted. @@ -205,14 +217,7 @@ export async function selectSite( 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}).`, - ); + if (shouldLink) linkDirectory(context); }, }; } diff --git a/packages/cli/src/commands/sites/open.ts b/packages/cli/src/commands/sites/open.ts index ec2ce8d..be0f06c 100644 --- a/packages/cli/src/commands/sites/open.ts +++ b/packages/cli/src/commands/sites/open.ts @@ -57,19 +57,11 @@ export const sitesOpenCommand = defineCommand({ describe: "Print the URL instead of opening it in the browser", }), - handler: async ({ - site: ref, - link, - print, - profile, - output, - verbose, - apiKey, - }) => { + handler: async ({ site: ref, 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 { site } = await selectSite(client, { site: ref, output }); const { state } = site; const hostnames = await fetchPullZoneHostnames(client, state.pullZoneId); diff --git a/packages/cli/src/commands/sites/show.ts b/packages/cli/src/commands/sites/show.ts index ac7d842..51f0266 100644 --- a/packages/cli/src/commands/sites/show.ts +++ b/packages/cli/src/commands/sites/show.ts @@ -17,6 +17,7 @@ import { withSpinner } from "../../core/ui.ts"; import { type SiteSelectorArgs, selectSite, + siteLinkOption, sitePositionalBuilder, } from "./interactive.ts"; @@ -31,7 +32,7 @@ export const sitesShowCommand = defineCommand({ ["$0 sites show --output json", "JSON output"], ], - builder: (yargs) => sitePositionalBuilder(yargs), + builder: (yargs) => siteLinkOption(sitePositionalBuilder(yargs)), handler: async ({ site: ref, link, profile, output, verbose, apiKey }) => { const config = resolveConfig(profile, apiKey, verbose); diff --git a/packages/cli/src/commands/sites/ssl.ts b/packages/cli/src/commands/sites/ssl.ts index 0b6dcd8..4628efa 100644 --- a/packages/cli/src/commands/sites/ssl.ts +++ b/packages/cli/src/commands/sites/ssl.ts @@ -41,13 +41,13 @@ export const sitesSslCommand = defineCommand({ }), handler: async (args) => { - const { site: ref, link, profile, output, verbose, apiKey } = args; + const { site: ref, 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 { site } = await selectSite(client, { site: ref, output }); const { state } = site; const systemHost = await withSpinner("Updating Force SSL...", async () => { diff --git a/packages/cli/src/commands/sites/upgrade-router.ts b/packages/cli/src/commands/sites/upgrade-router.ts index f6ea990..7c00735 100644 --- a/packages/cli/src/commands/sites/upgrade-router.ts +++ b/packages/cli/src/commands/sites/upgrade-router.ts @@ -10,6 +10,7 @@ import { withSpinner } from "../../core/ui.ts"; import { type SiteSelectorArgs, selectSite, + siteLinkOption, sitePositionalBuilder, } from "./interactive.ts"; import { routerSource } from "./router/source.ts"; @@ -25,7 +26,7 @@ export const sitesUpgradeRouterCommand = defineCommand({ ["$0 sites upgrade-router my-site", "Republish a specific site's router"], ], - builder: (yargs) => sitePositionalBuilder(yargs), + builder: (yargs) => siteLinkOption(sitePositionalBuilder(yargs)), handler: async (args) => { const { profile, output, verbose, apiKey } = args; diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index 016d565..9bb0666 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -9,6 +9,8 @@ Most commands accept an optional site (a trailing `[site]` positional, or the `- 3. `sites.name` in `bunny.jsonc` 4. Interactive prompt (suppressed in `--output json` mode, and on destructive commands run with `--force`; pass a site or link the directory in CI) +Commands that can link the directory (`deploy`, `show`, `deployments list/publish`, `upgrade-router`, `ci init`) take `--link`/`--no-link`: the picker prompts unless the flag decided it, and an explicit `--link` also links a site resolved from a ref or from `bunny.jsonc`. The other site commands never write the manifest and don't take the flag. + ## Typical workflows ```bash @@ -77,6 +79,7 @@ bunny sites deploy ./out --build "npm run build" --env VITE_FLAG=1 | `--production` | Publish as the live site (alias `--prod`; default is preview only) | | `--force` | Deploy even when content is unchanged | | `--site` | Target site (name or storage zone ID) | +| `--link` | Link this directory to the deployed site (`--no-link` never links) | 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. From 45c826eb8f410d1d7a11f0ad00a18348a6853b18 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 28 Jul 2026 10:35:16 +0100 Subject: [PATCH 05/11] fix(sites): accept the site as a positional on deployments prune `prune` used the `--site` flag builder despite having no positionals of its own, so `bunny sites deployments prune my-site` failed with "Unknown argument" while `deployments list my-site` worked. Declare `[site]` like the sibling subcommands; yargs keeps accepting the `--site` form. --- .changeset/sites-prune-site-positional.md | 5 +++++ AGENTS.md | 2 +- packages/cli/src/commands/sites/deployments/prune.ts | 7 ++++--- skills/bunny-cli/references/sites.md | 1 + 4 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 .changeset/sites-prune-site-positional.md diff --git a/.changeset/sites-prune-site-positional.md b/.changeset/sites-prune-site-positional.md new file mode 100644 index 0000000..147a53f --- /dev/null +++ b/.changeset/sites-prune-site-positional.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": patch +--- + +fix(sites): `deployments prune` takes the site as a positional (`prune my-site`) like its sibling subcommands, instead of rejecting it as an unknown argument; `--site` keeps working diff --git a/AGENTS.md b/AGENTS.md index db02485..49066ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1117,7 +1117,7 @@ bunny │ │ ├── list [site] [--link] (alias: ls) List deploys (● Live / ○ Previous markers, created, source, files, size) │ │ ├── publish [id] [--previous] [--site] [--link] [--force] (alias: promote) │ │ │ Promote a past deploy; instant rollback (--previous = the previous deploy). Unattended runs need --force (the confirmation is guarded by requireConfirmable) -│ │ └── prune [--keep N] [--site] [--force] Delete old deploys (never current/previous; default keeps 5). --keep is validated as a whole number >= 0 before anything runs (resolveKeepCount: yargs turns `--keep abc` into NaN, which would otherwise slip through pruneVictims and prune everything). Unattended runs need --force; "nothing to prune" still succeeds without it +│ │ └── prune [site] [--keep N] [--force] Delete old deploys (never current/previous; default keeps 5). --keep is validated as a whole number >= 0 before anything runs (resolveKeepCount: yargs turns `--keep abc` into NaN, which would otherwise slip through pruneVictims and prune everything). Unattended runs need --force; "nothing to prune" still succeeds without it │ ├── domains (hidden alias: hostnames); mounts core/hostnames createHostnamesCommands with a sites resolver │ │ ├── add [site] [--ssl] [--wait] [--no-force-ssl] Add a domain; also attaches *.preview. + records the domain in site state (onAdded hook) │ │ ├── ssl [site] Issue a free SSL certificate diff --git a/packages/cli/src/commands/sites/deployments/prune.ts b/packages/cli/src/commands/sites/deployments/prune.ts index dcd48ee..7fa9cfe 100644 --- a/packages/cli/src/commands/sites/deployments/prune.ts +++ b/packages/cli/src/commands/sites/deployments/prune.ts @@ -14,7 +14,7 @@ import { import { type SiteSelectorArgs, selectSite, - siteOptionBuilder, + sitePositionalBuilder, } from "../interactive.ts"; interface PruneArgs extends SiteSelectorArgs { @@ -35,7 +35,7 @@ export function resolveKeepCount(keep: number | undefined): number { } export const sitesDeploymentsPruneCommand = defineCommand({ - command: "prune", + command: "prune [site]", describe: "Delete old deploys, keeping the most recent ones.", examples: [ [ @@ -43,11 +43,12 @@ export const sitesDeploymentsPruneCommand = defineCommand({ `Keep the ${DEFAULT_KEEP_DEPLOYS} newest deploys`, ], ["$0 sites deployments prune --keep 10", "Keep the 10 newest deploys"], + ["$0 sites deployments prune my-site", "Prune a specific site"], ["$0 sites deployments prune --force", "Skip confirmation"], ], builder: (yargs) => - siteOptionBuilder(yargs) + sitePositionalBuilder(yargs) .option("keep", { type: "number", default: DEFAULT_KEEP_DEPLOYS, diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index 9bb0666..bd9c5e0 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -97,6 +97,7 @@ 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 ``` `publish` (alias `promote`) flips production to a past deploy; the files are already on the CDN, so this is instant plus a cache purge. From b7f879570aab98092c9e275ff0e40a7e5f899d17 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 28 Jul 2026 10:38:40 +0100 Subject: [PATCH 06/11] fix(sites): ci init writes sites.dir and sites.build into the workflow The scaffold only ever used the framework preset, so a project with `sites.dir: "build"` deployed `build/` locally and the preset's `dist/` from CI, and a configured `sites.build` was dropped entirely. Thread the `sites` block through scaffoldSitesWorkflow into renderSitesWorkflow (both for `ci init` and the offer inside `sites create`), report the effective directory in the detection and success lines, and quote a configured build command that a bare YAML scalar couldn't carry. --- .changeset/sites-ci-init-respects-config.md | 5 +++ AGENTS.md | 4 +- README.md | 2 +- packages/cli/src/commands/sites/ci/init.ts | 8 +++- .../cli/src/commands/sites/ci/scaffold.ts | 30 +++++++++++-- .../src/commands/sites/ci/workflow.test.ts | 36 +++++++++++++++ .../cli/src/commands/sites/ci/workflow.ts | 44 +++++++++++++------ packages/cli/src/commands/sites/create.ts | 8 +++- skills/bunny-cli/references/sites.md | 2 +- 9 files changed, 115 insertions(+), 24 deletions(-) create mode 100644 .changeset/sites-ci-init-respects-config.md diff --git a/.changeset/sites-ci-init-respects-config.md b/.changeset/sites-ci-init-respects-config.md new file mode 100644 index 0000000..0fceae0 --- /dev/null +++ b/.changeset/sites-ci-init-respects-config.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": patch +--- + +fix(sites): `ci init` now writes `sites.dir` and `sites.build` from `bunny.jsonc` into the generated workflow, so CI stops deploying the framework preset's directory while a local `sites deploy` uses the configured one diff --git a/AGENTS.md b/AGENTS.md index 49066ee..666f10d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -404,7 +404,7 @@ bunny-cli/ │ │ │ ├── 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 +│ │ │ ├── ci/ # frameworks.ts (preset table of ~30 frameworks across js/ruby/hugo/python/zola/dotnet toolchains + detection: package.json deps/Gemfile/python+zola config files + lockfile pm), workflow.ts (renderSitesWorkflow -> .github/workflows/bunny-sites.yml using BunnyWay/actions/deploy-site; optional dir/build override the preset, and a build command that would break a bare YAML scalar is quoted), scaffold.ts (git helpers, scaffoldSitesWorkflow -> ScaffoldResult.dir is the effective deploy dir, printWorkflowInstructions, offerGitHubSecret via gh), init.ts (bunny sites ci init) + tests │ │ │ ├── deployments/ # list (● Live/○ Previous), publish [id]|--previous (alias promote; confirm + promote + current/previous swap), prune --keep N (resolveKeepCount validates the count first; pruneVictims never drops current/previous) + prune.test.ts │ │ │ └── domains/index.ts # Mounts core/hostnames createHostnamesCommands as "sites domains" with onAdded/onRemoved hooks: apex add also attaches *.preview. (attachPreviewWildcard, best-effort SSL) + records state.domain; remove takes the wildcard down too. setupSiteDomain composes setupHostname + wildcard for create --domain │ │ ├── registries/ @@ -1125,7 +1125,7 @@ bunny │ │ └── remove [site] [--force] Remove a domain (also removes its *.preview wildcard, onRemoved hook) │ ├── ssl [site] [--no-force-ssl] Toggle Force HTTPS on the site's b-cdn.net system host (no cert issued; custom domains use `sites domains ssl`) │ ├── ci -│ │ └── init [--site] [--link] [--framework] [--force] Write .github/workflows/bunny-sites.yml: framework detection (package.json deps, Gemfile, hugo/python/zola config files; lockfile picks the package manager), previews on PRs + production on main via BunnyWay/actions/deploy-site, offers `gh secret set BUNNY_API_KEY` +│ │ └── init [--site] [--link] [--framework] [--force] Write .github/workflows/bunny-sites.yml: `sites.dir`/`sites.build` from bunny.jsonc override the preset's directory and build command (so CI deploys what `sites deploy` does), else framework detection (package.json deps, Gemfile, hugo/python/zola config files; lockfile picks the package manager), previews on PRs + production on main via BunnyWay/actions/deploy-site, offers `gh secret set BUNNY_API_KEY` │ ├── link [site] Link this directory to a site → .bunny/site.json │ ├── unlink Remove .bunny/site.json │ ├── upgrade-router [site] [--link] Republish the site's router script with the CLI's current source diff --git a/README.md b/README.md index 79f9be2..c3e6ec2 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ bun ny sites open # open the site's live URL in the br bun ny sites ci init # add a GitHub Actions workflow (preview on PRs, production on main) ``` -Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`) so a deploy needs no flags: `bun ny sites deploy --build --prod`. See [`examples/sites/`](examples/sites/) for ready-to-copy configs (Vite, Astro, Next.js static export, Hugo, plain HTML, and a combined app + site file). +Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`) so a deploy needs no flags: `bun ny sites deploy --build --prod`. `bun ny sites ci init` writes the same `build` and `dir` into the generated workflow. See [`examples/sites/`](examples/sites/) for ready-to-copy configs (Vite, Astro, Next.js static export, Hugo, plain HTML, and a combined app + site file). ### Available Scripts diff --git a/packages/cli/src/commands/sites/ci/init.ts b/packages/cli/src/commands/sites/ci/init.ts index b1ed314..bdffdb4 100644 --- a/packages/cli/src/commands/sites/ci/init.ts +++ b/packages/cli/src/commands/sites/ci/init.ts @@ -4,6 +4,7 @@ import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { logger } from "../../../core/logger.ts"; import { isInteractive } from "../../../core/ui.ts"; +import { loadSiteConfig } from "../config.ts"; import { type SiteSelectorArgs, selectSite, @@ -69,12 +70,16 @@ export const sitesCiInitCommand = defineCommand({ ); } + // `sites.dir`/`sites.build` are what a local deploy uses, so the workflow follows them. + const siteConfig = loadSiteConfig()?.config; const result = await scaffoldSitesWorkflow({ site: name, root, frameworkId: args.framework, interactive, force: args.force, + dir: siteConfig?.dir, + build: siteConfig?.build, }); if (output === "json") { @@ -85,6 +90,7 @@ export const sitesCiInitCommand = defineCommand({ path: result.path, framework: result.preset.id, packageManager: result.packageManager, + directory: result.dir, }, null, 2, @@ -99,7 +105,7 @@ export const sitesCiInitCommand = defineCommand({ } logger.success( - `Wrote ${result.path} (${result.preset.label}, deploys ${result.preset.dir}).`, + `Wrote ${result.path} (${result.preset.label}, deploys ${result.dir}).`, ); logger.log(); await offerGitHubSecret({ apiKey: config.apiKey, root, interactive }); diff --git a/packages/cli/src/commands/sites/ci/scaffold.ts b/packages/cli/src/commands/sites/ci/scaffold.ts index 766acbd..2c02de4 100644 --- a/packages/cli/src/commands/sites/ci/scaffold.ts +++ b/packages/cli/src/commands/sites/ci/scaffold.ts @@ -30,6 +30,8 @@ export interface ScaffoldResult { path: string; preset: FrameworkPreset; packageManager: PackageManager; + /** Directory the workflow deploys: `sites.dir` when configured, else the preset's. */ + dir: string; } /** Resolve the framework preset: explicit id, detection, prompt, static fallback. */ @@ -37,6 +39,7 @@ async function resolvePreset( root: string, frameworkId: string | undefined, interactive: boolean, + dir: string | undefined, ): Promise { if (frameworkId) { const preset = findPreset(frameworkId); @@ -51,7 +54,7 @@ async function resolvePreset( const detected = await detectFramework(root); if (detected) { - logger.info(`Detected ${detected.label} (deploys ${detected.dir}).`); + logger.info(`Detected ${detected.label} (deploys ${dir ?? detected.dir}).`); return detected; } @@ -72,24 +75,29 @@ async function resolvePreset( 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`. +// 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`. `sites.dir`/`sites.build` from bunny.jsonc win over the preset, so CI deploys what `sites deploy` does. export async function scaffoldSitesWorkflow(opts: { site: string; root: string; frameworkId?: string; interactive: boolean; force?: boolean; + dir?: string; + build?: string; }): Promise { const preset = await resolvePreset( opts.root, opts.frameworkId, opts.interactive, + opts.dir, ); const packageManager = await detectPackageManager(opts.root); const content = renderSitesWorkflow({ site: opts.site, preset, packageManager, + dir: opts.dir, + build: opts.build, }); const target = join(opts.root, SITES_WORKFLOW_PATH); @@ -109,13 +117,19 @@ export async function scaffoldSitesWorkflow(opts: { mkdirSync(dirname(target), { recursive: true }); await Bun.write(target, content); - return { path: SITES_WORKFLOW_PATH, preset, packageManager }; + return { + path: SITES_WORKFLOW_PATH, + preset, + packageManager, + dir: opts.dir ?? preset.dir, + }; } /** Print the workflow and setup steps for users who declined the scaffold. */ export async function printWorkflowInstructions( site: string, root: string, + config?: { dir?: string; build?: string }, ): Promise { const preset = (await detectFramework(root)) ?? findPreset("static"); if (!preset) return; @@ -123,7 +137,15 @@ export async function printWorkflowInstructions( logger.log(); logger.log(`To deploy from GitHub later, add ${SITES_WORKFLOW_PATH}:`); logger.log(); - logger.log(renderSitesWorkflow({ site, preset, packageManager })); + logger.log( + renderSitesWorkflow({ + site, + preset, + packageManager, + dir: config?.dir, + build: config?.build, + }), + ); printSecretHint(); } diff --git a/packages/cli/src/commands/sites/ci/workflow.test.ts b/packages/cli/src/commands/sites/ci/workflow.test.ts index 470a279..e2867a2 100644 --- a/packages/cli/src/commands/sites/ci/workflow.test.ts +++ b/packages/cli/src/commands/sites/ci/workflow.test.ts @@ -94,6 +94,42 @@ test("zola installs the zola binary and blazor uses dotnet", () => { expect(blazor).toContain('directory: "bin/Release/net8.0/publish/wwwroot"'); }); +test("sites.dir and sites.build from bunny.jsonc win over the preset", () => { + const yml = renderSitesWorkflow({ + site: "s", + preset: preset("astro"), + packageManager: "npm", + dir: "build", + build: "make site", + }); + expect(yml).toContain("run: npm ci"); + expect(yml).toContain("run: make site"); + expect(yml).not.toContain("run: npm run build"); + expect(yml).toContain('directory: "build"'); +}); + +test("a configured build runs even for a static preset", () => { + const yml = renderSitesWorkflow({ + site: "s", + preset: preset("static"), + packageManager: "npm", + build: "./build.sh", + }); + expect(yml).toContain("run: ./build.sh"); + expect(yml).not.toContain("# No build step"); +}); + +test("a configured build that would break a bare YAML scalar is quoted", () => { + const yml = renderSitesWorkflow({ + site: "s", + preset: preset("static"), + packageManager: "npm", + build: "echo hi\n run: rm -rf /", + }); + expect(yml).toContain('run: "echo hi\\n run: rm -rf /"'); + expect(yml).not.toContain("\n run: rm -rf /\n"); +}); + test("interpolated site name is a quoted, inert YAML scalar", () => { const yml = renderSitesWorkflow({ site: "evil\n run: rm -rf /", diff --git a/packages/cli/src/commands/sites/ci/workflow.ts b/packages/cli/src/commands/sites/ci/workflow.ts index 0b29621..a764680 100644 --- a/packages/cli/src/commands/sites/ci/workflow.ts +++ b/packages/cli/src/commands/sites/ci/workflow.ts @@ -41,25 +41,38 @@ const JS_SETUP: Record = { ], }; -function jsSteps(preset: FrameworkPreset, pm: PackageManager): string[] { - const build = presetBuildCommand(preset, pm) ?? `${pm} run build`; - return [...JS_SETUP[pm], ` - run: ${build}`]; +// A `sites.build` from bunny.jsonc is user text, so quote it when a bare YAML scalar wouldn't survive it; preset commands are plain and stay unquoted. +const YAML_UNSAFE_SCALAR = /^[-?:,[\]{}#&*!|>'"%@`]|\n|:\s|\s#/; + +function runStep(command: string | undefined): string { + const value = command ?? ""; + return ` - run: ${YAML_UNSAFE_SCALAR.test(value) ? JSON.stringify(value) : value}`; +} + +function jsSteps( + preset: FrameworkPreset, + pm: PackageManager, + build: string | undefined, +): string[] { + const command = build ?? presetBuildCommand(preset, pm) ?? `${pm} run build`; + return [...JS_SETUP[pm], runStep(command)]; } function buildSteps( preset: FrameworkPreset, packageManager: PackageManager, + build: string | undefined, ): string[] { switch (preset.toolchain) { case "js": - return jsSteps(preset, packageManager); + return jsSteps(preset, packageManager, build); case "ruby": return [ " - uses: ruby/setup-ruby@v1", " with:", ' ruby-version: "3.3"', " bundler-cache: true", - ` - run: ${preset.build}`, + runStep(build ?? preset.build), " env:", " JEKYLL_ENV: production", ]; @@ -69,7 +82,7 @@ function buildSteps( " with:", ' hugo-version: "latest"', " extended: true", - ` - run: ${preset.build}`, + runStep(build ?? preset.build), ]; case "python": return [ @@ -77,32 +90,37 @@ function buildSteps( " with:", ' python-version: "3.x"', " - run: pip install -r requirements.txt", - ` - run: ${preset.build}`, + runStep(build ?? preset.build), ]; case "zola": return [ " - uses: taiki-e/install-action@v2", " with:", " tool: zola", - ` - run: ${preset.build}`, + runStep(build ?? preset.build), ]; case "dotnet": return [ " - uses: actions/setup-dotnet@v4", " with:", ' dotnet-version: "8.0.x"', - ` - run: ${preset.build}`, + runStep(build ?? preset.build), ]; case "none": - return [" # No build step: static files deploy as-is."]; + // A static site has no toolchain to set up, but a configured build still runs. + return build + ? [runStep(build)] + : [" # 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. +// Render the GitHub Actions workflow: previews on PRs, production on pushes to main, via the BunnyWay/actions deploy-site action. `dir`/`build` carry `sites.dir`/`sites.build` from bunny.jsonc so CI deploys what `sites deploy` does. export function renderSitesWorkflow(opts: { site: string; preset: FrameworkPreset; packageManager: PackageManager; + dir?: string; + build?: string; }): string { const { site, preset, packageManager } = opts; const lines = [ @@ -128,13 +146,13 @@ export function renderSitesWorkflow(opts: { " steps:", " - uses: actions/checkout@v4", "", - ...buildSteps(preset, packageManager), + ...buildSteps(preset, packageManager, opts.build), "", ` - 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)}`, + ` directory: ${JSON.stringify(opts.dir ?? preset.dir)}`, " production: ${{ github.event_name == 'push' }}", " api_key: ${{ secrets.BUNNY_API_KEY }}", ]; diff --git a/packages/cli/src/commands/sites/create.ts b/packages/cli/src/commands/sites/create.ts index 2c44160..e8196a8 100644 --- a/packages/cli/src/commands/sites/create.ts +++ b/packages/cli/src/commands/sites/create.ts @@ -21,6 +21,7 @@ import { printWorkflowInstructions, scaffoldSitesWorkflow, } from "./ci/scaffold.ts"; +import { loadSiteConfig } from "./config.ts"; import { SITES_MANIFEST, type SiteManifest } from "./constants.ts"; import { setupSiteDomain } from "./domains/index.ts"; import { createSiteWithProgress, promptSiteName } from "./provision.ts"; @@ -213,15 +214,18 @@ export const sitesCreateCommand = defineCommand({ "Set up GitHub deployments (preview on PRs, production on main)?", { initial: true }, ); + const siteConfig = loadSiteConfig()?.config; if (setup) { const scaffold = await scaffoldSitesWorkflow({ site: name, root, interactive: true, + dir: siteConfig?.dir, + build: siteConfig?.build, }); if (scaffold) { logger.success( - `Wrote ${scaffold.path} (${scaffold.preset.label}, deploys ${scaffold.preset.dir}).`, + `Wrote ${scaffold.path} (${scaffold.preset.label}, deploys ${scaffold.dir}).`, ); await offerGitHubSecret({ apiKey: config.apiKey, @@ -230,7 +234,7 @@ export const sitesCreateCommand = defineCommand({ }); } } else { - await printWorkflowInstructions(name, root); + await printWorkflowInstructions(name, root, siteConfig); } } } diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index bd9c5e0..568a6f7 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -125,7 +125,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 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. +Writes a workflow that deploys previews on pull requests and publishes to production on merges to `main`, using the `BunnyWay/actions/deploy-site` action with the site name baked in. `sites.dir` and `sites.build` from `bunny.jsonc` override the preset's deploy directory and build command, so CI builds and deploys exactly what a local `sites deploy` does. Framework detection reads `package.json` dependencies, `Gemfile`, or Hugo config; the lockfile picks the package manager for the install steps. Fork PRs are skipped (no secrets there). After writing, the CLI offers to run `gh secret set BUNNY_API_KEY` (or prints the manual steps). `sites create` offers the same scaffold on GitHub repos; declining prints the workflow instead. --- From 5fd2a7d42339524e50ddd5781a414fd5ab4b1f9b Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 28 Jul 2026 10:39:52 +0100 Subject: [PATCH 07/11] fix(sites): create falls back to sites.name from bunny.jsonc Every other sites command resolves the site through `sites.name`, but create never read the config, so `bunny sites create --output json` in a configured project failed with "Site name is required." Use the configured name when no positional is passed, and say where it came from in text output. --- .changeset/sites-create-config-name.md | 5 +++++ AGENTS.md | 4 ++-- packages/cli/src/commands/sites/create.ts | 18 ++++++++++++++---- skills/bunny-cli/references/sites.md | 2 +- 4 files changed, 22 insertions(+), 7 deletions(-) create mode 100644 .changeset/sites-create-config-name.md diff --git a/.changeset/sites-create-config-name.md b/.changeset/sites-create-config-name.md new file mode 100644 index 0000000..c449dea --- /dev/null +++ b/.changeset/sites-create-config-name.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": patch +--- + +fix(sites): `create` falls back to `sites.name` from `bunny.jsonc` like every other sites command, instead of failing with "Site name is required." in a configured project when it can't prompt diff --git a/AGENTS.md b/AGENTS.md index 666f10d..f587df5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -394,7 +394,7 @@ bunny-cli/ │ │ │ ├── uploader.test.ts # Walk/skip/hash tests + upload paths/checksums/retry via siteFiles swap │ │ │ ├── build.ts # resolveAutoBuild (framework preset or package.json build script, via ci/frameworks detection) + runBuildCommand (Bun.spawn shell, caller env + overrides, throws on non-zero exit) │ │ │ ├── build.test.ts # Env parsing + real build spawn success/failure -│ │ │ ├── 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) +│ │ │ ├── create.ts # bunny sites create [name] (falls back to `sites.name` in bunny.jsonc, else prompted with a directory-name suggestion): createSite (storage + router + pull zone; forces HTTPS on the system host, best-effort) + manifest link + custom domain via setupSiteDomain (--domain flag, offered interactively when omitted; domain failure warns, never fails the create) │ │ │ ├── list.ts # List sites (name, URL, deploy count, current) │ │ │ ├── show.ts # Site details + hostname table (SSL cert + Force SSL columns) + router-outdated warning │ │ │ ├── open.ts # bunny sites open [site]: open the live URL (recorded custom domain when live, else system host) in the browser; --print emits it, siteLiveUrl is the pure resolver @@ -1107,7 +1107,7 @@ bunny ├── 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). The picker is skipped, with an error, under `--output json`/no TTY and on destructive commands run with `--force`. │ ├── create [name] [--region] [--domain] [--link] -│ │ Provision a site (idempotent; a failed create re-runs cleanly; each resource is looked up by name first). Interactive runs prompt for the name when omitted (directory-name suggestion). --domain also attaches *.preview. for per-deploy previews; when omitted, interactive runs offer to add one (Bunny DNS record with confirmation, nameserver guidance when undelegated, DNS wait + SSL). GitHub repos then get an offer to scaffold the deploy workflow (declining prints it instead). +│ │ Provision a site (idempotent; a failed create re-runs cleanly; each resource is looked up by name first). A missing name comes from `sites.name` in bunny.jsonc (reported in text output), else interactive runs prompt for one (directory-name suggestion). --domain also attaches *.preview. for per-deploy previews; when omitted, interactive runs offer to add one (Bunny DNS record with confirmation, nameserver guidance when undelegated, DNS wait + SSL). GitHub repos then get an offer to scaffold the deploy workflow (declining prints it instead). │ ├── list (alias: ls) List sites (middleware+storage pull zones with matching remote state) │ ├── show [site] [--link] Show resources, domains (with SSL + Force SSL state), current deploy; warns when a newer router is available │ ├── open [site] [--print] Open the live URL (recorded custom domain when live, else system host) in the browser; --print emits it diff --git a/packages/cli/src/commands/sites/create.ts b/packages/cli/src/commands/sites/create.ts index e8196a8..0a56397 100644 --- a/packages/cli/src/commands/sites/create.ts +++ b/packages/cli/src/commands/sites/create.ts @@ -64,7 +64,10 @@ export const sitesCreateCommand = defineCommand({ 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", + "Use `sites.name` from bunny.jsonc, else prompt (directory-name suggestion)", + ], [ "$0 sites create my-site", "Create a site served at sites-my-site-.b-cdn.net", @@ -81,7 +84,7 @@ export const sitesCreateCommand = defineCommand({ .positional("name", { type: "string", describe: - "Site name; the storage zone, pull zone, and b-cdn.net subdomain become sites--xxxxxx (prompted when omitted)", + "Site name; the storage zone, pull zone, and b-cdn.net subdomain become sites--xxxxxx (defaults to `sites.name` in bunny.jsonc, else prompted)", }) .option("region", { type: "string", @@ -103,7 +106,15 @@ export const sitesCreateCommand = defineCommand({ const { profile, output, verbose, apiKey } = args; const interactive = isInteractive(output); - const name = await promptSiteName(args.name, interactive); + // `sites.name` is how every other sites command resolves the site, so create takes it as the name too. + const siteConfig = loadSiteConfig()?.config; + const name = await promptSiteName( + args.name ?? siteConfig?.name, + interactive, + ); + if (!args.name && siteConfig?.name && output !== "json") { + logger.info(`Using site name "${name}" from bunny.jsonc.`); + } const domain = args.domain ? normalizeHostname(args.domain) : undefined; @@ -214,7 +225,6 @@ export const sitesCreateCommand = defineCommand({ "Set up GitHub deployments (preview on PRs, production on main)?", { initial: true }, ); - const siteConfig = loadSiteConfig()?.config; if (setup) { const scaffold = await scaffoldSitesWorkflow({ site: name, diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index 568a6f7..b5caac9 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -44,7 +44,7 @@ bunny sites domains add example.com --wait # also attaches *.preview.example.com ## `bunny sites create`; Provision a site ```bash -bunny sites create # prompts for a name (directory-name suggestion), then a custom domain +bunny sites create # uses `sites.name` from bunny.jsonc, else prompts (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 From 1c6d0a4c047008717324f36e533946a809214546 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 28 Jul 2026 11:30:21 +0100 Subject: [PATCH 08/11] fix(sites): place the generated workflow for a nested bunny.jsonc `ci init` writes the workflow at the git root, but `sites.dir` and `sites.build` resolve against the bunny.jsonc directory. With the config in a monorepo package, CI ran the build from the checkout root and deployed `/dist` instead of `/packages/site/dist`. Pass the config directory through as `projectRoot`: framework and package-manager detection now run there, run steps get `defaults.run.working-directory`, the action's `directory` input takes the prefix (its inputs aren't affected by run defaults), and a lockfile of the project's own becomes `cache-dependency-path` so setup-node still finds one. Paths are compared through realpath, and a bunny.jsonc above the git root can't be expressed in a repo-rooted workflow, so its paths are dropped with a warning. --- .changeset/sites-ci-init-respects-config.md | 2 +- AGENTS.md | 4 +- packages/cli/src/commands/sites/ci/init.ts | 9 +- .../src/commands/sites/ci/scaffold.test.ts | 16 +++ .../cli/src/commands/sites/ci/scaffold.ts | 118 +++++++++++++++--- .../src/commands/sites/ci/workflow.test.ts | 39 ++++++ .../cli/src/commands/sites/ci/workflow.ts | 106 ++++++++++------ packages/cli/src/commands/sites/create.ts | 10 +- skills/bunny-cli/references/sites.md | 2 +- 9 files changed, 245 insertions(+), 61 deletions(-) create mode 100644 packages/cli/src/commands/sites/ci/scaffold.test.ts diff --git a/.changeset/sites-ci-init-respects-config.md b/.changeset/sites-ci-init-respects-config.md index 0fceae0..1815261 100644 --- a/.changeset/sites-ci-init-respects-config.md +++ b/.changeset/sites-ci-init-respects-config.md @@ -2,4 +2,4 @@ "@bunny.net/cli": patch --- -fix(sites): `ci init` now writes `sites.dir` and `sites.build` from `bunny.jsonc` into the generated workflow, so CI stops deploying the framework preset's directory while a local `sites deploy` uses the configured one +fix(sites): `ci init` now writes `sites.dir` and `sites.build` from `bunny.jsonc` into the generated workflow, so CI stops deploying the framework preset's directory while a local `sites deploy` uses the configured one; a `bunny.jsonc` below the repo root also gets a job working directory, a prefixed deploy directory, and its own lockfile as the cache path diff --git a/AGENTS.md b/AGENTS.md index f587df5..ab4e675 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -404,7 +404,7 @@ bunny-cli/ │ │ │ ├── unlink.ts # Remove .bunny/site.json │ │ │ ├── upgrade-router.ts # Republish the site's router script with the CLI's current source (pushes router improvements to an existing site) │ │ │ ├── delete.ts # Delete a site (typed-name confirm; --keep-storage; drops .bunny/site.json if it pointed here) -│ │ │ ├── ci/ # frameworks.ts (preset table of ~30 frameworks across js/ruby/hugo/python/zola/dotnet toolchains + detection: package.json deps/Gemfile/python+zola config files + lockfile pm), workflow.ts (renderSitesWorkflow -> .github/workflows/bunny-sites.yml using BunnyWay/actions/deploy-site; optional dir/build override the preset, and a build command that would break a bare YAML scalar is quoted), scaffold.ts (git helpers, scaffoldSitesWorkflow -> ScaffoldResult.dir is the effective deploy dir, printWorkflowInstructions, offerGitHubSecret via gh), init.ts (bunny sites ci init) + tests +│ │ │ ├── ci/ # frameworks.ts (preset table of ~30 frameworks across js/ruby/hugo/python/zola/dotnet toolchains + detection: package.json deps/Gemfile/python+zola config files + lockfile pm), workflow.ts (renderSitesWorkflow -> .github/workflows/bunny-sites.yml using BunnyWay/actions/deploy-site; optional dir/build override the preset, workingDirectory/cacheDependencyPath place a project that sits below the workflow root (`defaults.run.working-directory` covers every run step, `uses` inputs take the prefix via workflowPath instead), and a build command that would break a bare YAML scalar is quoted), scaffold.ts (git helpers, projectPrefix (bunny.jsonc directory relative to the git root, realpath-resolved; undefined when it escapes the root, which drops its paths with a warning), framework/package-manager detection runs in that project directory, scaffoldSitesWorkflow -> ScaffoldResult.dir is the effective root-relative deploy dir, printWorkflowInstructions, offerGitHubSecret via gh), init.ts (bunny sites ci init) + tests │ │ │ ├── deployments/ # list (● Live/○ Previous), publish [id]|--previous (alias promote; confirm + promote + current/previous swap), prune --keep N (resolveKeepCount validates the count first; pruneVictims never drops current/previous) + prune.test.ts │ │ │ └── domains/index.ts # Mounts core/hostnames createHostnamesCommands as "sites domains" with onAdded/onRemoved hooks: apex add also attaches *.preview. (attachPreviewWildcard, best-effort SSL) + records state.domain; remove takes the wildcard down too. setupSiteDomain composes setupHostname + wildcard for create --domain │ │ ├── registries/ @@ -1125,7 +1125,7 @@ bunny │ │ └── remove [site] [--force] Remove a domain (also removes its *.preview wildcard, onRemoved hook) │ ├── ssl [site] [--no-force-ssl] Toggle Force HTTPS on the site's b-cdn.net system host (no cert issued; custom domains use `sites domains ssl`) │ ├── ci -│ │ └── init [--site] [--link] [--framework] [--force] Write .github/workflows/bunny-sites.yml: `sites.dir`/`sites.build` from bunny.jsonc override the preset's directory and build command (so CI deploys what `sites deploy` does), else framework detection (package.json deps, Gemfile, hugo/python/zola config files; lockfile picks the package manager), previews on PRs + production on main via BunnyWay/actions/deploy-site, offers `gh secret set BUNNY_API_KEY` +│ │ └── init [--site] [--link] [--framework] [--force] Write .github/workflows/bunny-sites.yml at the git root: `sites.dir`/`sites.build` from bunny.jsonc override the preset's directory and build command (so CI deploys what `sites deploy` does), and a bunny.jsonc below the git root sets the job's working directory + prefixes the deploy directory; else framework detection (package.json deps, Gemfile, hugo/python/zola config files; lockfile picks the package manager), 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] [--link] Republish the site's router script with the CLI's current source diff --git a/packages/cli/src/commands/sites/ci/init.ts b/packages/cli/src/commands/sites/ci/init.ts index bdffdb4..abf7a9a 100644 --- a/packages/cli/src/commands/sites/ci/init.ts +++ b/packages/cli/src/commands/sites/ci/init.ts @@ -70,16 +70,17 @@ export const sitesCiInitCommand = defineCommand({ ); } - // `sites.dir`/`sites.build` are what a local deploy uses, so the workflow follows them. - const siteConfig = loadSiteConfig()?.config; + // `sites.dir`/`sites.build` are what a local deploy uses, so the workflow follows them, relative to the bunny.jsonc directory they resolve against. + const siteConfig = loadSiteConfig(); const result = await scaffoldSitesWorkflow({ site: name, root, + projectRoot: siteConfig?.root, frameworkId: args.framework, interactive, force: args.force, - dir: siteConfig?.dir, - build: siteConfig?.build, + dir: siteConfig?.config.dir, + build: siteConfig?.config.build, }); if (output === "json") { diff --git a/packages/cli/src/commands/sites/ci/scaffold.test.ts b/packages/cli/src/commands/sites/ci/scaffold.test.ts new file mode 100644 index 0000000..09201af --- /dev/null +++ b/packages/cli/src/commands/sites/ci/scaffold.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "bun:test"; +import { projectPrefix } from "./scaffold.ts"; + +test("projectPrefix is empty when the project is the workflow root", () => { + expect(projectPrefix("/repo", "/repo")).toBe(""); + expect(projectPrefix("/repo", undefined)).toBe(""); +}); + +test("projectPrefix returns the POSIX offset for a nested project", () => { + expect(projectPrefix("/repo", "/repo/packages/site")).toBe("packages/site"); +}); + +// A bunny.jsonc above the git root can't be referenced from a repo-rooted workflow. +test("projectPrefix is undefined when the project escapes the workflow root", () => { + expect(projectPrefix("/repo/app", "/repo")).toBeUndefined(); +}); diff --git a/packages/cli/src/commands/sites/ci/scaffold.ts b/packages/cli/src/commands/sites/ci/scaffold.ts index 2c02de4..f4b2e1b 100644 --- a/packages/cli/src/commands/sites/ci/scaffold.ts +++ b/packages/cli/src/commands/sites/ci/scaffold.ts @@ -1,5 +1,5 @@ -import { existsSync, mkdirSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { existsSync, mkdirSync, realpathSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, sep } from "node:path"; import prompts from "prompts"; import { UserError } from "../../../core/errors.ts"; import { runGit } from "../../../core/git.ts"; @@ -13,7 +13,11 @@ import { findPreset, type PackageManager, } from "./frameworks.ts"; -import { renderSitesWorkflow, SITES_WORKFLOW_PATH } from "./workflow.ts"; +import { + renderSitesWorkflow, + SITES_WORKFLOW_PATH, + workflowPath, +} from "./workflow.ts"; /** The repo root, or null when `cwd` isn't inside a git repository. */ export async function gitTopLevel(cwd: string): Promise { @@ -30,10 +34,79 @@ export interface ScaffoldResult { path: string; preset: FrameworkPreset; packageManager: PackageManager; - /** Directory the workflow deploys: `sites.dir` when configured, else the preset's. */ + /** 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; } +// Lockfiles that decide the package manager; a nested project may carry its own, in which case setup-node needs to be pointed at it. +const LOCKFILES = [ + "bun.lock", + "bun.lockb", + "pnpm-lock.yaml", + "yarn.lock", + "package-lock.json", +]; + +// 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 { + return realpathSync(path); + } catch { + return path; + } +} + +/** Where the project sits relative to the workflow root, POSIX-style; "" when they're the same and undefined when the project is outside the root. */ +export function projectPrefix( + workflowRoot: string, + projectRoot: string | undefined, +): string | undefined { + if (!projectRoot) return ""; + const rel = relative(realOrSelf(workflowRoot), realOrSelf(projectRoot)); + if (rel === "") return ""; + if (rel.startsWith("..") || isAbsolute(rel)) return undefined; + return rel.split(sep).join("/"); +} + +interface WorkflowSettings { + /** Directory detection runs in and `sites.dir`/`sites.build` resolve against. */ + projectRoot: string; + /** That directory relative to the workflow root, POSIX-style; "" when they're the same. */ + prefix: string; + dir?: string; + build?: string; + cacheDependencyPath?: string; +} + +// A bunny.jsonc outside the workflow root can't be expressed in a repo-rooted workflow, so its paths are dropped (with a warning) and the preset's are used from the root. +async function workflowSettings(opts: { + root: string; + projectRoot?: string; + dir?: string; + build?: string; +}): Promise { + const prefix = projectPrefix(opts.root, opts.projectRoot); + if (prefix === undefined) { + logger.warn( + `bunny.jsonc sits outside ${opts.root}, so its \`dir\`/\`build\` can't be used in the workflow.`, + ); + return { projectRoot: opts.root, prefix: "" }; + } + + const projectRoot = opts.projectRoot ?? opts.root; + // Only a lockfile of its own moves the cache lookup; a monorepo-root lockfile is what setup-node finds by default. + const lockfile = prefix + ? LOCKFILES.find((name) => existsSync(join(projectRoot, name))) + : undefined; + return { + projectRoot, + prefix, + dir: opts.dir, + build: opts.build, + cacheDependencyPath: lockfile ? workflowPath(prefix, lockfile) : undefined, + }; +} + /** Resolve the framework preset: explicit id, detection, prompt, static fallback. */ async function resolvePreset( root: string, @@ -75,29 +148,33 @@ async function resolvePreset( 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`. `sites.dir`/`sites.build` from bunny.jsonc win over the preset, so CI deploys what `sites deploy` does. +// 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`. `sites.dir`/`sites.build` from bunny.jsonc win over the preset, so CI deploys what `sites deploy` does; `projectRoot` (the bunny.jsonc directory) is where those paths resolve, and the workflow gets a working directory when it sits below `root`. export async function scaffoldSitesWorkflow(opts: { site: string; root: string; + projectRoot?: string; frameworkId?: string; interactive: boolean; force?: boolean; dir?: string; build?: string; }): Promise { + const settings = await workflowSettings(opts); const preset = await resolvePreset( - opts.root, + settings.projectRoot, opts.frameworkId, opts.interactive, - opts.dir, + settings.dir, ); - const packageManager = await detectPackageManager(opts.root); + const packageManager = await detectPackageManager(settings.projectRoot); const content = renderSitesWorkflow({ site: opts.site, preset, packageManager, - dir: opts.dir, - build: opts.build, + dir: settings.dir, + build: settings.build, + workingDirectory: settings.prefix || undefined, + cacheDependencyPath: settings.cacheDependencyPath, }); const target = join(opts.root, SITES_WORKFLOW_PATH); @@ -121,7 +198,7 @@ export async function scaffoldSitesWorkflow(opts: { path: SITES_WORKFLOW_PATH, preset, packageManager, - dir: opts.dir ?? preset.dir, + dir: workflowPath(settings.prefix, settings.dir ?? preset.dir), }; } @@ -129,11 +206,18 @@ export async function scaffoldSitesWorkflow(opts: { export async function printWorkflowInstructions( site: string, root: string, - config?: { dir?: string; build?: string }, + config?: { root?: string; dir?: string; build?: string }, ): Promise { - const preset = (await detectFramework(root)) ?? findPreset("static"); + const settings = await workflowSettings({ + root, + projectRoot: config?.root, + dir: config?.dir, + build: config?.build, + }); + const preset = + (await detectFramework(settings.projectRoot)) ?? findPreset("static"); if (!preset) return; - const packageManager = await detectPackageManager(root); + const packageManager = await detectPackageManager(settings.projectRoot); logger.log(); logger.log(`To deploy from GitHub later, add ${SITES_WORKFLOW_PATH}:`); logger.log(); @@ -142,8 +226,10 @@ export async function printWorkflowInstructions( site, preset, packageManager, - dir: config?.dir, - build: config?.build, + dir: settings.dir, + build: settings.build, + workingDirectory: settings.prefix || undefined, + cacheDependencyPath: settings.cacheDependencyPath, }), ); printSecretHint(); diff --git a/packages/cli/src/commands/sites/ci/workflow.test.ts b/packages/cli/src/commands/sites/ci/workflow.test.ts index e2867a2..31fefdf 100644 --- a/packages/cli/src/commands/sites/ci/workflow.test.ts +++ b/packages/cli/src/commands/sites/ci/workflow.test.ts @@ -108,6 +108,45 @@ test("sites.dir and sites.build from bunny.jsonc win over the preset", () => { expect(yml).toContain('directory: "build"'); }); +// bunny.jsonc can live below the repo root; the workflow runs from the checkout root, so run steps get a working directory and the action's directory carries the prefix. +test("a nested project builds from its own directory and deploys the prefixed path", () => { + const yml = renderSitesWorkflow({ + site: "s", + preset: preset("astro"), + packageManager: "npm", + dir: "dist", + build: "npm run build:site", + workingDirectory: "packages/site", + cacheDependencyPath: "packages/site/package-lock.json", + }); + expect(yml).toContain(' working-directory: "packages/site"'); + expect(yml).toContain( + ' cache-dependency-path: "packages/site/package-lock.json"', + ); + expect(yml).toContain('directory: "packages/site/dist"'); +}); + +test("a static site at the project root deploys the project directory itself", () => { + const yml = renderSitesWorkflow({ + site: "s", + preset: preset("static"), + packageManager: "npm", + workingDirectory: "sites/marketing", + }); + expect(yml).toContain('directory: "sites/marketing"'); +}); + +test("a root-level project gets no working directory or cache path", () => { + const yml = renderSitesWorkflow({ + site: "s", + preset: preset("astro"), + packageManager: "npm", + }); + expect(yml).not.toContain("working-directory"); + expect(yml).not.toContain("cache-dependency-path"); + expect(yml).toContain('directory: "dist"'); +}); + test("a configured build runs even for a static preset", () => { const yml = renderSitesWorkflow({ site: "s", diff --git a/packages/cli/src/commands/sites/ci/workflow.ts b/packages/cli/src/commands/sites/ci/workflow.ts index a764680..cdc6152 100644 --- a/packages/cli/src/commands/sites/ci/workflow.ts +++ b/packages/cli/src/commands/sites/ci/workflow.ts @@ -11,35 +11,52 @@ export const SITES_WORKFLOW_PATH = ".github/workflows/bunny-sites.yml"; 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", - ], -}; +// 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[] { + const cachePath = cacheDependencyPath + ? [ + ` cache-dependency-path: ${JSON.stringify(cacheDependencyPath)}`, + ] + : []; + switch (pm) { + case "bun": + return [ + " - uses: oven-sh/setup-bun@v2", + " - run: bun install --frozen-lockfile", + ]; + case "pnpm": + return [ + " - uses: pnpm/action-setup@v4", + " - uses: actions/setup-node@v4", + " with:", + ' node-version: "lts/*"', + " cache: pnpm", + ...cachePath, + " - run: pnpm install --frozen-lockfile", + ]; + case "yarn": + return [ + " - uses: actions/setup-node@v4", + " with:", + ' node-version: "lts/*"', + " cache: yarn", + ...cachePath, + " - run: yarn install --frozen-lockfile", + ]; + case "npm": + return [ + " - uses: actions/setup-node@v4", + " with:", + ' node-version: "lts/*"', + " cache: npm", + ...cachePath, + " - run: npm ci", + ]; + } +} // A `sites.build` from bunny.jsonc is user text, so quote it when a bare YAML scalar wouldn't survive it; preset commands are plain and stay unquoted. const YAML_UNSAFE_SCALAR = /^[-?:,[\]{}#&*!|>'"%@`]|\n|:\s|\s#/; @@ -53,19 +70,21 @@ function jsSteps( preset: FrameworkPreset, pm: PackageManager, build: string | undefined, + cacheDependencyPath: string | undefined, ): string[] { const command = build ?? presetBuildCommand(preset, pm) ?? `${pm} run build`; - return [...JS_SETUP[pm], runStep(command)]; + return [...jsSetup(pm, cacheDependencyPath), runStep(command)]; } function buildSteps( preset: FrameworkPreset, packageManager: PackageManager, build: string | undefined, + cacheDependencyPath: string | undefined, ): string[] { switch (preset.toolchain) { case "js": - return jsSteps(preset, packageManager, build); + return jsSteps(preset, packageManager, build, cacheDependencyPath); case "ruby": return [ " - uses: ruby/setup-ruby@v1", @@ -114,15 +133,31 @@ function buildSteps( } } -// Render the GitHub Actions workflow: previews on PRs, production on pushes to main, via the BunnyWay/actions deploy-site action. `dir`/`build` carry `sites.dir`/`sites.build` from bunny.jsonc so CI deploys what `sites deploy` does. +/** Join a workflow-root-relative prefix onto a project-relative path, POSIX-style (these are YAML/GitHub paths, never local ones). */ +export function workflowPath(prefix: string | undefined, path: string): string { + if (!prefix) return path; + return path === "." ? prefix : `${prefix.replace(/\/$/, "")}/${path}`; +} + +// Render the GitHub Actions workflow: previews on PRs, production on pushes to main, via the BunnyWay/actions deploy-site action. `dir`/`build` carry `sites.dir`/`sites.build` from bunny.jsonc, and `workingDirectory` is where that config lives relative to the workflow root, so CI builds and deploys exactly what `sites deploy` does. export function renderSitesWorkflow(opts: { site: string; preset: FrameworkPreset; packageManager: PackageManager; dir?: string; build?: string; + workingDirectory?: string; + cacheDependencyPath?: string; }): string { - const { site, preset, packageManager } = opts; + 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. + const defaults = workingDirectory + ? [ + " defaults:", + " run:", + ` working-directory: ${JSON.stringify(workingDirectory)}`, + ] + : []; const lines = [ "name: Deploy site", "on:", @@ -138,6 +173,7 @@ export function renderSitesWorkflow(opts: { "jobs:", " deploy:", " runs-on: ubuntu-latest", + ...defaults, " # Fork PRs have no access to secrets; skip instead of failing.", " if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository", " permissions:", @@ -146,13 +182,13 @@ export function renderSitesWorkflow(opts: { " steps:", " - uses: actions/checkout@v4", "", - ...buildSteps(preset, packageManager, opts.build), + ...buildSteps(preset, packageManager, opts.build, opts.cacheDependencyPath), "", ` - uses: ${DEPLOY_SITE_ACTION}`, " with:", // Quote the interpolated values so they're always inert YAML scalars. ` site: ${JSON.stringify(site)}`, - ` directory: ${JSON.stringify(opts.dir ?? preset.dir)}`, + ` directory: ${JSON.stringify(workflowPath(workingDirectory, opts.dir ?? preset.dir))}`, " production: ${{ github.event_name == 'push' }}", " api_key: ${{ secrets.BUNNY_API_KEY }}", ]; diff --git a/packages/cli/src/commands/sites/create.ts b/packages/cli/src/commands/sites/create.ts index 0a56397..2091ade 100644 --- a/packages/cli/src/commands/sites/create.ts +++ b/packages/cli/src/commands/sites/create.ts @@ -107,7 +107,9 @@ export const sitesCreateCommand = defineCommand({ const interactive = isInteractive(output); // `sites.name` is how every other sites command resolves the site, so create takes it as the name too. - const siteConfig = loadSiteConfig()?.config; + const loadedConfig = loadSiteConfig(); + const siteConfig = loadedConfig?.config; + const configRoot = loadedConfig?.root; const name = await promptSiteName( args.name ?? siteConfig?.name, interactive, @@ -229,6 +231,7 @@ export const sitesCreateCommand = defineCommand({ const scaffold = await scaffoldSitesWorkflow({ site: name, root, + projectRoot: configRoot, interactive: true, dir: siteConfig?.dir, build: siteConfig?.build, @@ -244,7 +247,10 @@ export const sitesCreateCommand = defineCommand({ }); } } else { - await printWorkflowInstructions(name, root, siteConfig); + await printWorkflowInstructions(name, root, { + root: configRoot, + ...siteConfig, + }); } } } diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index b5caac9..53f4149 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -125,7 +125,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 that deploys previews on pull requests and publishes to production on merges to `main`, using the `BunnyWay/actions/deploy-site` action with the site name baked in. `sites.dir` and `sites.build` from `bunny.jsonc` override the preset's deploy directory and build command, so CI builds and deploys exactly what a local `sites deploy` does. Framework detection reads `package.json` dependencies, `Gemfile`, or Hugo config; the lockfile picks the package manager for the install steps. Fork PRs are skipped (no secrets there). After writing, the CLI offers to run `gh secret set BUNNY_API_KEY` (or prints the manual steps). `sites create` offers the same scaffold on GitHub repos; declining prints the workflow instead. +Writes a workflow that deploys previews on pull requests and publishes to production on merges to `main`, using the `BunnyWay/actions/deploy-site` action with the site name baked in. `sites.dir` and `sites.build` from `bunny.jsonc` override the preset's deploy directory and build command, so CI builds and deploys exactly what a local `sites deploy` does. The workflow is written at the git root; when `bunny.jsonc` lives below it (a monorepo package), the job gets `defaults.run.working-directory` and the deploy directory is prefixed, so those paths still mean what they do locally. Framework detection reads `package.json` dependencies, `Gemfile`, or Hugo config; the lockfile picks the package manager for the install steps. Fork PRs are skipped (no secrets there). After writing, the CLI offers to run `gh secret set BUNNY_API_KEY` (or prints the manual steps). `sites create` offers the same scaffold on GitHub repos; declining prints the workflow instead. --- From 3ae7c884f1966408f9a9a4f49637939a3d374ef7 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 28 Jul 2026 11:39:47 +0100 Subject: [PATCH 09/11] fix(sites): always quote a configured build in the generated workflow The unsafe-token regex let YAML-typed commands through: `run: true` parses as a boolean, `run: null` as null and `run: 1.5` as a number, and Actions rejects a `run` that isn't a string. Drop the pattern list and emit `sites.build` as a quoted scalar unconditionally; preset commands come from our own table and stay readable. --- AGENTS.md | 2 +- .../src/commands/sites/ci/workflow.test.ts | 24 ++++++++++++----- .../cli/src/commands/sites/ci/workflow.ts | 26 +++++++++---------- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ab4e675..2acf356 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -404,7 +404,7 @@ bunny-cli/ │ │ │ ├── unlink.ts # Remove .bunny/site.json │ │ │ ├── upgrade-router.ts # Republish the site's router script with the CLI's current source (pushes router improvements to an existing site) │ │ │ ├── delete.ts # Delete a site (typed-name confirm; --keep-storage; drops .bunny/site.json if it pointed here) -│ │ │ ├── ci/ # frameworks.ts (preset table of ~30 frameworks across js/ruby/hugo/python/zola/dotnet toolchains + detection: package.json deps/Gemfile/python+zola config files + lockfile pm), workflow.ts (renderSitesWorkflow -> .github/workflows/bunny-sites.yml using BunnyWay/actions/deploy-site; optional dir/build override the preset, workingDirectory/cacheDependencyPath place a project that sits below the workflow root (`defaults.run.working-directory` covers every run step, `uses` inputs take the prefix via workflowPath instead), and a build command that would break a bare YAML scalar is quoted), scaffold.ts (git helpers, projectPrefix (bunny.jsonc directory relative to the git root, realpath-resolved; undefined when it escapes the root, which drops its paths with a warning), framework/package-manager detection runs in that project directory, scaffoldSitesWorkflow -> ScaffoldResult.dir is the effective root-relative deploy dir, printWorkflowInstructions, offerGitHubSecret via gh), init.ts (bunny sites ci init) + tests +│ │ │ ├── ci/ # frameworks.ts (preset table of ~30 frameworks across js/ruby/hugo/python/zola/dotnet toolchains + detection: package.json deps/Gemfile/python+zola config files + lockfile pm), workflow.ts (renderSitesWorkflow -> .github/workflows/bunny-sites.yml using BunnyWay/actions/deploy-site; optional dir/build override the preset, workingDirectory/cacheDependencyPath place a project that sits below the workflow root (`defaults.run.working-directory` covers every run step, `uses` inputs take the prefix via workflowPath instead), and a configured build command is always a quoted scalar so YAML can't retype it), scaffold.ts (git helpers, projectPrefix (bunny.jsonc directory relative to the git root, realpath-resolved; undefined when it escapes the root, which drops its paths with a warning), framework/package-manager detection runs in that project directory, scaffoldSitesWorkflow -> ScaffoldResult.dir is the effective root-relative deploy dir, printWorkflowInstructions, offerGitHubSecret via gh), init.ts (bunny sites ci init) + tests │ │ │ ├── deployments/ # list (● Live/○ Previous), publish [id]|--previous (alias promote; confirm + promote + current/previous swap), prune --keep N (resolveKeepCount validates the count first; pruneVictims never drops current/previous) + prune.test.ts │ │ │ └── domains/index.ts # Mounts core/hostnames createHostnamesCommands as "sites domains" with onAdded/onRemoved hooks: apex add also attaches *.preview. (attachPreviewWildcard, best-effort SSL) + records state.domain; remove takes the wildcard down too. setupSiteDomain composes setupHostname + wildcard for create --domain │ │ ├── registries/ diff --git a/packages/cli/src/commands/sites/ci/workflow.test.ts b/packages/cli/src/commands/sites/ci/workflow.test.ts index 31fefdf..3a29076 100644 --- a/packages/cli/src/commands/sites/ci/workflow.test.ts +++ b/packages/cli/src/commands/sites/ci/workflow.test.ts @@ -103,7 +103,7 @@ test("sites.dir and sites.build from bunny.jsonc win over the preset", () => { build: "make site", }); expect(yml).toContain("run: npm ci"); - expect(yml).toContain("run: make site"); + expect(yml).toContain('run: "make site"'); expect(yml).not.toContain("run: npm run build"); expect(yml).toContain('directory: "build"'); }); @@ -120,6 +120,7 @@ test("a nested project builds from its own directory and deploys the prefixed pa cacheDependencyPath: "packages/site/package-lock.json", }); expect(yml).toContain(' working-directory: "packages/site"'); + expect(yml).toContain('run: "npm run build:site"'); expect(yml).toContain( ' cache-dependency-path: "packages/site/package-lock.json"', ); @@ -154,19 +155,30 @@ test("a configured build runs even for a static preset", () => { packageManager: "npm", build: "./build.sh", }); - expect(yml).toContain("run: ./build.sh"); + expect(yml).toContain('run: "./build.sh"'); expect(yml).not.toContain("# No build step"); }); -test("a configured build that would break a bare YAML scalar is quoted", () => { - const yml = renderSitesWorkflow({ +// A configured build is always a quoted scalar: bare `true`/`null`/`1.5` would parse as a boolean/null/number, which Actions rejects, and a newline would open a new YAML line. +test("a configured build is always emitted as a quoted scalar", () => { + const injected = renderSitesWorkflow({ site: "s", preset: preset("static"), packageManager: "npm", build: "echo hi\n run: rm -rf /", }); - expect(yml).toContain('run: "echo hi\\n run: rm -rf /"'); - expect(yml).not.toContain("\n run: rm -rf /\n"); + expect(injected).toContain('run: "echo hi\\n run: rm -rf /"'); + expect(injected).not.toContain("\n run: rm -rf /\n"); + + for (const build of ["true", "false", "null", "1.5"]) { + const yml = renderSitesWorkflow({ + site: "s", + preset: preset("static"), + packageManager: "npm", + build, + }); + expect(yml).toContain(`run: "${build}"`); + } }); test("interpolated site name is a quoted, inert YAML scalar", () => { diff --git a/packages/cli/src/commands/sites/ci/workflow.ts b/packages/cli/src/commands/sites/ci/workflow.ts index cdc6152..d15a1ed 100644 --- a/packages/cli/src/commands/sites/ci/workflow.ts +++ b/packages/cli/src/commands/sites/ci/workflow.ts @@ -58,12 +58,10 @@ function jsSetup( } } -// A `sites.build` from bunny.jsonc is user text, so quote it when a bare YAML scalar wouldn't survive it; preset commands are plain and stay unquoted. -const YAML_UNSAFE_SCALAR = /^[-?:,[\]{}#&*!|>'"%@`]|\n|:\s|\s#/; - -function runStep(command: string | undefined): string { - const value = command ?? ""; - return ` - run: ${YAML_UNSAFE_SCALAR.test(value) ? JSON.stringify(value) : value}`; +// A `sites.build` from bunny.jsonc is user text, so it's always a quoted scalar: bare `true`/`null`/`1.5` would parse as a boolean/null/number and Actions rejects a `run` that isn't a string. Preset commands come from our own table and stay readable. +function runStep(configured: string | undefined, preset?: string): string { + const value = configured !== undefined ? JSON.stringify(configured) : preset; + return ` - run: ${value ?? ""}`; } function jsSteps( @@ -72,8 +70,10 @@ function jsSteps( build: string | undefined, cacheDependencyPath: string | undefined, ): string[] { - const command = build ?? presetBuildCommand(preset, pm) ?? `${pm} run build`; - return [...jsSetup(pm, cacheDependencyPath), runStep(command)]; + return [ + ...jsSetup(pm, cacheDependencyPath), + runStep(build, presetBuildCommand(preset, pm) ?? `${pm} run build`), + ]; } function buildSteps( @@ -91,7 +91,7 @@ function buildSteps( " with:", ' ruby-version: "3.3"', " bundler-cache: true", - runStep(build ?? preset.build), + runStep(build, preset.build), " env:", " JEKYLL_ENV: production", ]; @@ -101,7 +101,7 @@ function buildSteps( " with:", ' hugo-version: "latest"', " extended: true", - runStep(build ?? preset.build), + runStep(build, preset.build), ]; case "python": return [ @@ -109,21 +109,21 @@ function buildSteps( " with:", ' python-version: "3.x"', " - run: pip install -r requirements.txt", - runStep(build ?? preset.build), + runStep(build, preset.build), ]; case "zola": return [ " - uses: taiki-e/install-action@v2", " with:", " tool: zola", - runStep(build ?? preset.build), + runStep(build, preset.build), ]; case "dotnet": return [ " - uses: actions/setup-dotnet@v4", " with:", ' dotnet-version: "8.0.x"', - runStep(build ?? preset.build), + runStep(build, preset.build), ]; case "none": // A static site has no toolchain to set up, but a configured build still runs. From 71291c0cc3e90c22a0d04f16b1332e3ff02597d2 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 28 Jul 2026 11:41:07 +0100 Subject: [PATCH 10/11] fix(sites): install dependencies for a configured build on the static preset An unrecognized bundler falls back to the static preset, so a configured `sites.build` of `npm run build` emitted a lone run step: the runner had no dependencies and the workflow failed on a build that works locally. When the project has a package.json, that branch now gets the detected package manager's setup and install steps first. --- AGENTS.md | 2 +- .../cli/src/commands/sites/ci/scaffold.ts | 12 ++++++++ .../src/commands/sites/ci/workflow.test.ts | 29 +++++++++++++++++++ .../cli/src/commands/sites/ci/workflow.ts | 21 ++++++++++---- 4 files changed, 57 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2acf356..730bbe8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -404,7 +404,7 @@ bunny-cli/ │ │ │ ├── unlink.ts # Remove .bunny/site.json │ │ │ ├── upgrade-router.ts # Republish the site's router script with the CLI's current source (pushes router improvements to an existing site) │ │ │ ├── delete.ts # Delete a site (typed-name confirm; --keep-storage; drops .bunny/site.json if it pointed here) -│ │ │ ├── ci/ # frameworks.ts (preset table of ~30 frameworks across js/ruby/hugo/python/zola/dotnet toolchains + detection: package.json deps/Gemfile/python+zola config files + lockfile pm), workflow.ts (renderSitesWorkflow -> .github/workflows/bunny-sites.yml using BunnyWay/actions/deploy-site; optional dir/build override the preset, workingDirectory/cacheDependencyPath place a project that sits below the workflow root (`defaults.run.working-directory` covers every run step, `uses` inputs take the prefix via workflowPath instead), and a configured build command is always a quoted scalar so YAML can't retype it), scaffold.ts (git helpers, projectPrefix (bunny.jsonc directory relative to the git root, realpath-resolved; undefined when it escapes the root, which drops its paths with a warning), framework/package-manager detection runs in that project directory, scaffoldSitesWorkflow -> ScaffoldResult.dir is the effective root-relative deploy dir, printWorkflowInstructions, offerGitHubSecret via gh), init.ts (bunny sites ci init) + tests +│ │ │ ├── ci/ # frameworks.ts (preset table of ~30 frameworks across js/ruby/hugo/python/zola/dotnet toolchains + detection: package.json deps/Gemfile/python+zola config files + lockfile pm), workflow.ts (renderSitesWorkflow -> .github/workflows/bunny-sites.yml using BunnyWay/actions/deploy-site; optional dir/build override the preset, workingDirectory/cacheDependencyPath place a project that sits below the workflow root (`defaults.run.working-directory` covers every run step, `uses` inputs take the prefix via workflowPath instead), installDeps adds the JS setup/install steps to a configured build the static preset wouldn't have installed for, and a configured build command is always a quoted scalar so YAML can't retype it), scaffold.ts (git helpers, projectPrefix (bunny.jsonc directory relative to the git root, realpath-resolved; undefined when it escapes the root, which drops its paths with a warning), framework/package-manager detection runs in that project directory, scaffoldSitesWorkflow -> ScaffoldResult.dir is the effective root-relative deploy dir, printWorkflowInstructions, offerGitHubSecret via gh), init.ts (bunny sites ci init) + tests │ │ │ ├── deployments/ # list (● Live/○ Previous), publish [id]|--previous (alias promote; confirm + promote + current/previous swap), prune --keep N (resolveKeepCount validates the count first; pruneVictims never drops current/previous) + prune.test.ts │ │ │ └── domains/index.ts # Mounts core/hostnames createHostnamesCommands as "sites domains" with onAdded/onRemoved hooks: apex add also attaches *.preview. (attachPreviewWildcard, best-effort SSL) + records state.domain; remove takes the wildcard down too. setupSiteDomain composes setupHostname + wildcard for create --domain │ │ ├── registries/ diff --git a/packages/cli/src/commands/sites/ci/scaffold.ts b/packages/cli/src/commands/sites/ci/scaffold.ts index f4b2e1b..683ed0e 100644 --- a/packages/cli/src/commands/sites/ci/scaffold.ts +++ b/packages/cli/src/commands/sites/ci/scaffold.ts @@ -12,6 +12,7 @@ import { type FrameworkPreset, findPreset, type PackageManager, + readPackageJson, } from "./frameworks.ts"; import { renderSitesWorkflow, @@ -107,6 +108,15 @@ async function workflowSettings(opts: { }; } +// A configured build on the static preset (an unrecognized bundler, say) gets the JS setup/install steps when the project has a package.json; the toolchain presets already install for themselves. +async function needsJsInstall( + preset: FrameworkPreset, + settings: WorkflowSettings, +): Promise { + if (preset.toolchain !== "none" || settings.build === undefined) return false; + return (await readPackageJson(settings.projectRoot)) !== null; +} + /** Resolve the framework preset: explicit id, detection, prompt, static fallback. */ async function resolvePreset( root: string, @@ -175,6 +185,7 @@ export async function scaffoldSitesWorkflow(opts: { build: settings.build, workingDirectory: settings.prefix || undefined, cacheDependencyPath: settings.cacheDependencyPath, + installDeps: await needsJsInstall(preset, settings), }); const target = join(opts.root, SITES_WORKFLOW_PATH); @@ -230,6 +241,7 @@ export async function printWorkflowInstructions( build: settings.build, workingDirectory: settings.prefix || undefined, cacheDependencyPath: settings.cacheDependencyPath, + installDeps: await needsJsInstall(preset, settings), }), ); printSecretHint(); diff --git a/packages/cli/src/commands/sites/ci/workflow.test.ts b/packages/cli/src/commands/sites/ci/workflow.test.ts index 3a29076..51ed287 100644 --- a/packages/cli/src/commands/sites/ci/workflow.test.ts +++ b/packages/cli/src/commands/sites/ci/workflow.test.ts @@ -157,6 +157,35 @@ test("a configured build runs even for a static preset", () => { }); expect(yml).toContain('run: "./build.sh"'); expect(yml).not.toContain("# No build step"); + // No package.json, so nothing to install. + expect(yml).not.toContain("setup-node"); +}); + +// An unrecognized bundler lands on the static preset, but a configured `npm run build` still needs dependencies on the runner. +test("a configured build gets the JS install steps when installDeps is set", () => { + const yml = renderSitesWorkflow({ + site: "s", + preset: preset("static"), + packageManager: "pnpm", + build: "pnpm run build", + dir: "out", + installDeps: true, + }); + expect(yml).toContain("uses: pnpm/action-setup@v4"); + expect(yml).toContain("run: pnpm install --frozen-lockfile"); + expect(yml).toContain('run: "pnpm run build"'); + expect(yml).toContain('directory: "out"'); +}); + +test("a static site with no configured build never installs", () => { + const yml = renderSitesWorkflow({ + site: "s", + preset: preset("static"), + packageManager: "npm", + installDeps: true, + }); + expect(yml).toContain("# No build step: static files deploy as-is."); + expect(yml).not.toContain("setup-node"); }); // A configured build is always a quoted scalar: bare `true`/`null`/`1.5` would parse as a boolean/null/number, which Actions rejects, and a newline would open a new YAML line. diff --git a/packages/cli/src/commands/sites/ci/workflow.ts b/packages/cli/src/commands/sites/ci/workflow.ts index d15a1ed..c097870 100644 --- a/packages/cli/src/commands/sites/ci/workflow.ts +++ b/packages/cli/src/commands/sites/ci/workflow.ts @@ -81,6 +81,7 @@ function buildSteps( packageManager: PackageManager, build: string | undefined, cacheDependencyPath: string | undefined, + installDeps: boolean | undefined, ): string[] { switch (preset.toolchain) { case "js": @@ -126,10 +127,11 @@ function buildSteps( runStep(build, preset.build), ]; case "none": - // A static site has no toolchain to set up, but a configured build still runs. - return build - ? [runStep(build)] - : [" # No build step: static files deploy as-is."]; + 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)] + : [runStep(build)]; } } @@ -139,7 +141,7 @@ export function workflowPath(prefix: string | undefined, path: string): string { return path === "." ? prefix : `${prefix.replace(/\/$/, "")}/${path}`; } -// Render the GitHub Actions workflow: previews on PRs, production on pushes to main, via the BunnyWay/actions deploy-site action. `dir`/`build` carry `sites.dir`/`sites.build` from bunny.jsonc, and `workingDirectory` is where that config lives relative to the workflow root, so CI builds and deploys exactly what `sites deploy` does. +// Render the GitHub Actions workflow: previews on PRs, production on pushes to main, via the BunnyWay/actions deploy-site action. `dir`/`build` carry `sites.dir`/`sites.build` from bunny.jsonc, `workingDirectory` is where that config lives relative to the workflow root, and `installDeps` adds the JS setup/install steps to a configured build the preset wouldn't have installed for, so CI builds and deploys exactly what `sites deploy` does. export function renderSitesWorkflow(opts: { site: string; preset: FrameworkPreset; @@ -148,6 +150,7 @@ export function renderSitesWorkflow(opts: { build?: string; workingDirectory?: string; cacheDependencyPath?: string; + installDeps?: boolean; }): 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. @@ -182,7 +185,13 @@ export function renderSitesWorkflow(opts: { " steps:", " - uses: actions/checkout@v4", "", - ...buildSteps(preset, packageManager, opts.build, opts.cacheDependencyPath), + ...buildSteps( + preset, + packageManager, + opts.build, + opts.cacheDependencyPath, + opts.installDeps, + ), "", ` - uses: ${DEPLOY_SITE_ACTION}`, " with:", From 222ce65fb3f89386748820053ec3add2ee66afad Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 28 Jul 2026 11:42:58 +0100 Subject: [PATCH 11/11] fix(sites): apply an explicit --link during site resolution offerLink() runs at the end of a handler, but every command returns from its `--output json` branch before reaching it, so `--link --output json` reported success and left `.bunny/site.json` unwritten. Link during resolution instead, where the flag is unambiguous, and keep offerLink for the picker's prompt. The confirmation line is suppressed under json so the payload stays clean. --- .changeset/sites-link-flag-scope.md | 2 +- AGENTS.md | 2 +- .../cli/src/commands/sites/interactive.ts | 33 +++++++++++-------- skills/bunny-cli/references/sites.md | 2 +- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/.changeset/sites-link-flag-scope.md b/.changeset/sites-link-flag-scope.md index ff9d5a3..889af79 100644 --- a/.changeset/sites-link-flag-scope.md +++ b/.changeset/sites-link-flag-scope.md @@ -2,4 +2,4 @@ "@bunny.net/cli": patch --- -fix(sites): `--link` is now only accepted by the commands that can act on it (`deploy`, `show`, `deployments list/publish`, `upgrade-router`, `ci init`), where it also links a site resolved from `--site` or `bunny.jsonc`; `open`, `ssl`, `delete` and `deployments prune` no longer advertise a flag they ignored +fix(sites): `--link` is now only accepted by the commands that can act on it (`deploy`, `show`, `deployments list/publish`, `upgrade-router`, `ci init`), where an explicit `--link` also links a site resolved from `--site` or `bunny.jsonc`, including under `--output json`; `open`, `ssl`, `delete` and `deployments prune` no longer advertise a flag they ignored diff --git a/AGENTS.md b/AGENTS.md index 730bbe8..2487c3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -384,7 +384,7 @@ bunny-cli/ │ │ │ ├── constants.test.ts # parseRemoteState round-trip/rejection + helper tests │ │ │ ├── api.ts # siteFiles IO seam (connect/download/upload/remove; swap in tests instead of mock.module), remote state read/write (sha256 etag optimistic lock: concurrent deploy records merge on mismatch, ours win per id; 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); `force` errors instead of opening the picker (destructive commands pass their --force, which also skips the confirmation, so a picked site would be acted on unprompted; deploy's --force means "redeploy unchanged content" and is not passed); optional offerCreate (deploy only) adds a new-vs-existing prompt, and creates straight away when the account has no sites; siteOptionBuilder (--site) + sitePositionalBuilder ([site]) + siteLinkOption (--link, mounted only by the commands that call offerLink); an explicit --link links whatever site was resolved (ref or bunny.jsonc included), while the picker keeps prompting unless --link/--no-link decided it +│ │ │ ├── interactive.ts # selectSite: explicit ref (storage zone ID/name, falling back to a state.name match since zone names carry a suffix) → .bunny/site.json → bunny.jsonc sites.name → picker (offerLink like scripts); `force` errors instead of opening the picker (destructive commands pass their --force, which also skips the confirmation, so a picked site would be acted on unprompted; deploy's --force means "redeploy unchanged content" and is not passed); optional offerCreate (deploy only) adds a new-vs-existing prompt, and creates straight away when the account has no sites; siteOptionBuilder (--site) + sitePositionalBuilder ([site]) + siteLinkOption (--link, mounted only by the commands that call offerLink); an explicit --link links whatever site was resolved (ref or bunny.jsonc included) during resolution, not via offerLink, since every command returns from its `--output json` branch before offerLink runs (and the confirmation line is suppressed under json); the picker keeps prompting unless --link/--no-link already decided it │ │ │ ├── provision.ts # promptSiteName (normalize/validate, directory-name suggestion) + createSiteWithProgress (createSite under a step-tracking spinner; shared with create.ts) + createLinkedSite (create + manifest link → SiteContext, skipping create's domain/CI prompts) for the deploy picker's new-site branch │ │ │ ├── config.ts # loadSiteConfig: reads bunny.jsonc via core/bunny-config.ts and validates ONLY the `sites` block (SiteConfigSchema from @bunny.net/config), so sites-only configs work without an `app` block or `version` │ │ │ ├── router/source.ts # routerSource: the middleware Edge Script (one script per site; no version tracking; upgrade-router just republishes the latest). apex → CURRENT_DEPLOY, dpl-{id}.preview.{domain} → that deploy, /deploys/{id}/ passthrough (path preview) flagged with x-bunny-preview header, /_bunny/* → 403 (client-sent x-bunny-preview 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 diff --git a/packages/cli/src/commands/sites/interactive.ts b/packages/cli/src/commands/sites/interactive.ts index 07db7ee..a4d4845 100644 --- a/packages/cli/src/commands/sites/interactive.ts +++ b/packages/cli/src/commands/sites/interactive.ts @@ -96,16 +96,21 @@ async function contextFromRef( export interface SelectedSite { site: SiteContext; - // Link the directory to the site: prompted when it came from the picker, silent when `--link` asked for it, a no-op otherwise, so commands can always call it. + // Offer to link the directory to the site: prompts for a picked site, a no-op otherwise (an explicit `--link` is already applied by then), so commands can always call it. offerLink: () => Promise; } -function linkDirectory(site: SiteContext): void { +function linkDirectory(site: SiteContext, output: OutputFormat): void { saveManifest(SITES_MANIFEST, { id: site.state.storageZoneId, name: site.state.name, }); - logger.success(`Linked to ${site.state.name} (${site.state.storageZoneId}).`); + // A JSON consumer reads the result from the payload, not from a log line. + if (output !== "json") { + logger.success( + `Linked to ${site.state.name} (${site.state.storageZoneId}).`, + ); + } } // 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. Destructive commands pass their `--force` to opt out of the picker. @@ -118,9 +123,10 @@ export async function selectSite( }, ): Promise { const noLink = async () => {}; - // A site resolved from a ref or from bunny.jsonc isn't prompted about, but an explicit `--link` still asks for it to be linked. - const linkIfRequested = (site: SiteContext) => async () => { - if (args.link === true) linkDirectory(site); + // An explicit `--link` is applied during resolution rather than deferred: commands return from their `--output json` branch before offerLink would run. `--no-link` never links. + const linked = (site: SiteContext): SelectedSite => { + if (args.link === true) linkDirectory(site, args.output); + return { site, offerLink: noLink }; }; if (args.site) { @@ -128,7 +134,7 @@ export async function selectSite( const site = await withSpinner("Resolving site...", () => contextFromRef(client, ref), ); - return { site, offerLink: linkIfRequested(site) }; + return linked(site); } const manifest = loadManifest(SITES_MANIFEST); @@ -152,7 +158,7 @@ export async function selectSite( `Resolving site "${configured}" from bunny.jsonc...`, () => contextFromRef(client, configured), ); - return { site, offerLink: linkIfRequested(site) }; + return linked(site); } // `--force` skips the confirmation too, so picking a site from a list would act on it unprompted. @@ -210,14 +216,15 @@ export async function selectSite( throw new UserError(`Site "${summary.state.name}" could not be loaded.`); } + // A picked site is prompted about at the end of the command, unless --link/--no-link already settled it. + if (args.link !== undefined) return linked(context); + return { site: context, offerLink: async () => { - const shouldLink = - args.link !== undefined - ? args.link - : await confirm(`Link this directory to ${context.state.name}?`); - if (shouldLink) linkDirectory(context); + if (await confirm(`Link this directory to ${context.state.name}?`)) { + linkDirectory(context, args.output); + } }, }; } diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index 53f4149..8dd38f3 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -9,7 +9,7 @@ Most commands accept an optional site (a trailing `[site]` positional, or the `- 3. `sites.name` in `bunny.jsonc` 4. Interactive prompt (suppressed in `--output json` mode, and on destructive commands run with `--force`; pass a site or link the directory in CI) -Commands that can link the directory (`deploy`, `show`, `deployments list/publish`, `upgrade-router`, `ci init`) take `--link`/`--no-link`: the picker prompts unless the flag decided it, and an explicit `--link` also links a site resolved from a ref or from `bunny.jsonc`. The other site commands never write the manifest and don't take the flag. +Commands that can link the directory (`deploy`, `show`, `deployments list/publish`, `upgrade-router`, `ci init`) take `--link`/`--no-link`: the picker prompts unless the flag decided it, and an explicit `--link` also links a site resolved from a ref or from `bunny.jsonc`, including under `--output json`. The other site commands never write the manifest and don't take the flag. ## Typical workflows